diff --git a/src/main/java/graphql/ExecutionResultImpl.java b/src/main/java/graphql/ExecutionResultImpl.java index 33ddd67e21..62419a63a7 100644 --- a/src/main/java/graphql/ExecutionResultImpl.java +++ b/src/main/java/graphql/ExecutionResultImpl.java @@ -40,6 +40,10 @@ public ExecutionResultImpl(ExecutionResultImpl other) { this(other.dataPresent, other.data, other.errors, other.extensions); } + public > ExecutionResultImpl(Builder builder) { + this(builder.dataPresent, builder.data, builder.errors, builder.extensions); + } + private ExecutionResultImpl(boolean dataPresent, Object data, List errors, Map extensions) { this.dataPresent = dataPresent; this.data = data; @@ -103,61 +107,61 @@ public String toString() { '}'; } - public static Builder newExecutionResult() { - return new Builder(); + public static > Builder newExecutionResult() { + return new Builder<>(); } - public static class Builder implements ExecutionResult.Builder { + public static class Builder> implements ExecutionResult.Builder { private boolean dataPresent; private Object data; private List errors = new ArrayList<>(); private Map extensions; @Override - public Builder from(ExecutionResult executionResult) { + public T from(ExecutionResult executionResult) { dataPresent = executionResult.isDataPresent(); data = executionResult.getData(); errors = new ArrayList<>(executionResult.getErrors()); extensions = executionResult.getExtensions(); - return this; + return (T) this; } @Override - public Builder data(Object data) { + public T data(Object data) { dataPresent = true; this.data = data; - return this; + return (T) this; } @Override - public Builder errors(List errors) { + public T errors(List errors) { this.errors = errors; - return this; + return (T) this; } @Override - public Builder addErrors(List errors) { + public T addErrors(List errors) { this.errors.addAll(errors); - return this; + return (T) this; } @Override - public Builder addError(GraphQLError error) { + public T addError(GraphQLError error) { this.errors.add(error); - return this; + return (T) this; } @Override - public Builder extensions(Map extensions) { + public T extensions(Map extensions) { this.extensions = extensions; - return this; + return (T) this; } @Override - public Builder addExtension(String key, Object value) { + public T addExtension(String key, Object value) { this.extensions = (this.extensions == null ? new LinkedHashMap<>() : this.extensions); this.extensions.put(key, value); - return this; + return (T) this; } @Override diff --git a/src/main/java/graphql/incremental/DeferPayload.java b/src/main/java/graphql/incremental/DeferPayload.java new file mode 100644 index 0000000000..2327493ef5 --- /dev/null +++ b/src/main/java/graphql/incremental/DeferPayload.java @@ -0,0 +1,72 @@ +package graphql.incremental; + +import graphql.ExperimentalApi; +import graphql.GraphQLError; + +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a defer payload + */ +@ExperimentalApi +public class DeferPayload extends IncrementalPayload { + private final Object data; + + private DeferPayload(Object data, List path, String label, List errors, Map extensions) { + super(path, label, errors, extensions); + this.data = data; + } + + /** + * @return the resolved data + * @param the type to cast the result to + */ + @Nullable + public T getData() { + //noinspection unchecked + return (T) this.data; + } + + /** + * @return a map of this payload that strictly follows the spec + */ + @Override + public Map toSpecification() { + Map map = new LinkedHashMap<>(super.toSpecification()); + + if (data != null) { + map.put("data", data); + } + + return map; + } + + /** + * @return a {@link DeferPayload.Builder} that can be used to create an instance of {@link DeferPayload} + */ + public static DeferPayload.Builder newDeferredItem() { + return new DeferPayload.Builder(); + } + + public static class Builder extends IncrementalPayload.Builder { + private Object data = null; + + public Builder data(Object data) { + this.data = data; + return this; + } + + public Builder from(DeferPayload deferredItem) { + super.from(deferredItem); + this.data = deferredItem.data; + return this; + } + + public DeferPayload build() { + return new DeferPayload(data, this.path, this.label, this.errors, this.extensions); + } + } +} diff --git a/src/main/java/graphql/incremental/DelayedIncrementalExecutionResult.java b/src/main/java/graphql/incremental/DelayedIncrementalExecutionResult.java new file mode 100644 index 0000000000..3ba520dab7 --- /dev/null +++ b/src/main/java/graphql/incremental/DelayedIncrementalExecutionResult.java @@ -0,0 +1,38 @@ +package graphql.incremental; + +import graphql.ExperimentalApi; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.Map; + +/** + * Represents a result that is delivered asynchronously, after the initial {@link IncrementalExecutionResult}. + *

+ * Multiple defer and/or stream payloads (represented by {@link IncrementalPayload}) can be part of the same + * {@link DelayedIncrementalExecutionResult} + */ +@ExperimentalApi +public interface DelayedIncrementalExecutionResult { + /** + * @return a list of defer and/or stream payloads. + */ + @Nullable + List getIncremental(); + + /** + * Indicates whether the stream will continue emitting {@link DelayedIncrementalExecutionResult}s after this one. + *

+ * The value returned by this method should be "true" for all but the last response in the stream. The value of this + * entry is `false` for the last response of the stream. + * + * @return "true" if there are more responses in the stream, "false" otherwise. + */ + boolean hasNext(); + + /** + * @return a map of extensions or null if there are none + */ + @Nullable + Map getExtensions(); +} diff --git a/src/main/java/graphql/incremental/DelayedIncrementalExecutionResultImpl.java b/src/main/java/graphql/incremental/DelayedIncrementalExecutionResultImpl.java new file mode 100644 index 0000000000..5434db8798 --- /dev/null +++ b/src/main/java/graphql/incremental/DelayedIncrementalExecutionResultImpl.java @@ -0,0 +1,67 @@ +package graphql.incremental; + +import graphql.ExperimentalApi; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@ExperimentalApi +public class DelayedIncrementalExecutionResultImpl implements DelayedIncrementalExecutionResult { + private final List incrementalItems; + private final boolean hasNext; + private final Map extensions; + + private DelayedIncrementalExecutionResultImpl(Builder builder) { + this.incrementalItems = builder.incrementalItems; + this.hasNext = builder.hasNext; + this.extensions = builder.extensions; + } + + @Override + public List getIncremental() { + return this.incrementalItems; + } + + @Override + public boolean hasNext() { + return this.hasNext; + } + + @Override + public Map getExtensions() { + return this.extensions; + } + + /** + * @return a {@link Builder} that can be used to create an instance of {@link DelayedIncrementalExecutionResultImpl} + */ + public static Builder newIncrementalExecutionResult() { + return new Builder(); + } + + public static class Builder { + private boolean hasNext = false; + private List incrementalItems = Collections.emptyList(); + private Map extensions; + + public Builder hasNext(boolean hasNext) { + this.hasNext = hasNext; + return this; + } + + public Builder incrementalItems(List incrementalItems) { + this.incrementalItems = incrementalItems; + return this; + } + + public Builder extensions(boolean hasNext) { + this.hasNext = hasNext; + return this; + } + + public DelayedIncrementalExecutionResultImpl build() { + return new DelayedIncrementalExecutionResultImpl(this); + } + } +} diff --git a/src/main/java/graphql/incremental/IncrementalExecutionResult.java b/src/main/java/graphql/incremental/IncrementalExecutionResult.java new file mode 100644 index 0000000000..6794f01185 --- /dev/null +++ b/src/main/java/graphql/incremental/IncrementalExecutionResult.java @@ -0,0 +1,111 @@ +package graphql.incremental; + +import graphql.ExecutionResult; +import graphql.ExperimentalApi; +import org.reactivestreams.Publisher; + +import javax.annotation.Nullable; +import java.util.List; + +/** + * A result that is part of an execution that includes incrementally delivered data (data has been deferred of streamed). + *

+ * For example, this query + *

+ * query {
+ *   person(id: "cGVvcGxlOjE=") {
+ *     ...HomeWorldFragment @defer(label: "homeWorldDefer")
+ *     name
+ *     films @stream(initialCount: 1, label: "filmsStream") {
+ *       title
+ *     }
+ *   }
+ * }
+ * fragment HomeWorldFragment on Person {
+ *   homeWorld {
+ *     name
+ *   }
+ * }
+ * 
+ * Could result on an incremental response with the following payloads (in JSON format here for simplicity). + *

+ * Response 1, the initial response does not contain any deferred or streamed results. + *

+ * {
+ *   "data": {
+ *     "person": {
+ *       "name": "Luke Skywalker",
+ *       "films": [{ "title": "A New Hope" }]
+ *     }
+ *   },
+ *   "hasNext": true
+ * }
+ * 
+ * + * Response 2, contains the defer payload and the first stream payload. + *
+ * {
+ *   "incremental": [
+ *     {
+ *       "label": "homeWorldDefer",
+ *       "path": ["person"],
+ *       "data": { "homeWorld": { "name": "Tatooine" } }
+ *     },
+ *     {
+ *       "label": "filmsStream",
+ *       "path": ["person", "films", 1],
+ *       "items": [{ "title": "The Empire Strikes Back" }]
+ *     }
+ *   ],
+ *   "hasNext": true
+ * }
+ * 
+ * + * Response 3, contains the final stream payload. Note how "hasNext" is "false", indicating this is the final response. + *
+ * {
+ *   "incremental": [
+ *     {
+ *       "label": "filmsStream",
+ *       "path": ["person", "films", 2],
+ *       "items": [{ "title": "Return of the Jedi" }]
+ *     }
+ *   ],
+ *   "hasNext": false
+ * }
+ * 
+ * + *

+ * This implementation is based on the state of Defer/Stream PR + * More specifically at the state of this + * commit + *

+ * The execution behaviour should match what we get from running Apollo Server 4.9.5 with graphql-js v17.0.0-alpha.2 + */ +@ExperimentalApi +public interface IncrementalExecutionResult extends ExecutionResult { + /** + * Indicates whether there are pending incremental data. + * @return "true" if there are incremental data, "false" otherwise. + */ + boolean hasNext(); + + /** + * Returns a list of defer and/or stream payloads that the execution engine decided (for whatever reason) to resolve at the same time as the initial payload. + *

+ * (...)this field may appear on both the initial and subsequent values. + *

+ * source + * + * @return a list of Stream and/or Defer payloads that were resolved at the same time as the initial payload. + */ + @Nullable + List getIncremental(); + + /** + * This {@link Publisher} will asynchronously emit events containing defer and/or stream payloads. + * + * @return a {@link Publisher} that clients can subscribe to receive incremental payloads. + */ + Publisher getIncrementalItemPublisher(); +} diff --git a/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java b/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java new file mode 100644 index 0000000000..5b16cdaf50 --- /dev/null +++ b/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java @@ -0,0 +1,98 @@ +package graphql.incremental; + +import graphql.ExecutionResultImpl; +import graphql.ExperimentalApi; +import org.reactivestreams.Publisher; + +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.toList; + +@ExperimentalApi +public class IncrementalExecutionResultImpl extends ExecutionResultImpl implements IncrementalExecutionResult { + private final boolean hasNext; + private final List incremental; + private final Publisher incrementalItemPublisher; + + private IncrementalExecutionResultImpl(Builder builder) { + super(builder); + this.hasNext = builder.hasNext; + this.incremental = builder.incremental; + this.incrementalItemPublisher = builder.incrementalItemPublisher; + } + + @Override + public boolean hasNext() { + return this.hasNext; + } + + @Nullable + @Override + public List getIncremental() { + return this.incremental; + } + + @Override + public Publisher getIncrementalItemPublisher() { + return incrementalItemPublisher; + } + + /** + * @return a {@link Builder} that can be used to create an instance of {@link IncrementalExecutionResultImpl} + */ + public static Builder newIncrementalExecutionResult() { + return new Builder(); + } + + @Override + public Map toSpecification() { + Map map = new LinkedHashMap<>(super.toSpecification()); + map.put("hasNext", hasNext); + + if (this.incremental != null) { + map.put("incremental", + this.incremental.stream() + .map(IncrementalPayload::toSpecification) + .collect(Collectors.toCollection(LinkedList::new)) + ); + } + + return map; + } + + public static class Builder extends ExecutionResultImpl.Builder { + private boolean hasNext = true; + public List incremental; + private Publisher incrementalItemPublisher; + + public Builder hasNext(boolean hasNext) { + this.hasNext = hasNext; + return this; + } + + public Builder incremental(List incremental) { + this.incremental = incremental; + return this; + } + + public Builder incrementalItemPublisher(Publisher incrementalItemPublisher) { + this.incrementalItemPublisher = incrementalItemPublisher; + return this; + } + + public Builder from(IncrementalExecutionResult incrementalExecutionResult) { + super.from(incrementalExecutionResult); + this.hasNext = incrementalExecutionResult.hasNext(); + return this; + } + + public IncrementalExecutionResult build() { + return new IncrementalExecutionResultImpl(this); + } + } +} diff --git a/src/main/java/graphql/incremental/IncrementalPayload.java b/src/main/java/graphql/incremental/IncrementalPayload.java new file mode 100644 index 0000000000..a3ddd55826 --- /dev/null +++ b/src/main/java/graphql/incremental/IncrementalPayload.java @@ -0,0 +1,144 @@ +package graphql.incremental; + +import graphql.ExperimentalApi; +import graphql.GraphQLError; +import graphql.execution.ResultPath; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static java.util.stream.Collectors.toList; + +/** + * Represents a payload that can be resolved after the initial response. + */ +@ExperimentalApi +public abstract class IncrementalPayload { + private final List path; + private final String label; + private final List errors; + private final transient Map extensions; + + protected IncrementalPayload(List path, String label, List errors, Map extensions) { + this.path = path; + this.errors = errors; + this.label = label; + this.extensions = extensions; + } + + /** + * @return list of field names and indices from root to the location of the corresponding `@defer` or `@stream` directive. + */ + public List getPath() { + return this.path; + } + + /** + * @return value derived from the corresponding `@defer` or `@stream` directive. + */ + @Nullable + public String getLabel() { + return label; + } + + /** + * @return a list of field errors encountered during execution. + */ + @Nullable + public List getErrors() { + return this.errors; + } + + /** + * @return a map of extensions or null if there are none + */ + @Nullable + public Map getExtensions() { + return this.extensions; + } + + protected Map toSpecification() { + Map result = new LinkedHashMap<>(); + + result.put("path", path); + + if (label != null) { + result.put("label", label); + } + + if (errors != null && !errors.isEmpty()) { + result.put("errors", errorsToSpec(errors)); + } + if (extensions != null) { + result.put("extensions", extensions); + } + return result; + } + + protected Object errorsToSpec(List errors) { + return errors.stream().map(GraphQLError::toSpecification).collect(toList()); + } + + protected static abstract class Builder> { + protected List path; + protected String label; + protected List errors = new ArrayList<>(); + protected Map extensions; + + public T from(IncrementalPayload incrementalPayload) { + this.path = incrementalPayload.getPath(); + this.label = incrementalPayload.getLabel(); + if (incrementalPayload.getErrors() != null) { + this.errors = new ArrayList<>(incrementalPayload.getErrors()); + } + this.extensions = incrementalPayload.getExtensions(); + return (T) this; + } + + public T path(ResultPath path) { + if (path != null) { + this.path = path.toList(); + } + return (T) this; + } + + public T path(List path) { + this.path = path; + return (T) this; + } + + public T label(String label) { + this.label = label; + return (T) this; + } + + public T errors(List errors) { + this.errors = errors; + return (T) this; + } + + public Builder addErrors(List errors) { + this.errors.addAll(errors); + return this; + } + + public Builder addError(GraphQLError error) { + this.errors.add(error); + return this; + } + + public Builder extensions(Map extensions) { + this.extensions = extensions; + return this; + } + + public Builder addExtension(String key, Object value) { + this.extensions = (this.extensions == null ? new LinkedHashMap<>() : this.extensions); + this.extensions.put(key, value); + return this; + } + } +} diff --git a/src/main/java/graphql/incremental/StreamPayload.java b/src/main/java/graphql/incremental/StreamPayload.java new file mode 100644 index 0000000000..e8bdfcf85c --- /dev/null +++ b/src/main/java/graphql/incremental/StreamPayload.java @@ -0,0 +1,72 @@ +package graphql.incremental; + +import graphql.ExperimentalApi; +import graphql.GraphQLError; + +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a stream payload + */ +@ExperimentalApi +public class StreamPayload extends IncrementalPayload { + private final List items; + + private StreamPayload(List items, List path, String label, List errors, Map extensions) { + super(path, label, errors, extensions); + this.items = items; + } + + /** + * @return the resolved list of items + * @param the type to cast the result to + */ + @Nullable + public List getItems() { + //noinspection unchecked + return (List) this.items; + } + + /** + * @return a map of this payload that strictly follows the spec + */ + @Override + public Map toSpecification() { + Map map = new LinkedHashMap<>(super.toSpecification()); + + if (items != null) { + map.put("items", items); + } + + return map; + } + + /** + * @return a {@link Builder} that can be used to create an instance of {@link StreamPayload} + */ + public static StreamPayload.Builder newStreamedItem() { + return new StreamPayload.Builder(); + } + + public static class Builder extends IncrementalPayload.Builder { + private List items = null; + + public Builder items(List items) { + this.items = items; + return this; + } + + public Builder from(StreamPayload streamedItem) { + super.from(streamedItem); + this.items = streamedItem.items; + return this; + } + + public StreamPayload build() { + return new StreamPayload(items, this.path, this.label, this.errors, this.extensions); + } + } +} diff --git a/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy b/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy new file mode 100644 index 0000000000..d30747fccc --- /dev/null +++ b/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy @@ -0,0 +1,59 @@ +package graphql.incremental + +import graphql.execution.ResultPath +import spock.lang.Specification + +import static graphql.incremental.DeferPayload.newDeferredItem +import static graphql.incremental.IncrementalExecutionResultImpl.newIncrementalExecutionResult +import static graphql.incremental.StreamPayload.newStreamedItem + +class IncrementalExecutionResultTest extends Specification { + + def "sanity test to check builders work"() { + when: + def defer1 = newDeferredItem() + .label("homeWorldDefer") + .path(ResultPath.parse("/person")) + .data([homeWorld: "Tatooine"]) + .build() + + def stream1 = newStreamedItem() + .label("filmsStream") + .path(ResultPath.parse("/person/films[1]")) + .items([[title: "The Empire Strikes Back"]]) + .build() + + def stream2 = newStreamedItem() + .label("filmsStream") + .path(ResultPath.parse("/person/films[2]")) + .items([[title: "Return of the Jedi"]]) + .build() + + def result = newIncrementalExecutionResult() + .data([ + person: [ + name : "Luke Skywalker", + films: [ + [title: "A New Hope"] + ] + ] + ]) + .hasNext(true) + .incremental([defer1, stream1, stream2]) + .build() + + def toSpec = result.toSpecification() + + then: + toSpec == [ + data : [person: [name: "Luke Skywalker", films: [[title: "A New Hope"]]]], + hasNext : true, + incremental: [ + [path: ["person"], label: "homeWorldDefer", data: [homeWorld: "Tatooine"]], + [path: ["person", "films", 1], label: "filmsStream", items: [[title: "The Empire Strikes Back"]]], + [path: ["person", "films", 2], label: "filmsStream", items: [[title: "Return of the Jedi"]]], + ] + ] + + } +}