Skip to content

Commit 681dca9

Browse files
samuelAndalonSamuel Vazquez
andauthored
feat(10.x.x): @OneOf input directive support (#2193)
### 📝 Description cherry pick #2183 Co-authored-by: Samuel Vazquez <samvazquez@expediagroup.com>
1 parent 3d83250 commit 681dca9

18 files changed

Lines changed: 1929 additions & 15 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* Copyright 2026 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.examples.server.spring.query
18+
19+
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
20+
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
21+
import com.expediagroup.graphql.generator.annotations.GraphQLOneOf
22+
import com.expediagroup.graphql.generator.annotations.GraphQLOneOfField
23+
import com.expediagroup.graphql.generator.annotations.GraphQLOneOfFieldType
24+
import com.expediagroup.graphql.generator.scalars.ID
25+
import com.expediagroup.graphql.server.operations.Query
26+
import org.springframework.stereotype.Component
27+
28+
@Component
29+
class OneOfQuery : Query {
30+
31+
@GraphQLDescription("Describes a content block supplied as a @oneOf input.")
32+
fun describeContentBlock(input: ContentBlockInput): String = when (input) {
33+
is ContentBlockInput.Paragraph -> "paragraph: ${input.text}"
34+
is ContentBlockInput.BlockQuote -> buildString {
35+
append("blockquote: ${input.value}")
36+
input.attribution?.let { append(" ($it)") }
37+
input.attributionUrl?.let { append(" <$it>") }
38+
}
39+
is ContentBlockInput.Image -> "image: ${input.altText} at ${input.url}"
40+
}
41+
42+
@GraphQLDescription("Describes each content block supplied as a list of @oneOf inputs.")
43+
fun describeContentBlocks(input: List<ContentBlockInput>): List<String> =
44+
input.map { contentBlock -> describeContentBlock(contentBlock) }
45+
46+
@GraphQLDescription("Describes how a user would be looked up from a scalar or object @oneOf input.")
47+
fun findUserBy(input: UserLookupInput): String = when (input) {
48+
is UserLookupInput.ById -> "user id=${input.id.value}"
49+
is UserLookupInput.ByEmail -> "user email=${input.email}"
50+
is UserLookupInput.ByCriteria -> "user criteria name=${input.name} address=${input.address}"
51+
}
52+
53+
@GraphQLDescription("Describes a nested @oneOf lookup for either a user or an organization.")
54+
fun resolveEntity(input: EntityLookupInput): String = when (input) {
55+
is EntityLookupInput.User -> "user ${describeUserLookup(input.lookup)}"
56+
is EntityLookupInput.Organization -> "organization ${describeOrganizationLookup(input.lookup)}"
57+
}
58+
59+
private fun describeUserLookup(input: UserLookupInput): String = when (input) {
60+
is UserLookupInput.ById -> "id=${input.id.value}"
61+
is UserLookupInput.ByEmail -> "email=${input.email}"
62+
is UserLookupInput.ByCriteria -> "criteria name=${input.name} address=${input.address}"
63+
}
64+
65+
private fun describeOrganizationLookup(input: OrganizationLookupInput): String = when (input) {
66+
is OrganizationLookupInput.ById -> "id=${input.id.value}"
67+
is OrganizationLookupInput.BySlug -> "slug=${input.slug}"
68+
}
69+
}
70+
71+
@GraphQLDescription("A content block input where exactly one block shape must be supplied.")
72+
@GraphQLOneOf
73+
sealed interface ContentBlockInput {
74+
75+
@GraphQLDescription("Paragraph content supplied as a wrapped @oneOf object field.")
76+
@GraphQLOneOfField("paragraph")
77+
@GraphQLDeprecated("This paragraph field is deprecated.")
78+
data class Paragraph(
79+
@param:GraphQLDescription("The paragraph text.")
80+
val text: String
81+
) : ContentBlockInput
82+
83+
@GraphQLDescription("Quoted content supplied as a wrapped @oneOf object field.")
84+
@GraphQLOneOfField("blockquote")
85+
data class BlockQuote(
86+
@param:GraphQLDescription("The quoted text.")
87+
val value: String,
88+
@param:GraphQLDescription("The optional source of the quote.")
89+
val attribution: String?,
90+
@param:GraphQLDescription("The optional URL for the quote source.")
91+
val attributionUrl: String?
92+
) : ContentBlockInput
93+
94+
@GraphQLDescription("Image content supplied as a wrapped @oneOf object field.")
95+
@GraphQLOneOfField("image")
96+
data class Image(
97+
@param:GraphQLDescription("The image URL.")
98+
val url: String,
99+
@param:GraphQLDescription("The image alt text.")
100+
val altText: String
101+
) : ContentBlockInput
102+
}
103+
104+
@GraphQLDescription("A user lookup input where exactly one lookup strategy must be supplied.")
105+
@GraphQLOneOf
106+
sealed interface UserLookupInput {
107+
108+
@GraphQLDescription("Lookup a user by ID using an unwrapped @oneOf scalar field.")
109+
@GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED)
110+
data class ById(
111+
@param:GraphQLDescription("The user ID.")
112+
@param:GraphQLDeprecated("lookup by ID is deprecated.")
113+
val id: ID
114+
) : UserLookupInput
115+
116+
@GraphQLDescription("Lookup a user by email using an unwrapped @oneOf scalar field.")
117+
@GraphQLOneOfField("email", GraphQLOneOfFieldType.UNWRAPPED)
118+
data class ByEmail(
119+
@param:GraphQLDescription("The user's email address.")
120+
val email: String
121+
) : UserLookupInput
122+
123+
@GraphQLDescription("Lookup a user by criteria using a wrapped @oneOf object field.")
124+
@GraphQLOneOfField("criteria")
125+
data class ByCriteria(
126+
@param:GraphQLDescription("The optional display name to match.")
127+
val name: String?,
128+
@param:GraphQLDescription("The optional mailing address to match.")
129+
val address: String?
130+
) : UserLookupInput
131+
}
132+
133+
@GraphQLDescription("An entity lookup input where exactly one entity type must be supplied.")
134+
@GraphQLOneOf
135+
sealed interface EntityLookupInput {
136+
137+
@GraphQLDescription("Lookup a user using a nested @oneOf selector.")
138+
@GraphQLOneOfField("user", GraphQLOneOfFieldType.UNWRAPPED)
139+
data class User(
140+
@param:GraphQLDescription("The nested user lookup selector.")
141+
val lookup: UserLookupInput
142+
) : EntityLookupInput
143+
144+
@GraphQLDescription("Lookup an organization using a nested @oneOf selector.")
145+
@GraphQLOneOfField("organization", GraphQLOneOfFieldType.UNWRAPPED)
146+
data class Organization(
147+
@param:GraphQLDescription("The nested organization lookup selector.")
148+
val lookup: OrganizationLookupInput
149+
) : EntityLookupInput
150+
}
151+
152+
@GraphQLDescription("An organization lookup input where exactly one lookup strategy must be supplied.")
153+
@GraphQLOneOf
154+
sealed interface OrganizationLookupInput {
155+
156+
@GraphQLDescription("Lookup an organization by ID using an unwrapped @oneOf scalar field.")
157+
@GraphQLOneOfField("id", GraphQLOneOfFieldType.UNWRAPPED)
158+
data class ById(
159+
@param:GraphQLDescription("The organization ID.")
160+
val id: ID
161+
) : OrganizationLookupInput
162+
163+
@GraphQLDescription("Lookup an organization by slug using an unwrapped @oneOf scalar field.")
164+
@GraphQLOneOfField("slug", GraphQLOneOfFieldType.UNWRAPPED)
165+
data class BySlug(
166+
@param:GraphQLDescription("The organization slug.")
167+
val slug: String
168+
) : OrganizationLookupInput
169+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Copyright 2026 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.examples.server.spring.query
18+
19+
import com.expediagroup.graphql.examples.server.spring.DATA_JSON_PATH
20+
import com.expediagroup.graphql.examples.server.spring.GRAPHQL_ENDPOINT
21+
import com.expediagroup.graphql.examples.server.spring.GRAPHQL_MEDIA_TYPE
22+
import com.expediagroup.graphql.examples.server.spring.verifyData
23+
import com.expediagroup.graphql.examples.server.spring.verifyError
24+
import com.expediagroup.graphql.examples.server.spring.verifyOnlyDataExists
25+
import org.junit.jupiter.api.BeforeEach
26+
import org.junit.jupiter.api.Test
27+
import org.junit.jupiter.api.TestInstance
28+
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
29+
import org.springframework.beans.factory.annotation.Autowired
30+
import org.springframework.boot.test.context.SpringBootTest
31+
import org.springframework.context.ApplicationContext
32+
import org.springframework.http.MediaType.APPLICATION_JSON
33+
import org.springframework.test.web.reactive.server.WebTestClient
34+
35+
@SpringBootTest
36+
@TestInstance(PER_CLASS)
37+
class OneOfQueryIT {
38+
39+
private lateinit var testClient: WebTestClient
40+
41+
@BeforeEach
42+
fun setup(@Autowired context: ApplicationContext) {
43+
testClient = WebTestClient.bindToApplicationContext(context).build()
44+
}
45+
46+
@Test
47+
fun `verify describeContentBlock query with wrapped object input`() {
48+
val query = "describeContentBlock"
49+
val expectedData = "paragraph: Hello @oneOf"
50+
51+
testClient.post()
52+
.uri(GRAPHQL_ENDPOINT)
53+
.accept(APPLICATION_JSON)
54+
.contentType(GRAPHQL_MEDIA_TYPE)
55+
.bodyValue("query { $query(input: { paragraph: { text: \"Hello @oneOf\" } }) }")
56+
.exchange()
57+
.verifyData(query, expectedData)
58+
}
59+
60+
@Test
61+
fun `verify describeContentBlocks query with list of oneOf inputs`() {
62+
val query = "describeContentBlocks"
63+
64+
testClient.post()
65+
.uri(GRAPHQL_ENDPOINT)
66+
.accept(APPLICATION_JSON)
67+
.contentType(GRAPHQL_MEDIA_TYPE)
68+
.bodyValue(
69+
"""
70+
query {
71+
$query(input: [
72+
{ paragraph: { text: "Hello" } },
73+
{ image: { url: "https://example.com/logo.png", altText: "Logo" } }
74+
])
75+
}
76+
""".trimIndent()
77+
)
78+
.exchange()
79+
.verifyOnlyDataExists(query)
80+
.jsonPath("$DATA_JSON_PATH.$query[0]").isEqualTo("paragraph: Hello")
81+
.jsonPath("$DATA_JSON_PATH.$query[1]").isEqualTo("image: Logo at https://example.com/logo.png")
82+
}
83+
84+
@Test
85+
fun `verify findUserBy query with unwrapped scalar input`() {
86+
val query = "findUserBy"
87+
val expectedData = "user id=user-123"
88+
89+
testClient.post()
90+
.uri(GRAPHQL_ENDPOINT)
91+
.accept(APPLICATION_JSON)
92+
.contentType(GRAPHQL_MEDIA_TYPE)
93+
.bodyValue("query { $query(input: { id: \"user-123\" }) }")
94+
.exchange()
95+
.verifyData(query, expectedData)
96+
}
97+
98+
@Test
99+
fun `verify findUserBy query with wrapped object input`() {
100+
val query = "findUserBy"
101+
val expectedData = "user criteria name=Sam address=Seattle"
102+
103+
testClient.post()
104+
.uri(GRAPHQL_ENDPOINT)
105+
.accept(APPLICATION_JSON)
106+
.contentType(GRAPHQL_MEDIA_TYPE)
107+
.bodyValue("query { $query(input: { criteria: { name: \"Sam\", address: \"Seattle\" } }) }")
108+
.exchange()
109+
.verifyData(query, expectedData)
110+
}
111+
112+
@Test
113+
fun `verify resolveEntity query with nested oneOf input`() {
114+
val query = "resolveEntity"
115+
val expectedData = "organization slug=expedia"
116+
117+
testClient.post()
118+
.uri(GRAPHQL_ENDPOINT)
119+
.accept(APPLICATION_JSON)
120+
.contentType(GRAPHQL_MEDIA_TYPE)
121+
.bodyValue("query { $query(input: { organization: { slug: \"expedia\" } }) }")
122+
.exchange()
123+
.verifyData(query, expectedData)
124+
}
125+
126+
@Test
127+
fun `verify oneOf input rejects multiple fields`() {
128+
val query = "describeContentBlock"
129+
val expectedError = "Exactly one key must be specified for OneOf type 'ContentBlockInput'."
130+
131+
testClient.post()
132+
.uri(GRAPHQL_ENDPOINT)
133+
.accept(APPLICATION_JSON)
134+
.contentType(GRAPHQL_MEDIA_TYPE)
135+
.bodyValue(
136+
"""
137+
query {
138+
$query(input: {
139+
paragraph: { text: "Hello" },
140+
image: { url: "https://example.com/logo.png", altText: "Logo" }
141+
})
142+
}
143+
""".trimIndent()
144+
)
145+
.exchange()
146+
.verifyError(expectedError)
147+
}
148+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2026 Expedia, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.expediagroup.graphql.generator.annotations
18+
19+
@Target(AnnotationTarget.CLASS)
20+
@Retention(AnnotationRetention.RUNTIME)
21+
/**
22+
* `@oneOf` inputs allow exactly one non-null field to be supplied
23+
*/
24+
annotation class GraphQLOneOf

0 commit comments

Comments
 (0)