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
This commit is contained in:
@@ -393,19 +393,36 @@ include-code::UseInterceptor[tag=register,indent=0]
|
||||
|
||||
|
||||
|
||||
[[client.argument-value]
|
||||
== Argument Value
|
||||
[[client.fieldvalue]]
|
||||
== `FieldValue`
|
||||
|
||||
If you want to use `ArgumentValue` from a client or test, you can register the
|
||||
`GraphQLModule` in Jackson which can serialize/deserialize the value depending on
|
||||
the state.
|
||||
By default, input types in GraphQL are nullable and optional, an input value (or any of its fields)
|
||||
can be set to the `null` literal, or not provided at all. This distinction is useful for
|
||||
partial updates with a mutation where the underlying data may also be, either set to
|
||||
`null` or not changed at all accordingly.
|
||||
|
||||
For example:
|
||||
Similar to the xref:controllers.adoc#controllers.schema-mapping.fieldvalue[`FieldValue<T> support in controllers`],
|
||||
we can wrap an Input type with `FieldValue<T>` or use it at the level of class attributes on the client side.
|
||||
Given a `ProjectInput` class like:
|
||||
|
||||
include-code::ProjectInput[indent=0]
|
||||
|
||||
We can use our client to send a mutation request:
|
||||
|
||||
include-code::FieldValueClient[tag=fieldvalue,indent=0]
|
||||
|
||||
For this to work, the client must use Jackson for JSON (de)serialization and must be configured
|
||||
with the `org.springframework.graphql.client.json.GraphQlModule`.
|
||||
This can be registered manually on the underlying HTTP client like so:
|
||||
|
||||
include-code::FieldValueClient[tag=createclient,indent=0]
|
||||
|
||||
This `GraphQlModule` can be globally registered in Spring Boot applications by contributing it as a bean:
|
||||
|
||||
[source,java,indent=0,subs="verbatim,quotes"]
|
||||
----
|
||||
@Configuration
|
||||
public class MyConfiguration {
|
||||
public class GraphQlJsonConfiguration {
|
||||
|
||||
@Bean
|
||||
public GraphQLModule graphQLModule() {
|
||||
|
||||
@@ -403,7 +403,7 @@ specified in the annotation, or to the parameter name. For access to the full ar
|
||||
map, please use xref:controllers.adoc#controllers.schema-mapping.arguments[`@Arguments`] instead.
|
||||
|
||||
|
||||
[[controllers.schema-mapping.field-value]]
|
||||
[[controllers.schema-mapping.fieldvalue]]
|
||||
=== `FieldValue`
|
||||
|
||||
By default, input arguments in GraphQL are nullable and optional, which means an argument
|
||||
@@ -426,13 +426,21 @@ For example:
|
||||
@Controller
|
||||
public class BookController {
|
||||
|
||||
@MutationMapping
|
||||
public void addBook(FieldValue<BookInput> bookInput) {
|
||||
if (!bookInput.isOmitted()) {
|
||||
BookInput value = bookInput.value();
|
||||
// ...
|
||||
@QueryMapping
|
||||
public List<Book> searchBook(@Argument String search, FieldValue<Genre> genre) {
|
||||
if (!genre.isOmitted()) {
|
||||
// genre has been set but might hold a "null" value
|
||||
Genre genreValue = genre.value();
|
||||
}
|
||||
}
|
||||
|
||||
@MutationMapping
|
||||
public void addBook(@Argument BookInput bookInput) {
|
||||
FieldValue<String> genre = bookInput.genre();
|
||||
genre.ifPresent(genre -> {
|
||||
//...
|
||||
});
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
@@ -440,6 +448,9 @@ For example:
|
||||
method parameter, either initialized via a constructor argument or via a setter, including
|
||||
as a field of an object nested at any level below the top level object.
|
||||
|
||||
This is also supported on the client side with a dedicated Jackson Module,
|
||||
see the xref:client.adoc#client.fieldvalue[`FieldValue` support for clients] section.
|
||||
|
||||
|
||||
[[controllers.schema-mapping.arguments]]
|
||||
=== `@Arguments`
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2020-2025 the original author or authors.
|
||||
*
|
||||
* 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 org.springframework.graphql.docs.client.fieldvalue;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.springframework.graphql.FieldValue;
|
||||
import org.springframework.graphql.client.ClientGraphQlResponse;
|
||||
import org.springframework.graphql.client.HttpGraphQlClient;
|
||||
import org.springframework.graphql.client.json.GraphQlModule;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
public class FieldValueClient {
|
||||
|
||||
private final HttpGraphQlClient graphQlClient;
|
||||
|
||||
// tag::createclient[]
|
||||
public FieldValueClient(HttpGraphQlClient graphQlClient) {
|
||||
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
|
||||
.modulesToInstall(new GraphQlModule())
|
||||
.build();
|
||||
Jackson2JsonEncoder jsonEncoder = new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON);
|
||||
WebClient webClient = WebClient.builder()
|
||||
.baseUrl("https://example.com/graphql")
|
||||
.codecs((codecs) -> codecs.defaultCodecs().jackson2JsonEncoder(jsonEncoder))
|
||||
.build();
|
||||
this.graphQlClient = HttpGraphQlClient.create(webClient);
|
||||
}
|
||||
// end::createclient[]
|
||||
|
||||
// tag::fieldvalue[]
|
||||
public void updateProject() {
|
||||
ProjectInput projectInput = new ProjectInput("spring-graphql",
|
||||
FieldValue.ofNullable("Spring for GraphQL"));
|
||||
ClientGraphQlResponse response = this.graphQlClient.document("""
|
||||
mutation updateProject($project: ProjectInput!) {
|
||||
updateProject($project: $project) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
""")
|
||||
.variables(Map.of("project", projectInput))
|
||||
.executeSync();
|
||||
}
|
||||
// end::fieldvalue[]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2020-2025 the original author or authors.
|
||||
*
|
||||
* 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 org.springframework.graphql.docs.client.fieldvalue;
|
||||
|
||||
import org.springframework.graphql.FieldValue;
|
||||
|
||||
public record ProjectInput(String id, FieldValue<String> name) {
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2002-2024 the original author or authors.
|
||||
* Copyright 2002-2025 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -23,16 +23,22 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.springframework.core.codec.Decoder;
|
||||
import org.springframework.core.codec.Encoder;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.graphql.MediaTypes;
|
||||
import org.springframework.graphql.client.GraphQlClientInterceptor.Chain;
|
||||
import org.springframework.graphql.client.GraphQlClientInterceptor.SubscriptionChain;
|
||||
import org.springframework.graphql.client.json.GraphQlModule;
|
||||
import org.springframework.graphql.support.CachingDocumentSource;
|
||||
import org.springframework.graphql.support.DocumentSource;
|
||||
import org.springframework.graphql.support.ResourceDocumentSource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.json.Jackson2JsonDecoder;
|
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
@@ -235,12 +241,15 @@ public abstract class AbstractGraphQlClientBuilder<B extends AbstractGraphQlClie
|
||||
|
||||
protected static class DefaultJackson2Codecs {
|
||||
|
||||
private static final ObjectMapper JSON_MAPPER = Jackson2ObjectMapperBuilder.json()
|
||||
.modulesToInstall(new GraphQlModule()).build();
|
||||
|
||||
static Encoder<?> encoder() {
|
||||
return new Jackson2JsonEncoder();
|
||||
return new Jackson2JsonEncoder(JSON_MAPPER, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
static Decoder<?> decoder() {
|
||||
return new Jackson2JsonDecoder();
|
||||
return new Jackson2JsonDecoder(JSON_MAPPER, MediaType.APPLICATION_JSON, MediaTypes.APPLICATION_GRAPHQL_RESPONSE);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
@@ -30,10 +31,12 @@ import org.springframework.core.codec.Encoder;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.graphql.GraphQlResponse;
|
||||
import org.springframework.graphql.client.SyncGraphQlClientInterceptor.Chain;
|
||||
import org.springframework.graphql.client.json.GraphQlModule;
|
||||
import org.springframework.graphql.support.CachingDocumentSource;
|
||||
import org.springframework.graphql.support.DocumentSource;
|
||||
import org.springframework.graphql.support.ResourceDocumentSource;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
@@ -191,7 +194,9 @@ public abstract class AbstractGraphQlClientSyncBuilder<B extends AbstractGraphQl
|
||||
private static final class DefaultJacksonConverter {
|
||||
|
||||
static HttpMessageConverter<Object> initialize() {
|
||||
return new MappingJackson2HttpMessageConverter();
|
||||
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
|
||||
.modulesToInstall(new GraphQlModule()).build();
|
||||
return new MappingJackson2HttpMessageConverter(objectMapper);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import graphql.validation.ValidationErrorType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.graphql.FieldValue;
|
||||
import org.springframework.graphql.GraphQlRequest;
|
||||
import org.springframework.graphql.support.DefaultGraphQlRequest;
|
||||
|
||||
@@ -58,6 +59,45 @@ class GraphQlClientTests extends GraphQlClientTestSupport {
|
||||
assertThat(movieCharacter).isEqualTo(MovieCharacter.create("Luke Skywalker"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retrieveEntityFieldValuePresent() {
|
||||
|
||||
String document = "mockRequest1";
|
||||
getGraphQlService().setDataAsJson(document, "{\"current\": {\"id\":\"spring-graphql\", \"name\":\"Spring for GraphQL\"}}");
|
||||
|
||||
Project currentProject = graphQlClient().document(document)
|
||||
.retrieve("current").toEntity(Project.class)
|
||||
.block(TIMEOUT);
|
||||
|
||||
assertThat(currentProject).isEqualTo(new Project("spring-graphql", FieldValue.ofNullable("Spring for GraphQL")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retrieveEntityOmittedField() {
|
||||
|
||||
String document = "mockRequest1";
|
||||
getGraphQlService().setDataAsJson(document, "{\"current\": {\"id\":\"spring-graphql\"}}");
|
||||
|
||||
Project currentProject = graphQlClient().document(document)
|
||||
.retrieve("current").toEntity(Project.class)
|
||||
.block(TIMEOUT);
|
||||
|
||||
assertThat(currentProject).isEqualTo(new Project("spring-graphql", FieldValue.omitted()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retrieveEntityNullField() {
|
||||
|
||||
String document = "mockRequest1";
|
||||
getGraphQlService().setDataAsJson(document, "{\"current\": {\"id\":\"spring-graphql\", \"name\": null}}");
|
||||
|
||||
Project currentProject = graphQlClient().document(document)
|
||||
.retrieve("current").toEntity(Project.class)
|
||||
.block(TIMEOUT);
|
||||
|
||||
assertThat(currentProject).isEqualTo(new Project("spring-graphql", FieldValue.ofNullable(null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retrieveEntityList() {
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2024 the original author or authors.
|
||||
* Copyright 2020-2025 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -17,16 +17,28 @@
|
||||
package org.springframework.graphql.client;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import org.springframework.graphql.Book;
|
||||
import org.springframework.graphql.FieldValue;
|
||||
import org.springframework.graphql.MediaTypes;
|
||||
import org.springframework.graphql.MockWebServerExtension;
|
||||
import org.springframework.graphql.client.json.GraphQlModule;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -39,6 +51,49 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
@ExtendWith(MockWebServerExtension.class)
|
||||
class HttpGraphQlTransportIntegrationTests {
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("fieldValues")
|
||||
void shouldSerializeFieldValues(ProjectInput projectInput, String variable, MockWebServer server) throws Exception {
|
||||
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().modules(new GraphQlModule()).build();
|
||||
Jackson2JsonEncoder jsonEncoder = new Jackson2JsonEncoder(objectMapper, MediaType.APPLICATION_JSON);
|
||||
WebClient webClient = WebClient.builder()
|
||||
.baseUrl(server.url("/graphql").toString())
|
||||
.codecs(codecs -> codecs.defaultCodecs().jackson2JsonEncoder(jsonEncoder))
|
||||
.build();
|
||||
HttpGraphQlClient graphQlClient = HttpGraphQlClient.create(webClient);
|
||||
|
||||
server.enqueue(new MockResponse().addHeader("Content-Type", MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
|
||||
.setBody("""
|
||||
{
|
||||
"data": {
|
||||
"createProject": {
|
||||
"id": "spring-graphql"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""));
|
||||
graphQlClient.document("""
|
||||
mutation createProject($project: ProjectInput!) {
|
||||
createProject($project: $project) {
|
||||
id
|
||||
}
|
||||
}
|
||||
""")
|
||||
.variables(Map.of("project", projectInput))
|
||||
.executeSync();
|
||||
|
||||
assertThat(server.takeRequest().getBody().readUtf8()).contains("\"variables\":{\"project\":" + variable + "}");
|
||||
}
|
||||
|
||||
static Stream<Arguments> fieldValues() {
|
||||
return Stream.of(
|
||||
Arguments.arguments(new ProjectInput("spring-graphql", FieldValue.omitted()), "{\"id\":\"spring-graphql\"}"),
|
||||
Arguments.arguments(new ProjectInput("spring-graphql", FieldValue.ofNullable(null)), "{\"id\":\"spring-graphql\",\"name\":null}"),
|
||||
Arguments.arguments(new ProjectInput("spring-graphql", FieldValue.ofNullable("Spring for GraphQL")), "{\"id\":\"spring-graphql\",\"name\":\"Spring for GraphQL\"}")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldStreamSubscriptionResultsOverSse(MockWebServer server) {
|
||||
WebClient webClient = WebClient.create(server.url("/graphql").toString());
|
||||
@@ -95,4 +150,8 @@ class HttpGraphQlTransportIntegrationTests {
|
||||
}
|
||||
|
||||
|
||||
public record ProjectInput(String id, FieldValue<String> name) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2020-2025 the original author or authors.
|
||||
*
|
||||
* 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 org.springframework.graphql.client;
|
||||
|
||||
import org.springframework.graphql.FieldValue;
|
||||
|
||||
public record Project(String id, FieldValue<String> name) {
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user