Skip to content

Commit cdd8b67

Browse files
committed
Refine GraphQlResponseField getError()
Instead of a simple check, looking for an associated field error at or above the field, this method now more focused on finding the reason for a failure when the field has no value. This allows performing a more thorough search including cases when the field error is at, above, or even below (e.g. non-null nested field that bubbled up), in the end falling back on request errors (e.g. failed response without any field errors). Also, rename ResponseField to GraphQlResponseField and move to a top-level class. See gh-10
1 parent 827b70b commit cdd8b67

File tree

10 files changed

+284
-214
lines changed

10 files changed

+284
-214
lines changed

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

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ which is a strategy for loading the document for a request by file name.
135135

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

140140

141141

@@ -159,50 +159,42 @@ The below retrieves and decodes the data for a query:
159159
.retrieve("project") <2>
160160
.toEntity(Project.class); <3>
161161
----
162-
<1> The operation to perform
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
162+
<1> The operation to perform.
163+
<2> The path under the "data" key in the response map to decode from.
164+
<3> Decode the data at the path to the target type.
165165

166-
The document is a `String` that could be a literal or produced through a code generated
167-
request object. You can also define documents in files and use a
166+
The input document is a `String` that could be a literal or produced through a code
167+
generated 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
171-
for nested fields with optional array indices for list elements, e.g. `"project.name"`,
172-
`"project.releases[0].version"`, and so on.
171+
for nested fields with optional array indices for list elements, e.g. `"project.name"`
172+
or `"project.releases[0].version"`.
173173

174-
Decoding can fail with `FieldAccessException` if the given path is not present in the
175-
response map, or when the value is `null` and there is an error for the field.
176-
177-
By default, `FieldAccessException` is also raised on `retrieve` for partial data where
178-
the field value exists but nested fields may be `null` with a field error. In such
179-
cases, you can handle the exception to examine the errors and decide whether or how to
180-
decode the partial data:
174+
Decoding can result in `FieldAccessException` if the given path is not present, or the
175+
field value is `null` and has an error. `FieldAccessException` provides access to the
176+
response and the field:
181177

182178
[source,java,indent=0,subs="verbatim,quotes"]
183179
----
184180
Mono<Project> projectMono = graphQlClient.document(document)
185181
.retrieve("project")
186182
.toEntity(Project.class)
187183
.onErrorResume(FieldAccessException.class, ex -> {
188-
ResponseField field = ex.getField();
189-
// Use field to check nested field errors and/or decode
190-
// Return Mono with Project or an error
184+
ClientGraphQlResponse response = ex.getResponse();
185+
// ...
186+
GraphQlResponseField field = ex.getField();
187+
// ...
191188
});
192189
----
193190

194-
TIP: The GraphQL spec considers a partial response or a partial field to be valid, and
195-
it may be feasible to decode them. By contrast, a failed field (i.e. value not present or
196-
is `null` with field error) or a failed response (no "data" key) are not valid and
197-
attempts to decode those are always rejected.
198-
199191

200192

201193
[[client-requests-execute]]
202194
=== Execute
203195

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.
196+
`retrieve` is only a shortcut to decode from a single path in the response map. For more
197+
control, use the `execute` method and handle the response:
206198

207199
For example:
208200

@@ -212,17 +204,27 @@ For example:
212204
Mono<Project> projectMono = graphQlClient.document(document)
213205
.execute()
214206
.map(response -> {
215-
// Check response.isValid(), getErrors()
207+
if (!response.isValid()) {
208+
// Request failure... <1>
209+
}
216210
217211
ResponseField field = response.field("project");
218-
// Check field.hasValue(), getError()
219-
220-
return field.toEntity(Project.class)
212+
if (!field.hasValue()) {
213+
if (field.getError() != null) {
214+
// Field failure... <2>
215+
}
216+
else {
217+
// Optional field set to null... <3>
218+
}
219+
}
220+
221+
return field.toEntity(Project.class); <4>
221222
});
222223
----
223-
224-
You can use `execute` to check response errors, obtain different fields, check their
225-
field errors and nested field errors, and/or decode their values.
224+
<1> The response does not have data, only errors
225+
<2> Field that is `null` and has an associated error
226+
<3> Field that was set to `null` by its `DataFetcher`
227+
<4> Decode the data at the given path
226228

227229

228230

@@ -272,7 +274,7 @@ You can use the `GraphQlClient` <<client-graphqlclient-builder>> to customize th
272274
== Subscriptions
273275

274276
For a subscription operation, call `retrieveSubscription` instead of `retrieve` to
275-
obtain a stream of responses rather than a single response:
277+
obtain a stream of responses, each decoded to a target object:
276278

277279
[source,java,indent=0,subs="verbatim,quotes"]
278280
----
@@ -281,29 +283,34 @@ obtain a stream of responses rather than a single response:
281283
.toEntity(String.class);
282284
----
283285

284-
Similar to the <<client-requests-retrieve>> vs <<client-requests-execute>> choice
285-
for requests with a single response, the same choice is also available for subscriptions.
286-
For example, for more control over each response, use `executeSubscription` instead of
287-
`retrieveSubscription`:
286+
Similar to the <<client-requests-retrieve, retrieve>> vs <<client-requests-execute, execute>>
287+
alternatives for single response requests, the same is also available for subscriptions.
288+
For more control over each response, use `executeSubscription`:
288289

289290
[source,java,indent=0,subs="verbatim,quotes"]
290291
----
291292
Flux<String> greetingFlux = client.document("subscription { greetings }")
292293
.executeSubscription()
293294
.map(response -> {
294-
// Check response.isValid(), getErrors()
295+
if (!response.isValid()) {
296+
// Request failure...
297+
}
295298
296-
ResponseField field = response.field("greeting");
297-
// Check field.isValid(), getError()
299+
ResponseField field = response.field("project");
300+
if (!field.hasValue()) {
301+
if (field.getError() != null) {
302+
// Field failure...
303+
}
304+
else {
305+
// Optional field set to null... <3>
306+
}
307+
}
298308
299309
return field.toEntity(String.class)
300310
});
301311
----
302312

303-
304-
305-
Subscriptions are supported only with the <<client-websocketgraphqlclient,
306-
WebSocketGraphQlClient>> extension.
313+
NOTE: Subscriptions are supported only over <<client-websocketgraphqlclient, WebSocket>>.
307314

308315

309316

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
public interface ClientGraphQlResponse extends GraphQlResponse {
3232

3333
/**
34-
* Return the request associated with this response.
34+
* Return the request for the response.
3535
*/
3636
GraphQlRequest getRequest();
3737

@@ -48,10 +48,10 @@ public interface ClientGraphQlResponse extends GraphQlResponse {
4848
* </pre>
4949
* @param path relative to the "data" key
5050
* @return representation for the field with further options to inspect or
51-
* decode its value; use {@link ResponseField#hasValue()} to check if the
52-
* field actually exists and has a value.
51+
* decode its value; use {@link GraphQlResponseField#hasValue()} to check if
52+
* the field actually exists and has a value.
5353
*/
54-
ResponseField field(String path);
54+
GraphQlResponseField field(String path);
5555

5656
/**
5757
* Decode the full response map to the given target type.

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

Lines changed: 17 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,13 @@
2323
import java.util.stream.Collectors;
2424

2525
import org.springframework.core.ParameterizedTypeReference;
26-
import org.springframework.core.ResolvableType;
2726
import org.springframework.core.codec.Decoder;
2827
import org.springframework.core.codec.Encoder;
29-
import org.springframework.core.io.buffer.DataBuffer;
30-
import org.springframework.core.io.buffer.DataBufferFactory;
31-
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
3228
import org.springframework.graphql.GraphQlRequest;
3329
import org.springframework.graphql.GraphQlResponse;
3430
import org.springframework.graphql.GraphQlResponseError;
3531
import org.springframework.lang.Nullable;
3632
import org.springframework.util.Assert;
37-
import org.springframework.util.MimeType;
38-
import org.springframework.util.MimeTypeUtils;
3933
import org.springframework.util.StringUtils;
4034

4135

@@ -70,13 +64,22 @@ public GraphQlRequest getRequest() {
7064
return this.request;
7165
}
7266

67+
Encoder<?> getEncoder() {
68+
return this.encoder;
69+
}
70+
71+
Decoder<?> getDecoder() {
72+
return this.decoder;
73+
}
74+
75+
7376
@Override
74-
public ResponseField field(String path) {
75-
List<Object> dataPath = parseFieldPath(path);
76-
return new DefaultField(path, dataPath, getFieldValue(dataPath), getFieldErrors(path));
77+
public GraphQlResponseField field(String path) {
78+
List<Object> parsedPath = parsePath(path);
79+
return new DefaultGraphQlResponseField(this, path, parsedPath, getValue(parsedPath), getFieldErrors(path));
7780
}
7881

79-
private static List<Object> parseFieldPath(String path) {
82+
private static List<Object> parsePath(String path) {
8083
if (!StringUtils.hasText(path)) {
8184
return Collections.emptyList();
8285
}
@@ -117,18 +120,18 @@ private static List<Object> parseFieldPath(String path) {
117120
}
118121

119122
@Nullable
120-
private Object getFieldValue(List<Object> fieldPath) {
123+
private Object getValue(List<Object> path) {
121124
Object value = (isValid() ? getData() : null);
122-
for (Object segment : fieldPath) {
125+
for (Object segment : path) {
123126
if (value == null) {
124127
return null;
125128
}
126129
if (segment instanceof String) {
127-
Assert.isTrue(value instanceof Map, () -> "Invalid path " + fieldPath + ", data: " + getData());
130+
Assert.isTrue(value instanceof Map, () -> "Invalid path " + path + ", data: " + getData());
128131
value = ((Map<?, ?>) value).getOrDefault(segment, null);
129132
}
130133
else {
131-
Assert.isTrue(value instanceof List, () -> "Invalid path " + fieldPath + ", data: " + getData());
134+
Assert.isTrue(value instanceof List, () -> "Invalid path " + path + ", data: " + getData());
132135
int index = (int) segment;
133136
value = (index < ((List<?>) value).size() ? ((List<?>) value).get(index) : null);
134137
}
@@ -163,102 +166,4 @@ public <D> D toEntity(ParameterizedTypeReference<D> type) {
163166
return field("").toEntity(type);
164167
}
165168

166-
167-
/**
168-
* Default implementation of {@link ResponseField}.
169-
*/
170-
private class DefaultField implements ResponseField {
171-
172-
private final String path;
173-
174-
private final List<Object> parsedPath;
175-
176-
private final List<GraphQlResponseError> fieldErrors;
177-
178-
@Nullable
179-
private final Object value;
180-
181-
public DefaultField(
182-
String path, List<Object> parsedPath, @Nullable Object value, List<GraphQlResponseError> errors) {
183-
184-
this.path = path;
185-
this.parsedPath = parsedPath;
186-
this.value = value;
187-
this.fieldErrors = errors;
188-
}
189-
190-
@Override
191-
public String getPath() {
192-
return this.path;
193-
}
194-
195-
@Override
196-
public List<Object> getParsedPath() {
197-
return this.parsedPath;
198-
}
199-
200-
@Override
201-
public boolean hasValue() {
202-
return (this.value != null);
203-
}
204-
205-
@SuppressWarnings("unchecked")
206-
@Override
207-
public <T> T getValue() {
208-
return (T) this.value;
209-
}
210-
211-
@Override
212-
public GraphQlResponseError getError() {
213-
for (GraphQlResponseError error : this.fieldErrors) {
214-
if (error.getParsedPath().size() <= this.parsedPath.size()) {
215-
return error;
216-
}
217-
}
218-
return null;
219-
}
220-
221-
@Override
222-
public List<GraphQlResponseError> getErrors() {
223-
return this.fieldErrors;
224-
}
225-
226-
@Override
227-
public <D> D toEntity(Class<D> entityType) {
228-
return toEntity(ResolvableType.forType(entityType));
229-
}
230-
231-
@Override
232-
public <D> D toEntity(ParameterizedTypeReference<D> entityType) {
233-
return toEntity(ResolvableType.forType(entityType));
234-
}
235-
236-
@Override
237-
public <D> List<D> toEntityList(Class<D> elementType) {
238-
return toEntity(ResolvableType.forClassWithGenerics(List.class, elementType));
239-
}
240-
241-
@Override
242-
public <D> List<D> toEntityList(ParameterizedTypeReference<D> elementType) {
243-
return toEntity(ResolvableType.forClassWithGenerics(List.class, ResolvableType.forType(elementType)));
244-
}
245-
246-
@SuppressWarnings({"unchecked", "ConstantConditions"})
247-
private <T> T toEntity(ResolvableType targetType) {
248-
if (this.value == null) {
249-
throw new FieldAccessException(request, DefaultClientGraphQlResponse.this, this);
250-
}
251-
252-
DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance;
253-
MimeType mimeType = MimeTypeUtils.APPLICATION_JSON;
254-
Map<String, Object> hints = Collections.emptyMap();
255-
256-
DataBuffer buffer = ((Encoder<T>) encoder).encodeValue(
257-
(T) this.value, bufferFactory, ResolvableType.forInstance(this.value), mimeType, hints);
258-
259-
return ((Decoder<T>) decoder).decode(buffer, targetType, mimeType, hints);
260-
}
261-
262-
}
263-
264169
}

0 commit comments

Comments
 (0)