diff options
| -rw-r--r-- | CHANGELOG.md | 14 | ||||
| -rw-r--r-- | README.md | 25 | ||||
| -rw-r--r-- | graphql.cabal | 9 | ||||
| -rw-r--r-- | package.yaml | 4 | ||||
| -rw-r--r-- | src/Language/GraphQL/AST/Document.hs | 80 | ||||
| -rw-r--r-- | src/Language/GraphQL/AST/Encoder.hs | 25 | ||||
| -rw-r--r-- | src/Language/GraphQL/Type/Definition.hs | 7 | ||||
| -rw-r--r-- | src/Language/GraphQL/Type/In.hs | 14 | ||||
| -rw-r--r-- | src/Language/GraphQL/Type/Internal.hs | 43 | ||||
| -rw-r--r-- | src/Language/GraphQL/Type/Out.hs | 24 | ||||
| -rw-r--r-- | src/Language/GraphQL/Type/Schema.hs | 22 | ||||
| -rw-r--r-- | src/Language/GraphQL/Validate.hs | 2 | ||||
| -rw-r--r-- | src/Language/GraphQL/Validate/Rules.hs | 676 | ||||
| -rw-r--r-- | stack.yaml | 2 | ||||
| -rw-r--r-- | tests/Language/GraphQL/AST/DocumentSpec.hs | 20 | ||||
| -rw-r--r-- | tests/Language/GraphQL/Validate/RulesSpec.hs | 914 | ||||
| -rw-r--r-- | tests/Language/GraphQL/ValidateSpec.hs | 665 |
17 files changed, 1770 insertions, 776 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3cd40..37ac5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on and this project adheres to [Haskell Package Versioning Policy](https://pvp.haskell.org/). +## [0.11.1.0] - 2021-02-07 +### Added +- `Validate.Rules`: + - `overlappingFieldsCanBeMergedRule` + - `possibleFragmentSpreadsRule` + - `variablesInAllowedPositionRule` + - `valuesOfCorrectTypeRule` +- `Type.Schema.implementations` contains a map from interfaces and objects to + interfaces they implement. +- Show instances for GraphQL type definitions in the `Type` modules. +- Custom Show instances for type and value representations in the AST. +- `AST.Document.escape` escapes a single character in a `StringValue`. + ## [0.11.0.0] - 2020-11-07 ### Changed - `AST.Document.Selection` wraps additional new types: `Field`, `FragmentSpread` @@ -400,6 +413,7 @@ and this project adheres to ### Added - Data types for the GraphQL language. +[0.11.1.0]: https://www.caraus.tech/projects/pub-graphql/repository/23/diff?rev=v0.11.1.0&rev_to=v0.11.0.0 [0.11.0.0]: https://www.caraus.tech/projects/pub-graphql/repository/23/diff?rev=v0.11.0.0&rev_to=v0.10.0.0 [0.10.0.0]: https://www.caraus.tech/projects/pub-graphql/repository/23/diff?rev=v0.10.0.0&rev_to=v0.9.0.0 [0.9.0.0]: https://www.caraus.tech/projects/pub-graphql/repository/23/diff?rev=v0.9.0.0&rev_to=v0.8.0.0 @@ -3,29 +3,10 @@ [](https://www.simplehaskell.org) [](https://build.caraus.tech/go/pipelines) -This implementation is relatively low-level by design, it doesn't provide any -mappings between the GraphQL types and Haskell's type system and avoids -compile-time magic. It focuses on flexibility instead, so other solutions can -be built on top of it. +See https://www.caraus.tech/projects/pub-graphql. -## State of the work - -For now this library provides: - -- Parser for the query and schema languages, as well as a printer for the query - language (minimizer and pretty-printer). -- Data structures to define a type system. -- Executor (queries, mutations and subscriptions are supported). -- Validation is work in progress. -- Introspection isn't available yet. - -But the idea is to be a Haskell port of -[`graphql-js`](https://github.com/graphql/graphql-js). - -For a more precise list of currently missing features see -[issues](https://www.caraus.tech/projects/pub-graphql/issues). - -## Documentation +Report issues on the +[bug tracker](https://www.caraus.tech/projects/pub-graphql/issues). API documentation is available through [Hackage](https://hackage.haskell.org/package/graphql). diff --git a/graphql.cabal b/graphql.cabal index ebc3f4b..c06dba4 100644 --- a/graphql.cabal +++ b/graphql.cabal @@ -4,10 +4,10 @@ cabal-version: 2.2 -- -- see: https://github.com/sol/hpack -- --- hash: c89b0164372b6e02e4f338d3865dd6bb9dfd1a4475f25d808450480d73f94f91 +-- hash: 15a0880180192f918ba0bd3b3e955c57232f1efe8993745d505fcb6e1aab1451 name: graphql -version: 0.11.0.0 +version: 0.11.1.0 synopsis: Haskell GraphQL implementation description: Haskell <https://spec.graphql.org/June2018/ GraphQL> implementation. category: Language @@ -17,7 +17,7 @@ author: Danny Navarro <j@dannynavarro.net>, Matthías Páll Gissurarson <mpg@mpg.is>, Sólrún Halla Einarsdóttir <she@mpg.is> maintainer: belka@caraus.de -copyright: (c) 2019-2020 Eugen Wissner, +copyright: (c) 2019-2021 Eugen Wissner, (c) 2015-2017 J. Daniel Navarro license: MPL-2.0 AND BSD-3-Clause license-files: LICENSE, @@ -78,6 +78,7 @@ test-suite graphql-test type: exitcode-stdio-1.0 main-is: Spec.hs other-modules: + Language.GraphQL.AST.DocumentSpec Language.GraphQL.AST.EncoderSpec Language.GraphQL.AST.LexerSpec Language.GraphQL.AST.ParserSpec @@ -85,7 +86,7 @@ test-suite graphql-test Language.GraphQL.Execute.CoerceSpec Language.GraphQL.ExecuteSpec Language.GraphQL.Type.OutSpec - Language.GraphQL.ValidateSpec + Language.GraphQL.Validate.RulesSpec Test.DirectiveSpec Test.FragmentSpec Test.RootOperationSpec diff --git a/package.yaml b/package.yaml index 15b6ad1..da9eefd 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: graphql -version: 0.11.0.0 +version: 0.11.1.0 synopsis: Haskell GraphQL implementation description: Haskell <https://spec.graphql.org/June2018/ GraphQL> implementation. @@ -11,7 +11,7 @@ category: Language license: MPL-2.0 AND BSD-3-Clause copyright: -- (c) 2019-2020 Eugen Wissner +- (c) 2019-2021 Eugen Wissner - (c) 2015-2017 J. Daniel Navarro author: - Danny Navarro <j@dannynavarro.net> diff --git a/src/Language/GraphQL/AST/Document.hs b/src/Language/GraphQL/AST/Document.hs index b30271c..a78b007 100644 --- a/src/Language/GraphQL/AST/Document.hs +++ b/src/Language/GraphQL/AST/Document.hs @@ -1,5 +1,6 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE ExplicitForAll #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE Safe #-} @@ -47,11 +48,15 @@ module Language.GraphQL.AST.Document , UnionMemberTypes(..) , Value(..) , VariableDefinition(..) + , escape ) where +import Data.Char (ord) import Data.Foldable (toList) import Data.Int (Int32) +import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty) +import Numeric (showFloat, showHex) import Data.Text (Text) import qualified Data.Text as Text import Language.GraphQL.AST.DirectiveLocation (DirectiveLocation) @@ -79,7 +84,10 @@ instance Ord Location where data Node a = Node { node :: a , location :: Location - } deriving (Eq, Show) + } deriving Eq + +instance Show a => Show (Node a) where + show Node{ node } = show node instance Functor Node where fmap f Node{..} = Node (f node) location @@ -218,6 +226,28 @@ type TypeCondition = Name -- ** Input Values +escape :: Char -> String +escape char' + | char' == '\\' = "\\\\" + | char' == '\"' = "\\\"" + | char' == '\b' = "\\b" + | char' == '\f' = "\\f" + | char' == '\n' = "\\n" + | char' == '\r' = "\\r" + | char' == '\t' = "\\t" + | char' < '\x0010' = unicode "\\u000" char' + | char' < '\x0020' = unicode "\\u00" char' + | otherwise = [char'] + where + unicode prefix uchar = prefix <> (showHex $ ord uchar) "" + +showList' :: Show a => [a] -> String +showList' list = "[" ++ intercalate ", " (show <$> list) ++ "]" + +showObject :: Show a => [ObjectField a] -> String +showObject fields = + "{ " ++ intercalate ", " (show <$> fields) ++ " }" + -- | Input value (literal or variable). data Value = Variable Name @@ -229,7 +259,19 @@ data Value | Enum Name | List [Value] | Object [ObjectField Value] - deriving (Eq, Show) + deriving Eq + +instance Show Value where + showList = mappend . showList' + show (Variable variableName) = '$' : Text.unpack variableName + show (Int integer) = show integer + show (Float float) = show $ ConstFloat float + show (String text) = show $ ConstString text + show (Boolean boolean) = show boolean + show Null = "null" + show (Enum name) = Text.unpack name + show (List list) = show list + show (Object fields) = showObject fields -- | Constant input value. data ConstValue @@ -241,7 +283,18 @@ data ConstValue | ConstEnum Name | ConstList [ConstValue] | ConstObject [ObjectField ConstValue] - deriving (Eq, Show) + deriving Eq + +instance Show ConstValue where + showList = mappend . showList' + show (ConstInt integer) = show integer + show (ConstFloat float) = showFloat float mempty + show (ConstString text) = "\"" <> Text.foldr (mappend . escape) "\"" text + show (ConstBoolean boolean) = show boolean + show ConstNull = "null" + show (ConstEnum name) = Text.unpack name + show (ConstList list) = show list + show (ConstObject fields) = showObject fields -- | Key-value pair. -- @@ -250,7 +303,13 @@ data ObjectField a = ObjectField { name :: Name , value :: Node a , location :: Location - } deriving (Eq, Show) + } deriving Eq + +instance Show a => Show (ObjectField a) where + show ObjectField{..} = Text.unpack name ++ ": " ++ show value + +instance Functor ObjectField where + fmap f ObjectField{..} = ObjectField name (f <$> value) location -- ** Variables @@ -281,7 +340,12 @@ data Type = TypeNamed Name | TypeList Type | TypeNonNull NonNullType - deriving (Eq, Show) + deriving Eq + +instance Show Type where + show (TypeNamed typeName) = Text.unpack typeName + show (TypeList listType) = concat ["[", show listType, "]"] + show (TypeNonNull nonNullType) = show nonNullType -- | Represents type names. type NamedType = Name @@ -290,7 +354,11 @@ type NamedType = Name data NonNullType = NonNullTypeNamed Name | NonNullTypeList Type - deriving (Eq, Show) + deriving Eq + +instance Show NonNullType where + show (NonNullTypeNamed typeName) = '!' : Text.unpack typeName + show (NonNullTypeList listType) = concat ["![", show listType, "]"] -- ** Directives diff --git a/src/Language/GraphQL/AST/Encoder.hs b/src/Language/GraphQL/AST/Encoder.hs index 9ba51b8..f04f385 100644 --- a/src/Language/GraphQL/AST/Encoder.hs +++ b/src/Language/GraphQL/AST/Encoder.hs @@ -16,7 +16,6 @@ module Language.GraphQL.AST.Encoder , value ) where -import Data.Char (ord) import Data.Foldable (fold) import qualified Data.List.NonEmpty as NonEmpty import Data.Text (Text) @@ -25,7 +24,7 @@ import qualified Data.Text.Lazy as Lazy (Text) import qualified Data.Text.Lazy as Lazy.Text import Data.Text.Lazy.Builder (Builder) import qualified Data.Text.Lazy.Builder as Builder -import Data.Text.Lazy.Builder.Int (decimal, hexadecimal) +import Data.Text.Lazy.Builder.Int (decimal) import Data.Text.Lazy.Builder.RealFloat (realFloat) import qualified Language.GraphQL.AST.Document as Full @@ -234,11 +233,12 @@ quote :: Builder.Builder quote = Builder.singleton '\"' oneLine :: Text -> Builder -oneLine string = quote <> Text.foldr (mappend . escape) quote string +oneLine string = quote <> Text.foldr merge quote string + where + merge = mappend . Builder.fromString . Full.escape stringValue :: Formatter -> Text -> Lazy.Text -stringValue Minified string = Builder.toLazyText - $ quote <> Text.foldr (mappend . escape) quote string +stringValue Minified string = Builder.toLazyText $ oneLine string stringValue (Pretty indentation) string = if hasEscaped string then stringValue Minified string @@ -266,21 +266,6 @@ stringValue (Pretty indentation) string = = Builder.fromLazyText (indent (indentation + 1)) <> line' <> newline <> acc -escape :: Char -> Builder -escape char' - | char' == '\\' = Builder.fromString "\\\\" - | char' == '\"' = Builder.fromString "\\\"" - | char' == '\b' = Builder.fromString "\\b" - | char' == '\f' = Builder.fromString "\\f" - | char' == '\n' = Builder.fromString "\\n" - | char' == '\r' = Builder.fromString "\\r" - | char' == '\t' = Builder.fromString "\\t" - | char' < '\x0010' = unicode "\\u000" char' - | char' < '\x0020' = unicode "\\u00" char' - | otherwise = Builder.singleton char' - where - unicode prefix = mappend (Builder.fromString prefix) . (hexadecimal . ord) - listValue :: Formatter -> [Full.Value] -> Lazy.Text listValue formatter = bracketsCommas formatter $ value formatter diff --git a/src/Language/GraphQL/Type/Definition.hs b/src/Language/GraphQL/Type/Definition.hs index 476fb3a..6ca77aa 100644 --- a/src/Language/GraphQL/Type/Definition.hs +++ b/src/Language/GraphQL/Type/Definition.hs @@ -22,6 +22,7 @@ import Data.HashMap.Strict (HashMap) import qualified Data.HashMap.Strict as HashMap import Data.String (IsString(..)) import Data.Text (Text) +import qualified Data.Text as Text import Language.GraphQL.AST (Name) import Prelude hiding (id) @@ -63,6 +64,9 @@ data ScalarType = ScalarType Name (Maybe Text) instance Eq ScalarType where (ScalarType this _) == (ScalarType that _) = this == that +instance Show ScalarType where + show (ScalarType typeName _) = Text.unpack typeName + -- | Enum type definition. -- -- Some leaf values of requests and input values are Enums. GraphQL serializes @@ -73,6 +77,9 @@ data EnumType = EnumType Name (Maybe Text) (HashMap Name EnumValue) instance Eq EnumType where (EnumType this _ _) == (EnumType that _ _) = this == that +instance Show EnumType where + show (EnumType typeName _ _) = Text.unpack typeName + -- | Enum value is a single member of an 'EnumType'. newtype EnumValue = EnumValue (Maybe Text) diff --git a/src/Language/GraphQL/Type/In.hs b/src/Language/GraphQL/Type/In.hs index 59a6d59..d42599b 100644 --- a/src/Language/GraphQL/Type/In.hs +++ b/src/Language/GraphQL/Type/In.hs @@ -24,6 +24,7 @@ module Language.GraphQL.Type.In import Data.HashMap.Strict (HashMap) import Data.Text (Text) +import qualified Data.Text as Text import Language.GraphQL.AST.Document (Name) import qualified Language.GraphQL.Type.Definition as Definition @@ -40,6 +41,9 @@ data InputObjectType = InputObjectType instance Eq InputObjectType where (InputObjectType this _ _) == (InputObjectType that _ _) = this == that +instance Show InputObjectType where + show (InputObjectType typeName _ _) = Text.unpack typeName + -- | These types may be used as input types for arguments and directives. -- -- GraphQL distinguishes between "wrapping" and "named" types. Each wrapping @@ -56,6 +60,16 @@ data Type | NonNullListType Type deriving Eq +instance Show Type where + show (NamedScalarType scalarType) = show scalarType + show (NamedEnumType enumType) = show enumType + show (NamedInputObjectType inputObjectType) = show inputObjectType + show (ListType baseType) = concat ["[", show baseType, "]"] + show (NonNullScalarType scalarType) = '!' : show scalarType + show (NonNullEnumType enumType) = '!' : show enumType + show (NonNullInputObjectType inputObjectType) = '!' : show inputObjectType + show (NonNullListType baseType) = concat ["![", show baseType, "]"] + -- | Field argument definition. data Argument = Argument (Maybe Text) Type (Maybe Definition.Value) diff --git a/src/Language/GraphQL/Type/Internal.hs b/src/Language/GraphQL/Type/Internal.hs index eb8489c..2081b97 100644 --- a/src/Language/GraphQL/Type/Internal.hs +++ b/src/Language/GraphQL/Type/Internal.hs @@ -14,11 +14,14 @@ module Language.GraphQL.Type.Internal , Type(..) , directives , doesFragmentTypeApply + , implementations , instanceOf + , lookupCompositeField , lookupInputType , lookupTypeCondition , lookupTypeField , mutation + , outToComposite , subscription , query , types @@ -62,26 +65,31 @@ data Schema m = Schema (Maybe (Out.ObjectType m)) Directives (HashMap Full.Name (Type m)) + (HashMap Full.Name [Type m]) -- | Schema query type. query :: forall m. Schema m -> Out.ObjectType m -query (Schema query' _ _ _ _) = query' +query (Schema query' _ _ _ _ _) = query' -- | Schema mutation type. mutation :: forall m. Schema m -> Maybe (Out.ObjectType m) -mutation (Schema _ mutation' _ _ _) = mutation' +mutation (Schema _ mutation' _ _ _ _) = mutation' -- | Schema subscription type. subscription :: forall m. Schema m -> Maybe (Out.ObjectType m) -subscription (Schema _ _ subscription' _ _) = subscription' +subscription (Schema _ _ subscription' _ _ _) = subscription' -- | Schema directive definitions. directives :: forall m. Schema m -> Directives -directives (Schema _ _ _ directives' _) = directives' +directives (Schema _ _ _ directives' _ _) = directives' -- | Types referenced by the schema. types :: forall m. Schema m -> HashMap Full.Name (Type m) -types (Schema _ _ _ _ types') = types' +types (Schema _ _ _ _ types' _) = types' + +-- | Interface implementations. +implementations :: forall m. Schema m -> HashMap Full.Name [Type m] +implementations (Schema _ _ _ _ _ implementations') = implementations' -- | These types may describe the parent context of a selection set. data CompositeType m @@ -160,12 +168,16 @@ lookupInputType (Full.TypeNonNull (Full.NonNullTypeList nonNull)) types' <$> lookupInputType nonNull types' lookupTypeField :: forall a. Full.Name -> Out.Type a -> Maybe (Out.Field a) -lookupTypeField fieldName = \case - Out.ObjectBaseType objectType -> - objectChild objectType - Out.InterfaceBaseType interfaceType -> - interfaceChild interfaceType - Out.ListBaseType listType -> lookupTypeField fieldName listType +lookupTypeField fieldName outputType = + outToComposite outputType >>= lookupCompositeField fieldName + +lookupCompositeField :: forall a + . Full.Name + -> CompositeType a + -> Maybe (Out.Field a) +lookupCompositeField fieldName = \case + CompositeObjectType objectType -> objectChild objectType + CompositeInterfaceType interfaceType -> interfaceChild interfaceType _ -> Nothing where objectChild (Out.ObjectType _ _ _ resolvers) = @@ -174,3 +186,12 @@ lookupTypeField fieldName = \case HashMap.lookup fieldName fields resolverType (Out.ValueResolver objectField _) = objectField resolverType (Out.EventStreamResolver objectField _ _) = objectField + +outToComposite :: forall a. Out.Type a -> Maybe (CompositeType a) +outToComposite = \case + Out.ObjectBaseType objectType -> Just $ CompositeObjectType objectType + Out.InterfaceBaseType interfaceType -> + Just $ CompositeInterfaceType interfaceType + Out.UnionBaseType unionType -> Just $ CompositeUnionType unionType + Out.ListBaseType listType -> outToComposite listType + _ -> Nothing diff --git a/src/Language/GraphQL/Type/Out.hs b/src/Language/GraphQL/Type/Out.hs index b0668f5..847a8a5 100644 --- a/src/Language/GraphQL/Type/Out.hs +++ b/src/Language/GraphQL/Type/Out.hs @@ -38,6 +38,7 @@ import Data.HashMap.Strict (HashMap) import qualified Data.HashMap.Strict as HashMap import Data.Maybe (fromMaybe) import Data.Text (Text) +import qualified Data.Text as Text import Language.GraphQL.AST (Name) import Language.GraphQL.Type.Definition import qualified Language.GraphQL.Type.In as In @@ -52,6 +53,9 @@ data ObjectType m = ObjectType instance forall a. Eq (ObjectType a) where (ObjectType this _ _ _) == (ObjectType that _ _ _) = this == that +instance forall a. Show (ObjectType a) where + show (ObjectType typeName _ _ _) = Text.unpack typeName + -- | Interface Type Definition. -- -- When a field can return one of a heterogeneous set of types, a Interface type @@ -63,6 +67,9 @@ data InterfaceType m = InterfaceType instance forall a. Eq (InterfaceType a) where (InterfaceType this _ _ _) == (InterfaceType that _ _ _) = this == that +instance forall a. Show (InterfaceType a) where + show (InterfaceType typeName _ _ _) = Text.unpack typeName + -- | Union Type Definition. -- -- When a field can return one of a heterogeneous set of types, a Union type is @@ -72,6 +79,9 @@ data UnionType m = UnionType Name (Maybe Text) [ObjectType m] instance forall a. Eq (UnionType a) where (UnionType this _ _) == (UnionType that _ _) = this == that +instance forall a. Show (UnionType a) where + show (UnionType typeName _ _) = Text.unpack typeName + -- | Output object field definition. data Field m = Field (Maybe Text) -- ^ Description. @@ -98,6 +108,20 @@ data Type m | NonNullListType (Type m) deriving Eq +instance forall a. Show (Type a) where + show (NamedScalarType scalarType) = show scalarType + show (NamedEnumType enumType) = show enumType + show (NamedObjectType inputObjectType) = show inputObjectType + show (NamedInterfaceType interfaceType) = show interfaceType + show (NamedUnionType unionType) = show unionType + show (ListType baseType) = concat ["[", show baseType, "]"] + show (NonNullScalarType scalarType) = '!' : show scalarType + show (NonNullEnumType enumType) = '!' : show enumType + show (NonNullObjectType inputObjectType) = '!' : show inputObjectType + show (NonNullInterfaceType interfaceType) = '!' : show interfaceType + show (NonNullUnionType unionType) = '!' : show unionType + show (NonNullListType baseType) = concat ["![", show baseType, "]"] + -- | Matches either 'NamedScalarType' or 'NonNullScalarType'. pattern ScalarBaseType :: forall m. ScalarType -> Type m pattern ScalarBaseType scalarType <- (isScalarType -> Just scalarType) diff --git a/src/Language/GraphQL/Type/Schema.hs b/src/Language/GraphQL/Type/Schema.hs index 099c256..dae8e18 100644 --- a/src/Language/GraphQL/Type/Schema.hs +++ b/src/Language/GraphQL/Type/Schema.hs @@ -23,6 +23,7 @@ import Language.GraphQL.Type.Internal , Schema , Type(..) , directives + , implementations , mutation , subscription , query @@ -41,9 +42,11 @@ schema :: forall m -> Directives -- ^ Directive definitions. -> Schema m -- ^ Schema. schema queryRoot mutationRoot subscriptionRoot directiveDefinitions = - Internal.Schema queryRoot mutationRoot subscriptionRoot allDirectives collectedTypes + Internal.Schema queryRoot mutationRoot subscriptionRoot + allDirectives collectedTypes collectedImplementations where collectedTypes = collectReferencedTypes queryRoot mutationRoot subscriptionRoot + collectedImplementations = collectImplementations collectedTypes allDirectives = HashMap.union directiveDefinitions defaultDirectives defaultDirectives = HashMap.fromList [ ("skip", skipDirective) @@ -153,3 +156,20 @@ collectReferencedTypes queryRoot mutationRoot subscriptionRoot = polymorphicTraverser interfaces fields = flip (foldr visitFields) fields . flip (foldr traverseInterfaceType) interfaces + +-- | Looks for objects and interfaces under the schema types and collects the +-- interfaces they implement. +collectImplementations :: forall m + . HashMap Full.Name (Type m) + -> HashMap Full.Name [Type m] +collectImplementations = HashMap.foldr go HashMap.empty + where + go implementation@(InterfaceType interfaceType) accumulator = + let Out.InterfaceType _ _ interfaces _ = interfaceType + in foldr (add implementation) accumulator interfaces + go implementation@(ObjectType objectType) accumulator = + let Out.ObjectType _ _ interfaces _ = objectType + in foldr (add implementation) accumulator interfaces + go _ accumulator = accumulator + add implementation (Out.InterfaceType typeName _ _ _) accumulator = + HashMap.insertWith (++) typeName [implementation] accumulator diff --git a/src/Language/GraphQL/Validate.hs b/src/Language/GraphQL/Validate.hs index 277f84d..ea72018 100644 --- a/src/Language/GraphQL/Validate.hs +++ b/src/Language/GraphQL/Validate.hs @@ -210,7 +210,7 @@ typeDefinition context rule = \case directives context rule scalarLocation directives' Full.ObjectTypeDefinition _ _ _ directives' fields -> directives context rule objectLocation directives' - >< foldMap (fieldDefinition context rule) fields + >< foldMap (fieldDefinition context rule) fields Full.InterfaceTypeDefinition _ _ directives' fields -> directives context rule interfaceLocation directives' >< foldMap (fieldDefinition context rule) fields diff --git a/src/Language/GraphQL/Validate/Rules.hs b/src/Language/GraphQL/Validate/Rules.hs index c67df1c..71455d3 100644 --- a/src/Language/GraphQL/Validate/Rules.hs +++ b/src/Language/GraphQL/Validate/Rules.hs @@ -3,6 +3,7 @@ obtain one at https://mozilla.org/MPL/2.0/. -} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -24,6 +25,8 @@ module Language.GraphQL.Validate.Rules , noUndefinedVariablesRule , noUnusedFragmentsRule , noUnusedVariablesRule + , overlappingFieldsCanBeMergedRule + , possibleFragmentSpreadsRule , providedRequiredInputFieldsRule , providedRequiredArgumentsRule , scalarLeafsRule @@ -35,22 +38,24 @@ module Language.GraphQL.Validate.Rules , uniqueInputFieldNamesRule , uniqueOperationNamesRule , uniqueVariableNamesRule + , valuesOfCorrectTypeRule + , variablesInAllowedPositionRule , variablesAreInputTypesRule ) where import Control.Monad ((>=>), foldM) import Control.Monad.Trans.Class (MonadTrans(..)) -import Control.Monad.Trans.Reader (ReaderT(..), asks, mapReaderT) +import Control.Monad.Trans.Reader (ReaderT(..), ask, asks, mapReaderT) import Control.Monad.Trans.State (StateT, evalStateT, gets, modify) import Data.Bifunctor (first) -import Data.Foldable (find, toList) +import Data.Foldable (find, fold, foldl', toList) import qualified Data.HashMap.Strict as HashMap import Data.HashMap.Strict (HashMap) import Data.HashSet (HashSet) import qualified Data.HashSet as HashSet import Data.List (groupBy, sortBy, sortOn) -import Data.Maybe (isNothing, mapMaybe) -import Data.List.NonEmpty (NonEmpty) +import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) +import Data.List.NonEmpty (NonEmpty(..)) import Data.Ord (comparing) import Data.Sequence (Seq(..), (|>)) import qualified Data.Sequence as Seq @@ -80,6 +85,7 @@ specifiedRules = -- Fields , fieldsOnCorrectTypeRule , scalarLeafsRule + , overlappingFieldsCanBeMergedRule -- Arguments. , knownArgumentNamesRule , uniqueArgumentNamesRule @@ -91,7 +97,9 @@ specifiedRules = , noUnusedFragmentsRule , fragmentSpreadTargetDefinedRule , noFragmentCyclesRule + , possibleFragmentSpreadsRule -- Values + , valuesOfCorrectTypeRule , knownInputFieldNamesRule , uniqueInputFieldNamesRule , providedRequiredInputFieldsRule @@ -104,6 +112,7 @@ specifiedRules = , variablesAreInputTypesRule , noUndefinedVariablesRule , noUnusedVariablesRule + , variablesInAllowedPositionRule ] -- | Definition must be OperationDefinition or FragmentDefinition. @@ -320,10 +329,8 @@ fragmentSpreadTypeExistenceRule :: forall m. Rule m fragmentSpreadTypeExistenceRule = SelectionRule $ const $ \case Full.FragmentSpreadSelection fragmentSelection | Full.FragmentSpread fragmentName _ location' <- fragmentSelection -> do - ast' <- asks ast - let target = find (isSpreadTarget fragmentName) ast' - typeCondition <- lift $ maybeToSeq $ target >>= extractTypeCondition types' <- asks $ Schema.types . schema + typeCondition <- findSpreadTarget fragmentName case HashMap.lookup typeCondition types' of Nothing -> pure $ Error { message = spreadError fragmentName typeCondition @@ -342,10 +349,6 @@ fragmentSpreadTypeExistenceRule = SelectionRule $ const $ \case Just _ -> lift mempty _ -> lift mempty where - extractTypeCondition (viewFragment -> Just fragmentDefinition) = - let Full.FragmentDefinition _ typeCondition _ _ _ = fragmentDefinition - in Just typeCondition - extractTypeCondition _ = Nothing spreadError fragmentName typeCondition = concat [ "Fragment \"" , Text.unpack fragmentName @@ -451,8 +454,7 @@ filterSelections applyFilter selections noFragmentCyclesRule :: forall m. Rule m noFragmentCyclesRule = FragmentDefinitionRule $ \case Full.FragmentDefinition fragmentName _ _ selections location' -> do - state <- evalStateT (collectFields selections) - (0, fragmentName) + state <- evalStateT (collectCycles selections) (0, fragmentName) let spreadPath = fst <$> sortBy (comparing snd) (HashMap.toList state) case reverse spreadPath of x : _ | x == fragmentName -> pure $ Error @@ -467,10 +469,10 @@ noFragmentCyclesRule = FragmentDefinitionRule $ \case } _ -> lift mempty where - collectFields :: Traversable t + collectCycles :: Traversable t => t Full.Selection -> StateT (Int, Full.Name) (ReaderT (Validation m) Seq) (HashMap Full.Name Int) - collectFields selectionSet = foldM forEach HashMap.empty selectionSet + collectCycles selectionSet = foldM forEach HashMap.empty selectionSet forEach accumulator = \case Full.FieldSelection fieldSelection -> forField accumulator fieldSelection Full.InlineFragmentSelection fragmentSelection -> @@ -487,15 +489,15 @@ noFragmentCyclesRule = FragmentDefinitionRule $ \case then pure newAccumulator else collectFromSpread fragmentName newAccumulator forInline accumulator (Full.InlineFragment _ _ selections _) = - (accumulator <>) <$> collectFields selections + (accumulator <>) <$> collectCycles selections forField accumulator (Full.Field _ _ _ _ selections _) = - (accumulator <>) <$> collectFields selections + (accumulator <>) <$> collectCycles selections collectFromSpread fragmentName accumulator = do ast' <- lift $ asks ast case findFragmentDefinition fragmentName ast' of Nothing -> pure accumulator Just (Full.FragmentDefinition _ _ _ selections _) -> - (accumulator <>) <$> collectFields selections + (accumulator <>) <$> collectCycles selections findFragmentDefinition :: Text -> NonEmpty Full.Definition @@ -531,15 +533,22 @@ uniqueDirectiveNamesRule = DirectivesRule extract (Full.Directive directiveName _ location') = (directiveName, location') -filterDuplicates :: (a -> (Text, Full.Location)) -> String -> [a] -> Seq Error +groupSorted :: forall a. (a -> Text) -> [a] -> [[a]] +groupSorted getName = groupBy equalByName . sortOn getName + where + equalByName lhs rhs = getName lhs == getName rhs + +filterDuplicates :: forall a + . (a -> (Text, Full.Location)) + -> String + -> [a] + -> Seq Error filterDuplicates extract nodeType = Seq.fromList . fmap makeError . filter ((> 1) . length) - . groupBy equalByName - . sortOn getName + . groupSorted getName where getName = fst . extract - equalByName lhs rhs = getName lhs == getName rhs makeError directives' = Error { message = makeMessage $ head directives' , locations = snd . extract <$> directives' @@ -647,12 +656,9 @@ variableUsageDifference difference errorMessage = OperationDefinitionRule $ \cas lift $ lift $ mapArguments arguments <> mapDirectives directives' variableFilter (Full.FragmentSpreadSelection spread) | Full.FragmentSpread fragmentName _ _ <- spread = do - definitions <- lift $ asks ast - visited <- gets (HashSet.member fragmentName) - modify (HashSet.insert fragmentName) - case find (isSpreadTarget fragmentName) definitions of - Just (viewFragment -> Just fragmentDefinition) - | not visited -> diveIntoSpread fragmentDefinition + nonVisitedFragmentDefinition <- visitFragmentDefinition fragmentName + case nonVisitedFragmentDefinition of + Just fragmentDefinition -> diveIntoSpread fragmentDefinition _ -> lift $ lift mempty diveIntoSpread (Full.FragmentDefinition _ _ directives' selections _) = filterSelections' selections @@ -710,7 +716,7 @@ fieldsOnCorrectTypeRule = FieldRule fieldRule fieldRule parentType (Full.Field _ fieldName _ _ _ location') | Just objectType <- parentType , Nothing <- Type.lookupTypeField fieldName objectType - , Just typeName <- compositeTypeName objectType = pure $ Error + , Just typeName <- typeNameIfComposite objectType = pure $ Error { message = errorMessage fieldName typeName , locations = [location'] } @@ -723,20 +729,17 @@ fieldsOnCorrectTypeRule = FieldRule fieldRule , "\"." ] -compositeTypeName :: forall m. Out.Type m -> Maybe Full.Name -compositeTypeName (Out.ObjectBaseType (Out.ObjectType typeName _ _ _)) = - Just typeName -compositeTypeName (Out.InterfaceBaseType interfaceType) = +compositeTypeName :: forall m. Type.CompositeType m -> Full.Name +compositeTypeName (Type.CompositeObjectType (Out.ObjectType typeName _ _ _)) = + typeName +compositeTypeName (Type.CompositeInterfaceType interfaceType) = let Out.InterfaceType typeName _ _ _ = interfaceType - in Just typeName -compositeTypeName (Out.UnionBaseType (Out.UnionType typeName _ _)) = - Just typeName -compositeTypeName (Out.ScalarBaseType _) = - Nothing -compositeTypeName (Out.EnumBaseType _) = - Nothing -compositeTypeName (Out.ListBaseType wrappedType) = - compositeTypeName wrappedType + in typeName +compositeTypeName (Type.CompositeUnionType (Out.UnionType typeName _ _)) = + typeName + +typeNameIfComposite :: forall m. Out.Type m -> Maybe Full.Name +typeNameIfComposite = fmap compositeTypeName . Type.outToComposite -- | Field selections on scalars or enums are never allowed, because they are -- the leaf nodes of any GraphQL query. @@ -794,7 +797,7 @@ knownArgumentNamesRule = ArgumentsRule fieldRule directiveRule where fieldRule (Just objectType) (Full.Field _ fieldName arguments _ _ _) | Just typeField <- Type.lookupTypeField fieldName objectType - , Just typeName <- compositeTypeName objectType = + , Just typeName <- typeNameIfComposite objectType = lift $ foldr (go typeName fieldName typeField) Seq.empty arguments fieldRule _ _ = lift mempty go typeName fieldName fieldDefinition (Full.Argument argumentName _ location') errors @@ -1013,3 +1016,590 @@ providedRequiredInputFieldsRule = ValueRule go constGo , Text.unpack typeName , "\" is required, but it was not provided." ] + +-- | If multiple field selections with the same response names are encountered +-- during execution, the field and arguments to execute and the resulting value +-- should be unambiguous. Therefore any two field selections which might both be +-- encountered for the same object are only valid if they are equivalent. +-- +-- For simple hand‐written GraphQL, this rule is obviously a clear developer +-- error, however nested fragments can make this difficult to detect manually. +overlappingFieldsCanBeMergedRule :: Rule m +overlappingFieldsCanBeMergedRule = OperationDefinitionRule $ \case + Full.SelectionSet selectionSet _ -> do + schema' <- asks schema + go (toList selectionSet) + $ Type.CompositeObjectType + $ Schema.query schema' + Full.OperationDefinition operationType _ _ _ selectionSet _ -> do + schema' <- asks schema + let root = go (toList selectionSet) . Type.CompositeObjectType + case operationType of + Full.Query -> root $ Schema.query schema' + Full.Mutation + | Just objectType <- Schema.mutation schema' -> root objectType + Full.Subscription + | Just objectType <- Schema.mutation schema' -> root objectType + _ -> lift mempty + where + go selectionSet selectionType = do + fieldTuples <- evalStateT (collectFields selectionType selectionSet) HashSet.empty + fieldsInSetCanMerge fieldTuples + fieldsInSetCanMerge :: forall m + . HashMap Full.Name (NonEmpty (Full.Field, Type.CompositeType m)) + -> ReaderT (Validation m) Seq Error + fieldsInSetCanMerge fieldTuples = do + validation <- ask + let (lonely, paired) = flattenPairs fieldTuples + let reader = flip runReaderT validation + lift $ foldMap (reader . visitLonelyFields) lonely + <> foldMap (reader . forEachFieldTuple) paired + forEachFieldTuple :: forall m + . (FieldInfo m, FieldInfo m) + -> ReaderT (Validation m) Seq Error + forEachFieldTuple (fieldA, fieldB) = + case (parent fieldA, parent fieldB) of + (parentA@Type.CompositeObjectType{}, parentB@Type.CompositeObjectType{}) + | parentA /= parentB -> sameResponseShape fieldA fieldB + _ -> mapReaderT (checkEquality (node fieldA) (node fieldB)) + $ sameResponseShape fieldA fieldB + checkEquality fieldA fieldB Seq.Empty + | Full.Field _ fieldNameA _ _ _ _ <- fieldA + , Full.Field _ fieldNameB _ _ _ _ <- fieldB + , fieldNameA /= fieldNameB = pure $ makeError fieldA fieldB + | Full.Field _ fieldNameA argumentsA _ _ locationA <- fieldA + , Full.Field _ _ argumentsB _ _ locationB <- fieldB + , argumentsA /= argumentsB = + let message = concat + [ "Fields \"" + , Text.unpack fieldNameA + , "\" conflict because they have different arguments. Use " + , "different aliases on the fields to fetch both if this " + , "was intentional." + ] + in pure $ Error message [locationB, locationA] + checkEquality _ _ previousErrors = previousErrors + visitLonelyFields FieldInfo{..} = + let Full.Field _ _ _ _ subSelections _ = node + compositeFieldType = Type.outToComposite type' + in maybe (lift Seq.empty) (go subSelections) compositeFieldType + sameResponseShape :: forall m + . FieldInfo m + -> FieldInfo m + -> ReaderT (Validation m) Seq Error + sameResponseShape fieldA fieldB = + let Full.Field _ _ _ _ selectionsA _ = node fieldA + Full.Field _ _ _ _ selectionsB _ = node fieldB + in case unwrapTypes (type' fieldA) (type' fieldB) of + Left True -> lift mempty + Right (compositeA, compositeB) -> do + validation <- ask + let collectFields' composite = flip runReaderT validation + . flip evalStateT HashSet.empty + . collectFields composite + let collectA = collectFields' compositeA selectionsA + let collectB = collectFields' compositeB selectionsB + fieldsInSetCanMerge + $ foldl' (HashMap.unionWith (<>)) HashMap.empty + $ collectA <> collectB + _ -> pure $ makeError (node fieldA) (node fieldB) + makeError fieldA fieldB = + let Full.Field aliasA fieldNameA _ _ _ locationA = fieldA + Full.Field _ fieldNameB _ _ _ locationB = fieldB + message = concat + [ "Fields \"" + , Text.unpack (fromMaybe fieldNameA aliasA) + , "\" conflict because \"" + , Text.unpack fieldNameB + , "\" and \"" + , Text.unpack fieldNameA + , "\" are different fields. Use different aliases on the fields " + , "to fetch both if this was intentional." + ] + in Error message [locationB, locationA] + unwrapTypes typeA@Out.ScalarBaseType{} typeB@Out.ScalarBaseType{} = + Left $ typeA == typeB + unwrapTypes typeA@Out.EnumBaseType{} typeB@Out.EnumBaseType{} = + Left $ typeA == typeB + unwrapTypes (Out.ListType listA) (Out.ListType listB) = + unwrapTypes listA listB + unwrapTypes (Out.NonNullListType listA) (Out.NonNullListType listB) = + unwrapTypes listA listB + unwrapTypes typeA typeB + | Out.isNonNullType typeA == Out.isNonNullType typeB + , Just compositeA <- Type.outToComposite typeA + , Just compositeB <- Type.outToComposite typeB = + Right (compositeA, compositeB) + | otherwise = Left False + flattenPairs :: forall m + . HashMap Full.Name (NonEmpty (Full.Field, Type.CompositeType m)) + -> (Seq (FieldInfo m), Seq (FieldInfo m, FieldInfo m)) + flattenPairs xs = HashMap.foldr splitSingleFields (Seq.empty, Seq.empty) + $ foldr lookupTypeField [] <$> xs + splitSingleFields :: forall m + . [FieldInfo m] + -> (Seq (FieldInfo m), Seq (FieldInfo m, FieldInfo m)) + -> (Seq (FieldInfo m), Seq (FieldInfo m, FieldInfo m)) + splitSingleFields [head'] (fields, pairList) = (fields |> head', pairList) + splitSingleFields xs (fields, pairList) = (fields, pairs pairList xs) + lookupTypeField (field, parentType) accumulator = + let Full.Field _ fieldName _ _ _ _ = field + in case Type.lookupCompositeField fieldName parentType of + Nothing -> accumulator + Just (Out.Field _ typeField _) -> + FieldInfo field typeField parentType : accumulator + pairs :: forall m + . Seq (FieldInfo m, FieldInfo m) + -> [FieldInfo m] + -> Seq (FieldInfo m, FieldInfo m) + pairs accumulator [] = accumulator + pairs accumulator (fieldA : fields) = + pair fieldA (pairs accumulator fields) fields + pair _ accumulator [] = accumulator + pair field accumulator (fieldA : fields) = + pair field accumulator fields |> (field, fieldA) + collectFields objectType = accumulateFields objectType mempty + accumulateFields = foldM . forEach + forEach parentType accumulator = \case + Full.FieldSelection fieldSelection -> + forField parentType accumulator fieldSelection + Full.FragmentSpreadSelection fragmentSelection -> + forSpread accumulator fragmentSelection + Full.InlineFragmentSelection fragmentSelection -> + forInline parentType accumulator fragmentSelection + forField parentType accumulator field@(Full.Field alias fieldName _ _ _ _) = + let key = fromMaybe fieldName alias + value = (field, parentType) :| [] + in pure $ HashMap.insertWith (<>) key value accumulator + forSpread accumulator (Full.FragmentSpread fragmentName _ _) = do + inVisitetFragments <- gets $ HashSet.member fragmentName + if inVisitetFragments + then pure accumulator + else collectFromSpread fragmentName accumulator + forInline parentType accumulator = \case + Full.InlineFragment maybeType _ selections _ + | Just typeCondition <- maybeType -> + collectFromFragment typeCondition selections accumulator + | otherwise -> accumulateFields parentType accumulator $ toList selections + collectFromFragment typeCondition selectionSet' accumulator = do + types' <- lift $ asks $ Schema.types . schema + case Type.lookupTypeCondition typeCondition types' of + Nothing -> pure accumulator + Just compositeType -> + accumulateFields compositeType accumulator $ toList selectionSet' + collectFromSpread fragmentName accumulator = do + modify $ HashSet.insert fragmentName + ast' <- lift $ asks ast + case findFragmentDefinition fragmentName ast' of + Nothing -> pure accumulator + Just (Full.FragmentDefinition _ typeCondition _ selectionSet' _) -> + collectFromFragment typeCondition selectionSet' accumulator + +data FieldInfo m = FieldInfo + { node :: Full.Field + , type' :: Out.Type m + , parent :: Type.CompositeType m + } + +-- | Fragments are declared on a type and will only apply when the runtime +-- object type matches the type condition. They also are spread within the +-- context of a parent type. A fragment spread is only valid if its type +-- condition could ever apply within the parent type. +possibleFragmentSpreadsRule :: forall m. Rule m +possibleFragmentSpreadsRule = SelectionRule go + where + go (Just parentType) (Full.InlineFragmentSelection fragmentSelection) + | Full.InlineFragment maybeType _ _ location' <- fragmentSelection + , Just typeCondition <- maybeType = do + (fragmentTypeName, parentTypeName) <- + compareTypes typeCondition parentType + pure $ Error + { message = concat + [ "Fragment cannot be spread here as objects of type \"" + , Text.unpack parentTypeName + , "\" can never be of type \"" + , Text.unpack fragmentTypeName + , "\"." + ] + , locations = [location'] + } + go (Just parentType) (Full.FragmentSpreadSelection fragmentSelection) + | Full.FragmentSpread fragmentName _ location' <- fragmentSelection = do + typeCondition <- findSpreadTarget fragmentName + (fragmentTypeName, parentTypeName) <- + compareTypes typeCondition parentType + pure $ Error + { message = concat + [ "Fragment \"" + , Text.unpack fragmentName + , "\" cannot be spread here as objects of type \"" + , Text.unpack parentTypeName + , "\" can never be of type \"" + , Text.unpack fragmentTypeName + , "\"." + ] + , locations = [location'] + } + go _ _ = lift mempty + compareTypes typeCondition parentType = do + types' <- asks $ Schema.types . schema + fragmentType <- lift + $ maybeToSeq + $ Type.lookupTypeCondition typeCondition types' + parentComposite <- lift + $ maybeToSeq + $ Type.outToComposite parentType + possibleFragments <- getPossibleTypes fragmentType + possibleParents <- getPossibleTypes parentComposite + let fragmentTypeName = compositeTypeName fragmentType + let parentTypeName = compositeTypeName parentComposite + if HashSet.null $ HashSet.intersection possibleFragments possibleParents + then pure (fragmentTypeName, parentTypeName) + else lift mempty + getPossibleTypeList (Type.CompositeObjectType objectType) = + pure [Schema.ObjectType objectType] + getPossibleTypeList (Type.CompositeUnionType unionType) = + let Out.UnionType _ _ members = unionType + in pure $ Schema.ObjectType <$> members + getPossibleTypeList (Type.CompositeInterfaceType interfaceType) = + let Out.InterfaceType typeName _ _ _ = interfaceType + in HashMap.lookupDefault [] typeName + <$> asks (Schema.implementations . schema) + getPossibleTypes compositeType + = foldr (HashSet.insert . internalTypeName) HashSet.empty + <$> getPossibleTypeList compositeType + +internalTypeName :: forall m. Schema.Type m -> Full.Name +internalTypeName (Schema.ScalarType (Definition.ScalarType typeName _)) = + typeName +internalTypeName (Schema.EnumType (Definition.EnumType typeName _ _)) = typeName +internalTypeName (Schema.ObjectType (Out.ObjectType typeName _ _ _)) = typeName +internalTypeName (Schema.InputObjectType (In.InputObjectType typeName _ _)) = + typeName +internalTypeName (Schema.InterfaceType (Out.InterfaceType typeName _ _ _)) = + typeName +internalTypeName (Schema.UnionType (Out.UnionType typeName _ _)) = typeName + +findSpreadTarget :: Full.Name -> ReaderT (Validation m1) Seq Full.TypeCondition +findSpreadTarget fragmentName = do + ast' <- asks ast + let target = find (isSpreadTarget fragmentName) ast' + lift $ maybeToSeq $ target >>= extractTypeCondition + where + extractTypeCondition (viewFragment -> Just fragmentDefinition) = + let Full.FragmentDefinition _ typeCondition _ _ _ = fragmentDefinition + in Just typeCondition + extractTypeCondition _ = Nothing + +visitFragmentDefinition :: forall m + . Text + -> ValidationState m (Maybe Full.FragmentDefinition) +visitFragmentDefinition fragmentName = do + definitions <- lift $ asks ast + visited <- gets (HashSet.member fragmentName) + modify (HashSet.insert fragmentName) + case find (isSpreadTarget fragmentName) definitions of + Just (viewFragment -> Just fragmentDefinition) + | not visited -> pure $ Just fragmentDefinition + _ -> pure Nothing + +-- | Variable usages must be compatible with the arguments they are passed to. +-- +-- Validation failures occur when variables are used in the context of types +-- that are complete mismatches, or if a nullable type in a variable is passed +-- to a non‐null argument type. +variablesInAllowedPositionRule :: forall m. Rule m +variablesInAllowedPositionRule = OperationDefinitionRule $ \case + Full.OperationDefinition operationType _ variables _ selectionSet _ -> do + schema' <- asks schema + let root = go variables (toList selectionSet) . Type.CompositeObjectType + case operationType of + Full.Query -> root $ Schema.query schema' + Full.Mutation + | Just objectType <- Schema.mutation schema' -> root objectType + Full.Subscription + | Just objectType <- Schema.mutation schema' -> root objectType + _ -> lift mempty + _ -> lift mempty + where + go variables selections selectionType = mapReaderT (foldr (<>) Seq.empty) + $ flip evalStateT HashSet.empty + $ visitSelectionSet variables selectionType + $ toList selections + visitSelectionSet :: Foldable t + => [Full.VariableDefinition] + -> Type.CompositeType m + -> t Full.Selection + -> ValidationState m (Seq Error) + visitSelectionSet variables selectionType selections = + foldM (evaluateSelection variables selectionType) mempty selections + evaluateFieldSelection variables selections accumulator = \case + Just newParentType -> do + let folder = evaluateSelection variables newParentType + selectionErrors <- foldM folder accumulator selections + pure $ accumulator <> selectionErrors + Nothing -> pure accumulator + evaluateSelection :: [Full.VariableDefinition] + -> Type.CompositeType m + -> Seq Error + -> Full.Selection + -> ValidationState m (Seq Error) + evaluateSelection variables selectionType accumulator selection + | Full.FragmentSpreadSelection spread <- selection + , Full.FragmentSpread fragmentName _ _ <- spread = do + types' <- lift $ asks $ Schema.types . schema + nonVisitedFragmentDefinition <- visitFragmentDefinition fragmentName + case nonVisitedFragmentDefinition of + Just fragmentDefinition + | Full.FragmentDefinition _ typeCondition _ _ _ <- fragmentDefinition + , Just spreadType <- Type.lookupTypeCondition typeCondition types' -> do + spreadErrors <- spreadVariables variables spread + selectionErrors <- diveIntoSpread variables spreadType fragmentDefinition + pure $ accumulator <> spreadErrors <> selectionErrors + _ -> lift $ lift mempty + | Full.FieldSelection fieldSelection <- selection + , Full.Field _ fieldName _ _ subselections _ <- fieldSelection = + case Type.lookupCompositeField fieldName selectionType of + Just (Out.Field _ typeField argumentTypes) -> do + fieldErrors <- fieldVariables variables argumentTypes fieldSelection + selectionErrors <- evaluateFieldSelection variables subselections accumulator + $ Type.outToComposite typeField + pure $ selectionErrors <> fieldErrors + Nothing -> pure accumulator + | Full.InlineFragmentSelection inlineSelection <- selection + , Full.InlineFragment typeCondition _ subselections _ <- inlineSelection = do + types' <- lift $ asks $ Schema.types . schema + let inlineType = fromMaybe selectionType + $ typeCondition >>= flip Type.lookupTypeCondition types' + fragmentErrors <- inlineVariables variables inlineSelection + let folder = evaluateSelection variables inlineType + selectionErrors <- foldM folder accumulator subselections + pure $ accumulator <> fragmentErrors <> selectionErrors + inlineVariables variables inline + | Full.InlineFragment _ directives' _ _ <- inline = + mapDirectives variables directives' + fieldVariables :: [Full.VariableDefinition] + -> In.Arguments + -> Full.Field + -> ValidationState m (Seq Error) + fieldVariables variables argumentTypes fieldSelection = do + let Full.Field _ _ arguments directives' _ _ = fieldSelection + argumentErrors <- mapArguments variables argumentTypes arguments + directiveErrors <- mapDirectives variables directives' + pure $ argumentErrors <> directiveErrors + spreadVariables variables (Full.FragmentSpread _ directives' _) = + mapDirectives variables directives' + diveIntoSpread variables fieldType fragmentDefinition = do + let Full.FragmentDefinition _ _ directives' selections _ = + fragmentDefinition + selectionErrors <- visitSelectionSet variables fieldType selections + directiveErrors <- mapDirectives variables directives' + pure $ selectionErrors <> directiveErrors + findDirectiveVariables variables directive = do + let Full.Directive directiveName arguments _ = directive + directiveDefinitions <- lift $ asks $ Schema.directives . schema + case HashMap.lookup directiveName directiveDefinitions of + Just (Schema.Directive _ _ directiveArguments) -> + mapArguments variables directiveArguments arguments + Nothing -> pure mempty + mapArguments variables argumentTypes = fmap fold + . traverse (findArgumentVariables variables argumentTypes) + mapDirectives variables = fmap fold + <$> traverse (findDirectiveVariables variables) + lookupInputObject variables objectFieldValue locationInfo + | Full.Node{ node = Full.Object objectFields } <- objectFieldValue + , Just (expectedType, _) <- locationInfo + , In.InputObjectBaseType inputObjectType <- expectedType + , In.InputObjectType _ _ fieldTypes' <- inputObjectType = + fold <$> traverse (traverseObjectField variables fieldTypes') objectFields + | otherwise = pure mempty + maybeUsageAllowed variableName variables locationInfo + | Just (locationType, locationValue) <- locationInfo + , findVariableDefinition' <- findVariableDefinition variableName + , Just variableDefinition <- find findVariableDefinition' variables + = maybeToSeq + <$> isVariableUsageAllowed locationType locationValue variableDefinition + | otherwise = pure mempty + findArgumentVariables :: [Full.VariableDefinition] + -> HashMap Full.Name In.Argument + -> Full.Argument + -> ValidationState m (Seq Error) + findArgumentVariables variables argumentTypes argument + | Full.Argument argumentName argumentValue _ <- argument + , Full.Node{ node = Full.Variable variableName } <- argumentValue + = maybeUsageAllowed variableName variables + $ locationPair extractArgument argumentTypes argumentName + | Full.Argument argumentName argumentValue _ <- argument + = lookupInputObject variables argumentValue + $ locationPair extractArgument argumentTypes argumentName + extractField (In.InputField _ locationType locationValue) = + (locationType, locationValue) + extractArgument (In.Argument _ locationType locationValue) = + (locationType, locationValue) + locationPair extract fieldTypes name = + extract <$> HashMap.lookup name fieldTypes + traverseObjectField variables fieldTypes Full.ObjectField{..} + | Full.Node{ node = Full.Variable variableName } <- value + = maybeUsageAllowed variableName variables + $ locationPair extractField fieldTypes name + | otherwise = lookupInputObject variables value + $ locationPair extractField fieldTypes name + findVariableDefinition variableName variableDefinition = + let Full.VariableDefinition variableName' _ _ _ = variableDefinition + in variableName == variableName' + isVariableUsageAllowed locationType locationDefaultValue variableDefinition + | Full.VariableDefinition _ variableType _ _ <- variableDefinition + , Full.TypeNonNull _ <- variableType = + typesCompatibleOrError variableDefinition locationType + | Just nullableLocationType <- unwrapInType locationType + , Full.VariableDefinition _ variableType variableDefaultValue _ <- + variableDefinition + , hasNonNullVariableDefaultValue' <- + hasNonNullVariableDefaultValue variableDefaultValue + , hasLocationDefaultValue <- isJust locationDefaultValue = + if (hasNonNullVariableDefaultValue' || hasLocationDefaultValue) + && areTypesCompatible variableType nullableLocationType + then pure Nothing + else pure $ makeError variableDefinition locationType + | otherwise = typesCompatibleOrError variableDefinition locationType + typesCompatibleOrError variableDefinition locationType + | Full.VariableDefinition _ variableType _ _ <- variableDefinition + , areTypesCompatible variableType locationType = pure Nothing + | otherwise = pure $ makeError variableDefinition locationType + areTypesCompatible nonNullType (unwrapInType -> Just nullableLocationType) + | Full.TypeNonNull (Full.NonNullTypeNamed namedType) <- nonNullType = + areTypesCompatible (Full.TypeNamed namedType) nullableLocationType + | Full.TypeNonNull (Full.NonNullTypeList namedList) <- nonNullType = + areTypesCompatible (Full.TypeList namedList) nullableLocationType + areTypesCompatible _ (In.isNonNullType -> True) = False + areTypesCompatible (Full.TypeNonNull nonNullType) locationType + | Full.NonNullTypeNamed namedType <- nonNullType = + areTypesCompatible (Full.TypeNamed namedType) locationType + | Full.NonNullTypeList namedType <- nonNullType = + areTypesCompatible (Full.TypeList namedType) locationType + areTypesCompatible variableType locationType + | Full.TypeList itemVariableType <- variableType + , In.ListType itemLocationType <- locationType = + areTypesCompatible itemVariableType itemLocationType + | areIdentical variableType locationType = True + | otherwise = False + areIdentical (Full.TypeList typeList) (In.ListType itemLocationType) = + areIdentical typeList itemLocationType + areIdentical (Full.TypeNonNull nonNullType) locationType + | Full.NonNullTypeList nonNullList <- nonNullType + , In.NonNullListType itemLocationType <- locationType = + areIdentical nonNullList itemLocationType + | Full.NonNullTypeNamed _ <- nonNullType + , In.ListBaseType _ <- locationType = False + | Full.NonNullTypeNamed nonNullList <- nonNullType + , In.isNonNullType locationType = + nonNullList == inputTypeName locationType + areIdentical (Full.TypeNamed _) (In.ListBaseType _) = False + areIdentical (Full.TypeNamed typeNamed) locationType + | not $ In.isNonNullType locationType = + typeNamed == inputTypeName locationType + areIdentical _ _ = False + hasNonNullVariableDefaultValue (Just (Full.Node Full.ConstNull _)) = False + hasNonNullVariableDefaultValue Nothing = False + hasNonNullVariableDefaultValue _ = True + unwrapInType (In.NonNullScalarType nonNullType) = + Just $ In.NamedScalarType nonNullType + unwrapInType (In.NonNullEnumType nonNullType) = + Just $ In.NamedEnumType nonNullType + unwrapInType (In.NonNullInputObjectType nonNullType) = + Just $ In.NamedInputObjectType nonNullType + unwrapInType (In.NonNullListType nonNullType) = + Just $ In.ListType nonNullType + unwrapInType _ = Nothing + makeError variableDefinition expectedType = + let Full.VariableDefinition variableName variableType _ location' = + variableDefinition + in Just $ Error + { message = concat + [ "Variable \"$" + , Text.unpack variableName + , "\" of type \"" + , show variableType + , "\" used in position expecting type \"" + , show expectedType + , "\"." + ] + , locations = [location'] + } + +-- | Literal values must be compatible with the type expected in the position +-- they are found as per the coercion rules. +-- +-- The type expected in a position include the type defined by the argument a +-- value is provided for, the type defined by an input object field a value is +-- provided for, and the type of a variable definition a default value is +-- provided for. +valuesOfCorrectTypeRule :: forall m. Rule m +valuesOfCorrectTypeRule = ValueRule go constGo + where + go (Just inputType) value + | Just constValue <- toConstNode value = + lift $ check inputType constValue + go _ _ = lift mempty + toConstNode Full.Node{..} = flip Full.Node location <$> toConst node + toConst (Full.Variable _) = Nothing + toConst (Full.Int integer) = Just $ Full.ConstInt integer + toConst (Full.Float double) = Just $ Full.ConstFloat double + toConst (Full.String string) = Just $ Full.ConstString string + toConst (Full.Boolean boolean) = Just $ Full.ConstBoolean boolean + toConst Full.Null = Just Full.ConstNull + toConst (Full.Enum enum) = Just $ Full.ConstEnum enum + toConst (Full.List values) = + Just $ Full.ConstList $ catMaybes $ toConst <$> values + toConst (Full.Object fields) = Just $ Full.ConstObject + $ catMaybes $ constObjectField <$> fields + constObjectField Full.ObjectField{..} + | Just constValue <- toConstNode value = + Just $ Full.ObjectField name constValue location + | otherwise = Nothing + constGo Nothing = const $ lift mempty + constGo (Just inputType) = lift . check inputType + check :: In.Type -> Full.Node Full.ConstValue -> Seq Error + check _ Full.Node{ node = Full.ConstNull } = + mempty -- Ignore, required fields are checked elsewhere. + check (In.ScalarBaseType scalarType) Full.Node{ node } + | Definition.ScalarType "Int" _ <- scalarType + , Full.ConstInt _ <- node = mempty + | Definition.ScalarType "Boolean" _ <- scalarType + , Full.ConstBoolean _ <- node = mempty + | Definition.ScalarType "String" _ <- scalarType + , Full.ConstString _ <- node = mempty + | Definition.ScalarType "ID" _ <- scalarType + , Full.ConstString _ <- node = mempty + | Definition.ScalarType "ID" _ <- scalarType + , Full.ConstInt _ <- node = mempty + | Definition.ScalarType "Float" _ <- scalarType + , Full.ConstFloat _ <- node = mempty + | Definition.ScalarType "Float" _ <- scalarType + , Full.ConstInt _ <- node = mempty + check (In.EnumBaseType enumType) Full.Node{ node } + | Definition.EnumType _ _ members <- enumType + , Full.ConstEnum memberValue <- node + , HashMap.member memberValue members = mempty + check (In.InputObjectBaseType objectType) Full.Node{ node } + | In.InputObjectType _ _ typeFields <- objectType + , Full.ConstObject valueFields <- node = + foldMap (checkObjectField typeFields) valueFields + check (In.ListBaseType listType) constValue@Full.Node{ .. } + | Full.ConstList listValues <- node = + foldMap (check listType) $ flip Full.Node location <$> listValues + | otherwise = check listType constValue + check inputType Full.Node{ .. } = pure $ Error + { message = concat + [ "Value " + , show node, " cannot be coerced to type \"" + , show inputType + , "\"." + ] + , locations = [location] + } + checkObjectField typeFields Full.ObjectField{..} + | Just typeField <- HashMap.lookup name typeFields + , In.InputField _ fieldType _ <- typeField = + check fieldType value + checkObjectField _ _ = mempty @@ -1,4 +1,4 @@ -resolver: lts-16.20 +resolver: lts-17.2 packages: - . diff --git a/tests/Language/GraphQL/AST/DocumentSpec.hs b/tests/Language/GraphQL/AST/DocumentSpec.hs new file mode 100644 index 0000000..ca13e17 --- /dev/null +++ b/tests/Language/GraphQL/AST/DocumentSpec.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE OverloadedStrings #-} +module Language.GraphQL.AST.DocumentSpec + ( spec + ) where + +import Language.GraphQL.AST.Document +import Test.Hspec (Spec, describe, it, shouldBe) + +spec :: Spec +spec = do + describe "Document" $ do + it "shows objects" $ + let zero = Location 0 0 + object = ConstObject + [ ObjectField "field1" (Node (ConstFloat 1.2) zero) zero + , ObjectField "field2" (Node ConstNull zero) zero + ] + expected = "{ field1: 1.2, field2: null }" + in show object `shouldBe` expected diff --git a/tests/Language/GraphQL/Validate/RulesSpec.hs b/tests/Language/GraphQL/Validate/RulesSpec.hs new file mode 100644 index 0000000..6f90436 --- /dev/null +++ b/tests/Language/GraphQL/Validate/RulesSpec.hs @@ -0,0 +1,914 @@ +{- This Source Code Form is subject to the terms of the Mozilla Public License, + v. 2.0. If a copy of the MPL was not distributed with this file, You can + obtain one at https://mozilla.org/MPL/2.0/. -} + +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Language.GraphQL.Validate.RulesSpec + ( spec + ) where + +import Data.Foldable (toList) +import qualified Data.HashMap.Strict as HashMap +import Data.Text (Text) +import qualified Language.GraphQL.AST as AST +import Language.GraphQL.Type +import qualified Language.GraphQL.Type.In as In +import qualified Language.GraphQL.Type.Out as Out +import Language.GraphQL.Validate +import Test.Hspec (Spec, context, describe, it, shouldBe, shouldContain) +import Text.Megaparsec (parse, errorBundlePretty) +import Text.RawString.QQ (r) + +petSchema :: Schema IO +petSchema = schema queryType Nothing (Just subscriptionType) mempty + +queryType :: ObjectType IO +queryType = ObjectType "Query" Nothing [] $ HashMap.fromList + [ ("dog", dogResolver) + , ("cat", catResolver) + , ("findDog", findDogResolver) + ] + where + dogField = Field Nothing (Out.NamedObjectType dogType) mempty + dogResolver = ValueResolver dogField $ pure Null + findDogArguments = HashMap.singleton "complex" + $ In.Argument Nothing (In.NonNullInputObjectType dogDataType) Nothing + findDogField = Field Nothing (Out.NamedObjectType dogType) findDogArguments + findDogResolver = ValueResolver findDogField $ pure Null + catField = Field Nothing (Out.NamedObjectType catType) mempty + catResolver = ValueResolver catField $ pure Null + +catCommandType :: EnumType +catCommandType = EnumType "CatCommand" Nothing $ HashMap.fromList + [ ("JUMP", EnumValue Nothing) + ] + +catType :: ObjectType IO +catType = ObjectType "Cat" Nothing [petType] $ HashMap.fromList + [ ("name", nameResolver) + , ("nickname", nicknameResolver) + , ("doesKnowCommand", doesKnowCommandResolver) + , ("meowVolume", meowVolumeResolver) + ] + where + meowVolumeField = Field Nothing (Out.NamedScalarType int) mempty + meowVolumeResolver = ValueResolver meowVolumeField $ pure $ Int 3 + doesKnowCommandField = Field Nothing (Out.NonNullScalarType boolean) + $ HashMap.singleton "catCommand" + $ In.Argument Nothing (In.NonNullEnumType catCommandType) Nothing + doesKnowCommandResolver = ValueResolver doesKnowCommandField + $ pure $ Boolean True + +nameResolver :: Resolver IO +nameResolver = ValueResolver nameField $ pure "Name" + where + nameField = Field Nothing (Out.NonNullScalarType string) mempty + +nicknameResolver :: Resolver IO +nicknameResolver = ValueResolver nicknameField $ pure "Nickname" + where + nicknameField = Field Nothing (Out.NamedScalarType string) mempty + +dogCommandType :: EnumType +dogCommandType = EnumType "DogCommand" Nothing $ HashMap.fromList + [ ("SIT", EnumValue Nothing) + , ("DOWN", EnumValue Nothing) + , ("HEEL", EnumValue Nothing) + ] + +dogType :: ObjectType IO +dogType = ObjectType "Dog" Nothing [petType] $ HashMap.fromList + [ ("name", nameResolver) + , ("nickname", nicknameResolver) + , ("barkVolume", barkVolumeResolver) + , ("doesKnowCommand", doesKnowCommandResolver) + , ("isHousetrained", isHousetrainedResolver) + , ("owner", ownerResolver) + ] + where + barkVolumeField = Field Nothing (Out.NamedScalarType int) mempty + barkVolumeResolver = ValueResolver barkVolumeField $ pure $ Int 3 + doesKnowCommandField = Field Nothing (Out.NonNullScalarType boolean) + $ HashMap.singleton "dogCommand" + $ In.Argument Nothing (In.NonNullEnumType dogCommandType) Nothing + doesKnowCommandResolver = ValueResolver doesKnowCommandField + $ pure $ Boolean True + isHousetrainedField = Field Nothing (Out.NonNullScalarType boolean) + $ HashMap.singleton "atOtherHomes" + $ In.Argument Nothing (In.NamedScalarType boolean) Nothing + isHousetrainedResolver = ValueResolver isHousetrainedField + $ pure $ Boolean True + ownerField = Field Nothing (Out.NamedObjectType humanType) mempty + ownerResolver = ValueResolver ownerField $ pure Null + +dogDataType :: InputObjectType +dogDataType = InputObjectType "DogData" Nothing + $ HashMap.singleton "name" nameInputField + where + nameInputField = InputField Nothing (In.NonNullScalarType string) Nothing + +sentientType :: InterfaceType IO +sentientType = InterfaceType "Sentient" Nothing [] + $ HashMap.singleton "name" + $ Field Nothing (Out.NonNullScalarType string) mempty + +petType :: InterfaceType IO +petType = InterfaceType "Pet" Nothing [] + $ HashMap.singleton "name" + $ Field Nothing (Out.NonNullScalarType string) mempty + +subscriptionType :: ObjectType IO +subscriptionType = ObjectType "Subscription" Nothing [] $ HashMap.fromList + [ ("newMessage", newMessageResolver) + , ("disallowedSecondRootField", newMessageResolver) + ] + where + newMessageField = Field Nothing (Out.NonNullObjectType messageType) mempty + newMessageResolver = ValueResolver newMessageField + $ pure $ Object HashMap.empty + +messageType :: ObjectType IO +messageType = ObjectType "Message" Nothing [] $ HashMap.fromList + [ ("sender", senderResolver) + , ("body", bodyResolver) + ] + where + senderField = Field Nothing (Out.NonNullScalarType string) mempty + senderResolver = ValueResolver senderField $ pure "Sender" + bodyField = Field Nothing (Out.NonNullScalarType string) mempty + bodyResolver = ValueResolver bodyField $ pure "Message body." + +humanType :: ObjectType IO +humanType = ObjectType "Human" Nothing [sentientType] $ HashMap.fromList + [ ("name", nameResolver) + , ("pets", petsResolver) + ] + where + petsField = + Field Nothing (Out.ListType $ Out.NonNullInterfaceType petType) mempty + petsResolver = ValueResolver petsField $ pure $ List [] + +validate :: Text -> [Error] +validate queryString = + case parse AST.document "" queryString of + Left parseErrors -> error $ errorBundlePretty parseErrors + Right ast -> toList $ document petSchema specifiedRules ast + +spec :: Spec +spec = + describe "document" $ do + context "executableDefinitionsRule" $ + it "rejects type definitions" $ + let queryString = [r| + query getDogName { + dog { + name + color + } + } + + extend type Dog { + color: String + } + |] + expected = Error + { message = + "Definition must be OperationDefinition or \ + \FragmentDefinition." + , locations = [AST.Location 9 19] + } + in validate queryString `shouldContain` [expected] + + context "singleFieldSubscriptionsRule" $ do + it "rejects multiple subscription root fields" $ + let queryString = [r| + subscription sub { + newMessage { + body + sender + } + disallowedSecondRootField + } + |] + expected = Error + { message = + "Subscription \"sub\" must select only one top \ + \level field." + , locations = [AST.Location 2 19] + } + in validate queryString `shouldContain` [expected] + + it "rejects multiple subscription root fields coming from a fragment" $ + let queryString = [r| + subscription sub { + ...multipleSubscriptions + } + + fragment multipleSubscriptions on Subscription { + newMessage { + body + sender + } + disallowedSecondRootField + } + |] + expected = Error + { message = + "Subscription \"sub\" must select only one top \ + \level field." + , locations = [AST.Location 2 19] + } + in validate queryString `shouldContain` [expected] + + it "finds corresponding subscription fragment" $ + let queryString = [r| + subscription sub { + ...anotherSubscription + ...multipleSubscriptions + } + fragment multipleSubscriptions on Subscription { + newMessage { + body + } + disallowedSecondRootField { + sender + } + } + fragment anotherSubscription on Subscription { + newMessage { + body + sender + } + } + |] + expected = Error + { message = + "Subscription \"sub\" must select only one top \ + \level field." + , locations = [AST.Location 2 19] + } + in validate queryString `shouldBe` [expected] + + context "loneAnonymousOperationRule" $ + it "rejects multiple anonymous operations" $ + let queryString = [r| + { + dog { + name + } + } + + query getName { + dog { + owner { + name + } + } + } + |] + expected = Error + { message = + "This anonymous operation must be the only defined \ + \operation." + , locations = [AST.Location 2 19] + } + in validate queryString `shouldBe` [expected] + + context "uniqueOperationNamesRule" $ + it "rejects operations with the same name" $ + let queryString = [r| + query dogOperation { + dog { + name + } + } + + mutation dogOperation { + mutateDog { + id + } + } + |] + expected = Error + { message = + "There can be only one operation named \ + \\"dogOperation\"." + , locations = [AST.Location 2 19, AST.Location 8 19] + } + in validate queryString `shouldBe` [expected] + + context "uniqueFragmentNamesRule" $ + it "rejects fragments with the same name" $ + let queryString = [r| + { + dog { + ...fragmentOne + } + } + + fragment fragmentOne on Dog { + name + } + + fragment fragmentOne on Dog { + owner { + name + } + } + |] + expected = Error + { message = + "There can be only one fragment named \ + \\"fragmentOne\"." + , locations = [AST.Location 8 19, AST.Location 12 19] + } + in validate queryString `shouldBe` [expected] + + context "fragmentSpreadTargetDefinedRule" $ + it "rejects the fragment spread without a target" $ + let queryString = [r| + { + dog { + ...undefinedFragment + } + } + |] + expected = Error + { message = + "Fragment target \"undefinedFragment\" is \ + \undefined." + , locations = [AST.Location 4 23] + } + in validate queryString `shouldBe` [expected] + + context "fragmentSpreadTypeExistenceRule" $ do + it "rejects fragment spreads without an unknown target type" $ + let queryString = [r| + { + dog { + ...notOnExistingType + } + } + fragment notOnExistingType on NotInSchema { + name + } + |] + expected = Error + { message = + "Fragment \"notOnExistingType\" is specified on \ + \type \"NotInSchema\" which doesn't exist in the \ + \schema." + , locations = [AST.Location 4 23] + } + in validate queryString `shouldBe` [expected] + + it "rejects inline fragments without a target" $ + let queryString = [r| + { + ... on NotInSchema { + name + } + } + |] + expected = Error + { message = + "Inline fragment is specified on type \ + \\"NotInSchema\" which doesn't exist in the schema." + , locations = [AST.Location 3 21] + } + in validate queryString `shouldBe` [expected] + + context "fragmentsOnCompositeTypesRule" $ do + it "rejects fragments on scalar types" $ + let queryString = [r| + { + dog { + ...fragOnScalar + } + } + fragment fragOnScalar on Int { + name + } + |] + expected = Error + { message = + "Fragment cannot condition on non composite type \ + \\"Int\"." + , locations = [AST.Location 7 19] + } + in validate queryString `shouldContain` [expected] + + it "rejects inline fragments on scalar types" $ + let queryString = [r| + { + ... on Boolean { + name + } + } + |] + expected = Error + { message = + "Fragment cannot condition on non composite type \ + \\"Boolean\"." + , locations = [AST.Location 3 21] + } + in validate queryString `shouldContain` [expected] + + context "noUnusedFragmentsRule" $ + it "rejects unused fragments" $ + let queryString = [r| + fragment nameFragment on Dog { # unused + name + } + + { + dog { + name + } + } + |] + expected = Error + { message = + "Fragment \"nameFragment\" is never used." + , locations = [AST.Location 2 19] + } + in validate queryString `shouldBe` [expected] + + context "noFragmentCyclesRule" $ + it "rejects spreads that form cycles" $ + let queryString = [r| + { + dog { + ...nameFragment + } + } + fragment nameFragment on Dog { + name + ...barkVolumeFragment + } + fragment barkVolumeFragment on Dog { + barkVolume + ...nameFragment + } + |] + error1 = Error + { message = + "Cannot spread fragment \"barkVolumeFragment\" \ + \within itself (via barkVolumeFragment -> \ + \nameFragment -> barkVolumeFragment)." + , locations = [AST.Location 11 19] + } + error2 = Error + { message = + "Cannot spread fragment \"nameFragment\" within \ + \itself (via nameFragment -> barkVolumeFragment -> \ + \nameFragment)." + , locations = [AST.Location 7 19] + } + in validate queryString `shouldBe` [error1, error2] + + context "uniqueArgumentNamesRule" $ + it "rejects duplicate field arguments" $ + let queryString = [r| + { + dog { + isHousetrained(atOtherHomes: true, atOtherHomes: true) + } + } + |] + expected = Error + { message = + "There can be only one argument named \ + \\"atOtherHomes\"." + , locations = [AST.Location 4 38, AST.Location 4 58] + } + in validate queryString `shouldBe` [expected] + + context "uniqueDirectiveNamesRule" $ + it "rejects more than one directive per location" $ + let queryString = [r| + query ($foo: Boolean = true, $bar: Boolean = false) { + dog @skip(if: $foo) @skip(if: $bar) { + name + } + } + |] + expected = Error + { message = + "There can be only one directive named \"skip\"." + , locations = [AST.Location 3 25, AST.Location 3 41] + } + in validate queryString `shouldBe` [expected] + + context "uniqueVariableNamesRule" $ + it "rejects duplicate variables" $ + let queryString = [r| + query houseTrainedQuery($atOtherHomes: Boolean, $atOtherHomes: Boolean) { + dog { + isHousetrained(atOtherHomes: $atOtherHomes) + } + } + |] + expected = Error + { message = + "There can be only one variable named \ + \\"atOtherHomes\"." + , locations = [AST.Location 2 43, AST.Location 2 67] + } + in validate queryString `shouldBe` [expected] + + context "variablesAreInputTypesRule" $ + it "rejects non-input types as variables" $ + let queryString = [r| + query takesDogBang($dog: Dog!) { + dog { + isHousetrained(atOtherHomes: $dog) + } + } + |] + expected = Error + { message = + "Variable \"$dog\" cannot be non-input type \ + \\"Dog\"." + , locations = [AST.Location 2 38] + } + in validate queryString `shouldContain` [expected] + + context "noUndefinedVariablesRule" $ + it "rejects undefined variables" $ + let queryString = [r| + query variableIsNotDefinedUsedInSingleFragment { + dog { + ...isHousetrainedFragment + } + } + + fragment isHousetrainedFragment on Dog { + isHousetrained(atOtherHomes: $atOtherHomes) + } + |] + expected = Error + { message = + "Variable \"$atOtherHomes\" is not defined by \ + \operation \ + \\"variableIsNotDefinedUsedInSingleFragment\"." + , locations = [AST.Location 9 50] + } + in validate queryString `shouldBe` [expected] + + context "noUnusedVariablesRule" $ + it "rejects unused variables" $ + let queryString = [r| + query variableUnused($atOtherHomes: Boolean) { + dog { + isHousetrained + } + } + |] + expected = Error + { message = + "Variable \"$atOtherHomes\" is never used in \ + \operation \"variableUnused\"." + , locations = [AST.Location 2 40] + } + in validate queryString `shouldBe` [expected] + + context "uniqueInputFieldNamesRule" $ + it "rejects duplicate fields in input objects" $ + let queryString = [r| + { + findDog(complex: { name: "Fido", name: "Jack" }) { + name + } + } + |] + expected = Error + { message = + "There can be only one input field named \"name\"." + , locations = [AST.Location 3 40, AST.Location 3 54] + } + in validate queryString `shouldBe` [expected] + + context "fieldsOnCorrectTypeRule" $ + it "rejects undefined fields" $ + let queryString = [r| + { + dog { + meowVolume + } + } + |] + expected = Error + { message = + "Cannot query field \"meowVolume\" on type \"Dog\"." + , locations = [AST.Location 4 23] + } + in validate queryString `shouldBe` [expected] + + context "scalarLeafsRule" $ + it "rejects scalar fields with not empty selection set" $ + let queryString = [r| + { + dog { + barkVolume { + sinceWhen + } + } + } + |] + expected = Error + { message = + "Field \"barkVolume\" must not have a selection \ + \since type \"Int\" has no subfields." + , locations = [AST.Location 4 23] + } + in validate queryString `shouldBe` [expected] + + context "knownArgumentNamesRule" $ do + it "rejects field arguments missing in the type" $ + let queryString = [r| + { + dog { + doesKnowCommand(command: CLEAN_UP_HOUSE, dogCommand: SIT) + } + } + |] + expected = Error + { message = + "Unknown argument \"command\" on field \ + \\"Dog.doesKnowCommand\"." + , locations = [AST.Location 4 39] + } + in validate queryString `shouldBe` [expected] + + it "rejects directive arguments missing in the definition" $ + let queryString = [r| + { + dog { + isHousetrained(atOtherHomes: true) @include(unless: false, if: true) + } + } + |] + expected = Error + { message = + "Unknown argument \"unless\" on directive \ + \\"@include\"." + , locations = [AST.Location 4 67] + } + in validate queryString `shouldBe` [expected] + + context "knownDirectiveNamesRule" $ + it "rejects undefined directives" $ + let queryString = [r| + { + dog { + isHousetrained(atOtherHomes: true) @ignore(if: true) + } + } + |] + expected = Error + { message = "Unknown directive \"@ignore\"." + , locations = [AST.Location 4 58] + } + in validate queryString `shouldBe` [expected] + + context "knownInputFieldNamesRule" $ + it "rejects undefined input object fields" $ + let queryString = [r| + { + findDog(complex: { favoriteCookieFlavor: "Bacon", name: "Jack" }) { + name + } + } + |] + expected = Error + { message = + "Field \"favoriteCookieFlavor\" is not defined \ + \by type \"DogData\"." + , locations = [AST.Location 3 40] + } + in validate queryString `shouldBe` [expected] + + context "directivesInValidLocationsRule" $ + it "rejects directives in invalid locations" $ + let queryString = [r| + query @skip(if: $foo) { + dog { + name + } + } + |] + expected = Error + { message = + "Directive \"@skip\" may not be used on QUERY." + , locations = [AST.Location 2 25] + } + in validate queryString `shouldBe` [expected] + + context "overlappingFieldsCanBeMergedRule" $ do + it "fails to merge fields of mismatching types" $ + let queryString = [r| + { + dog { + name: nickname + name + } + } + |] + expected = Error + { message = + "Fields \"name\" conflict because \"nickname\" and \ + \\"name\" are different fields. Use different \ + \aliases on the fields to fetch both if this was \ + \intentional." + , locations = [AST.Location 4 23, AST.Location 5 23] + } + in validate queryString `shouldBe` [expected] + + it "fails if the arguments of the same field don't match" $ + let queryString = [r| + { + dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand(dogCommand: HEEL) + } + } + |] + expected = Error + { message = + "Fields \"doesKnowCommand\" conflict because they \ + \have different arguments. Use different aliases \ + \on the fields to fetch both if this was \ + \intentional." + , locations = [AST.Location 4 23, AST.Location 5 23] + } + in validate queryString `shouldBe` [expected] + + it "fails to merge same-named field and alias" $ + let queryString = [r| + { + dog { + doesKnowCommand(dogCommand: SIT) + doesKnowCommand: isHousetrained(atOtherHomes: true) + } + } + |] + expected = Error + { message = + "Fields \"doesKnowCommand\" conflict because \ + \\"doesKnowCommand\" and \"isHousetrained\" are \ + \different fields. Use different aliases on the \ + \fields to fetch both if this was intentional." + , locations = [AST.Location 4 23, AST.Location 5 23] + } + in validate queryString `shouldBe` [expected] + + it "looks for fields after a successfully merged field pair" $ + let queryString = [r| + { + dog { + name + doesKnowCommand(dogCommand: SIT) + } + dog { + name + doesKnowCommand: isHousetrained(atOtherHomes: true) + } + } + |] + expected = Error + { message = + "Fields \"doesKnowCommand\" conflict because \ + \\"doesKnowCommand\" and \"isHousetrained\" are \ + \different fields. Use different aliases on the \ + \fields to fetch both if this was intentional." + , locations = [AST.Location 5 23, AST.Location 9 23] + } + in validate queryString `shouldBe` [expected] + + context "possibleFragmentSpreadsRule" $ do + it "rejects object inline spreads outside object scope" $ + let queryString = [r| + { + dog { + ... on Cat { + meowVolume + } + } + } + |] + expected = Error + { message = + "Fragment cannot be spread here as objects of type \ + \\"Dog\" can never be of type \"Cat\"." + , locations = [AST.Location 4 23] + } + in validate queryString `shouldBe` [expected] + + it "rejects object named spreads outside object scope" $ + let queryString = [r| + { + dog { + ... catInDogFragmentInvalid + } + } + + fragment catInDogFragmentInvalid on Cat { + meowVolume + } + |] + expected = Error + { message = + "Fragment \"catInDogFragmentInvalid\" cannot be \ + \spread here as objects of type \"Dog\" can never \ + \be of type \"Cat\"." + , locations = [AST.Location 4 23] + } + in validate queryString `shouldBe` [expected] + + context "providedRequiredInputFieldsRule" $ + it "rejects missing required input fields" $ + let queryString = [r| + { + findDog(complex: { name: null }) { + name + } + } + |] + expected = Error + { message = + "Input field \"name\" of type \"DogData\" is \ + \required, but it was not provided." + , locations = [AST.Location 3 38] + } + in validate queryString `shouldBe` [expected] + + context "providedRequiredArgumentsRule" $ + it "checks for (non-)nullable arguments" $ + let queryString = [r| + { + dog { + doesKnowCommand(dogCommand: null) + } + } + |] + expected = Error + { message = + "Field \"doesKnowCommand\" argument \"dogCommand\" \ + \of type \"DogCommand\" is required, but it was \ + \not provided." + , locations = [AST.Location 4 23] + } + in validate queryString `shouldBe` [expected] + + context "variablesInAllowedPositionRule" $ do + it "rejects wrongly typed variable arguments" $ + let queryString = [r| + query catCommandArgQuery($catCommandArg: CatCommand) { + cat { + doesKnowCommand(catCommand: $catCommandArg) + } + } + |] + expected = Error + { message = + "Variable \"$catCommandArg\" of type \ + \\"CatCommand\" used in position expecting type \ + \\"!CatCommand\"." + , locations = [AST.Location 2 44] + } + in validate queryString `shouldBe` [expected] + + it "rejects wrongly typed variable arguments" $ + let queryString = [r| + query intCannotGoIntoBoolean($intArg: Int) { + dog { + isHousetrained(atOtherHomes: $intArg) + } + } + |] + expected = Error + { message = + "Variable \"$intArg\" of type \"Int\" used in \ + \position expecting type \"Boolean\"." + , locations = [AST.Location 2 48] + } + in validate queryString `shouldBe` [expected] + + context "valuesOfCorrectTypeRule" $ + it "rejects values of incorrect types" $ + let queryString = [r| + { + dog { + isHousetrained(atOtherHomes: 3) + } + } + |] + expected = Error + { message = + "Value 3 cannot be coerced to type \"Boolean\"." + , locations = [AST.Location 4 52] + } + in validate queryString `shouldBe` [expected] diff --git a/tests/Language/GraphQL/ValidateSpec.hs b/tests/Language/GraphQL/ValidateSpec.hs deleted file mode 100644 index 318045c..0000000 --- a/tests/Language/GraphQL/ValidateSpec.hs +++ /dev/null @@ -1,665 +0,0 @@ -{- This Source Code Form is subject to the terms of the Mozilla Public License, - v. 2.0. If a copy of the MPL was not distributed with this file, You can - obtain one at https://mozilla.org/MPL/2.0/. -} - -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE QuasiQuotes #-} - -module Language.GraphQL.ValidateSpec - ( spec - ) where - -import Data.Foldable (toList) -import qualified Data.HashMap.Strict as HashMap -import Data.Text (Text) -import qualified Language.GraphQL.AST as AST -import Language.GraphQL.Type -import qualified Language.GraphQL.Type.In as In -import qualified Language.GraphQL.Type.Out as Out -import Language.GraphQL.Validate -import Test.Hspec (Spec, describe, it, shouldBe, shouldContain) -import Text.Megaparsec (parse) -import Text.RawString.QQ (r) - -petSchema :: Schema IO -petSchema = schema queryType Nothing (Just subscriptionType) mempty - -queryType :: ObjectType IO -queryType = ObjectType "Query" Nothing [] $ HashMap.fromList - [ ("dog", dogResolver) - , ("findDog", findDogResolver) - ] - where - dogField = Field Nothing (Out.NamedObjectType dogType) mempty - dogResolver = ValueResolver dogField $ pure Null - findDogArguments = HashMap.singleton "complex" - $ In.Argument Nothing (In.NonNullInputObjectType dogDataType) Nothing - findDogField = Field Nothing (Out.NamedObjectType dogType) findDogArguments - findDogResolver = ValueResolver findDogField $ pure Null - -dogCommandType :: EnumType -dogCommandType = EnumType "DogCommand" Nothing $ HashMap.fromList - [ ("SIT", EnumValue Nothing) - , ("DOWN", EnumValue Nothing) - , ("HEEL", EnumValue Nothing) - ] - -dogType :: ObjectType IO -dogType = ObjectType "Dog" Nothing [petType] $ HashMap.fromList - [ ("name", nameResolver) - , ("nickname", nicknameResolver) - , ("barkVolume", barkVolumeResolver) - , ("doesKnowCommand", doesKnowCommandResolver) - , ("isHousetrained", isHousetrainedResolver) - , ("owner", ownerResolver) - ] - where - nameField = Field Nothing (Out.NonNullScalarType string) mempty - nameResolver = ValueResolver nameField $ pure "Name" - nicknameField = Field Nothing (Out.NamedScalarType string) mempty - nicknameResolver = ValueResolver nicknameField $ pure "Nickname" - barkVolumeField = Field Nothing (Out.NamedScalarType int) mempty - barkVolumeResolver = ValueResolver barkVolumeField $ pure $ Int 3 - doesKnowCommandField = Field Nothing (Out.NonNullScalarType boolean) - $ HashMap.singleton "dogCommand" - $ In.Argument Nothing (In.NonNullEnumType dogCommandType) Nothing - doesKnowCommandResolver = ValueResolver doesKnowCommandField - $ pure $ Boolean True - isHousetrainedField = Field Nothing (Out.NonNullScalarType boolean) - $ HashMap.singleton "atOtherHomes" - $ In.Argument Nothing (In.NamedScalarType boolean) Nothing - isHousetrainedResolver = ValueResolver isHousetrainedField - $ pure $ Boolean True - ownerField = Field Nothing (Out.NamedObjectType humanType) mempty - ownerResolver = ValueResolver ownerField $ pure Null - -dogDataType :: InputObjectType -dogDataType = InputObjectType "DogData" Nothing - $ HashMap.singleton "name" nameInputField - where - nameInputField = InputField Nothing (In.NonNullScalarType string) Nothing - -sentientType :: InterfaceType IO -sentientType = InterfaceType "Sentient" Nothing [] - $ HashMap.singleton "name" - $ Field Nothing (Out.NonNullScalarType string) mempty - -petType :: InterfaceType IO -petType = InterfaceType "Pet" Nothing [] - $ HashMap.singleton "name" - $ Field Nothing (Out.NonNullScalarType string) mempty - -subscriptionType :: ObjectType IO -subscriptionType = ObjectType "Subscription" Nothing [] $ HashMap.fromList - [ ("newMessage", newMessageResolver) - , ("disallowedSecondRootField", newMessageResolver) - ] - where - newMessageField = Field Nothing (Out.NonNullObjectType messageType) mempty - newMessageResolver = ValueResolver newMessageField - $ pure $ Object HashMap.empty - -messageType :: ObjectType IO -messageType = ObjectType "Message" Nothing [] $ HashMap.fromList - [ ("sender", senderResolver) - , ("body", bodyResolver) - ] - where - senderField = Field Nothing (Out.NonNullScalarType string) mempty - senderResolver = ValueResolver senderField $ pure "Sender" - bodyField = Field Nothing (Out.NonNullScalarType string) mempty - bodyResolver = ValueResolver bodyField $ pure "Message body." - -humanType :: ObjectType IO -humanType = ObjectType "Human" Nothing [sentientType] $ HashMap.fromList - [ ("name", nameResolver) - , ("pets", petsResolver) - ] - where - nameField = Field Nothing (Out.NonNullScalarType string) mempty - nameResolver = ValueResolver nameField $ pure "Name" - petsField = - Field Nothing (Out.ListType $ Out.NonNullInterfaceType petType) mempty - petsResolver = ValueResolver petsField $ pure $ List [] -{- -catOrDogType :: UnionType IO -catOrDogType = UnionType "CatOrDog" Nothing [catType, dogType] --} -validate :: Text -> [Error] -validate queryString = - case parse AST.document "" queryString of - Left _ -> [] - Right ast -> toList $ document petSchema specifiedRules ast - -spec :: Spec -spec = - describe "document" $ do - it "rejects type definitions" $ - let queryString = [r| - query getDogName { - dog { - name - color - } - } - - extend type Dog { - color: String - } - |] - expected = Error - { message = - "Definition must be OperationDefinition or FragmentDefinition." - , locations = [AST.Location 9 15] - } - in validate queryString `shouldContain` [expected] - - it "rejects multiple subscription root fields" $ - let queryString = [r| - subscription sub { - newMessage { - body - sender - } - disallowedSecondRootField - } - |] - expected = Error - { message = - "Subscription \"sub\" must select only one top level \ - \field." - , locations = [AST.Location 2 15] - } - in validate queryString `shouldContain` [expected] - - it "rejects multiple subscription root fields coming from a fragment" $ - let queryString = [r| - subscription sub { - ...multipleSubscriptions - } - - fragment multipleSubscriptions on Subscription { - newMessage { - body - sender - } - disallowedSecondRootField - } - |] - expected = Error - { message = - "Subscription \"sub\" must select only one top level \ - \field." - , locations = [AST.Location 2 15] - } - in validate queryString `shouldContain` [expected] - - it "rejects multiple anonymous operations" $ - let queryString = [r| - { - dog { - name - } - } - - query getName { - dog { - owner { - name - } - } - } - |] - expected = Error - { message = - "This anonymous operation must be the only defined operation." - , locations = [AST.Location 2 15] - } - in validate queryString `shouldBe` [expected] - - it "rejects operations with the same name" $ - let queryString = [r| - query dogOperation { - dog { - name - } - } - - mutation dogOperation { - mutateDog { - id - } - } - |] - expected = Error - { message = - "There can be only one operation named \"dogOperation\"." - , locations = [AST.Location 2 15, AST.Location 8 15] - } - in validate queryString `shouldBe` [expected] - - it "rejects fragments with the same name" $ - let queryString = [r| - { - dog { - ...fragmentOne - } - } - - fragment fragmentOne on Dog { - name - } - - fragment fragmentOne on Dog { - owner { - name - } - } - |] - expected = Error - { message = - "There can be only one fragment named \"fragmentOne\"." - , locations = [AST.Location 8 15, AST.Location 12 15] - } - in validate queryString `shouldBe` [expected] - - it "rejects the fragment spread without a target" $ - let queryString = [r| - { - dog { - ...undefinedFragment - } - } - |] - expected = Error - { message = - "Fragment target \"undefinedFragment\" is undefined." - , locations = [AST.Location 4 19] - } - in validate queryString `shouldBe` [expected] - - it "rejects fragment spreads without an unknown target type" $ - let queryString = [r| - { - dog { - ...notOnExistingType - } - } - fragment notOnExistingType on NotInSchema { - name - } - |] - expected = Error - { message = - "Fragment \"notOnExistingType\" is specified on type \ - \\"NotInSchema\" which doesn't exist in the schema." - , locations = [AST.Location 4 19] - } - in validate queryString `shouldBe` [expected] - - it "rejects inline fragments without a target" $ - let queryString = [r| - { - ... on NotInSchema { - name - } - } - |] - expected = Error - { message = - "Inline fragment is specified on type \"NotInSchema\" \ - \which doesn't exist in the schema." - , locations = [AST.Location 3 17] - } - in validate queryString `shouldBe` [expected] - - it "rejects fragments on scalar types" $ - let queryString = [r| - { - dog { - ...fragOnScalar - } - } - fragment fragOnScalar on Int { - name - } - |] - expected = Error - { message = - "Fragment cannot condition on non composite type \ - \\"Int\"." - , locations = [AST.Location 7 15] - } - in validate queryString `shouldContain` [expected] - - it "rejects inline fragments on scalar types" $ - let queryString = [r| - { - ... on Boolean { - name - } - } - |] - expected = Error - { message = - "Fragment cannot condition on non composite type \ - \\"Boolean\"." - , locations = [AST.Location 3 17] - } - in validate queryString `shouldContain` [expected] - - it "rejects unused fragments" $ - let queryString = [r| - fragment nameFragment on Dog { # unused - name - } - - { - dog { - name - } - } - |] - expected = Error - { message = - "Fragment \"nameFragment\" is never used." - , locations = [AST.Location 2 15] - } - in validate queryString `shouldBe` [expected] - - it "rejects spreads that form cycles" $ - let queryString = [r| - { - dog { - ...nameFragment - } - } - fragment nameFragment on Dog { - name - ...barkVolumeFragment - } - fragment barkVolumeFragment on Dog { - barkVolume - ...nameFragment - } - |] - error1 = Error - { message = - "Cannot spread fragment \"barkVolumeFragment\" within \ - \itself (via barkVolumeFragment -> nameFragment -> \ - \barkVolumeFragment)." - , locations = [AST.Location 11 15] - } - error2 = Error - { message = - "Cannot spread fragment \"nameFragment\" within itself \ - \(via nameFragment -> barkVolumeFragment -> \ - \nameFragment)." - , locations = [AST.Location 7 15] - } - in validate queryString `shouldBe` [error1, error2] - - it "rejects duplicate field arguments" $ do - let queryString = [r| - { - dog { - isHousetrained(atOtherHomes: true, atOtherHomes: true) - } - } - |] - expected = Error - { message = - "There can be only one argument named \"atOtherHomes\"." - , locations = [AST.Location 4 34, AST.Location 4 54] - } - in validate queryString `shouldBe` [expected] - - it "rejects more than one directive per location" $ do - let queryString = [r| - query ($foo: Boolean = true, $bar: Boolean = false) { - dog @skip(if: $foo) @skip(if: $bar) { - name - } - } - |] - expected = Error - { message = - "There can be only one directive named \"skip\"." - , locations = [AST.Location 3 21, AST.Location 3 37] - } - in validate queryString `shouldBe` [expected] - - it "rejects duplicate variables" $ - let queryString = [r| - query houseTrainedQuery($atOtherHomes: Boolean, $atOtherHomes: Boolean) { - dog { - isHousetrained(atOtherHomes: $atOtherHomes) - } - } - |] - expected = Error - { message = - "There can be only one variable named \"atOtherHomes\"." - , locations = [AST.Location 2 39, AST.Location 2 63] - } - in validate queryString `shouldBe` [expected] - - it "rejects non-input types as variables" $ - let queryString = [r| - query takesDogBang($dog: Dog!) { - dog { - isHousetrained(atOtherHomes: $dog) - } - } - |] - expected = Error - { message = - "Variable \"$dog\" cannot be non-input type \"Dog\"." - , locations = [AST.Location 2 34] - } - in validate queryString `shouldBe` [expected] - - it "rejects undefined variables" $ - let queryString = [r| - query variableIsNotDefinedUsedInSingleFragment { - dog { - ...isHousetrainedFragment - } - } - - fragment isHousetrainedFragment on Dog { - isHousetrained(atOtherHomes: $atOtherHomes) - } - |] - expected = Error - { message = - "Variable \"$atOtherHomes\" is not defined by \ - \operation \ - \\"variableIsNotDefinedUsedInSingleFragment\"." - , locations = [AST.Location 9 46] - } - in validate queryString `shouldBe` [expected] - - it "rejects unused variables" $ - let queryString = [r| - query variableUnused($atOtherHomes: Boolean) { - dog { - isHousetrained - } - } - |] - expected = Error - { message = - "Variable \"$atOtherHomes\" is never used in operation \ - \\"variableUnused\"." - , locations = [AST.Location 2 36] - } - in validate queryString `shouldBe` [expected] - - it "rejects duplicate fields in input objects" $ - let queryString = [r| - { - findDog(complex: { name: "Fido", name: "Jack" }) { - name - } - } - |] - expected = Error - { message = - "There can be only one input field named \"name\"." - , locations = [AST.Location 3 36, AST.Location 3 50] - } - in validate queryString `shouldBe` [expected] - - it "rejects undefined fields" $ - let queryString = [r| - { - dog { - meowVolume - } - } - |] - expected = Error - { message = - "Cannot query field \"meowVolume\" on type \"Dog\"." - , locations = [AST.Location 4 19] - } - in validate queryString `shouldBe` [expected] - - it "rejects scalar fields with not empty selection set" $ - let queryString = [r| - { - dog { - barkVolume { - sinceWhen - } - } - } - |] - expected = Error - { message = - "Field \"barkVolume\" must not have a selection since \ - \type \"Int\" has no subfields." - , locations = [AST.Location 4 19] - } - in validate queryString `shouldBe` [expected] - - it "rejects field arguments missing in the type" $ - let queryString = [r| - { - dog { - doesKnowCommand(command: CLEAN_UP_HOUSE, dogCommand: SIT) - } - } - |] - expected = Error - { message = - "Unknown argument \"command\" on field \ - \\"Dog.doesKnowCommand\"." - , locations = [AST.Location 4 35] - } - in validate queryString `shouldBe` [expected] - - it "rejects directive arguments missing in the definition" $ - let queryString = [r| - { - dog { - isHousetrained(atOtherHomes: true) @include(unless: false, if: true) - } - } - |] - expected = Error - { message = - "Unknown argument \"unless\" on directive \"@include\"." - , locations = [AST.Location 4 63] - } - in validate queryString `shouldBe` [expected] - - it "rejects undefined directives" $ - let queryString = [r| - { - dog { - isHousetrained(atOtherHomes: true) @ignore(if: true) - } - } - |] - expected = Error - { message = "Unknown directive \"@ignore\"." - , locations = [AST.Location 4 54] - } - in validate queryString `shouldBe` [expected] - - it "rejects undefined input object fields" $ - let queryString = [r| - { - findDog(complex: { favoriteCookieFlavor: "Bacon", name: "Jack" }) { - name - } - } - |] - expected = Error - { message = - "Field \"favoriteCookieFlavor\" is not defined \ - \by type \"DogData\"." - , locations = [AST.Location 3 36] - } - in validate queryString `shouldBe` [expected] - - it "rejects directives in invalid locations" $ - let queryString = [r| - query @skip(if: $foo) { - dog { - name - } - } - |] - expected = Error - { message = "Directive \"@skip\" may not be used on QUERY." - , locations = [AST.Location 2 21] - } - in validate queryString `shouldBe` [expected] - - it "rejects missing required input fields" $ - let queryString = [r| - { - findDog(complex: { name: null }) { - name - } - } - |] - expected = Error - { message = - "Input field \"name\" of type \"DogData\" is required, \ - \but it was not provided." - , locations = [AST.Location 3 34] - } - in validate queryString `shouldBe` [expected] - - it "finds corresponding subscription fragment" $ - let queryString = [r| - subscription sub { - ...anotherSubscription - ...multipleSubscriptions - } - fragment multipleSubscriptions on Subscription { - newMessage { - body - } - disallowedSecondRootField { - sender - } - } - fragment anotherSubscription on Subscription { - newMessage { - body - sender - } - } - |] - expected = Error - { message = - "Subscription \"sub\" must select only one top level \ - \field." - , locations = [AST.Location 2 15] - } - in validate queryString `shouldBe` [expected] |
