diff --git a/core/src/main/scala/caliban/Rendering.scala b/core/src/main/scala/caliban/Rendering.scala index f519b4c9b..b35f9e60d 100644 --- a/core/src/main/scala/caliban/Rendering.scala +++ b/core/src/main/scala/caliban/Rendering.scala @@ -1,44 +1,66 @@ package caliban +import caliban.Value.{ BooleanValue, EnumValue, FloatValue, IntValue, NullValue, StringValue } import caliban.introspection.adt._ +import caliban.parsing.adt.Directive object Rendering { + private implicit val kindOrdering: Ordering[__TypeKind] = Ordering + .by[__TypeKind, Int] { + case __TypeKind.SCALAR => 1 + case __TypeKind.NON_NULL => 2 + case __TypeKind.LIST => 3 + case __TypeKind.UNION => 4 + case __TypeKind.ENUM => 5 + case __TypeKind.INPUT_OBJECT => 6 + case __TypeKind.INTERFACE => 7 + case __TypeKind.OBJECT => 8 + } + + private implicit val renderOrdering: Ordering[(String, __Type)] = Ordering.by(o => (o._2.kind, o._2.name)) + /** * Returns a string that renders the provided types into the GraphQL format. */ def renderTypes(types: Map[String, __Type]): String = - types.flatMap { - case (_, t) => - t.kind match { - case __TypeKind.SCALAR => t.name.flatMap(name => if (isBuiltinScalar(name)) None else Some(s"scalar $name")) - case __TypeKind.NON_NULL => None - case __TypeKind.LIST => None - case __TypeKind.UNION => - val renderedTypes: String = - t.possibleTypes - .fold(List.empty[String])(_.flatMap(_.name)) - .mkString(" | ") - Some(s"""${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)} = $renderedTypes""") - case _ => - val renderedFields: String = t - .fields(__DeprecatedArgs()) - .fold(List.empty[String])(_.map(renderField)) - .mkString("\n ") - val renderedInputFields: String = t.inputFields - .fold(List.empty[String])(_.map(renderInputValue)) - .mkString("\n ") - val renderedEnumValues = t - .enumValues(__DeprecatedArgs()) - .fold(List.empty[String])(_.map(renderEnumValue)) - .mkString("\n ") - Some( - s"""${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)}${renderInterfaces(t)} { - | $renderedFields$renderedInputFields$renderedEnumValues - |}""".stripMargin - ) - } - }.mkString("\n\n") + types.toList + .sorted(renderOrdering) + .flatMap { + case (_, t) => + t.kind match { + case __TypeKind.SCALAR => t.name.flatMap(name => if (isBuiltinScalar(name)) None else Some(s"scalar $name")) + case __TypeKind.NON_NULL => None + case __TypeKind.LIST => None + case __TypeKind.UNION => + val renderedTypes: String = + t.possibleTypes + .fold(List.empty[String])(_.flatMap(_.name)) + .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)) + .mkString("\n ") + val renderedInputFields: String = t.inputFields + .fold(List.empty[String])(_.map(renderInputValue)) + .mkString("\n ") + val renderedEnumValues = t + .enumValues(__DeprecatedArgs()) + .fold(List.empty[String])(_.map(renderEnumValue)) + .mkString("\n ") + Some( + s"""${renderDescription(t.description)}${renderKind(t.kind)} ${renderTypeName(t)}${renderInterfaces(t)}$renderedDirectives { + | $renderedFields$renderedInputFields$renderedEnumValues + |}""".stripMargin + ) + } + } + .mkString("\n\n") private def renderInterfaces(t: __Type): String = t.interfaces() @@ -62,13 +84,41 @@ object Rendering { case Some(value) => if (value.contains("\n")) s"""\"\"\"\n$value\"\"\"\n""" else s""""$value"\n""" } + private def renderDirectiveArgument(value: InputValue): Option[String] = value match { + case InputValue.ListValue(values) => + Some(values.flatMap(renderDirectiveArgument).mkString("[", ",", "]")) + case InputValue.ObjectValue(fields) => + Some( + fields.map { case (key, value) => renderDirectiveArgument(value).map(v => s"$key: $v") }.mkString("{", ",", "}") + ) + case NullValue => Some("null") + case StringValue(value) => Some("\"" + value + "\"") + case i: IntValue => Some(i.toInt.toString) + case f: FloatValue => Some(f.toFloat.toString) + case BooleanValue(value) => Some(value.toString) + case EnumValue(value) => Some(value) + case InputValue.VariableValue(_) => None + } + + private def renderDirective(directive: Directive) = + s"@${directive.name}${if (directive.arguments.nonEmpty) s"""(${directive.arguments.flatMap { + case (key, value) => renderDirectiveArgument(value).map(v => s"$key: $v") + }.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) diff --git a/core/src/main/scala/caliban/execution/Executor.scala b/core/src/main/scala/caliban/execution/Executor.scala index c8d91c5de..a87041fe3 100644 --- a/core/src/main/scala/caliban/execution/Executor.scala +++ b/core/src/main/scala/caliban/execution/Executor.scala @@ -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) @@ -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])) diff --git a/core/src/main/scala/caliban/execution/Field.scala b/core/src/main/scala/caliban/execution/Field.scala index e13c6e6de..d9bc002b0 100644 --- a/core/src/main/scala/caliban/execution/Field.scala +++ b/core/src/main/scala/caliban/execution/Field.scala @@ -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 { @@ -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( @@ -48,7 +56,8 @@ object Field { field.fields, field.conditionalFields, arguments, - sourceMapper.getLocation(index) + sourceMapper.getLocation(index), + directives ++ schemaDirectives ) ), Map.empty[String, List[Field]] diff --git a/core/src/main/scala/caliban/execution/FieldInfo.scala b/core/src/main/scala/caliban/execution/FieldInfo.scala index 5a04bba2d..47db13537 100644 --- a/core/src/main/scala/caliban/execution/FieldInfo.scala +++ b/core/src/main/scala/caliban/execution/FieldInfo.scala @@ -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 +) diff --git a/core/src/main/scala/caliban/introspection/Introspector.scala b/core/src/main/scala/caliban/introspection/Introspector.scala index bb507ad9b..adf7cc436 100644 --- a/core/src/main/scala/caliban/introspection/Introspector.scala +++ b/core/src/main/scala/caliban/introspection/Introspector.scala @@ -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] diff --git a/core/src/main/scala/caliban/introspection/adt/__Field.scala b/core/src/main/scala/caliban/introspection/adt/__Field.scala index fa13708c7..a32b2d67b 100644 --- a/core/src/main/scala/caliban/introspection/adt/__Field.scala +++ b/core/src/main/scala/caliban/introspection/adt/__Field.scala @@ -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 ) diff --git a/core/src/main/scala/caliban/introspection/adt/__InputValue.scala b/core/src/main/scala/caliban/introspection/adt/__InputValue.scala index dfdc447b3..7b1a6af7b 100644 --- a/core/src/main/scala/caliban/introspection/adt/__InputValue.scala +++ b/core/src/main/scala/caliban/introspection/adt/__InputValue.scala @@ -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 +) diff --git a/core/src/main/scala/caliban/introspection/adt/__Type.scala b/core/src/main/scala/caliban/introspection/adt/__Type.scala index 64e7a8a81..9ef90544c 100644 --- a/core/src/main/scala/caliban/introspection/adt/__Type.scala +++ b/core/src/main/scala/caliban/introspection/adt/__Type.scala @@ -1,5 +1,7 @@ package caliban.introspection.adt +import caliban.parsing.adt.Directive + case class __Type( kind: __TypeKind, name: Option[String] = None, @@ -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, @@ -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(_ ++ _) ) } diff --git a/core/src/main/scala/caliban/parsing/adt/Directive.scala b/core/src/main/scala/caliban/parsing/adt/Directive.scala index 8c26c8bee..31534cfa6 100644 --- a/core/src/main/scala/caliban/parsing/adt/Directive.scala +++ b/core/src/main/scala/caliban/parsing/adt/Directive.scala @@ -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) diff --git a/core/src/main/scala/caliban/schema/Annotations.scala b/core/src/main/scala/caliban/schema/Annotations.scala index f6825f6fc..6ed7ff3f9 100644 --- a/core/src/main/scala/caliban/schema/Annotations.scala +++ b/core/src/main/scala/caliban/schema/Annotations.scala @@ -1,5 +1,7 @@ package caliban.schema +import caliban.parsing.adt.Directive + import scala.annotation.StaticAnnotation object Annotations { @@ -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 */ diff --git a/core/src/main/scala/caliban/schema/RootType.scala b/core/src/main/scala/caliban/schema/RootType.scala index e01277988..54efa125d 100644 --- a/core/src/main/scala/caliban/schema/RootType.scala +++ b/core/src/main/scala/caliban/schema/RootType.scala @@ -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) ++ diff --git a/core/src/main/scala/caliban/schema/Schema.scala b/core/src/main/scala/caliban/schema/Schema.scala index 5129c6e7e..2c47803c8 100644 --- a/core/src/main/scala/caliban/schema/Schema.scala +++ b/core/src/main/scala/caliban/schema/Schema.scala @@ -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 } @@ -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] { @@ -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) @@ -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 @@ -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] = @@ -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, @@ -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) diff --git a/core/src/main/scala/caliban/schema/Types.scala b/core/src/main/scala/caliban/schema/Types.scala index d3b5a86a5..5d3603357 100644 --- a/core/src/main/scala/caliban/schema/Types.scala +++ b/core/src/main/scala/caliban/schema/Types.scala @@ -1,6 +1,7 @@ package caliban.schema import caliban.introspection.adt._ +import caliban.parsing.adt.Directive object Types { @@ -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 = diff --git a/core/src/main/scala/caliban/validation/Validator.scala b/core/src/main/scala/caliban/validation/Validator.scala index 4fc091ef8..376d16280 100644 --- a/core/src/main/scala/caliban/validation/Validator.scala +++ b/core/src/main/scala/caliban/validation/Validator.scala @@ -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 ) @@ -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 ) diff --git a/core/src/main/scala/caliban/wrappers/ApolloCaching.scala b/core/src/main/scala/caliban/wrappers/ApolloCaching.scala new file mode 100644 index 000000000..ea57266e6 --- /dev/null +++ b/core/src/main/scala/caliban/wrappers/ApolloCaching.scala @@ -0,0 +1,145 @@ +package caliban.wrappers + +import java.util.concurrent.TimeUnit + +import caliban.ResponseValue +import caliban.ResponseValue.{ ListValue, ObjectValue } +import caliban.Value.{ EnumValue, IntValue, StringValue } +import caliban.parsing.adt.Directive +import caliban.wrappers.Wrapper.{ EffectfulWrapper, FieldWrapper, OverallWrapper } +import zio.Ref +import zio.duration.Duration +import zquery.ZQuery + +/** + * Returns a wrapper which applies apollo caching response extensions + */ +object ApolloCaching { + + private val directiveName = "cacheControl" + + object CacheControl { + + def apply(scope: ApolloCaching.CacheScope): Directive = + Directive(directiveName, Map("scope" -> EnumValue(scope.toString))) + + def apply(maxAge: Duration): Directive = + Directive(directiveName, Map("maxAge" -> IntValue(maxAge.toMillis / 1000))) + + def apply(maxAge: Duration, scope: ApolloCaching.CacheScope): Directive = + Directive(directiveName, Map("maxAge" -> IntValue(maxAge.toMillis / 1000), "scope" -> EnumValue(scope.toString))) + + } + + val apolloCaching: EffectfulWrapper[Any] = + EffectfulWrapper( + Ref.make(Caching()).map(ref => apolloCachingOverall(ref) |+| apolloCachingField(ref)) + ) + + sealed trait CacheScope + + object CacheScope { + + case object Private extends CacheScope { + override def toString: String = "PRIVATE" + } + + case object Public extends CacheScope { + override def toString: String = "PUBLIC" + } + + } + + case class CacheHint( + fieldName: String = "", + path: List[Either[String, Int]] = Nil, + maxAge: Duration, + scope: CacheScope + ) { + + def toResponseValue: ResponseValue = + ObjectValue( + List( + "path" -> ListValue((Left(fieldName) :: path).reverse.map(_.fold(StringValue, IntValue(_)))), + "maxAge" -> IntValue(maxAge.toMillis / 1000), + "scope" -> StringValue(scope match { + case CacheScope.Private => "PRIVATE" + case CacheScope.Public => "PUBLIC" + }) + ) + ) + + } + + case class Caching( + version: Int = 1, + hints: List[CacheHint] = List.empty + ) { + + def toResponseValue: ResponseValue = + ObjectValue(List("version" -> IntValue(version), "hints" -> ListValue(hints.map(_.toResponseValue)))) + } + + case class CacheDirective(scope: Option[CacheScope] = None, maxAge: Option[Duration] = None) + + private def extractCacheDirective(directives: List[Directive]): Option[CacheDirective] = + directives.collectFirst { + case d if d.name == directiveName => + val scope = d.arguments.get("scope").collectFirst { + case StringValue("PRIVATE") | EnumValue("PRIVATE") => CacheScope.Private + case StringValue("PUBLIC") | EnumValue("PUBLIC") => CacheScope.Public + } + + val maxAge = d.arguments.get("maxAge").collectFirst { + case i: IntValue => Duration(i.toLong, TimeUnit.SECONDS) + } + + CacheDirective(scope, maxAge) + } + + private def apolloCachingOverall(ref: Ref[Caching]): OverallWrapper[Any] = + OverallWrapper { + case (io, _) => + for { + result <- io + cache <- ref.get + } yield result.copy( + extensions = Some( + ObjectValue( + ("cacheControl" -> cache.toResponseValue) :: result.extensions.fold( + List.empty[(String, ResponseValue)] + )(_.fields) + ) + ) + ) + } + + private def apolloCachingField(ref: Ref[Caching]): FieldWrapper[Any] = + FieldWrapper( + { + case (query, fieldInfo) => + val cacheDirectives = extractCacheDirective( + fieldInfo.directives ++ fieldInfo.returnType.ofType.flatMap(_.directives).getOrElse(Nil) + ) + + cacheDirectives.foldLeft(query) { + case (q, cacheDirective) => + q <* ZQuery.fromEffect( + ref.update( + state => + state.copy( + hints = CacheHint( + path = fieldInfo.path, + fieldName = fieldInfo.fieldName, + maxAge = cacheDirective.maxAge getOrElse Duration.Zero, + scope = cacheDirective.scope getOrElse CacheScope.Private + ) :: state.hints + ) + ) + ) + } + }, + wrapPureValues = true + ) + +} diff --git a/core/src/test/scala/caliban/RenderingSpec.scala b/core/src/test/scala/caliban/RenderingSpec.scala new file mode 100644 index 000000000..cd767f2f1 --- /dev/null +++ b/core/src/test/scala/caliban/RenderingSpec.scala @@ -0,0 +1,61 @@ +package caliban + +import zio.test._ +import Assertion._ +import TestUtils._ +import caliban.GraphQL._ + +object RenderingSpec + extends DefaultRunnableSpec( + suite("rendering")( + test("it should render directives") { + assert( + graphQL(resolver).render.trim, + equalTo("""union Role = Captain | Engineer | Mechanic | Pilot + | + |enum Origin { + | BELT + | EARTH + | MARS + |} + | + |input CharacterInput { + | name: String! @external + | nicknames: [String!]! @required + | origin: Origin! + | role: Role + |} + | + |type Captain { + | shipName: String! + |} + | + |type Character @key({name: "name"}) { + | name: String! @external + | nicknames: [String!]! @required + | origin: Origin! + | role: Role + |} + | + |type Engineer { + | shipName: String! + |} + | + |type Mechanic { + | shipName: String! + |} + | + |type Pilot { + | shipName: String! + |} + | + |"Queries" + |type Query { + | characters(origin: Origin): [Character!]! + | charactersIn(names: [String!]!): [Character!]! + | exists(character: CharacterInput!): Boolean! + |}""".stripMargin.trim) + ) + } + ) + ) diff --git a/core/src/test/scala/caliban/TestUtils.scala b/core/src/test/scala/caliban/TestUtils.scala index b762a31da..90b362a61 100644 --- a/core/src/test/scala/caliban/TestUtils.scala +++ b/core/src/test/scala/caliban/TestUtils.scala @@ -2,7 +2,9 @@ package caliban import caliban.TestUtils.Origin._ import caliban.TestUtils.Role._ -import caliban.schema.Annotations.{ GQLDeprecated, GQLDescription, GQLInterface } +import caliban.Value.StringValue +import caliban.parsing.adt.Directive +import caliban.schema.Annotations.{ GQLDeprecated, GQLDescription, GQLDirective, GQLInterface } import caliban.schema.Schema import zio.UIO import zio.stream.ZStream @@ -34,7 +36,13 @@ object TestUtils { case class Mechanic(shipName: String) extends Role } - case class Character(name: String, nicknames: List[String], origin: Origin, role: Option[Role]) + @GQLDirective(Directive("key", Map("name" -> StringValue("name")))) + case class Character( + @GQLDirective(Directive("external")) name: String, + @GQLDirective(Directive("required")) nicknames: List[String], + origin: Origin, + role: Option[Role] + ) object Character { implicit val schema: Schema[Any, Character] = Schema.gen[Character] @@ -52,7 +60,7 @@ object TestUtils { case class CharactersArgs(origin: Option[Origin]) case class CharacterArgs(name: String) - case class CharacterInArgs(names: List[String]) + case class CharacterInArgs(@GQLDirective(Directive("lowercase")) names: List[String]) case class CharacterObjectArgs(character: Character) @GQLDescription("Queries") diff --git a/core/src/test/scala/caliban/wrappers/WrappersSpec.scala b/core/src/test/scala/caliban/wrappers/WrappersSpec.scala index cfe97cc12..c89399c0a 100644 --- a/core/src/test/scala/caliban/wrappers/WrappersSpec.scala +++ b/core/src/test/scala/caliban/wrappers/WrappersSpec.scala @@ -1,11 +1,12 @@ package caliban.wrappers import scala.language.postfixOps - import caliban.CalibanError.{ ExecutionError, ValidationError } import caliban.GraphQL._ import caliban.Macros.gqldoc +import caliban.schema.Annotations.GQLDirective import caliban.schema.GenericSchema +import caliban.wrappers.ApolloCaching.CacheControl import caliban.wrappers.Wrappers._ import caliban.{ CalibanError, GraphQLInterpreter, RootResolver } import zio.clock.Clock @@ -15,6 +16,8 @@ import zio.test._ import zio.test.environment.TestClock import zio.{ clock, Promise, URIO, ZIO } +import scala.language.postfixOps + object WrappersSpec extends DefaultRunnableSpec( suite("WrappersSpec")( @@ -144,6 +147,51 @@ object WrappersSpec ) ) ) + }, + testM("Apollo Caching") { + case class Query(@GQLDirective(CacheControl(10.seconds)) hero: Hero) + + @GQLDirective(CacheControl(2.seconds)) + case class Hero(name: URIO[Clock, String], friends: List[Hero] = Nil) + + object schema extends GenericSchema[Clock] + import schema._ + + def interpreter: GraphQLInterpreter[Clock, CalibanError] = + (graphQL( + RootResolver( + Query( + Hero( + ZIO.succeed("R2-D2"), + List( + Hero(ZIO.succeed("Luke Skywalker")), + Hero(ZIO.succeed("Han Solo")), + Hero(ZIO.succeed("Leia Organa")) + ) + ) + ) + ) + ) @@ ApolloCaching.apolloCaching).interpreter + + val query = gqldoc(""" + { + hero { + name + friends { + name + } + } + }""") + assertM( + for { + result <- interpreter.execute(query).map(_.extensions.map(_.toString)) + } yield result, + isSome( + equalTo( + "{\"cacheControl\":{\"version\":1,\"hints\":[{\"path\":[\"hero\"],\"maxAge\":10,\"scope\":\"PRIVATE\"}]}}" + ) + ) + ) } ) ) diff --git a/vuepress/docs/docs/middleware.md b/vuepress/docs/docs/middleware.md index 791f5f63e..f7adee763 100644 --- a/vuepress/docs/docs/middleware.md +++ b/vuepress/docs/docs/middleware.md @@ -5,7 +5,7 @@ Caliban allows you to perform additional actions at various levels of a query pr - modify a query before it's executed - add timeouts to queries or fields - log each field execution time -- support [Apollo Tracing](https://github.com/apollographql/apollo-tracing) or anything similar +- support [Apollo Tracing](https://github.com/apollographql/apollo-tracing), [Apollo Caching](https://github.com/apollographql/apollo-cache-control) or anything similar - etc. ## Wrapper types @@ -55,7 +55,9 @@ Caliban comes with a few pre-made wrappers in `caliban.wrappers.Wrappers`: - `printSlowQueries` returns a wrapper that prints slow queries - `onSlowQueries` returns a wrapper that can run a given function on slow queries -In addition to those, `caliban.wrappers.ApolloTracing.apolloTracing` returns a wrapper that adds tracing data into the `extensions` field of each response following [Apollo Tracing](https://github.com/apollographql/apollo-tracing) format. +In addition to those, Caliban also ships with some non-spec but standard wrappers +- `caliban.wrappers.ApolloTracing.apolloTracing` returns a wrapper that adds tracing data into the `extensions` field of each response following [Apollo Tracing](https://github.com/apollographql/apollo-tracing) format. +- `caliban.wrappers.ApolloCaching.apolloCaching` returns a wrapper that adds caching hints to properly annotated fields using the [Apollo Caching](https://github.com/apollographql/apollo-cache-control) format. They can be used like this: ```scala @@ -64,7 +66,8 @@ val api = maxDepth(50) @@ timeout(3 seconds) @@ printSlowQueries(500 millis) @@ - apolloTracing + apolloTracing @@ + apolloCaching ``` ## Wrapping the interpreter diff --git a/vuepress/docs/docs/schema.md b/vuepress/docs/docs/schema.md index 91d24bc1b..e39900c30 100644 --- a/vuepress/docs/docs/schema.md +++ b/vuepress/docs/docs/schema.md @@ -156,7 +156,8 @@ 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 +- `@GQLInterface` to force a sealed trait generating an interface instead of a union. +- `@GQLDirective(directive: Directive)` to add a directive to a field or type. ## Custom types