From 827b70b71d30d581322da18b62f15731db554e67 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 18 Mar 2022 14:44:02 +0000 Subject: [PATCH] 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 --- .../src/docs/asciidoc/client.adoc | 21 +- .../graphql/GraphQlResponse.java | 10 +- .../graphql/GraphQlResponseError.java | 68 ++++--- .../graphql/RequestOutput.java | 23 ++- .../graphql/client/ClientGraphQlResponse.java | 2 +- .../client/DefaultClientGraphQlResponse.java | 100 +++++++++- .../graphql/client/MapGraphQlResponse.java | 186 +++--------------- .../graphql/client/ResponseField.java | 10 +- ...=> DefaultGraphQlClientResponseTests.java} | 89 +++++---- .../graphql/client/GraphQlClientTests.java | 6 +- 10 files changed, 257 insertions(+), 258 deletions(-) rename spring-graphql/src/test/java/org/springframework/graphql/client/{MapGraphQlResponseTests.java => DefaultGraphQlClientResponseTests.java} (58%) diff --git a/spring-graphql-docs/src/docs/asciidoc/client.adoc b/spring-graphql-docs/src/docs/asciidoc/client.adoc index d361df2d..3a47aeb9 100644 --- a/spring-graphql-docs/src/docs/asciidoc/client.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/client.adoc @@ -134,8 +134,8 @@ which is a strategy for loading the document for a request by file name. == Requests Once you have a <>, you can begin to perform requests via -<> or <>, -with one merely a shortcut over the other. +<> or <> +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) }); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponse.java b/spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponse.java index 18d46d2a..12de54dc 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponse.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponse.java @@ -55,9 +55,13 @@ public interface GraphQlResponse { 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. + *

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}. + *

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 getErrors(); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponseError.java b/spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponseError.java index 06612893..e6c13b25 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponseError.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/GraphQlResponseError.java @@ -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. + *

Example paths: + *

+	 * "hero"
+	 * "hero.name"
+	 * "hero.friends"
+	 * "hero.friends[2]"
+	 * "hero.friends[2].name"
+	 * 
+ * + */ + 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. + *

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 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 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. - *

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 getPath(); - - /** - * Return a map with GraphQL Java specific error details such as the - * {@link #getErrorType()}. - */ - @Nullable Map getExtensions(); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/RequestOutput.java b/spring-graphql/src/main/java/org/springframework/graphql/RequestOutput.java index 349edb6e..22a4917e 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/RequestOutput.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/RequestOutput.java @@ -86,7 +86,7 @@ public class RequestOutput implements GraphQlResponse { } public List 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 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 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 getParsedPath() { + return (this.delegate.getPath() != null ? this.delegate.getPath() : Collections.emptyList()); } @Override public Map getExtensions() { - return this.delegate.getExtensions(); + return (this.delegate.getExtensions() != null ? this.delegate.getExtensions() : Collections.emptyMap()); } } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java b/spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java index afc9bb7d..428a8098 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/ClientGraphQlResponse.java @@ -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. *

Example paths: - *

+	 * 
 	 * "hero"
 	 * "hero.name"
 	 * "hero.friends"
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java
index acb932c0..8aa7028a 100644
--- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java
+++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultClientGraphQlResponse.java
@@ -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 dataPath = parseFieldPath(path);
-		Object value = getFieldValue(dataPath);
-		List 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 parseFieldPath(String path) {
+		if (!StringUtils.hasText(path)) {
+			return Collections.emptyList();
+		}
+
+		String invalidPathMessage = "Invalid path: '" + path + "'";
+		List 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 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 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 parsedPath;
 
-		private final List errors;
+		private final List 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 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 getErrors() {
-			return this.errors;
+			return this.fieldErrors;
 		}
 
 		@Override
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/MapGraphQlResponse.java b/spring-graphql/src/main/java/org/springframework/graphql/client/MapGraphQlResponse.java
index a9b578b0..70b17503 100644
--- a/spring-graphql/src/main/java/org/springframework/graphql/client/MapGraphQlResponse.java
+++ b/spring-graphql/src/main/java/org/springframework/graphql/client/MapGraphQlResponse.java
@@ -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 responseMap;
 
 	private final List errors;
@@ -70,7 +60,7 @@ class MapGraphQlResponse implements GraphQlResponse {
 	private static List wrapErrors(Map map) {
 		List> errors = (List>) 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 parseFieldPath(String path) {
-		if (!StringUtils.hasText(path)) {
-			return Collections.emptyList();
-		}
-
-		String invalidPathMessage = "Invalid path: '" + path + "'";
-		List 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 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 getFieldErrors(List fieldPath) {
-		if (fieldPath.isEmpty()) {
-			return Collections.emptyList();
-		}
-		List fieldErrors = Collections.emptyList();
-		for (GraphQlResponseError error : this.errors) {
-			List 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 errorMap;
 
 		private final List locations;
 
-		MapError(Map errorMap) {
+		private final String path;
+
+		Error(Map errorMap) {
 			Assert.notNull(errorMap, "'errorMap' is required");
 			this.errorMap = errorMap;
 			this.locations = initLocations(errorMap);
+			this.path = initPath(errorMap);
 		}
 
 		@SuppressWarnings("unchecked")
 		private static List initLocations(Map errorMap) {
-			List> locations = (List>) 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>) 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 errorMap) {
+			return ((List) 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 getPath() {
-			return (List) this.errorMap.get("path");
+		public List getParsedPath() {
+			return (List) this.errorMap.getOrDefault("path", Collections.emptyList());
 		}
 
 		@SuppressWarnings("unchecked")
 		@Override
-		@Nullable
 		public Map getExtensions() {
-			return (Map) this.errorMap.get("extensions");
+			return (Map) 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;
 		}
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/ResponseField.java b/spring-graphql/src/main/java/org/springframework/graphql/client/ResponseField.java
index 64f7aaa5..e2a3bb58 100644
--- a/spring-graphql/src/main/java/org/springframework/graphql/client/ResponseField.java
+++ b/spring-graphql/src/main/java/org/springframework/graphql/client/ResponseField.java
@@ -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 getParsedPath();
+
 	/**
 	 * Return the field value without any decoding.
 	 * @param  the expected value type, e.g. Map, List, or a scalar type.
diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/MapGraphQlResponseTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/DefaultGraphQlClientResponseTests.java
similarity index 58%
rename from spring-graphql/src/test/java/org/springframework/graphql/client/MapGraphQlResponseTests.java
rename to spring-graphql/src/test/java/org/springframework/graphql/client/DefaultGraphQlClientResponseTests.java
index d02b0f6d..cb4a5956 100644
--- a/spring-graphql/src/test/java/org/springframework/graphql/client/MapGraphQlResponseTests.java
+++ b/spring-graphql/src/test/java/org/springframework/graphql/client/DefaultGraphQlClientResponseTests.java
@@ -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 parsedPath = MapGraphQlResponse.parseFieldPath(path);
-		Map 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 parsedPath = MapGraphQlResponse.parseFieldPath(path);
-		Map 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 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> errorList =
-				Stream.of(error0, error1, error2, error3)
-						.map(GraphQLError::toSpecification).collect(Collectors.toList());
-
-		MapGraphQlResponse response = new MapGraphQlResponse(Collections.singletonMap("errors", errorList));
-		List errors = response.getFieldErrors(path);
+		ResponseField field = getField(path, error0, error1, error2, error3);
+		List 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 responseMap) {
+		return new DefaultClientGraphQlResponse(
+				new GraphQlRequest("{test}"), GraphQlTransport.wrapResponseMap(responseMap),
+				new Jackson2JsonEncoder(), new Jackson2JsonDecoder());
+	}
+
 }
diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java
index 25807870..47fc7e22 100644
--- a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java
+++ b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java
@@ -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) {