Skip to content

Commit c5d449b

Browse files
authored
Merge branch 'master' into build/stricter-verify
2 parents c996dd8 + 79e65c6 commit c5d449b

7 files changed

Lines changed: 341 additions & 23 deletions

File tree

‎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: 46 additions & 4 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 }
@@ -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>) {

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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>()
@@ -198,9 +199,10 @@ class SchemaParser internal constructor(
198199

199200
private fun createEnumObject(definition: EnumTypeDefinition): GraphQLEnumType {
200201
val name = definition.name
201-
val type = dictionary[definition]
202-
?: throw SchemaError("Expected enum with name '$name' but found none!")
203-
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!")
204206

205207
val builder = GraphQLEnumType.newEnum()
206208
.name(name)
@@ -211,8 +213,12 @@ class SchemaParser internal constructor(
211213
.apply {
212214
definition.enumValueDefinitions.forEach { valueDefinition ->
213215
val enumName = valueDefinition.name
214-
val enumValue = type.unwrap().enumConstants.find { (it as Enum<*>).name == enumName }
215-
?: 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+
}
216222

217223
value(
218224
GraphQLEnumValueDefinition.newEnumValueDefinition()

‎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)

0 commit comments

Comments
 (0)