From 77b121c84ee6ed019a71859a58b93141aacc973b Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Fri, 15 Oct 2021 15:27:46 +0900 Subject: [PATCH] Implement variable validation spec --- .../scala/caliban/Http4sAdapterSpec.scala | 4 +- .../test/scala/caliban/PlayAdapterSpec.scala | 4 +- .../caliban/introspection/Introspector.scala | 4 +- .../main/scala/caliban/parsing/adt/Type.scala | 6 + .../validation/DefaultValueValidator.scala | 7 +- .../scala/caliban/validation/Validator.scala | 133 ++++++++++++++++-- .../caliban/execution/ExecutionSpec.scala | 2 +- .../introspection/IntrospectionSpec.scala | 4 +- .../caliban/validation/ValidationSpec.scala | 58 +++++++- 9 files changed, 196 insertions(+), 26 deletions(-) diff --git a/adapters/http4s/src/test/scala/caliban/Http4sAdapterSpec.scala b/adapters/http4s/src/test/scala/caliban/Http4sAdapterSpec.scala index 9c80f4a5f..91ce988e3 100644 --- a/adapters/http4s/src/test/scala/caliban/Http4sAdapterSpec.scala +++ b/adapters/http4s/src/test/scala/caliban/Http4sAdapterSpec.scala @@ -130,7 +130,7 @@ object Http4sAdapterSpec extends DefaultRunnableSpec { val fileURL: URL = getClass.getResource(s"/$fileName") val query: String = - """{ "query": "mutation ($file: Upload!) { uploadFile(file: $file) { hash, path, filename, mimetype } }", "variables": { "file": null }}""" + """{ "query": "mutation ($file: UploadInput!) { uploadFile(file: $file) { hash, path, filename, mimetype } }", "variables": { "file": null }}""" val request = basicRequest .post(uri) @@ -169,7 +169,7 @@ object Http4sAdapterSpec extends DefaultRunnableSpec { val file2URL: URL = getClass.getResource(s"/$file2Name") val query: String = - """{ "query": "mutation ($files: [Upload!]!) { uploadFiles(files: $files) { hash, path, filename, mimetype } }", "variables": { "files": [null, null] }}""" + """{ "query": "mutation ($files: [UploadInput!]!) { uploadFiles(files: $files) { hash, path, filename, mimetype } }", "variables": { "files": [null, null] }}""" val request = basicRequest .post(uri) diff --git a/adapters/play/src/test/scala/caliban/PlayAdapterSpec.scala b/adapters/play/src/test/scala/caliban/PlayAdapterSpec.scala index 0c51e1019..96b0e57c7 100644 --- a/adapters/play/src/test/scala/caliban/PlayAdapterSpec.scala +++ b/adapters/play/src/test/scala/caliban/PlayAdapterSpec.scala @@ -133,7 +133,7 @@ object PlayAdapterSpec extends DefaultRunnableSpec { val fileURL: URL = getClass.getResource(s"/$fileName") val query: String = - """{ "query": "mutation ($file: Upload!) { uploadFile(file: $file) { hash, path, filename, mimetype } }", "variables": { "file": null }}""" + """{ "query": "mutation ($file: UploadInput!) { uploadFile(file: $file) { hash, path, filename, mimetype } }", "variables": { "file": null }}""" val request = basicRequest .post(uri) @@ -172,7 +172,7 @@ object PlayAdapterSpec extends DefaultRunnableSpec { val file2URL: URL = getClass.getResource(s"/$file2Name") val query: String = - """{ "query": "mutation ($files: [Upload!]!) { uploadFiles(files: $files) { hash, path, filename, mimetype } }", "variables": { "files": [null, null] }}""" + """{ "query": "mutation ($files: [UploadInput!]!) { uploadFiles(files: $files) { hash, path, filename, mimetype } }", "variables": { "files": [null, null] }}""" val request = basicRequest .post(uri) diff --git a/core/src/main/scala/caliban/introspection/Introspector.scala b/core/src/main/scala/caliban/introspection/Introspector.scala index 9635ec1fe..b2425d21e 100644 --- a/core/src/main/scala/caliban/introspection/Introspector.scala +++ b/core/src/main/scala/caliban/introspection/Introspector.scala @@ -21,7 +21,7 @@ object Introspector extends IntrospectionDerivation { "The @skip directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional exclusion during execution as described by the if argument." ), Set(__DirectiveLocation.FIELD, __DirectiveLocation.FRAGMENT_SPREAD, __DirectiveLocation.INLINE_FRAGMENT), - List(__InputValue("if", None, () => Types.boolean, None)) + List(__InputValue("if", None, () => Types.makeNonNull(Types.boolean), None)) ), __Directive( "include", @@ -29,7 +29,7 @@ object Introspector extends IntrospectionDerivation { "The @include directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional inclusion during execution as described by the if argument." ), Set(__DirectiveLocation.FIELD, __DirectiveLocation.FRAGMENT_SPREAD, __DirectiveLocation.INLINE_FRAGMENT), - List(__InputValue("if", None, () => Types.boolean, None)) + List(__InputValue("if", None, () => Types.makeNonNull(Types.boolean), None)) ) ) diff --git a/core/src/main/scala/caliban/parsing/adt/Type.scala b/core/src/main/scala/caliban/parsing/adt/Type.scala index 6e84eaa1d..618215e1d 100644 --- a/core/src/main/scala/caliban/parsing/adt/Type.scala +++ b/core/src/main/scala/caliban/parsing/adt/Type.scala @@ -10,6 +10,12 @@ sealed trait Type { self => case Type.NamedType(name, nonNull) => if (nonNull) s"$name!" else name case Type.ListType(ofType, nonNull) => if (nonNull) s"[$ofType]!" else s"[$ofType]" } + + def toNullable: Type = + self match { + case Type.NamedType(name, _) => Type.NamedType(name, nonNull = false) + case Type.ListType(ofType, _) => Type.ListType(ofType, nonNull = false) + } } object Type { diff --git a/core/src/main/scala/caliban/validation/DefaultValueValidator.scala b/core/src/main/scala/caliban/validation/DefaultValueValidator.scala index 087264210..9e3336a7a 100644 --- a/core/src/main/scala/caliban/validation/DefaultValueValidator.scala +++ b/core/src/main/scala/caliban/validation/DefaultValueValidator.scala @@ -6,7 +6,10 @@ import caliban.{ InputValue, Value } import caliban.Value._ import caliban.introspection.adt._ import caliban.introspection.adt.__TypeKind._ -import caliban.parsing.Parser +import caliban.parsing.{ Parser, SourceMapper } +import caliban.parsing.adt.Document +import caliban.schema.RootType +import caliban.validation.Validator.Context import zio.IO object DefaultValueValidator { @@ -21,7 +24,7 @@ object DefaultValueValidator { "The default value for a field must be written using GraphQL input syntax." ) ) - _ <- Validator.validateInputValues(field, value) + _ <- Validator.validateInputValues(field, value, Context.empty) _ <- validateInputTypes(field, value, errorContext) } yield () } diff --git a/core/src/main/scala/caliban/validation/Validator.scala b/core/src/main/scala/caliban/validation/Validator.scala index 543dff34a..1c2b6f470 100644 --- a/core/src/main/scala/caliban/validation/Validator.scala +++ b/core/src/main/scala/caliban/validation/Validator.scala @@ -18,6 +18,8 @@ import caliban.schema.{ RootSchema, RootSchemaBuilder, RootType, Types } import caliban.{ InputValue, Rendering, Value } import zio.IO +import scala.annotation.tailrec + object Validator { /** @@ -37,7 +39,7 @@ object Validator { validateRootQuery(schema) } - private[caliban] def validateType(t: __Type) = + private[caliban] def validateType(t: __Type): IO[ValidationError, Unit] = t.kind match { case __TypeKind.ENUM => validateEnum(t) case __TypeKind.UNION => validateUnion(t) @@ -251,12 +253,22 @@ object Validator { "GraphQL servers define what directives they support. For each usage of a directive, the directive must be available on that server." ) case Some(directive) => - IO.when(!directive.locations.contains(location))( - failValidation( - s"Directive '${d.name}' is used in invalid location '$location'.", - "GraphQL servers define what directives they support and where they support them. For each usage of a directive, the directive must be used in a location that the server has declared support for." + IO.foreach_(d.arguments) { case (arg, argValue) => + directive.args.find(_.name == arg) match { + case None => + failValidation( + s"Argument '$arg' is not defined on directive '${d.name}' ($location).", + "Every argument provided to a field or directive must be defined in the set of possible arguments of that field or directive." + ) + case Some(inputValue) => validateInputValues(inputValue, argValue, context) + } + } *> + IO.when(!directive.locations.contains(location))( + failValidation( + s"Directive '${d.name}' is used in invalid location '$location'.", + "GraphQL servers define what directives they support and where they support them. For each usage of a directive, the directive must be used in a location that the server has declared support for." + ) ) - ) } } } yield () @@ -415,11 +427,16 @@ object Validator { ) .flatMap { f => validateFields(context, field.selectionSet, Types.innerType(f.`type`())) *> - validateArguments(field, f, currentType) + validateArguments(field, f, currentType, context) } } - private def validateArguments(field: Field, f: __Field, currentType: __Type): IO[ValidationError, Unit] = + private def validateArguments( + field: Field, + f: __Field, + currentType: __Type, + context: Context + ): IO[ValidationError, Unit] = IO.foreach_(field.arguments) { case (arg, argValue) => f.args.find(_.name == arg) match { case None => @@ -427,7 +444,7 @@ object Validator { s"Argument '$arg' is not defined on field '${field.name}' of type '${currentType.name.getOrElse("")}'.", "Every argument provided to a field or directive must be defined in the set of possible arguments of that field or directive." ) - case Some(inputValue) => validateInputValues(inputValue, argValue) + case Some(inputValue) => validateInputValues(inputValue, argValue, context) } } *> IO.foreach_(f.args.filter(a => a.`type`().kind == __TypeKind.NON_NULL))(arg => @@ -451,7 +468,8 @@ object Validator { private[caliban] def validateInputValues( inputValue: __InputValue, - argValue: InputValue + argValue: InputValue, + context: Context ): IO[ValidationError, Unit] = { val t = inputValue.`type`() val inputType = if (t.kind == __TypeKind.NON_NULL) t.ofType.getOrElse(t) else t @@ -465,14 +483,14 @@ object Validator { s"Input field '$k' is not defined on type '${inputType.name.getOrElse("?")}'.", "Every input field provided in an input object value must be defined in the set of possible fields of that input object’s expected type." ) - case Some(value) => validateInputValues(value, v) + case Some(value) => validateInputValues(value, v, context) } } *> IO .foreach_(inputFields)(inputField => IO.when( inputField.defaultValue.isEmpty && inputField.`type`().kind == __TypeKind.NON_NULL && - fields.get(inputField.name).getOrElse(NullValue) == NullValue + fields.getOrElse(inputField.name, NullValue) == NullValue )( failValidation( s"Required field '${inputField.name}' on object '${inputType.name.getOrElse("?")}' was not provided.", @@ -480,10 +498,91 @@ object Validator { ) ) ) + case VariableValue(variableName) => + context.variableDefinitions.get(variableName) match { + case Some(variableDefinition) => checkVariableUsageAllowed(variableDefinition, inputValue) + case None => + failValidation( + s"Variable '$variableName' is not defined.", + "Variables are scoped on a per‐operation basis. That means that any variable used within the context of an operation must be defined at the top level of that operation" + ) + } case _ => IO.unit } } + private def checkVariableUsageAllowed( + variableDefinition: VariableDefinition, + inputValue: __InputValue + ): IO[ValidationError, Unit] = { + val locationType = inputValue.`type`() + val variableType = variableDefinition.variableType + if (!locationType.isNullable && !variableType.nonNull) { + val hasNonNullVariableDefaultValue = variableDefinition.defaultValue.exists(_ != NullValue) + val hasLocationDefaultValue = inputValue.defaultValue.nonEmpty + if (!hasNonNullVariableDefaultValue && !hasLocationDefaultValue) + failValidation( + s"Variable '${variableDefinition.name}' usage is not allowed because it is nullable and doesn't have a default value.", + "Variable usages must be compatible with the arguments they are passed to." + ) + else { + val nullableLocationType = locationType.ofType.getOrElse(locationType) + checkTypesCompatible(variableDefinition.name, variableType, nullableLocationType) + } + } else checkTypesCompatible(variableDefinition.name, variableType, locationType) + } + + @tailrec + private def checkTypesCompatible( + variableName: String, + variableType: Type, + locationType: __Type + ): IO[ValidationError, Unit] = { + val explanation = "Variable usages must be compatible with the arguments they are passed to." + if (!locationType.isNullable) { + if (variableType.nullable) + failValidation( + s"Variable '$variableName' usage is not allowed because it is nullable but it shouldn't be.", + explanation + ) + else { + val nullableLocationType = locationType.ofType.getOrElse(locationType) + val nullableVariableType = variableType.toNullable + checkTypesCompatible(variableName, nullableVariableType, nullableLocationType) + } + } else if (variableType.nonNull) { + val nullableVariableType = variableType.toNullable + checkTypesCompatible(variableName, nullableVariableType, locationType) + } else if (locationType.kind == __TypeKind.LIST) { + variableType match { + case _: Type.NamedType => + failValidation( + s"Variable '$variableName' usage is not allowed because it is a not a list but it should be.", + explanation + ) + case Type.ListType(ofType, _) => + val itemLocationType = locationType.ofType.getOrElse(locationType) + val itemVariableType = ofType + checkTypesCompatible(variableName, itemVariableType, itemLocationType) + } + } else + variableType match { + case Type.ListType(_, _) => + failValidation( + s"Variable '$variableName' usage is not allowed because it is a list but it should not be.", + explanation + ) + case Type.NamedType(name, _) => + IO.when(!locationType.name.contains(name))( + failValidation( + s"Variable '$variableName' usage is not allowed because its type doesn't match the schema ($name instead of ${locationType.name + .getOrElse("")}).", + explanation + ) + ) + } + } + private def validateLeafFieldSelection(selections: List[Selection], currentType: __Type): IO[ValidationError, Unit] = IO.whenCase(currentType.kind) { case __TypeKind.SCALAR | __TypeKind.ENUM if selections.nonEmpty => @@ -936,6 +1035,14 @@ object Validator { operations: List[OperationDefinition], fragments: Map[String, FragmentDefinition], selectionSets: List[Selection] - ) + ) { + lazy val variableDefinitions: Map[String, VariableDefinition] = + operations.flatMap(_.variableDefinitions.map(d => d.name -> d)).toMap + } + + object Context { + val empty: Context = + Context(Document(Nil, SourceMapper.empty), RootType(Types.boolean, None, None), Nil, Map.empty, Nil) + } } diff --git a/core/src/test/scala/caliban/execution/ExecutionSpec.scala b/core/src/test/scala/caliban/execution/ExecutionSpec.scala index 688a73922..1b18a5749 100644 --- a/core/src/test/scala/caliban/execution/ExecutionSpec.scala +++ b/core/src/test/scala/caliban/execution/ExecutionSpec.scala @@ -283,7 +283,7 @@ object ExecutionSpec extends DefaultRunnableSpec { testM("variable in object") { val interpreter = graphQL(resolver).interpreter val query = gqldoc(""" - query test($name: String) { + query test($name: String!) { exists(character: { name: $name, nicknames: [], origin: EARTH }) }""") diff --git a/core/src/test/scala/caliban/introspection/IntrospectionSpec.scala b/core/src/test/scala/caliban/introspection/IntrospectionSpec.scala index 2cd5b4226..6c6ce3e55 100644 --- a/core/src/test/scala/caliban/introspection/IntrospectionSpec.scala +++ b/core/src/test/scala/caliban/introspection/IntrospectionSpec.scala @@ -124,7 +124,7 @@ object IntrospectionSpec extends DefaultRunnableSpec { assertM(interpreter.flatMap(_.execute(fullIntrospectionQuery)).map(_.data.toString))( equalTo( - """{"__schema":{"queryType":{"name":"QueryIO"},"mutationType":null,"subscriptionType":null,"types":[{"kind":"SCALAR","name":"Boolean","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Captain","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"CaptainShipName","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"SCALAR","name":"CaptainShipName","description":"Description of custom scalar emphasizing proper captain ship names","fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Character","description":null,"fields":[{"name":"name","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null},{"name":"nicknames","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"LIST","name":null,"ofType":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}}}},"isDeprecated":false,"deprecationReason":null},{"name":"origin","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"ENUM","name":"Origin","ofType":null}},"isDeprecated":false,"deprecationReason":null},{"name":"role","description":null,"args":[],"type":{"kind":"UNION","name":"Role","ofType":null},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Engineer","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Mechanic","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"ENUM","name":"Origin","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":[{"name":"BELT","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"EARTH","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"MARS","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"MOON","description":null,"isDeprecated":true,"deprecationReason":"Use: EARTH | MARS | BELT"}],"possibleTypes":null},{"kind":"OBJECT","name":"Pilot","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"QueryIO","description":"Queries","fields":[{"name":"characters","description":"Return all characters from a given origin","args":[{"name":"origin","description":null,"type":{"kind":"ENUM","name":"Origin","ofType":null},"defaultValue":null}],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"LIST","name":null,"ofType":{"kind":"NON_NULL","name":null,"ofType":{"kind":"OBJECT","name":"Character","ofType":null}}}},"isDeprecated":false,"deprecationReason":null},{"name":"character","description":null,"args":[{"name":"name","description":null,"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"defaultValue":null}],"type":{"kind":"OBJECT","name":"Character","ofType":null},"isDeprecated":true,"deprecationReason":"Use `characters`"}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"UNION","name":"Role","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":[{"kind":"OBJECT","name":"Captain","ofType":null},{"kind":"OBJECT","name":"Engineer","ofType":null},{"kind":"OBJECT","name":"Mechanic","ofType":null},{"kind":"OBJECT","name":"Pilot","ofType":null}]},{"kind":"SCALAR","name":"String","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null}],"directives":[{"name":"skip","description":"The @skip directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional exclusion during execution as described by the if argument.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":null,"type":{"kind":"SCALAR","name":"Boolean","ofType":null},"defaultValue":null}]},{"name":"include","description":"The @include directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional inclusion during execution as described by the if argument.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":null,"type":{"kind":"SCALAR","name":"Boolean","ofType":null},"defaultValue":null}]}]}}""" + """{"__schema":{"queryType":{"name":"QueryIO"},"mutationType":null,"subscriptionType":null,"types":[{"kind":"SCALAR","name":"Boolean","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Captain","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"CaptainShipName","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"SCALAR","name":"CaptainShipName","description":"Description of custom scalar emphasizing proper captain ship names","fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Character","description":null,"fields":[{"name":"name","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null},{"name":"nicknames","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"LIST","name":null,"ofType":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}}}},"isDeprecated":false,"deprecationReason":null},{"name":"origin","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"ENUM","name":"Origin","ofType":null}},"isDeprecated":false,"deprecationReason":null},{"name":"role","description":null,"args":[],"type":{"kind":"UNION","name":"Role","ofType":null},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Engineer","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Mechanic","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"ENUM","name":"Origin","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":[{"name":"BELT","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"EARTH","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"MARS","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"MOON","description":null,"isDeprecated":true,"deprecationReason":"Use: EARTH | MARS | BELT"}],"possibleTypes":null},{"kind":"OBJECT","name":"Pilot","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"QueryIO","description":"Queries","fields":[{"name":"characters","description":"Return all characters from a given origin","args":[{"name":"origin","description":null,"type":{"kind":"ENUM","name":"Origin","ofType":null},"defaultValue":null}],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"LIST","name":null,"ofType":{"kind":"NON_NULL","name":null,"ofType":{"kind":"OBJECT","name":"Character","ofType":null}}}},"isDeprecated":false,"deprecationReason":null},{"name":"character","description":null,"args":[{"name":"name","description":null,"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"defaultValue":null}],"type":{"kind":"OBJECT","name":"Character","ofType":null},"isDeprecated":true,"deprecationReason":"Use `characters`"}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"UNION","name":"Role","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":[{"kind":"OBJECT","name":"Captain","ofType":null},{"kind":"OBJECT","name":"Engineer","ofType":null},{"kind":"OBJECT","name":"Mechanic","ofType":null},{"kind":"OBJECT","name":"Pilot","ofType":null}]},{"kind":"SCALAR","name":"String","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null}],"directives":[{"name":"skip","description":"The @skip directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional exclusion during execution as described by the if argument.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":null,"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"Boolean","ofType":null}},"defaultValue":null}]},{"name":"include","description":"The @include directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional inclusion during execution as described by the if argument.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":null,"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"Boolean","ofType":null}},"defaultValue":null}]}]}}""" ) ) }, @@ -151,7 +151,7 @@ object IntrospectionSpec extends DefaultRunnableSpec { assertM(interpreter.flatMap(_.execute(fullIntrospectionQuery)).map(_.data.toString))( equalTo( - """{"__schema":{"queryType":{"name":"QueryIO"},"mutationType":null,"subscriptionType":null,"types":[{"kind":"SCALAR","name":"Boolean","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Captain","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"CaptainShipName","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"SCALAR","name":"CaptainShipName","description":"Description of custom scalar emphasizing proper captain ship names","fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Character","description":null,"fields":[{"name":"name","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null},{"name":"nicknames","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"LIST","name":null,"ofType":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}}}},"isDeprecated":false,"deprecationReason":null},{"name":"origin","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"ENUM","name":"Origin","ofType":null}},"isDeprecated":false,"deprecationReason":null},{"name":"role","description":null,"args":[],"type":{"kind":"UNION","name":"Role","ofType":null},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Engineer","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Mechanic","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"ENUM","name":"Origin","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":[{"name":"BELT","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"EARTH","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"MARS","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"MOON","description":null,"isDeprecated":true,"deprecationReason":"Use: EARTH | MARS | BELT"}],"possibleTypes":null},{"kind":"OBJECT","name":"Pilot","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"QueryIO","description":"Queries","fields":[{"name":"characters","description":"Return all characters from a given origin","args":[{"name":"origin","description":null,"type":{"kind":"ENUM","name":"Origin","ofType":null},"defaultValue":null}],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"LIST","name":null,"ofType":{"kind":"NON_NULL","name":null,"ofType":{"kind":"OBJECT","name":"Character","ofType":null}}}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"UNION","name":"Role","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":[{"kind":"OBJECT","name":"Captain","ofType":null},{"kind":"OBJECT","name":"Engineer","ofType":null},{"kind":"OBJECT","name":"Mechanic","ofType":null},{"kind":"OBJECT","name":"Pilot","ofType":null}]},{"kind":"SCALAR","name":"String","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null}],"directives":[{"name":"skip","description":"The @skip directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional exclusion during execution as described by the if argument.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":null,"type":{"kind":"SCALAR","name":"Boolean","ofType":null},"defaultValue":null}]},{"name":"include","description":"The @include directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional inclusion during execution as described by the if argument.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":null,"type":{"kind":"SCALAR","name":"Boolean","ofType":null},"defaultValue":null}]}]}}""" + """{"__schema":{"queryType":{"name":"QueryIO"},"mutationType":null,"subscriptionType":null,"types":[{"kind":"SCALAR","name":"Boolean","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Captain","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"CaptainShipName","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"SCALAR","name":"CaptainShipName","description":"Description of custom scalar emphasizing proper captain ship names","fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Character","description":null,"fields":[{"name":"name","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null},{"name":"nicknames","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"LIST","name":null,"ofType":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}}}},"isDeprecated":false,"deprecationReason":null},{"name":"origin","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"ENUM","name":"Origin","ofType":null}},"isDeprecated":false,"deprecationReason":null},{"name":"role","description":null,"args":[],"type":{"kind":"UNION","name":"Role","ofType":null},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Engineer","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"Mechanic","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"ENUM","name":"Origin","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":[{"name":"BELT","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"EARTH","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"MARS","description":null,"isDeprecated":false,"deprecationReason":null},{"name":"MOON","description":null,"isDeprecated":true,"deprecationReason":"Use: EARTH | MARS | BELT"}],"possibleTypes":null},{"kind":"OBJECT","name":"Pilot","description":null,"fields":[{"name":"shipName","description":null,"args":[],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"String","ofType":null}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"OBJECT","name":"QueryIO","description":"Queries","fields":[{"name":"characters","description":"Return all characters from a given origin","args":[{"name":"origin","description":null,"type":{"kind":"ENUM","name":"Origin","ofType":null},"defaultValue":null}],"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"LIST","name":null,"ofType":{"kind":"NON_NULL","name":null,"ofType":{"kind":"OBJECT","name":"Character","ofType":null}}}},"isDeprecated":false,"deprecationReason":null}],"inputFields":null,"interfaces":[],"enumValues":null,"possibleTypes":null},{"kind":"UNION","name":"Role","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":[{"kind":"OBJECT","name":"Captain","ofType":null},{"kind":"OBJECT","name":"Engineer","ofType":null},{"kind":"OBJECT","name":"Mechanic","ofType":null},{"kind":"OBJECT","name":"Pilot","ofType":null}]},{"kind":"SCALAR","name":"String","description":null,"fields":null,"inputFields":null,"interfaces":null,"enumValues":null,"possibleTypes":null}],"directives":[{"name":"skip","description":"The @skip directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional exclusion during execution as described by the if argument.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":null,"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"Boolean","ofType":null}},"defaultValue":null}]},{"name":"include","description":"The @include directive may be provided for fields, fragment spreads, and inline fragments, and allows for conditional inclusion during execution as described by the if argument.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":null,"type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"Boolean","ofType":null}},"defaultValue":null}]}]}}""" ) ) }, diff --git a/core/src/test/scala/caliban/validation/ValidationSpec.scala b/core/src/test/scala/caliban/validation/ValidationSpec.scala index e5782f8ff..226ed317d 100644 --- a/core/src/test/scala/caliban/validation/ValidationSpec.scala +++ b/core/src/test/scala/caliban/validation/ValidationSpec.scala @@ -5,7 +5,7 @@ import caliban.CalibanError.ValidationError import caliban.GraphQL._ import caliban.Macros.gqldoc import caliban.TestUtils._ -import caliban.Value.StringValue +import caliban.Value.{ BooleanValue, StringValue } import zio.IO import zio.test.Assertion._ import zio.test.environment.TestEnvironment @@ -249,7 +249,7 @@ object ValidationSpec extends DefaultRunnableSpec { }, testM("variable used in object") { val query = gqldoc(""" - query($x: String) { + query($x: String!) { exists(character: { name: $x, nicknames: [], origin: EARTH }) }""") assertM(interpreter.flatMap(_.execute(query, None, Map("x" -> StringValue("y")))).map(_.errors.headOption))( @@ -287,6 +287,60 @@ object ValidationSpec extends DefaultRunnableSpec { } }""") check(query, "Directive 'skip' is defined twice.") + }, + testM("variable type doesn't match") { + val query = gqldoc(""" + query($x: Int!) { + exists(character: { name: $x, nicknames: [], origin: EARTH }) + }""") + check( + query, + "Variable 'x' usage is not allowed because its type doesn't match the schema (Int instead of String)." + ) + }, + testM("variable cardinality is the same") { + val query = gqldoc(""" + query($x: [String]!) { + exists(character: { name: $x, nicknames: [], origin: EARTH }) + }""") + check(query, "Variable 'x' usage is not allowed because it is a list but it should not be.") + }, + testM("variable nullability is the same") { + val query = gqldoc(""" + query($x: String) { + exists(character: { name: $x, nicknames: [], origin: EARTH }) + }""") + check(query, "Variable 'x' usage is not allowed because it is nullable and doesn't have a default value.") + }, + testM("variable nullability with default") { + val query = gqldoc(""" + query($x: String = "test") { + exists(character: { name: $x, nicknames: [], origin: EARTH }) + }""") + assertM(interpreter.flatMap(_.execute(query, None, Map())).map(_.errors.headOption))(isNone) + }, + testM("directive with variable of the wrong type") { + val query = gqldoc(""" + query($x: String!) { + characters { + name @skip(if: $x) + } + }""") + check( + query, + "Variable 'x' usage is not allowed because its type doesn't match the schema (String instead of Boolean)." + ) + }, + testM("directive with variable of the right type") { + val query = gqldoc(""" + query($x: Boolean!) { + characters { + name @skip(if: $x) + } + }""") + assertM(interpreter.flatMap(_.execute(query, None, Map("x" -> BooleanValue(true)))).map(_.errors.headOption))( + isNone + ) } ) }