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

Add transformer that excludes fields / inputs based on directives #2293

Merged
merged 11 commits into from
Jun 27, 2024
Original file line number Diff line number Diff line change
@@ -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)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package caliban.schema

import caliban.parsing.adt.Directive

import scala.annotation.StaticAnnotation

trait AnnotationsVersionSpecific {
Expand All @@ -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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made this an open class in Scala 3, so that users can extend it without having to add the import scala.language.adhocExtensions import. More info on open classes here


object GQLDirective {
def unapply(annotation: GQLDirective): Option[Directive] =
Some(annotation.directive)
}

}
6 changes: 3 additions & 3 deletions core/src/main/scala/caliban/execution/Fragment.scala
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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]) {}

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
} =>
Expand All @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 7 additions & 6 deletions core/src/main/scala/caliban/introspection/adt/__Type.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
11 changes: 9 additions & 2 deletions core/src/main/scala/caliban/parsing/adt/Directive.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion core/src/main/scala/caliban/rendering/DocumentRenderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]] {
Expand Down
14 changes: 2 additions & 12 deletions core/src/main/scala/caliban/schema/Annotations.scala
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -80,5 +71,4 @@ object Annotations extends AnnotationsVersionSpecific {
* Annotation to make a sealed trait as a GraphQL @oneOf input
*/
case class GQLOneOfInput() extends StaticAnnotation

}
68 changes: 65 additions & 3 deletions core/src/main/scala/caliban/transformers/Transformer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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]

Expand Down Expand Up @@ -326,20 +328,80 @@ 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] =
kyri-petrou marked this conversation as resolved.
Show resolved Hide resolved
if (directives.isEmpty) Empty else new ExcludeDirectives(directives.map(_.directive).toSet)
}

final private class ExcludeDirectives(set: Set[Directive]) 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(d => set.contains(d))
}

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] =
Expand Down
3 changes: 2 additions & 1 deletion core/src/main/scala/caliban/wrappers/DeferSupport.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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),
_ =>
Expand Down
15 changes: 15 additions & 0 deletions core/src/test/scala/caliban/RenderingSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
) {
Expand Down
Loading