hinagata-core-0.1.0.0: src/Hinagata/Fixture/Manifest.hs
module Hinagata.Fixture.Manifest
( ManifestError (..),
FixtureManifest,
decodeManifest,
manifestDefinition,
manifestDescription,
implicitDefinition,
)
where
import Data.Aeson (Value (..), (.:), (.:?))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (Parser, parseEither, withObject)
import Data.ByteString (ByteString)
import Data.Text qualified as Text
import Data.Yaml qualified as Yaml
import Hinagata.Fixture.Types
import Hinagata.Prelude
import Hinagata.Types
import System.FilePath (isAbsolute)
data ManifestError
= InvalidYaml !Text
| InvalidManifest !Text
deriving stock (Eq, Show)
data FixtureManifest = FixtureManifest
{ name :: !FixtureName,
description :: !(Maybe Text),
includes :: ![FixtureName],
steps :: ![FixtureStep]
}
deriving stock (Eq, Show, Generic)
-- | Decode an explicit fixture.yaml. Its name must match the directory name.
-- The caller still validates filesystem paths and captures bytes before use.
decodeManifest :: FixtureName -> ByteString -> Either ManifestError FixtureManifest
decodeManifest expected bytes = do
value <- either (Left . InvalidYaml . Text.pack . show) Right (Yaml.decodeEither' bytes)
parsed <- either (Left . InvalidManifest . Text.pack) Right (parseEither parseManifest value)
let FixtureManifest {name = actualName} = parsed
if actualName == expected
then Right parsed
else Left (InvalidManifest "manifest name does not match fixture directory")
manifestDefinition :: FixtureManifest -> FixtureDefinition
manifestDefinition FixtureManifest {name, includes, steps} = FixtureDefinition {name, includes, steps}
manifestDescription :: FixtureManifest -> Maybe Text
manifestDescription FixtureManifest {description} = description
-- | A directory without fixture.yaml has exactly one conventional SQL step.
implicitDefinition :: FixtureName -> FixtureDefinition
implicitDefinition name = FixtureDefinition {name, includes = [], steps = [SqlFile "fixture.sql"]}
parseManifest :: Value -> Parser FixtureManifest
parseManifest = withObject "fixture manifest" $ \object -> do
rejectUnknown ["name", "description", "include", "steps"] object
nameText <- object .: "name"
name <- either (fail . Text.unpack) pure (mkFixtureName nameText)
description <- object .:? "description"
includeNames <- fromMaybe [] <$> object .:? "include"
includes <- traverse (either (fail . Text.unpack) pure . mkFixtureName) includeNames
rawSteps <- object .: "steps"
steps <- case rawSteps of
[] -> fail "steps must be non-empty"
values -> traverse parseStep values
pure FixtureManifest {name, description, includes, steps}
parseStep :: Value -> Parser FixtureStep
parseStep = withObject "fixture step" $ \object -> do
rejectUnknown ["sql", "copy"] object
case (KeyMap.lookup "sql" object, KeyMap.lookup "copy" object) of
(Just (String path), Nothing) -> SqlFile <$> safePath path
(Nothing, Just value) -> CopyCsv <$> parseCopy value
_ -> fail "step must contain exactly one of sql or copy"
parseCopy :: Value -> Parser CopySpec
parseCopy = withObject "copy step" $ \object -> do
rejectUnknown ["table", "columns", "file", "format", "header"] object
tableValue <- object .: "table"
(schema, table) <- parseTable tableValue
columnNames <- object .: "columns"
columns <- case columnNames of
[] -> fail "COPY columns must be non-empty"
values -> traverse identifier values
fileText <- object .: "file"
file <- safePath fileText
format <- object .: "format"
unless (format == ("csv" :: Text)) (fail "COPY format must be csv")
header <- object .: "header"
pure CopySpec {schema, table, columns, file, header}
parseTable :: Value -> Parser (SqlIdentifier, SqlIdentifier)
parseTable = withObject "COPY table" $ \object -> do
rejectUnknown ["schema", "name"] object
schema <- object .: "schema" >>= identifier
table <- object .: "name" >>= identifier
pure (schema, table)
identifier :: Text -> Parser SqlIdentifier
identifier = either (fail . Text.unpack) pure . mkSqlIdentifier
safePath :: Text -> Parser FilePath
safePath raw = do
let path = Text.unpack raw
when (Text.null raw || Text.any (== '\0') raw || isAbsolute path || any (`elem` [".", "..", ""]) (Text.splitOn "/" raw)) $
fail "step path must be a non-empty relative path without . or .. components"
pure path
rejectUnknown :: [Text] -> KeyMap.KeyMap Value -> Parser ()
rejectUnknown allowed object =
case filter (`notElem` allowed) (map Key.toText (KeyMap.keys object)) of
[] -> pure ()
unknown -> fail ("unknown fields: " ++ Text.unpack (Text.intercalate ", " unknown))