From 7bc6a6f602bcf453ebb71f1428b8915b2dbb31de Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 26 Jun 2025 15:17:36 +0200 Subject: [PATCH 01/15] Next development version (v1.4.2-SNAPSHOT) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 5efc15c26..5f2a303c0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.4.1-SNAPSHOT +version=1.4.2-SNAPSHOT org.gradle.caching=true org.gradle.daemon=true From d01b55dc23b9a2335e924056f49173099437361f Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Sat, 12 Jul 2025 19:17:00 +0200 Subject: [PATCH 02/15] Publish releases and milestones using Central Portal Closes gh-1262 --- .../actions/sync-to-maven-central/action.yml | 27 +++++++------------ .github/workflows/release-milestone.yml | 15 +++++++++++ .github/workflows/release.yml | 5 ++-- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/.github/actions/sync-to-maven-central/action.yml b/.github/actions/sync-to-maven-central/action.yml index 0064fee92..5d6cc7865 100644 --- a/.github/actions/sync-to-maven-central/action.yml +++ b/.github/actions/sync-to-maven-central/action.yml @@ -1,17 +1,14 @@ name: Sync to Maven Central description: Syncs a release to Maven Central and waits for it to be available for use inputs: - jfrog-cli-config-token: - description: 'Config token for the JFrog CLI' - required: true - ossrh-s01-token-username: - description: 'Username for authentication with s01.oss.sonatype.org' + central-token-password: + description: 'Password for authentication with central.sonatype.com' required: true - ossrh-s01-token-password: - description: 'Password for authentication with s01.oss.sonatype.org' + central-token-username: + description: 'Username for authentication with central.sonatype.com' required: true - ossrh-s01-staging-profile: - description: 'Staging profile to use when syncing to Central' + jfrog-cli-config-token: + description: 'Config token for the JFrog CLI' required: true spring-graphql-version: description: 'The version of Spring GraphQL that is being synced to Central' @@ -27,16 +24,10 @@ runs: shell: bash run: jf rt download --spec ${{ format('{0}/artifacts.spec', github.action_path) }} --spec-vars 'buildName=${{ format('spring-graphql-{0}', inputs.spring-graphql-version) }};buildNumber=${{ github.run_number }}' - name: Sync - uses: spring-io/nexus-sync-action@42477a2230a2f694f9eaa4643fa9e76b99b7ab84 # v0.0.1 + uses: spring-io/central-publish-action@0cdd90d12e6876341e82860d951e1bcddc1e51b6 # v0.2.0 with: - username: ${{ inputs.ossrh-s01-token-username }} - password: ${{ inputs.ossrh-s01-token-password }} - staging-profile-name: ${{ inputs.ossrh-s01-staging-profile }} - create: true - upload: true - close: true - release: true - generate-checksums: true + token: ${{ inputs.central-token-password }} + token-name: ${{ inputs.central-token-username }} - name: Await uses: ./.github/actions/await-http-resource with: diff --git a/.github/workflows/release-milestone.yml b/.github/workflows/release-milestone.yml index 0ba078dc8..8b5a3ab1c 100644 --- a/.github/workflows/release-milestone.yml +++ b/.github/workflows/release-milestone.yml @@ -35,6 +35,21 @@ jobs: /**/spring-graphql-docs-*.zip::zip.name=spring-graphql,zip.type=docs,zip.deployed=false outputs: version: ${{ steps.build-and-publish.outputs.version }} + sync-to-maven-central: + name: Sync to Maven Central + needs: + - build-and-stage-release + runs-on: ubuntu-latest + steps: + - name: Check Out Code + uses: actions/checkout@v4 + - name: Sync to Maven Central + uses: ./.github/actions/sync-to-maven-central + with: + central-token-password: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + central-token-username: ${{ secrets.CENTRAL_TOKEN_USERNAME }} + jfrog-cli-config-token: ${{ secrets.JF_ARTIFACTORY_SPRING }} + spring-graphql-version: ${{ needs.build-and-stage-release.outputs.version }} promote-release: name: Promote Release needs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acc1549a0..6458499ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,10 +45,9 @@ jobs: - name: Sync to Maven Central uses: ./.github/actions/sync-to-maven-central with: + central-token-password: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + central-token-username: ${{ secrets.CENTRAL_TOKEN_USERNAME }} jfrog-cli-config-token: ${{ secrets.JF_ARTIFACTORY_SPRING }} - ossrh-s01-staging-profile: ${{ secrets.OSSRH_S01_STAGING_PROFILE }} - ossrh-s01-token-password: ${{ secrets.OSSRH_S01_TOKEN_PASSWORD }} - ossrh-s01-token-username: ${{ secrets.OSSRH_S01_TOKEN_USERNAME }} spring-graphql-version: ${{ needs.build-and-stage-release.outputs.version }} promote-release: name: Promote Release From dfa486b4480f538e3b95ddaffe743b1dddcc1d0b Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Wed, 20 Aug 2025 19:26:49 +0200 Subject: [PATCH 03/15] Fix missing response body in client exceptions As of gh-1117, the `HttpSyncGraphQlClient` supports the "GraphQL over HTTP" specification. More specifically, it now handles HTTP 4xx responses sent by GraphQL servers when the response content type is "applcation/graphql-response+json". This change introduced a regression where 4xx and 5xx HTTP responses now always throw `HttpClientErrorException` (instead of throwing `HttpClientErrorException` or `HttpServerErrorException` depending on the case), and this exception is missing the response body and other information from the response. This commit ensures that exceptions are thrown in a similar fashion to the default `StatusHandler` from Framwork. Fixes gh-1259 --- .../client/HttpSyncGraphQlTransport.java | 34 +- .../HttpGraphQlClientProtocolTests.java | 313 ++++++++++-------- 2 files changed, 211 insertions(+), 136 deletions(-) diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlTransport.java b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlTransport.java index 1b2ecf4d4..8601e2cbb 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlTransport.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlTransport.java @@ -16,6 +16,8 @@ package org.springframework.graphql.client; +import java.io.IOException; +import java.nio.charset.Charset; import java.util.Collections; import java.util.Map; @@ -24,11 +26,16 @@ import org.springframework.graphql.GraphQlResponse; import org.springframework.graphql.MediaTypes; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpMessage; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.RestClient; @@ -75,7 +82,16 @@ public GraphQlResponse execute(GraphQlRequest request) { else if (httpResponse.getStatusCode().is4xxClientError() && isGraphQlResponse(httpResponse)) { return httpResponse.bodyTo(MAP_TYPE); } - throw new HttpClientErrorException(httpResponse.getStatusCode(), httpResponse.getStatusText()); + else if (httpResponse.getStatusCode().is4xxClientError()) { + throw HttpClientErrorException.create(httpResponse.getStatusText(), httpResponse.getStatusCode(), + httpResponse.getStatusText(), httpResponse.getHeaders(), + getBody(httpResponse), getCharset(httpResponse)); + } + else { + throw HttpServerErrorException.create(httpResponse.getStatusText(), httpResponse.getStatusCode(), + httpResponse.getStatusText(), httpResponse.getHeaders(), + getBody(httpResponse), getCharset(httpResponse)); + } }); return new ResponseMapGraphQlResponse((body != null) ? body : Collections.emptyMap()); } @@ -85,4 +101,20 @@ private static boolean isGraphQlResponse(ClientHttpResponse clientResponse) { .isCompatibleWith(clientResponse.getHeaders().getContentType()); } + private static byte[] getBody(HttpInputMessage message) { + try { + return FileCopyUtils.copyToByteArray(message.getBody()); + } + catch (IOException ignore) { + } + return new byte[0]; + } + + @Nullable + private static Charset getCharset(HttpMessage response) { + HttpHeaders headers = response.getHeaders(); + MediaType contentType = headers.getContentType(); + return (contentType != null) ? contentType.getCharset() : null; + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlClientProtocolTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlClientProtocolTests.java index d8abbd2a1..2c34d60ad 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlClientProtocolTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlClientProtocolTests.java @@ -23,15 +23,22 @@ import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.graphql.MediaTypes; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.reactive.function.client.WebClientResponseException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; import static org.junit.jupiter.api.Named.named; import static org.junit.jupiter.params.provider.Arguments.arguments; @@ -56,43 +63,47 @@ void tearDown() throws IOException { this.server.shutdown(); } - private static Stream graphQlClientTypes() { - return Stream.of( - arguments(named("HttpGraphQlClient", ClientType.ASYNC)), - arguments(named("HttpSyncGraphQlClient", ClientType.SYNC)) - ); - } - /* - * If the GraphQL response contains the data entry, and it is not null, - * then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code. - */ - @ParameterizedTest - @MethodSource("graphQlClientTypes") - void successWhenValidRequest(ClientType clientType) { - prepareOkResponse(""" + @Nested + class GraphQlResponseContentTypeTests { + + static Stream graphQlClientTypes() { + return Stream.of( + arguments(named("HttpGraphQlClient", ClientType.ASYNC)), + arguments(named("HttpSyncGraphQlClient", ClientType.SYNC)) + ); + } + + /* + * If the GraphQL response contains the data entry, and it is not null, + * then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code. + */ + @ParameterizedTest + @MethodSource("graphQlClientTypes") + void successWhenValidRequest(ClientType clientType) { + prepareOkResponse(""" { "data": { "greeting": "Hello World!" } } """); - ClientGraphQlResponse response = createClient(clientType).document("{ greeting }") - .executeSync(); - assertThat(response.isValid()).isTrue(); - assertThat(response.getErrors()).isEmpty(); - assertThat(response.field("greeting").toEntity(String.class)).isEqualTo("Hello World!"); - } + ClientGraphQlResponse response = createClient(clientType).document("{ greeting }") + .executeSync(); + assertThat(response.isValid()).isTrue(); + assertThat(response.getErrors()).isEmpty(); + assertThat(response.field("greeting").toEntity(String.class)).isEqualTo("Hello World!"); + } - /* - * If the GraphQL response contains the data entry and it is not null, - * then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code. - * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Field-errors-encountered-during-execution - */ - @ParameterizedTest - @MethodSource("graphQlClientTypes") - void partialSuccessWhenError(ClientType clientType) { - prepareOkResponse(""" + /* + * If the GraphQL response contains the data entry and it is not null, + * then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code. + * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Field-errors-encountered-during-execution + */ + @ParameterizedTest + @MethodSource("graphQlClientTypes") + void partialSuccessWhenError(ClientType clientType) { + prepareOkResponse(""" { "errors":[ { @@ -106,8 +117,8 @@ void partialSuccessWhenError(ClientType clientType) { } """); - ClientGraphQlResponse response = createClient(clientType) - .document(""" + ClientGraphQlResponse response = createClient(clientType) + .document(""" { bookById(id: 1) { id @@ -117,36 +128,36 @@ void partialSuccessWhenError(ClientType clientType) { } } """) - .executeSync(); + .executeSync(); - assertThat(response.isValid()).isTrue(); - assertThat(response.getErrors()).singleElement() - .extracting("message").asString().contains("INTERNAL_ERROR"); - assertThat(response.field("bookById.id").toEntity(Long.class)).isEqualTo(1L); - } + assertThat(response.isValid()).isTrue(); + assertThat(response.getErrors()).singleElement() + .extracting("message").asString().contains("INTERNAL_ERROR"); + assertThat(response.field("bookById.id").toEntity(Long.class)).isEqualTo(1L); + } - /* - * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, - * then the server SHOULD reply with 400 status code. - * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.JSON-parsing-failure - */ - @ParameterizedTest - @MethodSource("graphQlClientTypes") - void requestErrorWhenInvalidRequest(ClientType clientType) { - prepareBadRequestResponse(); - assertThatThrownBy(() -> createClient(clientType).document("{ greeting }") - .executeSync()).isInstanceOfAny(HttpClientErrorException.class, WebClientResponseException.class); - } + /* + * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, + * then the server SHOULD reply with 400 status code. + * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.JSON-parsing-failure + */ + @ParameterizedTest + @MethodSource("graphQlClientTypes") + void requestErrorWhenInvalidRequest(ClientType clientType) { + prepareBadRequestResponse(); + assertThatThrownBy(() -> createClient(clientType).document("{ greeting }") + .executeSync()).isInstanceOfAny(HttpClientErrorException.class, WebClientResponseException.class); + } - /* - * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, - * then the server SHOULD reply with 400 status code. - * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-parsing-failure - */ - @ParameterizedTest - @MethodSource("graphQlClientTypes") - void requestErrorWhenDocumentParsingFailure(ClientType clientType) { - prepareBadRequestResponse(""" + /* + * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, + * then the server SHOULD reply with 400 status code. + * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-parsing-failure + */ + @ParameterizedTest + @MethodSource("graphQlClientTypes") + void requestErrorWhenDocumentParsingFailure(ClientType clientType) { + prepareBadRequestResponse(""" { "errors":[ { @@ -157,22 +168,22 @@ void requestErrorWhenDocumentParsingFailure(ClientType clientType) { ] } """); - ClientGraphQlResponse response = createClient(clientType).document("{") - .executeSync(); - assertThat(response.isValid()).isFalse(); - assertThat(response.getErrors()).singleElement() - .extracting("message").asString().contains("Invalid syntax with offending token"); - } + ClientGraphQlResponse response = createClient(clientType).document("{") + .executeSync(); + assertThat(response.isValid()).isFalse(); + assertThat(response.getErrors()).singleElement() + .extracting("message").asString().contains("Invalid syntax with offending token"); + } - /* - * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, - * then the server SHOULD reply with 400 status code. - * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-validation-failure - */ - @ParameterizedTest - @MethodSource("graphQlClientTypes") - void requestErrorWhenInvalidDocument(ClientType clientType) { - prepareBadRequestResponse(""" + /* + * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, + * then the server SHOULD reply with 400 status code. + * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-validation-failure + */ + @ParameterizedTest + @MethodSource("graphQlClientTypes") + void requestErrorWhenInvalidDocument(ClientType clientType) { + prepareBadRequestResponse(""" { "errors":[ { @@ -182,22 +193,22 @@ void requestErrorWhenInvalidDocument(ClientType clientType) { ] } """); - ClientGraphQlResponse response = createClient(clientType).document("{ unknown }") - .executeSync(); - assertThat(response.isValid()).isFalse(); - assertThat(response.getErrors()).singleElement() - .extracting("message").asString().contains("Validation error"); - } + ClientGraphQlResponse response = createClient(clientType).document("{ unknown }") + .executeSync(); + assertThat(response.isValid()).isFalse(); + assertThat(response.getErrors()).singleElement() + .extracting("message").asString().contains("Validation error"); + } - /* - * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, - * then the server SHOULD reply with 400 status code. - * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Operation-cannot-be-determined - */ - @ParameterizedTest - @MethodSource("graphQlClientTypes") - void requestErrorWhenUndeterminedOperation(ClientType clientType) { - prepareBadRequestResponse(""" + /* + * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, + * then the server SHOULD reply with 400 status code. + * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Operation-cannot-be-determined + */ + @ParameterizedTest + @MethodSource("graphQlClientTypes") + void requestErrorWhenUndeterminedOperation(ClientType clientType) { + prepareBadRequestResponse(""" { "errors":[ { @@ -207,26 +218,26 @@ void requestErrorWhenUndeterminedOperation(ClientType clientType) { ] } """); - ClientGraphQlResponse response = createClient(clientType).document(""" + ClientGraphQlResponse response = createClient(clientType).document(""" { "query" : "{ greeting }", "operationName" : "unknown" } """).executeSync(); - assertThat(response.isValid()).isFalse(); - assertThat(response.getErrors()).singleElement() - .extracting("message").asString().contains("Unknown operation named 'unknown'."); - } + assertThat(response.isValid()).isFalse(); + assertThat(response.getErrors()).singleElement() + .extracting("message").asString().contains("Unknown operation named 'unknown'."); + } - /* - * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, - * then the server SHOULD reply with 400 status code. - * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Variable-coercion-failure - */ - @ParameterizedTest - @MethodSource("graphQlClientTypes") - void requestErrorWhenVariableCoercion(ClientType clientType) { - prepareBadRequestResponse(""" + /* + * If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation, + * then the server SHOULD reply with 400 status code. + * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Variable-coercion-failure + */ + @ParameterizedTest + @MethodSource("graphQlClientTypes") + void requestErrorWhenVariableCoercion(ClientType clientType) { + prepareBadRequestResponse(""" { "errors":[ { @@ -237,51 +248,83 @@ void requestErrorWhenVariableCoercion(ClientType clientType) { ] } """); - ClientGraphQlResponse response = createClient(clientType).document("{ bookById(id: false) { id } }").executeSync(); - assertThat(response.isValid()).isFalse(); - assertThat(response.getErrors()).singleElement() - .extracting("message").asString().contains("Validation error (WrongType@[bookById])"); + ClientGraphQlResponse response = createClient(clientType).document("{ bookById(id: false) { id } }").executeSync(); + assertThat(response.isValid()).isFalse(); + assertThat(response.getErrors()).singleElement() + .extracting("message").asString().contains("Validation error (WrongType@[bookById])"); + } + + /* + * If the GraphQL response contains the data entry and it is null, then the server SHOULD reply + * with a 2xx status code and it is RECOMMENDED it replies with 200 status code. + * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json + */ + @ParameterizedTest + @MethodSource("graphQlClientTypes") + void successWhenEmptyData(ClientType clientType) { + prepareOkResponse("{\"data\":null}"); + ClientGraphQlResponse response = createClient(clientType).document("{ bookById(id: 100) { id } }").executeSync(); + assertThat(response.isValid()).isFalse(); + assertThat(response.getErrors()).isEmpty(); + } + + void prepareOkResponse(String body) { + prepareResponse(200, MediaTypes.APPLICATION_GRAPHQL_RESPONSE, body); + } + + void prepareBadRequestResponse(String body) { + prepareResponse(400, MediaTypes.APPLICATION_GRAPHQL_RESPONSE, body); + } + + void prepareBadRequestResponse() { + MockResponse mockResponse = new MockResponse(); + mockResponse.setResponseCode(400); + server.enqueue(mockResponse); + } + } - /* - * If the GraphQL response contains the data entry and it is null, then the server SHOULD reply - * with a 2xx status code and it is RECOMMENDED it replies with 200 status code. - * https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json - */ - @ParameterizedTest - @MethodSource("graphQlClientTypes") - void successWhenEmptyData(ClientType clientType) { - prepareOkResponse("{\"data\":null}"); - ClientGraphQlResponse response = createClient(clientType).document("{ bookById(id: 100) { id } }").executeSync(); - assertThat(response.isValid()).isFalse(); - assertThat(response.getErrors()).isEmpty(); + + @Nested + class LegacyContentTypeTests { + + @Test + void httpClientExceptionWhenBadRequest() { + String responseBody = """ + { + "type": "https://example.com/probs/test", + "title": "test error" + } + """; + prepareResponse(400, MediaType.APPLICATION_JSON, responseBody); + HttpClientErrorException exception = catchThrowableOfType(HttpClientErrorException.class, () -> createClient(ClientType.SYNC).document("{ unknown }").executeSync()); + assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exception.getResponseBodyAsString()).isEqualTo(responseBody); + } + + @Test + void httpServerExceptionWhenServerError() { + String responseBody = "error"; + prepareResponse(500, MediaType.APPLICATION_JSON, responseBody); + HttpServerErrorException exception = catchThrowableOfType(HttpServerErrorException.class, () -> createClient(ClientType.SYNC).document("{ unknown }").executeSync()); + assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(exception.getResponseBodyAsString()).isEqualTo(responseBody); + } + } - private GraphQlClient createClient(ClientType type) { + + GraphQlClient createClient(ClientType type) { return switch (type) { case ASYNC -> HttpGraphQlClient.builder().url(server.url("/").toString()).build(); case SYNC -> HttpSyncGraphQlClient.builder().url(server.url("/").toString()).build(); }; } - private void prepareOkResponse(String body) { - prepareResponse(200, body); - } - - private void prepareBadRequestResponse(String body) { - prepareResponse(400, body); - } - - private void prepareBadRequestResponse() { - MockResponse mockResponse = new MockResponse(); - mockResponse.setResponseCode(400); - this.server.enqueue(mockResponse); - } - - private void prepareResponse(int status, String body) { + private void prepareResponse(int status, MediaType contentType, String body) { MockResponse mockResponse = new MockResponse(); mockResponse.setResponseCode(status); - mockResponse.setHeader("Content-Type", "application/graphql-response+json"); + mockResponse.setHeader("Content-Type", contentType.toString()); mockResponse.setBody(body); this.server.enqueue(mockResponse); } From aa8bb81f361d7ba19018383e905ba0cbfdd7b9df Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 21 Aug 2025 11:19:59 +0200 Subject: [PATCH 04/15] Avoid further setter binding when possible As of gh-1163, the `GraphQlArgumentBinder` can bind using both a constructor and setters. This is especially useful for Kotlin data classes. This change introduced a significant performance penalty for large and deep data sets. This commit ensures that we are only performing setter binding for input data that was not already consumed during the constructor binding phase. Fixes gh-1284 --- .../graphql/data/GraphQlArgumentBinder.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java b/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java index 20f371891..7e6e2304e 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java @@ -20,6 +20,7 @@ import java.lang.reflect.Field; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -276,6 +277,7 @@ private Map bindMapToMap( private Object bindViaConstructorAndSetters(Constructor constructor, Map rawMap, ResolvableType ownerType, ArgumentsBindingResult bindingResult) { + Map dataToBind = new HashMap<>(rawMap); String[] paramNames = BeanUtils.getParameterNames(constructor); Class[] paramTypes = constructor.getParameterTypes(); Object[] constructorArguments = new Object[paramTypes.length]; @@ -287,7 +289,8 @@ private Object bindViaConstructorAndSetters(Constructor constructor, ResolvableType.forConstructorParameter(constructor, i).getType(), ownerType); constructorArguments[i] = bindRawValue( - name, rawMap.get(name), !rawMap.containsKey(name), targetType, paramTypes[i], bindingResult); + name, dataToBind.get(name), !dataToBind.containsKey(name), targetType, paramTypes[i], bindingResult); + dataToBind.remove(name); } Object target; @@ -302,9 +305,9 @@ private Object bindViaConstructorAndSetters(Constructor constructor, throw ex; } - // If no errors, apply setters too - if (!bindingResult.hasErrors()) { - bindViaSetters(target, rawMap, ownerType, bindingResult); + // If no errors and data remains to be bound, apply setters too + if (!dataToBind.isEmpty() && !bindingResult.hasErrors()) { + bindViaSetters(target, dataToBind, ownerType, bindingResult); } return target; From 5e3cdeb83ceae1e79a307176d138fada92dcc84b Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 22 Aug 2025 18:30:55 +0200 Subject: [PATCH 05/15] Fix links to Spring Boot autoconfig classes --- spring-graphql-docs/modules/ROOT/pages/boot-starter.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-graphql-docs/modules/ROOT/pages/boot-starter.adoc b/spring-graphql-docs/modules/ROOT/pages/boot-starter.adoc index 26b4313aa..669cf472f 100644 --- a/spring-graphql-docs/modules/ROOT/pages/boot-starter.adoc +++ b/spring-graphql-docs/modules/ROOT/pages/boot-starter.adoc @@ -16,6 +16,6 @@ features, and more. For testing support, see For further reference, check the following GraphQL related: - {spring-boot-ref-docs}/appendix/application-properties/index.html#appendix.application-properties.web[Configuration Properties] -- {spring-boot-ref-docs}/appendix/auto-configuration-classes/core.html[GraphQL Auto-Configuration Classes] -- {spring-boot-ref-docs}/appendix/auto-configuration-classes/actuator.html[GraphQL Actuator Auto-Configuration Classes] +- {spring-boot-ref-docs}/appendix/auto-configuration-classes/spring-boot-autoconfigure.html[GraphQL Auto-Configuration Classes] +- {spring-boot-ref-docs}/appendix/auto-configuration-classes/spring-boot-actuator-autoconfigure.html[GraphQL Actuator Auto-Configuration Classes] From b8e7a952ae06aaf4b9dc172badfffb680fd40ed1 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 29 Aug 2025 14:42:30 +0200 Subject: [PATCH 06/15] Fix NonNull wrapper types support in PropertySelection This commit ensures that `PropertySelection` supports NonNull wrapper types like `BookConnection!` when inspecting property paths for connection edges and nodes. Fixes gh-1298 --- .../graphql/data/query/PropertySelection.java | 15 +++++++-- .../data/query/PropertySelectionTests.java | 32 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/PropertySelection.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/PropertySelection.java index 422126191..f227e5976 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/PropertySelection.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/PropertySelection.java @@ -24,6 +24,8 @@ import graphql.schema.DataFetchingFieldSelectionSet; import graphql.schema.GraphQLNamedOutputType; +import graphql.schema.GraphQLNonNull; +import graphql.schema.GraphQLType; import graphql.schema.SelectedField; import org.springframework.data.mapping.PropertyPath; @@ -108,9 +110,16 @@ else if (isConnectionEdgeNode(selectedField)) { } private static boolean isConnectionEdges(SelectedField selectedField) { - return selectedField.getName().equals("edges") && - selectedField.getParentField().getType() instanceof GraphQLNamedOutputType namedType && - namedType.getName().endsWith("Connection"); + if (selectedField.getName().equals("edges")) { + GraphQLType fieldType = selectedField.getParentField().getType(); + if (fieldType instanceof GraphQLNonNull nonNullType) { + fieldType = nonNullType.getWrappedType(); + } + if (fieldType instanceof GraphQLNamedOutputType namedType) { + return namedType.getName().endsWith("Connection"); + } + } + return false; } private static boolean isConnectionEdgeNode(SelectedField selectedField) { diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/PropertySelectionTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/PropertySelectionTests.java index 1b26122d8..05a029f62 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/query/PropertySelectionTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/PropertySelectionTests.java @@ -16,13 +16,19 @@ package org.springframework.graphql.data.query; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingFieldSelectionSet; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; import org.springframework.data.util.TypeInformation; import org.springframework.graphql.BookSource; import org.springframework.graphql.GraphQlSetup; @@ -34,11 +40,13 @@ * Unit test for {@link PropertySelection}. * * @author Rossen Stoyanchev + * @author Brian Clozel */ class PropertySelectionTests { - @Test - void propertySelectionWithConnection() { + @ParameterizedTest + @MethodSource("schemaResource") + void propertySelectionWithConnection(Resource schemaResource) { AtomicReference ref = new AtomicReference<>(); DataFetcher dataFetcher = environment -> { @@ -46,7 +54,7 @@ void propertySelectionWithConnection() { return null; }; - GraphQlSetup.schemaResource(BookSource.paginationSchema) + GraphQlSetup.schemaResource(schemaResource) .typeDefinitionConfigurer(new ConnectionTypeDefinitionConfigurer()) .dataFetcher("Query", "books", dataFetcher) .toGraphQlService() @@ -59,4 +67,20 @@ void propertySelectionWithConnection() { assertThat(list).containsExactly("id", "name"); } + static Stream schemaResource() { + return Stream.of( + Arguments.of(BookSource.paginationSchema), + Arguments.of(new ByteArrayResource(""" + type Query { + books(first:Int, after:String): BookConnection! + } + + type Book { + id: ID + name: String + } + """.getBytes(StandardCharsets.UTF_8))) + ); + } + } From d87dffdfa9b971d5b3c8fefed41779329737b753 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 29 Aug 2025 14:44:46 +0200 Subject: [PATCH 07/15] Return null values from Connection visitor when expected Prior to this commit, a schema declaring a `BookConnection` nullable type would decorate the relevant data fetcher and return an `EMPTY_CONNECTION` value (a full Connection instance with empty nodes and edges) if the original DataFetcher returns `null`. This is unexpected for applications because the type is declared as nullable in the schema and the application returns a `null` value. This commit ensures that `EMPTY_CONNECTION` is only returned as a value if the Connection type is marked as non nullable, `BookConnection!`. This change should only affect applications with custom pagination support, as Spring Data never returns `null` for empty pages. Closes gh-1295 --- .../ConnectionFieldTypeVisitor.java | 20 ++++++++++++---- .../ConnectionFieldTypeVisitorTests.java | 24 +++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionFieldTypeVisitor.java b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionFieldTypeVisitor.java index c610b9f12..2d66edfec 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionFieldTypeVisitor.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/pagination/ConnectionFieldTypeVisitor.java @@ -24,6 +24,7 @@ import graphql.TrivialDataFetcher; import graphql.execution.DataFetcherResult; +import graphql.language.NonNullType; import graphql.relay.Connection; import graphql.relay.DefaultConnection; import graphql.relay.DefaultConnectionCursor; @@ -101,7 +102,7 @@ public TraversalControl visitGraphQLFieldDefinition( } } else { - dataFetcher = new ConnectionDataFetcher(dataFetcher, this.adapter); + dataFetcher = new ConnectionDataFetcher(dataFetcher, this.adapter, fieldDefinition); codeRegistry.dataFetcher(fieldCoordinates, dataFetcher); } } @@ -190,10 +191,11 @@ public static ConnectionFieldTypeVisitor create(List adapters /** * {@code DataFetcher} decorator that adapts return values with an adapter. - * @param delegate the datafetcher delegate + * @param delegate the DataFetcher delegate * @param adapter the connection adapter to use + * @param connectionFieldDefinition the field definition for the connection type */ - record ConnectionDataFetcher(DataFetcher delegate, ConnectionAdapter adapter) implements DataFetcher { + record ConnectionDataFetcher(DataFetcher delegate, ConnectionAdapter adapter, GraphQLFieldDefinition connectionFieldDefinition) implements DataFetcher { private static final Connection EMPTY_CONNECTION = new DefaultConnection<>(Collections.emptyList(), new DefaultPageInfo(null, null, false, false)); @@ -202,6 +204,7 @@ record ConnectionDataFetcher(DataFetcher delegate, ConnectionAdapter adapter) ConnectionDataFetcher { Assert.notNull(delegate, "DataFetcher delegate is required"); Assert.notNull(adapter, "ConnectionAdapter is required"); + Assert.notNull(connectionFieldDefinition, "GraphQLFieldDefinition is required"); } @@ -232,9 +235,9 @@ private Object adaptDataFetcherResult(@Nullable Object value) { } } - private Object adaptDataContainer(@Nullable Object container) { + private @Nullable Object adaptDataContainer(@Nullable Object container) { if (container == null) { - return EMPTY_CONNECTION; + return isConnectionTypeNullable() ? null : EMPTY_CONNECTION; } if (container instanceof Connection) { @@ -268,6 +271,13 @@ private Object adaptDataContainer(@Nullable Object container) { return new DefaultConnection<>(edges, pageInfo); } + private boolean isConnectionTypeNullable() { + if (this.connectionFieldDefinition.getDefinition() != null) { + return !(this.connectionFieldDefinition.getDefinition().getType() instanceof NonNullType); + } + return true; + } + } } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/ConnectionFieldTypeVisitorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/ConnectionFieldTypeVisitorTests.java index f874b9c77..208ae6bb3 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/ConnectionFieldTypeVisitorTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/pagination/ConnectionFieldTypeVisitorTests.java @@ -137,8 +137,8 @@ void customConnectionTypeIsPassedThrough() { ); } - @Test // gh-707 - void nullValueTreatedAsEmptyConnection() { + @Test // gh-707, gh-1295 + void nullValueTreatedAsNullConnectionWhenNullable() { Mono response = GraphQlSetup.schemaResource(BookSource.paginationSchema) .dataFetcher("Query", "books", environment -> null) @@ -146,6 +146,26 @@ void nullValueTreatedAsEmptyConnection() { .toGraphQlService() .execute(BookSource.booksConnectionQuery(null)); + ResponseHelper.forResponse(response).assertData("{\"books\":null}"); + } + + @Test // gh-1295 + void nullValueTreatedAsEmptyConnectionWhenNonNullable() { + Mono response = GraphQlSetup.schemaContent(""" + type Query { + books(first:Int, after:String): BookConnection! + } + + type Book { + id: ID + name: String + } + """) + .dataFetcher("Query", "books", environment -> null) + .connectionSupport(new ListConnectionAdapter()) + .toGraphQlService() + .execute(BookSource.booksConnectionQuery(null)); + ResponseHelper.forResponse(response).assertData( "{\"books\":{" + "\"edges\":[]," + From 478fb651d67102707ec80bf5a6c18ac8a098b28c Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Sat, 30 Aug 2025 12:26:38 +0200 Subject: [PATCH 08/15] Support Kotlin value classes in binder This commit adds support for Kotlin Value classes when binding arguments to `@SchemaMapping` methods. Complete Framework support is not available yet, but this change unlocks typical Kotlin usage in GraphQL applications. Closes gh-1186 --- .../graphql/data/GraphQlArgumentBinder.java | 48 ++++++++++++- .../data/GraphQlArgumentBinderKotlinTests.kt | 72 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 spring-graphql/src/test/kotlin/org/springframework/graphql/data/GraphQlArgumentBinderKotlinTests.kt diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java b/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java index 7e6e2304e..c22c87fef 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java @@ -25,6 +25,14 @@ import java.util.Optional; import graphql.schema.DataFetchingEnvironment; +import kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KClass; +import kotlin.reflect.KFunction; +import kotlin.reflect.KParameter; +import kotlin.reflect.KType; +import kotlin.reflect.full.KClasses; +import kotlin.reflect.jvm.KCallablesJvm; +import kotlin.reflect.jvm.ReflectJvmMapping; import org.springframework.beans.BeanInstantiationException; import org.springframework.beans.BeanUtils; @@ -36,6 +44,7 @@ import org.springframework.beans.TypeMismatchException; import org.springframework.core.CollectionFactory; import org.springframework.core.Conventions; +import org.springframework.core.KotlinDetector; import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; import org.springframework.core.convert.ConversionService; @@ -280,7 +289,7 @@ private Object bindViaConstructorAndSetters(Constructor constructor, Map dataToBind = new HashMap<>(rawMap); String[] paramNames = BeanUtils.getParameterNames(constructor); Class[] paramTypes = constructor.getParameterTypes(); - Object[] constructorArguments = new Object[paramTypes.length]; + Object[] constructorArguments = new Object[paramNames.length]; for (int i = 0; i < paramNames.length; i++) { String name = paramNames[i]; @@ -290,8 +299,12 @@ private Object bindViaConstructorAndSetters(Constructor constructor, constructorArguments[i] = bindRawValue( name, dataToBind.get(name), !dataToBind.containsKey(name), targetType, paramTypes[i], bindingResult); + dataToBind.remove(name); } + if (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(constructor.getDeclaringClass())) { + KotlinDelegate.rebindKotlinArguments(constructorArguments, constructor); + } Object target; try { @@ -419,4 +432,37 @@ void rejectArgumentValue( } } + // remove in favor of https://github.com/spring-projects/spring-framework/issues/33630 + private static final class KotlinDelegate { + + public static void rebindKotlinArguments(Object[] arguments, Constructor constructor) { + KFunction function = ReflectJvmMapping.getKotlinFunction(constructor); + if (function == null) { + return; + } + int index = 0; + for (KParameter parameter : function.getParameters()) { + switch (parameter.getKind()) { + case VALUE, EXTENSION_RECEIVER -> { + Object rawValue = arguments[index]; + if (!(parameter.isOptional() && rawValue == null)) { + KType type = parameter.getType(); + if (!(type.isMarkedNullable() && rawValue == null) && type.getClassifier() instanceof KClass kClass + && KotlinDetector.isInlineClass(JvmClassMappingKt.getJavaClass(kClass))) { + KFunction argConstructor = KClasses.getPrimaryConstructor(kClass); + if (argConstructor != null) { + if (!KCallablesJvm.isAccessible(argConstructor)) { + KCallablesJvm.setAccessible(argConstructor, true); + } + arguments[index] = argConstructor.call(rawValue); + } + } + } + } + } + index++; + } + } + } + } diff --git a/spring-graphql/src/test/kotlin/org/springframework/graphql/data/GraphQlArgumentBinderKotlinTests.kt b/spring-graphql/src/test/kotlin/org/springframework/graphql/data/GraphQlArgumentBinderKotlinTests.kt new file mode 100644 index 000000000..d505ea3f4 --- /dev/null +++ b/spring-graphql/src/test/kotlin/org/springframework/graphql/data/GraphQlArgumentBinderKotlinTests.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2025-present 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.data + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.json.JsonMapper +import graphql.schema.DataFetchingEnvironment +import graphql.schema.DataFetchingEnvironmentImpl +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.core.ResolvableType +import org.springframework.format.support.DefaultFormattingConversionService +import org.springframework.lang.Nullable + +class GraphQlArgumentBinderKotlinTests { + + private val mapper = JsonMapper.builder().build() + + private val binder = GraphQlArgumentBinder(DefaultFormattingConversionService()) + + @Test + fun bindValueClass() { + val targetType = ResolvableType.forClass(MyDataClassWithValueClass::class.java) + val result = bind(binder, "{\"first\": \"firstValue\", \"second\": \"secondValue\"}", targetType) as MyDataClassWithValueClass + assertThat(result.first).isEqualTo("firstValue") + assertThat(result.second).isEqualTo(MyValueClass("secondValue")) + } + + @Test + fun bindValueClassWithDefaultValue() { + val targetType = ResolvableType.forClass(MyDataClassWithDefaultValueClass::class.java) + val result = bind(binder, "{\"first\": \"firstValue\"}", targetType) as MyDataClassWithDefaultValueClass + assertThat(result.first).isEqualTo("firstValue") + + } + + @Nullable + @Throws(Exception::class) + private fun bind(binder: GraphQlArgumentBinder, json: String, targetType: ResolvableType): Any? { + val typeRef: TypeReference> = object : TypeReference>() {} + val map = this.mapper.readValue("{\"key\":$json}", typeRef) + val environment: DataFetchingEnvironment = + DataFetchingEnvironmentImpl.newDataFetchingEnvironment() + .arguments(map) + .build() + return binder.bind(environment, "key", targetType) + } + + data class MyDataClassWithValueClass(val first: String, val second: MyValueClass) + + data class MyDataClassWithDefaultValueClass(val first: String, val second: MyValueClass = MyValueClass("secondValue")) + + @JvmInline + value class MyValueClass(val value: String) { + + } + +} From 3443639aab4ed09623b608f5e5c119ee79328720 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 16 Sep 2025 11:00:47 +0200 Subject: [PATCH 09/15] Upgrade to Spring Framework 6.2.11 Closes gh-1306 --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 8acb98a95..10ecf4d08 100644 --- a/build.gradle +++ b/build.gradle @@ -2,9 +2,9 @@ description = "Spring for GraphQL" ext { moduleProjects = [project(":spring-graphql"), project(":spring-graphql-test")] - springFrameworkVersion = "6.2.8" + springFrameworkVersion = "6.2.11" graphQlJavaVersion = "24.1" - springBootVersion = "3.4.3" + springBootVersion = "3.4.9" } subprojects { From d81e89e4e9c449cd475e4a1035e29246f34a5acc Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 16 Sep 2025 11:02:14 +0200 Subject: [PATCH 10/15] Upgrade to Reactor 2024.0.10 Closes gh-1307 --- platform/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/build.gradle b/platform/build.gradle index 024237fcc..6ee67f6a6 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -9,7 +9,7 @@ javaPlatform { dependencies { api(platform("org.springframework:spring-framework-bom:${springFrameworkVersion}")) api(platform("com.fasterxml.jackson:jackson-bom:2.18.4")) - api(platform("io.projectreactor:reactor-bom:2024.0.7")) + api(platform("io.projectreactor:reactor-bom:2024.0.10")) api(platform("io.micrometer:micrometer-bom:1.15.1")) api(platform("io.micrometer:micrometer-tracing-bom:1.5.1")) api(platform("org.springframework.data:spring-data-bom:2025.0.1")) From 02e582a8996e6a003f2f9b7e382128db0d248a80 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 16 Sep 2025 11:02:55 +0200 Subject: [PATCH 11/15] Upgrade to Micrometer 1.15.4 and Tracing 1.5.4 Closes gh-1308 --- platform/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platform/build.gradle b/platform/build.gradle index 6ee67f6a6..146352e82 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -10,8 +10,8 @@ dependencies { api(platform("org.springframework:spring-framework-bom:${springFrameworkVersion}")) api(platform("com.fasterxml.jackson:jackson-bom:2.18.4")) api(platform("io.projectreactor:reactor-bom:2024.0.10")) - api(platform("io.micrometer:micrometer-bom:1.15.1")) - api(platform("io.micrometer:micrometer-tracing-bom:1.5.1")) + api(platform("io.micrometer:micrometer-bom:1.15.4")) + api(platform("io.micrometer:micrometer-tracing-bom:1.5.4")) api(platform("org.springframework.data:spring-data-bom:2025.0.1")) api(platform("org.springframework.security:spring-security-bom:6.5.1")) api(platform("com.querydsl:querydsl-bom:5.1.0")) From d25e96c1360612dc32cde711e85ae656f61deace Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 16 Sep 2025 11:03:58 +0200 Subject: [PATCH 12/15] Upgrade to Spring Data 2025.0.4 Closes gh-1309 --- platform/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/build.gradle b/platform/build.gradle index 146352e82..6badab391 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -12,7 +12,7 @@ dependencies { api(platform("io.projectreactor:reactor-bom:2024.0.10")) api(platform("io.micrometer:micrometer-bom:1.15.4")) api(platform("io.micrometer:micrometer-tracing-bom:1.5.4")) - api(platform("org.springframework.data:spring-data-bom:2025.0.1")) + api(platform("org.springframework.data:spring-data-bom:2025.0.4")) api(platform("org.springframework.security:spring-security-bom:6.5.1")) api(platform("com.querydsl:querydsl-bom:5.1.0")) api(platform("io.rsocket:rsocket-bom:1.1.5")) From 357502503a6af1f1af0927efa837fae0c4f7502b Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 16 Sep 2025 11:07:08 +0200 Subject: [PATCH 13/15] Upgrade to Spring Security 6.5.4 Closes gh-1310 --- platform/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/build.gradle b/platform/build.gradle index 6badab391..2021633e9 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -13,7 +13,7 @@ dependencies { api(platform("io.micrometer:micrometer-bom:1.15.4")) api(platform("io.micrometer:micrometer-tracing-bom:1.5.4")) api(platform("org.springframework.data:spring-data-bom:2025.0.4")) - api(platform("org.springframework.security:spring-security-bom:6.5.1")) + api(platform("org.springframework.security:spring-security-bom:6.5.4")) api(platform("com.querydsl:querydsl-bom:5.1.0")) api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.jetbrains.kotlin:kotlin-bom:${kotlinVersion}")) From 1b49ea876c7845b0437e23819bb3ddb62eba7b78 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 16 Sep 2025 11:17:12 +0200 Subject: [PATCH 14/15] Upgrade optional dependencies --- platform/build.gradle | 9 +++------ .../mongo/QueryByExampleDataFetcherMongoDbTests.java | 2 +- .../QueryByExampleDataFetcherReactiveMongoDbTests.java | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/platform/build.gradle b/platform/build.gradle index 2021633e9..83410a9ac 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -20,6 +20,7 @@ dependencies { api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1")) api(platform("org.junit:junit-bom:5.12.2")) api(platform("org.mockito:mockito-bom:5.16.0")) + api(platform("org.mongodb:mongodb-driver-bom:5.5.1")) api(platform("org.testcontainers:testcontainers-bom:1.21.2")) api(platform("org.apache.logging.log4j:log4j-bom:2.24.3")) api(platform("org.assertj:assertj-bom:3.27.3")) @@ -43,12 +44,8 @@ dependencies { api("com.squareup.okhttp3:mockwebserver:4.12.0") api("com.h2database:h2:2.3.232") - api("org.hibernate:hibernate-core:6.6.11.Final") - api("org.hibernate.validator:hibernate-validator:8.0.2.Final") - api("org.mongodb:bson:5.3.1") - api("org.mongodb:mongodb-driver-core:5.3.1") - api("org.mongodb:mongodb-driver-reactivestreams:5.3.1") - api("org.mongodb:mongodb-driver-sync:5.3.1") + api("org.hibernate:hibernate-core:6.6.29.Final") + api("org.hibernate.validator:hibernate-validator:8.0.3.Final") } } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherMongoDbTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherMongoDbTests.java index 8796a867f..95c8ce078 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherMongoDbTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherMongoDbTests.java @@ -71,7 +71,7 @@ class QueryByExampleDataFetcherMongoDbTests { @Container - static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10")); + static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:5.0")); @Autowired private BookMongoRepository repository; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherReactiveMongoDbTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherReactiveMongoDbTests.java index 20168c94c..78ce266d5 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherReactiveMongoDbTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/query/mongo/QueryByExampleDataFetcherReactiveMongoDbTests.java @@ -67,7 +67,7 @@ class QueryByExampleDataFetcherReactiveMongoDbTests { @Container - static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10")); + static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:5.0")); @Autowired private BookReactiveMongoRepository repository; From 2208189a4b6b44b763be3062473213bfd596d4d8 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 16 Sep 2025 16:43:44 +0200 Subject: [PATCH 15/15] Release v1.4.2 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 5f2a303c0..bbf168936 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.4.2-SNAPSHOT +version=1.4.2 org.gradle.caching=true org.gradle.daemon=true