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); + } + +}