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:
@@ -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);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 + "']";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user