From 70f16c115868cf2a8669d64537149eaaf850ee0f Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 15 Apr 2025 11:44:05 +0200 Subject: [PATCH] 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` usage on the client side. This also improves the documentation around `FieldValue` for both server and client side support. Closes gh-1190 --- .../modules/ROOT/pages/client.adoc | 31 +++++++-- .../modules/ROOT/pages/controllers.adoc | 23 +++++-- .../client/fieldvalue/FieldValueClient.java | 66 +++++++++++++++++++ .../docs/client/fieldvalue/ProjectInput.java | 23 +++++++ .../client/AbstractGraphQlClientBuilder.java | 15 ++++- .../AbstractGraphQlClientSyncBuilder.java | 7 +- .../graphql/client/GraphQlClientTests.java | 40 +++++++++++ .../HttpGraphQlTransportIntegrationTests.java | 61 ++++++++++++++++- .../graphql/client/Project.java | 23 +++++++ 9 files changed, 271 insertions(+), 18 deletions(-) create mode 100644 spring-graphql-docs/src/main/java/org/springframework/graphql/docs/client/fieldvalue/FieldValueClient.java create mode 100644 spring-graphql-docs/src/main/java/org/springframework/graphql/docs/client/fieldvalue/ProjectInput.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/client/Project.java diff --git a/spring-graphql-docs/modules/ROOT/pages/client.adoc b/spring-graphql-docs/modules/ROOT/pages/client.adoc index e0cbcce6..4327b36d 100644 --- a/spring-graphql-docs/modules/ROOT/pages/client.adoc +++ b/spring-graphql-docs/modules/ROOT/pages/client.adoc @@ -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 support in controllers`], +we can wrap an Input type with `FieldValue` 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() { diff --git a/spring-graphql-docs/modules/ROOT/pages/controllers.adoc b/spring-graphql-docs/modules/ROOT/pages/controllers.adoc index 9447b14a..6bba506b 100644 --- a/spring-graphql-docs/modules/ROOT/pages/controllers.adoc +++ b/spring-graphql-docs/modules/ROOT/pages/controllers.adoc @@ -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) { - if (!bookInput.isOmitted()) { - BookInput value = bookInput.value(); - // ... + @QueryMapping + public List searchBook(@Argument String search, FieldValue 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 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` diff --git a/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/client/fieldvalue/FieldValueClient.java b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/client/fieldvalue/FieldValueClient.java new file mode 100644 index 00000000..ccfa274e --- /dev/null +++ b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/client/fieldvalue/FieldValueClient.java @@ -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[] +} diff --git a/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/client/fieldvalue/ProjectInput.java b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/client/fieldvalue/ProjectInput.java new file mode 100644 index 00000000..9476df7e --- /dev/null +++ b/spring-graphql-docs/src/main/java/org/springframework/graphql/docs/client/fieldvalue/ProjectInput.java @@ -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 name) { + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java index 9f414dea..9adb8bec 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java @@ -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 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); } } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientSyncBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientSyncBuilder.java index dd13692a..01f6c847 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientSyncBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientSyncBuilder.java @@ -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 initialize() { - return new MappingJackson2HttpMessageConverter(); + ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json() + .modulesToInstall(new GraphQlModule()).build(); + return new MappingJackson2HttpMessageConverter(objectMapper); } } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java index bf95ae00..af216fad 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java @@ -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() { diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlTransportIntegrationTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlTransportIntegrationTests.java index 503a1c72..de4bd2b4 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlTransportIntegrationTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlTransportIntegrationTests.java @@ -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 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 name) { + + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/Project.java b/spring-graphql/src/test/java/org/springframework/graphql/client/Project.java new file mode 100644 index 00000000..4c756b2f --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/Project.java @@ -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 name) { + +}