diff --git a/README.md b/README.md index a6c4bd8e..56ec0eb4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,57 @@ all records have been fetched, so e.g. will iterate through *all* tickets. Most likely you will want to implement your own cut-off process to stop iterating when you have got enough data. +Idempotency +----------- + +The Zendesk API supports [idempotency keys](https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency) +to safely retry operations without creating duplicate resources. This client supports idempotency +for ticket creation operations. + +**Note:** Currently, only `createTicket()` and `createTicketAsync()` fully support idempotency. +Other create operations (comments, users, etc.) do not yet support this feature, and +`queueCreateTicketAsync` might not work as expected when used with idempotency keys. + +### Usage Example + +```java +class FooIssueService { + private final Zendesk zendesk; + private final IssueRepository issueRepository; + private final Logger logger = LoggerFactory.getLogger(FooIssueService.class); + + // ... + + public void postIssueUpdate(FooIssue issue, String update) { + // Note: in production code, we should probably also check for an existing ticket before + // trying to create a new one, in addition to the fallback. + Ticket ticket = new Ticket(issue.getRequesterId(), issue.getTitle(), new Comment(update)); + ticket.setIdempotencyKey(generateIdempotencyKey(issue)); + + try { + ticket = zendesk.createTicket(ticket); + if (Boolean.FALSE.equals(ticket.getIsIdempotencyHit())) { + issueRepository.saveTicketId(issue.getId(), ticket.getId()); + logger.info("Created new ticket (id = {})", ticket.getId()); + } + } catch (ZendeskResponseIdempotencyConflictException e) { + // Already created by a concurrent update, so just add a comment to the existing ticket + long existingTicketId = issueRepository.getTicketId(issue.getId()); + Comment comment = zendesk.createComment(existingTicketId, new Comment(update)); + logger.info( + "Added comment (id = {}) to existing ticket (id = {})", + comment.getId(), + existingTicketId); + } + } + + private String generateIdempotencyKey(FooIssue issue) { + // Must map 1-to-1 with the issue, so that retries for the same issue use the same key + return String.format("issue-%s", issue.getId()); + } +} +``` + Community ------------- diff --git a/pom.xml b/pom.xml index f0039636..26759f53 100644 --- a/pom.xml +++ b/pom.xml @@ -193,6 +193,12 @@ 4.3.0 test + + org.mockito + mockito-core + 5.15.2 + test + diff --git a/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java new file mode 100644 index 00000000..ed4a1f49 --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java @@ -0,0 +1,117 @@ +package org.zendesk.client.v2; + +import java.util.Optional; +import org.asynchttpclient.AsyncCompletionHandler; +import org.asynchttpclient.Request; +import org.asynchttpclient.Response; +import org.zendesk.client.v2.model.IdempotencyState; +import org.zendesk.client.v2.model.IdempotencyState.Status; +import org.zendesk.client.v2.model.IdempotentEntity; + +/** + * Utility class for handling Zendesk API idempotency keys. + * + *

Provides methods to add idempotency headers to requests and process idempotency-related + * response headers. Supports the Zendesk API's idempotency feature which allows safe retries of + * create operations without creating duplicate resources. + * + * @see + * Zendesk API Idempotency + * @since 1.5.0 + */ +public class IdempotencyUtil { + + static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; + static final String IDEMPOTENCY_LOOKUP_HEADER = "x-idempotency-lookup"; + static final String IDEMPOTENCY_LOOKUP_HIT = "hit"; + static final String IDEMPOTENCY_LOOKUP_MISS = "miss"; + static final String IDEMPOTENCY_ERROR_NAME = "IdempotentRequestError"; + + /** + * Adds an idempotency key header to the request if the state is present and pending. + * + * @param request the HTTP request to modify + * @param state the idempotency state, or null if idempotency is not being used + * @return a new request with the idempotency key header added, or the original request if state + * is null + * @throws IllegalArgumentException if the state is not in PENDING status + */ + public static Request addIdempotencyState(Request request, IdempotencyState state) { + if (state == null) { + return request; + } + + if (state.getStatus() != Status.PENDING) { + throw new IllegalArgumentException("Idempotency state must be PENDING to add to a request"); + } + + // https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency + return request.toBuilder() + .setHeader(IDEMPOTENCY_KEY_HEADER, state.getIdempotencyKey()) + .build(); + } + + /** + * Wraps an async completion handler to process idempotency response headers. + * + *

The wrapped handler will automatically update the entity's idempotency fields based on the + * response headers returned by the Zendesk API. If the {@code x-idempotency-lookup} header + * indicates a "hit", the entity will be marked as previously created. If "miss", it will be + * marked as newly created. + * + * @param the entity type that implements IdempotentEntity + * @param handler the original async completion handler + * @param idempotencyState the idempotency state, or null if idempotency is not being used + * @return a wrapped handler that processes idempotency headers, or the original handler if state + * is null + */ + public static AsyncCompletionHandler wrapHandler( + AsyncCompletionHandler handler, + IdempotencyState idempotencyState) { + if (idempotencyState == null) { + return handler; + } + + return new AsyncCompletionHandler<>() { + @Override + public T onCompleted(Response response) throws Exception { + T entity = handler.onCompleted(response); + transitionIdempotencyState(idempotencyState, response) + .ifPresent(newState -> newState.apply(entity)); + return entity; + } + + @Override + public void onThrowable(Throwable t) { + handler.onThrowable(t); + } + }; + } + + private static Optional transitionIdempotencyState( + IdempotencyState state, + Response response) { + if (state == null) { + return Optional.empty(); + } + + // https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency + String idempotencyLookup = response.getHeader(IDEMPOTENCY_LOOKUP_HEADER); + if (idempotencyLookup == null) { + return Optional.empty(); + } + + switch (idempotencyLookup) { + case IDEMPOTENCY_LOOKUP_HIT: + return Optional.of(state.toPreviouslyCreated()); + case IDEMPOTENCY_LOOKUP_MISS: + return Optional.of(state.toCreated()); + default: + return Optional.empty(); + } + } + + private IdempotencyUtil() { + throw new UnsupportedOperationException("Utility class"); + } +} diff --git a/src/main/java/org/zendesk/client/v2/Zendesk.java b/src/main/java/org/zendesk/client/v2/Zendesk.java index 968bed1e..c4e82feb 100644 --- a/src/main/java/org/zendesk/client/v2/Zendesk.java +++ b/src/main/java/org/zendesk/client/v2/Zendesk.java @@ -56,6 +56,8 @@ import org.zendesk.client.v2.model.Forum; import org.zendesk.client.v2.model.Group; import org.zendesk.client.v2.model.GroupMembership; +import org.zendesk.client.v2.model.IdempotencyState; +import org.zendesk.client.v2.model.IdempotentEntity; import org.zendesk.client.v2.model.Identity; import org.zendesk.client.v2.model.JiraLink; import org.zendesk.client.v2.model.JobStatus; @@ -432,9 +434,15 @@ public ListenableFuture queueCreateTicketAsync(Ticket ticket) { } public ListenableFuture createTicketAsync(Ticket ticket) { + IdempotencyState idempotencyState = IdempotencyState.of(ticket).orElse(null); return submit( - req("POST", cnst("/tickets.json"), JSON, json(Collections.singletonMap("ticket", ticket))), - handle(Ticket.class, "ticket")); + req( + "POST", + cnst("/tickets.json"), + JSON, + json(Collections.singletonMap("ticket", ticket))), + handle(Ticket.class, "ticket"), + idempotencyState); } public Ticket createTicket(Ticket ticket) { @@ -3474,8 +3482,7 @@ private byte[] json(Object object) { } } - private ListenableFuture submit( - Request request, ZendeskAsyncCompletionHandler handler) { + private ListenableFuture submit(Request request, AsyncCompletionHandler handler) { if (logger.isDebugEnabled()) { if (request.getStringData() != null) { logger.debug( @@ -3494,6 +3501,15 @@ private ListenableFuture submit( return client.executeRequest(request, handler); } + private ListenableFuture submit( + Request request, + AsyncCompletionHandler handler, + IdempotencyState idempotencyState) { + return submit( + IdempotencyUtil.addIdempotencyState(request, idempotencyState), + IdempotencyUtil.wrapHandler(handler, idempotencyState)); + } + private abstract static class ZendeskAsyncCompletionHandler extends AsyncCompletionHandler { @Override public void onThrowable(Throwable t) { @@ -3592,12 +3608,20 @@ public T onCompleted(Response response) throws Exception { } return mapper.convertValue( mapper.readTree(response.getResponseBodyAsStream()).get(name), clazz); - } else if (isRateLimitResponse(response)) { + } + + if (isRateLimitResponse(response)) { throw new ZendeskResponseRateLimitException(response); } + + if (isIdempotencyConflict(response)) { + throw new ZendeskResponseIdempotencyConflictException(response); + } + if (response.getStatusCode() == 404) { return null; } + throw new ZendeskResponseException(response); } } @@ -3925,6 +3949,21 @@ private boolean isRateLimitResponse(Response response) { return response.getStatusCode() == 429; } + private boolean isIdempotencyConflict(Response response) throws IOException { + if (response.getStatusCode() != 400) { + return false; + } + + try { + Map body = mapper.readValue(response.getResponseBody(), Map.class); + return IdempotencyUtil.IDEMPOTENCY_ERROR_NAME.equals(body.get("error")); + } catch (JsonProcessingException e) { + ZendeskResponseException exception = new ZendeskResponseException(response); + exception.addSuppressed(e); + throw exception; + } + } + ////////////////////////////////////////////////////////////////////// // Static helper methods ////////////////////////////////////////////////////////////////////// @@ -3935,14 +3974,18 @@ private static T complete(ListenableFuture future) { } catch (InterruptedException e) { throw new ZendeskException(e.getMessage(), e); } catch (ExecutionException e) { + if (e.getCause() instanceof ZendeskResponseRateLimitException) { + throw new ZendeskResponseRateLimitException( + (ZendeskResponseRateLimitException) e.getCause()); + } + if (e.getCause() instanceof ZendeskResponseIdempotencyConflictException) { + throw new ZendeskResponseIdempotencyConflictException( + (ZendeskResponseIdempotencyConflictException) e.getCause()); + } + if (e.getCause() instanceof ZendeskResponseException) { + throw new ZendeskResponseException((ZendeskResponseException) e.getCause()); + } if (e.getCause() instanceof ZendeskException) { - if (e.getCause() instanceof ZendeskResponseRateLimitException) { - throw new ZendeskResponseRateLimitException( - (ZendeskResponseRateLimitException) e.getCause()); - } - if (e.getCause() instanceof ZendeskResponseException) { - throw new ZendeskResponseException((ZendeskResponseException) e.getCause()); - } throw new ZendeskException(e.getCause()); } throw new ZendeskException(e.getMessage(), e); diff --git a/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java new file mode 100644 index 00000000..978487f9 --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java @@ -0,0 +1,38 @@ +package org.zendesk.client.v2; + +import java.io.IOException; +import org.asynchttpclient.Response; + +/** + * Exception thrown when the Zendesk API returns an idempotency conflict error. + * + *

This exception is thrown when a request is retried with the same idempotency key but + * different request parameters. The API returns a 400 status code with + * {@code error: "IdempotentRequestError"} to indicate that the request parameters don't match the + * original request associated with the idempotency key. + * + *

To resolve this error, either use a new idempotency key or ensure the request parameters + * match the original request. + * + * @see + * Zendesk API Idempotency + * @since 1.5.0 + */ +public class ZendeskResponseIdempotencyConflictException extends ZendeskResponseException { + + private static final long serialVersionUID = 1L; + + public ZendeskResponseIdempotencyConflictException(Response res) throws IOException { + super(res); + } + + public ZendeskResponseIdempotencyConflictException( + int statusCode, String statusText, String body) { + super(statusCode, statusText, body); + } + + public ZendeskResponseIdempotencyConflictException( + ZendeskResponseIdempotencyConflictException cause) { + super(cause); + } +} diff --git a/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java b/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java new file mode 100644 index 00000000..ba651921 --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java @@ -0,0 +1,140 @@ +package org.zendesk.client.v2.model; + +import java.util.Objects; +import java.util.Optional; + +/** + * Represents the state of an idempotent operation in the Zendesk API. + * + *

This immutable class tracks the lifecycle of an idempotent request: + * + *

+ * + * @since 1.5.0 + */ +public class IdempotencyState { + + /** + * The status of an idempotent operation. + */ + public enum Status { + /** Initial state, ready to be sent with a request. */ + PENDING, + /** The resource was newly created (first request with this idempotency key). */ + CREATED, + /** The resource was previously created (duplicate request with this idempotency key). */ + PREVIOUSLY_CREATED + } + + private final String idempotencyKey; + private final Status status; + + /** + * Creates an IdempotencyState from an entity if it has an idempotency key. + * + * @param entity the entity to extract the idempotency key from + * @return an Optional containing a PENDING IdempotencyState if the entity has an idempotency + * key, or an empty Optional if the key is null + */ + public static Optional of(IdempotentEntity entity) { + return Optional.ofNullable(entity.getIdempotencyKey()) + .map(key -> new IdempotencyState(key, Status.PENDING)); + } + + /** + * Applies this state to the given entity, setting its idempotency fields. + * + *

This method updates the entity's idempotency key and hit status based on the current state. + * The state must not be PENDING when calling this method. + * + * @param entity the entity to update, or null to skip the operation + * @throws IllegalStateException if this state is PENDING + * @throws IllegalArgumentException if the entity's idempotency key doesn't match this state's + * key + */ + public void apply(IdempotentEntity entity) { + if (entity == null) { + return; + } + + String entityKey = entity.getIdempotencyKey(); + if (entityKey != null && !entityKey.equals(idempotencyKey)) { + throw new IllegalArgumentException( + String.format( + "Idempotency key mismatch: entity key = %s, state key = %s", + entityKey, + idempotencyKey)); + } + + if (status == Status.PENDING) { + throw new IllegalStateException( + String.format("Cannot apply idempotency state: %s", this)); + } + + entity.setIdempotencyKey(idempotencyKey); + entity.setIsIdempotencyHit(status == Status.PREVIOUSLY_CREATED); + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public Status getStatus() { + return status; + } + + @Override + public String toString() { + return String.format( + "IdempotencyState{idempotencyKey=%s, status=%s}", + idempotencyKey, + status.name()); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (other instanceof IdempotencyState) { + IdempotencyState otherState = (IdempotencyState) other; + return Objects.equals(idempotencyKey, otherState.idempotencyKey) + && Objects.equals(status, otherState.status); + } + + return false; + } + + @Override + public int hashCode() { + return Objects.hash(idempotencyKey, status); + } + + /** + * Creates a new state with status CREATED, indicating the resource was newly created. + * + * @return a new IdempotencyState with the same key and CREATED status + */ + public IdempotencyState toCreated() { + return new IdempotencyState(idempotencyKey, Status.CREATED); + } + + /** + * Creates a new state with status PREVIOUSLY_CREATED, indicating a duplicate request. + * + * @return a new IdempotencyState with the same key and PREVIOUSLY_CREATED status + */ + public IdempotencyState toPreviouslyCreated() { + return new IdempotencyState(idempotencyKey, Status.PREVIOUSLY_CREATED); + } + + private IdempotencyState(String idempotencyKey, Status status) { + this.idempotencyKey = idempotencyKey; + this.status = status; + } +} diff --git a/src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java b/src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java new file mode 100644 index 00000000..483d7e1e --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java @@ -0,0 +1,70 @@ +package org.zendesk.client.v2.model; + +/** + * Interface for entities that support idempotent operations. + * + *

Entities implementing this interface can be created with an idempotency key to prevent + * duplicate resource creation. The Zendesk API uses idempotency keys to safely handle retries of + * create operations. + * + *

Usage example: + * + *

{@code
+ * Ticket ticket = new Ticket();
+ * ticket.setSubject("Help needed");
+ * ticket.setIdempotencyKey("unique-key-123"); // Prevents duplicate ticket creation
+ * Ticket created = zendesk.createTicket(ticket);
+ * if (Boolean.TRUE.equals(created.getIsIdempotencyHit())) {
+ *   // This ticket was already created with this key
+ * }
+ * }
+ * + * @see + * Zendesk API Idempotency + * @since 1.5.0 + */ +public interface IdempotentEntity { + + /** + * Gets the idempotency key for this entity. + * + * @return the idempotency key, or null if not set + */ + String getIdempotencyKey(); + + /** + * Sets the idempotency key for this entity. + * + *

The idempotency key should be a unique string (e.g., a UUID) that identifies this specific + * create operation. If a request with the same key is retried, the API will return the + * previously created resource instead of creating a duplicate. + * + * @param idempotencyKey the idempotency key to use, or null to disable idempotency + */ + void setIdempotencyKey(String idempotencyKey); + + /** + * Indicates whether this entity was retrieved from a previous idempotent request. + * + *

After a successful create operation, this field will be: + * + *

+ * + * @return true if this is a duplicate request, false if newly created, null if unknown + */ + Boolean getIsIdempotencyHit(); + + /** + * Sets whether this entity was retrieved from a previous idempotent request. + * + *

This is typically set automatically by the SDK based on response headers. + * + * @param isIdempotencyHit true if this is a duplicate request, false if newly created, null if + * unknown + */ + void setIsIdempotencyHit(Boolean isIdempotencyHit); +} diff --git a/src/main/java/org/zendesk/client/v2/model/Ticket.java b/src/main/java/org/zendesk/client/v2/model/Ticket.java index 341e96b3..04bae121 100644 --- a/src/main/java/org/zendesk/client/v2/model/Ticket.java +++ b/src/main/java/org/zendesk/client/v2/model/Ticket.java @@ -1,5 +1,6 @@ package org.zendesk.client.v2.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -12,7 +13,7 @@ * @since 04/04/2013 14:25 */ @JsonIgnoreProperties(ignoreUnknown = true) -public class Ticket extends Request implements SearchResultEntity { +public class Ticket extends Request implements SearchResultEntity, IdempotentEntity { private static final long serialVersionUID = -7559199410302237012L; @@ -35,6 +36,8 @@ public class Ticket extends Request implements SearchResultEntity { private Long brandId; private Boolean isPublic; private Boolean safeUpdate; + @JsonIgnore private String idempotencyKey; + @JsonIgnore private Boolean isIdempotencyHit; public Ticket() {} @@ -249,6 +252,28 @@ private Date getUpdatedStamp() { return Boolean.TRUE.equals(safeUpdate) ? updatedAt : null; } + @Override + @JsonIgnore + public String getIdempotencyKey() { + return idempotencyKey; + } + + @Override + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + @Override + @JsonIgnore + public Boolean getIsIdempotencyHit() { + return isIdempotencyHit; + } + + @Override + public void setIsIdempotencyHit(Boolean isIdempotencyHit) { + this.isIdempotencyHit = isIdempotencyHit; + } + @Override public String toString() { return "Ticket" diff --git a/src/test/java/org/zendesk/client/v2/IdempotencyUtilTest.java b/src/test/java/org/zendesk/client/v2/IdempotencyUtilTest.java new file mode 100644 index 00000000..1659d1d4 --- /dev/null +++ b/src/test/java/org/zendesk/client/v2/IdempotencyUtilTest.java @@ -0,0 +1,137 @@ +package org.zendesk.client.v2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.asynchttpclient.AsyncCompletionHandler; +import org.asynchttpclient.Request; +import org.asynchttpclient.RequestBuilder; +import org.asynchttpclient.Response; +import org.junit.Test; +import org.zendesk.client.v2.model.IdempotencyState; +import org.zendesk.client.v2.model.IdempotentEntity; + +public class IdempotencyUtilTest { + + private static final Request REQUEST = new RequestBuilder("POST") + .setUrl("https://example.com") + .build(); + private static final String KEY = "test-key-123"; + + @Test + public void addIdempotencyState_withNullState_returnsOriginalRequest() { + Request result = IdempotencyUtil.addIdempotencyState(REQUEST, null); + + assertThat(result).isSameAs(REQUEST); + assertThat(result.getHeaders().get(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER)).isNull(); + } + + @Test + public void addIdempotencyState_withPendingState_addsIdempotencyKeyHeader() { + IdempotentEntity entity = createMockEntity(); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow(); + + Request result = IdempotencyUtil.addIdempotencyState(REQUEST, state); + + assertThat(result).isNotSameAs(REQUEST); + assertThat(result.getHeaders().get(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER)).isEqualTo(KEY); + } + + @Test + public void addIdempotencyState_withCreatedState_throwsIllegalArgumentException() { + IdempotentEntity entity = createMockEntity(); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow().toCreated(); + + assertThatThrownBy(() -> IdempotencyUtil.addIdempotencyState(REQUEST, state)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void addIdempotencyState_withPreviouslyCreatedState_throwsIllegalArgumentException() { + IdempotentEntity entity = createMockEntity(); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow().toPreviouslyCreated(); + + assertThatThrownBy(() -> IdempotencyUtil.addIdempotencyState(REQUEST, state)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void wrapHandler_withNullState_returnsOriginalHandler() { + @SuppressWarnings("unchecked") + AsyncCompletionHandler handler = mock(AsyncCompletionHandler.class); + + AsyncCompletionHandler result = + IdempotencyUtil.wrapHandler(handler, null); + + assertThat(result).isSameAs(handler); + } + + @Test + public void wrapHandler_withMissHeader_setsIsIdempotencyHitToFalse() throws Exception { + testWrapHandler(false); + } + + @Test + public void wrapHandler_withHitHeader_setsIsIdempotencyHitToTrue() throws Exception { + testWrapHandler(true); + } + + @Test + public void wrapHandler_withMissingHeader_doesNotSetIdempotencyFields() throws Exception { + testWrapHandler(null); + } + + @Test + public void wrapHandler_propagatesOnThrowable() { + IdempotentEntity entity = createMockEntity(); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow(); + + @SuppressWarnings("unchecked") + AsyncCompletionHandler originalHandler = mock(AsyncCompletionHandler.class); + Throwable throwable = new RuntimeException("test exception"); + + AsyncCompletionHandler wrappedHandler = + IdempotencyUtil.wrapHandler(originalHandler, state); + wrappedHandler.onThrowable(throwable); + + verify(originalHandler).onThrowable(throwable); + } + + private IdempotentEntity createMockEntity() { + IdempotentEntity entity = mock(IdempotentEntity.class); + when(entity.getIdempotencyKey()).thenReturn(KEY); + return entity; + } + + private void testWrapHandler(Boolean isHit) throws Exception { + IdempotentEntity entity = createMockEntity(); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow(); + + @SuppressWarnings("unchecked") + AsyncCompletionHandler originalHandler = mock(AsyncCompletionHandler.class); + Response response = mock(Response.class); + + String headerValue = Optional.ofNullable(isHit) + .map(hit -> hit + ? IdempotencyUtil.IDEMPOTENCY_LOOKUP_HIT + : IdempotencyUtil.IDEMPOTENCY_LOOKUP_MISS) + .orElse(null); + when(response.getHeader(IdempotencyUtil.IDEMPOTENCY_LOOKUP_HEADER)).thenReturn(headerValue); + when(originalHandler.onCompleted(response)).thenReturn(entity); + + AsyncCompletionHandler wrappedHandler = + IdempotencyUtil.wrapHandler(originalHandler, state); + + IdempotentEntity result = wrappedHandler.onCompleted(response); + assertThat(result).isSameAs(entity); + + int numExpectedInvocations = isHit == null ? 0 : 1; + verify(entity, times(numExpectedInvocations)).setIdempotencyKey(KEY); + verify(entity, times(numExpectedInvocations)).setIsIdempotencyHit(isHit); + } +} diff --git a/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java b/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java new file mode 100644 index 00000000..28404488 --- /dev/null +++ b/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java @@ -0,0 +1,228 @@ +package org.zendesk.client.v2; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.absent; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.github.tomakehurst.wiremock.junit.WireMockClassRule; +import com.github.tomakehurst.wiremock.matching.StringValuePattern; +import java.util.Collections; +import java.util.function.Function; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.zendesk.client.v2.model.Comment; +import org.zendesk.client.v2.model.Status; +import org.zendesk.client.v2.model.Ticket; + +/** + * Integration tests for ticket creation with idempotency key support. + * Uses WireMock to simulate Zendesk API responses. + */ +public class TicketIdempotencyTest { + + private static final String CREATE_TICKET_PATH = "/api/v2/tickets.json"; + private static final long TICKET_ID = 12345L; + private static final String TICKET_KEY = "test-key-123"; + + @ClassRule + public static WireMockClassRule zendeskApiClass = + new WireMockClassRule(options().dynamicPort().dynamicHttpsPort()); + + @Rule public WireMockClassRule zendeskApiMock = zendeskApiClass; + + private Zendesk client; + private final ObjectMapper objectMapper = Zendesk.createMapper(Function.identity()); + + @Before + public void setUp() { + client = new Zendesk.Builder("http://localhost:" + zendeskApiMock.port()) + .setUsername("zana@example.com") + .setToken("still-sane-exile") + .build(); + } + + @After + public void tearDown() { + client.close(); + client = null; + } + + @Test + public void createTicket_withoutIdempotencyKey_doesNotSendHeader() throws JsonProcessingException { + Ticket requestTicket = createSampleTicket(); + requestTicket.setIdempotencyKey(null); + + Ticket responseTicket = createResponseTicket(); + String expectedJsonResponse = + objectMapper.writeValueAsString(Collections.singletonMap("ticket", responseTicket)); + + zendeskApiMock.stubFor( + post(urlEqualTo(CREATE_TICKET_PATH)) + .willReturn(ok().withBody(expectedJsonResponse))); + + Ticket result = client.createTicket(requestTicket); + + verifyRequest(null); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(TICKET_ID); + assertThat(result.getIdempotencyKey()).isNull(); + assertThat(result.getIsIdempotencyHit()).isNull(); + } + + @Test + public void createTicket_withIdempotencyKeyFirstRequest_sendsMissHeader() + throws JsonProcessingException { + Ticket requestTicket = createSampleTicket(); + requestTicket.setIdempotencyKey(TICKET_KEY); + + Ticket responseTicket = createResponseTicket(); + String expectedJsonResponse = + objectMapper.writeValueAsString(Collections.singletonMap("ticket", responseTicket)); + + zendeskApiMock.stubFor( + post(urlEqualTo(CREATE_TICKET_PATH)) + .withHeader(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER, equalTo(TICKET_KEY)) + .willReturn( + aResponse() + .withStatus(201) + .withHeader( + IdempotencyUtil.IDEMPOTENCY_LOOKUP_HEADER, + IdempotencyUtil.IDEMPOTENCY_LOOKUP_MISS) + .withBody(expectedJsonResponse))); + + Ticket result = client.createTicket(requestTicket); + + verifyRequest(TICKET_KEY); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(TICKET_ID); + assertThat(result.getIdempotencyKey()).isEqualTo(TICKET_KEY); + assertThat(result.getIsIdempotencyHit()).isFalse(); + } + + @Test + public void createTicket_withIdempotencyKeyDuplicateRequest_sendsHitHeader() + throws JsonProcessingException { + Ticket requestTicket = createSampleTicket(); + requestTicket.setIdempotencyKey(TICKET_KEY); + + Ticket responseTicket = createResponseTicket(); + String expectedJsonResponse = + objectMapper.writeValueAsString(Collections.singletonMap("ticket", responseTicket)); + + zendeskApiMock.stubFor( + post(urlEqualTo(CREATE_TICKET_PATH)) + .withHeader(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER, equalTo(TICKET_KEY)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader( + IdempotencyUtil.IDEMPOTENCY_LOOKUP_HEADER, + IdempotencyUtil.IDEMPOTENCY_LOOKUP_HIT) + .withBody(expectedJsonResponse))); + + Ticket result = client.createTicket(requestTicket); + + verifyRequest(TICKET_KEY); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(TICKET_ID); + assertThat(result.getIdempotencyKey()).isEqualTo(TICKET_KEY); + assertThat(result.getIsIdempotencyHit()).isTrue(); + } + + @Test + public void createTicket_onIdempotencyConflictError_throwsException() { + Ticket requestTicket = createSampleTicket(); + requestTicket.setIdempotencyKey(TICKET_KEY); + + JsonNode expectedJsonResponse = JsonNodeFactory.instance.objectNode() + .put("error", IdempotencyUtil.IDEMPOTENCY_ERROR_NAME) + .put("description", "Request parameters don't match the given idempotency key"); + + zendeskApiMock.stubFor( + post(urlEqualTo(CREATE_TICKET_PATH)) + .withHeader(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER, equalTo(TICKET_KEY)) + .willReturn( + aResponse() + .withStatus(400) + .withJsonBody(expectedJsonResponse))); + + assertThatThrownBy(() -> client.createTicket(requestTicket)).isInstanceOf( + ZendeskResponseIdempotencyConflictException.class); + + verifyRequest(TICKET_KEY); + } + + @Test + public void createTicket_withIdempotencyKeyNoHeader_doesNotSetIdempotencyFields() + throws JsonProcessingException { + Ticket requestTicket = createSampleTicket(); + requestTicket.setIdempotencyKey(TICKET_KEY); + + Ticket responseTicket = createResponseTicket(); + String expectedJsonResponse = + objectMapper.writeValueAsString(Collections.singletonMap("ticket", responseTicket)); + + zendeskApiMock.stubFor( + post(urlEqualTo(CREATE_TICKET_PATH)) + .withHeader(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER, equalTo(TICKET_KEY)) + .willReturn( + aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody(expectedJsonResponse))); + + Ticket result = client.createTicket(requestTicket); + + zendeskApiMock.verify( + postRequestedFor(urlEqualTo(CREATE_TICKET_PATH)) + .withHeader(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER, equalTo(TICKET_KEY))); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(TICKET_ID); + // Idempotency fields should not be set if server doesn't return the header + assertThat(result.getIdempotencyKey()).isNull(); + assertThat(result.getIsIdempotencyHit()).isNull(); + } + + private Ticket createSampleTicket() { + Ticket ticket = new Ticket(); + ticket.setSubject("Test Ticket"); + ticket.setComment(new Comment("This is a test ticket")); + ticket.setRequesterId(123456L); + return ticket; + } + + private Ticket createResponseTicket() { + Ticket ticket = createSampleTicket(); + ticket.setId(TICKET_ID); + ticket.setStatus(Status.OPEN); + return ticket; + } + + private void verifyRequest(String idempotencyKey) { + StringValuePattern pattern = idempotencyKey == null + ? absent() + : equalTo(idempotencyKey); + zendeskApiMock.verify( + postRequestedFor(urlEqualTo(CREATE_TICKET_PATH)) + .withHeader(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER, pattern)); + } +} diff --git a/src/test/java/org/zendesk/client/v2/model/IdempotencyStateTest.java b/src/test/java/org/zendesk/client/v2/model/IdempotencyStateTest.java new file mode 100644 index 00000000..2c260e01 --- /dev/null +++ b/src/test/java/org/zendesk/client/v2/model/IdempotencyStateTest.java @@ -0,0 +1,201 @@ +package org.zendesk.client.v2.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.Test; +import org.zendesk.client.v2.model.IdempotencyState.Status; + +public class IdempotencyStateTest { + + private static final String KEY = "test-key-123"; + private static final String OTHER_KEY = "test-key-456"; + + @Test + public void of_withIdempotencyKey_createsPendingState() { + IdempotentEntity entity = createMockEntity(KEY); + + Optional result = IdempotencyState.of(entity); + + assertThat(result).isPresent(); + IdempotencyState state = result.get(); + assertThat(state.getIdempotencyKey()).isEqualTo(KEY); + assertThat(state.getStatus()).isEqualTo(Status.PENDING); + } + + @Test + public void of_withNullIdempotencyKey_returnsEmptyOptional() { + IdempotentEntity entity = createMockEntity(null); + + Optional result = IdempotencyState.of(entity); + + assertThat(result).isEmpty(); + } + + @Test + public void apply_withCreatedState_setsEntityFieldsCorrectly() { + IdempotentEntity entity = createMockEntity(KEY); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow().toCreated(); + + state.apply(entity); + + verify(entity).setIdempotencyKey(KEY); + verify(entity).setIsIdempotencyHit(false); + } + + @Test + public void apply_withPreviouslyCreatedState_setsEntityFieldsCorrectly() { + IdempotentEntity entity = createMockEntity(KEY); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow().toPreviouslyCreated(); + + state.apply(entity); + + verify(entity).setIdempotencyKey(KEY); + verify(entity).setIsIdempotencyHit(true); + } + + @Test + public void apply_withPendingState_throwsIllegalStateException() { + IdempotentEntity entity = createMockEntity(KEY); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow(); + + assertThatThrownBy(() -> state.apply(entity)).isInstanceOf(IllegalStateException.class); + } + + @Test + public void apply_withMismatchedKey_throwsIllegalArgumentException() { + assertThat(OTHER_KEY).isNotEqualTo(KEY); + IdempotentEntity entity = createMockEntity(OTHER_KEY); + IdempotencyState state = IdempotencyState.of(createMockEntity(KEY)).orElseThrow().toCreated(); + + assertThatThrownBy(() -> state.apply(entity)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void apply_withNullEntity_doesNotThrow() { + IdempotentEntity entity = createMockEntity(KEY); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow().toCreated(); + + assertThatNoException().isThrownBy(() -> state.apply(null)); + } + + @Test + public void toCreated_transitionsCorrectly() { + IdempotentEntity entity = createMockEntity(KEY); + IdempotencyState pendingState = IdempotencyState.of(entity).orElseThrow(); + + IdempotencyState createdState = pendingState.toCreated(); + + assertThat(createdState.getIdempotencyKey()).isEqualTo(KEY); + assertThat(createdState.getStatus()).isEqualTo(Status.CREATED); + // Original state should not change + assertThat(pendingState.getStatus()).isEqualTo(Status.PENDING); + } + + @Test + public void toPreviouslyCreated_transitionsCorrectly() { + IdempotentEntity entity = createMockEntity(KEY); + IdempotencyState pendingState = IdempotencyState.of(entity).orElseThrow(); + + IdempotencyState previouslyCreatedState = pendingState.toPreviouslyCreated(); + + assertThat(previouslyCreatedState.getIdempotencyKey()).isEqualTo(KEY); + assertThat(previouslyCreatedState.getStatus()).isEqualTo(Status.PREVIOUSLY_CREATED); + // Original state should not change + assertThat(pendingState.getStatus()).isEqualTo(Status.PENDING); + } + + @Test + public void equals_withSameState_returnsTrue() { + IdempotentEntity entity1 = createMockEntity(KEY); + IdempotentEntity entity2 = createMockEntity(KEY); + + IdempotencyState state1 = IdempotencyState.of(entity1).orElseThrow(); + IdempotencyState state2 = IdempotencyState.of(entity2).orElseThrow(); + + assertThat(state1).isEqualTo(state2); + } + + @Test + public void equals_withDifferentKey_returnsFalse() { + IdempotentEntity entity1 = createMockEntity(KEY); + IdempotentEntity entity2 = createMockEntity(OTHER_KEY); + + IdempotencyState state1 = IdempotencyState.of(entity1).orElseThrow(); + IdempotencyState state2 = IdempotencyState.of(entity2).orElseThrow(); + + assertThat(state1).isNotEqualTo(state2); + } + + @Test + public void equals_withDifferentStatus_returnsFalse() { + IdempotentEntity entity = createMockEntity(KEY); + IdempotencyState pendingState = IdempotencyState.of(entity).orElseThrow(); + + assertThat(pendingState) + .isNotEqualTo(pendingState.toCreated()) + .isNotEqualTo(pendingState.toPreviouslyCreated()); + assertThat(pendingState.toCreated()).isNotEqualTo(pendingState.toPreviouslyCreated()); + } + + @Test + public void equals_withItself_returnsTrue() { + IdempotentEntity entity = createMockEntity(KEY); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow(); + + assertThat(state).isEqualTo(state); + } + + @Test + public void equals_withNull_returnsFalse() { + IdempotentEntity entity = createMockEntity("test-key-123"); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow(); + + assertThat(state).isNotEqualTo(null); + } + + @Test + public void equals_withDifferentType_returnsFalse() { + IdempotentEntity entity = createMockEntity("test-key-123"); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow(); + + assertThat(state).isNotEqualTo("not an IdempotencyState"); + } + + @Test + public void hashCode_withSameState_returnsSameHashCode() { + IdempotentEntity entity1 = createMockEntity(KEY); + IdempotentEntity entity2 = createMockEntity(KEY); + + IdempotencyState state1 = IdempotencyState.of(entity1).orElseThrow(); + IdempotencyState state2 = IdempotencyState.of(entity2).orElseThrow(); + + assertThat(state1).hasSameHashCodeAs(state2); + assertThat(state1.toCreated()).hasSameHashCodeAs(state2.toCreated()); + assertThat(state1.toPreviouslyCreated()).hasSameHashCodeAs(state2.toPreviouslyCreated()); + } + + @Test + public void toString_returnsFormattedString() { + IdempotentEntity entity = createMockEntity("test-key-123"); + IdempotencyState state = IdempotencyState.of(entity).orElseThrow(); + + String result = state.toString(); + + assertThat(result) + .contains("IdempotencyState") + .contains("test-key-123") + .contains("PENDING"); + } + + private IdempotentEntity createMockEntity(String idempotencyKey) { + IdempotentEntity entity = mock(IdempotentEntity.class); + when(entity.getIdempotencyKey()).thenReturn(idempotencyKey); + return entity; + } +} diff --git a/src/test/java/org/zendesk/client/v2/model/TicketTest.java b/src/test/java/org/zendesk/client/v2/model/TicketTest.java index c8639379..d06f48bb 100644 --- a/src/test/java/org/zendesk/client/v2/model/TicketTest.java +++ b/src/test/java/org/zendesk/client/v2/model/TicketTest.java @@ -3,54 +3,109 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.Calendar; +import com.fasterxml.jackson.databind.util.StdDateFormat; import java.util.Date; -import java.util.Random; +import java.util.Map; import java.util.function.Function; import org.junit.Test; import org.zendesk.client.v2.Zendesk; public class TicketTest { - private static final Random RANDOM = new Random(); - private static final String TICKET_COMMENT1 = "Please ignore this ticket"; - private static final Date NOW = Calendar.getInstance().getTime(); + private static final long TICKET_ID = 12345; + private static final String TICKET_SUBJECT = "Test subject"; + private static final Status TICKET_STATUS = Status.OPEN; + private static final Date TICKET_TS = new Date(); + + private static final Map TICKET_JSON_MAP = Map.of( + "id", TICKET_ID, + "subject", TICKET_SUBJECT, + "status", TICKET_STATUS.toString(), + "updated_at", new StdDateFormat().format(TICKET_TS), + "has_incidents", false); + + private static final String TICKET_IDEMPOTENCY_KEY = "test-key-123"; + + private static final ObjectMapper OBJECT_MAPPER = Zendesk.createMapper(Function.identity()); @Test public void serializeWithNullSafeUpdate() throws Exception { - ObjectMapper mapper = Zendesk.createMapper(Function.identity()); Ticket ticket = createSampleTicket(); - assertThat(mapper.writeValueAsString(ticket)) + assertThat(OBJECT_MAPPER.writeValueAsString(ticket)) .doesNotContain("\"safe_update\"") .doesNotContain("\"updated_stamp\""); } @Test public void serializeWithFalseSafeUpdate() throws Exception { - ObjectMapper mapper = Zendesk.createMapper(Function.identity()); Ticket ticket = createSampleTicket(); ticket.setSafeUpdate(false); - assertThat(mapper.writeValueAsString(ticket)) + assertThat(OBJECT_MAPPER.writeValueAsString(ticket)) .doesNotContain("\"safe_update\"") .doesNotContain("\"updated_stamp\""); } @Test public void serializeWithSafeUpdate() throws Exception { - ObjectMapper mapper = Zendesk.createMapper(Function.identity()); Ticket ticket = createSampleTicket(); ticket.setSafeUpdate(true); - assertThat(mapper.writeValueAsString(ticket)) + assertThat(OBJECT_MAPPER.writeValueAsString(ticket)) .contains("\"safe_update\"") .contains("\"updated_stamp\""); } + @Test + public void idempotencyFields_areNotSerialized() throws Exception { + Ticket ticket = createSampleTicket(); + ticket.setIdempotencyKey("test-idempotency-key"); + ticket.setIsIdempotencyHit(true); + + String json = OBJECT_MAPPER.writeValueAsString(ticket); + Map jsonMap = OBJECT_MAPPER.readValue(json, Map.class); + assertThat(jsonMap).isEqualTo(TICKET_JSON_MAP); + } + + @Test + public void ticket_canBeDeserializedWithoutIdempotencyFields() throws Exception { + String json = OBJECT_MAPPER.writeValueAsString(TICKET_JSON_MAP); + Ticket ticket = OBJECT_MAPPER.readValue(json, Ticket.class); + + assertThat(ticket).isNotNull(); + assertThat(ticket.getId()).isEqualTo(TICKET_ID); + assertThat(ticket.getSubject()).isEqualTo(TICKET_SUBJECT); + assertThat(ticket.getStatus()).isEqualTo(TICKET_STATUS); + assertThat(ticket.getIdempotencyKey()).isNull(); + assertThat(ticket.getIsIdempotencyHit()).isNull(); + } + + @Test + public void idempotencyKey_getterSetterWork() { + Ticket ticket = createSampleTicket(); + ticket.setIdempotencyKey(TICKET_IDEMPOTENCY_KEY); + + assertThat(ticket.getIdempotencyKey()).isEqualTo(TICKET_IDEMPOTENCY_KEY); + } + + @Test + public void isIdempotencyHit_getterSetterWork() { + Ticket ticket = createSampleTicket(); + + ticket.setIsIdempotencyHit(true); + assertThat(ticket.getIsIdempotencyHit()).isTrue(); + + ticket.setIsIdempotencyHit(false); + assertThat(ticket.getIsIdempotencyHit()).isFalse(); + + ticket.setIsIdempotencyHit(null); + assertThat(ticket.getIsIdempotencyHit()).isNull(); + } + private Ticket createSampleTicket() { Ticket ticket = new Ticket(); - ticket.setId(Math.abs(RANDOM.nextLong())); - ticket.setComment(new Comment(TICKET_COMMENT1)); - ticket.setUpdatedAt(NOW); - ticket.setCustomStatusId(Math.abs(RANDOM.nextLong())); + ticket.setId(TICKET_ID); + ticket.setSubject(TICKET_SUBJECT); + ticket.setStatus(TICKET_STATUS); + ticket.setUpdatedAt(TICKET_TS); return ticket; } }