Separate Query and Mutation resolvers

Fixes #33 .
This commit is contained in:
Eugen Wissner 2020-05-10 18:32:58 +02:00
parent 387d158bd1
commit 500cff20eb
9 changed files with 94 additions and 56 deletions

View File

@ -17,7 +17,8 @@ and this project adheres to
- Type system definition parser. - Type system definition parser.
- `Trans.argument`. - `Trans.argument`.
- Schema extension parser. - Schema extension parser.
- Contributing guidelines - Contributing guidelines.
- `Schema.resolversToMap` (intended for to be used internally).
### Changed ### Changed
- Rename `AST.Definition` into `AST.Document.ExecutableDefinition`. - Rename `AST.Definition` into `AST.Document.ExecutableDefinition`.
@ -31,6 +32,12 @@ and this project adheres to
`symbol "@"` now. `symbol "@"` now.
- Replace `MonadIO` with a plain `Monad`. Since the tests don't use IO, - Replace `MonadIO` with a plain `Monad`. Since the tests don't use IO,
set the inner monad to `Identity`. set the inner monad to `Identity`.
- `NonEmpty (Resolver m)` is now `HashMap Text (NonEmpty (Resolver m))`. Root
operation type can be any type, therefore a hashmap is needed. Since types
cannot be empty, we save the list of resolvers in the type as a non-empty
list. Currently only "Query" and "Mutation" are supported as types. For more
schema support is required. The executor checks now if the type in the query
matches the type of the provided root resolvers.
### Removed ### Removed
- `AST.Field`, `AST.InlineFragment` and `AST.FragmentSpread`. - `AST.Field`, `AST.InlineFragment` and `AST.FragmentSpread`.

View File

@ -17,6 +17,8 @@ Since this file is a literate haskell file, we start by importing some dependenc
> import Control.Monad.IO.Class (liftIO) > import Control.Monad.IO.Class (liftIO)
> import Data.Aeson (encode) > import Data.Aeson (encode)
> import Data.ByteString.Lazy.Char8 (putStrLn) > import Data.ByteString.Lazy.Char8 (putStrLn)
> import Data.HashMap.Strict (HashMap)
> import qualified Data.HashMap.Strict as HashMap
> import Data.List.NonEmpty (NonEmpty(..)) > import Data.List.NonEmpty (NonEmpty(..))
> import Data.Text (Text) > import Data.Text (Text)
> import Data.Time (getCurrentTime) > import Data.Time (getCurrentTime)
@ -33,8 +35,8 @@ example from [graphql.js](https://github.com/graphql/graphql-js).
First we build a GraphQL schema. First we build a GraphQL schema.
> schema1 :: NonEmpty (Schema.Resolver IO) > schema1 :: HashMap Text (NonEmpty (Schema.Resolver IO))
> schema1 = hello :| [] > schema1 = HashMap.singleton "Query" $ hello :| []
> >
> hello :: Schema.Resolver IO > hello :: Schema.Resolver IO
> hello = Schema.scalar "hello" (return ("it's me" :: Text)) > hello = Schema.scalar "hello" (return ("it's me" :: Text))
@ -63,8 +65,8 @@ returning
For this example, we're going to be using time. For this example, we're going to be using time.
> schema2 :: NonEmpty (Schema.Resolver IO) > schema2 :: HashMap Text (NonEmpty (Schema.Resolver IO))
> schema2 = time :| [] > schema2 = HashMap.singleton "Query" $ time :| []
> >
> time :: Schema.Resolver IO > time :: Schema.Resolver IO
> time = Schema.scalar "time" $ do > time = Schema.scalar "time" $ do
@ -122,8 +124,8 @@ This will fail
Now that we have two resolvers, we can define a schema which uses them both. Now that we have two resolvers, we can define a schema which uses them both.
> schema3 :: NonEmpty (Schema.Resolver IO) > schema3 :: HashMap Text (NonEmpty (Schema.Resolver IO))
> schema3 = hello :| [time] > schema3 = HashMap.singleton "Query" $ hello :| [time]
> >
> query3 :: Text > query3 :: Text
> query3 = "query timeAndHello { time hello }" > query3 = "query timeAndHello { time hello }"

View File

@ -6,7 +6,8 @@ module Language.GraphQL
import qualified Data.Aeson as Aeson import qualified Data.Aeson as Aeson
import Data.List.NonEmpty (NonEmpty) import Data.List.NonEmpty (NonEmpty)
import qualified Data.Text as T import Data.HashMap.Strict (HashMap)
import Data.Text (Text)
import Language.GraphQL.Error import Language.GraphQL.Error
import Language.GraphQL.Execute import Language.GraphQL.Execute
import Language.GraphQL.AST.Parser import Language.GraphQL.AST.Parser
@ -16,8 +17,8 @@ import Text.Megaparsec (parse)
-- | If the text parses correctly as a @GraphQL@ query the query is -- | If the text parses correctly as a @GraphQL@ query the query is
-- executed using the given 'Schema.Resolver's. -- executed using the given 'Schema.Resolver's.
graphql :: Monad m graphql :: Monad m
=> NonEmpty (Schema.Resolver m) -- ^ Resolvers. => HashMap Text (NonEmpty (Schema.Resolver m)) -- ^ Resolvers.
-> T.Text -- ^ Text representing a @GraphQL@ request document. -> Text -- ^ Text representing a @GraphQL@ request document.
-> m Aeson.Value -- ^ Response. -> m Aeson.Value -- ^ Response.
graphql = flip graphqlSubs mempty graphql = flip graphqlSubs mempty
@ -25,9 +26,9 @@ graphql = flip graphqlSubs mempty
-- applied to the query and the query is then executed using to the given -- applied to the query and the query is then executed using to the given
-- 'Schema.Resolver's. -- 'Schema.Resolver's.
graphqlSubs :: Monad m graphqlSubs :: Monad m
=> NonEmpty (Schema.Resolver m) -- ^ Resolvers. => HashMap Text (NonEmpty (Schema.Resolver m)) -- ^ Resolvers.
-> Schema.Subs -- ^ Variable substitution function. -> Schema.Subs -- ^ Variable substitution function.
-> T.Text -- ^ Text representing a @GraphQL@ request document. -> Text -- ^ Text representing a @GraphQL@ request document.
-> m Aeson.Value -- ^ Response. -> m Aeson.Value -- ^ Response.
graphqlSubs schema f graphqlSubs schema f
= either parseError (execute schema f) = either parseError (execute schema f)

View File

@ -7,9 +7,10 @@ module Language.GraphQL.Execute
) where ) where
import qualified Data.Aeson as Aeson import qualified Data.Aeson as Aeson
import Data.Foldable (toList)
import Data.List.NonEmpty (NonEmpty(..)) import Data.List.NonEmpty (NonEmpty(..))
import qualified Data.List.NonEmpty as NE import qualified Data.List.NonEmpty as NonEmpty
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HashMap
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as Text import qualified Data.Text as Text
import Language.GraphQL.AST.Document import Language.GraphQL.AST.Document
@ -24,12 +25,13 @@ import qualified Language.GraphQL.Schema as Schema
-- Returns the result of the query against the schema wrapped in a /data/ -- Returns the result of the query against the schema wrapped in a /data/
-- field, or errors wrapped in an /errors/ field. -- field, or errors wrapped in an /errors/ field.
execute :: Monad m execute :: Monad m
=> NonEmpty (Schema.Resolver m) -- ^ Resolvers. => HashMap Text (NonEmpty (Schema.Resolver m)) -- ^ Resolvers.
-> Schema.Subs -- ^ Variable substitution function. -> Schema.Subs -- ^ Variable substitution function.
-> Document -- @GraphQL@ document. -> Document -- @GraphQL@ document.
-> m Aeson.Value -> m Aeson.Value
execute schema subs doc = execute schema subs doc =
maybe transformError (document schema Nothing) $ Transform.document subs doc maybe transformError (document schema Nothing)
$ Transform.document subs doc
where where
transformError = return $ singleError "Schema transformation error." transformError = return $ singleError "Schema transformation error."
@ -40,23 +42,24 @@ execute schema subs doc =
-- Returns the result of the query against the schema wrapped in a /data/ -- Returns the result of the query against the schema wrapped in a /data/
-- field, or errors wrapped in an /errors/ field. -- field, or errors wrapped in an /errors/ field.
executeWithName :: Monad m executeWithName :: Monad m
=> NonEmpty (Schema.Resolver m) -- ^ Resolvers => HashMap Text (NonEmpty (Schema.Resolver m)) -- ^ Resolvers
-> Text -- ^ Operation name. -> Text -- ^ Operation name.
-> Schema.Subs -- ^ Variable substitution function. -> Schema.Subs -- ^ Variable substitution function.
-> Document -- ^ @GraphQL@ Document. -> Document -- ^ @GraphQL@ Document.
-> m Aeson.Value -> m Aeson.Value
executeWithName schema name subs doc = executeWithName schema name subs doc =
maybe transformError (document schema $ Just name) $ Transform.document subs doc maybe transformError (document schema $ Just name)
$ Transform.document subs doc
where where
transformError = return $ singleError "Schema transformation error." transformError = return $ singleError "Schema transformation error."
document :: Monad m document :: Monad m
=> NonEmpty (Schema.Resolver m) => HashMap Text (NonEmpty (Schema.Resolver m))
-> Maybe Text -> Maybe Text
-> AST.Core.Document -> AST.Core.Document
-> m Aeson.Value -> m Aeson.Value
document schema Nothing (op :| []) = operation schema op document schema Nothing (op :| []) = operation schema op
document schema (Just name) operations = case NE.dropWhile matchingName operations of document schema (Just name) operations = case NonEmpty.dropWhile matchingName operations of
[] -> return $ singleError [] -> return $ singleError
$ Text.unwords ["Operation", name, "couldn't be found in the document."] $ Text.unwords ["Operation", name, "couldn't be found in the document."]
(op:_) -> operation schema op (op:_) -> operation schema op
@ -67,10 +70,17 @@ document schema (Just name) operations = case NE.dropWhile matchingName operatio
document _ _ _ = return $ singleError "Missing operation name." document _ _ _ = return $ singleError "Missing operation name."
operation :: Monad m operation :: Monad m
=> NonEmpty (Schema.Resolver m) => HashMap Text (NonEmpty (Schema.Resolver m))
-> AST.Core.Operation -> AST.Core.Operation
-> m Aeson.Value -> m Aeson.Value
operation schema (AST.Core.Query _ flds) operation schema = schemaOperation
= runCollectErrs (Schema.resolve (toList schema) flds) where
operation schema (AST.Core.Mutation _ flds) runResolver fields = runCollectErrs
= runCollectErrs (Schema.resolve (toList schema) flds) . flip Schema.resolve fields
. Schema.resolversToMap
resolve fields queryType = maybe lookupError (runResolver fields)
$ HashMap.lookup queryType schema
lookupError = pure
$ singleError "Root operation type couldn't be found in the schema."
schemaOperation (AST.Core.Query _ fields) = resolve fields "Query"
schemaOperation (AST.Core.Mutation _ fields) = resolve fields "Mutation"

View File

@ -3,11 +3,12 @@
-- | This module provides a representation of a @GraphQL@ Schema in addition to -- | This module provides a representation of a @GraphQL@ Schema in addition to
-- functions for defining and manipulating schemas. -- functions for defining and manipulating schemas.
module Language.GraphQL.Schema module Language.GraphQL.Schema
( Resolver ( Resolver(..)
, Subs , Subs
, object , object
, scalar
, resolve , resolve
, resolversToMap
, scalar
, wrappedObject , wrappedObject
, wrappedScalar , wrappedScalar
-- * AST Reexports -- * AST Reexports
@ -18,7 +19,7 @@ module Language.GraphQL.Schema
import Control.Monad.Trans.Class (lift) import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except (runExceptT) import Control.Monad.Trans.Except (runExceptT)
import Control.Monad.Trans.Reader (runReaderT) import Control.Monad.Trans.Reader (runReaderT)
import Data.Foldable (find, fold) import Data.Foldable (fold, toList)
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import qualified Data.Aeson as Aeson import qualified Data.Aeson as Aeson
import Data.HashMap.Strict (HashMap) import Data.HashMap.Strict (HashMap)
@ -38,6 +39,15 @@ data Resolver m = Resolver
Text -- ^ Name Text -- ^ Name
(Field -> CollectErrsT m Aeson.Object) -- ^ Resolver (Field -> CollectErrsT m Aeson.Object) -- ^ Resolver
-- | Converts resolvers to a map.
resolversToMap
:: (Foldable f, Functor f)
=> f (Resolver m)
-> HashMap Text (Field -> CollectErrsT m Aeson.Object)
resolversToMap = HashMap.fromList . toList . fmap toKV
where
toKV (Resolver name f) = (name, f)
-- | Contains variables for the query. The key of the map is a variable name, -- | Contains variables for the query. The key of the map is a variable name,
-- and the value is the variable value. -- and the value is the variable value.
type Subs = HashMap Name Value type Subs = HashMap Name Value
@ -46,7 +56,8 @@ type Subs = HashMap Name Value
object :: Monad m => Name -> ActionT m [Resolver m] -> Resolver m object :: Monad m => Name -> ActionT m [Resolver m] -> Resolver m
object name f = Resolver name $ resolveFieldValue f resolveRight object name f = Resolver name $ resolveFieldValue f resolveRight
where where
resolveRight fld@(Field _ _ _ flds) resolver = withField (resolve resolver flds) fld resolveRight fld@(Field _ _ _ flds) resolver
= withField (resolve (resolversToMap resolver) flds) fld
-- | Like 'object' but can be null or a list of objects. -- | Like 'object' but can be null or a list of objects.
wrappedObject :: wrappedObject ::
@ -57,7 +68,8 @@ wrappedObject ::
wrappedObject name f = Resolver name $ resolveFieldValue f resolveRight wrappedObject name f = Resolver name $ resolveFieldValue f resolveRight
where where
resolveRight fld@(Field _ _ _ sels) resolver resolveRight fld@(Field _ _ _ sels) resolver
= withField (traverse (`resolve` sels) resolver) fld = withField (traverse (resolveMap sels) resolver) fld
resolveMap = flip (resolve . resolversToMap)
-- | A scalar represents a primitive value, like a string or an integer. -- | A scalar represents a primitive value, like a string or an integer.
scalar :: (Monad m, Aeson.ToJSON a) => Name -> ActionT m a -> Resolver m scalar :: (Monad m, Aeson.ToJSON a) => Name -> ActionT m a -> Resolver m
@ -81,7 +93,7 @@ wrappedScalar name f = Resolver name $ resolveFieldValue f resolveRight
resolveFieldValue :: resolveFieldValue ::
Monad m => Monad m =>
ActionT m a -> ActionT m a ->
(Field -> a -> CollectErrsT m (HashMap Text Aeson.Value)) -> (Field -> a -> CollectErrsT m Aeson.Object) ->
Field -> Field ->
CollectErrsT m (HashMap Text Aeson.Value) CollectErrsT m (HashMap Text Aeson.Value)
resolveFieldValue f resolveRight fld@(Field _ _ args _) = do resolveFieldValue f resolveRight fld@(Field _ _ args _) = do
@ -103,22 +115,21 @@ withField v fld
-- 'Resolver' to each 'Field'. Resolves into a value containing the -- 'Resolver' to each 'Field'. Resolves into a value containing the
-- resolved 'Field', or a null value and error information. -- resolved 'Field', or a null value and error information.
resolve :: Monad m resolve :: Monad m
=> [Resolver m] -> Seq Selection -> CollectErrsT m Aeson.Value => HashMap Text (Field -> CollectErrsT m Aeson.Object)
-> Seq Selection
-> CollectErrsT m Aeson.Value
resolve resolvers = fmap (Aeson.toJSON . fold) . traverse tryResolvers resolve resolvers = fmap (Aeson.toJSON . fold) . traverse tryResolvers
where where
resolveTypeName (Resolver "__typename" f) = do resolveTypeName f = do
value <- f $ Field Nothing "__typename" mempty mempty value <- f $ Field Nothing "__typename" mempty mempty
return $ HashMap.lookupDefault "" "__typename" value return $ HashMap.lookupDefault "" "__typename" value
resolveTypeName _ = return ""
tryResolvers (SelectionField fld@(Field _ name _ _)) tryResolvers (SelectionField fld@(Field _ name _ _))
= maybe (errmsg fld) (tryResolver fld) $ find (compareResolvers name) resolvers = fromMaybe (errmsg fld) $ HashMap.lookup name resolvers <*> Just fld
tryResolvers (SelectionFragment (Fragment typeCondition selections')) = do tryResolvers (SelectionFragment (Fragment typeCondition selections')) = do
that <- traverse resolveTypeName (find (compareResolvers "__typename") resolvers) that <- traverse resolveTypeName $ HashMap.lookup "__typename" resolvers
if maybe True (Aeson.String typeCondition ==) that if maybe True (Aeson.String typeCondition ==) that
then fmap fold . traverse tryResolvers $ selections' then fmap fold . traverse tryResolvers $ selections'
else return mempty else return mempty
compareResolvers name (Resolver name' _) = name == name'
tryResolver fld (Resolver _ resolver) = resolver fld
errmsg fld@(Field _ name _ _) = do errmsg fld@(Field _ name _ _) = do
addErrMsg $ T.unwords ["field", name, "not resolved."] addErrMsg $ T.unwords ["field", name, "not resolved."]
return $ HashMap.singleton (aliasOrName fld) Aeson.Null return $ HashMap.singleton (aliasOrName fld) Aeson.Null

View File

@ -1,4 +1,4 @@
resolver: lts-15.7 resolver: lts-15.11
packages: packages:
- . - .

View File

@ -5,14 +5,18 @@ module Test.DirectiveSpec
) where ) where
import Data.Aeson (Value, object, (.=)) import Data.Aeson (Value, object, (.=))
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HashMap
import Data.List.NonEmpty (NonEmpty(..)) import Data.List.NonEmpty (NonEmpty(..))
import Data.Text (Text)
import Language.GraphQL import Language.GraphQL
import qualified Language.GraphQL.Schema as Schema import qualified Language.GraphQL.Schema as Schema
import Test.Hspec (Spec, describe, it, shouldBe) import Test.Hspec (Spec, describe, it, shouldBe)
import Text.RawString.QQ (r) import Text.RawString.QQ (r)
experimentalResolver :: Schema.Resolver IO experimentalResolver :: HashMap Text (NonEmpty (Schema.Resolver IO))
experimentalResolver = Schema.scalar "experimentalField" $ pure (5 :: Int) experimentalResolver = HashMap.singleton "Query"
$ Schema.scalar "experimentalField" (pure (5 :: Int)) :| []
emptyObject :: Value emptyObject :: Value
emptyObject = object emptyObject = object
@ -29,7 +33,7 @@ spec =
} }
|] |]
actual <- graphql (experimentalResolver :| []) query actual <- graphql experimentalResolver query
actual `shouldBe` emptyObject actual `shouldBe` emptyObject
it "should not skip fields if @skip is false" $ do it "should not skip fields if @skip is false" $ do
@ -44,7 +48,7 @@ spec =
] ]
] ]
actual <- graphql (experimentalResolver :| []) query actual <- graphql experimentalResolver query
actual `shouldBe` expected actual `shouldBe` expected
it "should skip fields if @include is false" $ do it "should skip fields if @include is false" $ do
@ -54,7 +58,7 @@ spec =
} }
|] |]
actual <- graphql (experimentalResolver :| []) query actual <- graphql experimentalResolver query
actual `shouldBe` emptyObject actual `shouldBe` emptyObject
it "should be able to @skip a fragment spread" $ do it "should be able to @skip a fragment spread" $ do
@ -68,7 +72,7 @@ spec =
} }
|] |]
actual <- graphql (experimentalResolver :| []) query actual <- graphql experimentalResolver query
actual `shouldBe` emptyObject actual `shouldBe` emptyObject
it "should be able to @skip an inline fragment" $ do it "should be able to @skip an inline fragment" $ do
@ -80,5 +84,5 @@ spec =
} }
|] |]
actual <- graphql (experimentalResolver :| []) query actual <- graphql experimentalResolver query
actual `shouldBe` emptyObject actual `shouldBe` emptyObject

View File

@ -51,7 +51,7 @@ spec :: Spec
spec = do spec = do
describe "Inline fragment executor" $ do describe "Inline fragment executor" $ do
it "chooses the first selection if the type matches" $ do it "chooses the first selection if the type matches" $ do
actual <- graphql (garment "Hat" :| []) inlineQuery actual <- graphql (HashMap.singleton "Query" $ garment "Hat" :| []) inlineQuery
let expected = object let expected = object
[ "data" .= object [ "data" .= object
[ "garment" .= object [ "garment" .= object
@ -62,7 +62,7 @@ spec = do
in actual `shouldBe` expected in actual `shouldBe` expected
it "chooses the last selection if the type matches" $ do it "chooses the last selection if the type matches" $ do
actual <- graphql (garment "Shirt" :| []) inlineQuery actual <- graphql (HashMap.singleton "Query" $ garment "Shirt" :| []) inlineQuery
let expected = object let expected = object
[ "data" .= object [ "data" .= object
[ "garment" .= object [ "garment" .= object
@ -83,7 +83,7 @@ spec = do
}|] }|]
resolvers = Schema.object "garment" $ return [circumference, size] resolvers = Schema.object "garment" $ return [circumference, size]
actual <- graphql (resolvers :| []) query actual <- graphql (HashMap.singleton "Query" $ resolvers :| []) query
let expected = object let expected = object
[ "data" .= object [ "data" .= object
[ "garment" .= object [ "garment" .= object
@ -101,7 +101,7 @@ spec = do
} }
}|] }|]
actual <- graphql (size :| []) query actual <- graphql (HashMap.singleton "Query" $ size :| []) query
actual `shouldNotSatisfy` hasErrors actual `shouldNotSatisfy` hasErrors
describe "Fragment spread executor" $ do describe "Fragment spread executor" $ do
@ -116,7 +116,7 @@ spec = do
} }
|] |]
actual <- graphql (circumference :| []) query actual <- graphql (HashMap.singleton "Query" $ circumference :| []) query
let expected = object let expected = object
[ "data" .= object [ "data" .= object
[ "circumference" .= (60 :: Int) [ "circumference" .= (60 :: Int)
@ -141,7 +141,7 @@ spec = do
} }
|] |]
actual <- graphql (garment "Hat" :| []) query actual <- graphql (HashMap.singleton "Query" $ garment "Hat" :| []) query
let expected = object let expected = object
[ "data" .= object [ "data" .= object
[ "garment" .= object [ "garment" .= object
@ -162,7 +162,7 @@ spec = do
} }
|] |]
actual <- graphql (circumference :| []) query actual <- graphql (HashMap.singleton "Query" $ circumference :| []) query
actual `shouldSatisfy` hasErrors actual `shouldSatisfy` hasErrors
it "considers type condition" $ do it "considers type condition" $ do
@ -187,5 +187,5 @@ spec = do
] ]
] ]
] ]
actual <- graphql (garment "Hat" :| []) query actual <- graphql (HashMap.singleton "Query" $ garment "Hat" :| []) query
actual `shouldBe` expected actual `shouldBe` expected

View File

@ -10,8 +10,11 @@ module Test.StarWars.Schema
import Control.Monad.Trans.Except (throwE) import Control.Monad.Trans.Except (throwE)
import Control.Monad.Trans.Class (lift) import Control.Monad.Trans.Class (lift)
import Data.Functor.Identity (Identity) import Data.Functor.Identity (Identity)
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HashMap
import Data.List.NonEmpty (NonEmpty(..)) import Data.List.NonEmpty (NonEmpty(..))
import Data.Maybe (catMaybes) import Data.Maybe (catMaybes)
import Data.Text (Text)
import qualified Language.GraphQL.Schema as Schema import qualified Language.GraphQL.Schema as Schema
import Language.GraphQL.Trans import Language.GraphQL.Trans
import qualified Language.GraphQL.Type as Type import qualified Language.GraphQL.Type as Type
@ -19,8 +22,8 @@ import Test.StarWars.Data
-- See https://github.com/graphql/graphql-js/blob/master/src/__tests__/starWarsSchema.js -- See https://github.com/graphql/graphql-js/blob/master/src/__tests__/starWarsSchema.js
schema :: NonEmpty (Schema.Resolver Identity) schema :: HashMap Text (NonEmpty (Schema.Resolver Identity))
schema = hero :| [human, droid] schema = HashMap.singleton "Query" $ hero :| [human, droid]
hero :: Schema.Resolver Identity hero :: Schema.Resolver Identity
hero = Schema.object "hero" $ do hero = Schema.object "hero" $ do