Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Schema validation: interfaces #229 #243

Merged
161 changes: 124 additions & 37 deletions core/src/main/scala/caliban/validation/Validator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions core/src/test/scala/caliban/TestUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)
}
}
34 changes: 30 additions & 4 deletions core/src/test/scala/caliban/validation/ValidationSchemaSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}
)
})
)
})