Skip to content

Commit

Permalink
Update GraphQlResponseError contract
Browse files Browse the repository at this point in the history
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
  • Loading branch information
rstoyanchev committed Mar 18, 2022
1 parent db24c8f commit 827b70b
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 247 deletions.
21 changes: 11 additions & 10 deletions spring-graphql-docs/src/docs/asciidoc/client.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.



Expand All @@ -160,20 +160,19 @@ 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
<<client-requests-document-source>> to resole them by file name.

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
Expand Down Expand Up @@ -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"]
----
Expand All @@ -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)
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,44 +23,56 @@ public interface GraphQlResponseError {
@Nullable
String getMessage();

/**
* 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
* line and a column, both positive, starting from 1 and describing the
* beginning of an associated syntax element.
*/
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
*/
@Nullable
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. This
* allows a client to identify whether a {@code null} result is intentional
* or caused by an 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.
*/
@Nullable
List<Object> getPath();
List<Object> getParsedPath();

/**
* Return a map with GraphQL Java specific error details such as the
* {@link #getErrorType()}.
* 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
* line and a column, both positive, starting from 1 and describing the
* beginning of an associated syntax element.
*/
List<SourceLocation> getLocations();

/**
* Return a map with GraphQL Java and other implementation specific protocol
* error detail extensions such as {@link #getErrorType()}, possibly empty.
*/
@Nullable
Map<String, Object> getExtensions();

}
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public <T> T getData() {
}

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() {
Expand All @@ -104,11 +104,14 @@ public String toString() {
}


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

Expand All @@ -128,13 +131,21 @@ public ErrorClassification getErrorType() {
}

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

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +36,7 @@
import org.springframework.util.Assert;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.StringUtils;


/**
Expand Down Expand Up @@ -69,12 +72,85 @@ public GraphQlRequest getRequest() {

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

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 new DefaultField(path, dataPath, (value != NO_VALUE ? value : null), errors);
/**
* 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
Expand All @@ -97,7 +173,7 @@ private class DefaultField implements ResponseField {

private final List<Object> parsedPath;

private final List<GraphQlResponseError> errors;
private final List<GraphQlResponseError> fieldErrors;

@Nullable
private final Object value;
Expand All @@ -108,14 +184,19 @@ public DefaultField(
this.path = path;
this.parsedPath = parsedPath;
this.value = value;
this.errors = errors;
this.fieldErrors = errors;
}

@Override
public String getPath() {
return this.path;
}

@Override
public List<Object> getParsedPath() {
return this.parsedPath;
}

@Override
public boolean hasValue() {
return (this.value != null);
Expand All @@ -129,9 +210,8 @@ public <T> T getValue() {

@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;
}
}
Expand All @@ -140,7 +220,7 @@ public GraphQlResponseError getError() {

@Override
public List<GraphQlResponseError> getErrors() {
return this.errors;
return this.fieldErrors;
}

@Override
Expand Down
Loading

0 comments on commit 827b70b

Please sign in to comment.