Skip to content

Commit e2e4120

Browse files
committed
Support option input values in client requests
This commit adds support for optional input values in GraphQL client requests, using `ArgumentValue<T>`. This commit adds new Jackson 3.x and 2.x modules that support the serialisation of `ArgumentValue<T>` in input types. Note that this is not meant to be used on the server side, and deserialization is not supported. Closes gh-1264
1 parent 0fffc84 commit e2e4120

File tree

13 files changed

+711
-7
lines changed

13 files changed

+711
-7
lines changed

spring-graphql-docs/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies {
2727
implementation 'com.querydsl:querydsl-core'
2828
implementation "org.springframework.boot:spring-boot-starter-graphql:${springBootVersion}"
2929
implementation "org.springframework.boot:spring-boot-starter-web:${springBootVersion}"
30+
implementation 'tools.jackson.core:jackson-databind'
3031
implementation 'io.rsocket:rsocket-core'
3132
implementation 'io.rsocket:rsocket-transport-netty'
3233
implementation('com.netflix.graphql.dgs.codegen:graphql-dgs-codegen-shared-core') {

spring-graphql-docs/modules/ROOT/pages/client.adoc

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,49 @@ include-code::UseInterceptor[tag=register,indent=0]
393393

394394

395395

396+
[[client.argumentvalue]]
397+
== Optional input
398+
399+
By default, input types in GraphQL are nullable and optional.
400+
An input value (or any of its fields) can be set to the `null` literal, or not provided at all.
401+
This distinction is useful for partial updates with a mutation where the underlying data may also be,
402+
either set to `null` or not changed at all accordingly.
403+
404+
Similar to the xref:controllers.adoc#controllers.schema-mapping.argument-value[`ArgumentValue<T> support in controllers`],
405+
we can wrap an Input type with `ArgumentValue<T>` or use it at the level of class attributes on the client side.
406+
Given a `ProjectInput` class like:
407+
408+
include-code::ProjectInput[indent=0]
409+
410+
We can use our client to send a mutation request:
411+
412+
include-code::ArgumentValueClient[tag=argumentvalue,indent=0]
413+
<1> we can use `ArgumentValue.omitted()` instead, to ignore this field
414+
415+
For this to work, the client must use Jackson for JSON (de)serialization and must be configured
416+
with the `org.springframework.graphql.client.json.GraphQlJacksonModule`.
417+
This can be registered manually on the underlying HTTP client like so:
418+
419+
include-code::ArgumentValueClient[tag=createclient,indent=0]
420+
421+
This `GraphQlJacksonModule` can be globally registered in Spring Boot applications by contributing it as a bean:
422+
423+
[source,java,indent=0,subs="verbatim,quotes"]
424+
----
425+
@Configuration
426+
public class GraphQlJsonConfiguration {
427+
428+
@Bean
429+
public GraphQlJacksonModule graphQLModule() {
430+
return new GraphQlJacksonModule();
431+
}
432+
433+
}
434+
----
435+
436+
NOTE: Jackson 2.x support is also available with the `GraphQlJackson2Module`.
437+
438+
396439
[[client.dgsgraphqlclient]]
397440
== DGS Codegen
398441

spring-graphql-docs/modules/ROOT/pages/controllers.adoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,9 @@ For example:
440440
method parameter, either initialized via a constructor argument or via a setter, including
441441
as a field of an object nested at any level below the top level object.
442442

443+
This is also supported on the client side with a dedicated Jackson Module,
444+
see the xref:client.adoc#client.argumentvalue[`ArgumentValue` support for clients] section.
445+
443446

444447
[[controllers.schema-mapping.arguments]]
445448
=== `@Arguments`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2020-present the original author or authors.
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 org.springframework.graphql.docs.client.argumentvalue;
18+
19+
import java.util.Map;
20+
21+
import tools.jackson.databind.json.JsonMapper;
22+
23+
import org.springframework.graphql.client.ClientGraphQlResponse;
24+
import org.springframework.graphql.client.HttpGraphQlClient;
25+
import org.springframework.graphql.client.json.GraphQlJacksonModule;
26+
import org.springframework.graphql.data.ArgumentValue;
27+
import org.springframework.http.codec.json.JacksonJsonEncoder;
28+
import org.springframework.web.reactive.function.client.WebClient;
29+
30+
public class ArgumentValueClient {
31+
32+
private final HttpGraphQlClient graphQlClient;
33+
34+
// tag::createclient[]
35+
public ArgumentValueClient(HttpGraphQlClient graphQlClient) {
36+
JsonMapper jsonMapper = JsonMapper.builder().addModule(new GraphQlJacksonModule()).build();
37+
JacksonJsonEncoder jsonEncoder = new JacksonJsonEncoder(jsonMapper);
38+
WebClient webClient = WebClient.builder()
39+
.baseUrl("https://example.com/graphql")
40+
.codecs((codecs) -> codecs.defaultCodecs().jacksonJsonEncoder(jsonEncoder))
41+
.build();
42+
this.graphQlClient = HttpGraphQlClient.create(webClient);
43+
}
44+
// end::createclient[]
45+
46+
// tag::argumentvalue[]
47+
public void updateProject() {
48+
ProjectInput projectInput = new ProjectInput("spring-graphql",
49+
ArgumentValue.ofNullable("Spring for GraphQL")); // <1>
50+
ClientGraphQlResponse response = this.graphQlClient.document("""
51+
mutation updateProject($project: ProjectInput!) {
52+
updateProject($project: $project) {
53+
id
54+
name
55+
}
56+
}
57+
""")
58+
.variables(Map.of("project", projectInput))
59+
.executeSync();
60+
}
61+
// end::argumentvalue[]
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
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 org.springframework.graphql.docs.client.argumentvalue;
18+
19+
20+
import org.springframework.graphql.data.ArgumentValue;
21+
22+
public record ProjectInput(String id, ArgumentValue<String> name) {
23+
24+
}

spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,26 @@
2424
import java.util.function.Consumer;
2525

2626
import org.jspecify.annotations.Nullable;
27+
import tools.jackson.databind.ObjectMapper;
28+
import tools.jackson.databind.json.JsonMapper;
2729

2830
import org.springframework.core.codec.Decoder;
2931
import org.springframework.core.codec.Encoder;
3032
import org.springframework.core.io.ClassPathResource;
33+
import org.springframework.graphql.MediaTypes;
3134
import org.springframework.graphql.client.GraphQlClientInterceptor.Chain;
3235
import org.springframework.graphql.client.GraphQlClientInterceptor.SubscriptionChain;
36+
import org.springframework.graphql.client.json.GraphQlJackson2Module;
37+
import org.springframework.graphql.client.json.GraphQlJacksonModule;
3338
import org.springframework.graphql.support.CachingDocumentSource;
3439
import org.springframework.graphql.support.DocumentSource;
3540
import org.springframework.graphql.support.ResourceDocumentSource;
41+
import org.springframework.http.MediaType;
3642
import org.springframework.http.codec.json.Jackson2JsonDecoder;
3743
import org.springframework.http.codec.json.Jackson2JsonEncoder;
3844
import org.springframework.http.codec.json.JacksonJsonDecoder;
3945
import org.springframework.http.codec.json.JacksonJsonEncoder;
46+
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
4047
import org.springframework.util.Assert;
4148
import org.springframework.util.ClassUtils;
4249

@@ -47,7 +54,7 @@
4754
*
4855
* <p>Subclasses must implement {@link #build()} and call
4956
* {@link #buildGraphQlClient(GraphQlTransport)} to obtain a default, transport
50-
* agnostic {@code GraphQlClient}. A transport specific extension can then wrap
57+
* agnostic {@code GraphQlClient}. A transport-specific extension can then wrap
5158
* this default tester by extending {@link AbstractDelegatingGraphQlClient}.
5259
*
5360
* @param <B> the type of builder
@@ -241,25 +248,31 @@ private Decoder<?> getDecoder() {
241248

242249
protected static class DefaultJacksonCodecs {
243250

251+
private static final ObjectMapper JSON_MAPPER = JsonMapper.builder()
252+
.addModule(new GraphQlJacksonModule()).build();
253+
244254
static Encoder<?> encoder() {
245-
return new JacksonJsonEncoder();
255+
return new JacksonJsonEncoder(JSON_MAPPER, MediaType.APPLICATION_JSON);
246256
}
247257

248258
static Decoder<?> decoder() {
249-
return new JacksonJsonDecoder();
259+
return new JacksonJsonDecoder(JSON_MAPPER, MediaType.APPLICATION_JSON, MediaTypes.APPLICATION_GRAPHQL_RESPONSE);
250260
}
251261

252262
}
253263

254264
@SuppressWarnings("removal")
255265
protected static class DefaultJackson2Codecs {
256266

267+
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
268+
Jackson2ObjectMapperBuilder.json().modulesToInstall(new GraphQlJackson2Module()).build();
269+
257270
static Encoder<?> encoder() {
258-
return new Jackson2JsonEncoder();
271+
return new Jackson2JsonEncoder(JSON_MAPPER, MediaType.APPLICATION_JSON);
259272
}
260273

261274
static Decoder<?> decoder() {
262-
return new Jackson2JsonDecoder();
275+
return new Jackson2JsonDecoder(JSON_MAPPER, MediaType.APPLICATION_JSON, MediaTypes.APPLICATION_GRAPHQL_RESPONSE);
263276
}
264277
}
265278

spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientSyncBuilder.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,20 @@
2525
import org.jspecify.annotations.Nullable;
2626
import reactor.core.scheduler.Scheduler;
2727
import reactor.core.scheduler.Schedulers;
28+
import tools.jackson.databind.json.JsonMapper;
2829

2930
import org.springframework.core.codec.Decoder;
3031
import org.springframework.core.codec.Encoder;
3132
import org.springframework.core.io.ClassPathResource;
3233
import org.springframework.graphql.GraphQlResponse;
3334
import org.springframework.graphql.client.SyncGraphQlClientInterceptor.Chain;
35+
import org.springframework.graphql.client.json.GraphQlJackson2Module;
36+
import org.springframework.graphql.client.json.GraphQlJacksonModule;
3437
import org.springframework.graphql.support.CachingDocumentSource;
3538
import org.springframework.graphql.support.DocumentSource;
3639
import org.springframework.graphql.support.ResourceDocumentSource;
3740
import org.springframework.http.converter.HttpMessageConverter;
41+
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
3842
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
3943
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
4044
import org.springframework.util.Assert;
@@ -197,15 +201,18 @@ private HttpMessageConverter<Object> getJsonConverter() {
197201
private static final class DefaultJacksonConverter {
198202

199203
static HttpMessageConverter<Object> initialize() {
200-
return new JacksonJsonHttpMessageConverter();
204+
JsonMapper jsonMapper = JsonMapper.builder().addModule(new GraphQlJacksonModule()).build();
205+
return new JacksonJsonHttpMessageConverter(jsonMapper);
201206
}
202207
}
203208

204209
@SuppressWarnings("removal")
205210
private static final class DefaultJackson2Converter {
206211

207212
static HttpMessageConverter<Object> initialize() {
208-
return new MappingJackson2HttpMessageConverter();
213+
com.fasterxml.jackson.databind.ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
214+
.modulesToInstall(new GraphQlJackson2Module()).build();
215+
return new MappingJackson2HttpMessageConverter(objectMapper);
209216
}
210217
}
211218

0 commit comments

Comments
 (0)