Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,6 @@

<profiles>
<profile>
<!-- Tests of Java sealed classes, which need Java 17 to compile -->
<id>java17-tests</id>
<activation>
<jdk>[17,)</jdk>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,4 @@ internal data class ScannedSchemaObjects(

internal typealias TypeClassDictionary = BiMap<TypeDefinition<*>, JavaType>
internal typealias CustomScalarMap = Map<String, GraphQLScalarType>
// the extensions of each type by type name, in the order they're declared
internal typealias TypeExtensions = Map<String, List<TypeDefinition<*>>>
34 changes: 3 additions & 31 deletions src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,11 @@ internal class SchemaClassScanner(
private val typeDefinitions = allDefinitions.filterIsInstance<TypeDefinition<*>>()
private val baseTypeNames = typeDefinitions.filter { it !is SDLExtensionDefinition }.map { it.name }.toSet()

// the first extension of an undefined union, enum, interface or scalar stands in for its definition
private val loneExtensions = typeDefinitions
.filter { it is SDLExtensionDefinition && it !is ObjectTypeDefinition && it !is InputObjectTypeDefinition && it.name !in baseTypeNames }
.distinctBy { it.name }

private val definitionsByName = typeDefinitions.filter { it !is SDLExtensionDefinition || it in loneExtensions }.associateBy { it.name }
// without extensions of a different kind than their type, which are ignored with a warning, see init
private val typeExtensions: TypeExtensions = typeDefinitions
.filter { it is SDLExtensionDefinition && it !in loneExtensions && !extendsOtherKind(it) }
.groupBy { it.name }
Expand Down Expand Up @@ -80,8 +78,6 @@ internal class SchemaClassScanner(
log.warn("Type '${extension.name}' is defined as ${definition.kind()}, but extended as ${extension.kind()}. The extension is ignored.")
}

// Input object types never go into the type => class bimap that interface and union type resolution use, so several of
// them can share a class, e.g. ObjectNode
initialDictionary
.filterKeys { definitionsByName[it] !is InputObjectTypeDefinition }
.entries.groupBy({ it.value }, { it.key })
Expand Down Expand Up @@ -397,8 +393,6 @@ internal class SchemaClassScanner(
}
}

// Scanning a sealed subclass can find the classes of the other types, so only fail when no type has exactly one sealed
// subclass named after it. Types still without a class fail on the next pass.
val sealedMatches = typesWithoutClass.filterIsInstance<ObjectTypeDefinition>().mapNotNull { sealedSubclassMatches(it).singleOrNull() }
if (sealedMatches.isEmpty()) {
typesWithoutClass.firstOrNull()?.let { throw SchemaClassScannerError(failureMessage(it) + sealedSubclassHint(it)) }
Expand All @@ -407,9 +401,6 @@ internal class SchemaClassScanner(
sealedMatches.forEach { handleFoundType(it) }
}

/**
* Explains why no sealed subclass was used for the type, if any of its unions or interfaces has a sealed class.
*/
private fun sealedSubclassHint(type: TypeDefinition<*>): String {
if (type !is ObjectTypeDefinition) {
return ""
Expand All @@ -429,25 +420,15 @@ internal class SchemaClassScanner(
}
}

/**
* A member of a union or an implementor of an interface may never be returned directly, e.g. `NormalPet` when only
* `pets(): List<Pet>` returns `union Pet`. If the class found for the union or interface is sealed, its subclass named
* after the object type can be used instead.
*/
private fun sealedSubclassMatches(type: ObjectTypeDefinition): List<TypeClassMatcher.ValidMatch> =
sealedTypesOf(type)
.flatMap { sealedType ->
sealedSubclasses(sealedType.unwrap())
.filter { it.simpleName == type.name }
.map { TypeClassMatcher.ValidMatch(type, parameterize(it, sealedType), SealedSubclassReference(sealedType)) }
.map { TypeClassMatcher.ValidMatch(type, withSealedTypeArguments(it, sealedType), SealedSubclassReference(sealedType)) }
}
// a subclass is found twice when it's under two of these sealed types, or extends both a sealed type and one of its
// sealed subclasses
.distinctBy { it.javaType.unwrap() }

/**
* The sealed classes found for the unions the type is a member of and the interfaces it implements.
*/
private fun sealedTypesOf(type: ObjectTypeDefinition): List<JavaType> =
dictionary
.filterKeys { definition ->
Expand All @@ -461,18 +442,11 @@ internal class SchemaClassScanner(
.distinct()
.filter { it.unwrap().kotlin.isSealed }

/**
* All subclasses of a sealed class, including those of its sealed subclasses. kotlin-reflect also returns the permitted
* subclasses of Java sealed classes.
*/
// kotlin-reflect also returns the permitted subclasses of Java sealed classes
private fun sealedSubclasses(clazz: Class<*>): List<Class<*>> =
clazz.kotlin.sealedSubclasses.flatMap { listOf(it.java) + sealedSubclasses(it.java) }

/**
* Passes the type arguments of a parameterized sealed class on to its generic subclass, e.g. `Success<Pet>` for
* `Result<Pet>`, so the types of the subclass's fields can be resolved.
*/
private fun parameterize(subclass: Class<*>, sealedType: JavaType): JavaType {
private fun withSealedTypeArguments(subclass: Class<*>, sealedType: JavaType): JavaType {
if (sealedType !is ParameterizedType || subclass.typeParameters.isEmpty()) {
return subclass
}
Expand Down Expand Up @@ -538,7 +512,6 @@ internal class SchemaClassScanner(
}
}

// a JDK dynamic proxy only implements its target's interfaces, so the target's own methods and fields can't be called on it
// TODO throw a FieldResolverError instead in 15.0.0 (#868)
private fun warnIfNotCallableOnResolver(type: ObjectTypeDefinition, fieldResolver: FieldResolver) {
val source = fieldResolver.search.source ?: return
Expand Down Expand Up @@ -693,7 +666,6 @@ internal class SchemaClassScanner(
}
}

// e.g. `extend union Foo` of `type Foo`
private fun extendsOtherKind(definition: TypeDefinition<*>): Boolean {
val type = definitionsByName[definition.name]
return definition is SDLExtensionDefinition && type != null && type.kind() != definition.kind()
Expand Down
4 changes: 0 additions & 4 deletions src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,6 @@ class SchemaParser internal constructor(
.withAppliedDirectives(*buildAppliedDirectives(directives))
.withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.INPUT_OBJECT))
.apply {
// unlike graphql-java the fields of the extensions come first, which is kept so the order of the fields doesn't change
(extensionDefinitions + definition).forEach { typeDefinition ->
typeDefinition.inputValueDefinitions.forEach { fieldDefinition ->
field(
Expand Down Expand Up @@ -326,9 +325,6 @@ class SchemaParser internal constructor(
}
}

// The directives of the extensions follow those of the type, like in graphql-java. A non-repeatable directive that an extension
// applies again is dropped, where graphql-java rejects the schema, so those schemas keep building and the applied directives
// match the legacy ones (see buildDirectives). Undeclared directives may be repeatable, so they're all kept.
private fun directivesOf(definition: TypeDefinition<*>, extensions: List<TypeDefinition<*>>): List<Directive> {
val applied = definition.directives.mapTo(mutableSetOf()) { it.name }
return definition.directives + extensions.flatMap { extension ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import kotlin.reflect.KClass

class SchemaParserDictionary {

// Not a BiMap, since several input types can share a class, e.g. ObjectNode. The scanner checks that other types don't.
private val dictionary = linkedMapOf<String, Class<*>>()

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,14 @@ import graphql.schema.GraphQLType
import graphql.schema.GraphQLTypeUtil
import org.slf4j.LoggerFactory

/**
* Checks that the classes of resolver arguments have a property for each field of their input objects, see [UnmappedInputFields].
*
* The fields are looked up in the deserializer that the resolver's ObjectMapper converts the argument with, not in the class
* itself, so that a field counts as mapped whenever Jackson maps it. An input type can be used by fields whose mappers are
* configured differently, so it's checked once for each mapper.
*/
internal class UnmappedInputFieldChecker(
private val inputObjects: Map<String, GraphQLInputObjectType>,
private val unmappedInputFields: UnmappedInputFields
) {
private val log = LoggerFactory.getLogger(javaClass)

// a deserializer is cached by its mapper, so this is per mapper, class and input type, and ends recursive input types
private val checked = mutableSetOf<Pair<JsonDeserializer<*>, String>>()

// reported once for each consequence, with the first argument found using it, since mappers differ in ignoring unknown properties
private val messages = linkedMapOf<Unmapped, String>()

fun check(objectType: String, resolver: MethodFieldResolver) {
Expand All @@ -50,14 +41,11 @@ internal class UnmappedInputFieldChecker(
val mapper = resolver.objectMapper
val context = (mapper.deserializationContext as? DefaultDeserializationContext)
?.createDummyInstance(mapper.deserializationConfig) ?: return@forEachIndexed
// the same lookup as convertValue's when it converts the argument
val deserializer = context.findRootValueDeserializer(mapper.typeFactory.constructType(parameterType))
checkFields(mapper, context, deserializer, null, inputObject, usage)
} catch (e: VirtualMachineError) {
throw e
} catch (e: Throwable) {
// e.g. a class Jackson can't deserialize, or one that refers to a missing class, which only fails once the argument is
// converted, as it did before this check. Errors are caught too, since the lookup used to run on the first query
log.debug("Can't check the input fields of $usage with Jackson", e)
}
}
Expand Down Expand Up @@ -101,12 +89,9 @@ internal class UnmappedInputFieldChecker(
}
}

// the deserializer of the class an input object is converted to, past the lists, arrays and Optionals around it
private tailrec fun beanDeserializer(context: DeserializationContext, deserializer: JsonDeserializer<*>?, property: BeanProperty?): BeanDeserializerBase? =
when (deserializer) {
// a delegating creator gets the whole input object, e.g. as a Map
is BeanDeserializerBase -> deserializer.takeUnless { it.valueInstantiator.canCreateUsingDelegate() }
// a Map gets the fields as its keys
is ContainerDeserializerBase<*> ->
if (deserializer.valueType.isMapLikeType || isPolymorphic(context, deserializer.contentType)) null
else beanDeserializer(context, deserializer.contentDeserializer, property)
Expand All @@ -120,20 +105,17 @@ internal class UnmappedInputFieldChecker(
}

/**
* Whether the elements of a list, an array or an Optional are read with type info, so that their class is only known once their
* type id is read. Their deserializer is the base class's, which doesn't know the subclasses' properties. The same lookup as
* Jackson's when it creates the deserializer: type info from a property's annotation, or else from the class or default typing.
* The deserializer of elements read with type info is the base class's, which doesn't know the subclasses' properties. The
* same lookup as Jackson's when it creates the deserializer: type info from a property's annotation, or else from the class
* or default typing.
*/
private fun isPolymorphic(context: DeserializationContext, contentType: JavaType): Boolean =
contentType.getTypeHandler<Any?>() != null || context.factory.findTypeDeserializer(context.config, contentType) != null

/**
* Returns the properties Jackson knows if it fails on the field, or null if it maps or ignores it.
*
* `{"<field>": null}` is read into a placeholder object rather than an instance of the class, so neither its constructors nor
* its setters are called, but the field still goes through Jackson's handling of a property the class doesn't have: ignored
* names, any setters, ignoreUnknown and problem handlers. Unknown properties are made to fail even if the mapper ignores them.
* Any other exception, e.g. from a deserializer that can't read into an existing object, means the field can't be checked.
* names, any setters, ignoreUnknown and problem handlers.
*/
private fun knownPropertiesIfUnmapped(
mapper: ObjectMapper,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,10 @@ import java.io.IOException
import java.lang.reflect.Type

/**
* Converts an argument value that graphql-java already coerced to the type of the resolver method's parameter.
*
* ObjectMapper.convertValue serializes a value and reads it back, which the coerced values of many custom scalars don't
* survive, e.g. an upload, or a LocalDate without the jsr310 module. Those values are written as embedded objects
* instead, which Jackson's deserializers return as they are if they have the type being read, and convert with
* toString() if a String is read.
*
* An untyped parameter, e.g. a Map<String, Any?>, gets every coerced value as it is, apart from strings, numbers,
* booleans and enums, which are converted as before.
*/
internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {

Expand All @@ -39,16 +34,12 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {
}

if (isUntyped) {
// nothing reads an untyped value through its serialized form, so every coerced value is kept
return read(embed(value) { true } ?: value)
}

// a deserializer may read the serialized form, so values are only embedded if converting fails
return try {
mapper.convertValue<Any?>(value, javaType)
} catch (e: IllegalArgumentException) {
// a class whose value failed to convert isn't tried again in this value, since that can be expensive, e.g. for
// a list of uploads. It isn't remembered across calls, so the same input always gives the same result
val nonConvertingClasses = HashSet<Class<*>>()
val embedded = embed(value) { scalar ->
when {
Expand All @@ -63,16 +54,11 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {
try {
read(embedded)
} catch (retryError: IllegalArgumentException) {
// the values that failed to convert likely hid the actual problem
throw retryError.apply { addSuppressed(e) }
}
}
}

/**
* Returns a copy of the value in which the scalar values that [shouldEmbed] accepts are wrapped in [EmbeddedValue],
* or null if it accepts none.
*/
private fun embed(value: Any, shouldEmbed: (Any) -> Boolean): Any? {
var embedsAny = false
fun copy(value: Any?): Any? = when (value) {
Expand Down Expand Up @@ -131,7 +117,6 @@ internal class ArgumentConverter(private val mapper: ObjectMapper, type: Type) {
.withHandler(embeddedValueHandler)
}

// written to the buffer as an embedded object, which it keeps as it is
private class EmbeddedValue(private val value: Any) : JsonSerializable {
override fun serialize(gen: JsonGenerator, serializers: SerializerProvider) = gen.writeEmbeddedObject(value)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ internal class MethodFieldResolver(
private val numberOfParameters = method.parameterCountWithoutContinuation()
private val hasAdditionalParameter = numberOfParameters == (field.inputValueDefinitions.size + getIndexOffset() + 1)

// the arguments are converted with the same ObjectMapper that the UnmappedInputFieldChecker checks their classes with
internal val objectMapper: ObjectMapper by lazy { options.objectMapperProvider.provide(field) }

override fun createDataFetcher(): DataFetcher<*> {
Expand Down Expand Up @@ -202,7 +201,6 @@ internal class MethodFieldResolver(

private fun getJavaMethodParameterIndex(index: Int) = index + getIndexOffset()

// the type that the value of the argument at the index is converted to
internal fun getMethodParameterType(index: Int): JavaType? {
val methodIndex = getJavaMethodParameterIndex(index)
val parameters = method.parameterTypes
Expand Down
3 changes: 1 addition & 2 deletions src/main/kotlin/graphql/kickstart/tools/util/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ internal fun Type<*>.unwrap(): Type<*> = when (this) {
internal inline fun <reified T : TypeDefinition<*>> TypeExtensions.extensionsOf(name: String): List<T> =
this[name].orEmpty().filterIsInstance<T>()

// The type followed by its extensions, e.g. `union.withExtensions(typeExtensions).flatMap { it.memberTypes }`. Every extension class
// is a subclass of its type's class, e.g. UnionTypeExtensionDefinition of UnionTypeDefinition.
// Every extension class is a subclass of its type's class, e.g. UnionTypeExtensionDefinition of UnionTypeDefinition.
internal inline fun <reified T : TypeDefinition<*>> T.withExtensions(typeExtensions: TypeExtensions): List<T> =
listOf(this) + typeExtensions.extensionsOf<T>(name)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

// Sealed classes need Java 17, so the java17-tests profile only compiles and runs this on Java 17 and later.
public class SchemaClassScannerSealedJavaTypesTest {

@Test
Expand Down
Loading
Loading