diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultWebSocketGraphQlTester.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultWebSocketGraphQlTester.java index 045a5f6c7..0161672d4 100644 --- a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultWebSocketGraphQlTester.java +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultWebSocketGraphQlTester.java @@ -170,7 +170,7 @@ public Mono execute(GraphQlRequest request) { .operationName(request.getOperationName()) .variables(request.getVariables()) .execute() - .map(GraphQlClient.Response::andReturn); + .cast(GraphQlResponse.class); } @Override @@ -179,7 +179,8 @@ public Flux executeSubscription(GraphQlRequest request) { .document(request.getDocument()) .operationName(request.getOperationName()) .variables(request.getVariables()) - .executeSubscription().map(GraphQlClient.Response::andReturn); + .executeSubscription() + .cast(GraphQlResponse.class); } }; } 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 new file mode 100644 index 000000000..0e0ec8867 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.client; + + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.graphql.GraphQlResponse; + +/** + * {@link GraphQlResponse} for client use with further options to navigate and + * handle the selection set in the response. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public interface ClientGraphQlResponse extends GraphQlResponse { + + + /** + * Navigate to the given path under the "data" key of the response map and + * return a representation with further options to decode the field value, + * or to check whether it's valid, and so on. + * @param path relative to the "data" key. + * @return a representation for the field at the given path; this + */ + ResponseField field(String path); + + /** + * Decode the full response map to the given target type. + * @param type the target class + * @return the decoded value + */ + D toEntity(Class type); + + /** + * Variant of {@link #toEntity(Class)} with a {@link ParameterizedTypeReference}. + * @param type the target type + * @return the decoded value + */ + D toEntity(ParameterizedTypeReference type); + +} 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 new file mode 100644 index 000000000..1d9cf80e5 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java @@ -0,0 +1,252 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.client; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.PathNotFoundException; +import com.jayway.jsonpath.TypeRef; +import graphql.GraphQLError; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.graphql.GraphQlResponse; +import org.springframework.graphql.support.MapGraphQlResponse; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + + +/** + * Default implementation of {@link ClientGraphQlResponse}. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +class DefaultClientGraphQlResponse extends MapGraphQlResponse implements ClientGraphQlResponse { + + private final DocumentContext jsonPathDoc; + + + DefaultClientGraphQlResponse(GraphQlResponse response, Configuration jsonPathConfig) { + super(response.toMap()); + this.jsonPathDoc = JsonPath.parse(response.toMap(), jsonPathConfig); + } + + + @Override + public D toEntity(Class type) { + assertValidResponse(); + return field("").toEntity(type); + } + + @Override + public D toEntity(ParameterizedTypeReference type) { + assertValidResponse(); + return field("").toEntity(type); + } + + @Override + public ResponseField field(String path) { + path = "$.data" + (StringUtils.hasText(path) ? "." + path : ""); + return new DefaultField(path, this.jsonPathDoc, getErrors()); + } + + private void assertValidResponse() { + if (!isValid()) { + throw new IllegalStateException("Path not present exception"); + } + } + + + /** + * Default implementation of {@link ResponseField}. + */ + private static class DefaultField implements ResponseField { + + private final String path; + + private final DocumentContext jsonPathDoc; + + private final List errorsAt; + + private final List errorsBelow; + + private final boolean exists; + + @Nullable + private final Object value; + + public DefaultField(String path, DocumentContext jsonPathDoc, List errors) { + Assert.notNull(path, "'path' is required"); + this.path = path; + this.jsonPathDoc = jsonPathDoc; + + List errorsAt = null; + List errorsBelow = null; + + for (GraphQLError error : errors) { + String errorPath = toJsonPath(error); + if (errorPath == null) { + continue; + } + if (errorPath.equals(path)) { + errorsAt = (errorsAt != null ? errorsAt : new ArrayList<>()); + errorsAt.add(error); + } + if (errorPath.startsWith(path)) { + errorsBelow = (errorsBelow != null ? errorsBelow : new ArrayList<>()); + errorsBelow.add(error); + } + } + + this.errorsAt = (errorsAt != null ? errorsAt : Collections.emptyList()); + this.errorsBelow = (errorsBelow != null ? errorsBelow : Collections.emptyList()); + + + boolean exists = true; + Object value = null; + try { + value = jsonPathDoc.read(this.path); + } + catch (PathNotFoundException ex) { + exists = false; + } + + this.exists = exists; + this.value = value; + } + + @Nullable + private String toJsonPath(GraphQLError error) { + if (CollectionUtils.isEmpty(error.getPath())) { + return null; + } + List segments = error.getPath(); + StringBuilder sb = new StringBuilder((String) segments.get(0)); + for (int i = 1; i < segments.size(); i++) { + Object segment = segments.get(i); + if (segment instanceof Integer) { + sb.append("[").append(segment).append("]"); + } + else { + sb.append(".").append(segment); + } + } + return sb.toString(); + } + + @Override + public String getPath() { + return this.path; + } + + @Override + public boolean isValid() { + return (this.exists && (this.value != null || (this.errorsAt.isEmpty() && this.errorsBelow.isEmpty()))); + } + + @SuppressWarnings("unchecked") + @Override + public T getValue() { + return (T) this.value; + } + + @Override + public List getErrorsAt() { + return this.errorsAt; + } + + @Override + public List getErrorsBelow() { + return this.errorsBelow; + } + + @Override + public D toEntity(Class entityType) { + assertValidField(); + return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(entityType)); + } + + @Override + public D toEntity(ParameterizedTypeReference entityType) { + assertValidField(); + return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(entityType)); + } + + @Override + public List toEntityList(Class elementType) { + assertValidField(); + return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(List.class, elementType)); + } + + @Override + public List toEntityList(ParameterizedTypeReference elementType) { + assertValidField(); + return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(List.class, elementType)); + } + + private void assertValidField() { + if (!isValid()) { + throw (CollectionUtils.isEmpty(this.errorsAt) ? + new IllegalStateException("Path not present exception") : + new IllegalStateException("Field error exception")); + } + } + + } + + + /** + * Adapt JSONPath {@link TypeRef} to {@link ParameterizedTypeReference}. + */ + private static final class TypeRefAdapter extends TypeRef { + + private final Type type; + + TypeRefAdapter(Class clazz) { + this.type = clazz; + } + + TypeRefAdapter(ParameterizedTypeReference typeReference) { + this.type = typeReference.getType(); + } + + TypeRefAdapter(Class clazz, Class generic) { + this.type = ResolvableType.forClassWithGenerics(clazz, generic).getType(); + } + + TypeRefAdapter(Class clazz, ParameterizedTypeReference generic) { + this.type = ResolvableType.forClassWithGenerics(clazz, ResolvableType.forType(generic)).getType(); + } + + @Override + public Type getType() { + return this.type; + } + + } + + +} 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 7b7912bf5..8839ae7bb 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 @@ -15,28 +15,18 @@ */ package org.springframework.graphql.client; -import java.lang.reflect.Type; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.function.Consumer; import com.jayway.jsonpath.Configuration; -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import com.jayway.jsonpath.TypeRef; -import graphql.GraphQLError; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.core.ResolvableType; import org.springframework.graphql.GraphQlRequest; -import org.springframework.graphql.GraphQlResponse; import org.springframework.graphql.support.DocumentSource; import org.springframework.lang.Nullable; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; /** * Default, final {@link GraphQlClient} implementation for use with any transport. @@ -152,20 +142,20 @@ public Request variables(Map variables) { } @Override - public Mono execute() { - return getRequestMono() - .flatMap(this.transport::execute) - .map(payload -> new DefaultResponse(payload, this.jsonPathConfig)); + public Mono execute() { + return initRequest().flatMap(request -> + this.transport.execute(request) + .map(response -> new DefaultClientGraphQlResponse(response, this.jsonPathConfig))); } @Override - public Flux executeSubscription() { - return getRequestMono() - .flatMapMany(this.transport::executeSubscription) - .map(payload -> new DefaultResponse(payload, this.jsonPathConfig)); + public Flux executeSubscription() { + return initRequest().flatMapMany(request -> + this.transport.executeSubscription(request) + .map(response -> new DefaultClientGraphQlResponse(response, this.jsonPathConfig))); } - private Mono getRequestMono() { + private Mono initRequest() { return this.documentMono.map(document -> new GraphQlRequest(document, this.operationName, this.variables)); } @@ -173,93 +163,4 @@ private Mono getRequestMono() { } - /** - * Default {@link GraphQlClient.Response} implementation. - */ - private static class DefaultResponse implements Response { - - private final GraphQlResponse response; - - private final DocumentContext jsonPathDoc; - - private final List errors; - - private DefaultResponse(GraphQlResponse response, Configuration jsonPathConfig) { - this.response = response; - this.jsonPathDoc = JsonPath.parse(response.toMap(), jsonPathConfig); - this.errors = response.getErrors(); - } - - @Override - public D toEntity(String path, Class entityType) { - return this.jsonPathDoc.read(initJsonPath(path), new TypeRefAdapter<>(entityType)); - } - - @Override - public D toEntity(String path, ParameterizedTypeReference entityType) { - return this.jsonPathDoc.read(initJsonPath(path), new TypeRefAdapter<>(entityType)); - } - - @Override - public List toEntityList(String path, Class elementType) { - return this.jsonPathDoc.read(initJsonPath(path), new TypeRefAdapter<>(List.class, elementType)); - } - - @Override - public List toEntityList(String path, ParameterizedTypeReference elementType) { - return this.jsonPathDoc.read(initJsonPath(path), new TypeRefAdapter<>(List.class, elementType)); - } - - private static JsonPath initJsonPath(String path) { - if (!StringUtils.hasText(path)) { - path = "$.data"; - } - else if (!path.startsWith("$") && !path.startsWith("data.")) { - path = "$.data." + path; - } - return JsonPath.compile(path); - } - - @Override - public List errors() { - return this.errors; - } - - @Override - public GraphQlResponse andReturn() { - return this.response; - } - - } - - - /** - * Adapt JSONPath {@link TypeRef} to {@link ParameterizedTypeReference}. - */ - private static final class TypeRefAdapter extends TypeRef { - - private final Type type; - - TypeRefAdapter(Class clazz) { - this.type = clazz; - } - - TypeRefAdapter(ParameterizedTypeReference typeReference) { - this.type = typeReference.getType(); - } - - TypeRefAdapter(Class clazz, Class generic) { - this.type = ResolvableType.forClassWithGenerics(clazz, generic).getType(); - } - - TypeRefAdapter(Class clazz, ParameterizedTypeReference generic) { - this.type = ResolvableType.forClassWithGenerics(clazz, ResolvableType.forType(generic)).getType(); - } - - @Override - public Type getType() { - return this.type; - } - - } } 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 5fba9a3b7..48ff2acdf 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,15 +15,11 @@ */ package org.springframework.graphql.client; -import java.util.List; import java.util.Map; -import graphql.GraphQLError; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.graphql.GraphQlResponse; import org.springframework.graphql.support.DocumentSource; import org.springframework.graphql.support.ResourceDocumentSource; import org.springframework.lang.Nullable; @@ -135,15 +131,15 @@ interface Request { /** * Execute as a request with a single response such as a "query" or * "mutation" operation. - * @return a {@code Mono} with a {@code ResponseSpec} for further + * @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. */ - Mono execute(); + Mono execute(); /** * Execute a "subscription" request with a stream of responses. - * @return a {@code Flux} with a {@code ResponseSpec} for further + * @return a {@code Flux} with a {@code ClientGraphQlResponse} for further * decoding of the response. The {@code Flux} may terminate as follows: *
    *
  • Completes if the subscription completes before the connection is closed. @@ -155,72 +151,7 @@ interface Request { *

    The {@code Flux} may be cancelled to notify the server to end the * subscription stream. */ - Flux executeSubscription(); - - } - - - /** - * Declare options to decode a response. - */ - interface Response { - - /** - * Switch to the given the "data" path of the GraphQL response and - * convert the data to the target type. The path can be an operation - * root type name, e.g. "book", or a nested path such as "book.name", - * or any JsonPath - * relative to the "data" key of the response. - * @param path a JSON path to the data of interest - * @param entityType the type to convert to - * @param the target entity type - * @return the entity resulting from the conversion - */ - D toEntity(String path, Class entityType); - - /** - * Variant of {@link #toEntity(String, Class)} for entity classes with - * generic types. - * @param path a JSON path to the data of interest - * @param entityType the type to convert to - * @param the target entity type - * @return the entity resulting from the conversion - */ - D toEntity(String path, ParameterizedTypeReference entityType); - - /** - * Switch to the given the "data" path of the GraphQL response and - * convert the data to a List with the given element type. - * The path can be an operation root type name, e.g. "book", or a - * nested path such as "book.name", or any - * JsonPath - * relative to the "data" key of the response. - * @param path a JSON path to the data of interest - * @param elementType the type of element to convert to - * @param the target entity type - * @return the list of entities resulting from the conversion - */ - List toEntityList(String path, Class elementType); - - /** - * Variant of {@link #toEntityList(String, Class)} for entity classes - * with generic types. - * @param path a JSON path to the data of interest - * @param elementType the type to convert to - * @param the target entity type - * @return the list of entities resulting from the conversion - */ - List toEntityList(String path, ParameterizedTypeReference elementType); - - /** - * Return the errors from the response or an empty list. - */ - List errors(); - - /** - * Return the underlying {@link GraphQlResponse}. - */ - GraphQlResponse andReturn(); + Flux executeSubscription(); } 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 new file mode 100644 index 000000000..c941435c5 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/ResponseField.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.graphql.client; + + +import java.util.List; + +import graphql.GraphQLError; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.lang.Nullable; + +/** + * Representation for a field in a GraphQL response, with options to examine its + * value and errors, and to decode it. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public interface ResponseField { + + /** + * Whether the field is valid. A field is invalid if: + *

      + *
    • the path doesn't exist + *
    • it is {@code null} AND has a field error + *
    + *

    A field that is not {@code null} is valid but may still be partial + * with some fields below it set to {@code null} due to field errors. + * A valid field may be {@code null} if the schema allows it, but in that + * case it will not have any field errors. + */ + boolean isValid(); + + /** + * Return the path for the field under the "data" key in the response map. + */ + String getPath(); + + /** + * Return the field value without any decoding. + * @param the expected value type, e.g. Map, List, or a scalar type. + * @return the value + */ + @Nullable + T getValue(); + + /** + * Return errors with paths matching that of the field. + */ + List getErrorsAt(); + + /** + * Return errors with paths below that of the field. + */ + List getErrorsBelow(); + + /** + * Decode the field to an entity of the given type. + * @param entityType the type to convert to + * @return the entity instance + */ + D toEntity(Class entityType); + + /** + * Variant of {@link #toEntity(Class)} with a {@link ParameterizedTypeReference}. + * @param entityType the type to convert to + * @return the entity instance + */ + D 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 the list of entities + */ + List toEntityList(Class elementType); + + /** + * Variant of {@link #toEntityList(Class)} with {@link ParameterizedTypeReference}. + * @param elementType the type of elements in the list + * @return the list of entities + */ + List toEntityList(ParameterizedTypeReference elementType); + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/WebSocketGraphQlTransport.java b/spring-graphql/src/main/java/org/springframework/graphql/client/WebSocketGraphQlTransport.java index e20a634ff..eaf9e952f 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/WebSocketGraphQlTransport.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/WebSocketGraphQlTransport.java @@ -496,15 +496,15 @@ public void handleError(GraphQlMessage message) { return; } - List> payload = message.getPayload(); + List> errorList = message.getPayload(); Sinks.EmitResult emitResult; if (sink != null) { - GraphQlResponse response = MapGraphQlResponse.forErrorsOnly(payload); + GraphQlResponse response = MapGraphQlResponse.forErrorsOnly(errorList); emitResult = sink.tryEmitValue(response); } else { - List graphQLErrors = MapGraphQlError.from(payload); + List graphQLErrors = MapGraphQlError.from(errorList); Exception ex = new SubscriptionErrorException(graphQLErrors); emitResult = streamingSink.tryEmitError(ex); } 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 7454e720a..7e65ec703 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 @@ -32,24 +32,24 @@ * @author Rossen Stoyanchev * @since 1.0.0 */ -public final class MapGraphQlResponse implements GraphQlResponse { +public class MapGraphQlResponse implements GraphQlResponse { - private final Map resultMap; + private final Map responseMap; private final List errors; @SuppressWarnings("unchecked") - private MapGraphQlResponse(Map resultMap) { - Assert.notNull(resultMap, "'resultMap' is required"); - this.resultMap = resultMap; - this.errors = MapGraphQlError.from((List>) resultMap.get("errors")); + protected MapGraphQlResponse(Map responseMap) { + Assert.notNull(responseMap, "'responseMap' is required"); + this.responseMap = responseMap; + this.errors = MapGraphQlError.from((List>) responseMap.get("errors")); } @Override public boolean isValid() { - return (this.resultMap.containsKey("data") && this.resultMap.get("data") != null); + return (this.responseMap.containsKey("data") && this.responseMap.get("data") != null); } @Override @@ -60,34 +60,34 @@ public List getErrors() { @SuppressWarnings("unchecked") @Override public T getData() { - return (T) this.resultMap.get("data"); + return (T) this.responseMap.get("data"); } @SuppressWarnings("unchecked") @Override public Map getExtensions() { - return (Map) this.resultMap.getOrDefault("extensions", Collections.emptyMap()); + return (Map) this.responseMap.getOrDefault("extensions", Collections.emptyMap()); } @Override public Map toMap() { - return this.resultMap; + return this.responseMap; } @Override public boolean equals(Object other) { return (other instanceof MapGraphQlResponse && - this.resultMap.equals(((MapGraphQlResponse) other).resultMap)); + this.responseMap.equals(((MapGraphQlResponse) other).responseMap)); } @Override public int hashCode() { - return this.resultMap.hashCode(); + return this.responseMap.hashCode(); } @Override public String toString() { - return this.resultMap.toString(); + return this.responseMap.toString(); } 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 dd55995c3..861200a8c 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,11 +15,12 @@ */ package org.springframework.graphql.client; -import java.util.List; import java.util.Map; import graphql.GraphQLError; import graphql.GraphqlErrorBuilder; +import graphql.validation.ValidationError; +import graphql.validation.ValidationErrorType; import org.junit.jupiter.api.Test; import org.springframework.core.ParameterizedTypeReference; @@ -40,14 +41,17 @@ void entity() { String document = "{me {name}}"; setMockResponse("{\"me\": {\"name\":\"Luke Skywalker\"}}"); - GraphQlClient.Response response = execute(document); + MovieCharacter character = MovieCharacter.create("Luke Skywalker"); - MovieCharacter luke = MovieCharacter.create("Luke Skywalker"); - assertThat(response.toEntity("me", MovieCharacter.class)).isEqualTo(luke); + ClientGraphQlResponse response = execute(document); + assertThat(response.isValid()).isTrue(); - Map map = response.toEntity("", new ParameterizedTypeReference>() {}); - assertThat(map).containsEntry("me", luke); + 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); } @@ -58,20 +62,19 @@ void entityList() { setMockResponse("{" + " \"me\":{" + " \"name\":\"Luke Skywalker\"," - + " \"friends\":[{\"name\":\"Han Solo\"}, {\"name\":\"Leia Organa\"}]" + + + " \"friends\":[{\"name\":\"Han Solo\"}, {\"name\":\"Leia Organa\"}]" + " }" + "}"); - GraphQlClient.Response response = execute(document); - MovieCharacter han = MovieCharacter.create("Han Solo"); MovieCharacter leia = MovieCharacter.create("Leia Organa"); - List characters = response.toEntityList("me.friends", MovieCharacter.class); - assertThat(characters).containsExactly(han, leia); + ClientGraphQlResponse response = execute(document); + assertThat(response.isValid()).isTrue(); - characters = response.toEntityList("me.friends", new ParameterizedTypeReference() {}); - assertThat(characters).containsExactly(han, leia); + 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); } @@ -86,7 +89,7 @@ void operationNameAndVariables() { "}"; setMockResponse("{\"hero\": {\"name\":\"R2-D2\"}}"); - GraphQlClient.Response response = graphQlClient().document(document) + ClientGraphQlResponse response = graphQlClient().document(document) .operationName("HeroNameAndFriends") .variable("episode", "JEDI") .variable("foo", "bar") @@ -95,9 +98,8 @@ void operationNameAndVariables() { .block(TIMEOUT); assertThat(response).isNotNull(); - - MovieCharacter character = response.toEntity("hero", MovieCharacter.class); - assertThat(character).isEqualTo(MovieCharacter.create("R2-D2")); + assertThat(response.isValid()).isTrue(); + assertThat(response.field("hero").toEntity(MovieCharacter.class)).isEqualTo(MovieCharacter.create("R2-D2")); GraphQlRequest request = request(); assertThat(request.getDocument()).contains(document); @@ -108,6 +110,18 @@ void operationNameAndVariables() { assertThat(request.getVariables()).containsEntry("keyOnly", null); } + @Test + void requestFailureBeforeExecution() { + + String document = "{invalid"; + setMockResponse(new ValidationError(ValidationErrorType.InvalidSyntax)); + + ClientGraphQlResponse response = execute(document); + + assertThat(response.isValid()).isFalse(); + assertThat(response.field("me").isValid()).isFalse(); + } + @Test void errors() { @@ -116,14 +130,16 @@ void errors() { GraphqlErrorBuilder.newError().message("some error").build(), GraphqlErrorBuilder.newError().message("some other error").build()); - GraphQlClient.Response response = execute(document); + ClientGraphQlResponse response = execute(document); + assertThat(response.isValid()).isFalse(); - assertThat(response.errors()).extracting(GraphQLError::getMessage) + assertThat(response.getErrors()) + .extracting(GraphQLError::getMessage) .containsExactly("some error", "some other error"); } - private GraphQlClient.Response execute(String document) { - GraphQlClient.Response response = graphQlClient().document(document).execute().block(TIMEOUT); + private ClientGraphQlResponse execute(String document) { + ClientGraphQlResponse response = graphQlClient().document(document).execute().block(TIMEOUT); assertThat(response).isNotNull(); return response; } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/WebGraphQlClientBuilderTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/WebGraphQlClientBuilderTests.java index e41d76d2e..52d3e3d2c 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/WebGraphQlClientBuilderTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/WebGraphQlClientBuilderTests.java @@ -197,13 +197,13 @@ void codecConfigurerRegistersJsonPathMappingProvider(ClientBuilderSetup builderS .build()); WebGraphQlClient client = builder.build(); - GraphQlClient.Response response = client.document(document).execute().block(TIMEOUT); + ClientGraphQlResponse response = client.document(document).execute().block(TIMEOUT); testDecoder.resetLastValue(); assertThat(testDecoder.getLastValue()).isNull(); assertThat(response).isNotNull(); - assertThat(response.toEntity("me", MovieCharacter.class).getName()).isEqualTo("Luke Skywalker"); + assertThat(response.field("me").toEntity(MovieCharacter.class).getName()).isEqualTo("Luke Skywalker"); assertThat(testDecoder.getLastValue()).isEqualTo(character); }