Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/main/java/graphql/GraphQLContext.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -74,6 +75,23 @@ public Stream<Map.Entry<Object, Object>> 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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <a href="mailto:mario@ellebrecht.com">Mario Ellebrecht &lt;mario@ellebrecht.com&gt;</a>
*/
public class FetchedValueProvider implements ValueProvider {

@Override
public Object getValue(Object fetchedValue) {
if (fetchedValue instanceof FetchedValue) {
return ((FetchedValue) fetchedValue).getFetchedValue();
} else {
return fetchedValue;
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package graphql.execution.instrumentation.idempotency;

import graphql.execution.FetchedValue;

/**
* {@link ValueProvider} implementation that extracts an <code>id</code> property, method or field
* value using reflection.
*
* @author <a href="mailto:mario@ellebrecht.com">Mario Ellebrecht &lt;mario@ellebrecht.com&gt;</a>
*/
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;
}

}
Original file line number Diff line number Diff line change
@@ -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 <a href="mailto:mario@ellebrecht.com">Mario Ellebrecht &lt;mario@ellebrecht.com&gt;</a>
*/
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<String, Object> getData() {
final Map<String, Object> data = new HashMap<>();
data.put("key", key);
data.put("value", value);
return data;
}

}
Original file line number Diff line number Diff line change
@@ -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;

/**
* <p>
* 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.
* </p>
*
* <p>
* Any desired input field can be used as the idempotency key - the default assumes Relay-compliant
* mutations and reuses the <code>clientMutationId</code> field for this purpose. Adding this
* instrumentation to a GraphQL instance guarantees that mutations with identical
* <code>clientMutationId</code> are processed at most once.
* </p>
*
* <p>
* 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.
* </p>
*
* <p>
* 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, <tt>null</tt> may be passed
* in, meaning to use the default.
* </p>
*
* <p>
* 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.
* </p>
*
* <p>
* 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.
* </p>
*
* <p>
* 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
* <code>clientMutationId</code> field for this purpose
* (cf. https://facebook.github.io/relay/graphql/mutations.htm).
* </p>
*
* <p>
* 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.
* </p>
*
* <p>
* 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.
* </p>
*
* @author <a href="mailto:mario@ellebrecht.com">Mario Ellebrecht &lt;mario@ellebrecht.com&gt;</a>
*/
public final class IdempotencyInstrumentation extends SimpleInstrumentation {

private static final InstrumentationContext<ExecutionResult> 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<ExecutionResult> 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<ExecutionResult> 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;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package graphql.execution.instrumentation.idempotency;

/**
* <p>
* 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.
* </p>
*
* <p>
* 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.
* </p>
*
* <p>
* Implementations must be thread-safe to work properly in the context of multi-threaded access to
* the instrumentation making use of the store.
* </p>
*
* @author <a href="mailto:mario@ellebrecht.com">Mario Ellebrecht &lt;mario@ellebrecht.com&gt;</a>
*/
public interface IdempotencyStore {

Object get(Object scope, String key);

Object put(Object scope, String key, Object value);

}
Original file line number Diff line number Diff line change
@@ -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 <a href="mailto:mario@ellebrecht.com">Mario Ellebrecht &lt;mario@ellebrecht.com&gt;</a>
*/
public class InputContextScopeProvider implements ScopeProvider {

@Override
public Object getScope(ExecutionContext context) {
return context == null ? null : context.getContext();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package graphql.execution.instrumentation.idempotency;

import graphql.execution.ExecutionContext;
import graphql.execution.ExecutionStepInfo;
import graphql.schema.GraphQLFieldDefinition;

/**
* <p>
* KeyProvider is an extension interface for extracting the idempotency key value from a mutation.
* using {@link ExecutionContext}.
* </p>
*
* <p>
* The default {@link RelayKeyProvider} assumes Relay-compliant mutations and uses the String value
* of the <code>clientMutationId</code> input field for this purpose.
* Custom implementations may use specific input fields instead, or other data from the
* execution context.
* </p>
*
* <p>
* 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.
* </p>
*
* @author <a href="mailto:mario@ellebrecht.com">Mario Ellebrecht &lt;mario@ellebrecht.com&gt;</a>
*/
public interface KeyProvider {

String getKeyFromOperation(ExecutionContext context);

String getKeyFromField(ExecutionContext context, GraphQLFieldDefinition fieldDefinition,
ExecutionStepInfo typeInfo);

}
Loading