diff --git a/core/src/main/scala/caliban/validation/Validator.scala b/core/src/main/scala/caliban/validation/Validator.scala index 369ad7dc2..0ab5295b9 100644 --- a/core/src/main/scala/caliban/validation/Validator.scala +++ b/core/src/main/scala/caliban/validation/Validator.scala @@ -33,6 +33,7 @@ object Validator { t.kind match { case __TypeKind.ENUM => validateEnum(t) case __TypeKind.UNION => validateUnion(t) + case __TypeKind.INTERFACE => validateInterface(t) case __TypeKind.INPUT_OBJECT => validateInputObject(t) case _ => IO.unit } @@ -545,61 +546,147 @@ object Validator { } private def validateInputObject(t: __Type): IO[ValidationError, Unit] = { - // https://spec.graphql.org/June2018/#IsInputType() - def isInputType(t: __Type): Either[__Type, Unit] = t.kind match { - case __TypeKind.LIST | __TypeKind.NON_NULL => t.ofType.fold[Either[__Type, Unit]](Left(t))(isInputType) - case __TypeKind.SCALAR | __TypeKind.ENUM | __TypeKind.INPUT_OBJECT => Right(()) - case _ => Left(t) + val inputObjectContext = s"""InputObject '${t.name.getOrElse("")}'""" + + def noDuplicateInputValueName(inputValues: List[__InputValue], errorContext: String) = { + val messageBuilder = (i: __InputValue) => s"$errorContext has repeated fields: ${i.name}" + val explanatory = + "The input field must have a unique name within that Input Object type; no two input fields may share the same name" + noDuplicateName[__InputValue](inputValues, _.name, messageBuilder, explanatory) } def validateFields(fields: List[__InputValue]): IO[ValidationError, Unit] = - duplicateFieldName(fields) <* - IO.foreach(fields)(field => + noDuplicateInputValueName(fields, inputObjectContext) <* + IO.foreach(fields)(validateInputValue(_, inputObjectContext)) + + t.inputFields match { + case None | Some(Nil) => + failValidation( + s"$inputObjectContext does not have fields", + "An Input Object type must define one or more input fields" + ) + case Some(fields) => validateFields(fields) + } + } + + private def validateInputValue(inputValue: __InputValue, errorContext: String): IO[ValidationError, Unit] = { + val fieldContext = s"InputValue '${inputValue.name}' of $errorContext" + for { + _ <- doesNotStartWithUnderscore(inputValue, fieldContext) + _ <- onlyInputType(inputValue.`type`(), fieldContext) + } yield () + } + private def validateInterface(t: __Type): IO[ValidationError, Unit] = { + val interfaceContext = s"Interface '${t.name.getOrElse("")}'" + + def noDuplicateFieldName(fields: List[__Field], errorContext: String) = { + val messageBuilder = (f: __Field) => s"$errorContext has repeated fields: ${f.name}" + val explanatory = + "The field must have a unique name within that Interface type; no two fields may share the same name" + noDuplicateName[__Field](fields, _.name, messageBuilder, explanatory) + } + + def validateFields(fields: List[__Field]): IO[ValidationError, Unit] = + noDuplicateFieldName(fields, interfaceContext) <* + IO.foreach(fields) { field => + val fieldContext = s"Field '${field.name}' of $interfaceContext" for { - _ <- doesNotStartWithUnderscore(field) - _ <- onlyInputFieldType(field) + _ <- doesNotStartWithUnderscore(field, fieldContext) + _ <- onlyOutputType(field.`type`(), fieldContext) + _ <- IO.foreach(field.args)(validateInputValue(_, fieldContext)) } yield () - ) + } - def duplicateFieldName(fields: List[__InputValue]): IO[ValidationError, Unit] = - fields - .groupBy(_.name) - .collectFirst { case (_, f :: _ :: _) => f } - .fold[IO[ValidationError, Unit]](IO.unit)(duplicateField => - failValidation( - s"InputObject has repeated fields: ${duplicateField.name}", - "The input field must have a unique name within that Input Object type; no two input fields may share the same name" - ) + t.fields(__DeprecatedArgs(Some(true))) match { + case None | Some(Nil) => + failValidation( + s"$interfaceContext does not have fields", + "An Interface type must define one or more fields" ) + case Some(fields) => validateFields(fields) + } + } + + private def onlyInputType(`type`: __Type, errorContext: String): IO[ValidationError, Unit] = { + // https://spec.graphql.org/June2018/#IsInputType() + def isInputType(t: __Type): Either[__Type, Unit] = { + import __TypeKind._ + t.kind match { + case LIST | NON_NULL => t.ofType.fold[Either[__Type, Unit]](Left(t))(isInputType) + case SCALAR | ENUM | INPUT_OBJECT => Right(()) + case _ => Left(t) + } + } - def doesNotStartWithUnderscore(field: __InputValue): IO[ValidationError, Unit] = - IO.when(field.name.startsWith("__"))( + IO.whenCase(isInputType(`type`)) { + case Left(errorType) => failValidation( - s"InputObject can't start with '__': ${field.name}", - """The input field must not have a name which begins with the -characters {"__"} (two underscores)""" + s"${errorType.name.getOrElse("")} of $errorContext is of kind ${errorType.kind}, must be an InputType", + """The input field must accept a type where IsInputType(type) returns true, https://spec.graphql.org/June2018/#IsInputType()""" ) - ) + } + } - def onlyInputFieldType(field: __InputValue): IO[ValidationError, Unit] = - IO.whenCase(isInputType(field.`type`())) { - case Left(errorType) => - failValidation( - s"${errorType.name.getOrElse("")} is of kind ${errorType.kind}, must be an InputType", - """The input field must accept a type where IsInputType(inputFieldType) returns true, https://spec.graphql.org/June2018/#IsInputType()""" - ) + private def onlyOutputType(`type`: __Type, errorContext: String): IO[ValidationError, Unit] = { + // https://spec.graphql.org/June2018/#IsOutputType() + def isOutputType(t: __Type): Either[__Type, Unit] = { + import __TypeKind._ + t.kind match { + case LIST | NON_NULL => t.ofType.fold[Either[__Type, Unit]](Left(t))(isOutputType) + case SCALAR | OBJECT | INTERFACE | UNION | ENUM => Right(()) + case _ => Left(t) } + } - t.inputFields match { - case None | Some(Nil) => + IO.whenCase(isOutputType(`type`)) { + case Left(errorType) => failValidation( - s"InputObject ${t.name.getOrElse("")} does not have fields", - "An Input Object type must define one or more input fields" + s"${errorType.name.getOrElse("")} of $errorContext is of kind ${errorType.kind}, must be an OutputType", + """The input field must accept a type where IsOutputType(type) returns true, https://spec.graphql.org/June2018/#IsInputType()""" ) - case Some(fields) => validateFields(fields) } } + private def noDuplicateName[T]( + listOfNamed: List[T], + nameExtractor: T => String, + messageBuilder: T => String, + explanatoryText: String + ): IO[ValidationError, Unit] = + listOfNamed + .groupBy(nameExtractor(_)) + .collectFirst { case (_, f :: _ :: _) => f } + .fold[IO[ValidationError, Unit]](IO.unit)(duplicate => + failValidation( + messageBuilder(duplicate), + explanatoryText + ) + ) + + private def doesNotStartWithUnderscore(field: __Field, errorContext: String) = { + val explanatory = s"""The field must not have a name which begins with the characters {"__"} (two underscores)""" + doesNotStartWithUnderscore[__Field](field, _.name, errorContext, explanatory) + } + + private def doesNotStartWithUnderscore(inputValue: __InputValue, errorContext: String) = { + val explanatory = + s"""The input field must not have a name which begins with the characters "__" (two underscores)""" + doesNotStartWithUnderscore[__InputValue](inputValue, _.name, errorContext, explanatory) + } + + private def doesNotStartWithUnderscore[T]( + t: T, + nameExtractor: T => String, + errorContext: String, + explanatoryText: String + ): IO[ValidationError, Unit] = + IO.when(nameExtractor(t).startsWith("__"))( + failValidation( + s"$errorContext can't start with '__'", + explanatoryText + ) + ) + case class Context( document: Document, rootType: RootType, diff --git a/core/src/test/scala/caliban/TestUtils.scala b/core/src/test/scala/caliban/TestUtils.scala index 81dfc425c..d76e42af9 100644 --- a/core/src/test/scala/caliban/TestUtils.scala +++ b/core/src/test/scala/caliban/TestUtils.scala @@ -137,5 +137,55 @@ object TestUtils { resolverIO.queryResolver, WrongMutationUnion(_ => UIO.unit) ) + + @GQLInterface + sealed trait InterfaceEmpty + object InterfaceEmpty { + case class A(x: String) extends InterfaceEmpty + case class B(y: String) extends InterfaceEmpty + } + + case class TestEmpty(i: InterfaceEmpty) + val resolverEmptyInferface = RootResolver( + TestEmpty(InterfaceEmpty.A("a")) + ) + + @GQLInterface + sealed trait InterfaceWrongFieldName + object InterfaceWrongFieldName { + case class A(__name: String) extends InterfaceWrongFieldName + case class B(__name: String) extends InterfaceWrongFieldName + } + + case class TestWrongFieldName(i: InterfaceWrongFieldName) + val resolverInferfaceWrongFieldName = RootResolver( + TestWrongFieldName(InterfaceWrongFieldName.A("a")) + ) + + case class WrongArgumentName(__name: String) + + @GQLInterface + sealed trait InterfaceWrongArgumentName + object InterfaceWrongArgumentName { + case class A(x: WrongArgumentName => UIO[Unit]) extends InterfaceWrongArgumentName + case class B(x: WrongArgumentName => UIO[Unit]) extends InterfaceWrongArgumentName + } + + case class TestWrongArgumentName(i: InterfaceWrongArgumentName) + val resolverInterfaceWrongArgumentName = RootResolver( + TestWrongArgumentName(InterfaceWrongArgumentName.A(_ => UIO.unit)) + ) + + @GQLInterface + sealed trait InterfaceWrongArgumentInputType + object InterfaceWrongArgumentInputType { + case class A(x: UnionInputObjectArg => UIO[Unit]) extends InterfaceWrongArgumentInputType + case class B(x: UnionInputObjectArg => UIO[Unit]) extends InterfaceWrongArgumentInputType + } + + case class TestWrongArgumentType(i: InterfaceWrongArgumentInputType) + val resolverInterfaceWrongArgumentInputType = RootResolver( + TestWrongArgumentType(InterfaceWrongArgumentInputType.A(_ => UIO.unit)) + ) } } diff --git a/core/src/test/scala/caliban/validation/ValidationSchemaSpec.scala b/core/src/test/scala/caliban/validation/ValidationSchemaSpec.scala index 80a5c2249..2b5b70a9e 100644 --- a/core/src/test/scala/caliban/validation/ValidationSchemaSpec.scala +++ b/core/src/test/scala/caliban/validation/ValidationSchemaSpec.scala @@ -13,20 +13,46 @@ object ValidationSchemaSpec def check(gql: GraphQL[Any], expectedMessage: String): IO[ValidationError, TestResult] = assertM(gql.interpreter.run, fails[ValidationError](hasField("msg", _.msg, equalTo(expectedMessage)))) - suite("ValidationSchemaSpec")({ + suite("ValidationSchemaSpec")( suite("InputObjects")( testM("name can't start with '__'") { check( graphQL(resolverWrongMutationUnderscore), - "InputObject can't start with '__': __name" + "InputValue '__name' of InputObject 'DoubleUnderscoreArgInput' can't start with '__'" ) }, testM("should only contain types for which IsInputType(type) is true") { check( graphQL(resolverWrongMutationUnion), - "UnionInput is of kind UNION, must be an InputType" + "UnionInput of InputValue 'union' of InputObject 'UnionArgInput' is of kind UNION, must be an InputType" + ) + } + ), + suite("Interface")( + testM("must define one or more fields") { + check( + graphQL(resolverEmptyInferface), + "Interface 'InterfaceEmpty' does not have fields" + ) + }, + testM("field name can't start with '__'") { + check( + graphQL(resolverInferfaceWrongFieldName), + "Field '__name' of Interface 'InterfaceWrongFieldName' can't start with '__'" + ) + }, + testM("field argument name can't start with '__'") { + check( + graphQL(resolverInterfaceWrongArgumentName), + "InputValue '__name' of Field 'x' of Interface 'InterfaceWrongArgumentName' can't start with '__'" + ) + }, + testM("field argument can't be output type") { + check( + graphQL(resolverInterfaceWrongArgumentInputType), + "UnionInput of InputValue 'union' of InputObject 'UnionArgInput' is of kind UNION, must be an InputType" ) } ) - }) + ) })