Skip to content

Commit 827b70b

Browse files
committed
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
1 parent db24c8f commit 827b70b

File tree

10 files changed

+246
-247
lines changed

10 files changed

+246
-247
lines changed

spring-graphql-docs/src/docs/asciidoc/client.adoc

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,8 @@ which is a strategy for loading the document for a request by file name.
134134
== Requests
135135

136136
Once you have a <<client-graphqlclient>>, you can begin to perform requests via
137-
<<client-requests-retrieve, retrieve()>> or <<client-requests-execute, execute()>>,
138-
with one merely a shortcut over the other.
137+
<<client-requests-retrieve, retrieve()>> or <<client-requests-execute, execute()>>
138+
where the former is merely a shortcut for the latter.
139139

140140

141141

@@ -160,20 +160,19 @@ The below retrieves and decodes the data for a query:
160160
.toEntity(Project.class); <3>
161161
----
162162
<1> The operation to perform
163-
<2> Retrieve the response, and specify a path to decode from
164-
<3> Decode to a target object
163+
<2> Specify a path under the "data" key in the response map
164+
<3> Decode the data at the path to the target type
165165

166166
The document is a `String` that could be a literal or produced through a code generated
167167
request object. You can also define documents in files and use a
168168
<<client-requests-document-source>> to resole them by file name.
169169

170170
The path is relative to the "data" key and uses a simple dot (".") separated notation
171171
for nested fields with optional array indices for list elements, e.g. `"project.name"`,
172-
`"project .releases[0].version"`, and so on.
172+
`"project.releases[0].version"`, and so on.
173173

174174
Decoding can fail with `FieldAccessException` if the given path is not present in the
175-
response map, or when there is no "data" key (failed response) at all, or when there is
176-
a `null` value with a field error at the path.
175+
response map, or when the value is `null` and there is an error for the field.
177176

178177
By default, `FieldAccessException` is also raised on `retrieve` for partial data where
179178
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.
202201
[[client-requests-execute]]
203202
=== Execute
204203

205-
The `retrieve` method is only a shortcut to decode to a single higher level object. For
206-
more control and access to the response, use the `execute` method. For example:
204+
The `retrieve` method is only a shortcut to decode from a single path to a higher level
205+
object. For more control and access to the response, use the `execute` method.
206+
207+
For example:
207208

208209
[source,java,indent=0,subs="verbatim,quotes"]
209210
----
@@ -214,7 +215,7 @@ more control and access to the response, use the `execute` method. For example:
214215
// Check response.isValid(), getErrors()
215216
216217
ResponseField field = response.field("project");
217-
// Check field.isValid(), getError()
218+
// Check field.hasValue(), getError()
218219
219220
return field.toEntity(Project.class)
220221
});

spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponse.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,13 @@ public interface GraphQlResponse {
5555
<T> T getData();
5656

5757
/**
58-
* Return errors for the response. This contains "request errors" when the
59-
* response is not {@link #isValid() valid} and/or "field errors" for a
60-
* partial response.
58+
* Return errors included in the response.
59+
* <p>A response that is not {@link #isValid() valid} contains "request
60+
* errors". Those are errors that apply to the request as a whole, and have
61+
* an empty error {@link GraphQlResponseError#getPath() path}.
62+
* <p>A response that is valid may still be partial and contain "field
63+
* errors". Those are errors associated with a specific field through their
64+
* error path.
6165
*/
6266
List<GraphQlResponseError> getErrors();
6367

spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponseError.java

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,44 +23,56 @@ public interface GraphQlResponseError {
2323
@Nullable
2424
String getMessage();
2525

26-
/**
27-
* Return a list of locations in the GraphQL document, if the error can be
28-
* associated to a particular point in the document. Each location has a
29-
* line and a column, both positive, starting from 1 and describing the
30-
* beginning of an associated syntax element.
31-
*/
32-
List<SourceLocation> getLocations();
33-
3426
/**
3527
* Return a classification for the error that is specific to GraphQL Java.
3628
* This is serialized under {@link #getExtensions() "extensions"} in the
3729
* response map.
3830
* @see graphql.ErrorType
3931
* @see org.springframework.graphql.execution.ErrorType
4032
*/
41-
@Nullable
4233
ErrorClassification getErrorType();
4334

35+
/**
36+
* Return a String representation of the {@link #getParsedPath() parsed path},
37+
* or an empty String if the error is not associated with a field.
38+
* <p>Example paths:
39+
* <pre>
40+
* "hero"
41+
* "hero.name"
42+
* "hero.friends"
43+
* "hero.friends[2]"
44+
* "hero.friends[2].name"
45+
* </pre>
46+
*
47+
*/
48+
String getPath();
49+
4450
/**
4551
* Return the path to a response field which experienced the error,
46-
* if the error can be associated to a particular field in the result. This
47-
* allows a client to identify whether a {@code null} result is intentional
48-
* or caused by an error.
52+
* if the error can be associated to a particular field in the result, or
53+
* otherwise an empty list. This allows a client to identify whether a
54+
* {@code null} result is intentional or caused by an error.
4955
* <p>This list contains path segments starting at the root of the response
5056
* and ending with the field associated with the error. Path segments that
5157
* represent fields are strings, and path segments that represent list
5258
* indices are 0-indexed integers. If the error happens in an aliased field,
5359
* the path uses the aliased name, since it represents a path in the
5460
* response, not in the request.
5561
*/
56-
@Nullable
57-
List<Object> getPath();
62+
List<Object> getParsedPath();
5863

5964
/**
60-
* Return a map with GraphQL Java specific error details such as the
61-
* {@link #getErrorType()}.
65+
* Return a list of locations in the GraphQL document, if the error can be
66+
* associated to a particular point in the document. Each location has a
67+
* line and a column, both positive, starting from 1 and describing the
68+
* beginning of an associated syntax element.
69+
*/
70+
List<SourceLocation> getLocations();
71+
72+
/**
73+
* Return a map with GraphQL Java and other implementation specific protocol
74+
* error detail extensions such as {@link #getErrorType()}, possibly empty.
6275
*/
63-
@Nullable
6476
Map<String, Object> getExtensions();
6577

6678
}

spring-graphql/src/main/java/org/springframework/graphql/RequestOutput.java

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public <T> T getData() {
8686
}
8787

8888
public List<GraphQlResponseError> getErrors() {
89-
return this.result.getErrors().stream().map(OutputError::new).collect(Collectors.toList());
89+
return this.result.getErrors().stream().map(Error::new).collect(Collectors.toList());
9090
}
9191

9292
public Map<Object, Object> getExtensions() {
@@ -104,11 +104,14 @@ public String toString() {
104104
}
105105

106106

107-
private static class OutputError implements GraphQlResponseError {
107+
/**
108+
* {@link GraphQLError} that wraps a {@link GraphQLError}.
109+
*/
110+
private static class Error implements GraphQlResponseError {
108111

109112
private final GraphQLError delegate;
110113

111-
OutputError(GraphQLError delegate) {
114+
Error(GraphQLError delegate) {
112115
this.delegate = delegate;
113116
}
114117

@@ -128,13 +131,21 @@ public ErrorClassification getErrorType() {
128131
}
129132

130133
@Override
131-
public List<Object> getPath() {
132-
return this.delegate.getPath();
134+
public String getPath() {
135+
return getParsedPath().stream()
136+
.reduce("",
137+
(s, o) -> s + (o instanceof Integer ? "[" + o + "]" : (s.isEmpty() ? o : "." + o)),
138+
(s, s2) -> null);
139+
}
140+
141+
@Override
142+
public List<Object> getParsedPath() {
143+
return (this.delegate.getPath() != null ? this.delegate.getPath() : Collections.emptyList());
133144
}
134145

135146
@Override
136147
public Map<String, Object> getExtensions() {
137-
return this.delegate.getExtensions();
148+
return (this.delegate.getExtensions() != null ? this.delegate.getExtensions() : Collections.emptyMap());
138149
}
139150

140151
}

spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public interface ClientGraphQlResponse extends GraphQlResponse {
3939
* Navigate to the given path under the "data" key of the response map where
4040
* the path is a dot-separated string with optional array indexes.
4141
* <p>Example paths:
42-
* <pre style="class">
42+
* <pre>
4343
* "hero"
4444
* "hero.name"
4545
* "hero.friends"

spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616

1717
package org.springframework.graphql.client;
1818

19+
import java.util.ArrayList;
1920
import java.util.Collections;
2021
import java.util.List;
2122
import java.util.Map;
23+
import java.util.stream.Collectors;
2224

2325
import org.springframework.core.ParameterizedTypeReference;
2426
import org.springframework.core.ResolvableType;
@@ -34,6 +36,7 @@
3436
import org.springframework.util.Assert;
3537
import org.springframework.util.MimeType;
3638
import org.springframework.util.MimeTypeUtils;
39+
import org.springframework.util.StringUtils;
3740

3841

3942
/**
@@ -69,12 +72,85 @@ public GraphQlRequest getRequest() {
6972

7073
@Override
7174
public ResponseField field(String path) {
72-
7375
List<Object> dataPath = parseFieldPath(path);
74-
Object value = getFieldValue(dataPath);
75-
List<GraphQlResponseError> errors = getFieldErrors(dataPath);
76+
return new DefaultField(path, dataPath, getFieldValue(dataPath), getFieldErrors(path));
77+
}
78+
79+
private static List<Object> parseFieldPath(String path) {
80+
if (!StringUtils.hasText(path)) {
81+
return Collections.emptyList();
82+
}
83+
84+
String invalidPathMessage = "Invalid path: '" + path + "'";
85+
List<Object> dataPath = new ArrayList<>();
86+
87+
StringBuilder sb = new StringBuilder();
88+
boolean readingIndex = false;
89+
90+
for (int i = 0; i < path.length(); i++) {
91+
char c = path.charAt(i);
92+
switch (c) {
93+
case '.':
94+
case '[':
95+
Assert.isTrue(!readingIndex, invalidPathMessage);
96+
break;
97+
case ']':
98+
i++;
99+
Assert.isTrue(readingIndex, invalidPathMessage);
100+
Assert.isTrue(i == path.length() || path.charAt(i) == '.', invalidPathMessage);
101+
break;
102+
default:
103+
sb.append(c);
104+
if (i < path.length() - 1) {
105+
continue;
106+
}
107+
}
108+
String token = sb.toString();
109+
Assert.hasText(token, invalidPathMessage);
110+
dataPath.add(readingIndex ? Integer.parseInt(token) : token);
111+
sb.delete(0, sb.length());
112+
113+
readingIndex = (c == '[');
114+
}
115+
116+
return dataPath;
117+
}
118+
119+
@Nullable
120+
private Object getFieldValue(List<Object> fieldPath) {
121+
Object value = (isValid() ? getData() : null);
122+
for (Object segment : fieldPath) {
123+
if (value == null) {
124+
return null;
125+
}
126+
if (segment instanceof String) {
127+
Assert.isTrue(value instanceof Map, () -> "Invalid path " + fieldPath + ", data: " + getData());
128+
value = ((Map<?, ?>) value).getOrDefault(segment, null);
129+
}
130+
else {
131+
Assert.isTrue(value instanceof List, () -> "Invalid path " + fieldPath + ", data: " + getData());
132+
int index = (int) segment;
133+
value = (index < ((List<?>) value).size() ? ((List<?>) value).get(index) : null);
134+
}
135+
}
136+
return value;
137+
}
76138

77-
return new DefaultField(path, dataPath, (value != NO_VALUE ? value : null), errors);
139+
/**
140+
* Return field errors whose path starts with the given field path.
141+
* @param path the field path to match
142+
* @return errors whose path starts with the dataPath
143+
*/
144+
private List<GraphQlResponseError> getFieldErrors(String path) {
145+
if (path.isEmpty()) {
146+
return Collections.emptyList();
147+
}
148+
return getErrors().stream()
149+
.filter(error -> {
150+
String errorPath = error.getPath();
151+
return !errorPath.isEmpty() && (errorPath.startsWith(path) || path.startsWith(errorPath));
152+
})
153+
.collect(Collectors.toList());
78154
}
79155

80156
@Override
@@ -97,7 +173,7 @@ private class DefaultField implements ResponseField {
97173

98174
private final List<Object> parsedPath;
99175

100-
private final List<GraphQlResponseError> errors;
176+
private final List<GraphQlResponseError> fieldErrors;
101177

102178
@Nullable
103179
private final Object value;
@@ -108,14 +184,19 @@ public DefaultField(
108184
this.path = path;
109185
this.parsedPath = parsedPath;
110186
this.value = value;
111-
this.errors = errors;
187+
this.fieldErrors = errors;
112188
}
113189

114190
@Override
115191
public String getPath() {
116192
return this.path;
117193
}
118194

195+
@Override
196+
public List<Object> getParsedPath() {
197+
return this.parsedPath;
198+
}
199+
119200
@Override
120201
public boolean hasValue() {
121202
return (this.value != null);
@@ -129,9 +210,8 @@ public <T> T getValue() {
129210

130211
@Override
131212
public GraphQlResponseError getError() {
132-
for (GraphQlResponseError error : this.errors) {
133-
Assert.notNull(error.getPath(), "Expected field error");
134-
if (error.getPath().size() <= this.parsedPath.size()) {
213+
for (GraphQlResponseError error : this.fieldErrors) {
214+
if (error.getParsedPath().size() <= this.parsedPath.size()) {
135215
return error;
136216
}
137217
}
@@ -140,7 +220,7 @@ public GraphQlResponseError getError() {
140220

141221
@Override
142222
public List<GraphQlResponseError> getErrors() {
143-
return this.errors;
223+
return this.fieldErrors;
144224
}
145225

146226
@Override

0 commit comments

Comments
 (0)