diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 83196e79..fa2d1a77 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -7,7 +7,6 @@ import graphql.kickstart.tools.resolver.MethodFieldResolver import graphql.kickstart.tools.resolver.PropertyFieldResolver import graphql.kickstart.tools.util.* import graphql.language.* -import graphql.schema.GraphQLScalarType import graphql.schema.idl.ScalarInfo import graphql.schema.idl.TypeUtil import org.apache.commons.lang3.ClassUtils @@ -282,24 +281,15 @@ internal class SchemaClassScanner( } catch (t: Throwable) { throw SchemaClassScannerError("Error creating bimap of type => class", t) } - val scalarDefinitions = observedDefinitions.filterIsInstance() - - // Ensure all scalar definitions have implementations and add the definition to those. - val scalars = scalarDefinitions + // SchemaParser builds the scalars, this only checks that each one was provided + val scalars = observedDefinitions.filterIsInstance() .filter { // Filter for any defined scalars OR scalars that aren't defined but also aren't standard scalars.containsKey(it.name) || !ScalarInfo.GRAPHQL_SPECIFICATION_SCALARS_DEFINITIONS.containsKey(it.name) - }.map { definition -> - val provided = scalars[definition.name] - ?: throw SchemaClassScannerError("Expected a user-defined GraphQL scalar type with name '${definition.name}' but found none!") - GraphQLScalarType.newScalar() - .name(provided.name) - .description(getDocumentation(definition, options) ?: provided.description) - .coercing(provided.coercing) - .definition(definition) - .extensionDefinitions(typeExtensions.extensionsOf(definition.name)) - .build() - }.associateBy { it.name } + }.associate { definition -> + definition.name to (scalars[definition.name] + ?: throw SchemaClassScannerError("Expected a user-defined GraphQL scalar type with name '${definition.name}' but found none!")) + } val unusedDefinitions = (definitionsByName.values - observedDefinitions).toSet() unusedDefinitions diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaObjects.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaObjects.kt index c95ab4b7..06291432 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaObjects.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaObjects.kt @@ -64,5 +64,6 @@ data class SchemaObjects @JvmOverloads constructor( .description(description) .query(query) .additionalTypes(namedDictionary()) + .additionalDirectives(directives) .build() } diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 1a39afa5..8d57ec97 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -11,6 +11,7 @@ import graphql.introspection.Introspection.DirectiveLocation.INPUT_FIELD_DEFINIT import graphql.introspection.Introspection.DirectiveLocation.INPUT_OBJECT import graphql.introspection.Introspection.DirectiveLocation.INTERFACE import graphql.introspection.Introspection.DirectiveLocation.OBJECT +import graphql.introspection.Introspection.DirectiveLocation.SCALAR import graphql.introspection.Introspection.DirectiveLocation.SCHEMA import graphql.introspection.Introspection.DirectiveLocation.UNION import graphql.kickstart.tools.directive.DirectiveWiringHelper @@ -60,6 +61,7 @@ class SchemaParser internal constructor( private val directiveDefinitions = definitions.filterIsInstance() private val unionDefinitions = definitions.filterIsInstance() + private val scalarDefinitions = definitions.filterIsInstance().filter { it.name in customScalars }.associateBy { it.name } private val permittedTypesForObject: Set = (objectDefinitions.map { it.name } + enumDefinitions.map { it.name } + @@ -90,6 +92,7 @@ class SchemaParser internal constructor( // Create GraphQL objects val inputObjects: MutableList = mutableListOf() createDirectives() + val scalars = scalarDefinitions.values.map { createScalar(it) } inputObjectDefinitions.forEach { if (inputObjects.none { io -> io.name == it.name }) { inputObjects.add(createInputObject(it, inputObjects, mutableSetOf())) @@ -118,13 +121,13 @@ class SchemaParser internal constructor( val additionalObjects = objects.filter { o -> o != query && o != subscription && o != mutation } - val types = (additionalObjects.toSet() as Set) + inputObjects + enums + interfaces + unions + val types = (additionalObjects.toSet() as Set) + inputObjects + enums + interfaces + unions + scalars // Directives applied to the schema itself, e.g. `extend schema @link(...)`. Only GraphQLAppliedDirectives are built here, not // the deprecated GraphQLDirective ones: graphql-java doesn't resolve type references in those, so enum arguments would fail. val schemaAppliedDirectives = buildAppliedDirectives(rootInfo.getDirectives(), SCHEMA).toList() val schemaObjects = SchemaObjects(query, mutation, subscription, types, schemaDirectives.values.toSet(), codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives) // in the order the types were created - val wiredObjects = if (directiveWiringHelper.isWiringRequired) wire(schemaObjects, inputObjects + interfaces + objects + unions + enums) else schemaObjects + val wiredObjects = if (directiveWiringHelper.isWiringRequired) wire(schemaObjects, scalars + inputObjects + interfaces + objects + unions + enums) else schemaObjects if (options.unmappedInputFields != UnmappedInputFields.IGNORE) { val wiredInputObjects = wiredObjects.dictionary.filterIsInstance().associateBy { it.name } val checker = UnmappedInputFieldChecker(wiredInputObjects, options.unmappedInputFields) @@ -282,6 +285,31 @@ class SchemaParser internal constructor( return builder.build() } + private fun createScalar(definition: ScalarTypeDefinition): GraphQLScalarType { + val name = definition.name + val provided = customScalars.getValue(name) + val extensions = typeExtensions.extensionsOf(name) + val directives = directivesOf(definition, extensions) + val (appliedDirectives, legacyDirectives) = try { + buildAppliedDirectives(directives, SCALAR) to buildDirectives(directives, SCALAR) + } catch (e: SchemaError) { + // TODO throw instead in 15.0.0 (#893), like for the other types + log.warn("Scalar '$name' is built without its directives from the schema: ${e.message}") + emptyArray() to emptyArray() + } + val builder = GraphQLScalarType.newScalar(provided) + .definition(definition) + .extensionDefinitions(extensions) + .description(getDocumentation(definition, options) ?: provided.description) + .specifiedByUrl(getSpecifiedByUrl(directives) ?: provided.specifiedByUrl) + // like graphql-java, only the schema's directives are applied + .clearDirectives() + .withAppliedDirectives(*appliedDirectives) + .withDirectives(*legacyDirectives) + + return builder.build() + } + private fun createInterfaceObject(interfaceDefinition: InterfaceTypeDefinition, inputObjects: List): GraphQLInterfaceType { val extensions = typeExtensions.extensionsOf(interfaceDefinition.name) val directives = directivesOf(interfaceDefinition, extensions) @@ -591,10 +619,17 @@ class SchemaParser internal constructor( private fun missingArgumentsWithDefault(directive: Directive, graphQLDirective: GraphQLDirective): List = graphQLDirective.arguments.filter { it.hasSetDefaultValue() && directive.getArgument(it.name) == null } + private fun getSpecifiedByUrl(directives: List): String? = + (directives.find { it.name == Directives.SpecifiedByDirective.name }?.getArgument("url")?.value as? StringValue)?.value + + // custom scalars are in the dictionary, so they're referenced by name like the other types + private fun scalarType(name: String?): GraphQLInputType? = + scalarDefinitions[name]?.let { GraphQLTypeReference(it.name) } ?: GRAPHQL_SCALARS[name] + private fun determineOutputType(typeDefinition: Type<*>) = determineType(GraphQLOutputType::class, typeDefinition, permittedTypesForObject) as GraphQLOutputType - // named types other than scalars are type references + // named types other than spec scalars are type references private fun determineType( expectedType: KClass, typeDefinition: Type<*>, @@ -604,8 +639,7 @@ class SchemaParser internal constructor( is ListType -> GraphQLList(determineType(expectedType, typeDefinition.type, allowedTypeReferences)) is NonNullType -> GraphQLNonNull(determineType(expectedType, typeDefinition.type, allowedTypeReferences)) is TypeName -> { - val scalarType = customScalars[typeDefinition.name] - ?: GRAPHQL_SCALARS[typeDefinition.name] + val scalarType = scalarType(typeDefinition.name) if (scalarType != null) { scalarType } else { @@ -635,8 +669,7 @@ class SchemaParser internal constructor( createInputObject(typeDefinition, inputObjects, referencingInputObjects) } is TypeName -> { - val scalarType = customScalars[typeDefinition.name] - ?: GRAPHQL_SCALARS[typeDefinition.name] + val scalarType = scalarType(typeDefinition.name) if (scalarType != null) { scalarType } else { diff --git a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt index 3e16d73f..7ac1f143 100644 --- a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt @@ -2,6 +2,7 @@ package graphql.kickstart.tools.directive import graphql.introspection.Introspection import graphql.introspection.Introspection.DirectiveLocation.* +import graphql.kickstart.tools.GRAPHQL_SCALARS import graphql.kickstart.tools.directive.SchemaDirectiveWiringEnvironmentImpl.Parameters import graphql.language.NamedNode import graphql.language.NodeParentTree @@ -31,6 +32,7 @@ internal class DirectiveWiringHelper( is GraphQLInputObjectType -> wireInputObject(type) is GraphQLEnumType -> wireEnum(type) is GraphQLUnionType -> wireUnion(type) + is GraphQLScalarType -> wireScalar(type) else -> type } if (wired !== type) changed[type.name] = wired @@ -67,9 +69,12 @@ internal class DirectiveWiringHelper( private fun of(type: GraphQLType): GraphQLType = when (type) { is GraphQLNonNull -> GraphQLNonNull.nonNull(of(type.wrappedType)) is GraphQLList -> GraphQLList.list(of(type.wrappedType)) - // scalars aren't wired, and a reference only finds a scalar another field uses - is GraphQLScalarType -> type - is GraphQLNamedType -> if (schema.typeMap[type.name] === type) GraphQLTypeReference.typeRef(type.name) else built.getOrPut(type) { inType(type) } + is GraphQLNamedType -> when { + // spec scalars aren't in the dictionary, so a reference only finds one another field uses + GRAPHQL_SCALARS[type.name] === type -> type + schema.typeMap[type.name] === type -> GraphQLTypeReference.typeRef(type.name) + else -> built.getOrPut(type) { inType(type) } + } else -> type } @@ -104,6 +109,10 @@ internal class DirectiveWiringHelper( return wireDirectives(WiringWrapper(unionType, UNION, SchemaDirectiveWiring::onUnion)) } + private fun wireScalar(scalarType: GraphQLScalarType): GraphQLScalarType { + return wireDirectives(WiringWrapper(scalarType, SCALAR, SchemaDirectiveWiring::onScalar)) + } + private fun wireFields(fieldsContainer: GraphQLFieldsContainer): List { return fieldsContainer.fields.map { field -> // wire arguments diff --git a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt index bff82deb..1b51ae6b 100644 --- a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt @@ -1,6 +1,7 @@ package graphql.kickstart.tools import graphql.GraphQL +import graphql.Scalars import graphql.execution.AsyncExecutionStrategy import graphql.relay.Connection import graphql.relay.SimpleListConnection @@ -8,6 +9,7 @@ import graphql.schema.* import graphql.schema.idl.SchemaDirectiveWiring import graphql.schema.idl.SchemaDirectiveWiringEnvironment import graphql.schema.idl.SchemaPrinter +import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Test @@ -671,6 +673,129 @@ class DirectiveTest { } } + @Test + fun `should apply the schema's directives and the provided specifiedBy URL to scalars`() { + val wired = mutableListOf() + val parser = SchemaParser.newParser() + .schemaString( + """ + directive @unit(name: String!) on SCALAR + + "A calendar date" + scalar Date @specifiedBy(url: "https://example.com/date") @unit(name: "day") + scalar Time + + type Query { + today: Date + now: Time + } + """) + .resolvers(object : GraphQLQueryResolver { + fun today(): String = "2026-10-19" + fun now(): String = "12:00" + }) + .scalars( + GraphQLScalarType.newScalar().name("Date").description("A date").coercing(Scalars.GraphQLString.coercing) + .specifiedByUrl("https://provided.example.com/date").build(), + GraphQLScalarType.newScalar().name("Time").description("A time").coercing(Scalars.GraphQLString.coercing) + .specifiedByUrl("https://example.com/time").build() + ) + .directive("unit", object : SchemaDirectiveWiring { + override fun onScalar(environment: SchemaDirectiveWiringEnvironment): GraphQLScalarType { + wired.add("${environment.element.name} ${environment.appliedDirective.getArgument("name")?.getValue()}") + return environment.element + } + }) + .build() + val schema = parser.makeExecutableSchema().assertEveryFieldResolves() + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + __schema { types { name description specifiedByURL } } + } + """ + } + @Suppress("UNCHECKED_CAST") + val types = (data["__schema"] as Map>>).getValue("types") + assertEquals(types.filter { it["name"] in listOf("Date", "Time") }, listOf( + mapOf("name" to "Date", "description" to "A calendar date", "specifiedByURL" to "https://example.com/date"), + mapOf("name" to "Time", "description" to "A time", "specifiedByURL" to "https://example.com/time") + )) + assertEquals(wired, listOf("Date day")) + val date = schema.getType("Date") as GraphQLScalarType + assertEquals(date.appliedDirectives.map { it.name }, listOf("specifiedBy", "unit")) + val printed = SchemaPrinter().print(schema) + assert(printed.contains("""@specifiedBy(url : "https://example.com/date")""")) { printed } + assertEquals(parser.parseSchemaObjects().toReadOnlySchema().getDirective("unit")?.name, "unit") + } + + @Test + fun `should wire scalars used by the arguments of their own directives`() { + val recorder = ArgumentTypeRecorder() + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @since(date: Date) on SCALAR | FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + scalar Date @since(date: "2020-01-01") + + input Range { + from: Date @since(date: "2021-01-01") + } + + type Query { + name(after: Date, range: Range): String @since(date: "2022-01-01") + } + """) + .resolvers(object : GraphQLQueryResolver { + fun name(after: String?, range: Range?): String? = null + }) + .scalars(GraphQLScalarType.newScalar().name("Date").coercing(Scalars.GraphQLString.coercing).build()) + .directive("since", recorder) + .directiveWiring(object : SchemaDirectiveWiring { + override fun onScalar(environment: SchemaDirectiveWiringEnvironment): GraphQLScalarType = + environment.element.transform { it.description("wired") } + }) + .build() + .makeExecutableSchema() + .assertEveryFieldResolves() + + val expectedTypes = mapOf("name" to listOf("GraphQLScalarType"), "from" to listOf("GraphQLScalarType"), "Date" to listOf("GraphQLScalarType")) + assertEquals(recorder.argumentTypes, expectedTypes) + val date = schema.getType("Date") as GraphQLScalarType + assertEquals(date.description, "wired") + assertEquals(date.getAppliedDirective("since").getArgument("date")?.getValue(), "2020-01-01") + assertSame(schema.queryType.getField("name").getArgument("after").type, date) + } + + @Test + fun `should warn about undeclared directives on scalars and build them without the schema's directives`() { + val (schema, warnings) = withWarnings { + SchemaParser.newParser() + .schemaString( + """ + scalar Date @specifiedBy(url: "https://example.com/date") @format(pattern: "yyyy-MM-dd") + + type Query { + today: Date + } + """) + .resolvers(object : GraphQLQueryResolver { + fun today(): String = "2026-10-19" + }) + .scalars(GraphQLScalarType.newScalar().name("Date").coercing(Scalars.GraphQLString.coercing).build()) + .build() + .makeExecutableSchema() + .assertEveryFieldResolves() + } + + assertEquals(warnings, listOf("Scalar 'Date' is built without its directives from the schema: Found applied directive format without corresponding directive definition.")) + val date = schema.getType("Date") as GraphQLScalarType + assertEquals(date.appliedDirectives, emptyList()) + assertEquals(date.specifiedByUrl, "https://example.com/date") + } + @Test fun `should apply directives on the schema and its extensions`() { val schema = SchemaParser.newParser() @@ -917,6 +1042,10 @@ class DirectiveTest { val max: Int? ) + data class Range( + val from: String? + ) + /** * Records the unwrapped types of the arguments of each applied directive, by the name of the element it's applied to. */ @@ -929,6 +1058,8 @@ class DirectiveTest { override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment) = record(environment) + override fun onScalar(environment: SchemaDirectiveWiringEnvironment) = record(environment) + private fun record(environment: SchemaDirectiveWiringEnvironment): T { argumentTypes[environment.element.name] = environment.appliedDirective.arguments.map { GraphQLTypeUtil.unwrapAllAs(it.type).javaClass.simpleName } diff --git a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt index e98dc9a7..9012e67f 100644 --- a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt @@ -66,6 +66,7 @@ class TypeExtensionTest { assertEquals(item.interfaces.map { it.name }, listOf("Node", "Entity")) assertEquals(schema.getImplementations(schema.getType("Entity") as GraphQLInterfaceType)?.map { it.name }, listOf("Item")) assertEquals(date.description, "A date") + assertEquals(date.appliedDirectives.map { it.name }, listOf("format")) // through GraphQLNamedSchemaElement, since graphql-java 25 and 26 disagree on which types have a nullable definition assertEquals(listOf(fooBar, color, node, item, date).map { it.definition?.javaClass }, listOf( @@ -217,7 +218,7 @@ class TypeExtensionTest { val (schema, warnings) = withWarnings { parser( """ - directive @tag(name: String!) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + directive @tag(name: String!) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR directive @once(name: String!) on OBJECT | INPUT_OBJECT type Query @once(name: "Query") { @@ -225,6 +226,7 @@ class TypeExtensionTest { fooBar: FooBar color: Color search(filter: Filter): String + today: Date } extend type Query @once(name: "Query extension") @@ -244,9 +246,13 @@ class TypeExtensionTest { input Filter @tag(name: "Filter") { id: ID } extend input Filter @tag(name: "Filter extension") @once(name: "Filter extension") + scalar Date @tag(name: "Date") + extend scalar Date @tag(name: "Date extension") + type Baz { baz: String } """) { dictionary(Item::class, Baz::class) + scalars(GraphQLScalarType.newScalar().name("Date").coercing(Scalars.GraphQLString.coercing).build()) directive("tag", wiring) directive("once", wiring) }.makeExecutableSchema() @@ -255,7 +261,7 @@ class TypeExtensionTest { assertEquals(warnings, listOf("Directive @once isn't repeatable but an extension of type 'Query' applies it again, only the first one is kept.")) - val applied = listOf("Query", "Node", "Item", "FooBar", "Color", "Filter").associateWith { name -> + val applied = listOf("Query", "Node", "Item", "FooBar", "Color", "Filter", "Date").associateWith { name -> (schema.getType(name) as GraphQLDirectiveContainer).appliedDirectives.map { it.describe() } } assertEquals(applied, mapOf( @@ -264,7 +270,8 @@ class TypeExtensionTest { "Item" to listOf("@tag(Item)", "@tag(Item extension)"), "FooBar" to listOf("@tag(FooBar)", "@tag(FooBar extension)"), "Color" to listOf("@tag(Color)", "@tag(Color extension)"), - "Filter" to listOf("@tag(Filter)", "@tag(Filter extension)", "@once(Filter extension)") + "Filter" to listOf("@tag(Filter)", "@tag(Filter extension)", "@once(Filter extension)"), + "Date" to listOf("@tag(Date)", "@tag(Date extension)") )) assertEquals(wiring.wired.sorted(), applied.values.flatten().sorted()) @@ -273,6 +280,7 @@ class TypeExtensionTest { assertEquals(item.directives.map { it.name }, listOf("tag", "tag")) assertEquals(filter.directives.map { it.name }, listOf("tag", "tag", "once")) assertEquals(schema.queryType.directives.map { it.name }, listOf("once")) + assertEquals((schema.getType("Date") as GraphQLScalarType).directives.map { it.name }, listOf("tag", "tag")) assertEquals(item.fieldDefinitions.map { it.name }, listOf("id", "name")) assertEquals(filter.extensionDefinitions.size, 1) @@ -511,6 +519,8 @@ class TypeExtensionTest { override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment) = record(environment) + override fun onScalar(environment: SchemaDirectiveWiringEnvironment) = record(environment) + private fun record(environment: SchemaDirectiveWiringEnvironment): T { wired.add(environment.appliedDirective.describe()) return environment.element