Skip to content

Commit

Permalink
Add transformer that excludes fields / inputs based on directives (#2293
Browse files Browse the repository at this point in the history
)

* Add transformer that excludes fields / inputs based on tags

* Fix scaladoc

* Fix Scala 3 derivation

* Define tags as non-introspectable directives

* Change transformer to work on directives instead

* Fix for Scala 3

* PR comment

* Add overload of `ExcludeDirectives.apply` that takes a predicate
  • Loading branch information
kyri-petrou authored Jun 27, 2024
1 parent 1180ee6 commit 7078afb
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 30 deletions.
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

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

}
75 changes: 72 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,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] =
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

0 comments on commit 7078afb

Please sign in to comment.