From 5b058bf00477ba22e4447a0a070af1971fd3c153 Mon Sep 17 00:00:00 2001 From: Samuel Vazquez Date: Mon, 9 May 2022 11:29:14 -0700 Subject: [PATCH] feat: support directives with union (#1439) * feat: support directives with the union annotation (#1424) * feat: support directives with the union annotation * CR comment * fix: willAddGraphQLTypeToSchema needs annotations from field (#1437) * feat: use withDirective * feat: update tests Co-authored-by: bherrmann2 Co-authored-by: samvazquez --- .../graphql/generator/SchemaGenerator.kt | 3 +- .../generator/annotations/GraphQLUnion.kt | 2 +- .../extensions/annotationExtensions.kt | 7 +- .../internal/extensions/kClassExtensions.kt | 5 +- .../generator/internal/state/TypesCache.kt | 6 +- .../internal/types/generateGraphQLType.kt | 25 ++++- .../generator/internal/types/generateUnion.kt | 12 ++- .../hooks/SchemaGeneratorHooksTest.kt | 37 ++++++-- .../extensions/AnnotationExtensionsTest.kt | 33 ++++++- .../extensions/KClassExtensionsTest.kt | 21 +++++ .../internal/state/TypesCacheTest.kt | 61 +++++++++++++ .../internal/types/GenerateUnionTest.kt | 22 +++++ .../integration/CustomUnionAnnotationTest.kt | 91 ++++++++++++++++++- .../writing-schemas/unions.md | 28 +++++- 14 files changed, 332 insertions(+), 21 deletions(-) diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/SchemaGenerator.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/SchemaGenerator.kt index 2475dc93c0..f83d10abf3 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/SchemaGenerator.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/SchemaGenerator.kt @@ -17,6 +17,7 @@ package com.expediagroup.graphql.generator import com.expediagroup.graphql.generator.exceptions.InvalidPackagesException +import com.expediagroup.graphql.generator.internal.extensions.getKClass import com.expediagroup.graphql.generator.internal.state.AdditionalType import com.expediagroup.graphql.generator.internal.state.ClassScanner import com.expediagroup.graphql.generator.internal.state.TypesCache @@ -121,7 +122,7 @@ open class SchemaGenerator(internal val config: SchemaGeneratorConfig) : Closeab this.additionalTypes.clear() graphqlTypes.addAll( currentlyProcessedTypes.map { - GraphQLTypeUtil.unwrapNonNull(generateGraphQLType(this, it.kType, GraphQLKTypeMetadata(inputType = it.inputType))) + GraphQLTypeUtil.unwrapNonNull(generateGraphQLType(this, it.kType, GraphQLKTypeMetadata(inputType = it.inputType, fieldAnnotations = it.kType.getKClass().annotations))) } ) } diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLUnion.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLUnion.kt index a1153a3c72..0535a6ecb0 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLUnion.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLUnion.kt @@ -18,7 +18,7 @@ package com.expediagroup.graphql.generator.annotations import kotlin.reflect.KClass -@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) +@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION, AnnotationTarget.ANNOTATION_CLASS) annotation class GraphQLUnion( val name: String, val possibleTypes: Array>, diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/extensions/annotationExtensions.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/extensions/annotationExtensions.kt index 2f254be6e6..8691364306 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/extensions/annotationExtensions.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/extensions/annotationExtensions.kt @@ -22,6 +22,7 @@ import com.expediagroup.graphql.generator.annotations.GraphQLName import com.expediagroup.graphql.generator.annotations.GraphQLType import com.expediagroup.graphql.generator.annotations.GraphQLUnion import kotlin.reflect.KAnnotatedElement +import kotlin.reflect.KClass import kotlin.reflect.full.findAnnotation internal fun KAnnotatedElement.getGraphQLDescription(): String? = this.findAnnotation()?.value @@ -32,7 +33,11 @@ internal fun KAnnotatedElement.getDeprecationReason(): String? = this.findAnnota internal fun KAnnotatedElement.isGraphQLIgnored(): Boolean = this.findAnnotation() != null -internal fun List.getUnionAnnotation(): GraphQLUnion? = this.filterIsInstance(GraphQLUnion::class.java).firstOrNull() +internal fun List.getUnionAnnotation(): GraphQLUnion? = this.filterIsInstance(GraphQLUnion::class.java).firstOrNull() ?: this.map { it.getMetaUnionAnnotation() }.firstOrNull() + +internal fun List.getCustomUnionClassWithMetaUnionAnnotation(): KClass<*>? = this.firstOrNull { it.getMetaUnionAnnotation() != null }?.annotationClass + +internal fun Annotation.getMetaUnionAnnotation(): GraphQLUnion? = this.annotationClass.annotations.filterIsInstance(GraphQLUnion::class.java).firstOrNull() internal fun List.getCustomTypeAnnotation(): GraphQLType? = this.filterIsInstance(GraphQLType::class.java).firstOrNull() diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/extensions/kClassExtensions.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/extensions/kClassExtensions.kt index f716cdf2e1..b9bb4e71b9 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/extensions/kClassExtensions.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/extensions/kClassExtensions.kt @@ -64,7 +64,10 @@ internal fun KClass<*>.isUnion(fieldAnnotations: List = emptyList()) private fun KClass<*>.isDeclaredUnion() = this.isInterface() && this.declaredMemberProperties.isEmpty() && this.declaredMemberFunctions.isEmpty() -internal fun KClass<*>.isAnnotationUnion(fieldAnnotations: List): Boolean = this.isInstance(Any::class) && fieldAnnotations.getUnionAnnotation() != null +internal fun KClass<*>.isAnnotationUnion(fieldAnnotations: List): Boolean = (this.isInstance(Any::class) || this.isAnnotation()) && + fieldAnnotations.getUnionAnnotation() != null + +internal fun KClass<*>.isAnnotation(): Boolean = this.isSubclassOf(Annotation::class) /** * Do not add interfaces as additional types if it expects all the types diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/state/TypesCache.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/state/TypesCache.kt index dd0daabb0a..959bc75ab2 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/state/TypesCache.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/state/TypesCache.kt @@ -34,6 +34,7 @@ import graphql.schema.GraphQLTypeReference import java.io.Closeable import kotlin.reflect.KClass import kotlin.reflect.KType +import kotlin.reflect.full.createType import kotlin.reflect.full.isSubclassOf import kotlin.reflect.full.starProjectedType @@ -88,7 +89,7 @@ internal class TypesCache(private val supportedPackages: List) : Closeab val unionAnnotation = typeInfo.fieldAnnotations.getUnionAnnotation() if (unionAnnotation != null) { if (type.getKClass().isAnnotationUnion(typeInfo.fieldAnnotations)) { - return TypesCacheKey(type, typeInfo.inputType, getCustomUnionNameKey(unionAnnotation)) + return TypesCacheKey(Any::class.createType(), typeInfo.inputType, getCustomUnionNameKey(unionAnnotation)) } else { throw InvalidCustomUnionException(type) } @@ -148,7 +149,8 @@ internal class TypesCache(private val supportedPackages: List) : Closeab typesUnderConstruction.add(cacheKey) val newType = build(kClass) if (newType !is GraphQLTypeReference && newType is GraphQLNamedType) { - put(cacheKey, KGraphQLType(kClass, newType)) + val cacheKClass = if (kClass.isAnnotationUnion(typeInfo.fieldAnnotations)) Any::class else kClass + put(cacheKey, KGraphQLType(cacheKClass, newType)) } typesUnderConstruction.remove(cacheKey) newType diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateGraphQLType.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateGraphQLType.kt index 1955c3b7d1..40a71a5541 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateGraphQLType.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateGraphQLType.kt @@ -19,8 +19,11 @@ package com.expediagroup.graphql.generator.internal.types import com.expediagroup.graphql.generator.SchemaGenerator import com.expediagroup.graphql.generator.extensions.unwrapType import com.expediagroup.graphql.generator.internal.extensions.getCustomTypeAnnotation +import com.expediagroup.graphql.generator.internal.extensions.getCustomUnionClassWithMetaUnionAnnotation import com.expediagroup.graphql.generator.internal.extensions.getKClass +import com.expediagroup.graphql.generator.internal.extensions.getMetaUnionAnnotation import com.expediagroup.graphql.generator.internal.extensions.getUnionAnnotation +import com.expediagroup.graphql.generator.internal.extensions.isAnnotation import com.expediagroup.graphql.generator.internal.extensions.isEnum import com.expediagroup.graphql.generator.internal.extensions.isInterface import com.expediagroup.graphql.generator.internal.extensions.isListType @@ -30,6 +33,7 @@ import graphql.schema.GraphQLType import graphql.schema.GraphQLTypeReference import kotlin.reflect.KClass import kotlin.reflect.KType +import kotlin.reflect.full.createType /** * Return a basic GraphQL type given all the information about the kotlin type. @@ -61,7 +65,19 @@ private fun objectFromReflection(generator: SchemaGenerator, type: KType, typeIn return generator.cache.buildIfNotUnderConstruction(kClass, typeInfo) { val graphQLType = getGraphQLType(generator, kClass, type, typeInfo) - generator.config.hooks.willAddGraphQLTypeToSchema(type, graphQLType) + + /* + * For a field using the meta union annotation, the `type` is `Any`, but we need to pass the annotation with the meta union annotation as the type + * since that is really the type generated from reflection and has any potential directives on it needed by the hook + */ + val metaUnion = typeInfo.fieldAnnotations.firstOrNull { it.getMetaUnionAnnotation() != null } + val resolvedType = if (kClass.isInstance(Any::class) && metaUnion != null) { + metaUnion.annotationClass.createType() + } else { + type + } + + generator.config.hooks.willAddGraphQLTypeToSchema(resolvedType, graphQLType) } } @@ -79,7 +95,12 @@ private fun getGraphQLType( return when { kClass.isEnum() -> @Suppress("UNCHECKED_CAST") (generateEnum(generator, kClass as KClass>)) kClass.isListType() -> generateList(generator, type, typeInfo) - kClass.isUnion(typeInfo.fieldAnnotations) -> generateUnion(generator, kClass, typeInfo.fieldAnnotations.getUnionAnnotation()) + kClass.isUnion(typeInfo.fieldAnnotations) -> generateUnion( + generator, + kClass, + typeInfo.fieldAnnotations.getUnionAnnotation(), + if (kClass.isAnnotation()) kClass else typeInfo.fieldAnnotations.getCustomUnionClassWithMetaUnionAnnotation() + ) kClass.isInterface() -> generateInterface(generator, kClass) typeInfo.inputType -> generateInputObject(generator, kClass) else -> generateObject(generator, kClass) diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateUnion.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateUnion.kt index 5d648ad6eb..fd57a65913 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateUnion.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateUnion.kt @@ -32,15 +32,15 @@ import graphql.schema.GraphQLUnionType import kotlin.reflect.KClass import kotlin.reflect.full.createType -internal fun generateUnion(generator: SchemaGenerator, kClass: KClass<*>, unionAnnotation: GraphQLUnion? = null): GraphQLUnionType { +internal fun generateUnion(generator: SchemaGenerator, kClass: KClass<*>, unionAnnotation: GraphQLUnion? = null, customUnionAnnotationClass: KClass<*>? = null): GraphQLUnionType { return if (unionAnnotation != null) { - generateUnionFromAnnotation(generator, unionAnnotation, kClass) + generateUnionFromAnnotation(generator, unionAnnotation, kClass, customUnionAnnotationClass) } else { generateUnionFromKClass(generator, kClass) } } -private fun generateUnionFromAnnotation(generator: SchemaGenerator, unionAnnotation: GraphQLUnion, kClass: KClass<*>): GraphQLUnionType { +private fun generateUnionFromAnnotation(generator: SchemaGenerator, unionAnnotation: GraphQLUnion, kClass: KClass<*>, customUnionAnnotationClass: KClass<*>?): GraphQLUnionType { val unionName = unionAnnotation.name validateGraphQLName(unionName, kClass) @@ -48,6 +48,12 @@ private fun generateUnionFromAnnotation(generator: SchemaGenerator, unionAnnotat builder.name(unionName) builder.description(unionAnnotation.description) + customUnionAnnotationClass?.let { + generateDirectives(generator, customUnionAnnotationClass, DirectiveLocation.UNION).forEach { + builder.withDirective(it) + } + } + val possibleTypes = unionAnnotation.possibleTypes.toList() return createUnion(unionName, generator, builder, possibleTypes) diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/hooks/SchemaGeneratorHooksTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/hooks/SchemaGeneratorHooksTest.kt index c4b06ed49e..69e29bbd9b 100644 --- a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/hooks/SchemaGeneratorHooksTest.kt +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/hooks/SchemaGeneratorHooksTest.kt @@ -20,11 +20,13 @@ import com.expediagroup.graphql.generator.SchemaGenerator import com.expediagroup.graphql.generator.SchemaGeneratorConfig import com.expediagroup.graphql.generator.TopLevelObject import com.expediagroup.graphql.generator.annotations.GraphQLIgnore +import com.expediagroup.graphql.generator.annotations.GraphQLUnion import com.expediagroup.graphql.generator.exceptions.EmptyInputObjectTypeException import com.expediagroup.graphql.generator.exceptions.EmptyInterfaceTypeException import com.expediagroup.graphql.generator.exceptions.EmptyObjectTypeException import com.expediagroup.graphql.generator.extensions.deepName import com.expediagroup.graphql.generator.getTestSchemaConfigWithHooks +import com.expediagroup.graphql.generator.internal.extensions.getKClass import com.expediagroup.graphql.generator.internal.extensions.getSimpleName import com.expediagroup.graphql.generator.test.utils.graphqlUUIDType import com.expediagroup.graphql.generator.testSchemaConfig @@ -36,6 +38,7 @@ import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLScalarType import graphql.schema.GraphQLSchema import graphql.schema.GraphQLType +import graphql.schema.GraphQLUnionType import graphql.schema.validation.InvalidSchemaException import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.reactive.asPublisher @@ -221,19 +224,25 @@ class SchemaGeneratorHooksTest { override fun willAddGraphQLTypeToSchema(type: KType, generatedType: GraphQLType): GraphQLType { hookCalled = true return when { - generatedType is GraphQLObjectType && generatedType.name == "SomeData" -> GraphQLObjectType.newObject(generatedType).description("My custom description").build() - generatedType is GraphQLInterfaceType && generatedType.name == "RandomData" -> + generatedType is GraphQLObjectType && generatedType.name == "SomeData" && type.getKClass() == SomeData::class -> + GraphQLObjectType.newObject(generatedType).description("My custom description").build() + generatedType is GraphQLInterfaceType && generatedType.name == "RandomData" && type.getKClass() == RandomData::class -> GraphQLInterfaceType.newInterface(generatedType).description("My custom interface description").build() + generatedType is GraphQLUnionType && generatedType.name == "MyMetaUnion" && type.getKClass() == MyMetaUnion::class -> + GraphQLUnionType.newUnionType(generatedType).description("My meta union description").build() + generatedType is GraphQLUnionType && generatedType.name == "MyAdditionalMetaUnion" && type.getKClass() == MyAdditionalMetaUnion::class -> + GraphQLUnionType.newUnionType(generatedType).description("My additional meta union description").build() else -> generatedType } } } val hooks = MockSchemaGeneratorHooks() - val schema = toSchema( - queries = listOf(TopLevelObject(TestQuery())), - config = getTestSchemaConfigWithHooks(hooks) - ) + val generator = SchemaGenerator(getTestSchemaConfigWithHooks(hooks)) + val schema = generator.use { + it.generateSchema(queries = listOf(TopLevelObject(TestQuery())), additionalTypes = setOf(MyAdditionalMetaUnion::class.createType())) + } + assertTrue(hooks.hookCalled) val type = schema.getObjectType("SomeData") @@ -243,6 +252,14 @@ class SchemaGeneratorHooksTest { val interfaceType = schema.getType("RandomData") as? GraphQLInterfaceType assertNotNull(interfaceType) assertEquals(expected = "My custom interface description", actual = interfaceType.description) + + val metaUnionType = schema.getType("MyMetaUnion") as? GraphQLUnionType + assertNotNull(metaUnionType) + assertEquals(expected = "My meta union description", actual = metaUnionType.description) + + val additionalMetaUnionType = schema.getType("MyAdditionalMetaUnion") as? GraphQLUnionType + assertNotNull(additionalMetaUnionType) + assertEquals(expected = "My additional meta union description", actual = additionalMetaUnionType.description) } @Test @@ -346,8 +363,16 @@ class SchemaGeneratorHooksTest { class TestQuery { fun query(): SomeData = SomeData("someData", 0) + @MyMetaUnion + fun unionQuery(): Any = SomeData("someData", 0) } + @GraphQLUnion(name = "MyMetaUnion", possibleTypes = [SomeData::class]) + annotation class MyMetaUnion + + @GraphQLUnion(name = "MyAdditionalMetaUnion", possibleTypes = [SomeData::class]) + annotation class MyAdditionalMetaUnion + class TestSubscription { fun subscription(): Publisher = flowOf(SomeData("someData", 0)).asPublisher() } diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/extensions/AnnotationExtensionsTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/extensions/AnnotationExtensionsTest.kt index 258e309fb8..e9547e8bfb 100644 --- a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/extensions/AnnotationExtensionsTest.kt +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/extensions/AnnotationExtensionsTest.kt @@ -19,11 +19,13 @@ package com.expediagroup.graphql.generator.internal.extensions import com.expediagroup.graphql.generator.annotations.GraphQLDescription import com.expediagroup.graphql.generator.annotations.GraphQLIgnore import com.expediagroup.graphql.generator.annotations.GraphQLName +import com.expediagroup.graphql.generator.annotations.GraphQLUnion import org.junit.jupiter.api.Test import kotlin.reflect.KClass import kotlin.reflect.full.declaredMemberProperties import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -38,11 +40,20 @@ class AnnotationExtensionsTest { @property:Deprecated("property deprecated") @property:GraphQLDescription("property description") @property:GraphQLName("newName") - val id: String + val id: String, + + @GraphQLUnion(name = "CustomUnion", possibleTypes = [NoAnnotations::class]) + val union: Any, + + @property:MetaUnion + val metaUnion: Any ) private data class NoAnnotations(val id: String) + @GraphQLUnion(name = "MetaUnion", possibleTypes = [NoAnnotations::class]) + annotation class MetaUnion + @Test fun `verify @GraphQLName on classes`() { @Suppress("DEPRECATION") @@ -85,5 +96,25 @@ class AnnotationExtensionsTest { assertFalse(NoAnnotations::class.isGraphQLIgnored()) } + @Test + fun `verify @GraphQLUnion`() { + @Suppress("DEPRECATION") + assertNotNull(WithAnnotations::class.findMemberProperty("union")?.annotations?.getUnionAnnotation()) + @Suppress("DEPRECATION") + assertNull(WithAnnotations::class.findMemberProperty("union")?.annotations?.getCustomUnionClassWithMetaUnionAnnotation()) + @Suppress("DEPRECATION") + assertNotNull(WithAnnotations::class.findMemberProperty("metaUnion")?.annotations?.getUnionAnnotation()) + @Suppress("DEPRECATION") + assertNotNull(WithAnnotations::class.findMemberProperty("metaUnion")?.annotations?.getCustomUnionClassWithMetaUnionAnnotation()) + @Suppress("DEPRECATION") + assertNull(WithAnnotations::class.findMemberProperty("id")?.annotations?.getUnionAnnotation()) + @Suppress("DEPRECATION") + assertNull(WithAnnotations::class.findMemberProperty("id")?.annotations?.getCustomUnionClassWithMetaUnionAnnotation()) + @Suppress("DEPRECATION") + assertNotNull(WithAnnotations::class.findMemberProperty("metaUnion")?.annotations?.firstOrNull { it is MetaUnion }?.getMetaUnionAnnotation()) + @Suppress("DEPRECATION") + assertNull(WithAnnotations::class.findMemberProperty("union")?.annotations?.firstOrNull { it is GraphQLUnion }?.getMetaUnionAnnotation()) + } + private fun KClass<*>.findMemberProperty(name: String) = this.declaredMemberProperties.find { it.name == name } } diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/extensions/KClassExtensionsTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/extensions/KClassExtensionsTest.kt index 06b35ac743..5678407cd2 100644 --- a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/extensions/KClassExtensionsTest.kt +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/extensions/KClassExtensionsTest.kt @@ -157,8 +157,17 @@ open class KClassExtensionsTest { @GraphQLUnion(name = "InvalidUnion", possibleTypes = [One::class, Two::class]) fun invalidCustomUnion(): Int = 1 + + @MetaUnion + fun customMetaUnion(): Any = One("1") + + @MetaUnion + fun invalidCustomMetaUnion(): Int = 1 } + @GraphQLUnion(name = "MetaUnion", possibleTypes = [One::class, Two::class]) + annotation class MetaUnion + private class FilterHooks : SchemaGeneratorHooks { override fun isValidProperty(kClass: KClass<*>, property: KProperty<*>) = property.name.contains("filteredProperty").not() @@ -285,11 +294,23 @@ open class KClassExtensionsTest { assertTrue(TestUnion::class.isUnion()) val customAnnotationUnion = TestQuery::customUnion assertTrue(customAnnotationUnion.returnType.getKClass().isUnion(customAnnotationUnion.annotations)) + val metaAnnotationUnion = TestQuery::customMetaUnion + assertTrue(metaAnnotationUnion.returnType.getKClass().isUnion(metaAnnotationUnion.annotations)) + val metaUnionAnnotationClass = MetaUnion::class + assertTrue(metaUnionAnnotationClass.isUnion(metaAnnotationUnion.annotations)) assertFalse(InvalidPropertyUnionInterface::class.isUnion()) assertFalse(InvalidFunctionUnionInterface::class.isUnion()) assertFalse(Pet::class.isUnion()) val invalidAnnotationUnion = TestQuery::invalidCustomUnion assertFalse(invalidAnnotationUnion.returnType.getKClass().isUnion(invalidAnnotationUnion.annotations)) + val invalidMetaAnnotationUnion = TestQuery::invalidCustomMetaUnion + assertFalse(invalidMetaAnnotationUnion.returnType.getKClass().isUnion(invalidMetaAnnotationUnion.annotations)) + } + + @Test + fun `test isAnnotation extension`() { + assertTrue(MetaUnion::class.isAnnotation()) + assertFalse(TestUnion::class.isAnnotation()) } @Test diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/state/TypesCacheTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/state/TypesCacheTest.kt index 88577e8e4f..8d6deccd1f 100644 --- a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/state/TypesCacheTest.kt +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/state/TypesCacheTest.kt @@ -24,6 +24,7 @@ import graphql.schema.GraphQLNamedType import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Test +import kotlin.reflect.full.createType import kotlin.reflect.full.findParameterByName import kotlin.reflect.full.starProjectedType import kotlin.test.assertEquals @@ -49,6 +50,10 @@ class TypesCacheTest { every { name } returns "CustomUnion" } + private val metaUnionGraphQLType: GraphQLNamedType = mockk { + every { name } returns "MetaUnion" + } + class MyClass { fun listFun(list: List) = list.joinToString(separator = ",") { it } @@ -61,10 +66,19 @@ class TypesCacheTest { @GraphQLUnion(name = "CustomUnion", possibleTypes = [MyType::class, Int::class]) fun customUnion(): Any = MyType(1) + @MetaUnion + fun metaUnion(): Any = MyType(1) + @GraphQLUnion(name = "InvalidUnion", possibleTypes = [MyType::class, Int::class]) fun invalidUnion(): String = "foobar" + + @MetaUnion + fun invalidMetaUnion(): String = "foobar" } + @GraphQLUnion(name = "MetaUnion", possibleTypes = [MyType::class, Int::class]) + annotation class MetaUnion + @Test fun `basic get and put with non input type`() { val cache = TypesCache(listOf("com.expediagroup.graphql.generator")) @@ -184,6 +198,37 @@ class TypesCacheTest { } } + @Test + fun `meta unions are cached by special name`() { + val cache = TypesCache(listOf("com.expediagroup.graphql.generator")) + val type = MyClass::metaUnion.returnType + val annotations = MyClass::metaUnion.annotations + val typeInfo = GraphQLKTypeMetadata(inputType = false, fieldAnnotations = annotations) + + val cacheKey = TypesCacheKey(type = type, typeInfo.inputType, name = "MetaUnion[MyType,Int]") + val cacheValue = KGraphQLType(type.getKClass(), metaUnionGraphQLType) + + assertNull(cache.get(cacheKey)) + assertNull(cache.get(type = type, typeInfo)) + assertNotNull(cache.put(cacheKey, cacheValue)) + assertNotNull(cache.get(type = type, typeInfo)) + } + + @Test + fun `invalid meta unions throw an exception`() { + val cache = TypesCache(listOf("com.expediagroup.graphql.generator")) + val type = MyClass::invalidMetaUnion.returnType + val annotations = MyClass::invalidMetaUnion.annotations + val typeInfo = GraphQLKTypeMetadata(fieldAnnotations = annotations) + + val cacheKey = TypesCacheKey(type = type, inputType = typeInfo.inputType, name = "InvalidMetaUnion[MyType,Int]") + + assertNull(cache.get(cacheKey)) + assertFailsWith(InvalidCustomUnionException::class) { + cache.get(type = type, typeInfo) + } + } + @Test fun `verify doesNotContainGraphQLType()`() { val cache = TypesCache(listOf("com.expediagroup.graphql.generator")) @@ -231,4 +276,20 @@ class TypesCacheTest { assertNotNull(cacheHit) assertEquals(expected = cacheValue.graphQLType, actual = cacheHit) } + + @Test + fun `buildIfNotUnderConstruction puts custom union into the cache`() { + val cache = TypesCache(listOf("com.expediagroup.graphql.generator")) + val annotations = MyClass::customUnion.annotations + val typeInfo = GraphQLKTypeMetadata(inputType = false, fieldAnnotations = annotations) + + val cacheKey = TypesCacheKey(type = Any::class.createType(), typeInfo.inputType, name = "CustomUnion[MyType,Int]") + + cache.buildIfNotUnderConstruction(MyClass::customUnion.returnType.getKClass(), typeInfo) { + customUnionGraphQLType + } + + val cacheValue = cache.get(cacheKey) + assertNotNull(cacheValue) + } } diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateUnionTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateUnionTest.kt index 33096d70ad..02a7127eff 100644 --- a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateUnionTest.kt +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateUnionTest.kt @@ -21,6 +21,7 @@ import com.expediagroup.graphql.generator.annotations.GraphQLName import com.expediagroup.graphql.generator.annotations.GraphQLUnion import com.expediagroup.graphql.generator.exceptions.InvalidGraphQLNameException import com.expediagroup.graphql.generator.exceptions.InvalidUnionException +import com.expediagroup.graphql.generator.internal.extensions.getUnionAnnotation import com.expediagroup.graphql.generator.test.utils.SimpleDirective import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLUnionType @@ -60,6 +61,10 @@ class GenerateUnionTest : TypeTestHelper() { fun getUnionB(): NestedUnionB = NestedClass() } + @SimpleDirective + @GraphQLUnion(name = "MetaUnion", possibleTypes = [StrawBerryCake::class], description = "meta union") + annotation class MetaUnion + class AnnotationUnion { @GraphQLUnion(name = "Foo", possibleTypes = [StrawBerryCakeCustomName::class, StrawBerryCake::class], description = "A custom cake") fun cake(withName: Boolean): Any = if (withName) StrawBerryCakeCustomName() else StrawBerryCake() @@ -69,6 +74,9 @@ class GenerateUnionTest : TypeTestHelper() { @GraphQLUnion(name = "Invalid\$Name", possibleTypes = [StrawBerryCake::class]) fun invalidUnion(): Any = StrawBerryCake() + + @MetaUnion + fun metaUnion(): Any = StrawBerryCake() } interface `Invalid$UnionName` @@ -131,6 +139,20 @@ class GenerateUnionTest : TypeTestHelper() { assertEquals("A custom cake", result.description) } + @Test + fun `custom union with meta union annotation and directives can be used`() { + val annotation = AnnotationUnion::metaUnion.annotations.first() as MetaUnion + val result = generateUnion(generator, Any::class, annotation.annotationClass.annotations.getUnionAnnotation(), annotation.annotationClass) + + assertEquals("MetaUnion", result.name) + assertEquals(1, result.types.size) + assertEquals("StrawBerryCake", result.types[0].name) + assertEquals("meta union", result.description) + assertNotNull(result.directives) + assertEquals(1, result.directives.size) + assertEquals("simpleDirective", result.directives.first().name) + } + @Test fun `custom union annotation throws if possible types is empty`() { val annotation = AnnotationUnion::emptyUnion.annotations.first() as GraphQLUnion diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/test/integration/CustomUnionAnnotationTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/test/integration/CustomUnionAnnotationTest.kt index 6482ff0fe3..516dadf2d9 100644 --- a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/test/integration/CustomUnionAnnotationTest.kt +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/test/integration/CustomUnionAnnotationTest.kt @@ -16,15 +16,23 @@ package com.expediagroup.graphql.generator.test.integration +import com.expediagroup.graphql.generator.SchemaGenerator +import com.expediagroup.graphql.generator.SchemaGeneratorConfig import com.expediagroup.graphql.generator.TopLevelObject +import com.expediagroup.graphql.generator.annotations.GraphQLDirective import com.expediagroup.graphql.generator.annotations.GraphQLUnion import com.expediagroup.graphql.generator.extensions.deepName +import com.expediagroup.graphql.generator.extensions.unwrapType import com.expediagroup.graphql.generator.testSchemaConfig import com.expediagroup.graphql.generator.toSchema +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLUnionType import org.junit.jupiter.api.Test +import kotlin.reflect.KClass import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertNotNull +import kotlin.test.assertSame class CustomUnionAnnotationTest { @@ -39,10 +47,22 @@ class CustomUnionAnnotationTest { assertNotNull(schema.getType("Even")) assertNotNull(schema.getType("Odd")) assertNotNull(schema.getType("Number")) + assertNotNull(schema.getType("Prime")) assertEquals("Even!", schema.queryType.getFieldDefinition("even").type.deepName) assertEquals("Odd!", schema.queryType.getFieldDefinition("odd").type.deepName) assertEquals("Number!", schema.queryType.getFieldDefinition("number").type.deepName) + assertEquals("Number", schema.queryType.getFieldDefinition("nullableNumber").type.deepName) assertEquals("[Number!]!", schema.queryType.getFieldDefinition("listNumbers").type.deepName) + assertEquals("[Number]", schema.queryType.getFieldDefinition("nullableListNumbers").type.deepName) + assertEquals("Prime!", schema.queryType.getFieldDefinition("prime").type.deepName) + assertEquals("Prime", schema.queryType.getFieldDefinition("nullablePrime").type.deepName) + assertEquals("[Prime!]!", schema.queryType.getFieldDefinition("listPrimes").type.deepName) + assertEquals("[Prime]", schema.queryType.getFieldDefinition("nullableListPrimes").type.deepName) + + val unionWithDirective = schema.getType("Prime") as GraphQLUnionType + assertNotNull(unionWithDirective.directives) + assertEquals(1, unionWithDirective.directives.size) + assertEquals("TestDirective", unionWithDirective.directives[0].name) } @Test @@ -55,10 +75,28 @@ class CustomUnionAnnotationTest { @Test fun `verify exception is thrown when custom union return type is not Any`() { assertFails { - toSchema(testSchemaConfig, listOf(TopLevelObject(InvalidReturnType()))) + toSchema(testSchemaConfig, listOf(TopLevelObject(InvalidReturnTypeNumber()))) + } + assertFails { + toSchema(testSchemaConfig, listOf(TopLevelObject(InvalidReturnTypePrime()))) } } + @Test + fun `verify Meta Union Annotation when adding as additional type`() { + val generator = CustomSchemaGenerator(testSchemaConfig) + generator.addTypes(MyAnnotation::class) + val types = generator.generateCustomAdditionalTypes() + + assertEquals(2, types.size) + val metaUnion = types.find { it.deepName == "Prime" } as GraphQLUnionType + assertNotNull(metaUnion) + assertNotNull(metaUnion.directives) + assertEquals(1, metaUnion.directives.size) + assertEquals("TestDirective", metaUnion.directives[0].name) + assertSame(metaUnion, (types.find { it.deepName != "Prime" } as GraphQLObjectType).getField("union").type.unwrapType()) + } + class One(val value: String) class Two(val value: String) class Three(val value: String) @@ -74,8 +112,26 @@ class CustomUnionAnnotationTest { @GraphQLUnion(name = "Number", possibleTypes = [One::class, Two::class, Three::class, Four::class]) fun number(): Any = One("1") + @GraphQLUnion(name = "Number", possibleTypes = [One::class, Two::class, Three::class, Four::class]) + fun nullableNumber(isNull: Boolean): Any? = if (isNull) null else One("1") + @GraphQLUnion(name = "Number", possibleTypes = [One::class, Two::class, Three::class, Four::class]) fun listNumbers(): List = listOf(One("1"), Two("2")) + + @GraphQLUnion(name = "Number", possibleTypes = [One::class, Two::class, Three::class, Four::class]) + fun nullableListNumbers(): List? = null + + @PrimeUnion + fun prime(first: Boolean): Any = if (first) Two("2") else Three("3") + + @PrimeUnion + fun nullablePrime(isNull: Boolean): Any? = if (isNull) null else Two("2") + + @PrimeUnion + fun listPrimes(): List = listOf(Two("2"), Three("3")) + + @PrimeUnion + fun nullableListPrimes(): List? = null } /** @@ -94,10 +150,41 @@ class CustomUnionAnnotationTest { * While it is valid to compile, library users should return Any for the custom * union annotation */ - class InvalidReturnType { + class InvalidReturnTypeNumber { @GraphQLUnion(name = "Number", possibleTypes = [One::class, Two::class]) fun number1(): One = One("one") fun number2(): One = One("two") } + + /** + * While it is valid to compile, library users should return Any for the annotation + * with the meta union annotation + */ + class InvalidReturnTypePrime { + @PrimeUnion + fun prime(): Two = Two("two") + } + + @GraphQLDirective(name = "TestDirective") + annotation class TestDirective + + annotation class MyAnnotation + + @TestDirective + @MyAnnotation + @GraphQLUnion(name = "Prime", possibleTypes = [Two::class, Three::class]) + annotation class PrimeUnion + + @MyAnnotation + data class MyType( + @PrimeUnion + val union: Any + ) + + class CustomSchemaGenerator(config: SchemaGeneratorConfig) : SchemaGenerator(config) { + internal fun addTypes(annotation: KClass<*>) = addAdditionalTypesWithAnnotation(annotation) + + internal fun generateCustomAdditionalTypes() = generateAdditionalTypes() + } } diff --git a/website/docs/schema-generator/writing-schemas/unions.md b/website/docs/schema-generator/writing-schemas/unions.md index d832acc1d5..42e05b02f8 100644 --- a/website/docs/schema-generator/writing-schemas/unions.md +++ b/website/docs/schema-generator/writing-schemas/unions.md @@ -103,11 +103,37 @@ class Query { } ``` +If directives are needed, this can also be used as a meta-annotation + +### Example Usage +```kotlin +// Defined in some other library +class SharedModel(val foo: String) + +// Our code +class ServiceModel(val bar: String) + + +@SomeDirective +@GraphQLUnion( + name = "CustomUnion", + possibleTypes = [SharedModel::class, ServiceModel::class], + description = "Return one or the other model" +) +annotation class CustomUnion + +class Query { + @CustomUnion + fun getModel(): Any = ServiceModel("abc") +} +``` + The annotation requires the `name` of the new union to create and the `possibleTypes` that this union can return. However since we can not enforce the type checks anymore, you must use `Any` as the return type. ### Limitations -Since this union is defined with an added annotation it is not currently possible to add directives directly to this union definition. +Even when using it as a meta-annotation, it is not always possible to add directives to the union definition +if the directive annotation cannot apply to an annotation class. You will have to modify the type with [schema generator hooks](../customizing-schemas/generator-config.md). This limitations can be met with the [@GraphQLType](../customizing-schemas/custom-type-reference) annotation.