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) {