Skip to content

Commit 5affacd

Browse files
author
Dariusz Kuc
authored
[client] generate fallback/default implementation for polymorphic types (#1382)
Currently our client generator requires exhaustive selection of all polymorphic type implementations. This behavior is limiting as there are valid use cases when end-users may not need to select all the types (e.g. they only select common interface fields). By adding default/fallback implementation we also make it more robust as clients could be long lived and underlying schema could add/remove some implementations. Due to the `kotlinx-serialization` limitations, we won't be able to provide auto configuration to handle default deserialization logic. Users will have to explicitly configure serializers module. See Kotlin/kotlinx.serialization#1575 for details. ```kotlin val client = GraphQLKtorClient( url = URL("http://localhost:8080/graphql"), serializer = GraphQLClientKotlinxSerializer(jsonBuilder = { serializersModule = SerializersModule { polymorphic(MyInterface::class) { defaultDeserializer { DefaultMyInterfaceImplementation.serializer() } } } }) ) ``` Related Issues: Resolves #1376
1 parent 0dfb6c3 commit 5affacd

File tree

60 files changed

+1133
-77
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+1133
-77
lines changed

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError
1717
kotlinJvmVersion = 1.8
1818
kotlinVersion = 1.6.10
1919
kotlinCoroutinesVersion = 1.6.0
20-
kotlinxSerializationVersion = 1.3.1
20+
kotlinxSerializationVersion = 1.3.2
2121

2222
androidPluginVersion = 7.0.1
2323
classGraphVersion = 4.8.138

plugins/client/graphql-kotlin-client-generator/build.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ val kotlinVersion: String by project
99
val kotlinPoetVersion: String by project
1010
val kotlinxSerializationVersion: String by project
1111
val ktorVersion: String by project
12+
val slf4jVersion: String by project
1213
val wireMockVersion: String by project
1314

1415
dependencies {
@@ -18,12 +19,13 @@ dependencies {
1819
}
1920
api("com.squareup:kotlinpoet:$kotlinPoetVersion")
2021
api("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion")
22+
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
2123
implementation("io.ktor:ktor-client-apache:$ktorVersion")
2224
implementation("io.ktor:ktor-client-jackson:$ktorVersion") {
2325
exclude("com.fasterxml.jackson.core", "jackson-databind")
2426
exclude("com.fasterxml.jackson.module", "jackson-module-kotlin")
2527
}
26-
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
28+
implementation("org.slf4j:slf4j-api:$slf4jVersion")
2729
testImplementation(project(path = ":graphql-kotlin-client-jackson"))
2830
testImplementation(project(path = ":graphql-kotlin-client-serialization"))
2931
testImplementation("com.github.tomakehurst:wiremock-jre8:$wireMockVersion")

plugins/client/graphql-kotlin-client-generator/src/main/kotlin/com/expediagroup/graphql/plugin/client/generator/exceptions/InvalidPolymorphicQueryException.kt

Lines changed: 0 additions & 23 deletions
This file was deleted.

plugins/client/graphql-kotlin-client-generator/src/main/kotlin/com/expediagroup/graphql/plugin/client/generator/types/generateInterfaceTypeSpec.kt

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2021 Expedia, Inc
2+
* Copyright 2022 Expedia, Inc
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,7 +20,6 @@ import com.expediagroup.graphql.client.Generated
2020
import com.expediagroup.graphql.plugin.client.generator.GraphQLClientGeneratorContext
2121
import com.expediagroup.graphql.plugin.client.generator.GraphQLSerializer
2222
import com.expediagroup.graphql.plugin.client.generator.exceptions.InvalidFragmentException
23-
import com.expediagroup.graphql.plugin.client.generator.exceptions.InvalidPolymorphicQueryException
2423
import com.expediagroup.graphql.plugin.client.generator.exceptions.MissingTypeNameException
2524
import com.fasterxml.jackson.annotation.JsonSubTypes
2625
import com.fasterxml.jackson.annotation.JsonTypeInfo
@@ -30,6 +29,8 @@ import com.squareup.kotlinpoet.CodeBlock
3029
import com.squareup.kotlinpoet.FunSpec
3130
import com.squareup.kotlinpoet.KModifier
3231
import com.squareup.kotlinpoet.MemberName
32+
import com.squareup.kotlinpoet.MemberName.Companion.member
33+
import com.squareup.kotlinpoet.ParameterSpec
3334
import com.squareup.kotlinpoet.PropertySpec
3435
import com.squareup.kotlinpoet.TypeSpec
3536
import graphql.language.Field
@@ -41,6 +42,10 @@ import graphql.language.Selection
4142
import graphql.language.SelectionSet
4243
import kotlinx.serialization.SerialName
4344
import kotlinx.serialization.Serializable
45+
import org.slf4j.Logger
46+
import org.slf4j.LoggerFactory
47+
48+
private val logger: Logger = LoggerFactory.getLogger("com.expediagroup.graphql.plugin.client.generator.types.GenerateInterfaceTypeSpec")
4449

4550
/**
4651
* Generate interface [TypeSpec] based on the available field definitions and selection set. Generates all implementing classes as well.
@@ -123,10 +128,10 @@ internal fun generateInterfaceTypeSpec(
123128
existing.second.addAll(fragment.selectionSet.selections)
124129
}
125130

126-
// check if all implementations are selected
131+
// log warning if not all implementations are selected
127132
val notImplemented = implementations.minus(implementationSelections.keys)
128133
if (notImplemented.isNotEmpty()) {
129-
throw InvalidPolymorphicQueryException(context.operationName, interfaceName, notImplemented)
134+
logger.warn("Operation ${context.operationName} does not specify all polymorphic implementations - field selection on $interfaceName is missing $notImplemented")
130135
}
131136

132137
// generate implementations with final selection set
@@ -152,6 +157,7 @@ internal fun generateInterfaceTypeSpec(
152157
jsonSubTypesCodeBlock.add("com.fasterxml.jackson.annotation.JsonSubTypes.Type(value = %T::class, name=%S)", unwrappedClassName, implementationName)
153158
}
154159

160+
val fallbackClassName = generateFallbackImplementation(context, interfaceName, commonProperties)
155161
if (context.serializer == GraphQLSerializer.JACKSON) {
156162
// add jackson annotations to handle deserialization
157163
val jsonTypeInfoIdName = MemberName("com.fasterxml.jackson.annotation", "JsonTypeInfo.Id.NAME")
@@ -161,6 +167,7 @@ internal fun generateInterfaceTypeSpec(
161167
.addMember("use = %M", jsonTypeInfoIdName)
162168
.addMember("include = %M", jsonTypeInfoAsProperty)
163169
.addMember("property = %S", "__typename")
170+
.addMember("defaultImpl = %T::class", fallbackClassName)
164171
.build()
165172
)
166173
interfaceTypeSpec.addAnnotation(
@@ -216,3 +223,56 @@ private fun updateImplementationTypeSpecWithSuperInformation(
216223
context.polymorphicTypes[superClassName]?.add(implementationClassName)
217224
context.typeSpecs[implementationClassName] = updatedType
218225
}
226+
227+
private fun generateFallbackImplementation(context: GraphQLClientGeneratorContext, interfaceName: String, commonProperties: List<PropertySpec>): ClassName {
228+
val fallbackTypeName = "Default${interfaceName}Implementation"
229+
val superClassName = ClassName("${context.packageName}.${context.operationName.lowercase()}", interfaceName)
230+
val fallbackClassName = ClassName("${context.packageName}.${context.operationName.lowercase()}", fallbackTypeName)
231+
val fallbackType = TypeSpec.classBuilder(fallbackTypeName)
232+
.addAnnotation(Generated::class)
233+
.addKdoc("Fallback $interfaceName implementation that will be used when unknown/unhandled type is encountered.")
234+
.also {
235+
if (context.serializer == GraphQLSerializer.KOTLINX) {
236+
it.addAnnotation(Serializable::class)
237+
.superclass(superClassName)
238+
.addKdoc("\n\nNOTE: This fallback logic has to be manually registered with the instance of GraphQLClientKotlinxSerializer. See documentation for details.")
239+
} else {
240+
it.addSuperinterface(superClassName)
241+
}
242+
243+
if (commonProperties.isNotEmpty()) {
244+
it.addModifiers(KModifier.DATA)
245+
}
246+
}
247+
.addProperties(
248+
commonProperties.map { abstractProperty ->
249+
abstractProperty.toBuilder()
250+
.initializer(abstractProperty.name)
251+
.addModifiers(KModifier.OVERRIDE)
252+
.also {
253+
it.modifiers.remove(KModifier.ABSTRACT)
254+
}
255+
.build()
256+
}
257+
)
258+
.primaryConstructor(
259+
FunSpec.constructorBuilder()
260+
.addParameters(
261+
commonProperties.map { propertySpec ->
262+
val constructorParameter = ParameterSpec.builder(propertySpec.name, propertySpec.type)
263+
val className = propertySpec.type as? ClassName
264+
if (propertySpec.type.isNullable) {
265+
constructorParameter.defaultValue("null")
266+
} else if (className != null && context.enumClassToTypeSpecs.keys.contains(className)) {
267+
constructorParameter.defaultValue("%M", className.member(UNKNOWN_VALUE))
268+
}
269+
constructorParameter.build()
270+
}
271+
)
272+
.build()
273+
)
274+
.build()
275+
context.polymorphicTypes[superClassName]?.add(fallbackClassName)
276+
context.typeSpecs[fallbackClassName] = fallbackType
277+
return fallbackClassName
278+
}

plugins/client/graphql-kotlin-client-generator/src/test/data/generator/interface_diff_selection_sets/differentselectionsetquery/BasicInterface.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import kotlin.String
1616
@JsonTypeInfo(
1717
use = JsonTypeInfo.Id.NAME,
1818
include = JsonTypeInfo.As.PROPERTY,
19-
property = "__typename"
19+
property = "__typename",
20+
defaultImpl = DefaultBasicInterfaceImplementation::class
2021
)
2122
@JsonSubTypes(value = [com.fasterxml.jackson.annotation.JsonSubTypes.Type(value =
2223
FirstInterfaceImplementation::class,
@@ -71,3 +72,19 @@ public data class SecondInterfaceImplementation(
7172
*/
7273
public val floatValue: Double
7374
) : BasicInterface
75+
76+
/**
77+
* Fallback BasicInterface implementation that will be used when unknown/unhandled type is
78+
* encountered.
79+
*/
80+
@Generated
81+
public data class DefaultBasicInterfaceImplementation(
82+
/**
83+
* Unique identifier of an interface
84+
*/
85+
public override val id: Int,
86+
/**
87+
* Name field
88+
*/
89+
public override val name: String
90+
) : BasicInterface

plugins/client/graphql-kotlin-client-generator/src/test/data/generator/interface_diff_selection_sets/differentselectionsetquery/BasicInterface2.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import kotlin.String
1616
@JsonTypeInfo(
1717
use = JsonTypeInfo.Id.NAME,
1818
include = JsonTypeInfo.As.PROPERTY,
19-
property = "__typename"
19+
property = "__typename",
20+
defaultImpl = DefaultBasicInterface2Implementation::class
2021
)
2122
@JsonSubTypes(value = [com.fasterxml.jackson.annotation.JsonSubTypes.Type(value =
2223
FirstInterfaceImplementation2::class,
@@ -58,3 +59,15 @@ public data class SecondInterfaceImplementation2(
5859
*/
5960
public val floatValue: Double
6061
) : BasicInterface2
62+
63+
/**
64+
* Fallback BasicInterface2 implementation that will be used when unknown/unhandled type is
65+
* encountered.
66+
*/
67+
@Generated
68+
public data class DefaultBasicInterface2Implementation(
69+
/**
70+
* Name field
71+
*/
72+
public override val name: String
73+
) : BasicInterface2

plugins/client/graphql-kotlin-client-generator/src/test/data/generator/interface_impl_diff_selection_sets/differentselectionsetquery/BasicInterface.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import kotlin.Int
1515
@JsonTypeInfo(
1616
use = JsonTypeInfo.Id.NAME,
1717
include = JsonTypeInfo.As.PROPERTY,
18-
property = "__typename"
18+
property = "__typename",
19+
defaultImpl = DefaultBasicInterfaceImplementation::class
1920
)
2021
@JsonSubTypes(value = [com.fasterxml.jackson.annotation.JsonSubTypes.Type(value =
2122
FirstInterfaceImplementation::class,
@@ -57,3 +58,15 @@ public data class SecondInterfaceImplementation(
5758
*/
5859
public val floatValue: Double
5960
) : BasicInterface
61+
62+
/**
63+
* Fallback BasicInterface implementation that will be used when unknown/unhandled type is
64+
* encountered.
65+
*/
66+
@Generated
67+
public data class DefaultBasicInterfaceImplementation(
68+
/**
69+
* Unique identifier of an interface
70+
*/
71+
public override val id: Int
72+
) : BasicInterface

plugins/client/graphql-kotlin-client-generator/src/test/data/generator/interface_impl_diff_selection_sets/differentselectionsetquery/BasicInterface2.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import kotlin.String
1616
@JsonTypeInfo(
1717
use = JsonTypeInfo.Id.NAME,
1818
include = JsonTypeInfo.As.PROPERTY,
19-
property = "__typename"
19+
property = "__typename",
20+
defaultImpl = DefaultBasicInterface2Implementation::class
2021
)
2122
@JsonSubTypes(value = [com.fasterxml.jackson.annotation.JsonSubTypes.Type(value =
2223
FirstInterfaceImplementation2::class,
@@ -66,3 +67,15 @@ public data class SecondInterfaceImplementation2(
6667
*/
6768
public val floatValue: Double
6869
) : BasicInterface2
70+
71+
/**
72+
* Fallback BasicInterface2 implementation that will be used when unknown/unhandled type is
73+
* encountered.
74+
*/
75+
@Generated
76+
public data class DefaultBasicInterface2Implementation(
77+
/**
78+
* Unique identifier of an interface
79+
*/
80+
public override val id: Int
81+
) : BasicInterface2

plugins/client/graphql-kotlin-client-generator/src/test/data/invalid/interface_missing_types/InvalidQueryMissingTypeSelection.graphql renamed to plugins/client/graphql-kotlin-client-generator/src/test/data/generator/interface_missing_types/InterfaceMissingTypeSelection.graphql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
query InvalidQueryMissingTypeSelection {
1+
query InterfaceMissingTypeSelection {
22
interfaceQuery {
33
__typename
44
id
55
name
66
... on FirstInterfaceImplementation {
7-
value
7+
intValue
88
}
99
}
1010
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.expediagroup.graphql.generated
2+
3+
import com.expediagroup.graphql.client.Generated
4+
import com.expediagroup.graphql.client.types.GraphQLClientRequest
5+
import com.expediagroup.graphql.generated.interfacemissingtypeselection.BasicInterface
6+
import kotlin.String
7+
import kotlin.reflect.KClass
8+
9+
public const val INTERFACE_MISSING_TYPE_SELECTION: String =
10+
"query InterfaceMissingTypeSelection {\n interfaceQuery {\n __typename\n id\n name\n ... on FirstInterfaceImplementation {\n intValue\n }\n }\n}"
11+
12+
@Generated
13+
public class InterfaceMissingTypeSelection :
14+
GraphQLClientRequest<InterfaceMissingTypeSelection.Result> {
15+
public override val query: String = INTERFACE_MISSING_TYPE_SELECTION
16+
17+
public override val operationName: String = "InterfaceMissingTypeSelection"
18+
19+
public override fun responseType(): KClass<InterfaceMissingTypeSelection.Result> =
20+
InterfaceMissingTypeSelection.Result::class
21+
22+
@Generated
23+
public data class Result(
24+
/**
25+
* Query returning an interface
26+
*/
27+
public val interfaceQuery: BasicInterface
28+
)
29+
}

0 commit comments

Comments
 (0)