Update GraphQlResponseError contract
Add a String path representation making it easy to filter errors by path using String comparison, and refine nullability. Take advantage of the String error paths to simplify internal filtering of error fields. See gh-10
This commit is contained in:
@@ -134,8 +134,8 @@ which is a strategy for loading the document for a request by file name.
|
||||
== Requests
|
||||
|
||||
Once you have a <<client-graphqlclient>>, you can begin to perform requests via
|
||||
<<client-requests-retrieve, retrieve()>> or <<client-requests-execute, execute()>>,
|
||||
with one merely a shortcut over the other.
|
||||
<<client-requests-retrieve, retrieve()>> or <<client-requests-execute, execute()>>
|
||||
where the former is merely a shortcut for the latter.
|
||||
|
||||
|
||||
|
||||
@@ -160,8 +160,8 @@ The below retrieves and decodes the data for a query:
|
||||
.toEntity(Project.class); <3>
|
||||
----
|
||||
<1> The operation to perform
|
||||
<2> Retrieve the response, and specify a path to decode from
|
||||
<3> Decode to a target object
|
||||
<2> Specify a path under the "data" key in the response map
|
||||
<3> Decode the data at the path to the target type
|
||||
|
||||
The document is a `String` that could be a literal or produced through a code generated
|
||||
request object. You can also define documents in files and use a
|
||||
@@ -169,11 +169,10 @@ request object. You can also define documents in files and use a
|
||||
|
||||
The path is relative to the "data" key and uses a simple dot (".") separated notation
|
||||
for nested fields with optional array indices for list elements, e.g. `"project.name"`,
|
||||
`"project .releases[0].version"`, and so on.
|
||||
`"project.releases[0].version"`, and so on.
|
||||
|
||||
Decoding can fail with `FieldAccessException` if the given path is not present in the
|
||||
response map, or when there is no "data" key (failed response) at all, or when there is
|
||||
a `null` value with a field error at the path.
|
||||
response map, or when the value is `null` and there is an error for the field.
|
||||
|
||||
By default, `FieldAccessException` is also raised on `retrieve` for partial data where
|
||||
the field value exists but nested fields may be `null` with a field error. In such
|
||||
@@ -202,8 +201,10 @@ attempts to decode those are always rejected.
|
||||
[[client-requests-execute]]
|
||||
=== Execute
|
||||
|
||||
The `retrieve` method is only a shortcut to decode to a single higher level object. For
|
||||
more control and access to the response, use the `execute` method. For example:
|
||||
The `retrieve` method is only a shortcut to decode from a single path to a higher level
|
||||
object. For more control and access to the response, use the `execute` method.
|
||||
|
||||
For example:
|
||||
|
||||
[source,java,indent=0,subs="verbatim,quotes"]
|
||||
----
|
||||
@@ -214,7 +215,7 @@ more control and access to the response, use the `execute` method. For example:
|
||||
// Check response.isValid(), getErrors()
|
||||
|
||||
ResponseField field = response.field("project");
|
||||
// Check field.isValid(), getError()
|
||||
// Check field.hasValue(), getError()
|
||||
|
||||
return field.toEntity(Project.class)
|
||||
});
|
||||
|
||||
@@ -55,9 +55,13 @@ public interface GraphQlResponse {
|
||||
<T> T getData();
|
||||
|
||||
/**
|
||||
* Return errors for the response. This contains "request errors" when the
|
||||
* response is not {@link #isValid() valid} and/or "field errors" for a
|
||||
* partial response.
|
||||
* Return errors included in the response.
|
||||
* <p>A response that is not {@link #isValid() valid} contains "request
|
||||
* errors". Those are errors that apply to the request as a whole, and have
|
||||
* an empty error {@link GraphQlResponseError#getPath() path}.
|
||||
* <p>A response that is valid may still be partial and contain "field
|
||||
* errors". Those are errors associated with a specific field through their
|
||||
* error path.
|
||||
*/
|
||||
List<GraphQlResponseError> getErrors();
|
||||
|
||||
|
||||
@@ -23,6 +23,44 @@ public interface GraphQlResponseError {
|
||||
@Nullable
|
||||
String getMessage();
|
||||
|
||||
/**
|
||||
* Return a classification for the error that is specific to GraphQL Java.
|
||||
* This is serialized under {@link #getExtensions() "extensions"} in the
|
||||
* response map.
|
||||
* @see graphql.ErrorType
|
||||
* @see org.springframework.graphql.execution.ErrorType
|
||||
*/
|
||||
ErrorClassification getErrorType();
|
||||
|
||||
/**
|
||||
* Return a String representation of the {@link #getParsedPath() parsed path},
|
||||
* or an empty String if the error is not associated with a field.
|
||||
* <p>Example paths:
|
||||
* <pre>
|
||||
* "hero"
|
||||
* "hero.name"
|
||||
* "hero.friends"
|
||||
* "hero.friends[2]"
|
||||
* "hero.friends[2].name"
|
||||
* </pre>
|
||||
*
|
||||
*/
|
||||
String getPath();
|
||||
|
||||
/**
|
||||
* Return the path to a response field which experienced the error,
|
||||
* if the error can be associated to a particular field in the result, or
|
||||
* otherwise an empty list. This allows a client to identify whether a
|
||||
* {@code null} result is intentional or caused by an error.
|
||||
* <p>This list contains path segments starting at the root of the response
|
||||
* and ending with the field associated with the error. Path segments that
|
||||
* represent fields are strings, and path segments that represent list
|
||||
* indices are 0-indexed integers. If the error happens in an aliased field,
|
||||
* the path uses the aliased name, since it represents a path in the
|
||||
* response, not in the request.
|
||||
*/
|
||||
List<Object> getParsedPath();
|
||||
|
||||
/**
|
||||
* Return a list of locations in the GraphQL document, if the error can be
|
||||
* associated to a particular point in the document. Each location has a
|
||||
@@ -32,35 +70,9 @@ public interface GraphQlResponseError {
|
||||
List<SourceLocation> getLocations();
|
||||
|
||||
/**
|
||||
* Return a classification for the error that is specific to GraphQL Java.
|
||||
* This is serialized under {@link #getExtensions() "extensions"} in the
|
||||
* response map.
|
||||
* @see graphql.ErrorType
|
||||
* @see org.springframework.graphql.execution.ErrorType
|
||||
* Return a map with GraphQL Java and other implementation specific protocol
|
||||
* error detail extensions such as {@link #getErrorType()}, possibly empty.
|
||||
*/
|
||||
@Nullable
|
||||
ErrorClassification getErrorType();
|
||||
|
||||
/**
|
||||
* Return the path to a response field which experienced the error,
|
||||
* if the error can be associated to a particular field in the result. This
|
||||
* allows a client to identify whether a {@code null} result is intentional
|
||||
* or caused by an error.
|
||||
* <p>This list contains path segments starting at the root of the response
|
||||
* and ending with the field associated with the error. Path segments that
|
||||
* represent fields are strings, and path segments that represent list
|
||||
* indices are 0-indexed integers. If the error happens in an aliased field,
|
||||
* the path uses the aliased name, since it represents a path in the
|
||||
* response, not in the request.
|
||||
*/
|
||||
@Nullable
|
||||
List<Object> getPath();
|
||||
|
||||
/**
|
||||
* Return a map with GraphQL Java specific error details such as the
|
||||
* {@link #getErrorType()}.
|
||||
*/
|
||||
@Nullable
|
||||
Map<String, Object> getExtensions();
|
||||
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ public class RequestOutput implements GraphQlResponse {
|
||||
}
|
||||
|
||||
public List<GraphQlResponseError> getErrors() {
|
||||
return this.result.getErrors().stream().map(OutputError::new).collect(Collectors.toList());
|
||||
return this.result.getErrors().stream().map(Error::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Map<Object, Object> getExtensions() {
|
||||
@@ -104,11 +104,14 @@ public class RequestOutput implements GraphQlResponse {
|
||||
}
|
||||
|
||||
|
||||
private static class OutputError implements GraphQlResponseError {
|
||||
/**
|
||||
* {@link GraphQLError} that wraps a {@link GraphQLError}.
|
||||
*/
|
||||
private static class Error implements GraphQlResponseError {
|
||||
|
||||
private final GraphQLError delegate;
|
||||
|
||||
OutputError(GraphQLError delegate) {
|
||||
Error(GraphQLError delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@@ -128,13 +131,21 @@ public class RequestOutput implements GraphQlResponse {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Object> getPath() {
|
||||
return this.delegate.getPath();
|
||||
public String getPath() {
|
||||
return getParsedPath().stream()
|
||||
.reduce("",
|
||||
(s, o) -> s + (o instanceof Integer ? "[" + o + "]" : (s.isEmpty() ? o : "." + o)),
|
||||
(s, s2) -> null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Object> getParsedPath() {
|
||||
return (this.delegate.getPath() != null ? this.delegate.getPath() : Collections.emptyList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getExtensions() {
|
||||
return this.delegate.getExtensions();
|
||||
return (this.delegate.getExtensions() != null ? this.delegate.getExtensions() : Collections.emptyMap());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public interface ClientGraphQlResponse extends GraphQlResponse {
|
||||
* 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">
|
||||
* <pre>
|
||||
* "hero"
|
||||
* "hero.name"
|
||||
* "hero.friends"
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
|
||||
package org.springframework.graphql.client;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.core.ResolvableType;
|
||||
@@ -34,6 +36,7 @@ import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.MimeType;
|
||||
import org.springframework.util.MimeTypeUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
|
||||
/**
|
||||
@@ -69,12 +72,85 @@ final class DefaultClientGraphQlResponse extends MapGraphQlResponse implements C
|
||||
|
||||
@Override
|
||||
public ResponseField field(String path) {
|
||||
|
||||
List<Object> dataPath = parseFieldPath(path);
|
||||
Object value = getFieldValue(dataPath);
|
||||
List<GraphQlResponseError> errors = getFieldErrors(dataPath);
|
||||
return new DefaultField(path, dataPath, getFieldValue(dataPath), getFieldErrors(path));
|
||||
}
|
||||
|
||||
return new DefaultField(path, dataPath, (value != NO_VALUE ? value : null), errors);
|
||||
private 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;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Object getFieldValue(List<Object> fieldPath) {
|
||||
Object value = (isValid() ? getData() : null);
|
||||
for (Object segment : fieldPath) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (segment instanceof String) {
|
||||
Assert.isTrue(value instanceof Map, () -> "Invalid path " + fieldPath + ", data: " + getData());
|
||||
value = ((Map<?, ?>) value).getOrDefault(segment, null);
|
||||
}
|
||||
else {
|
||||
Assert.isTrue(value instanceof List, () -> "Invalid path " + fieldPath + ", data: " + getData());
|
||||
int index = (int) segment;
|
||||
value = (index < ((List<?>) value).size() ? ((List<?>) value).get(index) : null);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return field errors whose path starts with the given field path.
|
||||
* @param path the field path to match
|
||||
* @return errors whose path starts with the dataPath
|
||||
*/
|
||||
private List<GraphQlResponseError> getFieldErrors(String path) {
|
||||
if (path.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return getErrors().stream()
|
||||
.filter(error -> {
|
||||
String errorPath = error.getPath();
|
||||
return !errorPath.isEmpty() && (errorPath.startsWith(path) || path.startsWith(errorPath));
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -97,7 +173,7 @@ final class DefaultClientGraphQlResponse extends MapGraphQlResponse implements C
|
||||
|
||||
private final List<Object> parsedPath;
|
||||
|
||||
private final List<GraphQlResponseError> errors;
|
||||
private final List<GraphQlResponseError> fieldErrors;
|
||||
|
||||
@Nullable
|
||||
private final Object value;
|
||||
@@ -108,7 +184,7 @@ final class DefaultClientGraphQlResponse extends MapGraphQlResponse implements C
|
||||
this.path = path;
|
||||
this.parsedPath = parsedPath;
|
||||
this.value = value;
|
||||
this.errors = errors;
|
||||
this.fieldErrors = errors;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -116,6 +192,11 @@ final class DefaultClientGraphQlResponse extends MapGraphQlResponse implements C
|
||||
return this.path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Object> getParsedPath() {
|
||||
return this.parsedPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasValue() {
|
||||
return (this.value != null);
|
||||
@@ -129,9 +210,8 @@ final class DefaultClientGraphQlResponse extends MapGraphQlResponse implements C
|
||||
|
||||
@Override
|
||||
public GraphQlResponseError getError() {
|
||||
for (GraphQlResponseError error : this.errors) {
|
||||
Assert.notNull(error.getPath(), "Expected field error");
|
||||
if (error.getPath().size() <= this.parsedPath.size()) {
|
||||
for (GraphQlResponseError error : this.fieldErrors) {
|
||||
if (error.getParsedPath().size() <= this.parsedPath.size()) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@@ -140,7 +220,7 @@ final class DefaultClientGraphQlResponse extends MapGraphQlResponse implements C
|
||||
|
||||
@Override
|
||||
public List<GraphQlResponseError> getErrors() {
|
||||
return this.errors;
|
||||
return this.fieldErrors;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package org.springframework.graphql.client;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -28,12 +27,9 @@ import graphql.language.SourceLocation;
|
||||
|
||||
import org.springframework.graphql.GraphQlResponse;
|
||||
import org.springframework.graphql.GraphQlResponseError;
|
||||
import org.springframework.graphql.execution.ErrorType;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* {@link GraphQlResponse} that wraps a deserialized the GraphQL response map.
|
||||
@@ -43,12 +39,6 @@ import org.springframework.util.StringUtils;
|
||||
*/
|
||||
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<GraphQlResponseError> errors;
|
||||
@@ -70,7 +60,7 @@ class MapGraphQlResponse implements GraphQlResponse {
|
||||
private static List<GraphQlResponseError> wrapErrors(Map<String, Object> map) {
|
||||
List<Map<String, Object>> errors = (List<Map<String, Object>>) map.get("errors");
|
||||
errors = (errors != null ? errors : Collections.emptyList());
|
||||
return errors.stream().map(MapError::new).collect(Collectors.toList());
|
||||
return errors.stream().map(Error::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -101,112 +91,6 @@ 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<GraphQlResponseError> getFieldErrors(List<Object> fieldPath) {
|
||||
if (fieldPath.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<GraphQlResponseError> fieldErrors = Collections.emptyList();
|
||||
for (GraphQlResponseError error : this.errors) {
|
||||
List<Object> errorPath = error.getPath();
|
||||
if (CollectionUtils.isEmpty(errorPath)) {
|
||||
continue;
|
||||
}
|
||||
boolean match = true;
|
||||
for (int i = 0; match && i < fieldPath.size() && i < errorPath.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 &&
|
||||
@@ -227,33 +111,36 @@ class MapGraphQlResponse implements GraphQlResponse {
|
||||
/**
|
||||
* {@link GraphQLError} that wraps a deserialized the GraphQL response map.
|
||||
*/
|
||||
@SuppressWarnings("serial")
|
||||
private static final class MapError implements GraphQlResponseError {
|
||||
private static final class Error implements GraphQlResponseError {
|
||||
|
||||
private final Map<String, Object> errorMap;
|
||||
|
||||
private final List<SourceLocation> locations;
|
||||
|
||||
MapError(Map<String, Object> errorMap) {
|
||||
private final String path;
|
||||
|
||||
Error(Map<String, Object> errorMap) {
|
||||
Assert.notNull(errorMap, "'errorMap' is required");
|
||||
this.errorMap = errorMap;
|
||||
this.locations = initLocations(errorMap);
|
||||
this.path = initPath(errorMap);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<SourceLocation> initLocations(Map<String, Object> errorMap) {
|
||||
List<Map<String, Object>> locations = (List<Map<String, Object>>) errorMap.get("locations");
|
||||
if (locations == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return locations.stream()
|
||||
.map(map -> new SourceLocation(
|
||||
(int) map.getOrDefault("line", 0),
|
||||
(int) map.getOrDefault("column", 0),
|
||||
(String) map.get("sourceName")))
|
||||
return ((List<Map<String, Object>>) errorMap.getOrDefault("locations", Collections.emptyList())).stream()
|
||||
.map(map -> new SourceLocation((int) map.get("line"), (int) map.get("column"), (String) map.get("sourceName")))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String initPath(Map<String, Object> errorMap) {
|
||||
return ((List<Object>) errorMap.getOrDefault("path", Collections.emptyList())).stream()
|
||||
.reduce("",
|
||||
(s, o) -> s + (o instanceof Integer ? "[" + o + "]" : (s.isEmpty() ? o : "." + o)),
|
||||
(s, s2) -> null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public String getMessage() {
|
||||
@@ -266,40 +153,31 @@ class MapGraphQlResponse implements GraphQlResponse {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public ErrorClassification getErrorType() {
|
||||
// Attempt the reverse of how errorType is serialized in GraphqlErrorHelper.toSpecification.
|
||||
// However, we can only do that for ErrorClassification enums that we know of.
|
||||
String value = (getExtensions() != null ? (String) getExtensions().get("classification") : null);
|
||||
if (value != null) {
|
||||
try {
|
||||
return graphql.ErrorType.valueOf(value);
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
return ErrorType.valueOf(value);
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
// ignore
|
||||
}
|
||||
String classification = (String) getExtensions().getOrDefault("classification", "");
|
||||
try {
|
||||
return graphql.ErrorType.valueOf(classification);
|
||||
}
|
||||
return null;
|
||||
catch (IllegalArgumentException ex) {
|
||||
return org.springframework.graphql.execution.ErrorType.valueOf(classification);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPath() {
|
||||
return this.path;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
@Nullable
|
||||
public List<Object> getPath() {
|
||||
return (List<Object>) this.errorMap.get("path");
|
||||
public List<Object> getParsedPath() {
|
||||
return (List<Object>) this.errorMap.getOrDefault("path", Collections.emptyList());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
@Nullable
|
||||
public Map<String, Object> getExtensions() {
|
||||
return (Map<String, Object>) this.errorMap.get("extensions");
|
||||
return (Map<String, Object>) this.errorMap.getOrDefault("extensions", Collections.emptyMap());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -313,7 +191,7 @@ class MapGraphQlResponse implements GraphQlResponse {
|
||||
GraphQlResponseError other = (GraphQlResponseError) o;
|
||||
return (ObjectUtils.nullSafeEquals(getMessage(), other.getMessage()) &&
|
||||
ObjectUtils.nullSafeEquals(getLocations(), other.getLocations()) &&
|
||||
ObjectUtils.nullSafeEquals(getPath(), other.getPath()) &&
|
||||
ObjectUtils.nullSafeEquals(getParsedPath(), other.getParsedPath()) &&
|
||||
getErrorType() == other.getErrorType());
|
||||
}
|
||||
|
||||
@@ -322,7 +200,7 @@ class MapGraphQlResponse implements GraphQlResponse {
|
||||
int result = 1;
|
||||
result = 31 * result + ObjectUtils.nullSafeHashCode(getMessage());
|
||||
result = 31 * result + ObjectUtils.nullSafeHashCode(getLocations());
|
||||
result = 31 * result + ObjectUtils.nullSafeHashCode(getPath());
|
||||
result = 31 * result + ObjectUtils.nullSafeHashCode(getParsedPath());
|
||||
result = 31 * result + ObjectUtils.nullSafeHashCode(getErrorType());
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -47,10 +47,18 @@ public interface ResponseField {
|
||||
boolean hasValue();
|
||||
|
||||
/**
|
||||
* Return the path for the field under the "data" key in the response map.
|
||||
* Return a String representation of the field path as described in
|
||||
* {@link ClientGraphQlResponse#field(String)}.
|
||||
*/
|
||||
String getPath();
|
||||
|
||||
/**
|
||||
* Return a parsed representation of the field path, in the format described
|
||||
* for error paths in Section 7.1.2, "Response Format" of the GraphQL spec.
|
||||
* @see GraphQlResponseError#getParsedPath()
|
||||
*/
|
||||
List<Object> getParsedPath();
|
||||
|
||||
/**
|
||||
* Return the field value without any decoding.
|
||||
* @param <T> the expected value type, e.g. Map, List, or a scalar type.
|
||||
|
||||
@@ -16,12 +16,11 @@
|
||||
|
||||
package org.springframework.graphql.client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import graphql.GraphQLError;
|
||||
import graphql.GraphqlErrorBuilder;
|
||||
@@ -29,7 +28,10 @@ import graphql.execution.ResultPath;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.springframework.graphql.GraphQlRequest;
|
||||
import org.springframework.graphql.GraphQlResponseError;
|
||||
import org.springframework.http.codec.json.Jackson2JsonDecoder;
|
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -40,13 +42,13 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
|
||||
* Unit tests for {@link MapGraphQlResponse}.
|
||||
* @author Rossen Stoyanchev
|
||||
*/
|
||||
public class MapGraphQlResponseTests {
|
||||
public class DefaultGraphQlClientResponseTests {
|
||||
|
||||
private static final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
|
||||
@Test
|
||||
void parsePath() {
|
||||
void parsePath() throws Exception {
|
||||
testParsePath("");
|
||||
testParsePath(" \t ");
|
||||
testParsePath("me.name", "me", "name");
|
||||
@@ -55,8 +57,8 @@ public class MapGraphQlResponseTests {
|
||||
testParsePath(" me . name ", " me ", " name ");
|
||||
}
|
||||
|
||||
private static void testParsePath(String path, Object... expected) {
|
||||
assertThat(MapGraphQlResponse.parseFieldPath(path)).containsExactly(expected);
|
||||
private void testParsePath(String path, Object... expected) throws Exception {
|
||||
assertThat(getField(path, "{}").getParsedPath()).containsExactly(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -71,10 +73,8 @@ public class MapGraphQlResponseTests {
|
||||
testParseInvalidPath("me.friends[5]]");
|
||||
}
|
||||
|
||||
private static void testParseInvalidPath(String path) {
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> MapGraphQlResponse.parseFieldPath(path))
|
||||
.withMessage("Invalid path: '" + path + "'");
|
||||
private void testParseInvalidPath(String path) {
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> getField(path, "{}")).withMessage("Invalid path: '" + path + "'");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -82,71 +82,58 @@ public class MapGraphQlResponseTests {
|
||||
|
||||
// null "data"
|
||||
testFieldValue("", "null", null);
|
||||
testFieldValue("me", "null", MapGraphQlResponse.NO_VALUE);
|
||||
testFieldValue("me", "null", null);
|
||||
|
||||
// no such key or index
|
||||
testFieldValue("me", "{}", MapGraphQlResponse.NO_VALUE); // "data" not null but no such key
|
||||
testFieldValue("me.friends", "{\"me\":{}}", MapGraphQlResponse.NO_VALUE);
|
||||
testFieldValue("me.friends[0]", "{\"me\": {\"friends\": []}}", MapGraphQlResponse.NO_VALUE);
|
||||
testFieldValue("me", "{}", null); // "data" not null but no such key
|
||||
testFieldValue("me.friends", "{\"me\":{}}", null);
|
||||
testFieldValue("me.friends[0]", "{\"me\": {\"friends\": []}}", null);
|
||||
|
||||
// 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 = new MapGraphQlResponse(Collections.singletonMap("data", map));
|
||||
Object value = response.getFieldValue(parsedPath);
|
||||
if (expected != null) {
|
||||
assertThat(value).isEqualTo(expected);
|
||||
private void testFieldValue(String path, String dataJson, @Nullable Object expected) throws Exception {
|
||||
Object value = getField(path, dataJson).getValue();
|
||||
if (expected == null) {
|
||||
assertThat(value).isNull();
|
||||
}
|
||||
else {
|
||||
assertThat(value).isNotNull();
|
||||
assertThat(value).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fieldValueInvalidPath() throws Exception {
|
||||
void fieldValueInvalidPath() {
|
||||
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 = new MapGraphQlResponse(Collections.singletonMap("data", map));
|
||||
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> response.getFieldValue(parsedPath))
|
||||
.withMessage("Invalid path " + parsedPath + ", data: " + map);
|
||||
private void testFieldValueInvalidPath(String path, String json) {
|
||||
assertThatIllegalArgumentException().isThrownBy(() -> getField(path, json))
|
||||
.withMessageStartingWith("Invalid path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fieldErrors() {
|
||||
|
||||
List<Object> path = MapGraphQlResponse.parseFieldPath("me.friends");
|
||||
String path = "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 = new MapGraphQlResponse(Collections.singletonMap("errors", errorList));
|
||||
List<GraphQlResponseError> errors = response.getFieldErrors(path);
|
||||
ResponseField field = getField(path, error0, error1, error2, error3);
|
||||
List<GraphQlResponseError> errors = field.getErrors();
|
||||
|
||||
assertThat(errors).hasSize(3);
|
||||
assertThat(errors.get(0).getPath()).containsExactly("me");
|
||||
assertThat(errors.get(1).getPath()).containsExactly("me", "friends");
|
||||
assertThat(errors.get(2).getPath()).containsExactly("me", "friends", 0, "name");
|
||||
assertThat(errors.get(0).getPath()).isEqualTo("me");
|
||||
assertThat(errors.get(1).getPath()).isEqualTo("me.friends");
|
||||
assertThat(errors.get(2).getPath()).isEqualTo("me.friends[0].name");
|
||||
}
|
||||
|
||||
private GraphQLError createError(@Nullable String errorPath, String message) {
|
||||
@@ -157,4 +144,22 @@ public class MapGraphQlResponseTests {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private ResponseField getField(String path, String dataJson) throws Exception {
|
||||
Map<?, ?> dataMap = mapper.readValue(dataJson, Map.class);
|
||||
ClientGraphQlResponse response = creatResponse(Collections.singletonMap("data", dataMap));
|
||||
return response.field(path);
|
||||
}
|
||||
|
||||
private ResponseField getField(String path, GraphQLError... errors) {
|
||||
List<?> list = Arrays.stream(errors).map(GraphQLError::toSpecification).collect(Collectors.toList());
|
||||
ClientGraphQlResponse response = creatResponse(Collections.singletonMap("errors", list));
|
||||
return response.field(path);
|
||||
}
|
||||
|
||||
private ClientGraphQlResponse creatResponse(Map<String, Object> responseMap) {
|
||||
return new DefaultClientGraphQlResponse(
|
||||
new GraphQlRequest("{test}"), GraphQlTransport.wrapResponseMap(responseMap),
|
||||
new Jackson2JsonEncoder(), new Jackson2JsonDecoder());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -191,7 +191,7 @@ public class GraphQlClientTests extends GraphQlClientTestSupport {
|
||||
ResponseField field = response.field("me");
|
||||
assertThat(field.hasValue()).isTrue();
|
||||
assertThat(field.getErrors()).hasSize(1);
|
||||
assertThat(field.getErrors().get(0).getPath()).containsExactly("me", "name");
|
||||
assertThat(field.getErrors().get(0).getParsedPath()).containsExactly("me", "name");
|
||||
assertThat(field.toEntity(MovieCharacter.class))
|
||||
.as("Decoding with nested field error should not be precluded")
|
||||
.isNotNull();
|
||||
@@ -199,7 +199,7 @@ public class GraphQlClientTests extends GraphQlClientTestSupport {
|
||||
ResponseField nameField = response.field("me.name");
|
||||
assertThat(nameField.hasValue()).isFalse();
|
||||
assertThat(nameField.getError()).isNotNull();
|
||||
assertThat(nameField.getError().getPath()).containsExactly("me", "name");
|
||||
assertThat(nameField.getError().getParsedPath()).containsExactly("me", "name");
|
||||
assertThatThrownBy(() -> nameField.toEntity(String.class))
|
||||
.as("Decoding field null with direct field error should be rejected")
|
||||
.isInstanceOf(FieldAccessException.class);
|
||||
@@ -207,7 +207,7 @@ public class GraphQlClientTests extends GraphQlClientTestSupport {
|
||||
ResponseField nonExistingField = response.field("me.name.other");
|
||||
assertThat(nonExistingField.hasValue()).isFalse();
|
||||
assertThat(nameField.getError()).isNotNull();
|
||||
assertThat(nameField.getError().getPath()).containsExactly("me", "name");
|
||||
assertThat(nameField.getError().getParsedPath()).containsExactly("me", "name");
|
||||
}
|
||||
|
||||
private GraphQLError errorForPath(String errorPath) {
|
||||
|
||||
Reference in New Issue
Block a user