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
This commit is contained in:
rstoyanchev
2022-03-15 07:28:12 +00:00
parent 3214a9cfc9
commit 38a9eb1b3c
18 changed files with 508 additions and 263 deletions

View File

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

View File

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

View File

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

View File

@@ -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"));
}

View File

@@ -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'

View File

@@ -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<B extends AbstractGraphQlClie
private DocumentSource documentSource = new CachingDocumentSource(new ResourceDocumentSource());
private Configuration jsonPathConfig = Configuration.builder().build();
@Nullable
private Encoder<?> jsonEncoder;
@Nullable
private Decoder<?> jsonDecoder;
/**
@@ -78,11 +83,13 @@ public abstract class AbstractGraphQlClientBuilder<B extends AbstractGraphQlClie
/**
* Allow transport-specific subclass builders to register a JSON Path
* {@link MappingProvider} that matches the JSON encoding/decoding they use.
* Transport-specific subclasses can provide their JSON {@code Encoder} and
* {@code Decoder} for use at the client level, for mapping response data
* to some target entity type.
*/
protected void configureJsonPathConfig(Function<Configuration, Configuration> 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<B extends AbstractGraphQlClie
protected GraphQlClient buildGraphQlClient(GraphQlTransport transport) {
if (jackson2Present) {
configureJsonPathConfig(Jackson2Configurer::configure);
this.jsonEncoder = (this.jsonEncoder == null ? Jackson2Configurer.encoder() : this.jsonEncoder);
this.jsonDecoder = (this.jsonDecoder == null ? Jackson2Configurer.decoder() : this.jsonDecoder);
}
return new DefaultGraphQlClient(
transport, this.jsonPathConfig, this.documentSource, getBuilderInitializer());
this.documentSource, transport, getJsonEncoder(), getJsonDecoder(), getBuilderInitializer());
}
/**
@@ -105,25 +113,29 @@ public abstract class AbstractGraphQlClientBuilder<B extends AbstractGraphQlClie
protected Consumer<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();
}
}

View File

@@ -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.
* <p>Example paths:
* <pre style="class">
* "hero"
* "hero.name"
* "hero.friends"
* "hero.friends[2]"
* "hero.friends[2].name"
* </pre>
* @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> D toEntity(Class<D> 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> D toEntity(ParameterizedTypeReference<D> type);
}

View File

@@ -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<Object> dataPath = parseFieldPath(path);
Object value = getFieldValue(dataPath);
List<GraphQLError> 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<Object> parsedPath;
private final List<GraphQLError> errorsAt;
private final List<GraphQLError> errorsBelow;
private final List<GraphQLError> errorsAtOrBelow;
private final List<GraphQLError> 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<GraphQLError> errors) {
this.request = request;
this.response = response;
this.path = path ;
this.jsonPathDoc = jsonPathDoc;
List<GraphQLError> errorsAt = null;
List<GraphQLError> errorsBelow = null;
List<GraphQLError> 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<Object> parsedPath, boolean exists, @Nullable Object value,
List<GraphQLError> 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<Object> 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<GraphQLError> 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<GraphQLError> getErrorsBelow() {
return this.errorsBelow;
}
@Override
public List<GraphQLError> getErrorsAtOrBelow() {
return this.errorsAtOrBelow;
public List<GraphQLError> getErrors() {
return this.errors;
}
@Override
public <D> D toEntity(Class<D> entityType) {
assertIsValid();
return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(entityType));
return toEntity(ResolvableType.forType(entityType));
}
@Override
public <D> D toEntity(ParameterizedTypeReference<D> entityType) {
assertIsValid();
return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(entityType));
return toEntity(ResolvableType.forType(entityType));
}
@Override
public <D> List<D> toEntityList(Class<D> elementType) {
assertIsValid();
return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(List.class, elementType));
List<D> list = toEntity(ResolvableType.forClassWithGenerics(List.class, elementType));
return (list != null ? list : Collections.emptyList());
}
@Override
public <D> List<D> toEntityList(ParameterizedTypeReference<D> elementType) {
assertIsValid();
return this.jsonPathDoc.read(this.path, new TypeRefAdapter<>(List.class, elementType));
List<D> list = toEntity(ResolvableType.forClassWithGenerics(List.class, ResolvableType.forType(elementType)));
return (list != null ? list : Collections.emptyList());
}
private void assertIsValid() {
@SuppressWarnings("unchecked")
@Nullable
private <T> 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<String, Object> hints = Collections.emptyMap();
DataBuffer buffer = ((Encoder<T>) encoder).encodeValue(
(T) this.value, bufferFactory, ResolvableType.forInstance(this.value), mimeType, hints);
return ((Decoder<T>) decoder).decode(buffer, targetType, mimeType, hints);
}
}
/**
* Adapt JSONPath {@link TypeRef} to {@link ParameterizedTypeReference}.
*/
private static final class TypeRefAdapter<T> extends TypeRef<T> {
private final Type type;
TypeRefAdapter(Class<T> clazz) {
this.type = clazz;
}
TypeRefAdapter(ParameterizedTypeReference<T> 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;
}
}
}

View File

@@ -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<AbstractGraphQlClientBuilder<?>> builderInitializer;
DefaultGraphQlClient(
GraphQlTransport transport, Configuration jsonPathConfig, DocumentSource documentSource,
DocumentSource documentSource, GraphQlTransport transport,
Encoder<?> jsonEncoder, Decoder<?> jsonDecoder,
Consumer<AbstractGraphQlClientBuilder<?>> 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<String> 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<String> documentMono;
@@ -112,15 +118,9 @@ final class DefaultGraphQlClient implements GraphQlClient {
private final Map<String, Object> variables = new LinkedHashMap<>();
private final GraphQlTransport transport;
private final Configuration jsonPathConfig;
DefaultRequest(Mono<String> documentMono, GraphQlTransport transport, Configuration jsonPathConfig) {
DefaultRequest(Mono<String> 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<ClientGraphQlResponse> 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<ClientGraphQlResponse> 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 <T> Mono<T> toGraphQlTransportException(Throwable ex, GraphQlRequest request) {
return Mono.error(new GraphQlTransportException(ex, request));
}

View File

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

View File

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

View File

@@ -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();
}

View File

@@ -60,45 +60,51 @@ public interface ResponseField {
<T> T getValue();
/**
* Return errors with paths matching that of the field.
* Return the first error whose path is equal to the field path.
* <p>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<GraphQLError> 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<GraphQLError> getErrorsBelow();
/**
* Return errors with paths at or below that of the field.
*/
List<GraphQLError> getErrorsAtOrBelow();
List<GraphQLError> 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> D toEntity(Class<D> 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> D toEntity(ParameterizedTypeReference<D> 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}
*/
<D> List<D> toEntityList(Class<D> 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}
*/
<D> List<D> toEntityList(ParameterizedTypeReference<D> elementType);

View File

@@ -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<Map<String, Object>> 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<GraphQLError> graphQLErrors = MapGraphQlError.from(errorList);
List<GraphQLError> graphQLErrors = response.getErrors();
Exception ex = new SubscriptionErrorException(subscriptionState.request(), graphQLErrors);
emitResult = subscriptionState.sink().tryEmitError(ex);
}

View File

@@ -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<String, Object> errorMap;
private final List<SourceLocation> locations;
private MapGraphQlError(Map<String, Object> errorMap) {
MapGraphQlError(Map<String, Object> 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<GraphQLError> from(@Nullable List<Map<String, Object>> errors) {
errors = (errors != null ? errors : Collections.emptyList());
return errors.stream().map(MapGraphQlError::new).collect(Collectors.toList());
}
}

View File

@@ -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<String, Object> responseMap;
private final List<GraphQLError> errors;
@@ -43,7 +53,20 @@ public class MapGraphQlResponse implements GraphQlResponse {
protected MapGraphQlResponse(Map<String, Object> responseMap) {
Assert.notNull(responseMap, "'responseMap' is required");
this.responseMap = responseMap;
this.errors = MapGraphQlError.from((List<Map<String, Object>>) responseMap.get("errors"));
this.errors = wrapErrors(responseMap);
}
@SuppressWarnings("unchecked")
private static List<GraphQLError> wrapErrors(Map<String, Object> responseMap) {
List<Map<String, Object>> rawErrors = (List<Map<String, Object>>) responseMap.get("errors");
if (CollectionUtils.isEmpty(rawErrors)) {
return Collections.emptyList();
}
List<GraphQLError> errors = new ArrayList<>(rawErrors.size());
for (Map<String, Object> 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<Object> parseFieldPath(String path) {
if (!StringUtils.hasText(path)) {
return Collections.emptyList();
}
String invalidPathMessage = "Invalid path: '" + path + "'";
List<Object> 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<Object> 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<GraphQLError> getFieldErrors(List<Object> fieldPath) {
if (fieldPath.isEmpty()) {
return Collections.emptyList();
}
List<GraphQLError> fieldErrors = Collections.emptyList();
for (GraphQLError error : this.errors) {
List<Object> 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<String, Object> map) {
public static MapGraphQlResponse forResponse(Map<String, Object> 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<String, Object> map) {
public static MapGraphQlResponse forDataOnly(@Nullable Map<String, Object> 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<Map<String, Object>> errors) {
public static MapGraphQlResponse forErrorsOnly(List<Map<String, Object>> errors) {
return new MapGraphQlResponse(Collections.singletonMap("errors", errors));
}

View File

@@ -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 + "']";
}
}

View File

@@ -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<Object> parsedPath = MapGraphQlResponse.parseFieldPath(path);
Map<String, Object> 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<Object> parsedPath = MapGraphQlResponse.parseFieldPath(path);
Map<String, Object> 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<Object> 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<Map<String, Object>> errorList =
Stream.of(error0, error1, error2, error3)
.map(GraphQLError::toSpecification).collect(Collectors.toList());
MapGraphQlResponse response = MapGraphQlResponse.forErrorsOnly(errorList);
List<GraphQLError> 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<String, Object> errorMap = builder.build().toSpecification();
return new MapGraphQlError(errorMap);
}
}