diff --git a/core/src/main/scala-2/caliban/schema/AnnotationsVersionSpecific.scala b/core/src/main/scala-2/caliban/schema/AnnotationsVersionSpecific.scala index 7f068f25f5..9f129cd1a6 100644 --- a/core/src/main/scala-2/caliban/schema/AnnotationsVersionSpecific.scala +++ b/core/src/main/scala-2/caliban/schema/AnnotationsVersionSpecific.scala @@ -1,3 +1,19 @@ package caliban.schema -trait AnnotationsVersionSpecific +import caliban.parsing.adt.Directive + +import scala.annotation.StaticAnnotation + +trait AnnotationsVersionSpecific { + + /** + * Annotation used to provide directives to a schema type + */ + class GQLDirective(val directive: Directive) extends StaticAnnotation + + object GQLDirective { + def unapply(annotation: GQLDirective): Option[Directive] = + Some(annotation.directive) + } + +} diff --git a/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala index 40bbd51d48..69f950b1b4 100644 --- a/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala +++ b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala @@ -286,6 +286,7 @@ trait CommonSchemaDerivation[R] { private def getDescription[Typeclass[_], Type](ctx: ReadOnlyParam[Typeclass, Type]): Option[String] = getDescription(ctx.annotations) + } trait SchemaDerivation[R] extends CommonSchemaDerivation[R] { diff --git a/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala b/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala index b9f39cd240..87436a8461 100644 --- a/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala +++ b/core/src/main/scala-3/caliban/schema/AnnotationsVersionSpecific.scala @@ -1,5 +1,7 @@ package caliban.schema +import caliban.parsing.adt.Directive + import scala.annotation.StaticAnnotation trait AnnotationsVersionSpecific { @@ -21,4 +23,14 @@ trait AnnotationsVersionSpecific { */ case class GQLFieldsFromMethods() extends StaticAnnotation + /** + * Annotation used to provide directives to a schema type + */ + open class GQLDirective(val directive: Directive) extends StaticAnnotation + + object GQLDirective { + def unapply(annotation: GQLDirective): Option[Directive] = + Some(annotation.directive) + } + } diff --git a/core/src/main/scala/caliban/execution/Fragment.scala b/core/src/main/scala/caliban/execution/Fragment.scala index 7a403c7476..0dc1eb87b6 100644 --- a/core/src/main/scala/caliban/execution/Fragment.scala +++ b/core/src/main/scala/caliban/execution/Fragment.scala @@ -1,7 +1,7 @@ package caliban.execution import caliban.Value.{ BooleanValue, IntValue, StringValue } -import caliban.parsing.adt.Directive +import caliban.parsing.adt.{ Directive, Directives } case class Fragment(name: Option[String], directives: List[Directive]) {} @@ -9,7 +9,7 @@ object Fragment { object IsDeferred { def unapply(fragment: Fragment): Option[Option[String]] = fragment.directives.collectFirst { - case Directive("defer", args, _) if args.get("if").forall { + case Directive(Directives.Defer, args, _, _) if args.get("if").forall { case BooleanValue(v) => v case _ => true } => @@ -20,7 +20,7 @@ object Fragment { object IsStream { def unapply(field: Field): Option[(Option[String], Option[Int])] = - field.directives.collectFirst { case Directive("stream", args, _) => + field.directives.collectFirst { case Directive(Directives.Stream, args, _, _) => ( args.get("label").collect { case StringValue(v) => v }, args.get("initialCount").collect { case v: IntValue => v.toInt } diff --git a/core/src/main/scala/caliban/introspection/adt/__InputValue.scala b/core/src/main/scala/caliban/introspection/adt/__InputValue.scala index 3ab5e06124..0217a85246 100644 --- a/core/src/main/scala/caliban/introspection/adt/__InputValue.scala +++ b/core/src/main/scala/caliban/introspection/adt/__InputValue.scala @@ -1,9 +1,9 @@ package caliban.introspection.adt import caliban.Value.StringValue +import caliban.parsing.Parser import caliban.parsing.adt.Definition.TypeSystemDefinition.TypeDefinition.InputValueDefinition import caliban.parsing.adt.Directive -import caliban.parsing.Parser import caliban.schema.Annotations.GQLExcluded import scala.annotation.tailrec diff --git a/core/src/main/scala/caliban/introspection/adt/__Type.scala b/core/src/main/scala/caliban/introspection/adt/__Type.scala index fb77f00140..d74be7f6a0 100644 --- a/core/src/main/scala/caliban/introspection/adt/__Type.scala +++ b/core/src/main/scala/caliban/introspection/adt/__Type.scala @@ -60,12 +60,13 @@ case class __Type( Some( ScalarTypeDefinition( description, - name.getOrElse(""), - directives - .getOrElse(Nil) ++ - specifiedBy - .map(url => Directive("specifiedBy", Map("url" -> StringValue(url)), directives.size)) - .toList + name.getOrElse(""), { + val dirs = directives.getOrElse(Nil) + dirs ++ + specifiedBy + .map(url => Directive("specifiedBy", Map("url" -> StringValue(url)), dirs.size)) + .toList + } ) ) case __TypeKind.OBJECT => diff --git a/core/src/main/scala/caliban/parsing/adt/Directive.scala b/core/src/main/scala/caliban/parsing/adt/Directive.scala index e3cc12df1f..6f604b2e12 100644 --- a/core/src/main/scala/caliban/parsing/adt/Directive.scala +++ b/core/src/main/scala/caliban/parsing/adt/Directive.scala @@ -2,14 +2,21 @@ package caliban.parsing.adt import caliban.{ InputValue, Value } -case class Directive(name: String, arguments: Map[String, InputValue] = Map.empty, index: Int = 0) +case class Directive( + name: String, + arguments: Map[String, InputValue] = Map.empty, + index: Int = 0, + isIntrospectable: Boolean = true +) object Directives { + final val Defer = "defer" + final val DeprecatedDirective = "deprecated" final val LazyDirective = "lazy" final val NewtypeDirective = "newtype" - final val DeprecatedDirective = "deprecated" final val OneOf = "oneOf" + final val Stream = "stream" def isDeprecated(directives: List[Directive]): Boolean = directives.exists(_.name == DeprecatedDirective) diff --git a/core/src/main/scala/caliban/rendering/DocumentRenderer.scala b/core/src/main/scala/caliban/rendering/DocumentRenderer.scala index 0be015ad1f..78c76a70bc 100644 --- a/core/src/main/scala/caliban/rendering/DocumentRenderer.scala +++ b/core/src/main/scala/caliban/rendering/DocumentRenderer.scala @@ -76,7 +76,9 @@ object DocumentRenderer extends Renderer[Document] { typeDefinitionsRenderer.contramap(_.flatMap(_.toTypeDefinition)) private[caliban] lazy val directivesRenderer: Renderer[List[Directive]] = - directiveRenderer.list(Renderer.spaceOrEmpty, omitFirst = false).contramap(_.sortBy(_.name)) + directiveRenderer + .list(Renderer.spaceOrEmpty, omitFirst = false) + .contramap(_.filter(_.isIntrospectable).sortBy(_.name)) private[caliban] lazy val descriptionRenderer: Renderer[Option[String]] = new Renderer[Option[String]] { diff --git a/core/src/main/scala/caliban/schema/Annotations.scala b/core/src/main/scala/caliban/schema/Annotations.scala index 39b662b9f1..f6e852f50f 100644 --- a/core/src/main/scala/caliban/schema/Annotations.scala +++ b/core/src/main/scala/caliban/schema/Annotations.scala @@ -1,6 +1,7 @@ package caliban.schema -import caliban.parsing.adt.Directive +import caliban.{ InputValue, Value } +import caliban.parsing.adt.{ Directive, Directives } import scala.annotation.StaticAnnotation @@ -32,16 +33,6 @@ object Annotations extends AnnotationsVersionSpecific { */ case class GQLName(value: String) extends StaticAnnotation - /** - * Annotation used to provide directives to a schema type - */ - class GQLDirective(val directive: Directive) extends StaticAnnotation - - object GQLDirective { - def unapply(annotation: GQLDirective): Option[Directive] = - Some(annotation.directive) - } - /** * Annotation to make a sealed trait an interface instead of a union type or an enum * @@ -80,5 +71,4 @@ object Annotations extends AnnotationsVersionSpecific { * Annotation to make a sealed trait as a GraphQL @oneOf input */ case class GQLOneOfInput() extends StaticAnnotation - } diff --git a/core/src/main/scala/caliban/transformers/Transformer.scala b/core/src/main/scala/caliban/transformers/Transformer.scala index 0303b166c7..079f98d27f 100644 --- a/core/src/main/scala/caliban/transformers/Transformer.scala +++ b/core/src/main/scala/caliban/transformers/Transformer.scala @@ -3,6 +3,8 @@ package caliban.transformers import caliban.InputValue import caliban.execution.Field import caliban.introspection.adt._ +import caliban.parsing.adt.Directive +import caliban.schema.Annotations.GQLDirective import caliban.schema.Step import caliban.schema.Step.{ FunctionStep, MetadataFunctionStep, NullStep, ObjectStep } @@ -19,7 +21,7 @@ abstract class Transformer[-R] { self => * Set of type names that this transformer applies to. * Needed for applying optimizations when combining transformers. */ - protected val typeNames: collection.Set[String] + protected def typeNames: collection.Set[String] protected def transformStep[R1 <: R](step: ObjectStep[R1], field: Field): ObjectStep[R1] @@ -326,20 +328,87 @@ object Transformer { } } + object ExcludeDirectives { + + /** + * A transformer that allows excluding fields and inputs with specific directives. + * + * {{{ + * case object Experimental extends GQLDirective(Directive("experimental")) + * case object Internal extends GQLDirective(Directive("internal")) + * + * ExcludeDirectives(Experimental, Internal) + * }}} + */ + def apply(directives: GQLDirective*): Transformer[Any] = + if (directives.isEmpty) Empty else new ExcludeDirectives(directives.map(_.directive).toSet.contains) + + /** + * A transformer that allows excluding fields and inputs with specific directives based on a predicate. + */ + def apply(predicate: Directive => Boolean): Transformer[Any] = + new ExcludeDirectives(predicate) + + } + + final private class ExcludeDirectives(predicate: Directive => Boolean) extends Transformer[Any] { + private val map: mutable.HashMap[String, Set[String]] = mutable.HashMap.empty + + private def hasMatchingDirectives(directives: Option[List[Directive]]): Boolean = + directives match { + case None | Some(Nil) => false + case Some(dirs) => dirs.exists(predicate) + } + + private def shouldKeepType(tpe: __Type, field: __Field): Boolean = { + val matched = hasMatchingDirectives(field.directives) + if (matched) map.updateWith(tpe.name.getOrElse("")) { + case Some(set) => Some(set + field.name) + case None => Some(Set(field.name)) + } + !matched + } + + val typeVisitor: TypeVisitor = + TypeVisitor.fields.filterWith((t, field) => shouldKeepType(t, field)) |+| + TypeVisitor.fields.modify { field => + def loop(arg: __InputValue): Option[__InputValue] = + if (arg._type.isNullable && hasMatchingDirectives(arg.directives)) None + else { + lazy val newType = arg._type.mapInnerType { t => + t.copy(inputFields = t.inputFields(_).map(_.flatMap(loop))) + } + Some(arg.copy(`type` = () => newType)) + } + + field.copy(args = field.args(_).flatMap(loop)) + } + + protected def typeNames: collection.Set[String] = map.keySet + + protected def transformStep[R](step: ObjectStep[R], field: Field): ObjectStep[R] = + map.getOrElse(step.name, null) match { + case null => step + case excl => step.copy(fields = name => if (!excl(name)) step.fields(name) else NullStep) + } + } + final private class Combined[-R](left: Transformer[R], right: Transformer[R]) extends Transformer[R] { val typeVisitor: TypeVisitor = left.typeVisitor |+| right.typeVisitor - protected val typeNames: mutable.HashSet[String] = { + protected def typeNames: mutable.HashSet[String] = { val set = mutable.HashSet.from(left.typeNames) set ++= right.typeNames set } + private lazy val materializedTypeNames = typeNames + protected def transformStep[R1 <: R](step: ObjectStep[R1], field: Field): ObjectStep[R1] = right.transformStep(left.transformStep(step, field), field) override def apply[R1 <: R](step: ObjectStep[R1], field: Field): ObjectStep[R1] = - if (typeNames(step.name)) transformStep(step, field) else step + if (materializedTypeNames(step.name)) transformStep(step, field) else step } private def mapFunctionStep[R](step: Step[R])(f: Map[String, InputValue] => Map[String, InputValue]): Step[R] = diff --git a/core/src/main/scala/caliban/wrappers/DeferSupport.scala b/core/src/main/scala/caliban/wrappers/DeferSupport.scala index f0982f754b..9730b7128b 100644 --- a/core/src/main/scala/caliban/wrappers/DeferSupport.scala +++ b/core/src/main/scala/caliban/wrappers/DeferSupport.scala @@ -2,12 +2,13 @@ package caliban.wrappers import caliban.execution.Feature import caliban.introspection.adt.{ __Directive, __DirectiveLocation, __InputValue } +import caliban.parsing.adt.Directives import caliban.schema.Types import caliban.{ GraphQL, GraphQLAspect } object DeferSupport { private[caliban] val deferDirective = __Directive( - "defer", + Directives.Defer, Some(""), Set(__DirectiveLocation.FRAGMENT_SPREAD, __DirectiveLocation.INLINE_FRAGMENT), _ => diff --git a/core/src/test/scala/caliban/RenderingSpec.scala b/core/src/test/scala/caliban/RenderingSpec.scala index fdfde45f71..d05b6679db 100644 --- a/core/src/test/scala/caliban/RenderingSpec.scala +++ b/core/src/test/scala/caliban/RenderingSpec.scala @@ -132,6 +132,21 @@ object RenderingSpec extends ZIOSpecDefault { val renderedType = DocumentRenderer.typesRenderer.render(List(testType)).trim assertTrue(renderedType == "type TestType @testdirective(object: {key1: \"value1\", key2: \"value2\"})") }, + test("only introspectable directives are rendered") { + val all = List( + Directive("d0", isIntrospectable = false), + Directive("d1"), + Directive("d2", isIntrospectable = false), + Directive("d3"), + Directive("d4"), + Directive("d5", isIntrospectable = false), + Directive("d6", isIntrospectable = false) + ) + val filtered = all.filter(_.isIntrospectable) + val renderedAll = DocumentRenderer.directivesRenderer.render(all) + val renderedFiltered = DocumentRenderer.directivesRenderer.render(filtered) + assertTrue(renderedAll == renderedFiltered, renderedAll == " @d1 @d3 @d4") + }, test( "it should escape \", \\, backspace, linefeed, carriage-return and tab inside a normally quoted description string" ) { diff --git a/core/src/test/scala/caliban/transformers/TransformerSpec.scala b/core/src/test/scala/caliban/transformers/TransformerSpec.scala index 3dd2d64f20..5c595fd5a3 100644 --- a/core/src/test/scala/caliban/transformers/TransformerSpec.scala +++ b/core/src/test/scala/caliban/transformers/TransformerSpec.scala @@ -2,6 +2,8 @@ package caliban.transformers import caliban.Macros.gqldoc import caliban._ +import caliban.parsing.adt.Directive +import caliban.schema.Annotations.GQLDirective import caliban.schema.ArgBuilder.auto._ import caliban.schema.Schema.auto._ import zio.test._ @@ -218,6 +220,151 @@ object TransformerSpec extends ZIOSpecDefault { | c(arg: String!): String! |}""".stripMargin ) + }, + suite("ExcludeDirectives") { + case class SchemaA() extends GQLDirective(Directive("schemaA", isIntrospectable = false)) + case class SchemaB() extends GQLDirective(Directive("schemaB", isIntrospectable = false)) + + test("fields") { + case class Query( + a: String, + @SchemaA b: Int, + @SchemaB c: Double, + @SchemaA @SchemaB d: Boolean + ) + val api: GraphQL[Any] = graphQL(RootResolver(Query("a", 2, 3d, true))) + val apiA: GraphQL[Any] = api.transform(Transformer.ExcludeDirectives(SchemaA())) + val apiB: GraphQL[Any] = api.transform(Transformer.ExcludeDirectives(_.name == "schemaB")) + val apiC: GraphQL[Any] = api.transform(Transformer.ExcludeDirectives(SchemaA(), SchemaB())) + + for { + _ <- Configurator.setSkipValidation(true) + res0 <- api.interpreterUnsafe.execute("""{ a b c d }""").map(_.data.toString) + resA <- apiA.interpreterUnsafe.execute("""{ a b c d }""").map(_.data.toString) + resB <- apiB.interpreterUnsafe.execute("""{ a b c d }""").map(_.data.toString) + resC <- apiC.interpreterUnsafe.execute("""{ a b c d }""").map(_.data.toString) + rendered = api.render + renderedA = apiA.render + renderedB = apiB.render + renderedC = apiC.render + } yield assertTrue( + res0 == """{"a":"a","b":2,"c":3.0,"d":true}""", + resA == """{"a":"a","b":null,"c":3.0,"d":null}""", + resB == """{"a":"a","b":2,"c":null,"d":null}""", + resC == """{"a":"a","b":null,"c":null,"d":null}""", + rendered == + """schema { + | query: Query + |} + | + |type Query { + | a: String! + | b: Int! + | c: Float! + | d: Boolean! + |}""".stripMargin, + renderedA == + """schema { + | query: Query + |} + | + |type Query { + | a: String! + | c: Float! + |}""".stripMargin, + renderedB == + """schema { + | query: Query + |} + | + |type Query { + | a: String! + | b: Int! + |}""".stripMargin, + renderedC == + """schema { + | query: Query + |} + | + |type Query { + | a: String! + |}""".stripMargin + ) + } + test("input fields") { + case class Nested( + a: String, + @SchemaA b: Option[Int], + @SchemaB c: Option[Double], + @SchemaA @SchemaB d: Option[Boolean] + ) + case class Args(a: String, b: String, l: List[String], nested: Nested) + case class Query(foo: Args => String) + val api: GraphQL[Any] = graphQL(RootResolver(Query(_ => "value"))) + val apiA: GraphQL[Any] = api.transform(Transformer.ExcludeDirectives(SchemaA())) + val apiB: GraphQL[Any] = api.transform(Transformer.ExcludeDirectives(_.name == "schemaB")) + val apiC: GraphQL[Any] = api.transform(Transformer.ExcludeDirectives(SchemaA(), SchemaB())) + + val rendered = api.render + val renderedA = apiA.render + val renderedB = apiB.render + val renderedC = apiC.render + + assertTrue( + rendered == + """schema { + | query: Query + |} + | + |input NestedInput { + | a: String! + | b: Int + | c: Float + | d: Boolean + |} + | + |type Query { + | foo(a: String!, b: String!, l: [String!]!, nested: NestedInput!): String! + |}""".stripMargin, + renderedA == + """schema { + | query: Query + |} + | + |input NestedInput { + | a: String! + | c: Float + |} + | + |type Query { + | foo(a: String!, b: String!, l: [String!]!, nested: NestedInput!): String! + |}""".stripMargin, + renderedB == + """schema { + | query: Query + |} + | + |input NestedInput { + | a: String! + | b: Int + |} + | + |type Query { + | foo(a: String!, b: String!, l: [String!]!, nested: NestedInput!): String! + |}""".stripMargin, + renderedC == + """schema { + | query: Query + |} + | + |input NestedInput { + | a: String! + |} + | + |type Query { + | foo(a: String!, b: String!, l: [String!]!, nested: NestedInput!): String! + |}""".stripMargin + ) + } } ) }