From 92bc66dfdeba6a672a71df3d936886ec21f5d4aa Mon Sep 17 00:00:00 2001 From: Samuel Vazquez Date: Tue, 16 Jun 2026 13:08:21 -0600 Subject: [PATCH 1/3] feat: oneOf directive --- .../server/spring/query/OneOfQuery.kt | 166 ++++++++ .../server/spring/query/OneOfQueryIT.kt | 148 +++++++ .../generator/annotations/GraphQLOneOf.kt | 24 ++ .../annotations/GraphQLOneOfField.kt | 95 +++++ .../exceptions/OneOfInputObjectExceptions.kt | 39 ++ .../execution/convertArgumentValue.kt | 39 +- .../internal/extensions/kClassExtensions.kt | 7 + .../internal/types/generateArgument.kt | 9 +- .../internal/types/generateGraphQLType.kt | 6 +- .../types/generateOneOfInputObject.kt | 80 ++++ .../types/generateOneOfInputProperty.kt | 52 +++ .../types/utils/validateOneOfInputObject.kt | 63 +++ .../ConvertArgumentValueOneOfTest.kt | 249 ++++++++++++ .../execution/ConvertArgumentValueTest.kt | 2 +- .../types/GenerateArgumentOneOfTest.kt | 368 ++++++++++++++++++ .../fixtures/GraphQLArgumentExtensions.kt | 25 ++ 16 files changed, 1362 insertions(+), 10 deletions(-) create mode 100644 examples/server/spring-server/src/main/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQuery.kt create mode 100644 examples/server/spring-server/src/test/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQueryIT.kt create mode 100644 generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLOneOf.kt create mode 100644 generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLOneOfField.kt create mode 100644 generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/exceptions/OneOfInputObjectExceptions.kt create mode 100644 generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputObject.kt create mode 100644 generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputProperty.kt create mode 100644 generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/utils/validateOneOfInputObject.kt create mode 100644 generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/execution/ConvertArgumentValueOneOfTest.kt create mode 100644 generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateArgumentOneOfTest.kt create mode 100644 generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/fixtures/GraphQLArgumentExtensions.kt diff --git a/examples/server/spring-server/src/main/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQuery.kt b/examples/server/spring-server/src/main/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQuery.kt new file mode 100644 index 0000000000..f510cba2c4 --- /dev/null +++ b/examples/server/spring-server/src/main/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQuery.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.examples.server.spring.query + +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import com.expediagroup.graphql.generator.annotations.GraphQLOneOf +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfField +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfFieldType +import com.expediagroup.graphql.generator.scalars.ID +import com.expediagroup.graphql.server.operations.Query +import org.springframework.stereotype.Component + +@Component +class OneOfQuery : Query { + + @GraphQLDescription("Describes a content block supplied as a @oneOf input.") + fun describeContentBlock(input: ContentBlockInput): String = when (input) { + is ContentBlockInput.Paragraph -> "paragraph: ${input.text}" + is ContentBlockInput.BlockQuote -> buildString { + append("blockquote: ${input.value}") + input.attribution?.let { append(" ($it)") } + input.attributionUrl?.let { append(" <$it>") } + } + is ContentBlockInput.Image -> "image: ${input.altText} at ${input.url}" + } + + @GraphQLDescription("Describes each content block supplied as a list of @oneOf inputs.") + fun describeContentBlocks(input: List): List = + input.map { contentBlock -> describeContentBlock(contentBlock) } + + @GraphQLDescription("Describes how a user would be looked up from a scalar or object @oneOf input.") + fun findUserBy(input: UserLookupInput): String = when (input) { + is UserLookupInput.ById -> "user id=${input.id.value}" + is UserLookupInput.ByEmail -> "user email=${input.email}" + is UserLookupInput.ByCriteria -> "user criteria name=${input.name} address=${input.address}" + } + + @GraphQLDescription("Describes a nested @oneOf lookup for either a user or an organization.") + fun resolveEntity(input: EntityLookupInput): String = when (input) { + is EntityLookupInput.User -> "user ${describeUserLookup(input.lookup)}" + is EntityLookupInput.Organization -> "organization ${describeOrganizationLookup(input.lookup)}" + } + + private fun describeUserLookup(input: UserLookupInput): String = when (input) { + is UserLookupInput.ById -> "id=${input.id.value}" + is UserLookupInput.ByEmail -> "email=${input.email}" + is UserLookupInput.ByCriteria -> "criteria name=${input.name} address=${input.address}" + } + + private fun describeOrganizationLookup(input: OrganizationLookupInput): String = when (input) { + is OrganizationLookupInput.ById -> "id=${input.id.value}" + is OrganizationLookupInput.BySlug -> "slug=${input.slug}" + } +} + +@GraphQLDescription("A content block input where exactly one block shape must be supplied.") +@GraphQLOneOf +sealed interface ContentBlockInput { + + @GraphQLDescription("Paragraph content supplied as a wrapped @oneOf object field.") + @GraphQLOneOfField("paragraph") + data class Paragraph( + @param:GraphQLDescription("The paragraph text.") + val text: String + ) : ContentBlockInput + + @GraphQLDescription("Quoted content supplied as a wrapped @oneOf object field.") + @GraphQLOneOfField("blockquote") + data class BlockQuote( + @param:GraphQLDescription("The quoted text.") + val value: String, + @param:GraphQLDescription("The optional source of the quote.") + val attribution: String?, + @param:GraphQLDescription("The optional URL for the quote source.") + val attributionUrl: String? + ) : ContentBlockInput + + @GraphQLDescription("Image content supplied as a wrapped @oneOf object field.") + @GraphQLOneOfField("image") + data class Image( + @param:GraphQLDescription("The image URL.") + val url: String, + @param:GraphQLDescription("The image alt text.") + val altText: String + ) : ContentBlockInput +} + +@GraphQLDescription("A user lookup input where exactly one lookup strategy must be supplied.") +@GraphQLOneOf +sealed interface UserLookupInput { + + @GraphQLDescription("Lookup a user by ID using an unwrapped @oneOf scalar field.") + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class ById( + @param:GraphQLDescription("The user ID.") + val id: ID + ) : UserLookupInput + + @GraphQLDescription("Lookup a user by email using an unwrapped @oneOf scalar field.") + @GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED) + data class ByEmail( + @param:GraphQLDescription("The user's email address.") + val email: String + ) : UserLookupInput + + @GraphQLDescription("Lookup a user by criteria using a wrapped @oneOf object field.") + @GraphQLOneOfField("criteria") + data class ByCriteria( + @param:GraphQLDescription("The optional display name to match.") + val name: String?, + @param:GraphQLDescription("The optional mailing address to match.") + val address: String? + ) : UserLookupInput +} + +@GraphQLDescription("An entity lookup input where exactly one entity type must be supplied.") +@GraphQLOneOf +sealed interface EntityLookupInput { + + @GraphQLDescription("Lookup a user using a nested @oneOf selector.") + @GraphQLOneOfField("user", GraphQLOneOfFieldType.UNWRAPPED) + data class User( + @param:GraphQLDescription("The nested user lookup selector.") + val lookup: UserLookupInput + ) : EntityLookupInput + + @GraphQLDescription("Lookup an organization using a nested @oneOf selector.") + @GraphQLOneOfField("organization", GraphQLOneOfFieldType.UNWRAPPED) + data class Organization( + @param:GraphQLDescription("The nested organization lookup selector.") + val lookup: OrganizationLookupInput + ) : EntityLookupInput +} + +@GraphQLDescription("An organization lookup input where exactly one lookup strategy must be supplied.") +@GraphQLOneOf +sealed interface OrganizationLookupInput { + + @GraphQLDescription("Lookup an organization by ID using an unwrapped @oneOf scalar field.") + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class ById( + @param:GraphQLDescription("The organization ID.") + val id: ID + ) : OrganizationLookupInput + + @GraphQLDescription("Lookup an organization by slug using an unwrapped @oneOf scalar field.") + @GraphQLOneOfField("slug", GraphQLOneOfFieldType.UNWRAPPED) + data class BySlug( + @param:GraphQLDescription("The organization slug.") + val slug: String + ) : OrganizationLookupInput +} diff --git a/examples/server/spring-server/src/test/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQueryIT.kt b/examples/server/spring-server/src/test/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQueryIT.kt new file mode 100644 index 0000000000..63ad7b0f59 --- /dev/null +++ b/examples/server/spring-server/src/test/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQueryIT.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.examples.server.spring.query + +import com.expediagroup.graphql.examples.server.spring.DATA_JSON_PATH +import com.expediagroup.graphql.examples.server.spring.GRAPHQL_ENDPOINT +import com.expediagroup.graphql.examples.server.spring.GRAPHQL_MEDIA_TYPE +import com.expediagroup.graphql.examples.server.spring.verifyData +import com.expediagroup.graphql.examples.server.spring.verifyError +import com.expediagroup.graphql.examples.server.spring.verifyOnlyDataExists +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationContext +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.test.web.reactive.server.WebTestClient + +@SpringBootTest +@TestInstance(PER_CLASS) +class OneOfQueryIT { + + private lateinit var testClient: WebTestClient + + @BeforeEach + fun setup(@Autowired context: ApplicationContext) { + testClient = WebTestClient.bindToApplicationContext(context).build() + } + + @Test + fun `verify describeContentBlock query with wrapped object input`() { + val query = "describeContentBlock" + val expectedData = "paragraph: Hello @oneOf" + + testClient.post() + .uri(GRAPHQL_ENDPOINT) + .accept(APPLICATION_JSON) + .contentType(GRAPHQL_MEDIA_TYPE) + .bodyValue("query { $query(input: { paragraph: { text: \"Hello @oneOf\" } }) }") + .exchange() + .verifyData(query, expectedData) + } + + @Test + fun `verify describeContentBlocks query with list of oneOf inputs`() { + val query = "describeContentBlocks" + + testClient.post() + .uri(GRAPHQL_ENDPOINT) + .accept(APPLICATION_JSON) + .contentType(GRAPHQL_MEDIA_TYPE) + .bodyValue( + """ + query { + $query(input: [ + { paragraph: { text: "Hello" } }, + { image: { url: "https://example.com/logo.png", altText: "Logo" } } + ]) + } + """.trimIndent() + ) + .exchange() + .verifyOnlyDataExists(query) + .jsonPath("$DATA_JSON_PATH.$query[0]").isEqualTo("paragraph: Hello") + .jsonPath("$DATA_JSON_PATH.$query[1]").isEqualTo("image: Logo at https://example.com/logo.png") + } + + @Test + fun `verify findUserBy query with unwrapped scalar input`() { + val query = "findUserBy" + val expectedData = "user id=user-123" + + testClient.post() + .uri(GRAPHQL_ENDPOINT) + .accept(APPLICATION_JSON) + .contentType(GRAPHQL_MEDIA_TYPE) + .bodyValue("query { $query(input: { id: \"user-123\" }) }") + .exchange() + .verifyData(query, expectedData) + } + + @Test + fun `verify findUserBy query with wrapped object input`() { + val query = "findUserBy" + val expectedData = "user criteria name=Sam address=Seattle" + + testClient.post() + .uri(GRAPHQL_ENDPOINT) + .accept(APPLICATION_JSON) + .contentType(GRAPHQL_MEDIA_TYPE) + .bodyValue("query { $query(input: { criteria: { name: \"Sam\", address: \"Seattle\" } }) }") + .exchange() + .verifyData(query, expectedData) + } + + @Test + fun `verify resolveEntity query with nested oneOf input`() { + val query = "resolveEntity" + val expectedData = "organization slug=expedia" + + testClient.post() + .uri(GRAPHQL_ENDPOINT) + .accept(APPLICATION_JSON) + .contentType(GRAPHQL_MEDIA_TYPE) + .bodyValue("query { $query(input: { organization: { slug: \"expedia\" } }) }") + .exchange() + .verifyData(query, expectedData) + } + + @Test + fun `verify oneOf input rejects multiple fields`() { + val query = "describeContentBlock" + val expectedError = "Exactly one key must be specified for OneOf type 'ContentBlockInput'." + + testClient.post() + .uri(GRAPHQL_ENDPOINT) + .accept(APPLICATION_JSON) + .contentType(GRAPHQL_MEDIA_TYPE) + .bodyValue( + """ + query { + $query(input: { + paragraph: { text: "Hello" }, + image: { url: "https://example.com/logo.png", altText: "Logo" } + }) + } + """.trimIndent() + ) + .exchange() + .verifyError(expectedError) + } +} diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLOneOf.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLOneOf.kt new file mode 100644 index 0000000000..a6fca55a74 --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLOneOf.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.annotations + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +/** + * `@oneOf` inputs allow exactly one non-null field to be supplied + */ +annotation class GraphQLOneOf diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLOneOfField.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLOneOfField.kt new file mode 100644 index 0000000000..3fe05929d4 --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/annotations/GraphQLOneOfField.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.annotations + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +/** + * Specifies GraphQL `@oneOf` field name for a sealed input subtype. + * + * Used when a sealed type marked with [GraphQLOneOf] is generated as a GraphQL + * `@oneOf` input object. The value becomes field name that maps to this subtype + * during schema generation and runtime input conversion. + */ +annotation class GraphQLOneOfField( + val fieldName: String, + val type: GraphQLOneOfFieldType = GraphQLOneOfFieldType.WRAPPED +) + +/** + * Defines how a sealed subtype annotated with [GraphQLOneOfField] is represented + * as a field of the generated GraphQL `@oneOf` inputObject. + */ +enum class GraphQLOneOfFieldType { + /** + * Generate the oneOf field as an input object using the annotated subtype. + * + * Example: + * ``` + * @GraphQLOneOf + * sealed interface UserByInput + * + * @GraphQLOneOfField(name = "criteria", type = GraphQLOneOfFieldType.WRAPPED) + * data class Criteria(val name: String, val address: String) : UserByInput + * ``` + * + * Generates: + * ```graphql + * input UserByInput @oneOf { + * criteria: Criteria + * } + * ``` + */ + WRAPPED, + /** + * Generate the oneOf field using the annotated subtype's single constructor + * property type directly. + * + * The annotated subtype must define exactly one primary constructor property. + * + * Example: + * ``` + * @GraphQLOneOf + * sealed interface UserByInput + * + * @GraphQLOneOfField(name = "email", type = GraphQLOneOfFieldType.UNWRAPPED) + * data class UserByEmail(val email: String) : UserByInput + * ``` + * + * Generates: + * + * ```graphql + * input UserByInput @oneOf { + * email: String + * } + * ``` + * + * If [WRAPPED] is used in this use case the generated SDL would've been: + * + * ```graphql + * input UserByInput @oneOf { + * email: UserByEmailInput + * } + * + * input UserByEmailInput { + * email: String + * } + * ``` + * + */ + UNWRAPPED +} diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/exceptions/OneOfInputObjectExceptions.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/exceptions/OneOfInputObjectExceptions.kt new file mode 100644 index 0000000000..e0da085e8d --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/exceptions/OneOfInputObjectExceptions.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.exceptions + +import kotlin.reflect.KClass + +class InvalidGraphQLOneOfTargetException(kClass: KClass<*>) : GraphQLKotlinException( + "Invalid @GraphQLOneOf input object ${kClass.simpleName} - @GraphQLOneOf can only be used on sealed interfaces." +) + +class NoGraphQLOneOfImplementationsException(kClass: KClass<*>) : GraphQLKotlinException( + "Invalid @GraphQLOneOf input object ${kClass.simpleName} - sealed interface must define one or more implementations." +) + +class MissingOneOfInputFieldAnnotationException(parent: KClass<*>, subType: KClass<*>) : GraphQLKotlinException( + "Invalid @GraphQLOneOf input object ${parent.simpleName} - subtype ${subType.simpleName} must be annotated with @GraphQLOneOfField." +) + +class DuplicateOneOfInputFieldException(parent: KClass<*>, fieldNames: Set) : GraphQLKotlinException( + """Invalid @GraphQLOneOf input object ${parent.simpleName} - duplicated field names: "$fieldNames".""" +) + +class InvalidGraphQLOneOfUnwrappedFieldException(kClass: KClass<*>) : GraphQLKotlinException( + "Invalid @GraphQLOneOfField subtype ${kClass.simpleName} - UNWRAPPED fields must define exactly one primary constructor parameter." +) diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/execution/convertArgumentValue.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/execution/convertArgumentValue.kt index 84c626fdcd..82927d8879 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/execution/convertArgumentValue.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/execution/convertArgumentValue.kt @@ -16,18 +16,23 @@ package com.expediagroup.graphql.generator.execution +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfField +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfFieldType import com.expediagroup.graphql.generator.exceptions.MultipleConstructorsFound import com.expediagroup.graphql.generator.exceptions.PrimaryConstructorNotFound import com.expediagroup.graphql.generator.internal.extensions.getGraphQLName import com.expediagroup.graphql.generator.internal.extensions.getKClass import com.expediagroup.graphql.generator.internal.extensions.getName import com.expediagroup.graphql.generator.internal.extensions.getTypeOfFirstArgument +import com.expediagroup.graphql.generator.internal.extensions.isGraphQLOneOfSealedInput import com.expediagroup.graphql.generator.internal.extensions.isNotOptionalNullable import com.expediagroup.graphql.generator.internal.extensions.isOptionalInputType import com.expediagroup.graphql.generator.internal.extensions.isSubclassOf +import com.expediagroup.graphql.generator.internal.types.utils.getValidOneOfUnwrappedFieldParameter import kotlin.reflect.KClass import kotlin.reflect.KParameter import kotlin.reflect.KType +import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.primaryConstructor /** @@ -73,7 +78,13 @@ private fun convertValue( // If the value is a generic map, parse each entry which may have some values already parsed if (argumentValue is Map<*, *>) { @Suppress("UNCHECKED_CAST") - return mapToKotlinObject(argumentValue as Map, paramType.getKClass()) + val input = argumentValue as Map + val targetClass = paramType.getKClass() + return if (targetClass.isGraphQLOneOfSealedInput()) { + mapToOneOfKotlinObject(input, targetClass) + } else { + mapToKotlinObject(input, targetClass) + } } // If the value is enum we need to find the correct value @@ -95,7 +106,6 @@ private fun convertValue( * the only thing left to parse is object maps into the nested Kotlin classes */ private fun mapToKotlinObject(input: Map, targetClass: KClass): T { - val targetConstructor = targetClass.primaryConstructor ?: run { if (targetClass.constructors.size == 1) { targetClass.constructors.first() @@ -121,6 +131,31 @@ private fun mapToKotlinObject(input: Map, targetClass: KCla return targetConstructor.callBy(constructorArguments) } +/** + * + */ +private fun mapToOneOfKotlinObject(input: Map, targetClass: KClass): T { + // at this stage, graphql-java already did the validation of only one value allowed + // and input fields match the fields of the GraphQLType marked with @OneOf + // https://github.com/graphql-java/graphql-java/blob/master/src/main/java/graphql/execution/ValuesResolverOneOfValidation.java + val (fieldName, value) = input.entries.single() + val (subType, subTypeAnnotation) = targetClass.sealedSubclasses.firstNotNullOf { subClass -> + subClass.findAnnotation() + ?.takeIf { annotation -> annotation.fieldName == fieldName } + ?.let { annotation -> subClass to annotation } + } + + @Suppress("UNCHECKED_CAST") + return when (subTypeAnnotation.type) { + GraphQLOneOfFieldType.WRAPPED -> mapToKotlinObject(value as Map, subType) + GraphQLOneOfFieldType.UNWRAPPED -> { + val parameter = getValidOneOfUnwrappedFieldParameter(subType) + val input = mapOf(parameter.getName() to value) + mapToKotlinObject(input, subType) + } + } +} + private fun mapToEnumValue(paramType: KType, enumValue: String): Enum<*> = paramType.getKClass() .java .enumConstants 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 824dfce4a7..63766b06ed 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 @@ -16,6 +16,7 @@ package com.expediagroup.graphql.generator.internal.extensions +import com.expediagroup.graphql.generator.annotations.GraphQLOneOf import com.expediagroup.graphql.generator.annotations.GraphQLSkipInputSuffix import com.expediagroup.graphql.generator.exceptions.CouldNotGetNameOfKClassException import com.expediagroup.graphql.generator.hooks.SchemaGeneratorHooks @@ -102,3 +103,9 @@ internal fun KClass<*>.getQualifiedName(): String = this.qualifiedName.orEmpty() internal fun KClass<*>.isPublic(): Boolean = this.visibility == KVisibility.PUBLIC internal fun KClass<*>.isNotPublic(): Boolean = this.isPublic().not() + +internal fun KClass<*>.isGraphQLOneOf(): Boolean = + this.findAnnotation() != null + +internal fun KClass<*>.isGraphQLOneOfSealedInput(): Boolean = + this.isGraphQLOneOf() && this.isSealed diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateArgument.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateArgument.kt index 2b4bcc0f9d..c5fc3193fb 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateArgument.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateArgument.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2026 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import com.expediagroup.graphql.generator.internal.extensions.getGraphQLDescript import com.expediagroup.graphql.generator.internal.extensions.getKClass import com.expediagroup.graphql.generator.internal.extensions.getName import com.expediagroup.graphql.generator.internal.extensions.getTypeOfFirstArgument +import com.expediagroup.graphql.generator.internal.extensions.isGraphQLOneOf import com.expediagroup.graphql.generator.internal.extensions.isInterface import com.expediagroup.graphql.generator.internal.extensions.isListType import com.expediagroup.graphql.generator.internal.extensions.isUnion @@ -36,15 +37,11 @@ import kotlin.reflect.KType @Throws(InvalidInputFieldTypeException::class) internal fun generateArgument(generator: SchemaGenerator, parameter: KParameter): GraphQLArgument { - val inputTypeFromHooks = generator.config.hooks.willResolveInputMonad(parameter.type) val unwrappedType = inputTypeFromHooks.unwrapOptionalInputType() - // Validate that the input is not a polymorphic type - // This is not currently supported by the GraphQL spec - // https://github.com/graphql/graphql-spec/blob/master/rfcs/InputUnion.md val unwrappedClass = getUnwrappedClass(unwrappedType) - if (unwrappedClass.isUnion() || unwrappedClass.isInterface()) { + if ((unwrappedClass.isUnion() || unwrappedClass.isInterface()) && !unwrappedClass.isGraphQLOneOf()) { throw InvalidInputFieldTypeException(parameter) } 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 3b75e1340d..37abbf0958 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2026 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import com.expediagroup.graphql.generator.internal.extensions.getMetaUnionAnnota 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.isGraphQLOneOf import com.expediagroup.graphql.generator.internal.extensions.isInterface import com.expediagroup.graphql.generator.internal.extensions.isListType import com.expediagroup.graphql.generator.internal.extensions.isUnion @@ -93,6 +94,9 @@ private fun getGraphQLType( } return when { + typeInfo.inputType && kClass.isGraphQLOneOf() -> { + generateOneOfInputObject(generator, kClass) + } kClass.isEnum() -> @Suppress("UNCHECKED_CAST") (generateEnum(generator, kClass as KClass>)) kClass.isListType(typeInfo.isDirective) -> generateList(generator, type, typeInfo) kClass.isUnion(typeInfo.fieldAnnotations) -> generateUnion( diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputObject.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputObject.kt new file mode 100644 index 0000000000..56a7f84652 --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputObject.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.internal.types + +import com.expediagroup.graphql.generator.SchemaGenerator +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfField +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfFieldType +import com.expediagroup.graphql.generator.annotations.GraphQLValidObjectLocations +import com.expediagroup.graphql.generator.exceptions.MissingOneOfInputFieldAnnotationException +import com.expediagroup.graphql.generator.internal.extensions.getGraphQLDescription +import com.expediagroup.graphql.generator.internal.extensions.getSimpleName +import com.expediagroup.graphql.generator.internal.extensions.safeCast +import com.expediagroup.graphql.generator.internal.types.utils.validateGraphQLName +import com.expediagroup.graphql.generator.internal.types.utils.validateObjectLocation +import com.expediagroup.graphql.generator.internal.types.utils.validateOneOfInputDuplicatedFieldNames +import com.expediagroup.graphql.generator.internal.types.utils.validateOneOfInputObjectSealedInterface +import graphql.Directives +import graphql.introspection.Introspection.DirectiveLocation +import graphql.schema.GraphQLInputObjectType +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation + +internal fun generateOneOfInputObject(generator: SchemaGenerator, kClass: KClass<*>): GraphQLInputObjectType { + validateObjectLocation(kClass, GraphQLValidObjectLocations.Locations.INPUT_OBJECT) + validateOneOfInputObjectSealedInterface(kClass) + + val name = kClass.getSimpleName(isInputClass = true) + validateGraphQLName(name, kClass) + + val builder = GraphQLInputObjectType.newInputObject().apply { + name(name) + description(kClass.getGraphQLDescription()) + withAppliedDirective(Directives.OneOfDirective.toAppliedDirective()) + generateDirectives(generator, kClass, DirectiveLocation.INPUT_OBJECT).forEach { + withAppliedDirective(it) + } + } + + val fields = kClass.sealedSubclasses.map { subClass -> + val graphQLOneOfFieldAnnotation = subClass.findAnnotation() + ?: throw MissingOneOfInputFieldAnnotationException(kClass, subClass) + + validateGraphQLName(graphQLOneOfFieldAnnotation.fieldName, subClass) + + OneOfFieldMetadata( + graphQLOneOfFieldAnnotation.fieldName, + graphQLOneOfFieldAnnotation.type, + subClass + ) + } + + validateOneOfInputDuplicatedFieldNames(kClass, fields.map(OneOfFieldMetadata::fieldName)) + + fields.forEach { metadata -> + val field = generateOneOfInputProperty(generator, metadata) + builder.field(field) + } + + return generator.config.hooks.onRewireGraphQLType(builder.build(), null, generator.codeRegistry).safeCast() +} + +internal data class OneOfFieldMetadata( + val fieldName: String, + val fieldType: GraphQLOneOfFieldType, + val subClass: KClass<*>, +) diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputProperty.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputProperty.kt new file mode 100644 index 0000000000..900fea7b6f --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputProperty.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.internal.types + +import com.expediagroup.graphql.generator.SchemaGenerator +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfFieldType +import com.expediagroup.graphql.generator.internal.extensions.safeCast +import com.expediagroup.graphql.generator.internal.types.utils.getValidOneOfUnwrappedFieldParameter +import graphql.schema.GraphQLInputObjectField +import graphql.schema.GraphQLInputType +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.full.createType +import kotlin.reflect.full.withNullability + +internal fun generateOneOfInputProperty( + generator: SchemaGenerator, + metadata: OneOfFieldMetadata +): GraphQLInputObjectField { + val graphQLInputType = generateGraphQLType( + generator = generator, + type = getOneOfInputFieldType(metadata.subClass, metadata.fieldType), + typeInfo = GraphQLKTypeMetadata(inputType = true, fieldName = metadata.fieldName), + ).safeCast() + + return GraphQLInputObjectField.newInputObjectField() + .name(metadata.fieldName) + .type(graphQLInputType) + .build() +} + +private fun getOneOfInputFieldType( + subClass: KClass<*>, + fieldType: GraphQLOneOfFieldType +): KType = when (fieldType) { + GraphQLOneOfFieldType.WRAPPED -> subClass.createType(nullable = true) + GraphQLOneOfFieldType.UNWRAPPED -> getValidOneOfUnwrappedFieldParameter(subClass).type.withNullability(true) +} diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/utils/validateOneOfInputObject.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/utils/validateOneOfInputObject.kt new file mode 100644 index 0000000000..a3cfe7c7ea --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/utils/validateOneOfInputObject.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.internal.types.utils + +import com.expediagroup.graphql.generator.exceptions.DuplicateOneOfInputFieldException +import com.expediagroup.graphql.generator.exceptions.InvalidGraphQLOneOfUnwrappedFieldException +import com.expediagroup.graphql.generator.exceptions.InvalidGraphQLOneOfTargetException +import com.expediagroup.graphql.generator.exceptions.NoGraphQLOneOfImplementationsException +import com.expediagroup.graphql.generator.internal.extensions.isPublic +import kotlin.reflect.KClass +import kotlin.reflect.KParameter +import kotlin.reflect.full.primaryConstructor + +internal fun validateOneOfInputObjectSealedInterface(kClass: KClass<*>) { + if (!kClass.java.isInterface || !kClass.isSealed) { + throw InvalidGraphQLOneOfTargetException(kClass) + } + if (kClass.sealedSubclasses.isEmpty()) { + throw NoGraphQLOneOfImplementationsException(kClass) + } +} + +internal fun validateOneOfInputDuplicatedFieldNames(parent: KClass<*>, fieldNames: List) { + val duplicateNames = fieldNames + .groupingBy { it } + .eachCount() + .filterValues { it > 1 } + .keys + if (duplicateNames.isNotEmpty()) { + throw DuplicateOneOfInputFieldException(parent, duplicateNames) + } +} + +internal fun getValidOneOfUnwrappedFieldParameter(kClass: KClass<*>): KParameter { + val primaryConstructor = kClass.primaryConstructor + ?: throw InvalidGraphQLOneOfUnwrappedFieldException(kClass) + + if ( + kClass.java.isInterface || + kClass.isAbstract || + kClass.isSealed || + !primaryConstructor.isPublic() || + primaryConstructor.parameters.size != 1 + ) { + throw InvalidGraphQLOneOfUnwrappedFieldException(kClass) + } + + return primaryConstructor.parameters.single() +} diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/execution/ConvertArgumentValueOneOfTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/execution/ConvertArgumentValueOneOfTest.kt new file mode 100644 index 0000000000..4745c0dafc --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/execution/ConvertArgumentValueOneOfTest.kt @@ -0,0 +1,249 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.expediagroup.graphql.generator.execution + +import com.expediagroup.graphql.generator.annotations.GraphQLName +import com.expediagroup.graphql.generator.annotations.GraphQLOneOf +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfField +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfFieldType +import com.expediagroup.graphql.generator.exceptions.InvalidGraphQLOneOfUnwrappedFieldException +import com.expediagroup.graphql.generator.scalars.ID +import org.junit.jupiter.api.Test +import kotlin.reflect.full.findParameterByName +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +class ConvertArgumentValueOneOfTest { + + class TestFunctions { + fun oneOfInput(input: PostElementInput): String = TODO() + fun oneOfScalarInput(input: UserByInput): String = TODO() + fun nestedOneOfInput(input: SearchTargetInput): String = TODO() + fun listOneOfInput(input: List): String = TODO() + fun invalidUnwrappedOneOfInput(input: InvalidUnwrappedOneOfInput): String = TODO() + fun renamedUnwrappedOneOfInput(input: RenamedUserByInput): String = TODO() + } + + @GraphQLOneOf + sealed interface PostElementInput { + @GraphQLOneOfField("paragraph") + data class ParagraphInput(val text: String) : PostElementInput + @GraphQLOneOfField("blockquote") + data class BlockQuoteInput(val value: String, val attribution: String?, val attributionUrl: String?) : PostElementInput + } + + @GraphQLOneOf + sealed interface UserByInput { + @GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED) + data class UserByEmail(val email: String) : UserByInput + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class UserByID(val id: ID) : UserByInput + @GraphQLOneOfField("criteria") + data class UserByCriteria(val name: String?, val address: String?) : UserByInput + } + + @GraphQLOneOf + sealed interface SearchTargetInput { + @GraphQLOneOfField("user", GraphQLOneOfFieldType.UNWRAPPED) + data class User(val value: UserSelectorInput) : SearchTargetInput + @GraphQLOneOfField("organization", GraphQLOneOfFieldType.UNWRAPPED) + data class Organization(val value: OrganizationSelector) : SearchTargetInput + } + @GraphQLOneOf + sealed interface UserSelectorInput { + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class Id(val value: ID) : UserSelectorInput + @GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED) + data class Email(val value: String) : UserSelectorInput + } + @GraphQLOneOf + sealed interface OrganizationSelector { + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class Id(val value: ID) : OrganizationSelector + @GraphQLOneOfField("slug", GraphQLOneOfFieldType.UNWRAPPED) + data class Slug(val value: String) : OrganizationSelector + } + + @GraphQLOneOf + sealed interface InvalidUnwrappedOneOfInput { + @GraphQLOneOfField("invalid", GraphQLOneOfFieldType.UNWRAPPED) + data class Invalid(val first: String, val second: String) : InvalidUnwrappedOneOfInput + } + + @GraphQLOneOf + sealed interface RenamedUserByInput { + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class UserByID(@param:GraphQLName("value") val id: ID) : RenamedUserByInput + } + + @Test + fun `oneOf sealed input object is parsed into subtype`() { + val kParam = assertNotNull(TestFunctions::oneOfInput.findParameterByName("input")) + val result = convertArgumentValue( + "input", + kParam, + mapOf( + "input" to mapOf( + "paragraph" to mapOf( + "text" to "test" + ) + ) + ) + ) + val castResult = assertIs(result) + assertEquals("test", castResult.text) + } + + @Test + fun `oneOf sealed scalar input is parsed into subtype`() { + val kParam = assertNotNull(TestFunctions::oneOfScalarInput.findParameterByName("input")) + val result = convertArgumentValue( + "input", + kParam, + mapOf( + "input" to mapOf( + "id" to "1234" + ) + ) + ) + + val castResult = assertIs(result) + assertEquals("1234", castResult.id.value) + } + + @Test + fun `oneOf sealed scalar string input is parsed into subtype`() { + val kParam = assertNotNull(TestFunctions::oneOfScalarInput.findParameterByName("input")) + val result = convertArgumentValue( + "input", + kParam, + mapOf( + "input" to mapOf( + "email" to "sam@example.com" + ) + ) + ) + + val castResult = assertIs(result) + assertEquals("sam@example.com", castResult.email) + } + + @Test + fun `oneOf sealed wrapped object input is parsed into subtype`() { + val kParam = assertNotNull(TestFunctions::oneOfScalarInput.findParameterByName("input")) + val result = convertArgumentValue( + "input", + kParam, + mapOf( + "input" to mapOf( + "criteria" to mapOf( + "name" to "Sam", + "address" to "Seattle" + ) + ) + ) + ) + + val castResult = assertIs(result) + assertEquals("Sam", castResult.name) + assertEquals("Seattle", castResult.address) + } + + @Test + fun `nested oneOf sealed input object is parsed into subtype`() { + val kParam = assertNotNull(TestFunctions::nestedOneOfInput.findParameterByName("input")) + val result = convertArgumentValue( + "input", + kParam, + mapOf( + "input" to mapOf( + "user" to mapOf( + "email" to "sam@example.com" + ) + ) + ) + ) + + val castResult = assertIs(result) + val userSelector = assertIs(castResult.value) + assertEquals("sam@example.com", userSelector.value) + } + + @Test + fun `list oneOf sealed input object is parsed into subtypes`() { + val kParam = assertNotNull(TestFunctions::listOneOfInput.findParameterByName("input")) + val result = convertArgumentValue( + "input", + kParam, + mapOf( + "input" to listOf( + mapOf( + "paragraph" to mapOf( + "text" to "test" + ) + ), + mapOf( + "blockquote" to mapOf( + "value" to "quote" + ) + ) + ) + ) + ) + + val listResult = assertIs>(result) + val paragraph = assertIs(listResult[0]) + assertEquals("test", paragraph.text) + val blockquote = assertIs(listResult[1]) + assertEquals("quote", blockquote.value) + assertEquals(null, blockquote.attribution) + assertEquals(null, blockquote.attributionUrl) + } + + @Test + fun `oneOf unwrapped subtype with multiple constructor parameters throws`() { + val kParam = assertNotNull(TestFunctions::invalidUnwrappedOneOfInput.findParameterByName("input")) + assertFailsWith { + convertArgumentValue( + "input", + kParam, + mapOf( + "input" to mapOf( + "invalid" to "value" + ) + ) + ) + } + } + + @Test + fun `oneOf unwrapped subtype with renamed constructor parameter is parsed into subtype`() { + val kParam = assertNotNull(TestFunctions::renamedUnwrappedOneOfInput.findParameterByName("input")) + val result = convertArgumentValue( + "input", + kParam, + mapOf( + "input" to mapOf( + "id" to "1234" + ) + ) + ) + val castResult = assertIs(result) + assertEquals("1234", castResult.id.value) + } +} diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/execution/ConvertArgumentValueTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/execution/ConvertArgumentValueTest.kt index fb560a225d..40825058a4 100644 --- a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/execution/ConvertArgumentValueTest.kt +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/execution/ConvertArgumentValueTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 Expedia, Inc + * Copyright 2026 Expedia, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateArgumentOneOfTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateArgumentOneOfTest.kt new file mode 100644 index 0000000000..3f640763ae --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateArgumentOneOfTest.kt @@ -0,0 +1,368 @@ +/* + * Copyright 2026 Expedia, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.graphql.generator.internal.types + +import com.expediagroup.graphql.generator.annotations.GraphQLOneOf +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfField +import com.expediagroup.graphql.generator.annotations.GraphQLOneOfFieldType +import com.expediagroup.graphql.generator.exceptions.DuplicateOneOfInputFieldException +import com.expediagroup.graphql.generator.exceptions.InvalidGraphQLOneOfUnwrappedFieldException +import com.expediagroup.graphql.generator.exceptions.InvalidGraphQLOneOfTargetException +import com.expediagroup.graphql.generator.exceptions.MissingOneOfInputFieldAnnotationException +import com.expediagroup.graphql.generator.exceptions.NoGraphQLOneOfImplementationsException +import com.expediagroup.graphql.generator.scalars.ID +import com.expediagroup.graphql.generator.test.utils.SimpleDirective +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLNonNull +import graphql.schema.idl.SchemaPrinter +import org.junit.jupiter.api.Test +import kotlin.reflect.full.findParameterByName +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class GenerateArgumentOneOfTest : TypeTestHelper() { + + class OneOfArgumentTestClass { + fun oneOfInput(input: PostElementInput): String = when (input) { + is PostElementInput.ParagraphInput -> input.text + is PostElementInput.BlockQuoteInput -> input.value + } + + fun oneOfScalarInput(input: UserByInput): String = when (input) { + is UserByInput.UserByID -> input.id.value + is UserByInput.UserByEmail -> input.email + is UserByInput.UserByCriteria -> "${input.name} ${input.address}" + } + + fun nestedOneOfInput(input: SearchTargetInput): String = input.toString() + + fun invalidOneOfInput(input: InvalidOneOfInput): String = input.toString() + + fun concreteOneOfInput(input: ConcreteOneOfInput): String = input.toString() + + fun nonSealedOneOfInput(input: NonSealedOneOfInput): String = input.toString() + + fun emptyOneOfInput(input: EmptyOneOfInput): String = input.toString() + + fun missingFieldAnnotationOneOfInput(input: MissingFieldAnnotationOneOfInput): String = input.toString() + + fun duplicateFieldNameOneOfInput(input: DuplicateFieldNameOneOfInput): String = input.toString() + + fun invalidUnwrappedFieldOneOfInput(input: InvalidUnwrappedFieldOneOfInput): String = input.toString() + + fun directedOneOfInput(input: DirectedOneOfInput): String = input.toString() + + fun privateConstructorUnwrappedFieldOneOfInput(input: PrivateConstructorUnwrappedFieldOneOfInput): String = input.toString() + } + + @GraphQLOneOf + sealed interface PostElementInput { + @GraphQLOneOfField("paragraph") + data class ParagraphInput(val text: String) : PostElementInput + @GraphQLOneOfField("blockquote") + data class BlockQuoteInput(val value: String, val attribution: String?, val attributionUrl: String?) : PostElementInput + } + + @GraphQLOneOf + sealed interface UserByInput { + @GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED) + data class UserByEmail(val email: String) : UserByInput + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class UserByID(val id: ID) : UserByInput + @GraphQLOneOfField("criteria") + data class UserByCriteria(val name: String?, val address: String?) : UserByInput + } + + @GraphQLOneOf + sealed interface SearchTargetInput { + @GraphQLOneOfField("user", GraphQLOneOfFieldType.UNWRAPPED) + data class User(val value: UserSelectorInput) : SearchTargetInput + @GraphQLOneOfField("organization", GraphQLOneOfFieldType.UNWRAPPED) + data class Organization(val value: OrganizationSelector) : SearchTargetInput + } + @GraphQLOneOf + sealed interface UserSelectorInput { + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class Id(val value: ID) : UserSelectorInput + @GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED) + data class Email(val value: String) : UserSelectorInput + } + @GraphQLOneOf + sealed interface OrganizationSelector { + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class Id(val value: ID) : OrganizationSelector + @GraphQLOneOfField("slug", GraphQLOneOfFieldType.UNWRAPPED) + data class Slug(val value: String) : OrganizationSelector + } + + @GraphQLOneOf + sealed class InvalidOneOfInput { + @GraphQLOneOfField("something") + data class Something(val message: String) : InvalidOneOfInput() + } + + @GraphQLOneOf + data class ConcreteOneOfInput(val id: ID?, val email: String?) + + @GraphQLOneOf + interface NonSealedOneOfInput + + @GraphQLOneOf + sealed interface EmptyOneOfInput + + @GraphQLOneOf + sealed interface MissingFieldAnnotationOneOfInput { + data class MissingAnnotation(val value: String) : MissingFieldAnnotationOneOfInput + } + + @GraphQLOneOf + sealed interface DuplicateFieldNameOneOfInput { + @GraphQLOneOfField("duplicate") + data class First(val value: String) : DuplicateFieldNameOneOfInput + @GraphQLOneOfField("duplicate") + data class Second(val value: String) : DuplicateFieldNameOneOfInput + } + + @GraphQLOneOf + sealed interface InvalidUnwrappedFieldOneOfInput { + @GraphQLOneOfField("invalid", GraphQLOneOfFieldType.UNWRAPPED) + data class Invalid(val first: String, val second: String) : InvalidUnwrappedFieldOneOfInput + } + + @GraphQLOneOf + @SimpleDirective + sealed interface DirectedOneOfInput { + @GraphQLOneOfField("value", GraphQLOneOfFieldType.UNWRAPPED) + data class Value(val value: String) : DirectedOneOfInput + } + + @GraphQLOneOf + sealed interface PrivateConstructorUnwrappedFieldOneOfInput { + @GraphQLOneOfField("invalid", GraphQLOneOfFieldType.UNWRAPPED) + class Invalid private constructor(val value: String) : PrivateConstructorUnwrappedFieldOneOfInput + } + + @Test + fun `oneOf sealed interface argument object type is valid`() { + val kParameter = OneOfArgumentTestClass::oneOfInput.findParameterByName("input") + assertNotNull(kParameter) + val result = generateArgument(generator, kParameter) + + assertEquals(expected = "input", actual = result.name) + val nonNullType = assertNotNull(result.type as? GraphQLNonNull) + val oneOfInputType = assertNotNull(nonNullType.wrappedType as? GraphQLInputObjectType) + + assertEquals( + expected = """ + input PostElementInput @oneOf { + blockquote: BlockQuoteInput + paragraph: ParagraphInput + } + """.trimIndent(), + actual = SchemaPrinter().print(oneOfInputType).trim() + ) + + val blockQuoteInputType = oneOfInputType.getField("blockquote")!!.type as GraphQLInputObjectType + assertEquals( + expected = """ + input BlockQuoteInput { + attribution: String + attributionUrl: String + value: String! + } + """.trimIndent(), + actual = SchemaPrinter().print(blockQuoteInputType).trim() + ) + + val paragraphInputType = oneOfInputType.getField("paragraph")!!.type as GraphQLInputObjectType + assertEquals( + expected = """ + input ParagraphInput { + text: String! + } + """.trimIndent(), + actual = SchemaPrinter().print(paragraphInputType).trim() + ) + } + + @Test + fun `oneOf sealed interface argument scalar type is valid`() { + val kParameter = OneOfArgumentTestClass::oneOfScalarInput.findParameterByName("input") + assertNotNull(kParameter) + + val result = generateArgument(generator, kParameter) + + assertEquals(expected = "input", actual = result.name) + + val nonNullType = assertNotNull(result.type as? GraphQLNonNull) + val oneOfInputType = assertNotNull(nonNullType.wrappedType as? GraphQLInputObjectType) + + assertEquals( + expected = """ + input UserByInput @oneOf { + criteria: UserByCriteriaInput + email: String + id: ID + } + """.trimIndent(), + actual = SchemaPrinter().print(oneOfInputType).trim() + ) + + val criteriaInputType = oneOfInputType.getField("criteria")!!.type as GraphQLInputObjectType + assertEquals( + expected = """ + input UserByCriteriaInput { + address: String + name: String + } + """.trimIndent(), + actual = SchemaPrinter().print(criteriaInputType).trim() + ) + } + + @Test + fun `nested oneOf sealed interface argument object type is valid`() { + val kParameter = OneOfArgumentTestClass::nestedOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + val result = generateArgument(generator, kParameter) + + assertEquals(expected = "input", actual = result.name) + val nonNullType = assertNotNull(result.type as? GraphQLNonNull) + val oneOfInputType = assertNotNull(nonNullType.wrappedType as? GraphQLInputObjectType) + + assertEquals( + expected = """ + input SearchTargetInput @oneOf { + organization: OrganizationSelectorInput + user: UserSelectorInput + } + """.trimIndent(), + actual = SchemaPrinter().print(oneOfInputType).trim() + ) + + val organizationSelectorInputType = oneOfInputType.getField("organization")!!.type as GraphQLInputObjectType + assertEquals( + expected = """ + input OrganizationSelectorInput @oneOf { + id: ID + slug: String + } + """.trimIndent(), + actual = SchemaPrinter().print(organizationSelectorInputType).trim() + ) + + val userSelectorInputType = oneOfInputType.getField("user")!!.type as GraphQLInputObjectType + assertEquals( + expected = """ + input UserSelectorInput @oneOf { + email: String + id: ID + } + """.trimIndent(), + actual = SchemaPrinter().print(userSelectorInputType).trim() + ) + } + + @Test + fun `oneOf sealed class argument throws invalid target exception`() { + val kParameter = OneOfArgumentTestClass::invalidOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + assertFailsWith { + generateArgument(generator, kParameter) + } + } + + @Test + fun `oneOf concrete data class argument throws invalid target exception`() { + val kParameter = OneOfArgumentTestClass::concreteOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + assertFailsWith { + generateArgument(generator, kParameter) + } + } + + @Test + fun `oneOf non sealed interface argument throws invalid target exception`() { + val kParameter = OneOfArgumentTestClass::nonSealedOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + assertFailsWith { + generateArgument(generator, kParameter) + } + } + + @Test + fun `oneOf empty sealed interface argument throws no implementation exception`() { + val kParameter = OneOfArgumentTestClass::emptyOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + assertFailsWith { + generateArgument(generator, kParameter) + } + } + + @Test + fun `oneOf sealed interface argument throws missing field annotation exception`() { + val kParameter = OneOfArgumentTestClass::missingFieldAnnotationOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + assertFailsWith { + generateArgument(generator, kParameter) + } + } + + @Test + fun `oneOf sealed interface argument throws duplicate field name exception`() { + val kParameter = OneOfArgumentTestClass::duplicateFieldNameOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + assertFailsWith { + generateArgument(generator, kParameter) + } + } + + @Test + fun `oneOf sealed interface argument throws invalid unwrapped field exception`() { + val kParameter = OneOfArgumentTestClass::invalidUnwrappedFieldOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + assertFailsWith { + generateArgument(generator, kParameter) + } + } + + @Test + fun `oneOf input object includes custom directives`() { + val kParameter = OneOfArgumentTestClass::directedOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + + val result = generateArgument(generator, kParameter) + + val nonNullType = assertNotNull(result.type as? GraphQLNonNull) + val oneOfInputType = assertNotNull(nonNullType.wrappedType as? GraphQLInputObjectType) + assertEquals( + expected = setOf("oneOf", "simpleDirective"), + actual = oneOfInputType.appliedDirectives.map { it.name }.toSet() + ) + } + + @Test + fun `oneOf unwrapped private constructor subtype is rejected during schema generation`() { + val kParameter = OneOfArgumentTestClass::privateConstructorUnwrappedFieldOneOfInput.findParameterByName("input") + assertNotNull(kParameter) + + assertFailsWith { + generateArgument(generator, kParameter) + } + } +} diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/fixtures/GraphQLArgumentExtensions.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/fixtures/GraphQLArgumentExtensions.kt new file mode 100644 index 0000000000..2788ebb72f --- /dev/null +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/fixtures/GraphQLArgumentExtensions.kt @@ -0,0 +1,25 @@ +package com.expediagroup.graphql.generator.internal.types.fixtures + +import graphql.Scalars +import graphql.schema.GraphQLArgument +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLSchema +import graphql.schema.idl.SchemaPrinter + +internal fun GraphQLArgument.toSdl(fieldName: String): String = + SchemaPrinter().print( + GraphQLSchema.newSchema() + .query( + GraphQLObjectType.newObject() + .name("Query") + .field( + GraphQLFieldDefinition.newFieldDefinition() + .name(fieldName) + .type(Scalars.GraphQLString) + .argument(this) + ) + .build() + ) + .build() + ) From 6448aed1114b1b8476ac9c054e7dcb6101a39031 Mon Sep 17 00:00:00 2001 From: Samuel Vazquez Date: Tue, 16 Jun 2026 16:42:11 -0600 Subject: [PATCH 2/3] chore: add documentation --- .../writing-schemas/oneOf.mdx | 462 ++++++++++++++++++ website/sidebars.js | 1 + 2 files changed, 463 insertions(+) create mode 100644 website/docs/schema-generator/writing-schemas/oneOf.mdx diff --git a/website/docs/schema-generator/writing-schemas/oneOf.mdx b/website/docs/schema-generator/writing-schemas/oneOf.mdx new file mode 100644 index 0000000000..6f425d0e54 --- /dev/null +++ b/website/docs/schema-generator/writing-schemas/oneOf.mdx @@ -0,0 +1,462 @@ +--- +id: oneOf +title: OneOf +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +The `@oneOf` directive lets you define an input type where exactly one field must be supplied. In `graphql-kotlin`, `@oneOf` input objects are modeled using Kotlin sealed interfaces, with each implementing class becoming one field of the generated input type. + +## The Problem Without `@oneOf` + +Most GraphQL queries start at a single node and traverse the data graph from there. But often there is more than one way to locate that node — for example, finding a user by ID, email address, or username. Traditionally that meant multiple root-level fields: + +```graphql +type Query { + user(id: ID!): User + userByEmail(email: String!): User + userByUsername(username: String!): User +} +``` + +This clutters the schema with near-duplicate fields and makes it hard to add new lookup strategies without a breaking change. The alternative was a single field accepting a catch-all input object with every option as a nullable field: + +```graphql +input UserLookupInput { + id: ID + email: String + username: String +} + +type Query { + user(input: UserLookupInput!): User +} +``` + +```kotlin +class Query { + fun user(input: UserLookupInput): User { + val count = listOfNotNull(input.id, input.email, input.username).size + require(count == 1) { "Exactly one lookup field must be provided" } + return when { + input.id != null -> userRepository.findById(input.id) + input.email != null -> userRepository.findByEmail(input.email) + else -> userRepository.findByUsername(input.username!!) + } + } +} +``` + +This is worse: the schema permits any combination of fields — including none — with no indication to clients that exactly one is expected. Validation is pushed into application code, nullability is lost in the resolver, and the constraint is invisible to schema tooling and code generators. + +`@oneOf` solves both problems. A single field accepts a typed input where exactly one option must be set, enforced at the schema level before your resolver runs: + +```graphql +input UserLookupInput @oneOf { + id: ID + email: String + username: String +} + +type Query { + user(input: UserLookupInput!): User +} +``` + +## `@GraphQLOneOf` + +Apply `@GraphQLOneOf` to a sealed interface to designate it as a `@oneOf` input type. The schema generator will produce an `input` type with the `@oneOf` directive applied, and each sealed subclass becomes one of its fields. + +```kotlin +@GraphQLOneOf +sealed interface UserLookupInput +``` + +## `@GraphQLOneOfField` + +Apply `@GraphQLOneOfField` to each implementing class to declare it as a named field of the generated `@oneOf` input type. The `fieldName` parameter sets the field name in the schema. + +```kotlin +@GraphQLOneOf +sealed interface UserLookupInput { + + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class ById(val id: String) : UserLookupInput + + @GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED) + data class ByEmail(val email: String) : UserLookupInput + + @GraphQLOneOfField("username", GraphQLOneOfFieldType.UNWRAPPED) + data class ByUsername(val username: String) : UserLookupInput +} +``` + +The `type` parameter of `@GraphQLOneOfField` controls how the subtype is projected into the schema. + +### `WRAPPED` (Default) + +The subtype itself becomes the field's input object type. Use this when the field carries multiple properties. The generated type name follows the standard graphql-kotlin `Input` suffix rule — if the class name does not already end in `Input`, it is appended. + +```kotlin +@GraphQLOneOf +sealed interface ContentBlockInput { + + @GraphQLOneOfField("paragraph", GraphQLOneOfFieldType.WRAPPED) + data class Paragraph(val text: String) : ContentBlockInput + + @GraphQLOneOfField("blockquote", GraphQLOneOfFieldType.WRAPPED) + data class BlockQuote( + val text: String, + val attribution: String? = null + ) : ContentBlockInput +} +``` + +```graphql +input ContentBlockInput @oneOf { + paragraph: ParagraphInput + blockquote: BlockQuoteInput +} + +input ParagraphInput { + text: String! +} + +input BlockQuoteInput { + text: String! + attribution: String +} +``` + +### `UNWRAPPED` + +The field type is taken directly from the subtype's single constructor property. Use this for scalar and enum fields to keep the schema flat. + +```kotlin +@GraphQLOneOf +sealed interface UserLookupInput { + + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class ById(val id: ID) : UserLookupInput + + @GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED) + data class ByEmail(val email: String) : UserLookupInput + + @GraphQLOneOfField("username", GraphQLOneOfFieldType.UNWRAPPED) + data class ByUsername(val username: String) : UserLookupInput +} +``` + +```graphql +input UserLookupInput @oneOf { + id: ID + email: String + username: String +} +``` + +Using `WRAPPED` instead would generate a redundant intermediate input type for each subclass, wrapping the scalar in an unnecessary object: + +```graphql +input UserLookupInput @oneOf { + id: ByIdInput + email: ByEmailInput + username: ByUsernameInput +} + +input ByIdInput { + id: ID! +} + +input ByEmailInput { + email: String! +} + +input ByUsernameInput { + username: String! +} +``` + +## Validation Rules + +`graphql-kotlin` validates the sealed interface structure at schema generation time — incorrect annotation usage throws an exception before the schema is built. + +At query execution time, `graphql-java` enforces the `@oneOf` contract: exactly one non-null field must be supplied. Clients sending zero or multiple fields will receive a validation error. + +## Examples + +### Scalar Field Lookup + +A `@oneOf` input where each option resolves to a scalar or ID. Use `UNWRAPPED` to keep the generated SDL clean — the field type is taken directly from the single constructor property rather than wrapping it in a nested input object. + + + + + +```kotlin +@GraphQLDescription("A user lookup input where exactly one lookup strategy must be supplied.") +@GraphQLOneOf +sealed interface UserLookupInput { + + @GraphQLDescription("Look up a user by their unique ID.") + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class ById( + @param:GraphQLDescription("The user ID.") + val id: ID + ) : UserLookupInput + + @GraphQLDescription("Look up a user by their email address.") + @GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED) + data class ByEmail( + @param:GraphQLDescription("The user's email address.") + val email: String + ) : UserLookupInput + + @GraphQLDescription("Look up a user by their username.") + @GraphQLOneOfField("username", GraphQLOneOfFieldType.UNWRAPPED) + data class ByUsername( + @param:GraphQLDescription("The user's username.") + val username: String + ) : UserLookupInput +} + +class Query { + fun findUser(input: UserLookupInput): User = TODO() +} +``` + + + + + +```graphql +"""A user lookup input where exactly one lookup strategy must be supplied.""" +input UserLookupInput @oneOf { + id: ID + email: String + username: String +} + +type Query { + findUser(input: UserLookupInput!): User! +} +``` + + + + + +```kotlin +class Query { + fun findUser(input: UserLookupInput): User = when (input) { + is UserLookupInput.ById -> userRepository.findById(input.id) + is UserLookupInput.ByEmail -> userRepository.findByEmail(input.email) + is UserLookupInput.ByUsername -> userRepository.findByUsername(input.username) + } +} +``` + + + + + +```graphql +query { + findUser(input: { email: "sam@example.com" }) { + id + name + } +} +``` + + + + + +### Mixed Scalar and Object Fields + +A `@oneOf` input that combines scalar fields (using `UNWRAPPED`) with multi-property object fields (using `WRAPPED`). This pattern is common for content or product filter inputs where some options are simple identifiers and others carry structured data. + + + + + +```kotlin +@GraphQLDescription("A content block input where exactly one block shape must be supplied.") +@GraphQLOneOf +sealed interface ContentBlockInput { + + @GraphQLDescription("A raw Markdown string.") + @GraphQLOneOfField("markdown", GraphQLOneOfFieldType.UNWRAPPED) + data class Markdown( + @param:GraphQLDescription("The Markdown content.") + val content: String + ) : ContentBlockInput + + @GraphQLDescription("A plain paragraph.") + @GraphQLOneOfField("paragraph") + data class Paragraph( + @param:GraphQLDescription("The paragraph text.") + val text: String + ) : ContentBlockInput + + @GraphQLDescription("A block quote with optional attribution.") + @GraphQLOneOfField("blockquote") + data class BlockQuote( + @param:GraphQLDescription("The quoted text.") + val value: String, + @param:GraphQLDescription("The optional attribution source.") + val attribution: String?, + @param:GraphQLDescription("The optional URL for the attribution source.") + val attributionUrl: String? + ) : ContentBlockInput + + @GraphQLDescription("An image block.") + @GraphQLOneOfField("image") + data class Image( + @param:GraphQLDescription("The image URL.") + val url: String, + @param:GraphQLDescription("The image alt text.") + val altText: String + ) : ContentBlockInput +} + +class Mutation { + fun addContentBlock(input: ContentBlockInput): Boolean = TODO() +} +``` + + + + + +```graphql +"""A content block input where exactly one block shape must be supplied.""" +input ContentBlockInput @oneOf { + markdown: String + paragraph: ParagraphInput + blockquote: BlockQuoteInput + image: ImageInput +} + +input ParagraphInput { + """The paragraph text.""" + text: String! +} + +input BlockQuoteInput { + """The quoted text.""" + value: String! + """The optional attribution source.""" + attribution: String + """The optional URL for the attribution source.""" + attributionUrl: String +} + +input ImageInput { + """The image URL.""" + url: String! + """The image alt text.""" + altText: String! +} + +type Mutation { + addContentBlock(input: ContentBlockInput!): Boolean! +} +``` + + + + + +```kotlin +class Mutation { + fun addContentBlock(input: ContentBlockInput): Boolean = when (input) { + is ContentBlockInput.Markdown -> contentService.addMarkdown(input.content) + is ContentBlockInput.Paragraph -> contentService.addParagraph(input.text) + is ContentBlockInput.BlockQuote -> contentService.addBlockQuote( + value = input.value, + attribution = input.attribution, + attributionUrl = input.attributionUrl, + ) + is ContentBlockInput.Image -> contentService.addImage( + url = input.url, + altText = input.altText, + ) + } +} +``` + + + + + +```graphql +# scalar field (UNWRAPPED): +mutation { + addContentBlock(input: { + markdown: "**Hello**, world." + }) +} + +# object field (WRAPPED): +mutation { + addContentBlock(input: { + blockquote: { + value: "Simple, clear, and direct." + attribution: "Style Guide" + } + }) +} +``` + + + + + +## Constraints and Validation Errors + +The following exceptions are thrown during schema generation when the `@GraphQLOneOf` or `@GraphQLOneOfField` annotations are used incorrectly. + +| Exception | Trigger | +|---|---| +| `InvalidGraphQLOneOfTargetException` | `@GraphQLOneOf` was applied to a concrete class, abstract class, sealed class, or non-sealed interface. Only sealed interfaces are supported. | +| `NoGraphQLOneOfImplementationsException` | The sealed interface annotated with `@GraphQLOneOf` has no implementing subclasses. | +| `MissingOneOfInputFieldAnnotationException` | A sealed subclass of a `@GraphQLOneOf` interface is missing the `@GraphQLOneOfField` annotation. | +| `DuplicateOneOfInputFieldException` | Two or more sealed subclasses share the same `fieldName` in their `@GraphQLOneOfField` annotations. | +| `InvalidGraphQLOneOfUnwrappedFieldException` | A subclass using `type = UNWRAPPED` does not define exactly one primary constructor parameter, its constructor is not public, or the subtype is abstract or an interface. | + +## Nesting `@oneOf` Types + +`@oneOf` input types can be nested inside other `@oneOf` types. Use `UNWRAPPED` on the outer subtype to keep the SDL flat — the field type resolves directly to the nested `@oneOf` type rather than creating an intermediate wrapper object: + +```kotlin +@GraphQLOneOf +sealed interface EntityLookupInput { + + @GraphQLOneOfField("user", GraphQLOneOfFieldType.UNWRAPPED) + data class User(val lookup: UserLookupInput) : EntityLookupInput + + @GraphQLOneOfField("organization", GraphQLOneOfFieldType.UNWRAPPED) + data class Organization(val lookup: OrganizationLookupInput) : EntityLookupInput +} +``` + +This produces a flat `@oneOf` input whose fields are themselves `@oneOf` input types, which allows clients to compose lookup strategies across entity types with full schema-level validation at every level. diff --git a/website/sidebars.js b/website/sidebars.js index ff95c8f660..a03c80dee4 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -21,6 +21,7 @@ module.exports = { 'schema-generator/writing-schemas/lists', 'schema-generator/writing-schemas/interfaces', 'schema-generator/writing-schemas/unions', + 'schema-generator/writing-schemas/oneOf', 'schema-generator/writing-schemas/nested-arguments' ] }, From 81b015dc4f976e43023ab69a4f05b2120c773621 Mon Sep 17 00:00:00 2001 From: Samuel Vazquez Date: Wed, 24 Jun 2026 15:58:23 -0600 Subject: [PATCH 3/3] feat: add missing support for directives, deprecation, and annotations --- .../server/spring/query/OneOfQuery.kt | 3 + .../internal/types/generateGraphQLType.kt | 8 +- .../types/generateOneOfInputProperty.kt | 49 ++++++++---- .../types/GenerateArgumentOneOfTest.kt | 75 +++++++++++++++++++ 4 files changed, 117 insertions(+), 18 deletions(-) diff --git a/examples/server/spring-server/src/main/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQuery.kt b/examples/server/spring-server/src/main/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQuery.kt index f510cba2c4..6c6a8fd117 100644 --- a/examples/server/spring-server/src/main/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQuery.kt +++ b/examples/server/spring-server/src/main/kotlin/com/expediagroup/graphql/examples/server/spring/query/OneOfQuery.kt @@ -16,6 +16,7 @@ package com.expediagroup.graphql.examples.server.spring.query +import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated import com.expediagroup.graphql.generator.annotations.GraphQLDescription import com.expediagroup.graphql.generator.annotations.GraphQLOneOf import com.expediagroup.graphql.generator.annotations.GraphQLOneOfField @@ -73,6 +74,7 @@ sealed interface ContentBlockInput { @GraphQLDescription("Paragraph content supplied as a wrapped @oneOf object field.") @GraphQLOneOfField("paragraph") + @GraphQLDeprecated("This paragraph field is deprecated.") data class Paragraph( @param:GraphQLDescription("The paragraph text.") val text: String @@ -107,6 +109,7 @@ sealed interface UserLookupInput { @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) data class ById( @param:GraphQLDescription("The user ID.") + @param:GraphQLDeprecated("lookup by ID is deprecated.") val id: ID ) : UserLookupInput 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 37abbf0958..931ec7c9bf 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 @@ -41,7 +41,10 @@ import kotlin.reflect.full.createType */ internal fun generateGraphQLType(generator: SchemaGenerator, type: KType, typeInfo: GraphQLKTypeMetadata = GraphQLKTypeMetadata()): GraphQLType { val hookGraphQLType = generator.config.hooks.willGenerateGraphQLType(type) + val customTypeAnnotation = typeInfo.fieldAnnotations.getCustomTypeAnnotation() val graphQLType = hookGraphQLType + // @GraphQLType overrides the reflected Kotlin type, including built-in scalar mappings. + ?: customTypeAnnotation?.let { GraphQLTypeReference.typeRef(it.typeName) } ?: generateScalar(generator, type) ?: objectFromReflection(generator, type, typeInfo) @@ -88,11 +91,6 @@ private fun getGraphQLType( type: KType, typeInfo: GraphQLKTypeMetadata ): GraphQLType { - val customTypeAnnotation = typeInfo.fieldAnnotations.getCustomTypeAnnotation() - if (customTypeAnnotation != null) { - return GraphQLTypeReference.typeRef(customTypeAnnotation.typeName) - } - return when { typeInfo.inputType && kClass.isGraphQLOneOf() -> { generateOneOfInputObject(generator, kClass) diff --git a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputProperty.kt b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputProperty.kt index 900fea7b6f..26176afc79 100644 --- a/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputProperty.kt +++ b/generator/graphql-kotlin-schema-generator/src/main/kotlin/com/expediagroup/graphql/generator/internal/types/generateOneOfInputProperty.kt @@ -18,35 +18,58 @@ package com.expediagroup.graphql.generator.internal.types import com.expediagroup.graphql.generator.SchemaGenerator import com.expediagroup.graphql.generator.annotations.GraphQLOneOfFieldType +import com.expediagroup.graphql.generator.directives.deprecatedDirectiveWithReason +import com.expediagroup.graphql.generator.internal.extensions.getDeprecationReason +import com.expediagroup.graphql.generator.internal.extensions.getGraphQLDescription import com.expediagroup.graphql.generator.internal.extensions.safeCast import com.expediagroup.graphql.generator.internal.types.utils.getValidOneOfUnwrappedFieldParameter +import graphql.introspection.Introspection.DirectiveLocation import graphql.schema.GraphQLInputObjectField import graphql.schema.GraphQLInputType -import kotlin.reflect.KClass -import kotlin.reflect.KType +import kotlin.reflect.KAnnotatedElement import kotlin.reflect.full.createType import kotlin.reflect.full.withNullability +/* + * WRAPPED subtype directives are ambiguous: @SimpleDirective on ParagraphInput could target + * `input ParagraphInput @simpleDirective` or + * `paragraph: ParagraphInput @simpleDirective`. + * Add an explicit way to distinguish type directives from synthetic oneOf field directives. + */ internal fun generateOneOfInputProperty( generator: SchemaGenerator, metadata: OneOfFieldMetadata ): GraphQLInputObjectField { + // Field metadata comes from the generated field source: constructor parameter for UNWRAPPED, subtype class for WRAPPED. + val unwrappedParameter = if (metadata.fieldType == GraphQLOneOfFieldType.UNWRAPPED) { + getValidOneOfUnwrappedFieldParameter(metadata.subClass) + } else { + null + } + val fieldMetadata: KAnnotatedElement = unwrappedParameter ?: metadata.subClass + // oneOf input fields stay nullable, @oneOf validation enforces that exactly one nullable field is provided. + val type = unwrappedParameter?.type?.withNullability(true) ?: metadata.subClass.createType(nullable = true) val graphQLInputType = generateGraphQLType( generator = generator, - type = getOneOfInputFieldType(metadata.subClass, metadata.fieldType), - typeInfo = GraphQLKTypeMetadata(inputType = true, fieldName = metadata.fieldName), + type = type, + typeInfo = GraphQLKTypeMetadata(inputType = true, fieldName = metadata.fieldName, fieldAnnotations = unwrappedParameter?.annotations.orEmpty()), ).safeCast() - return GraphQLInputObjectField.newInputObjectField() + val builder = GraphQLInputObjectField.newInputObjectField() .name(metadata.fieldName) + .description(fieldMetadata.getGraphQLDescription()) .type(graphQLInputType) - .build() -} -private fun getOneOfInputFieldType( - subClass: KClass<*>, - fieldType: GraphQLOneOfFieldType -): KType = when (fieldType) { - GraphQLOneOfFieldType.WRAPPED -> subClass.createType(nullable = true) - GraphQLOneOfFieldType.UNWRAPPED -> getValidOneOfUnwrappedFieldParameter(subClass).type.withNullability(true) + fieldMetadata.getDeprecationReason()?.let { + builder.deprecate(it) + builder.withAppliedDirective(deprecatedDirectiveWithReason(it)) + } + + unwrappedParameter?.let { + generateDirectives(generator, it, DirectiveLocation.INPUT_FIELD_DEFINITION).forEach { directive -> + builder.withAppliedDirective(directive) + } + } + + return builder.build() } diff --git a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateArgumentOneOfTest.kt b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateArgumentOneOfTest.kt index 3f640763ae..74a7a97524 100644 --- a/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateArgumentOneOfTest.kt +++ b/generator/graphql-kotlin-schema-generator/src/test/kotlin/com/expediagroup/graphql/generator/internal/types/GenerateArgumentOneOfTest.kt @@ -16,9 +16,12 @@ package com.expediagroup.graphql.generator.internal.types +import com.expediagroup.graphql.generator.annotations.GraphQLDescription +import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated import com.expediagroup.graphql.generator.annotations.GraphQLOneOf import com.expediagroup.graphql.generator.annotations.GraphQLOneOfField import com.expediagroup.graphql.generator.annotations.GraphQLOneOfFieldType +import com.expediagroup.graphql.generator.annotations.GraphQLType import com.expediagroup.graphql.generator.exceptions.DuplicateOneOfInputFieldException import com.expediagroup.graphql.generator.exceptions.InvalidGraphQLOneOfUnwrappedFieldException import com.expediagroup.graphql.generator.exceptions.InvalidGraphQLOneOfTargetException @@ -43,12 +46,21 @@ class GenerateArgumentOneOfTest : TypeTestHelper() { is PostElementInput.BlockQuoteInput -> input.value } + fun oneOfDeprecatedWrappedInput(input: DeprecatedWrappedOneOfInput): String = when (input) { + is DeprecatedWrappedOneOfInput.DeprecatedParagraphInput -> input.text + is DeprecatedWrappedOneOfInput.DeprecatedBlockQuoteInput -> input.value + } + fun oneOfScalarInput(input: UserByInput): String = when (input) { is UserByInput.UserByID -> input.id.value is UserByInput.UserByEmail -> input.email is UserByInput.UserByCriteria -> "${input.name} ${input.address}" } + fun oneOfCustomGraphQLTypeInput(input: UserByUnWrappedWithDirectivesInput): String = when (input) { + is UserByUnWrappedWithDirectivesInput.ById -> input.value + } + fun nestedOneOfInput(input: SearchTargetInput): String = input.toString() fun invalidOneOfInput(input: InvalidOneOfInput): String = input.toString() @@ -78,6 +90,16 @@ class GenerateArgumentOneOfTest : TypeTestHelper() { data class BlockQuoteInput(val value: String, val attribution: String?, val attributionUrl: String?) : PostElementInput } + @GraphQLOneOf + sealed interface DeprecatedWrappedOneOfInput { + @GraphQLOneOfField("paragraph") + @GraphQLDeprecated("Deprecated") + @GraphQLDescription("the paragraph description") + data class DeprecatedParagraphInput(val text: String) : DeprecatedWrappedOneOfInput + @GraphQLOneOfField("blockquote") + data class DeprecatedBlockQuoteInput(val value: String) : DeprecatedWrappedOneOfInput + } + @GraphQLOneOf sealed interface UserByInput { @GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED) @@ -88,6 +110,18 @@ class GenerateArgumentOneOfTest : TypeTestHelper() { data class UserByCriteria(val name: String?, val address: String?) : UserByInput } + @GraphQLOneOf + sealed interface UserByUnWrappedWithDirectivesInput { + @GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED) + data class ById( + @param:GraphQLDescription("something") + @param:GraphQLDeprecated("Deprecated") + @param:GraphQLType("ID") + @param:SimpleDirective + val value: String + ) : UserByUnWrappedWithDirectivesInput + } + @GraphQLOneOf sealed interface SearchTargetInput { @GraphQLOneOfField("user", GraphQLOneOfFieldType.UNWRAPPED) @@ -200,6 +234,27 @@ class GenerateArgumentOneOfTest : TypeTestHelper() { ) } + @Test + fun `oneOf wrapped field uses subtype deprecation annotation`() { + val kParameter = OneOfArgumentTestClass::oneOfDeprecatedWrappedInput.findParameterByName("input") + assertNotNull(kParameter) + + val result = generateArgument(generator, kParameter) + + val nonNullType = assertNotNull(result.type as? GraphQLNonNull) + val oneOfInputType = assertNotNull(nonNullType.wrappedType as? GraphQLInputObjectType) + assertEquals( + expected = """ + input DeprecatedWrappedOneOfInput @oneOf { + blockquote: DeprecatedBlockQuoteInput + "the paragraph description" + paragraph: DeprecatedParagraphInput @deprecated(reason : "Deprecated") + } + """.trimIndent(), + actual = SchemaPrinter().print(oneOfInputType).trim() + ) + } + @Test fun `oneOf sealed interface argument scalar type is valid`() { val kParameter = OneOfArgumentTestClass::oneOfScalarInput.findParameterByName("input") @@ -235,6 +290,26 @@ class GenerateArgumentOneOfTest : TypeTestHelper() { ) } + @Test + fun `oneOf unwrapped field uses custom GraphQL type annotation`() { + val kParameter = OneOfArgumentTestClass::oneOfCustomGraphQLTypeInput.findParameterByName("input") + assertNotNull(kParameter) + + val result = generateArgument(generator, kParameter) + + val nonNullType = assertNotNull(result.type as? GraphQLNonNull) + val oneOfInputType = assertNotNull(nonNullType.wrappedType as? GraphQLInputObjectType) + assertEquals( + expected = """ + input UserByUnWrappedWithDirectivesInput @oneOf { + "something" + id: ID @deprecated(reason : "Deprecated") @simpleDirective + } + """.trimIndent(), + actual = SchemaPrinter().print(oneOfInputType).trim() + ) + } + @Test fun `nested oneOf sealed interface argument object type is valid`() { val kParameter = OneOfArgumentTestClass::nestedOneOfInput.findParameterByName("input")