From e7b2f72d551f6f766926eef463e9f3c9060f2740 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 15 Mar 2022 07:44:31 +0000 Subject: [PATCH] Add RetrieveSpec to GraphQlClient Provides a shortcut alternative to the execute methods, for decoding a single field from the response. See gh-10 --- .../graphql/GraphQlRequest.java | 21 +- .../AbstractDelegatingGraphQlClient.java | 4 +- .../graphql/client/ClientGraphQlResponse.java | 8 +- .../client/DefaultClientGraphQlResponse.java | 5 + .../graphql/client/DefaultGraphQlClient.java | 115 +++++++++- .../graphql/client/GraphQlClient.java | 116 +++++++++- .../graphql/client/ResponseField.java | 7 - .../graphql/support/MapGraphQlResponse.java | 1 - .../client/GraphQlClientBuilderTests.java | 13 +- .../client/GraphQlClientTestSupport.java | 39 ++-- .../graphql/client/GraphQlClientTests.java | 209 +++++++++++------- 11 files changed, 414 insertions(+), 124 deletions(-) diff --git a/spring-graphql/src/main/java/org/springframework/graphql/GraphQlRequest.java b/spring-graphql/src/main/java/org/springframework/graphql/GraphQlRequest.java index 56b41a38..80eaeef0 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/GraphQlRequest.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/GraphQlRequest.java @@ -23,6 +23,7 @@ import java.util.Map; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; /** * Represents a GraphQL request with the inputs to pass to a GraphQL service @@ -63,7 +64,7 @@ public class GraphQlRequest { Assert.notNull(document, "'document' is required"); this.document = document; this.operationName = operationName; - this.variables = ((variables != null) ? variables : Collections.emptyMap()); + this.variables = (variables != null ? variables : Collections.emptyMap()); } @@ -115,6 +116,24 @@ public class GraphQlRequest { return map; } + @Override + public boolean equals(Object o) { + if (! (o instanceof GraphQlRequest)) { + return false; + } + GraphQlRequest other = (GraphQlRequest) o; + return (getDocument().equals(other.getDocument()) && + ObjectUtils.nullSafeEquals(getOperationName(), other.getOperationName()) && + ObjectUtils.nullSafeEquals(getVariables(), other.getVariables())); + } + + @Override + public int hashCode() { + return this.document.hashCode() + + 31 * ObjectUtils.nullSafeHashCode(this.operationName) + + 31 * this.variables.hashCode(); + } + @Override public String toString() { return "document='" + getDocument() + "'" + diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractDelegatingGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractDelegatingGraphQlClient.java index c714f32d..dd9a324f 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractDelegatingGraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractDelegatingGraphQlClient.java @@ -42,11 +42,11 @@ public abstract class AbstractDelegatingGraphQlClient implements GraphQlClient { } - public Request document(String document) { + public RequestSpec document(String document) { return this.graphQlClient.document(document); } - public Request documentName(String name) { + public RequestSpec documentName(String name) { return this.graphQlClient.documentName(name); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java b/spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java index 57138185..1580c4f5 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java @@ -18,6 +18,7 @@ package org.springframework.graphql.client; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.GraphQlRequest; import org.springframework.graphql.GraphQlResponse; import org.springframework.lang.Nullable; @@ -30,6 +31,11 @@ import org.springframework.lang.Nullable; */ public interface ClientGraphQlResponse extends GraphQlResponse { + /** + * Return the request associated with this response. + */ + GraphQlRequest getRequest(); + /** * Navigate to the given path under the "data" key of the response map where * the path is a dot-separated string with optional array indexes. @@ -44,7 +50,7 @@ public interface ClientGraphQlResponse extends GraphQlResponse { * @param path relative to the "data" key * @return representation for the field with further options to inspect or * decode its value; use {@link ResponseField#isValid()} to check if the - * field actually exists and its value is present + * field actually exists, has a value, or field errors */ ResponseField field(String path); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java index ef1805fd..d05ed53b 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java @@ -63,6 +63,11 @@ final class DefaultClientGraphQlResponse extends MapGraphQlResponse implements C } + @Override + public GraphQlRequest getRequest() { + return this.request; + } + @Override public ResponseField field(String path) { diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java index ea9e6acb..658a6967 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultGraphQlClient.java @@ -16,12 +16,14 @@ package org.springframework.graphql.client; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.function.Consumer; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Encoder; import org.springframework.graphql.GraphQlRequest; @@ -69,13 +71,13 @@ final class DefaultGraphQlClient implements GraphQlClient { @Override - public Request document(String document) { - return new DefaultRequest(Mono.just(document)); + public RequestSpec document(String document) { + return new DefaultRequestSpec(Mono.just(document)); } @Override - public Request documentName(String name) { - return new DefaultRequest(this.documentSource.getDocument(name)); + public RequestSpec documentName(String name) { + return new DefaultRequestSpec(this.documentSource.getDocument(name)); } @Override @@ -107,9 +109,9 @@ final class DefaultGraphQlClient implements GraphQlClient { /** - * Default {@link GraphQlClient.Request} implementation. + * Default {@link RequestSpec} implementation. */ - private final class DefaultRequest implements Request { + private final class DefaultRequestSpec implements RequestSpec { private final Mono documentMono; @@ -118,29 +120,39 @@ final class DefaultGraphQlClient implements GraphQlClient { private final Map variables = new LinkedHashMap<>(); - DefaultRequest(Mono documentMono) { + DefaultRequestSpec(Mono documentMono) { Assert.notNull(documentMono, "'document' is required"); this.documentMono = documentMono; } @Override - public DefaultRequest operationName(@Nullable String operationName) { + public DefaultRequestSpec operationName(@Nullable String operationName) { this.operationName = operationName; return this; } @Override - public DefaultRequest variable(String name, Object value) { + public DefaultRequestSpec variable(String name, @Nullable Object value) { this.variables.put(name, value); return this; } @Override - public Request variables(Map variables) { + public RequestSpec variables(Map variables) { this.variables.putAll(variables); return this; } + @Override + public RetrieveSpec retrieve(String path) { + return new DefaultRetrieveSpec(execute(), path); + } + + @Override + public RetrieveSubscriptionSpec retrieveSubscription(String path) { + return new DefaultRetrieveSubscriptionSpec(executeSubscription(), path); + } + @Override public Mono execute() { return initRequest().flatMap(request -> @@ -177,4 +189,87 @@ final class DefaultGraphQlClient implements GraphQlClient { } + private static class RetrieveSpecSupport { + + private final String path; + + protected RetrieveSpecSupport(String path) { + this.path = path; + } + + protected ResponseField getField(ClientGraphQlResponse response) { + ResponseField field = response.field(this.path); + if (!field.isValid() || !field.getErrors().isEmpty()) { + GraphQlRequest request = response.getRequest(); + throw new FieldAccessException(request, response, field); + } + return field; + } + + } + + + private static class DefaultRetrieveSpec extends RetrieveSpecSupport implements RetrieveSpec { + + private final Mono responseMono; + + DefaultRetrieveSpec(Mono responseMono, String path) { + super(path); + this.responseMono = responseMono; + } + + @Override + public Mono toEntity(Class entityType) { + return this.responseMono.map(this::getField).mapNotNull(field -> field.toEntity(entityType)); + } + + @Override + public Mono toEntity(ParameterizedTypeReference entityType) { + return this.responseMono.map(this::getField).mapNotNull(field -> field.toEntity(entityType)); + } + + @Override + public Mono> toEntityList(Class elementType) { + return this.responseMono.map(this::getField).map(field -> field.toEntityList(elementType)); + } + + @Override + public Mono> toEntityList(ParameterizedTypeReference elementType) { + return this.responseMono.map(this::getField).map(field -> field.toEntityList(elementType)); + } + + } + + + private static class DefaultRetrieveSubscriptionSpec extends RetrieveSpecSupport implements RetrieveSubscriptionSpec { + + private final Flux responseFlux; + + DefaultRetrieveSubscriptionSpec(Flux responseFlux, String path) { + super(path); + this.responseFlux = responseFlux; + } + + @Override + public Flux toEntity(Class entityType) { + return this.responseFlux.map(this::getField).mapNotNull(field -> field.toEntity(entityType)); + } + + @Override + public Flux toEntity(ParameterizedTypeReference entityType) { + return this.responseFlux.map(this::getField).mapNotNull(field -> field.toEntity(entityType)); + } + + @Override + public Flux> toEntityList(Class elementType) { + return this.responseFlux.map(this::getField).map(field -> field.toEntityList(elementType)); + } + + @Override + public Flux> toEntityList(ParameterizedTypeReference elementType) { + return this.responseFlux.map(this::getField).map(field -> field.toEntityList(elementType)); + } + + } + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java index 48ff2acd..35def12a 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/GraphQlClient.java @@ -15,11 +15,13 @@ */ package org.springframework.graphql.client; +import java.util.List; import java.util.Map; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.graphql.support.DocumentSource; import org.springframework.graphql.support.ResourceDocumentSource; import org.springframework.lang.Nullable; @@ -50,7 +52,7 @@ public interface GraphQlClient { * @param document the document for the request * @return spec to further define or execute the request */ - Request document(String document); + RequestSpec document(String document); /** * Variant of {@link #document(String)} that uses the given key to resolve @@ -58,7 +60,7 @@ public interface GraphQlClient { * {@link DocumentSource} that the client is configured with. * @throws IllegalArgumentException if the content could not be loaded */ - Request documentName(String name); + RequestSpec documentName(String name); /** * Return a builder initialized from the configuration of "this" client @@ -103,7 +105,7 @@ public interface GraphQlClient { /** * Declare options to gather input for a GraphQL request and execute it. */ - interface Request { + interface RequestSpec { /** * Set the name of the operation in the {@link #document(String) document} @@ -111,7 +113,7 @@ public interface GraphQlClient { * @param operationName the operation name * @return this request spec */ - Request operationName(@Nullable String operationName); + RequestSpec operationName(@Nullable String operationName); /** * Add a value for a variable defined by the operation. @@ -119,18 +121,32 @@ public interface GraphQlClient { * @param value the variable value * @return this request spec */ - Request variable(String name, Object value); + RequestSpec variable(String name, @Nullable Object value); /** * Add all given values for variables defined by the operation. * @param variables the variable values * @return this request spec */ - Request variables(Map variables); + RequestSpec variables(Map variables); /** - * Execute as a request with a single response such as a "query" or - * "mutation" operation. + * Shortcut for {@link #execute()} with a single field path to decode from. + * @return a spec with decoding options + * @throws FieldAccessException if the target field has any errors, + * including nested errors. + */ + RetrieveSpec retrieve(String path); + + /** + * Shortcut for {@link #executeSubscription()} with a single field path to decode from. + * @return a spec with decoding options + */ + RetrieveSubscriptionSpec retrieveSubscription(String path); + + /** + * Execute request with a single response, e.g. "query" or "mutation", and + * return a response for further options. * @return a {@code Mono} with a {@code ClientGraphQlResponse} for further * decoding of the response. The {@code Mono} may end wth an error due * to transport level issues. @@ -138,7 +154,7 @@ public interface GraphQlClient { Mono execute(); /** - * Execute a "subscription" request with a stream of responses. + * Execute a "subscription" request and return a stream of responses. * @return a {@code Flux} with a {@code ClientGraphQlResponse} for further * decoding of the response. The {@code Flux} may terminate as follows: *
    @@ -155,4 +171,86 @@ public interface GraphQlClient { } + + /** + * Declares options to decode a field for a single response operation. + */ + interface RetrieveSpec { + + /** + * Decode the field to an entity of the given type. + * @param entityType the type to convert to + * @return {@code Mono} with the decoded entity, possibly empty if the field + * {@link ResponseField#getValue() value} is {@code null} + * @throws FieldAccessException if the target field is not + * {@link ResponseField#isValid() valid} or has any errors, including + * nested errors. + */ + Mono toEntity(Class entityType); + + /** + * Variant of {@link #toEntity(Class)} with a {@link ParameterizedTypeReference}. + */ + Mono toEntity(ParameterizedTypeReference entityType); + + /** + * Decode the field to a list of entities with the given type. + * @param elementType the type of elements in the list + * @return {@code Mono} with the list of decoded entities, possibly an + * empty list if the field {@link ResponseField#getValue() value} is + * {@code null} or empty + * @throws FieldAccessException if the target field is not + * {@link ResponseField#isValid() valid} or has any errors, including + * nested errors. + */ + Mono> toEntityList(Class elementType); + + /** + * Variant of {@link #toEntityList(Class)} with {@link ParameterizedTypeReference}. + */ + Mono> toEntityList(ParameterizedTypeReference elementType); + + } + + + /** + * Declares options to decode a field in each response of a subscription. + */ + interface RetrieveSubscriptionSpec { + + /** + * Decode the field to an entity of the given type. + * @param entityType the type to convert to + * @return {@code Mono} with the decoded entity, possibly empty if the field + * {@link ResponseField#getValue() value} is {@code null} + * @throws FieldAccessException if the target field is not + * {@link ResponseField#isValid() valid} or has any errors, including + * nested errors. + */ + Flux toEntity(Class entityType); + + /** + * Variant of {@link #toEntity(Class)} with a {@link ParameterizedTypeReference}. + */ + Flux toEntity(ParameterizedTypeReference entityType); + + /** + * Decode the field to a list of entities with the given type. + * @param elementType the type of elements in the list + * @return {@code Mono} with the list of decoded entities, possibly an + * empty list if the field {@link ResponseField#getValue() value} is + * {@code null} or empty + * @throws FieldAccessException if the target field is not + * {@link ResponseField#isValid() valid} or has any errors, including + * nested errors. + */ + Flux> toEntityList(Class elementType); + + /** + * Variant of {@link #toEntityList(Class)} with {@link ParameterizedTypeReference}. + */ + Flux> toEntityList(ParameterizedTypeReference elementType); + + } + } \ No newline at end of file diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/ResponseField.java b/spring-graphql/src/main/java/org/springframework/graphql/client/ResponseField.java index c10ca9df..ec65d819 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/ResponseField.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/ResponseField.java @@ -84,10 +84,6 @@ public interface ResponseField { /** * Variant of {@link #toEntity(Class)} with a {@link ParameterizedTypeReference}. - * @param entityType the type to convert to - * @return the decoded entity, possibly {@code null} if the field - * {@link #getValue() value} is {@code null} - * @throws FieldAccessException if "this" field is not {@link #isValid() valid} */ @Nullable D toEntity(ParameterizedTypeReference entityType); @@ -102,9 +98,6 @@ public interface ResponseField { /** * Variant of {@link #toEntityList(Class)} with {@link ParameterizedTypeReference}. - * @param elementType the type of elements in the list - * @return the decoded list of entities, possibly empty - * @throws FieldAccessException if "this" field is not {@link #isValid() valid} */ List toEntityList(ParameterizedTypeReference elementType); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/support/MapGraphQlResponse.java b/spring-graphql/src/main/java/org/springframework/graphql/support/MapGraphQlResponse.java index 869af2d7..1ee11b12 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/support/MapGraphQlResponse.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/support/MapGraphQlResponse.java @@ -49,7 +49,6 @@ public class MapGraphQlResponse implements GraphQlResponse { private final List errors; - @SuppressWarnings("unchecked") protected MapGraphQlResponse(Map responseMap) { Assert.notNull(responseMap, "'responseMap' is required"); this.responseMap = responseMap; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientBuilderTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientBuilderTests.java index 7f1e054e..fc1e6c55 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientBuilderTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientBuilderTests.java @@ -40,21 +40,22 @@ public class GraphQlClientBuilderTests extends GraphQlClientTestSupport { DocumentSource documentSource = name -> name.equals("name") ? Mono.just(DOCUMENT) : Mono.error(new IllegalArgumentException()); - setMockResponse("{}"); + initDataResponse(DOCUMENT, "{}"); // Original GraphQlClient.Builder builder = graphQlClientBuilder().documentSource(documentSource); GraphQlClient client = builder.build(); - client.documentName("name").execute().block(TIMEOUT); + ClientGraphQlResponse response = client.documentName("name").execute().block(TIMEOUT); - GraphQlRequest request = request(); - assertThat(request.getDocument()).isEqualTo(DOCUMENT); + assertThat(response).isNotNull(); + assertThat(response.isValid()).isTrue(); // Mutate client = client.mutate().build(); - client.documentName("name").execute().block(TIMEOUT); + response = client.documentName("name").execute().block(TIMEOUT); - assertThat(request().getDocument()).isEqualTo(DOCUMENT); + assertThat(response).isNotNull(); + assertThat(response.isValid()).isTrue(); } } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTestSupport.java b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTestSupport.java index 156ed599..1761fa0b 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTestSupport.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTestSupport.java @@ -19,7 +19,6 @@ package org.springframework.graphql.client; import java.time.Duration; import java.util.Arrays; import java.util.Map; -import java.util.function.Consumer; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,9 +29,11 @@ import org.mockito.ArgumentCaptor; import reactor.core.publisher.Mono; import org.springframework.graphql.GraphQlRequest; -import org.springframework.graphql.GraphQlResponse; import org.springframework.graphql.support.MapGraphQlResponse; +import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -71,25 +72,37 @@ public class GraphQlClientTestSupport { } - protected void setMockResponse(String data) { - setMockResponse(builder -> serialize(data, builder)); + protected void initDataResponse(String document, String responseData) { + initResponse(new GraphQlRequest(document), responseData); } - protected void setMockResponse(GraphQLError... errors) { - setMockResponse(builder -> builder.errors(Arrays.asList(errors))); + protected void initErrorResponse(String document, GraphQLError... errors) { + initResponse(new GraphQlRequest(document), null, errors); } - private void setMockResponse(Consumer consumer) { + protected void initResponse(String document, String responseData, GraphQLError... errors) { + initResponse(new GraphQlRequest(document), responseData, errors); + } + + protected void initResponse(GraphQlRequest request, @Nullable String responseData, GraphQLError... errors) { ExecutionResultImpl.Builder builder = new ExecutionResultImpl.Builder(); - consumer.accept(builder); - ExecutionResult result = builder.build(); - GraphQlResponse response = MapGraphQlResponse.forResponse(result.toSpecification()); - when(this.transport.execute(this.requestCaptor.capture())).thenReturn(Mono.just(response)); + if (responseData != null) { + builder.data(decode(responseData)); + } + if (!ObjectUtils.isEmpty(errors)) { + builder.errors(Arrays.asList(errors)); + } + ExecutionResult executionResult = builder.build(); + Map responseMap = executionResult.toSpecification(); + + when(this.transport.execute(eq(request))) + .thenReturn(Mono.just(MapGraphQlResponse.forResponse(responseMap))); } - private void serialize(String data, ExecutionResultImpl.Builder builder) { + @SuppressWarnings("unchecked") + private T decode(String data) { try { - builder.data(OBJECT_MAPPER.readValue(data, Map.class)); + return (T) OBJECT_MAPPER.readValue(data, Map.class); } catch (JsonProcessingException ex) { throw new IllegalStateException(ex); diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java index 861200a8..cdd52ecc 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java @@ -15,10 +15,16 @@ */ package org.springframework.graphql.client; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; import java.util.Map; import graphql.GraphQLError; import graphql.GraphqlErrorBuilder; +import graphql.execution.NonNullableValueCoercedAsNullException; +import graphql.execution.ResultPath; +import graphql.schema.GraphQLObjectType; import graphql.validation.ValidationError; import graphql.validation.ValidationErrorType; import org.junit.jupiter.api.Test; @@ -27,6 +33,7 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.graphql.GraphQlRequest; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** @@ -37,111 +44,165 @@ import static org.assertj.core.api.Assertions.assertThat; public class GraphQlClientTests extends GraphQlClientTestSupport { @Test - void entity() { - String document = "{me {name}}"; - setMockResponse("{\"me\": {\"name\":\"Luke Skywalker\"}}"); + void retrieveEntity() { - MovieCharacter character = MovieCharacter.create("Luke Skywalker"); + String document = "mockRequest1"; + initDataResponse(document, "{\"me\": {\"name\":\"Luke Skywalker\"}}"); - ClientGraphQlResponse response = execute(document); - assertThat(response.isValid()).isTrue(); + MovieCharacter movieCharacter = graphQlClient().document(document) + .retrieve("me").toEntity(MovieCharacter.class) + .block(TIMEOUT); - ResponseField field = response.field("me"); - assertThat(field.isValid()).isTrue(); - assertThat(field.toEntity(MovieCharacter.class)).isEqualTo(character); - - Map map = response.toEntity(new ParameterizedTypeReference>() {}); - assertThat(map).containsEntry("me", character); - assertThat(request().getDocument()).contains(document); + assertThat(movieCharacter).isEqualTo(MovieCharacter.create("Luke Skywalker")); } @Test - void entityList() { + void retrieveEntityList() { - String document = "{me {name, friends}}"; - setMockResponse("{" + + String document = "mockRequest1"; + initDataResponse(document, "{" + " \"me\":{" + " \"name\":\"Luke Skywalker\"," + " \"friends\":[{\"name\":\"Han Solo\"}, {\"name\":\"Leia Organa\"}]" + " }" + "}"); - MovieCharacter han = MovieCharacter.create("Han Solo"); - MovieCharacter leia = MovieCharacter.create("Leia Organa"); - - ClientGraphQlResponse response = execute(document); - assertThat(response.isValid()).isTrue(); - - ResponseField field = response.field("me.friends"); - assertThat(field.toEntityList(MovieCharacter.class)).containsExactly(han, leia); - assertThat(field.toEntityList(new ParameterizedTypeReference() {})).containsExactly(han, leia); - - assertThat(request().getDocument()).contains(document); - } - - @Test - void operationNameAndVariables() { - - String document = "query HeroNameAndFriends($episode: Episode) {" + - " hero(episode: $episode) {" + - " name" - + " }" + - "}"; - setMockResponse("{\"hero\": {\"name\":\"R2-D2\"}}"); - - ClientGraphQlResponse response = graphQlClient().document(document) - .operationName("HeroNameAndFriends") - .variable("episode", "JEDI") - .variable("foo", "bar") - .variable("keyOnly", null) - .execute() + List movieCharacters = graphQlClient().document(document) + .retrieve("me.friends") + .toEntityList(MovieCharacter.class) .block(TIMEOUT); - assertThat(response).isNotNull(); - assertThat(response.isValid()).isTrue(); - assertThat(response.field("hero").toEntity(MovieCharacter.class)).isEqualTo(MovieCharacter.create("R2-D2")); - - GraphQlRequest request = request(); - assertThat(request.getDocument()).contains(document); - assertThat(request.getOperationName()).isEqualTo("HeroNameAndFriends"); - assertThat(request.getVariables()).hasSize(3); - assertThat(request.getVariables()).containsEntry("episode", "JEDI"); - assertThat(request.getVariables()).containsEntry("foo", "bar"); - assertThat(request.getVariables()).containsEntry("keyOnly", null); + assertThat(movieCharacters).containsExactly( + MovieCharacter.create("Han Solo"), MovieCharacter.create("Leia Organa")); } @Test - void requestFailureBeforeExecution() { + void retrieveAndDecodeDataMap() { - String document = "{invalid"; - setMockResponse(new ValidationError(ValidationErrorType.InvalidSyntax)); + String document = "mockRequest1"; + initDataResponse(document, "{\"me\": {\"name\":\"Luke Skywalker\"}}"); - ClientGraphQlResponse response = execute(document); + Map map = graphQlClient().document(document) + .retrieve("").toEntity(new ParameterizedTypeReference>() {}) + .block(TIMEOUT); + assertThat(map).containsEntry("me", MovieCharacter.create("Luke Skywalker")); + } + + @Test + void retrieveWithOperationNameAndVariables() { + + String document = "mockRequest1"; + String operationName = "HeroNameAndFriends"; + + Map vars = new HashMap<>(); + vars.put("episode", "JEDI"); + vars.put("foo", "bar"); + vars.put("keyOnly", null); + + GraphQlRequest request = new GraphQlRequest("mockRequest1", "HeroNameAndFriends", vars); + initResponse(request, "{\"hero\": {\"name\":\"R2-D2\"}}"); + + MovieCharacter character = graphQlClient().document(document) + .operationName(operationName) + .variables(vars) + .variable("keyOnly", null) + .retrieve("hero") + .toEntity(MovieCharacter.class) + .block(TIMEOUT); + + assertThat(character).isEqualTo(MovieCharacter.create("R2-D2")); + } + + @Test + void retrieveInvalidResponse() { + + String document = "errorsOnlyResponse"; + initErrorResponse(document, new ValidationError(ValidationErrorType.InvalidSyntax)); + testRetrieveFieldAccessException(document, "me"); + + document = "nullDataResponse"; + GraphQLObjectType type = GraphQLObjectType.newObject().name("n").build(); + initResponse(document, "null", new NonNullableValueCoercedAsNullException("f", new ArrayList<>(), type)); + testRetrieveFieldAccessException(document, "me"); + } + + @Test + void retrievePartialResponse() { + + String document = "fieldErrorResponse"; + initResponse(document, "{\"me\": {\"name\":null}}", errorForPath("/me/name")); + + testRetrieveFieldAccessException(document, "me"); + testRetrieveFieldAccessException(document, "me.name"); + } + + private void testRetrieveFieldAccessException(String document, String path) { + assertThatThrownBy(() -> + graphQlClient().document(document) + .retrieve(path).toEntity(MovieCharacter.class) + .block(TIMEOUT)) + .isInstanceOf(FieldAccessException.class); + } + + @Test + void executeInvalidResponse() { + + String document = "errorsOnlyResponse"; + initErrorResponse(document, new ValidationError(ValidationErrorType.InvalidSyntax)); + testExecuteFailedResponse(document); + + document = "nullDataResponse"; + GraphQLObjectType type = GraphQLObjectType.newObject().name("n").build(); + initResponse(document, "null", new NonNullableValueCoercedAsNullException("f", new ArrayList<>(), type)); + testExecuteFailedResponse(document); + } + + private void testExecuteFailedResponse(String document) { + + ClientGraphQlResponse response = + graphQlClient().document(document).execute().block(TIMEOUT); + + assertThat(response).isNotNull(); assertThat(response.isValid()).isFalse(); assertThat(response.field("me").isValid()).isFalse(); + + assertThatThrownBy(() -> response.field("me").toEntity(MovieCharacter.class)) + .isInstanceOf(FieldAccessException.class); } @Test - void errors() { + void executePartialResponse() { - String document = "{me {name, friends}}"; - setMockResponse( - GraphqlErrorBuilder.newError().message("some error").build(), - GraphqlErrorBuilder.newError().message("some other error").build()); + String document = "fieldErrorResponse"; + initResponse(document, "{\"me\": {\"name\":null}}", errorForPath("/me/name")); + testRetrieveFieldAccessException(document, "me.name"); - ClientGraphQlResponse response = execute(document); - assertThat(response.isValid()).isFalse(); + ClientGraphQlResponse response = + graphQlClient().document(document).execute().block(TIMEOUT); - assertThat(response.getErrors()) - .extracting(GraphQLError::getMessage) - .containsExactly("some error", "some other error"); + assertThat(response).isNotNull(); + assertThat(response.isValid()) + .as("Partial response with field errors should be considered valid") + .isTrue(); + + ResponseField field = response.field("me"); + assertThat(field.isValid()).isTrue(); + assertThat(field.toEntity(MovieCharacter.class)) + .as("Decoding with nested field error should not be precluded") + .isNotNull(); + + ResponseField nameField = response.field("me.name"); + assertThat(nameField.isValid()).isFalse(); + assertThatThrownBy(() -> nameField.toEntity(String.class)) + .as("Decoding field null with direct field error should be rejected") + .isInstanceOf(FieldAccessException.class); } - private ClientGraphQlResponse execute(String document) { - ClientGraphQlResponse response = graphQlClient().document(document).execute().block(TIMEOUT); - assertThat(response).isNotNull(); - return response; + private GraphQLError errorForPath(String errorPath) { + return GraphqlErrorBuilder.newError() + .message("Test error") + .path(ResultPath.parse(errorPath).toList()).build(); } }