diff --git a/src/main/java/graphql/GraphQLContext.java b/src/main/java/graphql/GraphQLContext.java index 907583df95..e9b4be6521 100644 --- a/src/main/java/graphql/GraphQLContext.java +++ b/src/main/java/graphql/GraphQLContext.java @@ -1,6 +1,7 @@ package graphql; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -74,6 +75,23 @@ public Stream> stream() { return map.entrySet().stream(); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof GraphQLContext)) { + return false; + } + GraphQLContext that = (GraphQLContext) o; + return Objects.equals(map, that.map); + } + + @Override + public int hashCode() { + return Objects.hash(map); + } + public static Builder newContext() { return new Builder(); } diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/FetchedValueProvider.java b/src/main/java/graphql/execution/instrumentation/idempotency/FetchedValueProvider.java new file mode 100644 index 0000000000..c2712d9ee6 --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/FetchedValueProvider.java @@ -0,0 +1,21 @@ +package graphql.execution.instrumentation.idempotency; + +import graphql.execution.FetchedValue; + +/** + * Simple {@link ValueProvider} implementation that uses the fetched value as-is. + * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +public class FetchedValueProvider implements ValueProvider { + + @Override + public Object getValue(Object fetchedValue) { + if (fetchedValue instanceof FetchedValue) { + return ((FetchedValue) fetchedValue).getFetchedValue(); + } else { + return fetchedValue; + } + } + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/IdValueProvider.java b/src/main/java/graphql/execution/instrumentation/idempotency/IdValueProvider.java new file mode 100644 index 0000000000..010a4def84 --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/IdValueProvider.java @@ -0,0 +1,60 @@ +package graphql.execution.instrumentation.idempotency; + +import graphql.execution.FetchedValue; + +/** + * {@link ValueProvider} implementation that extracts an id property, method or field + * value using reflection. + * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +public class IdValueProvider implements ValueProvider { + + private static final String ID_PROPERTY_GETTER = "getId"; + private static final String ID_FIELD_OR_METHOD = "id"; + + @Override + public Object getValue(Object fetchedValue) { + if (fetchedValue == null) { + return null; + } + final Object value = + fetchedValue instanceof FetchedValue + ? ((FetchedValue) fetchedValue).getFetchedValue() + : fetchedValue; + if (value == null) { + return null; + } + final Class c = value.getClass(); + if (c == String.class || c == Object.class || c.isPrimitive()) { + return value.toString(); + } + Object id; + try { + id = c.getMethod(ID_PROPERTY_GETTER).invoke(value); + if (id != null) { + return id.toString(); + } + } catch (Exception e) { + // nothing to do here + } + try { + id = c.getMethod(ID_FIELD_OR_METHOD).invoke(value); + if (id != null) { + return id.toString(); + } + } catch (Exception e) { + // nothing to do here + } + try { + id = c.getField(ID_FIELD_OR_METHOD).get(value); + if (id != null) { + return id.toString(); + } + } catch (Exception e) { + // nothing to do here + } + return null; + } + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/IdempotencyException.java b/src/main/java/graphql/execution/instrumentation/idempotency/IdempotencyException.java new file mode 100644 index 0000000000..8b9f45ee21 --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/IdempotencyException.java @@ -0,0 +1,42 @@ +package graphql.execution.instrumentation.idempotency; + +import graphql.execution.AbortExecutionException; +import java.util.HashMap; +import java.util.Map; + +/** + * Exception thrown when an idempotency key is encountered more than once in the same scope. + * Besides the key it will also contain the previous mutation result value as extracted by the + * configured {@link ValueProvider}. + * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +public final class IdempotencyException extends AbortExecutionException { + + private static final long serialVersionUID = -7077119608480767116L; + + private final String key; + private final Object value; + + public IdempotencyException(String key, Object value) { + super("Mutation with idempotency key " + key + " was already processed"); + this.key = key; + this.value = value; + } + + public String getKey() { + return key; + } + + public Object getValue() { + return value; + } + + public Map getData() { + final Map data = new HashMap<>(); + data.put("key", key); + data.put("value", value); + return data; + } + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/IdempotencyInstrumentation.java b/src/main/java/graphql/execution/instrumentation/idempotency/IdempotencyInstrumentation.java new file mode 100644 index 0000000000..353e930551 --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/IdempotencyInstrumentation.java @@ -0,0 +1,149 @@ +package graphql.execution.instrumentation.idempotency; + +import graphql.ExecutionResult; +import graphql.execution.AbortExecutionException; +import graphql.execution.ExecutionContext; +import graphql.execution.instrumentation.InstrumentationContext; +import graphql.execution.instrumentation.SimpleInstrumentation; +import graphql.execution.instrumentation.SimpleInstrumentationContext; +import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldCompleteParameters; +import graphql.language.OperationDefinition.Operation; +import java.util.Arrays; + +/** + *

+ * IdempotencyInstrumentation is an {@link graphql.execution.instrumentation.Instrumentation} + * implementing an idempotency key mechanism for GraphQL mutations (cf. + * https://www.enterpriseintegrationpatterns.com/patterns/conversation/RequestResponseRetry.html). + * This is useful in situations where the client may be unable to process the GraphQL server's + * response in a timely fashion, e.g. due to network instability, connectivity or power loss. When + * the client subsequently retries the mutation, IdempotencyInstrumentation will recognize that the + * mutation was already processed based on the contained idempotency key, and abort execution with a + * helpful message. + *

+ * + *

+ * Any desired input field can be used as the idempotency key - the default assumes Relay-compliant + * mutations and reuses the clientMutationId field for this purpose. Adding this + * instrumentation to a GraphQL instance guarantees that mutations with identical + * clientMutationId are processed at most once. + *

+ * + *

+ * If a client executes a mutation with a previously used idempotency key, it results in an {@link + * IdempotencyException} containing both the idempotency key and a value from the previous mutation + * result. + *

+ * + *

+ * The constructors take implementations for four interface types, described in further detail + * below, that form a service provider interface enabling extension of this instrumentation to fit + * any use case required by your domain. For any constructor arguments, null may be passed + * in, meaning to use the default. + *

+ * + *

+ * Keys are stored along with the corresponding previous mutation result value in an {@link + * IdempotencyStore} which may optionally implement policy-based eviction of keys. The default + * {@link MemoryIdempotencyStore} uses an unbounded Map on the heap. + *

+ * + *

+ * State is maintained in a scoped manner to avoid delivering previous mutation results to a + * different scope (e.g. user) than the one that initiated it. {@link ScopeProvider} is used to + * extract the desired scope from the {@link graphql.execution.ExecutionContext}. The default {@link + * InputContextScopeProvider} uses {@link graphql.execution.ExecutionContext#getContext()} for this + * purpose. + *

+ * + *

+ * Idempotency keys for mutations are extracted from the {@link graphql.execution.ExecutionContext} + * using {@link KeyProvider}, first at the beginning of the execution for checking if the key was + * previously encountered, and a second time after field completion for storing the result value. + * The default {@link RelayKeyProvider} assumes Relay-compliant mutations and reuses the + * clientMutationId field for this purpose + * (cf. https://facebook.github.io/relay/graphql/mutations.htm). + *

+ * + *

+ * Previous mutation results are stored either as-is or using a {@link ValueProvider} that extracts + * the value to be stored from the result. The default uses the result object as-is. + *

+ * + *

+ * This instrumentation is thread-safe and stateful, with all runtime state encapsulated in {@link + * IdempotencyStore}. This means you can safely re-use instances of this class across GraphQL + * instances that should share the same idempotency key scope. + *

+ * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +public final class IdempotencyInstrumentation extends SimpleInstrumentation { + + private static final InstrumentationContext EMPTY_CONTEXT = new SimpleInstrumentationContext<>(); + + private final IdempotencyStore store; + private final ScopeProvider scopeProvider; + private final KeyProvider keyProvider; + private final ValueProvider valueProvider; + + public IdempotencyInstrumentation() { + this(null, null, null, null); + } + + public IdempotencyInstrumentation(IdempotencyStore store) { + this(store, null, null, null); + } + + public IdempotencyInstrumentation(IdempotencyStore store, ScopeProvider scopeProvider) { + this(store, scopeProvider, null, null); + } + + public IdempotencyInstrumentation(IdempotencyStore store, ScopeProvider scopeProvider, + KeyProvider keyProvider) { + this(store, scopeProvider, keyProvider, null); + } + + public IdempotencyInstrumentation(IdempotencyStore store, ScopeProvider scopeProvider, + KeyProvider keyProvider, ValueProvider valueProvider) { + this.store = store == null ? new MemoryIdempotencyStore() : store; + this.scopeProvider = scopeProvider == null ? new InputContextScopeProvider() : scopeProvider; + this.keyProvider = keyProvider == null ? new RelayKeyProvider() : keyProvider; + this.valueProvider = valueProvider == null ? new FetchedValueProvider() : valueProvider; + } + + @Override + public InstrumentationContext beginExecuteOperation( + InstrumentationExecuteOperationParameters parameters) { + final ExecutionContext context = parameters.getExecutionContext(); + if (context.getOperationDefinition().getOperation() == Operation.MUTATION) { + final String key = keyProvider.getKeyFromOperation(context); + if (key != null && !key.isEmpty()) { + final Object value = store.get(scopeProvider.getScope(context), key); + if (value != null) { + throw new AbortExecutionException(Arrays.asList(new IdempotencyException(key, value))); + } + } + } + return EMPTY_CONTEXT; + } + + @Override + public InstrumentationContext beginFieldComplete( + InstrumentationFieldCompleteParameters parameters) { + final ExecutionContext context = parameters.getExecutionContext(); + if (context.getOperationDefinition().getOperation() == Operation.MUTATION) { + final String key = keyProvider.getKeyFromField( + context, parameters.getField(), parameters.getTypeInfo()); + if (key != null && !key.isEmpty()) { + final Object value = valueProvider.getValue(parameters.getFetchedValue()); + if (value != null) { + store.put(scopeProvider.getScope(context), key, value); + } + } + } + return EMPTY_CONTEXT; + } + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/IdempotencyStore.java b/src/main/java/graphql/execution/instrumentation/idempotency/IdempotencyStore.java new file mode 100644 index 0000000000..acb0a8eb7d --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/IdempotencyStore.java @@ -0,0 +1,30 @@ +package graphql.execution.instrumentation.idempotency; + +/** + *

+ * IdempotencyStore defines the service provider interface (SPI) for classes storing scoped (e.g. + * user-dependent) idempotency keys and the value a previous mutation resulted in. + *

+ * + *

+ * The default {@link MemoryIdempotencyStore} simply uses a Map on the heap. + * Custom implementations may use a filesystem, database, key-value store, distributed data + * structures of a cluster manager, or whatever is appropriate for the specific use case. + * Implementations may also choose to evict entries based on expiration (e.g. after 31 days) to + * support domain logic and to avoid storage overflow. + *

+ * + *

+ * Implementations must be thread-safe to work properly in the context of multi-threaded access to + * the instrumentation making use of the store. + *

+ * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +public interface IdempotencyStore { + + Object get(Object scope, String key); + + Object put(Object scope, String key, Object value); + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/InputContextScopeProvider.java b/src/main/java/graphql/execution/instrumentation/idempotency/InputContextScopeProvider.java new file mode 100644 index 0000000000..82f96bfcff --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/InputContextScopeProvider.java @@ -0,0 +1,18 @@ +package graphql.execution.instrumentation.idempotency; + +import graphql.execution.ExecutionContext; + +/** + * Simple {@link ScopeProvider} implementation that uses the embedded context object from {@link + * ExecutionContext#getContext()}. + * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +public class InputContextScopeProvider implements ScopeProvider { + + @Override + public Object getScope(ExecutionContext context) { + return context == null ? null : context.getContext(); + } + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/KeyProvider.java b/src/main/java/graphql/execution/instrumentation/idempotency/KeyProvider.java new file mode 100644 index 0000000000..02aa0d91fc --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/KeyProvider.java @@ -0,0 +1,35 @@ +package graphql.execution.instrumentation.idempotency; + +import graphql.execution.ExecutionContext; +import graphql.execution.ExecutionStepInfo; +import graphql.schema.GraphQLFieldDefinition; + +/** + *

+ * KeyProvider is an extension interface for extracting the idempotency key value from a mutation. + * using {@link ExecutionContext}. + *

+ * + *

+ * The default {@link RelayKeyProvider} assumes Relay-compliant mutations and uses the String value + * of the clientMutationId input field for this purpose. + * Custom implementations may use specific input fields instead, or other data from the + * execution context. + *

+ * + *

+ * Implementations must be thread-safe. They may return null keys, which leads to {@link + * IdempotencyInstrumentation} ignoring the key, deactivating idempotency for this specific mutation + * execution. + *

+ * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +public interface KeyProvider { + + String getKeyFromOperation(ExecutionContext context); + + String getKeyFromField(ExecutionContext context, GraphQLFieldDefinition fieldDefinition, + ExecutionStepInfo typeInfo); + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/MemoryIdempotencyStore.java b/src/main/java/graphql/execution/instrumentation/idempotency/MemoryIdempotencyStore.java new file mode 100644 index 0000000000..1675dbd130 --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/MemoryIdempotencyStore.java @@ -0,0 +1,40 @@ +package graphql.execution.instrumentation.idempotency; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Simple {@link IdempotencyStore} implementation using a Map on the heap. + * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +public final class MemoryIdempotencyStore implements IdempotencyStore { + + private static final int MAP_CAPACITY_ROOT = 190; + private static final int MAP_CAPACITY_LEAF = 16; + + private final Map> map = new ConcurrentHashMap<>(MAP_CAPACITY_ROOT); + + @Override + public Object get(Object scope, String key) { + return getValue(getMap(scope), key); + } + + @Override + public Object put(Object scope, String key, Object value) { + return createMap(scope).put(key, value); + } + + private Map createMap(Object scope) { + return map.computeIfAbsent(scope, e -> new ConcurrentHashMap<>(MAP_CAPACITY_LEAF)); + } + + private Map getMap(Object scope) { + return map.getOrDefault(scope, null); + } + + private static Object getValue(Map map, String key) { + return map == null ? null : map.get(key); + } + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/RelayKeyProvider.java b/src/main/java/graphql/execution/instrumentation/idempotency/RelayKeyProvider.java new file mode 100644 index 0000000000..d5977990a9 --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/RelayKeyProvider.java @@ -0,0 +1,113 @@ +package graphql.execution.instrumentation.idempotency; + +import graphql.execution.ExecutionContext; +import graphql.execution.ExecutionStepInfo; +import graphql.execution.MergedField; +import graphql.language.Argument; +import graphql.language.Field; +import graphql.language.Node; +import graphql.language.NullValue; +import graphql.language.ObjectField; +import graphql.language.ObjectValue; +import graphql.language.OperationDefinition; +import graphql.language.Selection; +import graphql.language.StringValue; +import graphql.language.Value; +import graphql.language.VariableReference; +import graphql.schema.GraphQLFieldDefinition; +import java.util.List; +import java.util.Map; + +/** + * {@link KeyProvider} implementation that assumes Relay-compliant mutations and uses the value of + * the clientMutationId input field as the idempotency key (cf. + * https://facebook.github.io/relay/graphql/mutations.htm). + * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +public class RelayKeyProvider implements KeyProvider { + + private static final String CLIENT_MUTATION_ID = "clientMutationId"; + + @Override + public String getKeyFromOperation(ExecutionContext context) { + final OperationDefinition op = context.getOperationDefinition(); + if (op == null) { + return null; + } + final List selections = op.getSelectionSet().getSelections(); + if (selections == null || selections.isEmpty() || !(selections.get(0) instanceof Field)) { + return null; + } + final Field selection = (Field) selections.get(0); + final List arguments = selection.getArguments(); + if (arguments == null || arguments.isEmpty()) { + return null; + } + final Value argValue = selection.getArguments().get(0).getValue(); + if (!(argValue instanceof ObjectValue)) { + return null; + } + final List fields = ((ObjectValue) argValue).getObjectFields(); + if (fields == null || fields.isEmpty()) { + return null; + } + for (ObjectField field : fields) { + final String clientMutationId = getClientMutationId(field, context); + if (clientMutationId != null) { + return clientMutationId; + } + } + return null; + } + + @Override + public String getKeyFromField(ExecutionContext context, GraphQLFieldDefinition fieldDefinition, + ExecutionStepInfo typeInfo) { + if (typeInfo == null) { + return null; + } + final MergedField field = typeInfo.getField(); + if (field == null) { + return null; + } + final List arguments = field.getArguments(); + if (arguments == null || arguments.isEmpty()) { + return null; + } + final Value value = arguments.get(0).getValue(); + if (value == null || value == NullValue.Null) { + return null; + } + final List children = value.getChildren(); + for (Node child : children) { + final String clientMutationId = getClientMutationId(child, context); + if (clientMutationId != null) { + return clientMutationId; + } + } + return null; + } + + private static String getClientMutationId(Node node, ExecutionContext context) { + if (!(node instanceof ObjectField)) { + return null; + } + final ObjectField field = (ObjectField) node; + final Value argValue = field.getValue(); + if (CLIENT_MUTATION_ID.equals(field.getName()) + && argValue != null + && argValue != NullValue.Null) { + if (argValue instanceof StringValue) { + return ((StringValue) argValue).getValue(); + } else if (argValue instanceof VariableReference) { + final Map variables = context.getVariables(); + final Object value = + variables == null ? null : variables.get(((VariableReference) argValue).getName()); + return value == null ? null : value.toString(); + } + } + return null; + } + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/ScopeProvider.java b/src/main/java/graphql/execution/instrumentation/idempotency/ScopeProvider.java new file mode 100644 index 0000000000..fe8d92b64e --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/ScopeProvider.java @@ -0,0 +1,31 @@ +package graphql.execution.instrumentation.idempotency; + +import graphql.execution.ExecutionContext; + +/** + *

+ * ScopeProvider is an extension interface for extracting the scope (e.g. user) from a mutation + * execution using {@link ExecutionContext}. + *

+ * + *

+ * The default {@link InputContextScopeProvider} simply uses the return value of + * {@link ExecutionContext#getContext()} for this purpose. + * Custom implementations may choose to make use of specific context objects, access + * authentication context holders, web server requests, or whatever is appropriate for the + * specific use case. + *

+ * + *

+ * Implementations must be thread-safe. They may return null scopes, making this specific mutation + * belong to a generic default scope shared with all other unscoped mutations. + *

+ * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +@FunctionalInterface +public interface ScopeProvider { + + Object getScope(ExecutionContext context); + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/ValueProvider.java b/src/main/java/graphql/execution/instrumentation/idempotency/ValueProvider.java new file mode 100644 index 0000000000..908bd72707 --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/ValueProvider.java @@ -0,0 +1,28 @@ +package graphql.execution.instrumentation.idempotency; + +/** + *

+ * ValueProvider is an extension interface mapping mutation result objects to values stored in the + * {@link IdempotencyStore}. + *

+ * + *

+ * The default {@link FetchedValueProvider} simply unpacks the fetched value object as-is. + * Custom implementations may choose to map to a bean property, field value, or whatever is + * appropriate for the specific use case. See {@link IdValueProvider} for another example. + *

+ * + *

+ * Implementations must be thread-safe. They may return null values, which leads to {@link + * IdempotencyStore} not storing the mutation result, deactivating idempotency for this specific + * mutation execution. + *

+ * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +@FunctionalInterface +public interface ValueProvider { + + Object getValue(Object value); + +} diff --git a/src/main/java/graphql/execution/instrumentation/idempotency/package-info.java b/src/main/java/graphql/execution/instrumentation/idempotency/package-info.java new file mode 100644 index 0000000000..9faa8b29dd --- /dev/null +++ b/src/main/java/graphql/execution/instrumentation/idempotency/package-info.java @@ -0,0 +1,6 @@ +/** + * Instrumentation supporting idempotency keys for mutations. + * + * @author Mario Ellebrecht <mario@ellebrecht.com> + */ +package graphql.execution.instrumentation.idempotency; diff --git a/src/test/groovy/graphql/execution/instrumentation/idempotency/IdempotencyTest.groovy b/src/test/groovy/graphql/execution/instrumentation/idempotency/IdempotencyTest.groovy new file mode 100644 index 0000000000..785a2f422d --- /dev/null +++ b/src/test/groovy/graphql/execution/instrumentation/idempotency/IdempotencyTest.groovy @@ -0,0 +1,183 @@ +package graphql.execution.instrumentation.idempotency + + +import graphql.ExecutionInput +import graphql.ExecutionResult +import graphql.GraphQL +import graphql.TestUtil +import graphql.execution.AbortExecutionException +import graphql.execution.AsyncExecutionStrategy +import graphql.execution.Execution +import graphql.execution.ExecutionId +import graphql.execution.instrumentation.SimpleInstrumentation +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLInputObjectType +import graphql.schema.GraphQLObjectType +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Stepwise + +import java.util.concurrent.CompletableFuture + +import static graphql.Scalars.GraphQLInt +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLInputObjectField.newInputObjectField +import static graphql.schema.GraphQLSchema.newSchema + +@Stepwise +class IdempotencyTest extends Specification { + + @Shared variableMutation = ''' + mutation MutationTest($key: String, $id: String) { + mutate(input: {clientMutationId: $key, id: $id}) { + clientMutationId + result + } + } + ''' + + @Shared inputType = GraphQLInputObjectType.newInputObject() + .name("Input") + .field(newInputObjectField() + .name("clientMutationId") + .type(GraphQLString)) + .field(newInputObjectField() + .name("id") + .type(GraphQLString)) + .build() + + @Shared resultType = GraphQLObjectType.newObject() + .name("Result") + .field(newFieldDefinition() + .name("clientMutationId") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("result") + .type(GraphQLInt)) + .build() + + @Shared mutationType = GraphQLObjectType.newObject() + .name("Mutation") + .field(newFieldDefinition() + .name("mutate") + .type(resultType) + .argument(newArgument() + .name("input") + .type(inputType)) + .dataFetcher(new ResultDataFetcher())) + .build() + + @Shared schema = newSchema().query(GraphQLObjectType.newObject().name("Query").build()).mutation(mutationType).build() + @Shared instrumentation = new IdempotencyInstrumentation() + @Shared graphql = GraphQL.newGraphQL(schema).instrumentation(instrumentation).build() + + def "test repeated relay-compliant variable mutation with identical clientMutationId fails"() { + given: + def variables = [ + key: "cc4d57b2-5cd8-43ac-9cb1-c014772101ed", + id : "265154b0-71ef-455b-b1e6-757b70244006", + ] + + when: + setupExecution(variableMutation, variables, 2) + + then: + def abortExecutionException = thrown(AbortExecutionException) + def errors = abortExecutionException.getUnderlyingErrors() + errors.size() == 1 + errors[0].getMessage() == "Mutation with idempotency key " + variables['key'] + " was already processed" + errors[0].getKey() == variables['key'] + errors[0].getValue().clientMutationId == variables['key'] + } + + def "test repeated relay-compliant non-variable mutation with identical clientMutationId fails"() { + given: + def nonVariableIdempotencyKey = "d90d7174-2e25-4cd6-90cb-c259dcef0b24" + def nonVariableMutation = """ + mutation { + mutate(input: {clientMutationId: "$nonVariableIdempotencyKey", id: "6e0ab600-9fcd-4ee2-9ce2-d591a0dddf19"}) { + clientMutationId + result + } + } + """ + + when: + setupExecution(nonVariableMutation, null, 2) + + then: + def abortExecutionException = thrown(AbortExecutionException) + def errors = abortExecutionException.getUnderlyingErrors() + errors.size() == 1 + errors[0].getMessage() == "Mutation with idempotency key " + nonVariableIdempotencyKey + " was already processed" + errors[0].getKey() == nonVariableIdempotencyKey + errors[0].getValue().clientMutationId == nonVariableIdempotencyKey + } + + def "test relay-compliant mutation end-to-end succeeds the first time"() { + given: + def variables = [ + key: "0e52b8e0-34bf-4a56-9bb4-768f460a0239", + id : "39a9b524-3494-439a-b7df-4d61cde2e942", + ] + + when: + def executionInput = ExecutionInput.newExecutionInput().query(variableMutation).variables(variables).context("user1").build() + def result = graphql.execute(executionInput) + + then: + result.getErrors().size() == 0 + result.getData()["mutate"]["clientMutationId"] == variables["key"] + result.getData()["mutate"]["result"] == 42 + } + + def "test relay-compliant mutation end-to-end fails the second time with identical idempotency key"() { + given: + def variables = [ + key: "0e52b8e0-34bf-4a56-9bb4-768f460a0239", + id : "5abedc0a-a9e6-4829-96fc-8073d30106b8", + ] + + when: + def executionInput = ExecutionInput.newExecutionInput().query(variableMutation).variables(variables).context("user1").build() + def result = graphql.execute(executionInput) + + then: + result.errors.size() == 1 + result.errors[0].getMessage() == "Mutation with idempotency key " + variables['key'] + " was already processed" + } + + private CompletableFuture setupExecution(String query, Map variables, int times) { + def document = TestUtil.parseQuery(query) + def strategy = new AsyncExecutionStrategy() + def instrumentation = new IdempotencyInstrumentation() + def execution = new Execution(strategy, strategy, strategy, instrumentation) + def executionInput = ExecutionInput.newExecutionInput().query(query).variables(variables).build() + for (def i = times; i > 0; i--) + execution.execute(document, schema, ExecutionId.generate(), executionInput, SimpleInstrumentation.INSTANCE.createState()) + } + + private static class Result { + + String clientMutationId + Integer result + + Result(String clientMutationId, Integer result) { + this.clientMutationId = clientMutationId + this.result = result + } + + } + + private static class ResultDataFetcher implements DataFetcher { + + Result get(DataFetchingEnvironment env) throws Exception { + return new Result(env.getArgument("input")["clientMutationId"], 42) + } + + } + +}