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 directive support #225

Merged
merged 7 commits into from
Feb 22, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions core/src/main/scala/caliban/Rendering.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package caliban

import caliban.Value.{ BooleanValue, EnumValue, FloatValue, IntValue, NullValue, StringValue }
import caliban.introspection.adt._
import caliban.parsing.adt.Directive

object Rendering {

Expand All @@ -21,6 +23,7 @@ object Rendering {
.mkString(" | ")
Some(s"""${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)} = $renderedTypes""")
case _ =>
val renderedDirectives: String = renderDirectives(t.directives)
val renderedFields: String = t
.fields(__DeprecatedArgs())
.fold(List.empty[String])(_.map(renderField))
Expand All @@ -33,7 +36,7 @@ object Rendering {
.fold(List.empty[String])(_.map(renderEnumValue))
.mkString("\n ")
Some(
s"""${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)}${renderInterfaces(t)} {
s"""${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)}${renderInterfaces(t)}$renderedDirectives {
| $renderedFields$renderedInputFields$renderedEnumValues
|}""".stripMargin
)
Expand Down Expand Up @@ -62,13 +65,40 @@ object Rendering {
case Some(value) => if (value.contains("\n")) s"""\"\"\"\n$value\"\"\"\n""" else s""""$value"\n"""
}

private def renderDirectiveArgument(value: InputValue): String = value match {
case InputValue.ListValue(values) =>
values.map(renderDirectiveArgument).mkString("[", ",", "]")
case InputValue.ObjectValue(fields) =>
fields.map { case (key, value) => s"$key: ${renderDirectiveArgument(value)}" }.mkString("{", ",", "}")
case InputValue.VariableValue(_) =>
throw new Exception("Variable values are not allowed in a directive declaration")
Copy link
Owner

Choose a reason for hiding this comment

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

I'd rather not render it here than throwing an exception.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Checking the spec after you mentioned this I realized there are a couple more things to be mindful of: https://spec.graphql.org/June2018/#sec-Type-System.Directives

Validation

A directive definition must not contain the use of a directive which references itself directly.
A directive definition must not contain the use of a directive which references itself indirectly by referencing a Type or Directive which transitively includes a reference to this directive.
The directive must not have a name which begins with the characters __ (two underscores).
For each argument of the directive:
The argument must not have a name which begins with the characters __ (two underscores).
The argument must accept a type where IsInputType(argumentType) returns true.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't know if it would be possible to resolve these at compile time, so it probably would have to be a runtime error, but the question is how should we handle it? Agree that throwing isn't the best option, should we have two rendering methods? one that simply suppresses errors and one that returns an IO[ValidationError, String]

Copy link
Owner

Choose a reason for hiding this comment

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

This is a bit similar to issue #170 where we lack a way to report something abnormal within the schema. Ideally we should fail at the point of building the schema rather than later during query execution or rendering like here.

It bothers me to make graphQL(...) return an effect because attaching the wrappers with @@ wouldn't be as smooth as it is now, but how about making .interpreter return an effect? That way we can build the schema easily, but when we turn it into an interpreter (which we do only once), we verify that the schema is valid and fail if it's not. What do you think?

Copy link
Owner

Choose a reason for hiding this comment

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

Btw if we go for that solution, we can do it in a later PR and just remove the check for now (so that you don't need to implement the whole thing to get this PR merged).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

.interpreter return an effect

I think this makes sense, since it is the "effect" of becoming an interpretable schema.

Ok I'm fixing so that it will just remove the arguments if they aren't valid and then we can fix it with the change you mentioned above.

case NullValue => "null"
case StringValue(value) => "\"" + value + "\""
case i: IntValue => i.toInt.toString
case f: FloatValue => f.toFloat.toString
case BooleanValue(value) => value.toString
case EnumValue(value) => value
}

private def renderDirective(directive: Directive) =
s"@${directive.name}${if (directive.arguments.nonEmpty) s"""(${directive.arguments.map {
case (key, value) => s"$key: ${renderDirectiveArgument(value)}"
}.mkString("{", ",", "}")})"""
else ""}"

private def renderDirectives(directives: Option[List[Directive]]) =
directives.fold("") {
case Nil => ""
case d => d.map(renderDirective).mkString(" ", " ", "")
}

private def renderField(field: __Field): String =
s"${field.name}${renderArguments(field.args)}: ${renderTypeName(field.`type`())}${if (field.isDeprecated)
s" @deprecated${field.deprecationReason.fold("")(reason => s"""(reason: "$reason")""")}"
else ""}"
else ""}${renderDirectives(field.directives)}"

private def renderInputValue(inputValue: __InputValue): String =
s"${inputValue.name}: ${renderTypeName(inputValue.`type`())}${inputValue.defaultValue.fold("")(d => s" = $d")}"
s"${inputValue.name}: ${renderTypeName(inputValue.`type`())}${inputValue.defaultValue.fold("")(d => s" = $d")}${renderDirectives(inputValue.directives)}"

private def renderEnumValue(v: __EnumValue): String =
s"${renderDescription(v.description)}${v.name}${if (v.isDeprecated)
Expand Down
12 changes: 6 additions & 6 deletions core/src/main/scala/caliban/execution/Executor.scala
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,16 @@ object Executor {
case ObjectStep(objectName, fields) =>
val mergedFields = mergeFields(currentField, objectName)
val items = mergedFields.map {
case f @ Field(name @ "__typename", _, _, alias, _, _, _, _) =>
(alias.getOrElse(name), PureStep(StringValue(objectName)), fieldInfo(f, path))
case f @ Field(name, _, _, alias, _, _, args, _) =>
case f @ Field(name @ "__typename", _, _, alias, _, _, _, _, directives) =>
(alias.getOrElse(name), PureStep(StringValue(objectName)), fieldInfo(f, path, directives))
case f @ Field(name, _, _, alias, _, _, args, _, directives) =>
val arguments = resolveVariables(args, request.variableDefinitions, variables)
(
alias.getOrElse(name),
fields
.get(name)
.fold(NullStep: ReducedStep[R])(reduceStep(_, f, arguments, Left(alias.getOrElse(name)) :: path)),
fieldInfo(f, path)
fieldInfo(f, path, directives)
)
}
reduceObject(items, fieldWrappers)
Expand Down Expand Up @@ -182,8 +182,8 @@ object Executor {
.toList
}

private def fieldInfo(field: Field, path: List[Either[String, Int]]): FieldInfo =
FieldInfo(field.alias.getOrElse(field.name), path, field.parentType, field.fieldType)
private def fieldInfo(field: Field, path: List[Either[String, Int]], fieldDirectives: List[Directive]): FieldInfo =
FieldInfo(field.alias.getOrElse(field.name), path, field.parentType, field.fieldType, fieldDirectives)

private def reduceList[R](list: List[ReducedStep[R]]): ReducedStep[R] =
if (list.forall(_.isInstanceOf[PureStep]))
Expand Down
19 changes: 14 additions & 5 deletions core/src/main/scala/caliban/execution/Field.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ case class Field(
fields: List[Field] = Nil,
conditionalFields: Map[String, List[Field]] = Map(),
arguments: Map[String, InputValue] = Map(),
locationInfo: LocationInfo = LocationInfo.origin
locationInfo: LocationInfo = LocationInfo.origin,
directives: List[Directive] = List.empty
)

object Field {
Expand All @@ -26,17 +27,24 @@ object Field {
fragments: Map[String, FragmentDefinition],
variableValues: Map[String, InputValue],
fieldType: __Type,
sourceMapper: SourceMapper
sourceMapper: SourceMapper,
directives: List[Directive]
): Field = {

def loop(selectionSet: List[Selection], fieldType: __Type): Field = {
val innerType = Types.innerType(fieldType)
val (fields, cFields) = selectionSet.map {
case f @ F(alias, name, arguments, _, selectionSet, index) if checkDirectives(f.directives, variableValues) =>
val t = innerType
case F(alias, name, arguments, directives, selectionSet, index)
if checkDirectives(directives, variableValues) =>
val selected = innerType
.fields(__DeprecatedArgs(Some(true)))
.flatMap(_.find(_.name == name))

val schemaDirectives = selected.flatMap(_.directives).getOrElse(Nil)

val t = selected
.fold(Types.string)(_.`type`()) // default only case where it's not found is __typename

val field = loop(selectionSet, t)
(
List(
Expand All @@ -48,7 +56,8 @@ object Field {
field.fields,
field.conditionalFields,
arguments,
sourceMapper.getLocation(index)
sourceMapper.getLocation(index),
directives ++ schemaDirectives
)
),
Map.empty[String, List[Field]]
Expand Down
9 changes: 8 additions & 1 deletion core/src/main/scala/caliban/execution/FieldInfo.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package caliban.execution

import caliban.introspection.adt.__Type
import caliban.parsing.adt.Directive

case class FieldInfo(fieldName: String, path: List[Either[String, Int]], parentType: Option[__Type], returnType: __Type)
case class FieldInfo(
fieldName: String,
path: List[Either[String, Int]],
parentType: Option[__Type],
returnType: __Type,
directives: List[Directive] = Nil
)
8 changes: 7 additions & 1 deletion core/src/main/scala/caliban/introspection/Introspector.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ object Introspector {
def introspect(rootType: RootType): RootSchema[Any] = {
val types = rootType.types.updated("Boolean", Types.boolean).values.toList.sortBy(_.name.getOrElse(""))
val resolver = __Introspection(
__Schema(rootType.queryType, rootType.mutationType, rootType.subscriptionType, types, directives),
__Schema(
rootType.queryType,
rootType.mutationType,
rootType.subscriptionType,
types,
directives ++ rootType.additionalDirectives
),
args => types.find(_.name.contains(args.name)).get
)
val introspectionSchema = Schema.gen[__Introspection]
Expand Down
5 changes: 4 additions & 1 deletion core/src/main/scala/caliban/introspection/adt/__Field.scala
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package caliban.introspection.adt

import caliban.parsing.adt.Directive

case class __Field(
name: String,
description: Option[String],
args: List[__InputValue],
`type`: () => __Type,
isDeprecated: Boolean = false,
deprecationReason: Option[String] = None
deprecationReason: Option[String] = None,
directives: Option[List[Directive]] = None
)
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
package caliban.introspection.adt

case class __InputValue(name: String, description: Option[String], `type`: () => __Type, defaultValue: Option[String])
import caliban.parsing.adt.Directive

case class __InputValue(
name: String,
description: Option[String],
`type`: () => __Type,
defaultValue: Option[String],
directives: Option[List[Directive]] = None
)
8 changes: 6 additions & 2 deletions core/src/main/scala/caliban/introspection/adt/__Type.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package caliban.introspection.adt

import caliban.parsing.adt.Directive

case class __Type(
kind: __TypeKind,
name: Option[String] = None,
Expand All @@ -9,7 +11,8 @@ case class __Type(
possibleTypes: Option[List[__Type]] = None,
enumValues: __DeprecatedArgs => Option[List[__EnumValue]] = _ => None,
inputFields: Option[List[__InputValue]] = None,
ofType: Option[__Type] = None
ofType: Option[__Type] = None,
directives: Option[List[Directive]] = None
) {
def |+|(that: __Type): __Type = __Type(
kind,
Expand All @@ -23,6 +26,7 @@ case class __Type(
(enumValues(args) ++ that.enumValues(args))
.reduceOption((a, b) => a.filterNot(v => b.exists(_.name == v.name)) ++ b),
(inputFields ++ that.inputFields).reduceOption((a, b) => a.filterNot(t => b.exists(_.name == t.name)) ++ b),
(ofType ++ that.ofType).reduceOption(_ |+| _)
(ofType ++ that.ofType).reduceOption(_ |+| _),
(directives ++ that.directives).reduceOption(_ ++ _)
)
}
2 changes: 1 addition & 1 deletion core/src/main/scala/caliban/parsing/adt/Directive.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ package caliban.parsing.adt

import caliban.InputValue

case class Directive(name: String, arguments: Map[String, InputValue], index: Int)
case class Directive(name: String, arguments: Map[String, InputValue] = Map.empty, index: Int = 0)
7 changes: 7 additions & 0 deletions core/src/main/scala/caliban/schema/Annotations.scala
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

object Annotations {
Expand All @@ -25,6 +27,11 @@ object Annotations {
*/
case class GQLName(value: String) extends StaticAnnotation

/**
* Annotation used to provide directives to a schema type
*/
case class GQLDirective(directive: Directive) extends StaticAnnotation

/**
* Annotation to make a sealed trait an interface instead of a union type
*/
Expand Down
9 changes: 7 additions & 2 deletions core/src/main/scala/caliban/schema/RootType.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package caliban.schema

import caliban.introspection.adt.__Type
import caliban.introspection.adt.{ __Directive, __Type }
import caliban.schema.Types.collectTypes

case class RootType(queryType: __Type, mutationType: Option[__Type], subscriptionType: Option[__Type]) {
case class RootType(
queryType: __Type,
mutationType: Option[__Type],
subscriptionType: Option[__Type],
additionalDirectives: List[__Directive] = List.empty
) {
val empty = Map.empty[String, __Type]
val types: Map[String, __Type] =
collectTypes(queryType) ++
Expand Down
25 changes: 18 additions & 7 deletions core/src/main/scala/caliban/schema/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import scala.language.experimental.macros
import caliban.ResponseValue._
import caliban.Value._
import caliban.introspection.adt._
import caliban.schema.Annotations.{ GQLDeprecated, GQLDescription, GQLInputName, GQLInterface, GQLName }
import caliban.parsing.adt.Directive
import caliban.schema.Annotations.{ GQLDeprecated, GQLDescription, GQLDirective, GQLInputName, GQLInterface, GQLName }
import caliban.schema.Step._
import caliban.schema.Types._
import caliban.{ InputValue, ResponseValue }
Expand Down Expand Up @@ -89,7 +90,8 @@ trait GenericSchema[R] extends DerivationSchema[R] {
def objectSchema[R1, A](
name: String,
description: Option[String],
fields: List[(__Field, A => Step[R1])]
fields: List[(__Field, A => Step[R1])],
directives: List[Directive] = List.empty
): Schema[R1, A] =
new Schema[R1, A] {

Expand All @@ -98,7 +100,7 @@ trait GenericSchema[R] extends DerivationSchema[R] {
makeInputObject(Some(customizeInputTypeName(name)), description, fields.map {
case (f, _) => __InputValue(f.name, f.description, f.`type`, None)
})
} else makeObject(Some(name), description, fields.map(_._1))
} else makeObject(Some(name), description, fields.map(_._1), directives)

override def resolve(value: A): Step[R1] =
ObjectStep(name, fields.map { case (f, plan) => f.name -> plan(value) }.toMap)
Expand Down Expand Up @@ -295,7 +297,8 @@ trait DerivationSchema[R] {
getDescription(p),
() =>
if (p.typeclass.optional) p.typeclass.toType(isInput) else makeNonNull(p.typeclass.toType(isInput)),
None
None,
Some(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty)
)
)
.toList
Expand All @@ -314,10 +317,12 @@ trait DerivationSchema[R] {
() =>
if (p.typeclass.optional) p.typeclass.toType(isInput) else makeNonNull(p.typeclass.toType(isInput)),
p.annotations.collectFirst { case GQLDeprecated(_) => () }.isDefined,
p.annotations.collectFirst { case GQLDeprecated(reason) => reason }
p.annotations.collectFirst { case GQLDeprecated(reason) => reason },
Option(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty)
)
)
.toList
.toList,
getDirectives(ctx)
)

override def resolve(value: T): Step[R] =
Expand Down Expand Up @@ -346,7 +351,7 @@ trait DerivationSchema[R] {
Some(getName(ctx)),
getDescription(ctx),
subtypes.collect {
case (__Type(_, Some(name), description, _, _, _, _, _, _), annotations) =>
case (__Type(_, Some(name), description, _, _, _, _, _, _, _), annotations) =>
__EnumValue(
name,
description,
Expand Down Expand Up @@ -408,6 +413,12 @@ trait DerivationSchema[R] {
ctx.dispatch(value)(subType => subType.typeclass.resolve(subType.cast(value)))
}

private def getDirectives(annotations: Seq[Any]): List[Directive] =
annotations.collect { case GQLDirective(dir) => dir }.toList

private def getDirectives[Typeclass[_], Type](ctx: CaseClass[Typeclass, Type]): List[Directive] =
getDirectives(ctx.annotations)

private def getName(annotations: Seq[Any], typeName: TypeName): String =
annotations.collectFirst { case GQLName(name) => name }
.getOrElse(typeName.short + typeName.typeArguments.map(_.short).mkString)
Expand Down
11 changes: 9 additions & 2 deletions core/src/main/scala/caliban/schema/Types.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package caliban.schema

import caliban.introspection.adt._
import caliban.parsing.adt.Directive

object Types {

Expand Down Expand Up @@ -29,13 +30,19 @@ 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]): __Type =
def makeObject(
name: Option[String],
description: Option[String],
fields: List[__Field],
directives: List[Directive]
): __Type =
__Type(
__TypeKind.OBJECT,
name,
description,
fields = args => Some(fields.filter(v => args.includeDeprecated.getOrElse(false) || !v.isDeprecated)),
interfaces = () => Some(Nil)
interfaces = () => Some(Nil),
directives = Some(directives)
)

def makeInputObject(name: Option[String], description: Option[String], fields: List[__InputValue]): __Type =
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/scala/caliban/validation/Validator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ object Validator {
}).map(
operation =>
ExecutionRequest(
F(op.selectionSet, fragments, variables, operation.opType, document.sourceMapper),
F(op.selectionSet, fragments, variables, operation.opType, document.sourceMapper, Nil),
op.operationType,
op.variableDefinitions
)
Expand Down Expand Up @@ -524,7 +524,7 @@ object Validator {
.filter(_.operationType == OperationType.Subscription)
.find(
op =>
F(op.selectionSet, context.fragments, Map.empty[String, InputValue], t, SourceMapper.empty).fields.length > 1
F(op.selectionSet, context.fragments, Map.empty[String, InputValue], t, SourceMapper.empty, Nil).fields.length > 1
)
} yield op
)
Expand Down
Loading