Skip to content

Commit 3c4559b

Browse files
authored
Merge branch 'master' into build/lock-kotlin
2 parents de7a896 + 55b4d19 commit 3c4559b

26 files changed

Lines changed: 471 additions & 75 deletions

‎.github/workflows/pull-request.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,23 @@ jobs:
2424
restore-keys: ${{ runner.os }}-m2
2525
- name: Build with Maven
2626
run: mvn --batch-mode --update-snapshots verify
27+
28+
# The same jar has to work with the oldest supported graphql-java, so build and test against it too
29+
graphql-java-25:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- name: Checkout
33+
uses: actions/checkout@v7
34+
- name: Setup java
35+
uses: actions/setup-java@v6
36+
with:
37+
java-version: '11'
38+
distribution: 'temurin'
39+
- name: Cache Maven packages
40+
uses: actions/cache@v6
41+
with:
42+
path: ~/.m2
43+
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
44+
restore-keys: ${{ runner.os }}-m2
45+
- name: Build with Maven
46+
run: mvn --batch-mode --update-snapshots verify -Dgraphql-java.version=25.1

‎pom.xml‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@
262262
<artifactId>kotlin-maven-plugin</artifactId>
263263
<groupId>org.jetbrains.kotlin</groupId>
264264
<version>${kotlin.version}</version>
265+
<configuration>
266+
<args>
267+
<arg>-Werror</arg>
268+
</args>
269+
</configuration>
265270
<executions>
266271
<execution>
267272
<id>compile</id>
@@ -286,8 +291,8 @@
286291
<artifactId>maven-compiler-plugin</artifactId>
287292
<version>3.16.0</version>
288293
<configuration>
289-
<source>11</source>
290-
<target>11</target>
294+
<release>11</release>
295+
<failOnWarning>true</failOnWarning>
291296
</configuration>
292297
</plugin>
293298

‎src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package graphql.kickstart.tools
33
import graphql.kickstart.tools.resolver.FieldResolver
44
import graphql.kickstart.tools.util.BiMap
55
import graphql.kickstart.tools.util.JavaType
6+
import graphql.language.EnumTypeDefinition
67
import graphql.language.FieldDefinition
78
import graphql.language.ObjectTypeDefinition
89
import graphql.language.SDLNamedDefinition
@@ -18,7 +19,8 @@ internal data class ScannedSchemaObjects(
1819
val customScalars: CustomScalarMap,
1920
val rootInfo: RootTypeInfo,
2021
val fieldResolversByType: Map<ObjectTypeDefinition, MutableMap<FieldDefinition, FieldResolver>>,
21-
val unusedDefinitions: Set<TypeDefinition<*>>
22+
val unusedDefinitions: Set<TypeDefinition<*>>,
23+
val directiveOnlyEnums: Set<EnumTypeDefinition>
2224
)
2325

2426
internal typealias TypeClassDictionary = BiMap<TypeDefinition<*>, JavaType>

‎src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt‎

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,51 @@ internal class SchemaClassScanner(
171171
?: error("No ${TypeDefinition::class.java.simpleName} for type name $inputTypeName")
172172
when (typeDefinition) {
173173
is ScalarTypeDefinition -> handleFoundScalarType(typeDefinition)
174-
is EnumTypeDefinition -> handleDictionaryTypes(listOf(typeDefinition)) {
175-
"Enum type '${it.name}' is used in a directive, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary."
176-
}
174+
is EnumTypeDefinition -> handleDirectiveEnum(typeDefinition)
177175
is InputObjectTypeDefinition -> handleDictionaryTypes(listOf(typeDefinition)) {
178176
"Input object type '${it.name}' is used in a directive, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary."
179177
}
180178
}
181179
}
182180

181+
/**
182+
* Directive argument values never reach resolver methods, so an enum used in a directive doesn't need a class. Without one
183+
* it goes into [unvalidatedTypes] and, unless a field uses it too, is built with its value names as values, see
184+
* [validateAndCreateResult].
185+
*/
186+
private fun handleDirectiveEnum(type: EnumTypeDefinition) {
187+
if (dictionary.containsKey(type)) {
188+
return
189+
}
190+
191+
val initialEntry = initialDictionary[type.name]
192+
if (initialEntry != null) {
193+
handleFoundType(type, initialEntry.get(), DictionaryReference())
194+
} else {
195+
unvalidatedTypes.add(type)
196+
}
197+
}
198+
199+
/**
200+
* The names of the types used by the fields, arguments and input fields of [definitions], including fields added by extensions.
201+
*/
202+
private fun typeNamesUsedByFields(definitions: Set<TypeDefinition<*>>): Set<String> {
203+
val names = definitions.map { it.name }.toSet()
204+
return (definitions + extensionDefinitions + inputExtensionDefinitions)
205+
.filter { it.name in names }
206+
.flatMap { definition ->
207+
when (definition) {
208+
is ImplementingTypeDefinition<*> -> definition.fieldDefinitions.flatMap { field ->
209+
field.inputValueDefinitions.map { it.type } + field.type
210+
}
211+
is InputObjectTypeDefinition -> definition.inputValueDefinitions.map { it.type }
212+
else -> emptyList()
213+
}
214+
}
215+
.mapNotNull { (it.unwrap() as TypeName).name }
216+
.toSet()
217+
}
218+
183219
private fun validateAndCreateResult(rootTypeHolder: RootTypesHolder): ScannedSchemaObjects {
184220
initialDictionary
185221
.filter { !it.value.accessed }
@@ -198,8 +234,8 @@ internal class SchemaClassScanner(
198234
dictionary
199235
.filter {
200236
it.value.javaType != null
201-
&& it.value.typeClass() != java.lang.Object::class.java
202-
&& !java.util.Map::class.java.isAssignableFrom(it.value.typeClass())
237+
&& it.value.typeClass() != Any::class.java
238+
&& !Map::class.java.isAssignableFrom(it.value.typeClass())
203239
&& it.key !is InputObjectTypeDefinition
204240
&& it.key !is UnionTypeDefinition
205241
}.mapValuesTo(it) { it.value.javaType }
@@ -246,7 +282,13 @@ internal class SchemaClassScanner(
246282

247283
val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions
248284

249-
return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions)
285+
// The enums in unvalidatedTypes come from directives without a class, so they can only be built when no field uses them.
286+
// A field hands the enum's values to user code even when the scanner found no class through it, e.g. an input field bound
287+
// to a Map or an unimplemented field's argument.
288+
val typeNamesUsedByFields = typeNamesUsedByFields(observedDefinitions)
289+
val directiveOnlyEnums = unvalidatedTypes.filterIsInstance<EnumTypeDefinition>().filter { it.name !in typeNamesUsedByFields }.toSet()
290+
291+
return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions, directiveOnlyEnums)
250292
}
251293

252294
private fun validateRootResolversWereUsed(rootType: RootType?, fieldResolvers: List<FieldResolver>) {
@@ -313,7 +355,7 @@ internal class SchemaClassScanner(
313355
* Scan a new object for types that haven't been mapped yet.
314356
*/
315357
private fun scanQueueItemForPotentialMatches(item: QueueItem) {
316-
val resolverInfo = if (item.clazz == Object::class.java) {
358+
val resolverInfo = if (item.clazz == Any::class.java) {
317359
getResolverInfoFromTypeDictionary(item.type.name)
318360
?: throw SchemaClassScannerError("The GraphQL schema type '${item.type.name}' maps to a field of type java.lang.Object however there is no matching entry for this type in the type dictionary. You may need to add this type to the dictionary before building the schema.")
319361
} else {
@@ -349,7 +391,7 @@ internal class SchemaClassScanner(
349391
}
350392

351393
private fun isResolverForSupertype(resolverInfo: NormalResolverInfo, dataClass: Class<*>) =
352-
resolverInfo.dataClassType != Object::class.java && resolverInfo.dataClassType.isAssignableFrom(dataClass)
394+
resolverInfo.dataClassType != Any::class.java && resolverInfo.dataClassType.isAssignableFrom(dataClass)
353395

354396
private fun scanResolverInfoForPotentialMatches(type: ObjectTypeDefinition, resolverInfo: ResolverInfo) {
355397
type.getExtendedFieldDefinitions(extensionDefinitions).forEach { field ->
@@ -386,7 +428,7 @@ internal class SchemaClassScanner(
386428
if (options.preferGraphQLResolver && realEntry.hasResolverRef()) {
387429
log.warn("The real entry ${realEntry.joinReferences()} is a GraphQLResolver so ignoring this one ${javaType.unwrap()} $reference")
388430
} else {
389-
if (java.util.Map::class.java.isAssignableFrom(javaType.unwrap())) {
431+
if (Map::class.java.isAssignableFrom(javaType.unwrap())) {
390432
throw SchemaClassScannerError("Two different property map classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType}:\n| ${reference.getDescription()}")
391433
}
392434
throw SchemaClassScannerError("Two different classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType.typeName}:\n| ${reference.getDescription()}")

‎src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import graphql.language.*
1212
import graphql.schema.*
1313
import graphql.schema.idl.RuntimeWiring
1414
import graphql.schema.idl.ScalarInfo
15-
import graphql.schema.visibility.NoIntrospectionGraphqlFieldVisibility
15+
import graphql.schema.visibility.BlockedFields
1616
import org.slf4j.LoggerFactory
1717
import kotlin.reflect.KClass
1818

@@ -39,6 +39,7 @@ class SchemaParser internal constructor(
3939
private val rootInfo = scanResult.rootInfo
4040
private val fieldResolversByType = scanResult.fieldResolversByType
4141
private val unusedDefinitions = scanResult.unusedDefinitions
42+
private val directiveOnlyEnums = scanResult.directiveOnlyEnums
4243

4344
private val extensionDefinitions = definitions.filterIsInstance<ObjectTypeExtensionDefinition>()
4445
private val inputExtensionDefinitions = definitions.filterIsInstance<InputObjectTypeExtensionDefinition>()
@@ -68,7 +69,10 @@ class SchemaParser internal constructor(
6869
*/
6970
fun parseSchemaObjects(): SchemaObjects {
7071
if (!options.introspectionEnabled) {
71-
codeRegistryBuilder.fieldVisibility(NoIntrospectionGraphqlFieldVisibility.NO_INTROSPECTION_FIELD_VISIBILITY)
72+
// What the deprecated NoIntrospectionGraphqlFieldVisibility does. Its suggested replacement,
73+
// Introspection.enabledJvmWide, would also turn introspection off for every other schema in the JVM.
74+
// TODO deprecate introspectionEnabled and remove this in 15.0.0 (#858)
75+
codeRegistryBuilder.fieldVisibility(BlockedFields.newBlock().addPattern("__.*").build())
7276
}
7377
// this overrides the above introspection enabled setting obviously... todo: add documentation
7478
options.fieldVisibility?.let { codeRegistryBuilder.fieldVisibility(it) }
@@ -195,9 +199,10 @@ class SchemaParser internal constructor(
195199

196200
private fun createEnumObject(definition: EnumTypeDefinition): GraphQLEnumType {
197201
val name = definition.name
198-
val type = dictionary[definition]
199-
?: throw SchemaError("Expected enum with name '$name' but found none!")
200-
if (!type.unwrap().isEnum) throw SchemaError("Type '$name' is declared as an enum in the GraphQL schema but is not a Java enum!")
202+
// enums only used by directives can do without a class, see SchemaClassScanner.handleDirectiveEnum
203+
val enumClass = dictionary[definition]?.unwrap()
204+
if (enumClass == null && definition !in directiveOnlyEnums) throw SchemaError("Expected enum with name '$name' but found none!")
205+
if (enumClass != null && !enumClass.isEnum) throw SchemaError("Type '$name' is declared as an enum in the GraphQL schema but is not a Java enum!")
201206

202207
val builder = GraphQLEnumType.newEnum()
203208
.name(name)
@@ -208,8 +213,12 @@ class SchemaParser internal constructor(
208213
.apply {
209214
definition.enumValueDefinitions.forEach { valueDefinition ->
210215
val enumName = valueDefinition.name
211-
val enumValue = type.unwrap().enumConstants.find { (it as Enum<*>).name == enumName }
212-
?: throw SchemaError("Expected value for name '$enumName' in enum '${type.unwrap().simpleName}' but found none!")
216+
val enumValue = if (enumClass == null) {
217+
enumName
218+
} else {
219+
enumClass.enumConstants.find { (it as Enum<*>).name == enumName }
220+
?: throw SchemaError("Expected value for name '$enumName' in enum '${enumClass.simpleName}' but found none!")
221+
}
213222

214223
value(
215224
GraphQLEnumValueDefinition.newEnumValueDefinition()
@@ -427,6 +436,7 @@ class SchemaParser internal constructor(
427436
}
428437

429438
// TODO remove this once directives are fully replaced with applied directives
439+
@Suppress("DEPRECATION") // legacy directive argument values
430440
private fun buildDirectives(
431441
directives: List<Directive>,
432442
directiveLocation: Introspection.DirectiveLocation
@@ -529,7 +539,7 @@ class SchemaParser internal constructor(
529539
is NonNullType -> GraphQLNonNull(determineType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects))
530540
is InputObjectTypeDefinition -> {
531541
log.info("Create input object")
532-
createInputObject(typeDefinition, inputObjects, referencingInputObjects as MutableSet<String>)
542+
createInputObject(typeDefinition, inputObjects, referencingInputObjects)
533543
}
534544
is TypeName -> {
535545
val scalarType = customScalars[typeDefinition.name]

‎src/main/kotlin/graphql/kickstart/tools/SchemaParserBuilder.kt‎

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package graphql.kickstart.tools
22

33
import graphql.language.Definition
44
import graphql.language.Document
5+
import graphql.language.SourceLocation
6+
import graphql.parser.InvalidSyntaxException
57
import graphql.parser.MultiSourceReader
68
import graphql.parser.Parser
79
import graphql.parser.ParserEnvironment
@@ -11,6 +13,7 @@ import graphql.schema.idl.RuntimeWiring
1113
import graphql.schema.idl.SchemaDirectiveWiring
1214
import org.antlr.v4.runtime.RecognitionException
1315
import org.antlr.v4.runtime.misc.ParseCancellationException
16+
import java.text.NumberFormat
1417
import kotlin.Int.Companion.MAX_VALUE
1518
import kotlin.reflect.KClass
1619

@@ -20,7 +23,7 @@ import kotlin.reflect.KClass
2023
class SchemaParserBuilder {
2124

2225
private val dictionary = SchemaParserDictionary()
23-
private val schemaString = StringBuilder()
26+
private val schemaStrings = mutableListOf<Source>()
2427
private val files = mutableListOf<String>()
2528
private val resolvers = mutableListOf<GraphQLResolver<*>>()
2629
private val scalars = mutableListOf<GraphQLScalarType>()
@@ -49,10 +52,14 @@ class SchemaParserBuilder {
4952
* Add a GraphQL schema string directly.
5053
*/
5154
fun schemaString(string: String) = this.apply {
52-
if (schemaString.isNotEmpty()) {
53-
schemaString.append("\n")
54-
}
55-
schemaString.append(string)
55+
schemaStrings.add(Source(string, null))
56+
}
57+
58+
/**
59+
* Add a GraphQL schema string directly, naming its source in parse errors and source locations.
60+
*/
61+
fun schemaString(string: String, sourceName: String) = this.apply {
62+
schemaStrings.add(Source(string, sourceName))
5663
}
5764

5865
/**
@@ -172,10 +179,10 @@ class SchemaParserBuilder {
172179

173180
private fun parseDocuments(): List<Document> {
174181
try {
175-
val documents = files.map { parseDocument(readFile(it), it) }.toMutableList()
182+
val documents = files.map { parseDocument(listOf(Source(readFile(it), it))) }.toMutableList()
176183

177-
if (schemaString.isNotBlank()) {
178-
documents.add(parseDocument(schemaString.toString()))
184+
if (schemaStrings.any { it.text.isNotBlank() }) {
185+
documents.add(parseDocument(schemaStrings))
179186
}
180187

181188
return documents
@@ -189,16 +196,37 @@ class SchemaParserBuilder {
189196
}
190197
}
191198

192-
private fun parseDocument(input: String, sourceName: String? = null): Document {
193-
val sourceReader = MultiSourceReader
194-
.newMultiSourceReader()
195-
.string(input, sourceName)
199+
private fun parseDocument(sources: List<Source>): Document {
200+
// graphql-java's MultiSourceReader numbers the last line of the last source from the start of the first one.
201+
// Ending every source with a line break leaves only the end of input on that line, see syntaxErrorInSource.
202+
val separated = if (sources.size > 1) sources.map { it.endingWithLineBreak() } else sources
203+
val sourceReader = MultiSourceReader.newMultiSourceReader()
204+
.apply { separated.forEach { string(it.text, it.name) } }
196205
.trackData(true).build()
197206
val environment = ParserEnvironment
198207
.newParserEnvironment()
199208
.document(sourceReader)
200209
.parserOptions(parserOptions).build()
201-
return parser.parseDocument(environment)
210+
try {
211+
return parser.parseDocument(environment)
212+
} catch (e: InvalidSyntaxException) {
213+
throw syntaxErrorInSource(e, separated.last())
214+
}
215+
}
216+
217+
/**
218+
* Makes the line of an unexpected end of input relative to the last source and adds the source name to the message.
219+
*/
220+
private fun syntaxErrorInSource(e: InvalidSyntaxException, lastSource: Source): InvalidSyntaxException {
221+
val location = e.location ?: return e
222+
val line = if (e.offendingToken == "<EOF>") lastSource.text.lines().size else location.line
223+
if (line == location.line && location.sourceName == null) return e
224+
225+
// graphql-java formats the line for the default locale, e.g. "1,103"
226+
val number = NumberFormat.getInstance()
227+
val message = e.message.orEmpty().replaceFirst(" ${number.format(location.line)} ", " ${number.format(line)} ")
228+
val inSource = location.sourceName?.let { " in $it" }.orEmpty()
229+
return SchemaSyntaxException(message + inSource, SourceLocation(line, location.column, location.sourceName), e)
202230
}
203231

204232
private fun readFile(filename: String) =
@@ -209,6 +237,10 @@ class SchemaParserBuilder {
209237
* Build the parser with the supplied schema and dictionary.
210238
*/
211239
fun build() = SchemaParser(scan(), options, runtimeWiringBuilder.build())
240+
241+
private data class Source(val text: String, val name: String?) {
242+
fun endingWithLineBreak() = if (text.endsWith("\n")) this else copy(text = "$text\n")
243+
}
212244
}
213245

214246
class InvalidSchemaError(
@@ -218,3 +250,6 @@ class InvalidSchemaError(
218250
override val message: String
219251
get() = "Invalid schema provided (${recognitionException.javaClass.name}) at: ${recognitionException.offendingToken}"
220252
}
253+
254+
internal class SchemaSyntaxException(message: String, location: SourceLocation, e: InvalidSyntaxException) :
255+
InvalidSyntaxException(message, location, e.offendingToken, e.sourcePreview, e)

‎src/main/kotlin/graphql/kickstart/tools/SchemaParserOptions.kt‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import graphql.schema.DataFetcher
1010
import graphql.schema.DataFetchingEnvironment
1111
import graphql.schema.visibility.GraphqlFieldVisibility
1212
import kotlinx.coroutines.Dispatchers
13-
import kotlinx.coroutines.ExperimentalCoroutinesApi
1413
import kotlinx.coroutines.channels.ReceiveChannel
1514
import kotlinx.coroutines.reactive.publish
1615
import org.reactivestreams.Publisher
@@ -45,7 +44,6 @@ data class SchemaParserOptions internal constructor(
4544
fun newOptions() = Builder()
4645

4746
@JvmStatic
48-
@ExperimentalCoroutinesApi
4947
fun defaultOptions() = Builder().build()
5048
}
5149

@@ -164,7 +162,6 @@ data class SchemaParserOptions internal constructor(
164162
this.allowUndeclaredDirectives = allowUndeclaredDirectives
165163
}
166164

167-
@ExperimentalCoroutinesApi
168165
fun build(): SchemaParserOptions {
169166
val coroutineContextProvider = coroutineContextProvider
170167
?: DefaultCoroutineContextProvider(Dispatchers.Default)

0 commit comments

Comments
 (0)