From 2caf01b10b5ba3ac0503c8bb860b5bddc734bdda Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Wed, 19 Feb 2020 10:57:25 +0900 Subject: [PATCH] Interface support --- core/src/main/scala/caliban/Rendering.scala | 16 +++++++-- .../caliban/introspection/adt/__Type.scala | 4 +-- .../scala/caliban/schema/Annotations.scala | 5 +++ .../main/scala/caliban/schema/Schema.scala | 35 ++++++++++++++----- .../src/main/scala/caliban/schema/Types.scala | 31 +++++++++++----- core/src/test/scala/caliban/TestUtils.scala | 10 +++++- .../caliban/execution/ExecutionSpec.scala | 13 ++++++- vuepress/docs/docs/schema.md | 6 +++- 8 files changed, 95 insertions(+), 25 deletions(-) diff --git a/core/src/main/scala/caliban/Rendering.scala b/core/src/main/scala/caliban/Rendering.scala index 3690a7445f..f519b4c9b3 100644 --- a/core/src/main/scala/caliban/Rendering.scala +++ b/core/src/main/scala/caliban/Rendering.scala @@ -32,18 +32,28 @@ object Rendering { .enumValues(__DeprecatedArgs()) .fold(List.empty[String])(_.map(renderEnumValue)) .mkString("\n ") - Some(s"""${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)} { - | $renderedFields$renderedInputFields$renderedEnumValues - |}""".stripMargin) + Some( + s"""${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)}${renderInterfaces(t)} { + | $renderedFields$renderedInputFields$renderedEnumValues + |}""".stripMargin + ) } }.mkString("\n\n") + private def renderInterfaces(t: __Type): String = + t.interfaces() + .fold("")(_.flatMap(_.name) match { + case Nil => "" + case list => s" implements ${list.mkString("& ")}" + }) + private def renderKind(kind: __TypeKind): String = kind match { case __TypeKind.OBJECT => "type" case __TypeKind.UNION => "union" case __TypeKind.ENUM => "enum" case __TypeKind.INPUT_OBJECT => "input" + case __TypeKind.INTERFACE => "interface" case _ => "" } diff --git a/core/src/main/scala/caliban/introspection/adt/__Type.scala b/core/src/main/scala/caliban/introspection/adt/__Type.scala index aa0cc73973..64e7a8a818 100644 --- a/core/src/main/scala/caliban/introspection/adt/__Type.scala +++ b/core/src/main/scala/caliban/introspection/adt/__Type.scala @@ -5,7 +5,7 @@ case class __Type( name: Option[String] = None, description: Option[String] = None, fields: __DeprecatedArgs => Option[List[__Field]] = _ => None, - interfaces: Option[List[__Type]] = None, + interfaces: () => Option[List[__Type]] = () => None, possibleTypes: Option[List[__Type]] = None, enumValues: __DeprecatedArgs => Option[List[__EnumValue]] = _ => None, inputFields: Option[List[__InputValue]] = None, @@ -17,7 +17,7 @@ case class __Type( (description ++ that.description).reduceOption((_, b) => b), args => (fields(args) ++ that.fields(args)).reduceOption((a, b) => a.filterNot(f => b.exists(_.name == f.name)) ++ b), - (interfaces ++ that.interfaces).reduceOption((a, b) => a.filterNot(t => b.exists(_.name == t.name)) ++ b), + () => (interfaces() ++ that.interfaces()).reduceOption((a, b) => a.filterNot(t => b.exists(_.name == t.name)) ++ b), (possibleTypes ++ that.possibleTypes).reduceOption((a, b) => a.filterNot(t => b.exists(_.name == t.name)) ++ b), args => (enumValues(args) ++ that.enumValues(args)) diff --git a/core/src/main/scala/caliban/schema/Annotations.scala b/core/src/main/scala/caliban/schema/Annotations.scala index ea34829202..f6825f6fc9 100644 --- a/core/src/main/scala/caliban/schema/Annotations.scala +++ b/core/src/main/scala/caliban/schema/Annotations.scala @@ -24,4 +24,9 @@ object Annotations { * Annotation used to provide an alternative name to a field or a type. */ case class GQLName(value: String) extends StaticAnnotation + + /** + * Annotation to make a sealed trait an interface instead of a union type + */ + case class GQLInterface() extends StaticAnnotation } diff --git a/core/src/main/scala/caliban/schema/Schema.scala b/core/src/main/scala/caliban/schema/Schema.scala index c755f7e0c7..5129c6e7e8 100644 --- a/core/src/main/scala/caliban/schema/Schema.scala +++ b/core/src/main/scala/caliban/schema/Schema.scala @@ -1,15 +1,13 @@ package caliban.schema import java.util.UUID - import scala.annotation.implicitNotFound import scala.concurrent.Future import scala.language.experimental.macros - import caliban.ResponseValue._ import caliban.Value._ import caliban.introspection.adt._ -import caliban.schema.Annotations.{ GQLDeprecated, GQLDescription, GQLInputName, GQLName } +import caliban.schema.Annotations.{ GQLDeprecated, GQLDescription, GQLInputName, GQLInterface, GQLName } import caliban.schema.Step._ import caliban.schema.Types._ import caliban.{ InputValue, ResponseValue } @@ -357,12 +355,31 @@ trait DerivationSchema[R] { ) } ) - else - makeUnion( - Some(getName(ctx)), - getDescription(ctx), - subtypes.map { case (t, _) => fixEmptyUnionObject(t) } - ) + else { + ctx.annotations.collectFirst { + case GQLInterface() => () + }.fold( + makeUnion( + Some(getName(ctx)), + getDescription(ctx), + subtypes.map { case (t, _) => fixEmptyUnionObject(t) } + ) + ) { _ => + val impl = subtypes.map(_._1.copy(interfaces = () => Some(List(toType(isInput))))) + val commonFields = impl + .flatMap(_.fields(__DeprecatedArgs(Some(true)))) + .flatten + .groupBy(_.name) + .collect { + case (name, list) + if impl.forall(_.fields(__DeprecatedArgs(Some(true))).getOrElse(Nil).exists(_.name == name)) => + list.headOption + } + .flatten + + makeInterface(Some(getName(ctx)), getDescription(ctx), commonFields.toList, impl) + } + } } // see https://github.com/graphql/graphql-spec/issues/568 diff --git a/core/src/main/scala/caliban/schema/Types.scala b/core/src/main/scala/caliban/schema/Types.scala index c3c13c47e3..d3b5a86a5a 100644 --- a/core/src/main/scala/caliban/schema/Types.scala +++ b/core/src/main/scala/caliban/schema/Types.scala @@ -7,7 +7,8 @@ object Types { /** * Creates a new scalar type with the given name. */ - def makeScalar(name: String, description: Option[String] = None) = __Type(__TypeKind.SCALAR, Some(name), description) + def makeScalar(name: String, description: Option[String] = None): __Type = + __Type(__TypeKind.SCALAR, Some(name), description) val boolean: __Type = makeScalar("Boolean") val string: __Type = makeScalar("String") @@ -16,11 +17,11 @@ object Types { val float: __Type = makeScalar("Float") val double: __Type = makeScalar("Double") - def makeList(underlying: __Type) = __Type(__TypeKind.LIST, ofType = Some(underlying)) + def makeList(underlying: __Type): __Type = __Type(__TypeKind.LIST, ofType = Some(underlying)) - def makeNonNull(underlying: __Type) = __Type(__TypeKind.NON_NULL, ofType = Some(underlying)) + def makeNonNull(underlying: __Type): __Type = __Type(__TypeKind.NON_NULL, ofType = Some(underlying)) - def makeEnum(name: Option[String], description: Option[String], values: List[__EnumValue]) = + def makeEnum(name: Option[String], description: Option[String], values: List[__EnumValue]): __Type = __Type( __TypeKind.ENUM, name, @@ -28,21 +29,35 @@ object Types { enumValues = args => Some(values.filter(v => args.includeDeprecated.getOrElse(false) || !v.isDeprecated)) ) - def makeObject(name: Option[String], description: Option[String], fields: List[__Field]) = + def makeObject(name: Option[String], description: Option[String], fields: List[__Field]): __Type = __Type( __TypeKind.OBJECT, name, description, fields = args => Some(fields.filter(v => args.includeDeprecated.getOrElse(false) || !v.isDeprecated)), - interfaces = Some(Nil) + interfaces = () => Some(Nil) ) - def makeInputObject(name: Option[String], description: Option[String], fields: List[__InputValue]) = + def makeInputObject(name: Option[String], description: Option[String], fields: List[__InputValue]): __Type = __Type(__TypeKind.INPUT_OBJECT, name, description, inputFields = Some(fields)) - def makeUnion(name: Option[String], description: Option[String], subTypes: List[__Type]) = + def makeUnion(name: Option[String], description: Option[String], subTypes: List[__Type]): __Type = __Type(__TypeKind.UNION, name, description, possibleTypes = Some(subTypes)) + def makeInterface( + name: Option[String], + description: Option[String], + fields: List[__Field], + subTypes: List[__Type] + ): __Type = + __Type( + __TypeKind.INTERFACE, + name, + description, + fields = args => Some(fields.filter(v => args.includeDeprecated.getOrElse(false) || !v.isDeprecated)), + possibleTypes = Some(subTypes) + ) + /** * Returns a map of all the types nested within the given root type. */ diff --git a/core/src/test/scala/caliban/TestUtils.scala b/core/src/test/scala/caliban/TestUtils.scala index a96d2da3d0..b762a31da4 100644 --- a/core/src/test/scala/caliban/TestUtils.scala +++ b/core/src/test/scala/caliban/TestUtils.scala @@ -2,13 +2,21 @@ package caliban import caliban.TestUtils.Origin._ import caliban.TestUtils.Role._ -import caliban.schema.Annotations.{ GQLDeprecated, GQLDescription } +import caliban.schema.Annotations.{ GQLDeprecated, GQLDescription, GQLInterface } import caliban.schema.Schema import zio.UIO import zio.stream.ZStream object TestUtils { + @GQLInterface + sealed trait Interface + object Interface { + case class A(id: String, other: Int) extends Interface + case class B(id: String) extends Interface + case class C(id: String, blah: Boolean) extends Interface + } + sealed trait Origin object Origin { diff --git a/core/src/test/scala/caliban/execution/ExecutionSpec.scala b/core/src/test/scala/caliban/execution/ExecutionSpec.scala index f896815a8e..db85106b74 100644 --- a/core/src/test/scala/caliban/execution/ExecutionSpec.scala +++ b/core/src/test/scala/caliban/execution/ExecutionSpec.scala @@ -1,7 +1,6 @@ package caliban.execution import java.util.UUID - import caliban.CalibanError.ExecutionError import caliban.GraphQL._ import caliban.Macros.gqldoc @@ -339,6 +338,18 @@ object ExecutionSpec interpreter.execute(query).map(_.data.toString), equalTo("""{"test":{"a":333}}""") ) + }, + testM("test Interface") { + case class Test(i: Interface) + val interpreter = graphQL(RootResolver(Test(Interface.B("ok")))).interpreter + val query = gqldoc(""" + { + i { + id + } + }""") + + assertM(interpreter.execute(query).map(_.data.toString), equalTo("""{"i":{"id":"ok"}}""")) } ) ) diff --git a/vuepress/docs/docs/schema.md b/vuepress/docs/docs/schema.md index 702d4afee5..91d24bc1b4 100644 --- a/vuepress/docs/docs/schema.md +++ b/vuepress/docs/docs/schema.md @@ -47,7 +47,7 @@ Make sure those implicits are in scope when you call `graphQL(...)`. This will m This will also improve compilation times and generate less bytecode. ::: -## Enum and union +## Enums, unions, interfaces A sealed trait will be converted to a different GraphQL type depending on its content: @@ -104,6 +104,9 @@ type Mechanic { } ``` +If you prefer an `Interface` instead of a `Union` type, add the `@GQLInterface` annotation to your sealed trait. +An interface will be created with all the fields that are common to the case classes extending the sealed trait. + ## Arguments To declare a field that take arguments, create a dedicated case class representing the arguments and make the field a _function_ from this class to the result type. @@ -153,6 +156,7 @@ Caliban supports a few annotations to enrich data types: - `@GQLInputName("name")` allows you to specify a different name for a data type used as an input (by default, the suffix `Input` is appended to the type name). - `@GQLDescription("description")` lets you provide a description for a data type or field. This description will be visible when your schema is introspected. - `@GQLDeprecated("reason")` allows deprecating a field or an enum value. +- `@GQLInterface` to force a sealed trait generating an interface instead of a union ## Custom types