Skip to content

Commit 70f16c1

Browse files
committed
Configure JSON GraphQlModule in client support
The various GraphQL clients supported in this project automatically detect JSON codecs for reading/writing GraphQL requests as JSON payloads. If there is none detected, clients provide a default codec instance. This commit configures automatically the `GraphQlModule` from gh-1174 in default codecs and add integration tests for `FieldValue<T>` usage on the client side. This also improves the documentation around `FieldValue<T>` for both server and client side support. Closes gh-1190
1 parent 1ec45c6 commit 70f16c1

File tree

9 files changed

+271
-18
lines changed

9 files changed

+271
-18
lines changed

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

+24-7
Original file line numberDiff line numberDiff line change
@@ -393,19 +393,36 @@ include-code::UseInterceptor[tag=register,indent=0]
393393

394394

395395

396-
[[client.argument-value]
397-
== Argument Value
396+
[[client.fieldvalue]]
397+
== `FieldValue`
398398

399-
If you want to use `ArgumentValue` from a client or test, you can register the
400-
`GraphQLModule` in Jackson which can serialize/deserialize the value depending on
401-
the state.
399+
By default, input types in GraphQL are nullable and optional, an input value (or any of its fields)
400+
can be set to the `null` literal, or not provided at all. This distinction is useful for
401+
partial updates with a mutation where the underlying data may also be, either set to
402+
`null` or not changed at all accordingly.
402403

403-
For example:
404+
Similar to the xref:controllers.adoc#controllers.schema-mapping.fieldvalue[`FieldValue<T> support in controllers`],
405+
we can wrap an Input type with `FieldValue<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::FieldValueClient[tag=fieldvalue,indent=0]
413+
414+
For this to work, the client must use Jackson for JSON (de)serialization and must be configured
415+
with the `org.springframework.graphql.client.json.GraphQlModule`.
416+
This can be registered manually on the underlying HTTP client like so:
417+
418+
include-code::FieldValueClient[tag=createclient,indent=0]
419+
420+
This `GraphQlModule` can be globally registered in Spring Boot applications by contributing it as a bean:
404421

405422
[source,java,indent=0,subs="verbatim,quotes"]
406423
----
407424
@Configuration
408-
public class MyConfiguration {
425+
public class GraphQlJsonConfiguration {
409426
410427
@Bean
411428
public GraphQLModule graphQLModule() {

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

+17-6
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ specified in the annotation, or to the parameter name. For access to the full ar
403403
map, please use xref:controllers.adoc#controllers.schema-mapping.arguments[`@Arguments`] instead.
404404

405405

406-
[[controllers.schema-mapping.field-value]]
406+
[[controllers.schema-mapping.fieldvalue]]
407407
=== `FieldValue`
408408

409409
By default, input arguments in GraphQL are nullable and optional, which means an argument
@@ -426,20 +426,31 @@ For example:
426426
@Controller
427427
public class BookController {
428428
429-
@MutationMapping
430-
public void addBook(FieldValue<BookInput> bookInput) {
431-
if (!bookInput.isOmitted()) {
432-
BookInput value = bookInput.value();
433-
// ...
429+
@QueryMapping
430+
public List<Book> searchBook(@Argument String search, FieldValue<Genre> genre) {
431+
if (!genre.isOmitted()) {
432+
// genre has been set but might hold a "null" value
433+
Genre genreValue = genre.value();
434434
}
435435
}
436+
437+
@MutationMapping
438+
public void addBook(@Argument BookInput bookInput) {
439+
FieldValue<String> genre = bookInput.genre();
440+
genre.ifPresent(genre -> {
441+
//...
442+
});
443+
}
436444
}
437445
----
438446

439447
`FieldValue` is also supported as a field within the object structure of an `@Argument`
440448
method parameter, either initialized via a constructor argument or via a setter, including
441449
as a field of an object nested at any level below the top level object.
442450

451+
This is also supported on the client side with a dedicated Jackson Module,
452+
see the xref:client.adoc#client.fieldvalue[`FieldValue` support for clients] section.
453+
443454

444455
[[controllers.schema-mapping.arguments]]
445456
=== `@Arguments`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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.fieldvalue;
18+
19+
import java.util.Map;
20+
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
23+
import org.springframework.graphql.FieldValue;
24+
import org.springframework.graphql.client.ClientGraphQlResponse;
25+
import org.springframework.graphql.client.HttpGraphQlClient;
26+
import org.springframework.graphql.client.json.GraphQlModule;
27+
import org.springframework.http.MediaType;
28+
import org.springframework.http.codec.json.Jackson2JsonEncoder;
29+
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
30+
import org.springframework.web.reactive.function.client.WebClient;
31+
32+
public class FieldValueClient {
33+
34+
private final HttpGraphQlClient graphQlClient;
35+
36+
// tag::createclient[]
37+
public FieldValueClient(HttpGraphQlClient graphQlClient) {
38+
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
39+
.modulesToInstall(new GraphQlModule())
40+
.build();
41+
Jackson2JsonEncoder jsonEncoder = new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON);
42+
WebClient webClient = WebClient.builder()
43+
.baseUrl("https://example.com/graphql")
44+
.codecs((codecs) -> codecs.defaultCodecs().jackson2JsonEncoder(jsonEncoder))
45+
.build();
46+
this.graphQlClient = HttpGraphQlClient.create(webClient);
47+
}
48+
// end::createclient[]
49+
50+
// tag::fieldvalue[]
51+
public void updateProject() {
52+
ProjectInput projectInput = new ProjectInput("spring-graphql",
53+
FieldValue.ofNullable("Spring for GraphQL"));
54+
ClientGraphQlResponse response = this.graphQlClient.document("""
55+
mutation updateProject($project: ProjectInput!) {
56+
updateProject($project: $project) {
57+
id
58+
name
59+
}
60+
}
61+
""")
62+
.variables(Map.of("project", projectInput))
63+
.executeSync();
64+
}
65+
// end::fieldvalue[]
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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.fieldvalue;
18+
19+
import org.springframework.graphql.FieldValue;
20+
21+
public record ProjectInput(String id, FieldValue<String> name) {
22+
23+
}

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

+12-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -23,16 +23,22 @@
2323
import java.util.List;
2424
import java.util.function.Consumer;
2525

26+
import com.fasterxml.jackson.databind.ObjectMapper;
27+
2628
import org.springframework.core.codec.Decoder;
2729
import org.springframework.core.codec.Encoder;
2830
import org.springframework.core.io.ClassPathResource;
31+
import org.springframework.graphql.MediaTypes;
2932
import org.springframework.graphql.client.GraphQlClientInterceptor.Chain;
3033
import org.springframework.graphql.client.GraphQlClientInterceptor.SubscriptionChain;
34+
import org.springframework.graphql.client.json.GraphQlModule;
3135
import org.springframework.graphql.support.CachingDocumentSource;
3236
import org.springframework.graphql.support.DocumentSource;
3337
import org.springframework.graphql.support.ResourceDocumentSource;
38+
import org.springframework.http.MediaType;
3439
import org.springframework.http.codec.json.Jackson2JsonDecoder;
3540
import org.springframework.http.codec.json.Jackson2JsonEncoder;
41+
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
3642
import org.springframework.lang.Nullable;
3743
import org.springframework.util.Assert;
3844
import org.springframework.util.ClassUtils;
@@ -235,12 +241,15 @@ private Decoder<?> getDecoder() {
235241

236242
protected static class DefaultJackson2Codecs {
237243

244+
private static final ObjectMapper JSON_MAPPER = Jackson2ObjectMapperBuilder.json()
245+
.modulesToInstall(new GraphQlModule()).build();
246+
238247
static Encoder<?> encoder() {
239-
return new Jackson2JsonEncoder();
248+
return new Jackson2JsonEncoder(JSON_MAPPER, MediaType.APPLICATION_JSON);
240249
}
241250

242251
static Decoder<?> decoder() {
243-
return new Jackson2JsonDecoder();
252+
return new Jackson2JsonDecoder(JSON_MAPPER, MediaType.APPLICATION_JSON, MediaTypes.APPLICATION_GRAPHQL_RESPONSE);
244253
}
245254

246255
}

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

+6-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.List;
2323
import java.util.function.Consumer;
2424

25+
import com.fasterxml.jackson.databind.ObjectMapper;
2526
import reactor.core.scheduler.Scheduler;
2627
import reactor.core.scheduler.Schedulers;
2728

@@ -30,10 +31,12 @@
3031
import org.springframework.core.io.ClassPathResource;
3132
import org.springframework.graphql.GraphQlResponse;
3233
import org.springframework.graphql.client.SyncGraphQlClientInterceptor.Chain;
34+
import org.springframework.graphql.client.json.GraphQlModule;
3335
import org.springframework.graphql.support.CachingDocumentSource;
3436
import org.springframework.graphql.support.DocumentSource;
3537
import org.springframework.graphql.support.ResourceDocumentSource;
3638
import org.springframework.http.converter.HttpMessageConverter;
39+
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
3740
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
3841
import org.springframework.lang.Nullable;
3942
import org.springframework.util.Assert;
@@ -191,7 +194,9 @@ private HttpMessageConverter<Object> getJsonConverter() {
191194
private static final class DefaultJacksonConverter {
192195

193196
static HttpMessageConverter<Object> initialize() {
194-
return new MappingJackson2HttpMessageConverter();
197+
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
198+
.modulesToInstall(new GraphQlModule()).build();
199+
return new MappingJackson2HttpMessageConverter(objectMapper);
195200
}
196201
}
197202

spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java

+40
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.junit.jupiter.api.Test;
3232

3333
import org.springframework.core.ParameterizedTypeReference;
34+
import org.springframework.graphql.FieldValue;
3435
import org.springframework.graphql.GraphQlRequest;
3536
import org.springframework.graphql.support.DefaultGraphQlRequest;
3637

@@ -58,6 +59,45 @@ void retrieveEntity() {
5859
assertThat(movieCharacter).isEqualTo(MovieCharacter.create("Luke Skywalker"));
5960
}
6061

62+
@Test
63+
void retrieveEntityFieldValuePresent() {
64+
65+
String document = "mockRequest1";
66+
getGraphQlService().setDataAsJson(document, "{\"current\": {\"id\":\"spring-graphql\", \"name\":\"Spring for GraphQL\"}}");
67+
68+
Project currentProject = graphQlClient().document(document)
69+
.retrieve("current").toEntity(Project.class)
70+
.block(TIMEOUT);
71+
72+
assertThat(currentProject).isEqualTo(new Project("spring-graphql", FieldValue.ofNullable("Spring for GraphQL")));
73+
}
74+
75+
@Test
76+
void retrieveEntityOmittedField() {
77+
78+
String document = "mockRequest1";
79+
getGraphQlService().setDataAsJson(document, "{\"current\": {\"id\":\"spring-graphql\"}}");
80+
81+
Project currentProject = graphQlClient().document(document)
82+
.retrieve("current").toEntity(Project.class)
83+
.block(TIMEOUT);
84+
85+
assertThat(currentProject).isEqualTo(new Project("spring-graphql", FieldValue.omitted()));
86+
}
87+
88+
@Test
89+
void retrieveEntityNullField() {
90+
91+
String document = "mockRequest1";
92+
getGraphQlService().setDataAsJson(document, "{\"current\": {\"id\":\"spring-graphql\", \"name\": null}}");
93+
94+
Project currentProject = graphQlClient().document(document)
95+
.retrieve("current").toEntity(Project.class)
96+
.block(TIMEOUT);
97+
98+
assertThat(currentProject).isEqualTo(new Project("spring-graphql", FieldValue.ofNullable(null)));
99+
}
100+
61101
@Test
62102
void retrieveEntityList() {
63103

0 commit comments

Comments
 (0)