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 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 { diff --git a/gradle.properties b/gradle.properties index 5efc15c26..bbf168936 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=1.4.1-SNAPSHOT +version=1.4.2 org.gradle.caching=true org.gradle.daemon=true diff --git a/platform/build.gradle b/platform/build.gradle index 024237fcc..83410a9ac 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -9,17 +9,18 @@ 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.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")) - api(platform("org.springframework.security:spring-security-bom:6.5.1")) + 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.4")) + 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}")) 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-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] 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/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java b/spring-graphql/src/main/java/org/springframework/graphql/data/GraphQlArgumentBinder.java index 20f371891..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 @@ -20,10 +20,19 @@ 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; 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; @@ -35,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; @@ -276,9 +286,10 @@ 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]; + Object[] constructorArguments = new Object[paramNames.length]; for (int i = 0; i < paramNames.length; i++) { String name = paramNames[i]; @@ -287,7 +298,12 @@ 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); + } + if (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(constructor.getDeclaringClass())) { + KotlinDelegate.rebindKotlinArguments(constructorArguments, constructor); } Object target; @@ -302,9 +318,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; @@ -416,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/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/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/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); } 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\":[]," + 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))) + ); + } + } 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; 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) { + + } + +}