From 38a9eb1b3cd0ec6427e1ee59bc398ed28fff8772 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 15 Mar 2022 07:28:12 +0000 Subject: [PATCH] Align client with GraphQL Java error paths DefaultClientGraphQlResponse now parses the path to a field and creates the same parsed representation as GraphQL Java does and uses for GraphQLError paths. This makes it easier to correlate fields to field errors and eliminates the need for a dependency on JSONPath in the client. See gh-10 --- .../test/tester/DefaultHttpGraphQlTester.java | 3 +- .../test/tester/DefaultWebGraphQlTester.java | 3 +- .../tester/DefaultWebSocketGraphQlTester.java | 3 +- .../tester/EncoderDecoderMappingProvider.java | 32 ++- spring-graphql/build.gradle | 1 - .../client/AbstractGraphQlClientBuilder.java | 60 +++-- .../graphql/client/ClientGraphQlResponse.java | 33 ++- .../client/DefaultClientGraphQlResponse.java | 208 ++++++------------ .../graphql/client/DefaultGraphQlClient.java | 52 ++--- .../client/DefaultHttpGraphQlClient.java | 14 +- .../client/DefaultWebSocketGraphQlClient.java | 11 +- .../graphql/client/FieldAccessException.java | 2 +- .../graphql/client/ResponseField.java | 32 +-- .../client/WebSocketGraphQlTransport.java | 5 +- .../graphql/support/MapGraphQlError.java | 14 +- .../graphql/support/MapGraphQlResponse.java | 137 +++++++++++- .../graphql/client/MovieCharacter.java | 4 + .../support/MapGraphQlResponseTests.java | 157 +++++++++++++ 18 files changed, 508 insertions(+), 263 deletions(-) rename spring-graphql/src/main/java/org/springframework/graphql/client/CodecMappingProvider.java => spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/EncoderDecoderMappingProvider.java (67%) create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/support/MapGraphQlResponseTests.java diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultHttpGraphQlTester.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultHttpGraphQlTester.java index ece8dc28..ccb31e19 100644 --- a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultHttpGraphQlTester.java +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultHttpGraphQlTester.java @@ -20,7 +20,6 @@ package org.springframework.graphql.test.tester; import java.net.URI; import java.util.function.Consumer; -import org.springframework.graphql.client.CodecMappingProvider; import org.springframework.http.HttpHeaders; import org.springframework.http.codec.CodecConfigurer; import org.springframework.test.web.reactive.server.WebTestClient; @@ -119,7 +118,7 @@ final class DefaultHttpGraphQlTester extends AbstractDelegatingGraphQlTester imp private void registerJsonPathMappingProvider() { this.webTestClientBuilder.codecs(codecConfigurer -> configureJsonPathConfig(config -> { - CodecMappingProvider provider = new CodecMappingProvider(codecConfigurer); + EncoderDecoderMappingProvider provider = new EncoderDecoderMappingProvider(codecConfigurer); return config.mappingProvider(provider); })); } diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultWebGraphQlTester.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultWebGraphQlTester.java index f0e0bdd9..ac4caba0 100644 --- a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultWebGraphQlTester.java +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/DefaultWebGraphQlTester.java @@ -21,7 +21,6 @@ import java.net.URI; import java.util.Arrays; import java.util.function.Consumer; -import org.springframework.graphql.client.CodecMappingProvider; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.http.HttpHeaders; import org.springframework.http.codec.ClientCodecConfigurer; @@ -134,7 +133,7 @@ final class DefaultWebGraphQlTester extends AbstractDelegatingGraphQlTester impl private void registerJsonPathMappingProvider() { configureJsonPathConfig(jsonPathConfig -> { - CodecMappingProvider provider = new CodecMappingProvider(this.codecConfigurer); + EncoderDecoderMappingProvider provider = new EncoderDecoderMappingProvider(this.codecConfigurer); return jsonPathConfig.mappingProvider(provider); }); } 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 0161672d..cc70b823 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 @@ -25,7 +25,6 @@ import reactor.core.publisher.Mono; import org.springframework.graphql.GraphQlRequest; import org.springframework.graphql.GraphQlResponse; -import org.springframework.graphql.client.CodecMappingProvider; import org.springframework.graphql.client.GraphQlClient; import org.springframework.graphql.client.GraphQlTransport; import org.springframework.graphql.client.WebSocketGraphQlClient; @@ -150,7 +149,7 @@ final class DefaultWebSocketGraphQlTester extends AbstractDelegatingGraphQlTeste private void registerJsonPathMappingProvider() { this.graphQlClientBuilder.codecConfigurer(codecConfigurer -> { configureJsonPathConfig(jsonPathConfig -> { - CodecMappingProvider provider = new CodecMappingProvider(codecConfigurer); + EncoderDecoderMappingProvider provider = new EncoderDecoderMappingProvider(codecConfigurer); return jsonPathConfig.mappingProvider(provider); }); }); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/CodecMappingProvider.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/EncoderDecoderMappingProvider.java similarity index 67% rename from spring-graphql/src/main/java/org/springframework/graphql/client/CodecMappingProvider.java rename to spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/EncoderDecoderMappingProvider.java index 75b11111..280cd27a 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/CodecMappingProvider.java +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/EncoderDecoderMappingProvider.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.graphql.client; +package org.springframework.graphql.test.tester; import java.util.Collections; @@ -30,7 +30,10 @@ import org.springframework.core.codec.Encoder; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.MediaType; import org.springframework.http.codec.CodecConfigurer; +import org.springframework.http.codec.DecoderHttpMessageReader; +import org.springframework.http.codec.EncoderHttpMessageWriter; import org.springframework.lang.Nullable; import org.springframework.util.MimeType; import org.springframework.util.MimeTypeUtils; @@ -42,7 +45,10 @@ import org.springframework.util.MimeTypeUtils; * @author Rossen Stoyanchev * @since 1.0.0 */ -public final class CodecMappingProvider implements MappingProvider { +final class EncoderDecoderMappingProvider implements MappingProvider { + + private static final ResolvableType MAP_TYPE = ResolvableType.forClass(Map.class); + private final Encoder encoder; @@ -54,9 +60,25 @@ public final class CodecMappingProvider implements MappingProvider { * {@link Decoder} in the given {@link CodecConfigurer}. * @throws IllegalArgumentException if there is no JSON encoder or decoder. */ - public CodecMappingProvider(CodecConfigurer configurer) { - this.encoder = CodecDelegate.findJsonEncoder(configurer); - this.decoder = CodecDelegate.findJsonDecoder(configurer); + public EncoderDecoderMappingProvider(CodecConfigurer configurer) { + this.encoder = findJsonEncoder(configurer); + this.decoder = findJsonDecoder(configurer); + } + + private static Decoder findJsonDecoder(CodecConfigurer configurer) { + return configurer.getReaders().stream() + .filter((reader) -> reader.canRead(MAP_TYPE, MediaType.APPLICATION_JSON)) + .map((reader) -> ((DecoderHttpMessageReader) reader).getDecoder()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No JSON Decoder")); + } + + private static Encoder findJsonEncoder(CodecConfigurer configurer) { + return configurer.getWriters().stream() + .filter((writer) -> writer.canWrite(MAP_TYPE, MediaType.APPLICATION_JSON)) + .map((writer) -> ((EncoderHttpMessageWriter) writer).getEncoder()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No JSON Encoder")); } diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index eba8b596..16a15050 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -23,7 +23,6 @@ dependencies { compileOnly 'org.jetbrains.kotlin:kotlin-stdlib' compileOnly 'org.jetbrains.kotlinx:kotlinx-coroutines-core' - compileOnly 'com.jayway.jsonpath:json-path' compileOnly 'com.fasterxml.jackson.core:jackson-databind' testImplementation 'org.junit.jupiter:junit-jupiter' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java index 8e5238af..ee5d4630 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/AbstractGraphQlClientBuilder.java @@ -17,15 +17,16 @@ package org.springframework.graphql.client; import java.util.function.Consumer; -import java.util.function.Function; - -import com.jayway.jsonpath.Configuration; -import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; -import com.jayway.jsonpath.spi.mapper.MappingProvider; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; import org.springframework.graphql.support.CachingDocumentSource; import org.springframework.graphql.support.DocumentSource; import org.springframework.graphql.support.ResourceDocumentSource; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -50,7 +51,11 @@ public abstract class AbstractGraphQlClientBuilder jsonEncoder; + + @Nullable + private Decoder jsonDecoder; /** @@ -78,11 +83,13 @@ public abstract class AbstractGraphQlClientBuilder configurer) { - this.jsonPathConfig = configurer.apply(this.jsonPathConfig); + protected void setJsonCodecs(Encoder encoder, Decoder decoder) { + this.jsonEncoder = encoder; + this.jsonDecoder = decoder; } /** @@ -92,11 +99,12 @@ public abstract class AbstractGraphQlClientBuilder> getBuilderInitializer() { return builder -> { builder.documentSource(documentSource); - builder.configureJsonPathConfig(config -> this.jsonPathConfig); + builder.setJsonCodecs(getJsonEncoder(), getJsonDecoder()); }; } + private Encoder getJsonEncoder() { + Assert.notNull(this.jsonEncoder, "jsonEncoder has not been set"); + return this.jsonEncoder; + } + + private Decoder getJsonDecoder() { + Assert.notNull(this.jsonDecoder, "jsonDecoder has not been set"); + return this.jsonDecoder; + } + private static class Jackson2Configurer { - private static final Class defaultMappingProviderType = - Configuration.defaultConfiguration().mappingProvider().getClass(); + static Encoder encoder() { + return new Jackson2JsonEncoder(); + } - // We only need a MappingProvider: - // GraphQlTransport returns GraphQlResponse with already parsed JSON - - static Configuration configure(Configuration config) { - MappingProvider provider = config.mappingProvider(); - if (provider == null || defaultMappingProviderType.isInstance(provider)) { - config = config.mappingProvider(new JacksonMappingProvider()); - } - return config; + static Decoder decoder() { + return new Jackson2JsonDecoder(); } } 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 0e0ec886..57138185 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 @@ -19,38 +19,51 @@ package org.springframework.graphql.client; import org.springframework.core.ParameterizedTypeReference; import org.springframework.graphql.GraphQlResponse; +import org.springframework.lang.Nullable; /** - * {@link GraphQlResponse} for client use with further options to navigate and - * handle the selection set in the response. + * {@link GraphQlResponse} for client use, with further options to handle 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 + * 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. + *

Example paths: + *

+	 * "hero"
+	 * "hero.name"
+	 * "hero.friends"
+	 * "hero.friends[2]"
+	 * "hero.friends[2].name"
+	 * 
+ * @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 */ ResponseField field(String path); /** * Decode the full response map to the given target type. * @param type the target class - * @return the decoded value + * @return the decoded value, or {@code null} if the "data" is {@code null} + * @throws FieldAccessException if the response is not {@link #isValid() valid} */ + @Nullable D toEntity(Class type); /** * Variant of {@link #toEntity(Class)} with a {@link ParameterizedTypeReference}. * @param type the target type - * @return the decoded value + * @return the decoded value, or {@code null} if the "data" is {@code null} + * @throws FieldAccessException if the response is not {@link #isValid() valid} */ + @Nullable 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 index bb080a66..ef1805fd 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 @@ -16,26 +16,25 @@ package org.springframework.graphql.client; -import java.lang.reflect.Type; -import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; -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.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; 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.CollectionUtils; -import org.springframework.util.StringUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; /** @@ -44,24 +43,35 @@ import org.springframework.util.StringUtils; * @author Rossen Stoyanchev * @since 1.0.0 */ -class DefaultClientGraphQlResponse extends MapGraphQlResponse implements ClientGraphQlResponse { +final class DefaultClientGraphQlResponse extends MapGraphQlResponse implements ClientGraphQlResponse { private final GraphQlRequest request; - private final DocumentContext jsonPathDoc; + private final Encoder encoder; + + private final Decoder decoder; - DefaultClientGraphQlResponse(GraphQlRequest request, GraphQlResponse response, Configuration jsonPathConfig) { + DefaultClientGraphQlResponse( + GraphQlRequest request, GraphQlResponse response, Encoder encoder, Decoder decoder) { + super(response.toMap()); + this.request = request; - this.jsonPathDoc = JsonPath.parse(response.toMap(), jsonPathConfig); + this.encoder = encoder; + this.decoder = decoder; } @Override public ResponseField field(String path) { - path = "$.data" + (StringUtils.hasText(path) ? "." + path : ""); - return new DefaultField(this.request, this, path, this.jsonPathDoc, getErrors()); + + List dataPath = parseFieldPath(path); + Object value = getFieldValue(dataPath); + List errors = getFieldErrors(dataPath); + + return new DefaultField( + path, dataPath, (value != NO_VALUE), (value != NO_VALUE ? value : null), errors); } @Override @@ -78,21 +88,13 @@ class DefaultClientGraphQlResponse extends MapGraphQlResponse implements ClientG /** * Default implementation of {@link ResponseField}. */ - private static class DefaultField implements ResponseField { - - private final GraphQlRequest request; - - private final ClientGraphQlResponse response; + private class DefaultField implements ResponseField { private final String path; - private final DocumentContext jsonPathDoc; + private final List parsedPath; - private final List errorsAt; - - private final List errorsBelow; - - private final List errorsAtOrBelow; + private final List errors; private final boolean exists; @@ -100,73 +102,14 @@ class DefaultClientGraphQlResponse extends MapGraphQlResponse implements ClientG private final Object value; public DefaultField( - GraphQlRequest request, ClientGraphQlResponse response, - String path, DocumentContext jsonPathDoc, List errors) { - - this.request = request; - this.response = response; - this.path = path ; - this.jsonPathDoc = jsonPathDoc; - - - List errorsAt = null; - List errorsBelow = null; - List errorsAtOrBelow = null; - - for (GraphQLError error : errors) { - String errorPath = toJsonPath(error); - if (errorPath == null) { - continue; - } - if (errorPath.startsWith(path)) { - if (errorPath.length() == path.length()) { - errorsAt = (errorsAt != null ? errorsAt : new ArrayList<>()); - errorsAt.add(error); - } - else { - errorsBelow = (errorsBelow != null ? errorsBelow : new ArrayList<>()); - errorsBelow.add(error); - } - errorsAtOrBelow = (errorsAtOrBelow != null ? errorsAtOrBelow : new ArrayList<>()); - errorsAtOrBelow.add(error); - } - } - - this.errorsAt = (errorsAt != null ? errorsAt : Collections.emptyList()); - this.errorsBelow = (errorsBelow != null ? errorsBelow : Collections.emptyList()); - this.errorsAtOrBelow = (errorsAtOrBelow != null ? errorsAtOrBelow : Collections.emptyList()); - - - boolean exists = true; - Object value = null; - try { - value = jsonPathDoc.read(this.path); - } - catch (PathNotFoundException ex) { - exists = false; - } + String path, List parsedPath, boolean exists, @Nullable Object value, + List errors) { + this.path = path; + this.parsedPath = parsedPath; 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(); + this.errors = errors; } @Override @@ -176,7 +119,7 @@ class DefaultClientGraphQlResponse extends MapGraphQlResponse implements ClientG @Override public boolean isValid() { - return (this.exists && (this.value != null || (this.errorsAt.isEmpty() && this.errorsBelow.isEmpty()))); + return (this.exists && (this.value != null || this.errors.isEmpty())); } @SuppressWarnings("unchecked") @@ -186,82 +129,63 @@ class DefaultClientGraphQlResponse extends MapGraphQlResponse implements ClientG } @Override - public List getErrorsAt() { - return this.errorsAt; + public GraphQLError getError() { + for (GraphQLError error : this.errors) { + if (this.parsedPath.size() == error.getPath().size()) { + return error; + } + } + return null; } @Override - public List getErrorsBelow() { - return this.errorsBelow; - } - - @Override - public List getErrorsAtOrBelow() { - return this.errorsAtOrBelow; + public List getErrors() { + return this.errors; } @Override public D toEntity(Class entityType) { - assertIsValid(); - return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(entityType)); + return toEntity(ResolvableType.forType(entityType)); } @Override public D toEntity(ParameterizedTypeReference entityType) { - assertIsValid(); - return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(entityType)); + return toEntity(ResolvableType.forType(entityType)); } @Override public List toEntityList(Class elementType) { - assertIsValid(); - return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(List.class, elementType)); + List list = toEntity(ResolvableType.forClassWithGenerics(List.class, elementType)); + return (list != null ? list : Collections.emptyList()); } @Override public List toEntityList(ParameterizedTypeReference elementType) { - assertIsValid(); - return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(List.class, elementType)); + List list = toEntity(ResolvableType.forClassWithGenerics(List.class, ResolvableType.forType(elementType))); + return (list != null ? list : Collections.emptyList()); } - private void assertIsValid() { + @SuppressWarnings("unchecked") + @Nullable + private T toEntity(ResolvableType targetType) { if (!isValid()) { - throw new FieldAccessException(this.request, this.response, this); + throw new FieldAccessException(request, DefaultClientGraphQlResponse.this, this); } + + if (this.value == null) { + return null; + } + + DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance; + MimeType mimeType = MimeTypeUtils.APPLICATION_JSON; + Map hints = Collections.emptyMap(); + + DataBuffer buffer = ((Encoder) encoder).encodeValue( + (T) this.value, bufferFactory, ResolvableType.forInstance(this.value), mimeType, hints); + + return ((Decoder) decoder).decode(buffer, targetType, mimeType, hints); } } - - /** - * 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 1d0414f4..ea9e6acb 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 @@ -19,11 +19,13 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Consumer; -import com.jayway.jsonpath.Configuration; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; 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; @@ -36,40 +38,44 @@ import org.springframework.util.Assert; */ final class DefaultGraphQlClient implements GraphQlClient { + private final DocumentSource documentSource; + private final GraphQlTransport transport; - private final Configuration jsonPathConfig; + private final Encoder jsonEncoder; - private final DocumentSource documentSource; + private final Decoder jsonDecoder; private final Consumer> builderInitializer; DefaultGraphQlClient( - GraphQlTransport transport, Configuration jsonPathConfig, DocumentSource documentSource, + DocumentSource documentSource, GraphQlTransport transport, + Encoder jsonEncoder, Decoder jsonDecoder, Consumer> builderInitializer) { - Assert.notNull(transport, "GraphQlTransport is required"); - Assert.notNull(jsonPathConfig, "JSONPath Configuration is required"); Assert.notNull(documentSource, "DocumentSource is required"); + Assert.notNull(transport, "GraphQlTransport is required"); + Assert.notNull(jsonEncoder, "'jsonEncoder' is required"); + Assert.notNull(jsonEncoder, "'jsonDecoder' is required"); Assert.notNull(builderInitializer, "`builderInitializer` is required"); - this.transport = transport; - this.jsonPathConfig = jsonPathConfig; this.documentSource = documentSource; + this.transport = transport; + this.jsonEncoder = jsonEncoder; + this.jsonDecoder = jsonDecoder; this.builderInitializer = builderInitializer; } @Override public Request document(String document) { - return new DefaultRequest(Mono.just(document), this.transport, this.jsonPathConfig); + return new DefaultRequest(Mono.just(document)); } @Override public Request documentName(String name) { - Mono document = this.documentSource.getDocument(name); - return new DefaultRequest(document, this.transport, this.jsonPathConfig); + return new DefaultRequest(this.documentSource.getDocument(name)); } @Override @@ -103,7 +109,7 @@ final class DefaultGraphQlClient implements GraphQlClient { /** * Default {@link GraphQlClient.Request} implementation. */ - private static final class DefaultRequest implements Request { + private final class DefaultRequest implements Request { private final Mono documentMono; @@ -112,15 +118,9 @@ final class DefaultGraphQlClient implements GraphQlClient { private final Map variables = new LinkedHashMap<>(); - private final GraphQlTransport transport; - - private final Configuration jsonPathConfig; - - DefaultRequest(Mono documentMono, GraphQlTransport transport, Configuration jsonPathConfig) { + DefaultRequest(Mono documentMono) { Assert.notNull(documentMono, "'document' is required"); this.documentMono = documentMono; - this.transport = transport; - this.jsonPathConfig = jsonPathConfig; } @Override @@ -144,9 +144,8 @@ final class DefaultGraphQlClient implements GraphQlClient { @Override public Mono execute() { return initRequest().flatMap(request -> - this.transport.execute(request) - .map(result -> - new DefaultClientGraphQlResponse(request, result, this.jsonPathConfig)) + transport.execute(request) + .map(response -> initResponse(request, response)) .onErrorResume( ex -> !(ex instanceof GraphQlClientException), ex -> toGraphQlTransportException(ex, request))); @@ -155,9 +154,8 @@ final class DefaultGraphQlClient implements GraphQlClient { @Override public Flux executeSubscription() { return initRequest().flatMapMany(request -> - this.transport.executeSubscription(request) - .map(result -> - new DefaultClientGraphQlResponse(request, result, this.jsonPathConfig)) + transport.executeSubscription(request) + .map(response -> initResponse(request, response)) .onErrorResume( ex -> !(ex instanceof GraphQlClientException), ex -> toGraphQlTransportException(ex, request))); @@ -168,6 +166,10 @@ final class DefaultGraphQlClient implements GraphQlClient { new GraphQlRequest(document, this.operationName, this.variables)); } + private DefaultClientGraphQlResponse initResponse(GraphQlRequest request, GraphQlResponse response) { + return new DefaultClientGraphQlResponse(request, response, jsonEncoder, jsonDecoder); + } + private Mono toGraphQlTransportException(Throwable ex, GraphQlRequest request) { return Mono.error(new GraphQlTransportException(ex, request)); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultHttpGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultHttpGraphQlClient.java index bccf4bf7..6af0bd93 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultHttpGraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultHttpGraphQlClient.java @@ -130,21 +130,17 @@ final class DefaultHttpGraphQlClient extends AbstractDelegatingGraphQlClient imp @Override public HttpGraphQlClient build() { - registerJsonPathMappingProvider(); + this.webClientBuilder.codecs(configurer -> + setJsonCodecs( + CodecDelegate.findJsonEncoder(configurer), + CodecDelegate.findJsonDecoder(configurer))); + WebClient webClient = this.webClientBuilder.build(); GraphQlClient graphQlClient = super.buildGraphQlClient(new HttpGraphQlTransport(webClient)); return new DefaultHttpGraphQlClient(graphQlClient, webClient, getBuilderInitializer()); } - private void registerJsonPathMappingProvider() { - this.webClientBuilder.codecs(codecConfigurer -> - configureJsonPathConfig(config -> { - CodecMappingProvider provider = new CodecMappingProvider(codecConfigurer); - return config.mappingProvider(provider); - })); - } - } } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultWebSocketGraphQlClient.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultWebSocketGraphQlClient.java index 41dcf115..53b4c157 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultWebSocketGraphQlClient.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultWebSocketGraphQlClient.java @@ -152,7 +152,9 @@ final class DefaultWebSocketGraphQlClient extends AbstractDelegatingGraphQlClien @Override public WebSocketGraphQlClient build() { - registerJsonPathMappingProvider(); + setJsonCodecs( + CodecDelegate.findJsonEncoder(this.codecConfigurer), + CodecDelegate.findJsonDecoder(this.codecConfigurer)); WebSocketGraphQlTransport transport = new WebSocketGraphQlTransport( this.url, this.headers, this.webSocketClient, this.codecConfigurer, null, payload -> {}); @@ -161,13 +163,6 @@ final class DefaultWebSocketGraphQlClient extends AbstractDelegatingGraphQlClien return new DefaultWebSocketGraphQlClient(graphQlClient, transport, getBuilderInitializer()); } - private void registerJsonPathMappingProvider() { - configureJsonPathConfig(jsonPathConfig -> { - CodecMappingProvider provider = new CodecMappingProvider(this.codecConfigurer); - return jsonPathConfig.mappingProvider(provider); - }); - } - } } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/FieldAccessException.java b/spring-graphql/src/main/java/org/springframework/graphql/client/FieldAccessException.java index 284cae95..69496ea7 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/FieldAccessException.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/FieldAccessException.java @@ -46,7 +46,7 @@ public class FieldAccessException extends GraphQlClientException { } private static String initDefaultMessage(ResponseField field) { - return "Invalid field '" + field.getPath() + "', errors: " + field.getErrorsAtOrBelow(); + return "Invalid field '" + field.getPath() + "', errors: " + field.getErrors(); } 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 9a4e4751..c10ca9df 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 @@ -60,45 +60,51 @@ public interface ResponseField { T getValue(); /** - * Return errors with paths matching that of the field. + * Return the first error whose path is equal to the field path. + *

According to section 6.4.4 "Handling Field Errors" of the GraphQL + * spec, only one error should be added to the errors list per field. */ - List getErrorsAt(); + @Nullable + GraphQLError getError(); /** - * Return errors with paths below that of the field. + * Return all field errors including those whose path is below the field path. */ - List getErrorsBelow(); - - /** - * Return errors with paths at or below that of the field. - */ - List getErrorsAtOrBelow(); + List getErrors(); /** * Decode the field to an entity of the given type. * @param entityType the type to convert to - * @return the entity instance + * @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(Class entityType); /** * Variant of {@link #toEntity(Class)} with a {@link ParameterizedTypeReference}. * @param entityType the type to convert to - * @return the entity instance + * @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); /** * 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 + * @return the decoded list of entities, possibly empty + * @throws FieldAccessException if "this" field is not {@link #isValid() valid} */ 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 + * @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/client/WebSocketGraphQlTransport.java b/spring-graphql/src/main/java/org/springframework/graphql/client/WebSocketGraphQlTransport.java index 5e7405b7..0e0de7ff 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 @@ -34,7 +34,6 @@ import reactor.core.publisher.Sinks; import org.springframework.graphql.GraphQlRequest; import org.springframework.graphql.GraphQlResponse; -import org.springframework.graphql.support.MapGraphQlError; import org.springframework.graphql.support.MapGraphQlResponse; import org.springframework.graphql.web.support.GraphQlMessage; import org.springframework.graphql.web.support.GraphQlMessageType; @@ -509,14 +508,14 @@ final class WebSocketGraphQlTransport implements GraphQlTransport { } List> errorList = message.getPayload(); + GraphQlResponse response = MapGraphQlResponse.forErrorsOnly(errorList); Sinks.EmitResult emitResult; if (responseState != null) { - GraphQlResponse response = MapGraphQlResponse.forErrorsOnly(errorList); emitResult = responseState.sink().tryEmitValue(response); } else { - List graphQLErrors = MapGraphQlError.from(errorList); + List graphQLErrors = response.getErrors(); Exception ex = new SubscriptionErrorException(subscriptionState.request(), graphQLErrors); emitResult = subscriptionState.sink().tryEmitError(ex); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/support/MapGraphQlError.java b/spring-graphql/src/main/java/org/springframework/graphql/support/MapGraphQlError.java index f2313a4b..475007f3 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/support/MapGraphQlError.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/support/MapGraphQlError.java @@ -37,14 +37,14 @@ import org.springframework.util.Assert; * @since 1.0.0 */ @SuppressWarnings("serial") -public final class MapGraphQlError implements GraphQLError { +final class MapGraphQlError implements GraphQLError { private final Map errorMap; private final List locations; - private MapGraphQlError(Map errorMap) { + MapGraphQlError(Map errorMap) { Assert.notNull(errorMap, "'errorMap' is required"); this.errorMap = errorMap; this.locations = initLocations(errorMap); @@ -134,14 +134,4 @@ public final class MapGraphQlError implements GraphQLError { return toSpecification().toString(); } - - /** - * Create a list of {@code GraphQlError} instances from the given - * deserialized content. - */ - public static List from(@Nullable List> errors) { - errors = (errors != null ? errors : Collections.emptyList()); - return errors.stream().map(MapGraphQlError::new).collect(Collectors.toList()); - } - } 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 7e65ec70..869af2d7 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 @@ -16,6 +16,7 @@ package org.springframework.graphql.support; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -24,7 +25,10 @@ import graphql.ExecutionResult; import graphql.GraphQLError; import org.springframework.graphql.GraphQlResponse; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; /** * {@link GraphQlResponse} for client use that wraps the GraphQL response map. @@ -34,6 +38,12 @@ import org.springframework.util.Assert; */ public class MapGraphQlResponse implements GraphQlResponse { + /** + * Returned from {@link #getFieldValue(List)} to indicate a value does not exist. + */ + protected final static Object NO_VALUE = new Object(); + + private final Map responseMap; private final List errors; @@ -43,7 +53,20 @@ public class MapGraphQlResponse implements GraphQlResponse { protected MapGraphQlResponse(Map responseMap) { Assert.notNull(responseMap, "'responseMap' is required"); this.responseMap = responseMap; - this.errors = MapGraphQlError.from((List>) responseMap.get("errors")); + this.errors = wrapErrors(responseMap); + } + + @SuppressWarnings("unchecked") + private static List wrapErrors(Map responseMap) { + List> rawErrors = (List>) responseMap.get("errors"); + if (CollectionUtils.isEmpty(rawErrors)) { + return Collections.emptyList(); + } + List errors = new ArrayList<>(rawErrors.size()); + for (Map map : rawErrors) { + errors.add(new MapGraphQlError(map)); + } + return errors; } @@ -74,6 +97,112 @@ public class MapGraphQlResponse implements GraphQlResponse { return this.responseMap; } + /** + * Parse the given field path, producing an output compatible with + * {@link graphql.execution.ResultPath#parse(String)} but using "." instead + * of "/" as separators. + * @param path the path to parse + * @return the parsed path segments and offsets, possibly empty + * @throws IllegalArgumentException for path syntax issues + */ + protected static List parseFieldPath(String path) { + if (!StringUtils.hasText(path)) { + return Collections.emptyList(); + } + + String invalidPathMessage = "Invalid path: '" + path + "'"; + List dataPath = new ArrayList<>(); + + StringBuilder sb = new StringBuilder(); + boolean readingIndex = false; + + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + switch (c) { + case '.': + case '[': + Assert.isTrue(!readingIndex, invalidPathMessage); + break; + case ']': + i++; + Assert.isTrue(readingIndex, invalidPathMessage); + Assert.isTrue(i == path.length() || path.charAt(i) == '.', invalidPathMessage); + break; + default: + sb.append(c); + if (i < path.length() - 1) { + continue; + } + } + String token = sb.toString(); + Assert.hasText(token, invalidPathMessage); + dataPath.add(readingIndex ? Integer.parseInt(token) : token); + sb.delete(0, sb.length()); + + readingIndex = (c == '['); + } + + return dataPath; + } + + /** + * Return the field value under the given path relative to the "data" key. + * @param fieldPath a field path parsed via {@link #parseFieldPath(String)} + * @return the field value, possibly {@code null} or {@link #NO_VALUE} + * @throws IllegalArgumentException in case of a mismatch between the path + * and the data, e.g. map or list expected vs actual value type + */ + @Nullable + protected Object getFieldValue(List fieldPath) { + Object value = (isValid() ? getData() : NO_VALUE); + for (Object segment : fieldPath) { + if (value == null || value == NO_VALUE) { + return NO_VALUE; + } + if (segment instanceof String) { + Assert.isTrue(value instanceof Map, () -> "Invalid path " + fieldPath + ", data: " + getData()); + Map map = (Map) value; + value = (map.containsKey(segment) ? map.get(segment) : NO_VALUE); + } + else { + Assert.isTrue(value instanceof List, () -> "Invalid path " + fieldPath + ", data: " + getData()); + int index = (int) segment; + List list = (List) value; + value = (index < list.size() ? list.get(index) : NO_VALUE); + } + } + return value; + } + + /** + * Return field errors whose path starts with the given field path. + * @param fieldPath the field path to match + * @return errors whose path starts with the dataPath + */ + protected List getFieldErrors(List fieldPath) { + if (fieldPath.isEmpty()) { + return Collections.emptyList(); + } + List fieldErrors = Collections.emptyList(); + for (GraphQLError error : this.errors) { + List errorPath = error.getPath(); + if (CollectionUtils.isEmpty(errorPath) || errorPath.size() < fieldPath.size()) { + continue; + } + boolean match = true; + for (int i = 0; match && i < fieldPath.size(); i++) { + match = fieldPath.get(i).equals(errorPath.get(i)); + } + if (!match) { + continue; + } + fieldErrors = (fieldErrors.isEmpty() ? new ArrayList<>() : fieldErrors); + fieldErrors.add(error); + } + return fieldErrors; + } + + @Override public boolean equals(Object other) { return (other instanceof MapGraphQlResponse && @@ -95,7 +224,7 @@ public class MapGraphQlResponse implements GraphQlResponse { * Create an instance from an {@code ExecutionResult} serialized to map via * {@link ExecutionResult#toSpecification()}. */ - public static GraphQlResponse forResponse(Map map) { + public static MapGraphQlResponse forResponse(Map map) { return new MapGraphQlResponse(map); } @@ -103,7 +232,7 @@ public class MapGraphQlResponse implements GraphQlResponse { * Create an {@code ExecutionResult} with a "data" key that returns the * given map. */ - public static GraphQlResponse forDataOnly(Map map) { + public static MapGraphQlResponse forDataOnly(@Nullable Map map) { return new MapGraphQlResponse(Collections.singletonMap("data", map)); } @@ -111,7 +240,7 @@ public class MapGraphQlResponse implements GraphQlResponse { * Create an {@code ExecutionResult} with an "errors" key that returns the * given serialized errors. */ - public static GraphQlResponse forErrorsOnly(List> errors) { + public static MapGraphQlResponse forErrorsOnly(List> errors) { return new MapGraphQlResponse(Collections.singletonMap("errors", errors)); } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/MovieCharacter.java b/spring-graphql/src/test/java/org/springframework/graphql/client/MovieCharacter.java index e8aac36d..28f55666 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/MovieCharacter.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/MovieCharacter.java @@ -59,4 +59,8 @@ public class MovieCharacter { return (this.name != null) ? this.name.hashCode() : super.hashCode(); } + @Override + public String toString() { + return "MovieCharacter[name='" + this.name + "']"; + } } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/support/MapGraphQlResponseTests.java b/spring-graphql/src/test/java/org/springframework/graphql/support/MapGraphQlResponseTests.java new file mode 100644 index 00000000..f130dc2b --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/support/MapGraphQlResponseTests.java @@ -0,0 +1,157 @@ +/* + * 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.support; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.execution.ResultPath; +import org.junit.jupiter.api.Test; +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.springframework.graphql.support.MapGraphQlResponse.NO_VALUE; + + +/** + * Unit tests for {@link MapGraphQlResponse}. + * @author Rossen Stoyanchev + */ +public class MapGraphQlResponseTests { + + private static final ObjectMapper mapper = new ObjectMapper(); + + + @Test + void parsePath() { + testParsePath(""); + testParsePath(" \t "); + testParsePath("me.name", "me", "name"); + testParsePath("me.friends[1]", "me", "friends", 1); + testParsePath("me.friends[1].name", "me", "friends", 1, "name"); + testParsePath(" me . name ", " me ", " name "); + } + + private static void testParsePath(String path, Object... expected) { + assertThat(MapGraphQlResponse.parseFieldPath(path)).containsExactly(expected); + } + + @Test + void parsePathInvalid() { + testParseInvalidPath(".me"); + testParseInvalidPath("me..name"); + testParseInvalidPath("me.friends]"); + testParseInvalidPath("me.friends[["); + testParseInvalidPath("me.friends[."); + testParseInvalidPath("me.friends[]"); + testParseInvalidPath("me.friends[5]name"); + testParseInvalidPath("me.friends[5]]"); + } + + private static void testParseInvalidPath(String path) { + assertThatIllegalArgumentException() + .isThrownBy(() -> MapGraphQlResponse.parseFieldPath(path)) + .withMessage("Invalid path: '" + path + "'"); + } + + @Test + void fieldValue() throws Exception { + + // null "data" + testFieldValue("", "null", null); + testFieldValue("me", "null", NO_VALUE); + + // no such key or index + testFieldValue("me", "{}", NO_VALUE); // "data" not null but no such key + testFieldValue("me.friends", "{\"me\":{}}", NO_VALUE); + testFieldValue("me.friends[0]", "{\"me\": {\"friends\": []}}", NO_VALUE); + + // nest within map or list + testFieldValue("me.name", "{\"me\":{\"name\":\"Luke\"}}", "Luke"); + testFieldValue("me.friends[1].name", "{\"me\": {\"friends\": [{\"name\": \"Luke\"}, {\"name\": \"Yoda\"}]}}", "Yoda"); + } + + @SuppressWarnings("unchecked") + private static void testFieldValue(String path, String json, @Nullable Object expected) throws IOException { + List parsedPath = MapGraphQlResponse.parseFieldPath(path); + Map map = mapper.readValue(json, Map.class); + MapGraphQlResponse response = MapGraphQlResponse.forDataOnly(map); + Object value = response.getFieldValue(parsedPath); + if (expected != null) { + assertThat(value).isEqualTo(expected); + } + else { + assertThat(value).isNotNull(); + } + } + + @Test + void fieldValueInvalidPath() throws Exception { + testFieldValueInvalidPath("me.name", "{\"me\": []}"); + testFieldValueInvalidPath("me.name", "{\"me\": \"string\"}"); + testFieldValueInvalidPath("me.friends[0]", "{\"me\": {\"friends\": {}}}"); + testFieldValueInvalidPath("me.friends[0]", "{\"me\": {\"friends\": {\"name\":\"Luke\"}}}"); + } + + @SuppressWarnings("unchecked") + private static void testFieldValueInvalidPath(String path, String json) throws IOException { + List parsedPath = MapGraphQlResponse.parseFieldPath(path); + Map map = mapper.readValue(json, Map.class); + MapGraphQlResponse response = MapGraphQlResponse.forDataOnly(map); + + assertThatIllegalArgumentException().isThrownBy(() -> response.getFieldValue(parsedPath)) + .withMessage("Invalid path " + parsedPath + ", data: " + map); + } + + @Test + void fieldErrors() { + + List path = MapGraphQlResponse.parseFieldPath("me.friends"); + + GraphQLError error0 = createError(null, "fail-me"); + GraphQLError error1 = createError("/me", "fail-me"); + GraphQLError error2 = createError("/me/friends", "fail-me-friends"); + GraphQLError error3 = createError("/me/friends[0]/name", "fail-me-friends-name"); + + List> errorList = + Stream.of(error0, error1, error2, error3) + .map(GraphQLError::toSpecification).collect(Collectors.toList()); + + MapGraphQlResponse response = MapGraphQlResponse.forErrorsOnly(errorList); + List errors = response.getFieldErrors(path); + + assertThat(errors).containsExactly(error2, error3); + } + + private GraphQLError createError(@Nullable String errorPath, String message) { + GraphqlErrorBuilder builder = GraphqlErrorBuilder.newError().message(message); + if (errorPath != null) { + builder = builder.path(ResultPath.parse(errorPath)); + } + Map errorMap = builder.build().toSpecification(); + return new MapGraphQlError(errorMap); + } + +}