diff --git a/core/src/main/scala-2/caliban/parsing/Parser.scala b/core/src/main/scala-2/caliban/parsing/Parser.scala index 2f047ac43..89fdc9d4f 100644 --- a/core/src/main/scala-2/caliban/parsing/Parser.scala +++ b/core/src/main/scala-2/caliban/parsing/Parser.scala @@ -1,10 +1,13 @@ package caliban.parsing import caliban.CalibanError.ParsingError +import caliban.InputValue import caliban.parsing.adt._ import fastparse._ import zio.{ IO, Task } +import scala.util.Try + object Parser { import caliban.parsing.parsers.Parsers._ @@ -21,6 +24,16 @@ object Parser { } } + def parseInputValue(rawValue: String): Either[ParsingError, InputValue] = { + val sm = SourceMapper(rawValue) + Try(parse(rawValue, value(_))).toEither.left + .map(ex => ParsingError(s"Internal parsing error", innerThrowable = Some(ex))) + .flatMap { + case Parsed.Success(value, _) => Right(value) + case f: Parsed.Failure => Left(ParsingError(f.msg, Some(sm.getLocation(f.index)))) + } + } + /** * Checks if the query is valid, if not returns an error string. */ diff --git a/core/src/main/scala-2/caliban/schema/ArgBuilderDerivation.scala b/core/src/main/scala-2/caliban/schema/ArgBuilderDerivation.scala index cd336379a..022a783b4 100644 --- a/core/src/main/scala-2/caliban/schema/ArgBuilderDerivation.scala +++ b/core/src/main/scala-2/caliban/schema/ArgBuilderDerivation.scala @@ -3,6 +3,8 @@ package caliban.schema import caliban.CalibanError.ExecutionError import caliban.InputValue import caliban.Value._ +import caliban.parsing.Parser +import caliban.schema.Annotations.GQLDefault import caliban.schema.Annotations.GQLName import magnolia._ import mercator.Monadic @@ -30,8 +32,9 @@ trait ArgBuilderDerivation { ctx.constructMonadic { p => input match { case InputValue.ObjectValue(fields) => - val label = p.annotations.collectFirst { case GQLName(name) => name }.getOrElse(p.label) - fields.get(label).fold(p.typeclass.buildMissing)(p.typeclass.build) + val label = p.annotations.collectFirst { case GQLName(name) => name }.getOrElse(p.label) + val default = p.annotations.collectFirst { case GQLDefault(v) => v } + fields.get(label).fold(p.typeclass.buildMissing(default))(p.typeclass.build) case value => p.typeclass.build(value) } } diff --git a/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala index 6fcd78bac..38f1ab9a4 100644 --- a/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala +++ b/core/src/main/scala-2/caliban/schema/SchemaDerivation.scala @@ -45,7 +45,7 @@ trait SchemaDerivation[R] extends LowPriorityDerivedSchema { () => if (p.typeclass.optional) p.typeclass.toType_(isInput, isSubscription) else makeNonNull(p.typeclass.toType_(isInput, isSubscription)), - None, + p.annotations.collectFirst { case GQLDefault(v) => v }, Some(p.annotations.collect { case GQLDirective(dir) => dir }.toList).filter(_.nonEmpty) ) ) diff --git a/core/src/main/scala-3/caliban/parsing/Parser.scala b/core/src/main/scala-3/caliban/parsing/Parser.scala index 332aff811..663e2b61f 100644 --- a/core/src/main/scala-3/caliban/parsing/Parser.scala +++ b/core/src/main/scala-3/caliban/parsing/Parser.scala @@ -17,6 +17,7 @@ import caliban.parsing.adt._ import cats.parse.{ Numbers, Parser => P } import cats.parse._ import zio.{ IO, Task } +import scala.util.Try object Parser { private final val UnicodeBOM = '\uFEFF' @@ -584,6 +585,23 @@ object Parser { } } + def parseInputValue(rawValue: String): Either[ParsingError, InputValue] = { + val sm = SourceMapper(rawValue) + Try(value.parse(rawValue)).toEither.left + .map(ex => ParsingError(s"Internal parsing error", innerThrowable = Some(ex))) + .flatMap { + case Left(error) => + Left( + ParsingError( + s"Parsing error at offset ${error.failedAtOffset}, expected: ${error.expected.toList.mkString(";")}", + Some(sm.getLocation(error.failedAtOffset)) + ) + ) + + case Right(_, result) => Right(result) + } + } + /** * Checks if the query is valid, if not returns an error string. */ diff --git a/core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala b/core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala index 01c917802..164a877b4 100644 --- a/core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala +++ b/core/src/main/scala-3/caliban/schema/ArgBuilderDerivation.scala @@ -4,6 +4,7 @@ import caliban.CalibanError.ExecutionError import caliban.InputValue import caliban.Value._ import caliban.schema.macros.Macros +import caliban.schema.Annotations.GQLDefault import caliban.schema.Annotations.GQLName import scala.deriving.Mirror @@ -54,7 +55,8 @@ trait ArgBuilderDerivation { input match { case InputValue.ObjectValue(fields) => val finalLabel = annotations.getOrElse(label, Nil).collectFirst { case GQLName(name) => name }.getOrElse(label) - fields.get(finalLabel).fold(builder.buildMissing)(builder.build) + val default = annotations.getOrElse(label, Nil).collectFirst { case GQLDefault(v) => v } + fields.get(finalLabel).fold(builder.buildMissing(default))(builder.build) case value => builder.build(value) } }.foldRight[Either[ExecutionError, Tuple]](Right(EmptyTuple)) { case (item, acc) => diff --git a/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala b/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala index faec25eee..1758f5a51 100644 --- a/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala +++ b/core/src/main/scala-3/caliban/schema/SchemaDerivation.scala @@ -121,12 +121,12 @@ trait SchemaDerivation[R] { .map { case (label, _, schema, _) => val fieldAnnotations = paramAnnotations.getOrElse(label, Nil) __InputValue( - getName(paramAnnotations.getOrElse(label, Nil), label), + getName(fieldAnnotations, label), getDescription(fieldAnnotations), () => if (schema.optional) schema.toType_(isInput, isSubscription) else makeNonNull(schema.toType_(isInput, isSubscription)), - None, + getDefaultValue(fieldAnnotations), Some(fieldAnnotations.collect { case GQLDirective(dir) => dir }).filter(_.nonEmpty) ) }, @@ -216,5 +216,8 @@ trait SchemaDerivation[R] { private def getDirectives(annotations: Seq[Any]): List[Directive] = annotations.collect { case GQLDirective(dir) => dir }.toList + private def getDefaultValue(annotations: Seq[Any]): Option[String] = + annotations.collectFirst { case GQLDefault(v) => v } + inline given gen[A]: Schema[R, A] = derived } diff --git a/core/src/main/scala/caliban/Rendering.scala b/core/src/main/scala/caliban/Rendering.scala index 8bf7b2d43..702d78695 100644 --- a/core/src/main/scala/caliban/Rendering.scala +++ b/core/src/main/scala/caliban/Rendering.scala @@ -125,19 +125,21 @@ object Rendering { else ""}${renderDirectives(field.directives)}" private def renderInputValue(inputValue: __InputValue): String = - s"${inputValue.name}: ${renderTypeName(inputValue.`type`())}${inputValue.defaultValue - .fold("")(d => s" = $d")}${renderDirectives(inputValue.directives)}" + s"${inputValue.name}: ${renderTypeName(inputValue.`type`())}${renderDefaultValue(inputValue)}${renderDirectives(inputValue.directives)}" private def renderEnumValue(v: __EnumValue): String = s"${renderDescription(v.description)}${v.name}${if (v.isDeprecated) s" @deprecated${v.deprecationReason.fold("")(reason => s"""(reason: "$reason")""")}" else ""}" - private def renderArguments(arguments: List[__InputValue]): String = arguments match { - case Nil => "" - case list => - s"(${list.map(a => s"${renderDescription(a.description, newline = false)}${a.name}: ${renderTypeName(a.`type`())}").mkString(", ")})" - } + private def renderDefaultValue(a: __InputValue): String = a.defaultValue.fold("")(d => s" = $d") + + private def renderArguments(arguments: List[__InputValue]): String = + arguments match { + case Nil => "" + case list => + s"(${list.map(a => s"${renderDescription(a.description, newline = false)}${a.name}: ${renderTypeName(a.`type`())}${renderDefaultValue(a)}").mkString(", ")})" + } private def isBuiltinScalar(name: String): Boolean = name == "Int" || name == "Float" || name == "String" || name == "Boolean" || name == "ID" diff --git a/core/src/main/scala/caliban/schema/Annotations.scala b/core/src/main/scala/caliban/schema/Annotations.scala index c0154b6ea..71ed12df5 100644 --- a/core/src/main/scala/caliban/schema/Annotations.scala +++ b/core/src/main/scala/caliban/schema/Annotations.scala @@ -46,4 +46,9 @@ object Annotations { * Annotation to make a union or interface redirect to a value type */ case class GQLValueType() extends StaticAnnotation + + /** + * Annotation to specify the default value of an input field + */ + case class GQLDefault(value: String) extends StaticAnnotation } diff --git a/core/src/main/scala/caliban/schema/ArgBuilder.scala b/core/src/main/scala/caliban/schema/ArgBuilder.scala index 7e4235576..b3e77df9f 100644 --- a/core/src/main/scala/caliban/schema/ArgBuilder.scala +++ b/core/src/main/scala/caliban/schema/ArgBuilder.scala @@ -3,6 +3,7 @@ package caliban.schema import caliban.CalibanError.ExecutionError import caliban.InputValue import caliban.Value._ +import caliban.parsing.Parser import zio.Chunk import java.time.format.DateTimeFormatter @@ -38,7 +39,12 @@ trait ArgBuilder[T] { self => * By default, this delegates to [[build]], passing it NullValue. * Fails with an [[caliban.CalibanError.ExecutionError]] if it was impossible to build the value. */ - def buildMissing: Either[ExecutionError, T] = build(NullValue) + def buildMissing(default: Option[String]): Either[ExecutionError, T] = + default + .map( + Parser.parseInputValue(_).flatMap(build(_)).left.map(e => ExecutionError(e.getMessage())) + ) + .getOrElse(build(NullValue)) /** * Builds a new `ArgBuilder` of `A` from an existing `ArgBuilder` of `T` and a function from `T` to `A`. diff --git a/core/src/main/scala/caliban/schema/Schema.scala b/core/src/main/scala/caliban/schema/Schema.scala index 74c661063..8da4b41f6 100644 --- a/core/src/main/scala/caliban/schema/Schema.scala +++ b/core/src/main/scala/caliban/schema/Schema.scala @@ -360,9 +360,9 @@ trait GenericSchema[R] extends SchemaDerivation[R] with TemporalSchema { ev2: Schema[RB, B] ): Schema[RA with RB, A => B] = new Schema[RA with RB, A => B] { - private lazy val inputType = ev1.toType_(true) - private val unwrappedArgumentName = "value" - override def arguments: List[__InputValue] = + private lazy val inputType = ev1.toType_(true) + private val unwrappedArgumentName = "value" + override def arguments: List[__InputValue] = inputType.inputFields.getOrElse( handleInput(List.empty[__InputValue])( List( @@ -375,6 +375,7 @@ trait GenericSchema[R] extends SchemaDerivation[R] with TemporalSchema { ) ) ) + override def optional: Boolean = ev2.optional override def toType(isInput: Boolean, isSubscription: Boolean): __Type = ev2.toType_(isInput, isSubscription) diff --git a/core/src/main/scala/caliban/validation/DefaultValueValidator.scala b/core/src/main/scala/caliban/validation/DefaultValueValidator.scala new file mode 100644 index 000000000..1281f154b --- /dev/null +++ b/core/src/main/scala/caliban/validation/DefaultValueValidator.scala @@ -0,0 +1,132 @@ +package caliban.validation + +import caliban.CalibanError.ValidationError +import caliban.InputValue +import caliban.InputValue._ +import caliban.Value +import caliban.Value._ +import caliban.introspection.adt._ +import caliban.introspection.adt.__TypeKind._ +import caliban.parsing.Parser +import zio.IO + +object DefaultValueValidator { + def validateDefaultValue(field: __InputValue, errorContext: String): IO[ValidationError, Unit] = + IO.whenCase(field.defaultValue) { case Some(v) => + for { + value <- + IO.fromEither(Parser.parseInputValue(v)) + .mapError(e => + ValidationError( + s"$errorContext failed to parse default value: ${e.msg}", + "The default value for a field must be written using GraphQL input syntax." + ) + ) + _ <- Validator.validateInputValues(field, value) + _ <- validateInputTypes(field, value, errorContext) + } yield () + } + + def validateInputTypes( + inputValue: __InputValue, + argValue: InputValue, + errorContext: String + ): IO[ValidationError, Unit] = validateType(inputValue.`type`(), argValue, errorContext) + + def validateType(inputType: __Type, argValue: InputValue, errorContext: String): IO[ValidationError, Unit] = + inputType.kind match { + case NON_NULL => + argValue match { + case NullValue => + failValidation(s"$errorContext is null", "Input field was null but was supposed to be non-null.") + case x => validateType(inputType.ofType.getOrElse(inputType), x, errorContext) + } + case LIST => + argValue match { + case ListValue(values) => + IO.foreach_(values)(v => + validateType(inputType.ofType.getOrElse(inputType), v, s"List item in $errorContext") + ) + case _ => + failValidation(s"$errorContext has invalid type: $argValue", "Input field was supposed to be a list.") + } + + case INPUT_OBJECT => + argValue match { + case ObjectValue(fields) => + IO.foreach_(inputType.inputFields.getOrElse(List.empty)) { f => + val value = + fields.collectFirst({ case (name, fieldValue) if name == f.name => fieldValue }).getOrElse(NullValue) + validateType(f.`type`(), value, s"Field ${f.name} in $errorContext") + } + case _ => + failValidation( + s"$errorContext has invalid type: $argValue", + "Input field was supposed to be an input object." + ) + } + case ENUM => + argValue match { + case EnumValue(value) => + val possible = inputType + .enumValues(__DeprecatedArgs(Some(true))) + .getOrElse(List.empty) + .map(_.name) + val exists = possible.exists(_ == value) + + IO.unless(exists)( + failValidation( + s"$errorContext has invalid enum value: $value", + s"Was supposed to be one of ${possible.mkString(", ")}" + ) + ) + case _ => + failValidation( + s"$errorContext has invalid type: $argValue", + "Input field was supposed to be an enum value." + ) + } + case SCALAR => validateScalar(inputType, argValue, errorContext) + case x => + failValidation( + s"$errorContext has invalid type $inputType", + "Input value is invalid, should be a scalar, list or input object." + ) + } + + def validateScalar(inputType: __Type, argValue: InputValue, errorContext: String) = + inputType.name.getOrElse("") match { + case "String" => + argValue match { + case StringValue(value) => + IO.unit + case t => failValidation(s"$errorContext has invalid type $t", "Expected 'String'") + } + case "ID" => + argValue match { + case StringValue(value) => + IO.unit + case t => failValidation(s"$errorContext has invalid type $t", "Expected 'ID'") + } + case "Int" => + argValue match { + case _: Value.IntValue => IO.unit + case t => failValidation(s"$errorContext has invalid type $t", "Expected 'Int'") + } + case "Float" => + argValue match { + case _: Value.FloatValue => IO.unit + case t => failValidation(s"$errorContext has invalid type $t", "Expected 'Float'") + } + case "Boolean" => + argValue match { + case BooleanValue(value) => IO.unit + case t => failValidation(s"$errorContext has invalid type $t", "Expected 'Boolean'") + } + // We can't really validate custom scalars here (since we can't summon a correct ArgBuilder instance), so just pass them along + case x => IO.unit + } + + def failValidation[T](msg: String, explanatoryText: String): IO[ValidationError, T] = + IO.fail(ValidationError(msg, explanatoryText)) +} diff --git a/core/src/main/scala/caliban/validation/Validator.scala b/core/src/main/scala/caliban/validation/Validator.scala index bf3268125..42ed4d983 100644 --- a/core/src/main/scala/caliban/validation/Validator.scala +++ b/core/src/main/scala/caliban/validation/Validator.scala @@ -440,7 +440,10 @@ object Validator { ) ) - private def validateInputValues(inputValue: __InputValue, argValue: InputValue): IO[ValidationError, Unit] = { + private[caliban] def validateInputValues( + inputValue: __InputValue, + argValue: InputValue + ): IO[ValidationError, Unit] = { val t = inputValue.`type`() val inputType = if (t.kind == __TypeKind.NON_NULL) t.ofType.getOrElse(t) else t val inputFields = inputType.inputFields.getOrElse(Nil) @@ -603,8 +606,8 @@ object Validator { } def validateFields(fields: List[__InputValue]): IO[ValidationError, Unit] = - noDuplicateInputValueName(fields, inputObjectContext) <* - IO.foreach_(fields)(validateInputValue(_, inputObjectContext)) + IO.foreach_(fields)(validateInputValue(_, inputObjectContext)) &> + noDuplicateInputValueName(fields, inputObjectContext) t.inputFields match { case None | Some(Nil) => @@ -619,6 +622,7 @@ object Validator { private[caliban] def validateInputValue(inputValue: __InputValue, errorContext: String): IO[ValidationError, Unit] = { val fieldContext = s"InputValue '${inputValue.name}' of $errorContext" for { + _ <- DefaultValueValidator.validateDefaultValue(inputValue, fieldContext) _ <- doesNotStartWithUnderscore(inputValue, fieldContext) _ <- onlyInputType(inputValue.`type`(), fieldContext) } yield () diff --git a/core/src/test/scala/caliban/execution/DefaultValueSpec.scala b/core/src/test/scala/caliban/execution/DefaultValueSpec.scala new file mode 100644 index 000000000..11ce9ca92 --- /dev/null +++ b/core/src/test/scala/caliban/execution/DefaultValueSpec.scala @@ -0,0 +1,182 @@ +package caliban.execution + +import caliban.CalibanError +import caliban.GraphQL._ +import caliban.Macros.gqldoc +import caliban.RootResolver +import caliban.schema.Annotations.GQLDefault +import zio.test.Assertion._ +import zio.test._ +import zio.test.environment.TestEnvironment + +import java.util.UUID + +object DefaultValueSpec extends DefaultRunnableSpec { + override def spec: ZSpec[TestEnvironment, Any] = + suite("DefaultValueSpec")( + suite("default value validation")( + testM("invalid string validation") { + case class TestInput(@GQLDefault("1") string: String) + case class Query(test: TestInput => String) + val gql = graphQL(RootResolver(Query(i => i.string))) + assertM(gql.interpreter.run)( + fails(isSubtype[CalibanError.ValidationError](anything)) + ) + }, + testM("invalid int validation") { + case class TestInput(@GQLDefault("\"1\"") int: Int) + case class Query(test: TestInput => Int) + val gql = graphQL(RootResolver(Query(i => i.int))) + assertM(gql.interpreter.run)( + fails(isSubtype[CalibanError.ValidationError](anything)) + ) + }, + testM("invalid float validation") { + case class TestInput(@GQLDefault("1") float: Float) + case class Query(test: TestInput => Float) + val gql = graphQL(RootResolver(Query(i => i.float))) + assertM(gql.interpreter.run)( + fails(isSubtype[CalibanError.ValidationError](anything)) + ) + }, + testM("invalid id validation") { + case class TestInput(@GQLDefault("1") id: UUID) + case class Query(test: TestInput => UUID) + val gql = graphQL(RootResolver(Query(i => i.id))) + assertM(gql.interpreter.run)( + fails(isSubtype[CalibanError.ValidationError](anything)) + ) + }, + testM("invalid boolean validation") { + case class TestInput(@GQLDefault("1") b: Boolean) + case class Query(test: TestInput => Boolean) + val gql = graphQL(RootResolver(Query(i => i.b))) + assertM(gql.interpreter.run)( + fails(isSubtype[CalibanError.ValidationError](anything)) + ) + }, + testM("invalid nullable validation") { + case class TestInput(@GQLDefault("1") s: Option[String]) + case class Query(test: TestInput => String) + val gql = graphQL(RootResolver(Query(i => i.s.getOrElse("default")))) + assertM(gql.interpreter.run)( + fails(isSubtype[CalibanError.ValidationError](anything)) + ) + }, + testM("valid nullable validation") { + case class TestInput(@GQLDefault("\"1\"") s: Option[String]) + case class Query(test: TestInput => String) + val gql = graphQL(RootResolver(Query(i => i.s.getOrElse("default")))) + assertM(gql.interpreter)(anything) + }, + testM("invalid list validation") { + case class TestInput(@GQLDefault("\"string\"") string: List[String]) + case class Query(test: TestInput => List[String]) + val gql = graphQL(RootResolver(Query(i => i.string))) + assertM(gql.interpreter.run)( + fails(isSubtype[CalibanError.ValidationError](anything)) + ) + }, + testM("valid list validation") { + case class TestInput(@GQLDefault("[\"string\"]") string: List[String]) + case class Query(test: TestInput => List[String]) + val gql = graphQL(RootResolver(Query(i => i.string))) + assertM(gql.interpreter)(anything) + }, + testM("invalid object validation") { + case class Nested(field: String) + case class TestInput(@GQLDefault("{field: 2}") nested: Nested) + case class Query(test: TestInput => String) + val gql = graphQL(RootResolver(Query(v => v.nested.field))) + assertM(gql.interpreter.run)( + fails(isSubtype[CalibanError.ValidationError](anything)) + ) + }, + testM("valid object validation") { + case class Nested(field: String) + case class TestInput(@GQLDefault("{field: \"2\"}") nested: Nested) + case class Query(test: TestInput => String) + val gql = graphQL(RootResolver(Query(v => v.nested.field))) + assertM(gql.interpreter)(anything) + } + ), + testM("field default values") { + case class TestInput(@GQLDefault("1") intValue: Int, stringValue: String) + case class Query(testDefault: TestInput => Int) + val api = graphQL(RootResolver(Query(i => i.intValue))) + val interpreter = api.interpreter + val query = + """query{ + | testDefault(stringValue: "Hi!") + |}""".stripMargin + assertM(interpreter.flatMap(_.execute(query)).map(_.data.toString))(equalTo("""{"testDefault":1}""")) + }, + testM("invalid field default values") { + case class TestInput(@GQLDefault("1.1") intValue: Int, stringValue: String) + case class Query(testDefault: TestInput => Int) + val api = graphQL(RootResolver(Query(i => i.intValue))) + val expected = + "InputValue 'intValue' of Field 'testDefault' of Object 'Query' has invalid type 1.1" + + assertM(api.interpreter.run)(fails(hasMessage(equalTo(expected)))) + }, + test("it should render default values in the SDL") { + case class TestInput(@GQLDefault("1") intValue: Int) + case class Query(testDefault: TestInput => Int) + val rendered = graphQL(RootResolver(Query(i => i.intValue))).render.trim + + assert(rendered)(equalTo("""|schema { + | query: Query + |} + | + |type Query { + | testDefault(intValue: Int! = 1): Int! + |}""".stripMargin.trim)) + }, + testM("it renders in introspection") { + val introspectionQuery = gqldoc(""" + query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + types { + ...FullType + } + } + } + + fragment FullType on __Type { + kind + name + fields(includeDeprecated: true) { + name + description + args { + ...InputValue + } + } + inputFields { + ...InputValue + } + } + + fragment InputValue on __InputValue { + name + description + defaultValue + } + """) + + case class TestInput(@GQLDefault("1") intValue: Int) + case class Query(testDefault: TestInput => Int) + val interpreter = graphQL(RootResolver(Query(i => i.intValue))).interpreter + + assertM(interpreter.flatMap(_.execute(introspectionQuery)).map(_.data.toString))( + equalTo( + """{"__schema":{"queryType":{"name":"Query"},"mutationType":null,"subscriptionType":null,"types":[{"kind":"SCALAR","name":"Boolean","fields":null,"inputFields":null},{"kind":"SCALAR","name":"Int","fields":null,"inputFields":null},{"kind":"OBJECT","name":"Query","fields":[{"name":"testDefault","description":null,"args":[{"name":"intValue","description":null,"defaultValue":"1"}]}],"inputFields":null}]}}""" + ) + ) + } + ) +} diff --git a/core/src/test/scala/caliban/schema/ArgBuilderSpec.scala b/core/src/test/scala/caliban/schema/ArgBuilderSpec.scala index ab240af91..5a13e2c64 100644 --- a/core/src/test/scala/caliban/schema/ArgBuilderSpec.scala +++ b/core/src/test/scala/caliban/schema/ArgBuilderSpec.scala @@ -73,7 +73,8 @@ object ArgBuilderSpec extends DefaultRunnableSpec { case _ => ev.build(input).map(SomeNullable(_)) } - override def buildMissing: Either[ExecutionError, Nullable[A]] = Right(MissingNullable) + override def buildMissing(default: Option[String]): Either[ExecutionError, Nullable[A]] = + Right(MissingNullable) } case class Wrapper(a: Nullable[String]) diff --git a/vuepress/docs/docs/schema.md b/vuepress/docs/docs/schema.md index 4447d7050..e3ab61be9 100644 --- a/vuepress/docs/docs/schema.md +++ b/vuepress/docs/docs/schema.md @@ -216,6 +216,7 @@ Caliban supports a few annotations to enrich data types: - `@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. - `@GQLValueType` forces a type to behave as a value type for derivation. Meaning that caliban will ignore the outer type and take the first case class parameter as the real type. +- `@GQLDefault("defaultValue")` allows you to specify a default value for an input field using GraphQL syntax. The default value will be visible in your schema's SDL and during introspection. ## Java 8 Time types