{-| Module : Data.Aeson.Schema.TH.Schema Maintainer : Brandon Chinn Stability : experimental Portability : portable The 'schema' quasiquoter. -} {-# LANGUAGE DataKinds #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE PolyKinds #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} module Data.Aeson.Schema.TH.Schema (schema) where import Control.Monad (unless, (<=<), (>=>)) import Data.Bifunctor (second) import qualified Data.HashMap.Strict as HashMap import Data.Maybe (mapMaybe) import Language.Haskell.TH import Language.Haskell.TH.Quote (QuasiQuoter(..)) import Data.Aeson.Schema.Internal (SchemaType(..)) import Data.Aeson.Schema.Key (SchemaKey(..), fromSchemaKey) import qualified Data.Aeson.Schema.Show as SchemaShow import Data.Aeson.Schema.TH.Parse import Data.Aeson.Schema.TH.Utils (parseSchemaType, schemaPairsToTypeQ, typeQListToTypeQ, typeToSchemaPairs) -- | Defines a QuasiQuoter for writing schemas. -- -- Example: -- -- > import Data.Aeson.Schema (schema) -- > -- > type MySchema = [schema| -- > { -- > foo: { -- > a: Int, -- > // you can add comments like this -- > nodes: List { -- > b: Maybe Bool, -- > }, -- > c: Text, -- > d: Text, -- > e: MyType, -- > f: Maybe List { -- > name: Text, -- > }, -- > }, -- > } -- > |] -- -- Syntax: -- -- * @{ key: \, ... }@ corresponds to a JSON 'Data.Aeson.Schema.Object' with the given key -- mapping to the given schema. -- -- * @Bool@, @Int@, @Double@, and @Text@ correspond to the usual Haskell values. -- -- * @Maybe \@ and @List \@ correspond to @Maybe@ and @[]@, containing values -- specified by the provided schema (no parentheses needed). -- -- * @Try \@ correspond to @Maybe@, where the value will be @Just@ if the given schema -- successfully parses the value, or @Nothing@ otherwise. -- -- * Any other uppercase identifier corresponds to the respective type in scope -- requires a -- FromJSON instance. -- -- Advanced syntax: -- -- * @\ | \@ corresponds to a JSON value that matches one of the given schemas. -- When extracted from an 'Data.Aeson.Schema.Object', it deserializes into a -- 'Data.Aeson.Schema.Utils.Sum.JSONSum' object. (added in v1.1.0) -- -- * @{ [key]: \ }@ uses the current object to resolve the keys in the given schema. Only -- object schemas are allowed here. -- -- * @{ key: #Other, ... }@ maps the given key to the @Other@ schema. -- -- * @{ #Other, ... }@ extends this schema with the @Other@ schema. schema :: QuasiQuoter schema = QuasiQuoter { quoteExp = error "Cannot use `schema` for Exp" , quoteDec = error "Cannot use `schema` for Dec" , quoteType = parse schemaDef >=> \case SchemaDefObj items -> generateSchemaObject items _ -> fail "`schema` definition must be an object" , quotePat = error "Cannot use `schema` for Pat" } generateSchemaObject :: [SchemaDefObjItem] -> TypeQ generateSchemaObject items = [t| 'SchemaObject $(fromItems items) |] where fromItems = schemaPairsToTypeQ <=< resolveParts . concat <=< mapM toParts generateSchema :: SchemaDef -> TypeQ generateSchema = \case SchemaDefType "Bool" -> [t| 'SchemaBool |] SchemaDefType "Int" -> [t| 'SchemaInt |] SchemaDefType "Double" -> [t| 'SchemaDouble |] SchemaDefType "Text" -> [t| 'SchemaText |] SchemaDefType other -> [t| 'SchemaCustom $(getType other) |] SchemaDefMaybe inner -> [t| 'SchemaMaybe $(generateSchema inner) |] SchemaDefTry inner -> [t| 'SchemaTry $(generateSchema inner) |] SchemaDefList inner -> [t| 'SchemaList $(generateSchema inner) |] SchemaDefInclude other -> getType other SchemaDefObj items -> generateSchemaObject items SchemaDefUnion schemas -> [t| 'SchemaUnion $(typeQListToTypeQ $ map generateSchema schemas) |] {- Helpers -} getName :: String -> Q Name getName ty = maybe (fail $ "Unknown type: " ++ ty) return =<< lookupTypeName ty getType :: String -> TypeQ getType = getName >=> conT data KeySource = Provided | Imported deriving (Show,Eq) -- | Parse SchemaDefObjItem into a list of tuples, each containing a key to add to the schema, -- the value for the key, and the source of the key. toParts :: SchemaDefObjItem -> Q [(SchemaKey, TypeQ, KeySource)] toParts = \case SchemaDefObjPair (schemaDefKey, schemaDefType) -> do let schemaKey = schemaDefToSchemaKey schemaDefKey schemaType <- generateSchema schemaDefType case schemaKey of PhantomKey _ -> do let schemaTypeShow = parseSchemaType schemaType unless (isValidPhantomSchema schemaTypeShow) $ fail $ "Invalid schema for '" ++ fromSchemaKey schemaKey ++ "': " ++ SchemaShow.showSchemaType schemaTypeShow _ -> return () pure . tagAs Provided $ [(schemaKey, pure schemaType)] SchemaDefObjExtend other -> do name <- getName other reify name >>= \case TyConI (TySynD _ _ (AppT (PromotedT ty) inner)) | ty == 'SchemaObject -> pure . tagAs Imported . map (second pure) $ typeToSchemaPairs inner _ -> fail $ "'" ++ show name ++ "' is not a SchemaObject" where tagAs source = map $ \(k,v) -> (k,v,source) schemaDefToSchemaKey = \case SchemaDefObjKeyNormal key -> NormalKey key SchemaDefObjKeyPhantom key -> PhantomKey key isValidPhantomSchema = \case SchemaShow.SchemaTry _ -> True SchemaShow.SchemaObject _ -> True SchemaShow.SchemaUnion schemas -> all isValidPhantomSchema schemas _ -> False -- | Resolve the parts returned by 'toParts' as such: -- -- 1. Any explicitly provided keys shadow/overwrite imported keys -- 2. Fail if duplicate keys are both explicitly provided -- 3. Fail if duplicate keys are both imported resolveParts :: [(SchemaKey, TypeQ, KeySource)] -> Q [(SchemaKey, TypeQ)] resolveParts parts = do resolved <- resolveParts' $ HashMap.fromListWith (++) $ map nameAndSource parts return $ mapMaybe (alignWithResolved resolved) parts where nameAndSource (name, _, source) = (fromSchemaKey name, [source]) resolveParts' = HashMap.traverseWithKey $ \name sources -> do -- invariant: length sources > 0 let numOf source = length $ filter (== source) sources case (numOf Provided, numOf Imported) of (1, _) -> return Provided (0, 1) -> return Imported (x, _) | x > 1 -> fail $ "Key '" ++ name ++ "' specified multiple times" (_, x) | x > 1 -> fail $ "Key '" ++ name ++ "' declared in multiple imported schemas" _ -> fail "Broken invariant in resolveParts" alignWithResolved resolved (key, ty, source) = let resolvedSource = resolved HashMap.! fromSchemaKey key in if resolvedSource == source then Just (key, ty) else Nothing