From 3727a905cf497fca5ea010598ae3b8e993cbd2d1 Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Wed, 18 Mar 2026 17:33:34 +0000 Subject: [PATCH 01/16] Added support for `Idempotency-Key` when creating tickets --- .../zendesk/client/v2/IdempotencyUtil.java | 77 +++++++++++++++ .../java/org/zendesk/client/v2/Zendesk.java | 24 ++++- .../client/v2/model/IdempotencyState.java | 93 +++++++++++++++++++ .../client/v2/model/IdempotentEntity.java | 12 +++ .../org/zendesk/client/v2/model/Ticket.java | 27 +++++- 5 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/zendesk/client/v2/IdempotencyUtil.java create mode 100644 src/main/java/org/zendesk/client/v2/model/IdempotencyState.java create mode 100644 src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java 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..5eaa0980 --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java @@ -0,0 +1,77 @@ +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; + +public class IdempotencyUtil { + + 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", state.getIdempotencyKey()) + .build(); + } + + 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("x-idempotency-lookup"); + if (idempotencyLookup == null) { + return Optional.empty(); + } + + switch (idempotencyLookup) { + case "hit": + return Optional.of(state.toPreviouslyCreated()); + case "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..b9aedc6c 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) { 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..2a48ab3e --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java @@ -0,0 +1,93 @@ +package org.zendesk.client.v2.model; + +import java.util.Objects; +import java.util.Optional; + +public class IdempotencyState { + + public enum Status { + PENDING, + CREATED, + PREVIOUSLY_CREATED + } + + private final String idempotencyKey; + private final Status status; + + public static Optional of(IdempotentEntity entity) { + return Optional.ofNullable(entity.getIdempotencyKey()) + .map(key -> new IdempotencyState(key, Status.PENDING)); + } + + 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); + } + + public IdempotencyState toCreated() { + return new IdempotencyState(idempotencyKey, Status.CREATED); + } + + 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..1263ae1e --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java @@ -0,0 +1,12 @@ +package org.zendesk.client.v2.model; + +public interface IdempotentEntity { + + String getIdempotencyKey(); + + void setIdempotencyKey(String idempotencyKey); + + Boolean getIsIdempotencyHit(); + + 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" From eb2114a5983b0fd37125390b022d5fdbaad198c3 Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Thu, 19 Mar 2026 14:06:37 +0000 Subject: [PATCH 02/16] Added tests --- pom.xml | 6 + .../zendesk/client/v2/IdempotencyUtil.java | 13 +- .../client/v2/IdempotencyUtilTest.java | 137 ++++++++++++ .../client/v2/TicketIdempotencyTest.java | 202 ++++++++++++++++++ .../client/v2/model/IdempotencyStateTest.java | 201 +++++++++++++++++ .../zendesk/client/v2/model/TicketTest.java | 85 ++++++-- 6 files changed, 625 insertions(+), 19 deletions(-) create mode 100644 src/test/java/org/zendesk/client/v2/IdempotencyUtilTest.java create mode 100644 src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java create mode 100644 src/test/java/org/zendesk/client/v2/model/IdempotencyStateTest.java 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 index 5eaa0980..27f48417 100644 --- a/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java +++ b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java @@ -10,6 +10,11 @@ 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"; + public static Request addIdempotencyState(Request request, IdempotencyState state) { if (state == null) { return request; @@ -21,7 +26,7 @@ public static Request addIdempotencyState(Request request, IdempotencyState stat // https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency return request.toBuilder() - .setHeader("Idempotency-Key", state.getIdempotencyKey()) + .setHeader(IDEMPOTENCY_KEY_HEADER, state.getIdempotencyKey()) .build(); } @@ -56,15 +61,15 @@ private static Optional transitionIdempotencyState( } // https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency - String idempotencyLookup = response.getHeader("x-idempotency-lookup"); + String idempotencyLookup = response.getHeader(IDEMPOTENCY_LOOKUP_HEADER); if (idempotencyLookup == null) { return Optional.empty(); } switch (idempotencyLookup) { - case "hit": + case IDEMPOTENCY_LOOKUP_HIT: return Optional.of(state.toPreviouslyCreated()); - case "miss": + case IDEMPOTENCY_LOOKUP_MISS: return Optional.of(state.toCreated()); default: return Optional.empty(); 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..0a72a686 --- /dev/null +++ b/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java @@ -0,0 +1,202 @@ +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 com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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_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; } } From 3975a39d40773f18e549569320ba4bb7c6f0ef0a Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Thu, 19 Mar 2026 17:58:06 +0000 Subject: [PATCH 03/16] Added a special exception subtype for idempotency conflicts --- .../zendesk/client/v2/IdempotencyUtil.java | 1 + .../java/org/zendesk/client/v2/Zendesk.java | 43 +++++++++++++++---- .../client/v2/ZendeskResponseException.java | 4 ++ ...kResponseIdempotencyConflictException.java | 28 ++++++++++++ .../client/v2/TicketIdempotencyTest.java | 27 ++++++++++++ 5 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java diff --git a/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java index 27f48417..4225d1b3 100644 --- a/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java +++ b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java @@ -14,6 +14,7 @@ public class IdempotencyUtil { 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"; public static Request addIdempotencyState(Request request, IdempotencyState state) { if (state == null) { diff --git a/src/main/java/org/zendesk/client/v2/Zendesk.java b/src/main/java/org/zendesk/client/v2/Zendesk.java index b9aedc6c..c4e82feb 100644 --- a/src/main/java/org/zendesk/client/v2/Zendesk.java +++ b/src/main/java/org/zendesk/client/v2/Zendesk.java @@ -3608,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); } } @@ -3941,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 ////////////////////////////////////////////////////////////////////// @@ -3951,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/ZendeskResponseException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java index 195e66a9..00d6cfbe 100644 --- a/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java @@ -42,4 +42,8 @@ public String getStatusText() { public String getBody() { return body; } + + public boolean isIdempotencyConflict() { + return false; + } } 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..89744c76 --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java @@ -0,0 +1,28 @@ +package org.zendesk.client.v2; + +import java.io.IOException; +import org.asynchttpclient.Response; + +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); + } + + @Override + public boolean isIdempotencyConflict() { + return true; + } +} diff --git a/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java b/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java index 0a72a686..26d545fb 100644 --- a/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java +++ b/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java @@ -9,9 +9,12 @@ 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; @@ -144,6 +147,30 @@ public void createTicket_withIdempotencyKeyDuplicateRequest_sendsHitHeader() 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)).isInstanceOfSatisfying( + ZendeskResponseIdempotencyConflictException.class, + e -> assertThat(e.isIdempotencyConflict()).isTrue()); + + verifyRequest(TICKET_KEY); + } + @Test public void createTicket_withIdempotencyKeyNoHeader_doesNotSetIdempotencyFields() throws JsonProcessingException { From 517cd1f93d36e57137d3dd0976523f337efd388a Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Thu, 19 Mar 2026 18:12:21 +0000 Subject: [PATCH 04/16] Generated JavaDoc for the new code --- .../zendesk/client/v2/IdempotencyUtil.java | 34 +++++++++++ .../client/v2/ZendeskResponseException.java | 9 +++ ...kResponseIdempotencyConflictException.java | 15 +++++ .../client/v2/model/IdempotencyState.java | 47 +++++++++++++++ .../client/v2/model/IdempotentEntity.java | 58 +++++++++++++++++++ 5 files changed, 163 insertions(+) diff --git a/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java index 4225d1b3..ed4a1f49 100644 --- a/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java +++ b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java @@ -8,6 +8,17 @@ 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"; @@ -16,6 +27,15 @@ public class IdempotencyUtil { 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; @@ -31,6 +51,20 @@ public static Request addIdempotencyState(Request request, IdempotencyState stat .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) { diff --git a/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java index 00d6cfbe..312d0f2b 100644 --- a/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java @@ -43,6 +43,15 @@ public String getBody() { return body; } + /** + * Determines if this exception represents an idempotency conflict error. + * + *

Returns true only for {@link ZendeskResponseIdempotencyConflictException}, which indicates + * that a request was retried with the same idempotency key but different parameters. + * + * @return true if this is an idempotency conflict, false otherwise + * @since 1.5.0 + */ public boolean isIdempotencyConflict() { return false; } diff --git a/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java index 89744c76..6196d6a9 100644 --- a/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java @@ -3,6 +3,21 @@ 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; diff --git a/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java b/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java index 2a48ab3e..ba651921 100644 --- a/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java +++ b/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java @@ -3,22 +3,59 @@ 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: + * + *

    + *
  • PENDING - Initial state before the request is sent + *
  • CREATED - The resource was newly created (idempotency key miss) + *
  • PREVIOUSLY_CREATED - The resource was previously created (idempotency key hit) + *
+ * + * @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; @@ -78,10 +115,20 @@ 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); } diff --git a/src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java b/src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java index 1263ae1e..483d7e1e 100644 --- a/src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java +++ b/src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java @@ -1,12 +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: + * + *

    + *
  • {@code false} if the resource was newly created (idempotency key miss) + *
  • {@code true} if the resource was previously created (idempotency key hit) + *
  • {@code null} if idempotency was not used or the API didn't return the header + *
+ * + * @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); } From 6f63773b0f4edc133435ca1613c38f93aa172ed0 Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Thu, 19 Mar 2026 18:37:08 +0000 Subject: [PATCH 05/16] Updated `README.md` --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) 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 ------------- From bc2272b0c7dd05977ec72307871458acf103c281 Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Thu, 19 Mar 2026 18:51:04 +0000 Subject: [PATCH 06/16] Removed `ZendeskResponseException$isIdempotencyConflict` --- .../zendesk/client/v2/ZendeskResponseException.java | 13 ------------- ...ZendeskResponseIdempotencyConflictException.java | 5 ----- .../zendesk/client/v2/TicketIdempotencyTest.java | 5 ++--- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java index 312d0f2b..195e66a9 100644 --- a/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java @@ -42,17 +42,4 @@ public String getStatusText() { public String getBody() { return body; } - - /** - * Determines if this exception represents an idempotency conflict error. - * - *

Returns true only for {@link ZendeskResponseIdempotencyConflictException}, which indicates - * that a request was retried with the same idempotency key but different parameters. - * - * @return true if this is an idempotency conflict, false otherwise - * @since 1.5.0 - */ - public boolean isIdempotencyConflict() { - return false; - } } diff --git a/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java index 6196d6a9..978487f9 100644 --- a/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java @@ -35,9 +35,4 @@ public ZendeskResponseIdempotencyConflictException( ZendeskResponseIdempotencyConflictException cause) { super(cause); } - - @Override - public boolean isIdempotencyConflict() { - return true; - } } diff --git a/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java b/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java index 26d545fb..28404488 100644 --- a/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java +++ b/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java @@ -164,9 +164,8 @@ public void createTicket_onIdempotencyConflictError_throwsException() { .withStatus(400) .withJsonBody(expectedJsonResponse))); - assertThatThrownBy(() -> client.createTicket(requestTicket)).isInstanceOfSatisfying( - ZendeskResponseIdempotencyConflictException.class, - e -> assertThat(e.isIdempotencyConflict()).isTrue()); + assertThatThrownBy(() -> client.createTicket(requestTicket)).isInstanceOf( + ZendeskResponseIdempotencyConflictException.class); verifyRequest(TICKET_KEY); } From 4ee2fc6d0a30730acec17c731d95950f3696f55c Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Tue, 7 Apr 2026 21:55:06 +0100 Subject: [PATCH 07/16] Reworked the implementation following a PR code review --- README.md | 26 +- pom.xml | 6 - .../zendesk/client/v2/IdempotencyUtil.java | 92 +++---- .../java/org/zendesk/client/v2/Zendesk.java | 209 ++++++++-------- .../client/v2/ZendeskResponseException.java | 3 +- ...kResponseIdempotencyConflictException.java | 3 +- .../v2/ZendeskResponseRateLimitException.java | 3 +- .../client/v2/model/IdempotencyState.java | 140 ----------- .../client/v2/model/IdempotentEntity.java | 70 ------ .../client/v2/model/IdempotentResult.java | 62 +++++ .../org/zendesk/client/v2/model/Ticket.java | 27 +-- .../client/v2/CreateTicketIdempotentTest.java | 195 +++++++++++++++ .../client/v2/IdempotencyUtilTest.java | 137 ----------- .../client/v2/TicketIdempotencyTest.java | 228 ------------------ .../client/v2/model/IdempotencyStateTest.java | 201 --------------- .../zendesk/client/v2/model/TicketTest.java | 85 ++----- 16 files changed, 421 insertions(+), 1066 deletions(-) delete mode 100644 src/main/java/org/zendesk/client/v2/model/IdempotencyState.java delete mode 100644 src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java create mode 100644 src/main/java/org/zendesk/client/v2/model/IdempotentResult.java create mode 100644 src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java delete mode 100644 src/test/java/org/zendesk/client/v2/IdempotencyUtilTest.java delete mode 100644 src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java delete mode 100644 src/test/java/org/zendesk/client/v2/model/IdempotencyStateTest.java diff --git a/README.md b/README.md index 56ec0eb4..d940afc8 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,8 @@ 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. +to safely retry operations without creating duplicate resources. This client supports idempotent +ticket creation via `createTicketIdempotent` and `createTicketIdempotentAsync`. ### Usage Example @@ -55,14 +51,17 @@ class FooIssueService { // ... 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)); + // Must map 1-to-1 with the issue, so that retries for the same issue use the same key + String idempotencyKey = String.format("issue-%s", issue.getId()); + try { - ticket = zendesk.createTicket(ticket); - if (Boolean.FALSE.equals(ticket.getIsIdempotencyHit())) { + // 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. + result = zendesk.createTicketIdempotent(ticket, idempotencyKey); + ticket = result.get(); + if (!result.isDuplicateRequest()) { issueRepository.saveTicketId(issue.getId(), ticket.getId()); logger.info("Created new ticket (id = {})", ticket.getId()); } @@ -76,11 +75,6 @@ class FooIssueService { 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()); - } } ``` diff --git a/pom.xml b/pom.xml index 26759f53..f0039636 100644 --- a/pom.xml +++ b/pom.xml @@ -193,12 +193,6 @@ 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 index ed4a1f49..f383a387 100644 --- a/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java +++ b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java @@ -1,12 +1,12 @@ package org.zendesk.client.v2; -import java.util.Optional; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.asynchttpclient.AsyncCompletionHandler; -import org.asynchttpclient.Request; +import org.asynchttpclient.RequestBuilder; 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; +import org.zendesk.client.v2.model.IdempotentResult; /** * Utility class for handling Zendesk API idempotency keys. @@ -27,58 +27,20 @@ public class IdempotencyUtil { 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"); - } - + public static RequestBuilder addIdempotencyHeader(RequestBuilder builder, String idempotencyKey) { // https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency - return request.toBuilder() - .setHeader(IDEMPOTENCY_KEY_HEADER, state.getIdempotencyKey()) - .build(); + return builder.setHeader(IDEMPOTENCY_KEY_HEADER, idempotencyKey); } - /** - * 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; - } - + public static AsyncCompletionHandler> wrapHandler( + AsyncCompletionHandler handler) { return new AsyncCompletionHandler<>() { @Override - public T onCompleted(Response response) throws Exception { + public IdempotentResult onCompleted(Response response) throws Exception { T entity = handler.onCompleted(response); - transitionIdempotencyState(idempotencyState, response) - .ifPresent(newState -> newState.apply(entity)); - return entity; + boolean duplicateRequest = isDuplicateResponse(response); + + return new IdempotentResult<>(entity, duplicateRequest); } @Override @@ -88,26 +50,36 @@ public void onThrowable(Throwable t) { }; } - private static Optional transitionIdempotencyState( - IdempotencyState state, - Response response) { - if (state == null) { - return Optional.empty(); + public static boolean isIdempotencyConflict( + Response response, + ObjectMapper mapper) throws JsonProcessingException { + if (response.getStatusCode() != 400) { + return false; } + // Note: Jackson's own docs are a bit outdated in that `readTree` returns + // `MissingNode.getInstance()` and not `null` when given an essentially empty string. + JsonNode error = mapper.readTree(response.getResponseBody()).path("error"); + return IDEMPOTENCY_ERROR_NAME.equals(error.textValue()); + } + + private static boolean isDuplicateResponse(Response response) { // https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency String idempotencyLookup = response.getHeader(IDEMPOTENCY_LOOKUP_HEADER); if (idempotencyLookup == null) { - return Optional.empty(); + idempotencyLookup = ""; } switch (idempotencyLookup) { case IDEMPOTENCY_LOOKUP_HIT: - return Optional.of(state.toPreviouslyCreated()); + return true; case IDEMPOTENCY_LOOKUP_MISS: - return Optional.of(state.toCreated()); + return false; default: - return Optional.empty(); + throw new IllegalArgumentException( + String.format( + "Unexpected value of the idempotency lookup header: %s", + idempotencyLookup)); } } diff --git a/src/main/java/org/zendesk/client/v2/Zendesk.java b/src/main/java/org/zendesk/client/v2/Zendesk.java index c4e82feb..8734fd3b 100644 --- a/src/main/java/org/zendesk/client/v2/Zendesk.java +++ b/src/main/java/org/zendesk/client/v2/Zendesk.java @@ -43,6 +43,7 @@ import org.jetbrains.annotations.VisibleForTesting; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.zendesk.client.v2.model.IdempotentResult; import org.zendesk.client.v2.model.AgentRole; import org.zendesk.client.v2.model.Attachment; import org.zendesk.client.v2.model.Audit; @@ -56,8 +57,6 @@ 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; @@ -434,21 +433,30 @@ 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"), - idempotencyState); + req("POST", cnst("/tickets.json"), JSON, json(Collections.singletonMap("ticket", ticket))), + handle(Ticket.class, "ticket")); } public Ticket createTicket(Ticket ticket) { return complete(createTicketAsync(ticket)); } + public ListenableFuture> createTicketIdempotentAsync(Ticket ticket, String idempotencyKey) { + return submitIdempotent( + reqBuilder( + "POST", + cnst("/tickets.json"), + JSON, + json(Collections.singletonMap("ticket", ticket))), + handle(Ticket.class, "ticket"), + idempotencyKey); + } + + public IdempotentResult createTicketIdempotent(Ticket ticket, String idempotencyKey) { + return complete(createTicketIdempotentAsync(ticket, idempotencyKey)); + } + public JobStatus createTickets(Ticket... tickets) { return createTickets(Arrays.asList(tickets)); } @@ -3501,13 +3509,15 @@ private ListenableFuture submit(Request request, AsyncCompletionHandler ListenableFuture submit( - Request request, + private ListenableFuture> submitIdempotent( + RequestBuilder builder, AsyncCompletionHandler handler, - IdempotencyState idempotencyState) { - return submit( - IdempotencyUtil.addIdempotencyState(request, idempotencyState), - IdempotencyUtil.wrapHandler(handler, idempotencyState)); + String idempotencyKey) { + Request request = IdempotencyUtil.addIdempotencyHeader(builder, idempotencyKey).build(); + AsyncCompletionHandler> idempotentHandler = + IdempotencyUtil.wrapHandler(handler); + + return submit(request, idempotentHandler); } private abstract static class ZendeskAsyncCompletionHandler extends AsyncCompletionHandler { @@ -3530,10 +3540,7 @@ private Request req(String method, String url) { } private Request req(String method, Uri template, String contentType, byte[] body) { - RequestBuilder builder = reqBuilder(method, template.toString()); - builder.addHeader("Content-type", contentType); - builder.setBody(body); - return builder.build(); + return reqBuilder(method, template, contentType, body).build(); } private RequestBuilder reqBuilder(String method, String url) { @@ -3547,17 +3554,19 @@ private RequestBuilder reqBuilder(String method, String url) { return builder.setUrl(url); } + private RequestBuilder reqBuilder(String method, Uri url, String contentType, byte[] body) { + return reqBuilder(method, url.toString()) + .addHeader("Content-type", contentType) + .setBody(body); + } + protected ZendeskAsyncCompletionHandler handleStatus() { return new ZendeskAsyncCompletionHandler() { @Override public Void onCompleted(Response response) throws Exception { logResponse(response); - if (isStatus2xx(response)) { - return null; - } else if (isRateLimitResponse(response)) { - throw new ZendeskResponseRateLimitException(response); - } - throw new ZendeskResponseException(response); + checkStatusCode(response); + return null; } }; } @@ -3573,15 +3582,10 @@ protected ZendeskAsyncCompletionHandler handle(ObjectReader reader) { @Override public T onCompleted(Response response) throws Exception { logResponse(response); - if (isStatus2xx(response)) { + if (checkStatusCode(response, true)) { return reader.readValue(response.getResponseBodyAsStream()); - } else if (isRateLimitResponse(response)) { - throw new ZendeskResponseRateLimitException(response); - } - if (response.getStatusCode() == 404) { - return null; } - throw new ZendeskResponseException(response); + return null; } }; } @@ -3600,7 +3604,8 @@ public BasicAsyncCompletionHandler(Class clazz, String name, Class... typeParams @Override public T onCompleted(Response response) throws Exception { logResponse(response); - if (isStatus2xx(response)) { + + if (checkStatusCode(response, true)) { if (typeParams.length > 0) { JavaType type = mapper.getTypeFactory().constructParametricType(clazz, typeParams); return mapper.convertValue( @@ -3610,19 +3615,7 @@ public T onCompleted(Response response) throws Exception { mapper.readTree(response.getResponseBodyAsStream()).get(name), clazz); } - if (isRateLimitResponse(response)) { - throw new ZendeskResponseRateLimitException(response); - } - - if (isIdempotencyConflict(response)) { - throw new ZendeskResponseIdempotencyConflictException(response); - } - - if (response.getStatusCode() == 404) { - return null; - } - - throw new ZendeskResponseException(response); + return null; } } @@ -3700,18 +3693,15 @@ public PagedAsyncListCompletionHandler(Class clazz, String name) { @Override public List onCompleted(Response response) throws Exception { logResponse(response); - if (isStatus2xx(response)) { - JsonNode responseNode = mapper.readTree(response.getResponseBodyAsBytes()); - setPagedProperties(responseNode, clazz); - List values = new ArrayList<>(); - for (JsonNode node : responseNode.get(name)) { - values.add(mapper.convertValue(node, clazz)); - } - return values; - } else if (isRateLimitResponse(response)) { - throw new ZendeskResponseRateLimitException(response); + checkStatusCode(response); + + JsonNode responseNode = mapper.readTree(response.getResponseBodyAsBytes()); + setPagedProperties(responseNode, clazz); + List values = new ArrayList<>(); + for (JsonNode node : responseNode.get(name)) { + values.add(mapper.convertValue(node, clazz)); } - throw new ZendeskResponseException(response); + return values; } } @@ -3787,22 +3777,19 @@ protected PagedAsyncCompletionHandler> handleSearchList @Override public List onCompleted(Response response) throws Exception { logResponse(response); - if (isStatus2xx(response)) { - JsonNode responseNode = mapper.readTree(response.getResponseBodyAsStream()).get(name); - setPagedProperties(responseNode, null); - List values = new ArrayList<>(); - for (JsonNode node : responseNode) { - Class clazz = - searchResultTypes.get(node.get("result_type").asText()); - if (clazz != null) { - values.add(mapper.convertValue(node, clazz)); - } + checkStatusCode(response); + + JsonNode responseNode = mapper.readTree(response.getResponseBodyAsStream()).get(name); + setPagedProperties(responseNode, null); + List values = new ArrayList<>(); + for (JsonNode node : responseNode) { + Class clazz = + searchResultTypes.get(node.get("result_type").asText()); + if (clazz != null) { + values.add(mapper.convertValue(node, clazz)); } - return values; - } else if (isRateLimitResponse(response)) { - throw new ZendeskResponseRateLimitException(response); } - throw new ZendeskResponseException(response); + return values; } }; } @@ -3812,21 +3799,18 @@ protected PagedAsyncCompletionHandler> handleTargetList(final Strin @Override public List onCompleted(Response response) throws Exception { logResponse(response); - if (isStatus2xx(response)) { - JsonNode responseNode = mapper.readTree(response.getResponseBodyAsBytes()); - setPagedProperties(responseNode, null); - List values = new ArrayList<>(); - for (JsonNode node : responseNode.get(name)) { - Class clazz = targetTypes.get(node.get("type").asText()); - if (clazz != null) { - values.add(mapper.convertValue(node, clazz)); - } + checkStatusCode(response); + + JsonNode responseNode = mapper.readTree(response.getResponseBodyAsBytes()); + setPagedProperties(responseNode, null); + List values = new ArrayList<>(); + for (JsonNode node : responseNode.get(name)) { + Class clazz = targetTypes.get(node.get("type").asText()); + if (clazz != null) { + values.add(mapper.convertValue(node, clazz)); } - return values; - } else if (isRateLimitResponse(response)) { - throw new ZendeskResponseRateLimitException(response); } - throw new ZendeskResponseException(response); + return values; } }; } @@ -3837,17 +3821,14 @@ protected PagedAsyncCompletionHandler> handleArticleAtt @Override public List onCompleted(Response response) throws Exception { logResponse(response); - if (isStatus2xx(response)) { - JsonNode responseNode = mapper.readTree(response.getResponseBodyAsBytes()); - List values = new ArrayList<>(); - for (JsonNode node : responseNode.get(name)) { - values.add(mapper.convertValue(node, ArticleAttachments.class)); - } - return values; - } else if (isRateLimitResponse(response)) { - throw new ZendeskResponseRateLimitException(response); + checkStatusCode(response); + + JsonNode responseNode = mapper.readTree(response.getResponseBodyAsBytes()); + List values = new ArrayList<>(); + for (JsonNode node : responseNode.get(name)) { + values.add(mapper.convertValue(node, ArticleAttachments.class)); } - throw new ZendeskResponseException(response); + return values; } }; } @@ -3914,7 +3895,7 @@ private Uri cnst(String template) { return new FixedUri(url + template); } - private void logResponse(Response response) throws IOException { + private void logResponse(Response response) { if (logger.isDebugEnabled()) { logger.debug( "Response HTTP/{} {}\n{}", @@ -3941,22 +3922,38 @@ private static long msToSeconds(long millis) { return TimeUnit.MILLISECONDS.toSeconds(millis); } - private boolean isStatus2xx(Response response) { - return response.getStatusCode() / 100 == 2; - } + // When `notFoundMeansNull == true`, may return `false` to indicate a 404 response. + private boolean checkStatusCode(Response response, boolean notFoundMeansNull) { + int statusCode = response.getStatusCode(); - private boolean isRateLimitResponse(Response response) { - return response.getStatusCode() == 429; - } + if (200 <= statusCode && statusCode < 300) { + return true; + } - private boolean isIdempotencyConflict(Response response) throws IOException { - if (response.getStatusCode() != 400) { + if (notFoundMeansNull && statusCode == 404) { return false; } + if (statusCode == 429) { + throw new ZendeskResponseRateLimitException(response); + } + + if (isIdempotencyConflict(response)) { + throw new ZendeskResponseIdempotencyConflictException(response); + } + + // Covers status codes 1xx and 3xx, we assume that they are handled internally + // by the HTTP client. + throw new ZendeskResponseException(response); + } + + private void checkStatusCode(Response response) { + checkStatusCode(response, false); // always returns `true` + } + + private boolean isIdempotencyConflict(Response response) { try { - Map body = mapper.readValue(response.getResponseBody(), Map.class); - return IdempotencyUtil.IDEMPOTENCY_ERROR_NAME.equals(body.get("error")); + return IdempotencyUtil.isIdempotencyConflict(response, mapper); } catch (JsonProcessingException e) { ZendeskResponseException exception = new ZendeskResponseException(response); exception.addSuppressed(e); diff --git a/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java index 195e66a9..7474ffd4 100644 --- a/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseException.java @@ -1,6 +1,5 @@ package org.zendesk.client.v2; -import java.io.IOException; import java.text.MessageFormat; import org.asynchttpclient.Response; @@ -13,7 +12,7 @@ public class ZendeskResponseException extends ZendeskException { private String statusText; private String body; - public ZendeskResponseException(Response resp) throws IOException { + public ZendeskResponseException(Response resp) { this(resp.getStatusCode(), resp.getStatusText(), resp.getResponseBody()); } diff --git a/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java index 978487f9..4b41b8fa 100644 --- a/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java @@ -1,6 +1,5 @@ package org.zendesk.client.v2; -import java.io.IOException; import org.asynchttpclient.Response; /** @@ -22,7 +21,7 @@ public class ZendeskResponseIdempotencyConflictException extends ZendeskResponse private static final long serialVersionUID = 1L; - public ZendeskResponseIdempotencyConflictException(Response res) throws IOException { + public ZendeskResponseIdempotencyConflictException(Response res) { super(res); } diff --git a/src/main/java/org/zendesk/client/v2/ZendeskResponseRateLimitException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseRateLimitException.java index c4dacb7c..fa387a01 100644 --- a/src/main/java/org/zendesk/client/v2/ZendeskResponseRateLimitException.java +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseRateLimitException.java @@ -1,6 +1,5 @@ package org.zendesk.client.v2; -import java.io.IOException; import org.asynchttpclient.Response; public class ZendeskResponseRateLimitException extends ZendeskResponseException { @@ -11,7 +10,7 @@ public class ZendeskResponseRateLimitException extends ZendeskResponseException private Long retryAfter = DEFAULT_RETRY_AFTER; - public ZendeskResponseRateLimitException(Response resp) throws IOException { + public ZendeskResponseRateLimitException(Response resp) { super(resp); try { this.retryAfter = Long.valueOf(resp.getHeader(RETRY_AFTER_HEADER)); diff --git a/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java b/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java deleted file mode 100644 index ba651921..00000000 --- a/src/main/java/org/zendesk/client/v2/model/IdempotencyState.java +++ /dev/null @@ -1,140 +0,0 @@ -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: - * - *

    - *
  • PENDING - Initial state before the request is sent - *
  • CREATED - The resource was newly created (idempotency key miss) - *
  • PREVIOUSLY_CREATED - The resource was previously created (idempotency key hit) - *
- * - * @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 deleted file mode 100644 index 483d7e1e..00000000 --- a/src/main/java/org/zendesk/client/v2/model/IdempotentEntity.java +++ /dev/null @@ -1,70 +0,0 @@ -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: - * - *

    - *
  • {@code false} if the resource was newly created (idempotency key miss) - *
  • {@code true} if the resource was previously created (idempotency key hit) - *
  • {@code null} if idempotency was not used or the API didn't return the header - *
- * - * @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/IdempotentResult.java b/src/main/java/org/zendesk/client/v2/model/IdempotentResult.java new file mode 100644 index 00000000..e2739dca --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/model/IdempotentResult.java @@ -0,0 +1,62 @@ +package org.zendesk.client.v2.model; + +/** + * Result wrapper for idempotent API operations. + * + *

Contains the response entity and a flag indicating whether the request was a duplicate. When + * using idempotency keys, the Zendesk API may return a cached response from a previous identical + * request rather than creating a new resource. + * + * @param the type of the result entity (e.g., {@link Ticket}) + * @see + * Zendesk API Idempotency + * @since 1.5.0 + */ +public class IdempotentResult { + + private final T result; + private final boolean duplicateRequest; + + /** + * Creates a new idempotent result. + * + * @param result the response entity returned by the API + * @param duplicateRequest {@code true} if this was a duplicate request (idempotency cache hit), + * {@code false} if this was a new request (idempotency cache miss) + */ + public IdempotentResult(T result, boolean duplicateRequest) { + this.result = result; + this.duplicateRequest = duplicateRequest; + } + + /** + * Returns the result entity from the API response. + * + *

This entity is returned regardless of whether the request was a duplicate or not. For + * duplicate requests, this represents the cached response from the original request. + * + * @return the response entity + */ + public T get() { + return result; + } + + /** + * Returns whether this request was identified as a duplicate. + * + *

Returns {@code true} if the Zendesk API returned a cached response (indicated by the + * {@code x-idempotency-lookup: hit} header), meaning this idempotency key was previously used + * and no new resource was created. Returns {@code false} if this was a new request that created + * a new resource (indicated by the {@code x-idempotency-lookup: miss} header). + * + *

Note: If the same idempotency key is reused with different request parameters, the + * Zendesk API will return a 400 error and a {@link + * org.zendesk.client.v2.ZendeskResponseIdempotencyConflictException} will be thrown instead of + * returning an {@code IdempotentResult}. + * + * @return {@code true} if this was a duplicate request, {@code false} otherwise + */ + public boolean isDuplicateRequest() { + return duplicateRequest; + } +} 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 04bae121..341e96b3 100644 --- a/src/main/java/org/zendesk/client/v2/model/Ticket.java +++ b/src/main/java/org/zendesk/client/v2/model/Ticket.java @@ -1,6 +1,5 @@ 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; @@ -13,7 +12,7 @@ * @since 04/04/2013 14:25 */ @JsonIgnoreProperties(ignoreUnknown = true) -public class Ticket extends Request implements SearchResultEntity, IdempotentEntity { +public class Ticket extends Request implements SearchResultEntity { private static final long serialVersionUID = -7559199410302237012L; @@ -36,8 +35,6 @@ public class Ticket extends Request implements SearchResultEntity, IdempotentEnt private Long brandId; private Boolean isPublic; private Boolean safeUpdate; - @JsonIgnore private String idempotencyKey; - @JsonIgnore private Boolean isIdempotencyHit; public Ticket() {} @@ -252,28 +249,6 @@ 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/CreateTicketIdempotentTest.java b/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java new file mode 100644 index 00000000..7470097f --- /dev/null +++ b/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java @@ -0,0 +1,195 @@ +package org.zendesk.client.v2; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +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.ObjectMapper; +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; +import com.github.tomakehurst.wiremock.junit.WireMockClassRule; +import java.time.Duration; +import java.util.Collections; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.function.Function; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.asynchttpclient.ListenableFuture; +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.IdempotentResult; +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 CreateTicketIdempotentTest { + + 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() { + verifyRequest(); + + client.close(); + client = null; + } + + @Test + public void idempotencyLookupMiss() throws JsonProcessingException { + stubPostTicket(createExpectedResponse(IdempotencyUtil.IDEMPOTENCY_LOOKUP_MISS)); + IdempotentResult result = client.createTicketIdempotent(createTicket(), TICKET_KEY); + + assertThat(result).isNotNull(); + assertThat(result.isDuplicateRequest()).isFalse(); + assertThat(result.get()).satisfies(new Consumer<>() { + @Override + public void accept(Ticket ticket) { + assertThat(ticket).isNotNull(); + assertThat(ticket.getId()).isEqualTo(TICKET_ID); + } + }); + } + + @Test + public void idempotencyLookupHit() throws JsonProcessingException { + stubPostTicket(createExpectedResponse(IdempotencyUtil.IDEMPOTENCY_LOOKUP_HIT)); + IdempotentResult result = client.createTicketIdempotent(createTicket(), TICKET_KEY); + + assertThat(result).isNotNull(); + assertThat(result.isDuplicateRequest()).isTrue(); + assertThat(result.get()).satisfies(new Consumer<>() { + @Override + public void accept(Ticket ticket) { + assertThat(ticket).isNotNull(); + assertThat(ticket.getId()).isEqualTo(TICKET_ID); + } + }); + } + + @Test + public void idempotencyLookupInvalid() throws JsonProcessingException { + stubPostTicket(createExpectedResponse("InvalidValue")); + assertThatThrownBy(new ThrowingCallable() { + @Override + public void call() { + client.createTicketIdempotent(createTicket(), TICKET_KEY); + } + }).isExactlyInstanceOf(ZendeskException.class); + } + + @Test + public void idempotencyLookupAbsent() throws JsonProcessingException { + stubPostTicket(createExpectedResponse(null)); + + assertThatThrownBy(new ThrowingCallable() { + @Override + public void call() { + client.createTicketIdempotent(createTicket(), TICKET_KEY); + } + }).isExactlyInstanceOf(ZendeskException.class); + } + + @Test + public void idempotencyConflict() throws JsonProcessingException { + stubPostTicket(aResponse() + .withStatus(400) + .withBody( + objectMapper.writeValueAsString( + Collections.singletonMap("error", IdempotencyUtil.IDEMPOTENCY_ERROR_NAME)))); + + assertThatThrownBy(new ThrowingCallable() { + @Override + public void call() { + client.createTicketIdempotent(createTicket(), TICKET_KEY); + } + }).isExactlyInstanceOf(ZendeskResponseIdempotencyConflictException.class); + } + + @Test + public void errorWithEmptyResponseBody() { + // White-box testing a known edge case where an older Jackson version would throw + // a `NullPointerException`. + stubPostTicket(aResponse() + .withStatus(400) + .withBody("")); + + ListenableFuture> future = + client.createTicketIdempotentAsync(createTicket(), TICKET_KEY); + assertThat(future.toCompletableFuture()) + .completesExceptionallyWithin(Duration.ofSeconds(5)) + .withThrowableOfType(ExecutionException.class) + .havingCause() + .isInstanceOf(ZendeskResponseException.class) + .withNoCause(); + } + + private Ticket createTicket() { + Ticket ticket = new Ticket(); + ticket.setSubject("Test Ticket"); + ticket.setComment(new Comment("This is a test ticket")); + ticket.setRequesterId(123456L); + return ticket; + } + + private ResponseDefinitionBuilder createExpectedResponse(String idempotencyLookupValue) + throws JsonProcessingException { + Ticket ticket = createTicket(); + ticket.setId(TICKET_ID); + ticket.setStatus(Status.OPEN); + String ticketJson = objectMapper.writeValueAsString(Collections.singletonMap("ticket", ticket)); + + ResponseDefinitionBuilder response = aResponse() + .withStatus(201) + .withBody(ticketJson); + + if (idempotencyLookupValue != null) { + response = response.withHeader(IdempotencyUtil.IDEMPOTENCY_LOOKUP_HEADER, idempotencyLookupValue); + } + + return response; + } + + private void stubPostTicket(ResponseDefinitionBuilder response) { + zendeskApiMock.stubFor( + post(urlEqualTo(CREATE_TICKET_PATH)) + .withHeader(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER, equalTo(TICKET_KEY)) + .willReturn(response)); + } + + private void verifyRequest() { + zendeskApiMock.verify( + postRequestedFor(urlEqualTo(CREATE_TICKET_PATH)) + .withHeader(IdempotencyUtil.IDEMPOTENCY_KEY_HEADER, equalTo(TICKET_KEY))); + } +} diff --git a/src/test/java/org/zendesk/client/v2/IdempotencyUtilTest.java b/src/test/java/org/zendesk/client/v2/IdempotencyUtilTest.java deleted file mode 100644 index 1659d1d4..00000000 --- a/src/test/java/org/zendesk/client/v2/IdempotencyUtilTest.java +++ /dev/null @@ -1,137 +0,0 @@ -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 deleted file mode 100644 index 28404488..00000000 --- a/src/test/java/org/zendesk/client/v2/TicketIdempotencyTest.java +++ /dev/null @@ -1,228 +0,0 @@ -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 deleted file mode 100644 index 2c260e01..00000000 --- a/src/test/java/org/zendesk/client/v2/model/IdempotencyStateTest.java +++ /dev/null @@ -1,201 +0,0 @@ -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 d06f48bb..c8639379 100644 --- a/src/test/java/org/zendesk/client/v2/model/TicketTest.java +++ b/src/test/java/org/zendesk/client/v2/model/TicketTest.java @@ -3,109 +3,54 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.util.StdDateFormat; +import java.util.Calendar; import java.util.Date; -import java.util.Map; +import java.util.Random; import java.util.function.Function; import org.junit.Test; import org.zendesk.client.v2.Zendesk; public class TicketTest { - 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()); + 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(); @Test public void serializeWithNullSafeUpdate() throws Exception { + ObjectMapper mapper = Zendesk.createMapper(Function.identity()); Ticket ticket = createSampleTicket(); - assertThat(OBJECT_MAPPER.writeValueAsString(ticket)) + assertThat(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(OBJECT_MAPPER.writeValueAsString(ticket)) + assertThat(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(OBJECT_MAPPER.writeValueAsString(ticket)) + assertThat(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(TICKET_ID); - ticket.setSubject(TICKET_SUBJECT); - ticket.setStatus(TICKET_STATUS); - ticket.setUpdatedAt(TICKET_TS); + ticket.setId(Math.abs(RANDOM.nextLong())); + ticket.setComment(new Comment(TICKET_COMMENT1)); + ticket.setUpdatedAt(NOW); + ticket.setCustomStatusId(Math.abs(RANDOM.nextLong())); return ticket; } } From 59c218e4aaf151007aa480b1b5d26f8fdea1059e Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Wed, 8 Apr 2026 21:22:31 +0100 Subject: [PATCH 08/16] Ran `mvn spotless:apply` --- .../zendesk/client/v2/IdempotencyUtil.java | 8 +- .../java/org/zendesk/client/v2/Zendesk.java | 18 +-- ...kResponseIdempotencyConflictException.java | 12 +- .../client/v2/model/IdempotentResult.java | 8 +- .../client/v2/CreateTicketIdempotentTest.java | 111 ++++++++++-------- 5 files changed, 79 insertions(+), 78 deletions(-) diff --git a/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java index f383a387..76b04d31 100644 --- a/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java +++ b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java @@ -50,9 +50,8 @@ public void onThrowable(Throwable t) { }; } - public static boolean isIdempotencyConflict( - Response response, - ObjectMapper mapper) throws JsonProcessingException { + public static boolean isIdempotencyConflict(Response response, ObjectMapper mapper) + throws JsonProcessingException { if (response.getStatusCode() != 400) { return false; } @@ -78,8 +77,7 @@ private static boolean isDuplicateResponse(Response response) { default: throw new IllegalArgumentException( String.format( - "Unexpected value of the idempotency lookup header: %s", - idempotencyLookup)); + "Unexpected value of the idempotency lookup header: %s", idempotencyLookup)); } } diff --git a/src/main/java/org/zendesk/client/v2/Zendesk.java b/src/main/java/org/zendesk/client/v2/Zendesk.java index 8734fd3b..0321cbc3 100644 --- a/src/main/java/org/zendesk/client/v2/Zendesk.java +++ b/src/main/java/org/zendesk/client/v2/Zendesk.java @@ -43,7 +43,6 @@ import org.jetbrains.annotations.VisibleForTesting; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.zendesk.client.v2.model.IdempotentResult; import org.zendesk.client.v2.model.AgentRole; import org.zendesk.client.v2.model.Attachment; import org.zendesk.client.v2.model.Audit; @@ -57,6 +56,7 @@ 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.IdempotentResult; import org.zendesk.client.v2.model.Identity; import org.zendesk.client.v2.model.JiraLink; import org.zendesk.client.v2.model.JobStatus; @@ -442,13 +442,11 @@ public Ticket createTicket(Ticket ticket) { return complete(createTicketAsync(ticket)); } - public ListenableFuture> createTicketIdempotentAsync(Ticket ticket, String idempotencyKey) { + public ListenableFuture> createTicketIdempotentAsync( + Ticket ticket, String idempotencyKey) { return submitIdempotent( reqBuilder( - "POST", - cnst("/tickets.json"), - JSON, - json(Collections.singletonMap("ticket", ticket))), + "POST", cnst("/tickets.json"), JSON, json(Collections.singletonMap("ticket", ticket))), handle(Ticket.class, "ticket"), idempotencyKey); } @@ -3510,9 +3508,7 @@ private ListenableFuture submit(Request request, AsyncCompletionHandler ListenableFuture> submitIdempotent( - RequestBuilder builder, - AsyncCompletionHandler handler, - String idempotencyKey) { + RequestBuilder builder, AsyncCompletionHandler handler, String idempotencyKey) { Request request = IdempotencyUtil.addIdempotencyHeader(builder, idempotencyKey).build(); AsyncCompletionHandler> idempotentHandler = IdempotencyUtil.wrapHandler(handler); @@ -3555,9 +3551,7 @@ private RequestBuilder reqBuilder(String method, String url) { } private RequestBuilder reqBuilder(String method, Uri url, String contentType, byte[] body) { - return reqBuilder(method, url.toString()) - .addHeader("Content-type", contentType) - .setBody(body); + return reqBuilder(method, url.toString()).addHeader("Content-type", contentType).setBody(body); } protected ZendeskAsyncCompletionHandler handleStatus() { diff --git a/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java index 4b41b8fa..2ec8884f 100644 --- a/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java @@ -5,13 +5,13 @@ /** * 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. + *

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. + *

To resolve this error, either use a new idempotency key or ensure the request parameters match + * the original request. * * @see * Zendesk API Idempotency diff --git a/src/main/java/org/zendesk/client/v2/model/IdempotentResult.java b/src/main/java/org/zendesk/client/v2/model/IdempotentResult.java index e2739dca..32009914 100644 --- a/src/main/java/org/zendesk/client/v2/model/IdempotentResult.java +++ b/src/main/java/org/zendesk/client/v2/model/IdempotentResult.java @@ -44,10 +44,10 @@ public T get() { /** * Returns whether this request was identified as a duplicate. * - *

Returns {@code true} if the Zendesk API returned a cached response (indicated by the - * {@code x-idempotency-lookup: hit} header), meaning this idempotency key was previously used - * and no new resource was created. Returns {@code false} if this was a new request that created - * a new resource (indicated by the {@code x-idempotency-lookup: miss} header). + *

Returns {@code true} if the Zendesk API returned a cached response (indicated by the {@code + * x-idempotency-lookup: hit} header), meaning this idempotency key was previously used and no new + * resource was created. Returns {@code false} if this was a new request that created a new + * resource (indicated by the {@code x-idempotency-lookup: miss} header). * *

Note: If the same idempotency key is reused with different request parameters, the * Zendesk API will return a 400 error and a {@link diff --git a/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java b/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java index 7470097f..8b255911 100644 --- a/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java +++ b/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java @@ -31,8 +31,8 @@ import org.zendesk.client.v2.model.Ticket; /** - * Integration tests for ticket creation with idempotency key support. - * Uses WireMock to simulate Zendesk API responses. + * Integration tests for ticket creation with idempotency key support. Uses WireMock to simulate + * Zendesk API responses. */ public class CreateTicketIdempotentTest { @@ -51,10 +51,11 @@ public class CreateTicketIdempotentTest { @Before public void setUp() { - client = new Zendesk.Builder("http://localhost:" + zendeskApiMock.port()) - .setUsername("zana@example.com") - .setToken("still-sane-exile") - .build(); + client = + new Zendesk.Builder("http://localhost:" + zendeskApiMock.port()) + .setUsername("zana@example.com") + .setToken("still-sane-exile") + .build(); } @After @@ -72,13 +73,15 @@ public void idempotencyLookupMiss() throws JsonProcessingException { assertThat(result).isNotNull(); assertThat(result.isDuplicateRequest()).isFalse(); - assertThat(result.get()).satisfies(new Consumer<>() { - @Override - public void accept(Ticket ticket) { - assertThat(ticket).isNotNull(); - assertThat(ticket.getId()).isEqualTo(TICKET_ID); - } - }); + assertThat(result.get()) + .satisfies( + new Consumer<>() { + @Override + public void accept(Ticket ticket) { + assertThat(ticket).isNotNull(); + assertThat(ticket.getId()).isEqualTo(TICKET_ID); + } + }); } @Test @@ -88,61 +91,68 @@ public void idempotencyLookupHit() throws JsonProcessingException { assertThat(result).isNotNull(); assertThat(result.isDuplicateRequest()).isTrue(); - assertThat(result.get()).satisfies(new Consumer<>() { - @Override - public void accept(Ticket ticket) { - assertThat(ticket).isNotNull(); - assertThat(ticket.getId()).isEqualTo(TICKET_ID); - } - }); + assertThat(result.get()) + .satisfies( + new Consumer<>() { + @Override + public void accept(Ticket ticket) { + assertThat(ticket).isNotNull(); + assertThat(ticket.getId()).isEqualTo(TICKET_ID); + } + }); } @Test public void idempotencyLookupInvalid() throws JsonProcessingException { stubPostTicket(createExpectedResponse("InvalidValue")); - assertThatThrownBy(new ThrowingCallable() { - @Override - public void call() { - client.createTicketIdempotent(createTicket(), TICKET_KEY); - } - }).isExactlyInstanceOf(ZendeskException.class); + assertThatThrownBy( + new ThrowingCallable() { + @Override + public void call() { + client.createTicketIdempotent(createTicket(), TICKET_KEY); + } + }) + .isExactlyInstanceOf(ZendeskException.class); } @Test public void idempotencyLookupAbsent() throws JsonProcessingException { stubPostTicket(createExpectedResponse(null)); - assertThatThrownBy(new ThrowingCallable() { - @Override - public void call() { - client.createTicketIdempotent(createTicket(), TICKET_KEY); - } - }).isExactlyInstanceOf(ZendeskException.class); + assertThatThrownBy( + new ThrowingCallable() { + @Override + public void call() { + client.createTicketIdempotent(createTicket(), TICKET_KEY); + } + }) + .isExactlyInstanceOf(ZendeskException.class); } @Test public void idempotencyConflict() throws JsonProcessingException { - stubPostTicket(aResponse() - .withStatus(400) - .withBody( - objectMapper.writeValueAsString( - Collections.singletonMap("error", IdempotencyUtil.IDEMPOTENCY_ERROR_NAME)))); - - assertThatThrownBy(new ThrowingCallable() { - @Override - public void call() { - client.createTicketIdempotent(createTicket(), TICKET_KEY); - } - }).isExactlyInstanceOf(ZendeskResponseIdempotencyConflictException.class); + stubPostTicket( + aResponse() + .withStatus(400) + .withBody( + objectMapper.writeValueAsString( + Collections.singletonMap("error", IdempotencyUtil.IDEMPOTENCY_ERROR_NAME)))); + + assertThatThrownBy( + new ThrowingCallable() { + @Override + public void call() { + client.createTicketIdempotent(createTicket(), TICKET_KEY); + } + }) + .isExactlyInstanceOf(ZendeskResponseIdempotencyConflictException.class); } @Test public void errorWithEmptyResponseBody() { // White-box testing a known edge case where an older Jackson version would throw // a `NullPointerException`. - stubPostTicket(aResponse() - .withStatus(400) - .withBody("")); + stubPostTicket(aResponse().withStatus(400).withBody("")); ListenableFuture> future = client.createTicketIdempotentAsync(createTicket(), TICKET_KEY); @@ -169,12 +179,11 @@ private ResponseDefinitionBuilder createExpectedResponse(String idempotencyLooku ticket.setStatus(Status.OPEN); String ticketJson = objectMapper.writeValueAsString(Collections.singletonMap("ticket", ticket)); - ResponseDefinitionBuilder response = aResponse() - .withStatus(201) - .withBody(ticketJson); + ResponseDefinitionBuilder response = aResponse().withStatus(201).withBody(ticketJson); if (idempotencyLookupValue != null) { - response = response.withHeader(IdempotencyUtil.IDEMPOTENCY_LOOKUP_HEADER, idempotencyLookupValue); + response = + response.withHeader(IdempotencyUtil.IDEMPOTENCY_LOOKUP_HEADER, idempotencyLookupValue); } return response; From 6423c43098c918f5aff2ccca9a68ee9cf2c790f4 Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Wed, 8 Apr 2026 21:37:19 +0100 Subject: [PATCH 09/16] Added `AGENTS.md` with instructions to pin the Java version with `JAVA_HOME=$(/usr/libexec/java_home -v 11)` when running Maven to avoid compatibility issues. --- AGENTS.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 66 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..6bbf9d54 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# Agent Instructions for zendesk-java-client + +This document provides guidance for AI agents and developers working on this project. + +## Java Version Requirements + +This project targets **Java 11** (as specified in `pom.xml` with `maven.compiler.source` and `maven.compiler.target` set to 11). + +### Running Maven Commands + +When running Maven commands, ensure you're using Java 11 by setting `JAVA_HOME`: + +```bash +JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn +``` + +### Common Commands + +**Build the project:** +```bash +JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn clean install +``` + +**Run tests:** +```bash +JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn test +``` + +**Apply code formatting (Spotless):** +```bash +JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn spotless:apply +``` + +**Check code formatting:** +```bash +JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn spotless:check +``` + +## Code Formatting + +This project uses [Spotless](https://github.com/diffplug/spotless) with google-java-format for code formatting. + +- All Java code must be formatted before committing +- Run `mvn spotless:apply` to format code automatically + +## Project Structure + +- **Source code:** `src/main/java/org/zendesk/client/v2/` +- **Tests:** `src/test/java/org/zendesk/client/v2/` +- **Main entry point:** `Zendesk.java` - The primary API client class + +## Dependencies + +Key dependencies include: +- async-http-client for HTTP operations +- Jackson for JSON serialization/deserialization +- SLF4J for logging +- JUnit 4 for testing +- WireMock for HTTP mocking in tests + +## Enforcement Rules + +The project uses Maven Enforcer Plugin to ensure: +- Bytecode version matches Java 11 +- API compatibility with Java 8 (via animal-sniffer) \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 26d61fe20832e9daf9b5e6b1a7dbd6486866bc7f Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Wed, 8 Apr 2026 21:50:56 +0100 Subject: [PATCH 10/16] Added test cases in `RealSmokeTest` for `createTicketIdempotent` --- .../org/zendesk/client/v2/RealSmokeTest.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/test/java/org/zendesk/client/v2/RealSmokeTest.java b/src/test/java/org/zendesk/client/v2/RealSmokeTest.java index 41379baf..61a8a6b5 100644 --- a/src/test/java/org/zendesk/client/v2/RealSmokeTest.java +++ b/src/test/java/org/zendesk/client/v2/RealSmokeTest.java @@ -72,6 +72,7 @@ import org.zendesk.client.v2.model.Field; import org.zendesk.client.v2.model.Group; import org.zendesk.client.v2.model.GroupMembership; +import org.zendesk.client.v2.model.IdempotentResult; import org.zendesk.client.v2.model.Identity; import org.zendesk.client.v2.model.JobResult; import org.zendesk.client.v2.model.JobStatus; @@ -678,6 +679,78 @@ public void createDeleteTicket() throws Exception { assertThat(instance.getTicket(ticket.getId()), nullValue()); } + @Test + public void createTicketIdempotentNewRequest() throws Exception { + createClientWithTokenOrPassword(); + + String idempotencyKey = UUID.randomUUID().toString(); + Ticket t = newTestTicket(); + IdempotentResult result = instance.createTicketIdempotent(t, idempotencyKey); + + assertThat(result, notNullValue()); + assertThat(result.isDuplicateRequest(), is(false)); + + Ticket ticket = result.get(); + assertThat(ticket.getId(), notNullValue()); + + try { + Ticket t2 = instance.getTicket(ticket.getId()); + assertThat(t2, notNullValue()); + assertThat(t2.getId(), is(ticket.getId())); + } finally { + instance.deleteTicket(ticket.getId()); + } + } + + @Test + public void createTicketIdempotentDuplicateRequest() throws Exception { + createClientWithTokenOrPassword(); + + String idempotencyKey = UUID.randomUUID().toString(); + Ticket t = newTestTicket(); + + IdempotentResult result1 = instance.createTicketIdempotent(t, idempotencyKey); + assertThat(result1, notNullValue()); + assertThat(result1.isDuplicateRequest(), is(false)); + + Ticket ticket1 = result1.get(); + assertThat(ticket1.getId(), notNullValue()); + + try { + IdempotentResult result2 = instance.createTicketIdempotent(t, idempotencyKey); + assertThat(result2, notNullValue()); + assertThat(result2.isDuplicateRequest(), is(true)); + + Ticket ticket2 = result2.get(); + assertThat(ticket2.getId(), is(ticket1.getId())); + } finally { + instance.deleteTicket(ticket1.getId()); + } + } + + @Test + public void createTicketIdempotentConflict() throws Exception { + createClientWithTokenOrPassword(); + + String idempotencyKey = UUID.randomUUID().toString(); + Ticket t1 = newTestTicket(); + + IdempotentResult result1 = instance.createTicketIdempotent(t1, idempotencyKey); + assertThat(result1, notNullValue()); + + Ticket ticket1 = result1.get(); + assertThat(ticket1.getId(), notNullValue()); + + try { + Ticket t2 = newTestTicket(); + assertThrows( + ZendeskResponseIdempotencyConflictException.class, + () -> instance.createTicketIdempotent(t2, idempotencyKey)); + } finally { + instance.deleteTicket(ticket1.getId()); + } + } + // https://github.com/cloudbees/zendesk-java-client/issues/94 @Test public void createTaskTicketWithDueDate() throws Exception { From 13489db16cf4a76b129dbb72ca693fbb49ed02d1 Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Wed, 8 Apr 2026 22:04:57 +0100 Subject: [PATCH 11/16] [Claude] Modernised the code for Java 11 --- AGENTS.md | 23 +++++++++- .../client/v2/CreateTicketIdempotentTest.java | 44 ++++--------------- 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6bbf9d54..e56087b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,22 @@ This document provides guidance for AI agents and developers working on this pro ## Java Version Requirements -This project targets **Java 11** (as specified in `pom.xml` with `maven.compiler.source` and `maven.compiler.target` set to 11). +This project **compiles with Java 11** (as specified in `pom.xml` with `maven.compiler.source` and `maven.compiler.target` set to 11) but **must maintain Java 8 API compatibility**. + +### CRITICAL: Java 8 API Compatibility + +**The project enforces Java 8 API compatibility via animal-sniffer, even though it compiles with Java 11.** + +This means: +- ✅ **Allowed:** Java 8 language features (lambdas, method references, streams, Optional, etc.) +- ✅ **Allowed:** Java 11 compiler and build tools +- ❌ **NOT Allowed:** APIs added in Java 9+ (e.g., `Objects.requireNonNullElse()`, `List.of()`, `Map.of()`, `String.isBlank()`, etc.) + +When modernizing code or adding features: +1. Use Java 8 language syntax features freely (lambdas, streams, etc.) +2. Only use APIs available in Java 8 (check the Javadoc version!) +3. The animal-sniffer plugin will fail the build if you use Java 9+ APIs +4. If in doubt, verify API availability at https://docs.oracle.com/javase/8/docs/api/ ### Running Maven Commands @@ -62,4 +77,8 @@ Key dependencies include: The project uses Maven Enforcer Plugin to ensure: - Bytecode version matches Java 11 -- API compatibility with Java 8 (via animal-sniffer) \ No newline at end of file +- **API compatibility with Java 8 (via animal-sniffer)** - This is enforced at build time and will fail if Java 9+ APIs are used + +### Why Java 8 API Compatibility? + +This library is designed to be usable by applications running on Java 8 JVMs, even though it's built with Java 11 tooling. This is a common pattern for libraries that want maximum compatibility while benefiting from modern build tools. \ No newline at end of file diff --git a/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java b/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java index 8b255911..9a1dce2f 100644 --- a/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java +++ b/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java @@ -16,9 +16,7 @@ import java.time.Duration; import java.util.Collections; import java.util.concurrent.ExecutionException; -import java.util.function.Consumer; import java.util.function.Function; -import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.asynchttpclient.ListenableFuture; import org.junit.After; import org.junit.Before; @@ -75,12 +73,9 @@ public void idempotencyLookupMiss() throws JsonProcessingException { assertThat(result.isDuplicateRequest()).isFalse(); assertThat(result.get()) .satisfies( - new Consumer<>() { - @Override - public void accept(Ticket ticket) { - assertThat(ticket).isNotNull(); - assertThat(ticket.getId()).isEqualTo(TICKET_ID); - } + ticket -> { + assertThat(ticket).isNotNull(); + assertThat(ticket.getId()).isEqualTo(TICKET_ID); }); } @@ -93,25 +88,16 @@ public void idempotencyLookupHit() throws JsonProcessingException { assertThat(result.isDuplicateRequest()).isTrue(); assertThat(result.get()) .satisfies( - new Consumer<>() { - @Override - public void accept(Ticket ticket) { - assertThat(ticket).isNotNull(); - assertThat(ticket.getId()).isEqualTo(TICKET_ID); - } + ticket -> { + assertThat(ticket).isNotNull(); + assertThat(ticket.getId()).isEqualTo(TICKET_ID); }); } @Test public void idempotencyLookupInvalid() throws JsonProcessingException { stubPostTicket(createExpectedResponse("InvalidValue")); - assertThatThrownBy( - new ThrowingCallable() { - @Override - public void call() { - client.createTicketIdempotent(createTicket(), TICKET_KEY); - } - }) + assertThatThrownBy(() -> client.createTicketIdempotent(createTicket(), TICKET_KEY)) .isExactlyInstanceOf(ZendeskException.class); } @@ -119,13 +105,7 @@ public void call() { public void idempotencyLookupAbsent() throws JsonProcessingException { stubPostTicket(createExpectedResponse(null)); - assertThatThrownBy( - new ThrowingCallable() { - @Override - public void call() { - client.createTicketIdempotent(createTicket(), TICKET_KEY); - } - }) + assertThatThrownBy(() -> client.createTicketIdempotent(createTicket(), TICKET_KEY)) .isExactlyInstanceOf(ZendeskException.class); } @@ -138,13 +118,7 @@ public void idempotencyConflict() throws JsonProcessingException { objectMapper.writeValueAsString( Collections.singletonMap("error", IdempotencyUtil.IDEMPOTENCY_ERROR_NAME)))); - assertThatThrownBy( - new ThrowingCallable() { - @Override - public void call() { - client.createTicketIdempotent(createTicket(), TICKET_KEY); - } - }) + assertThatThrownBy(() -> client.createTicketIdempotent(createTicket(), TICKET_KEY)) .isExactlyInstanceOf(ZendeskResponseIdempotencyConflictException.class); } From 5f361ee8d21a2b65c54a492e16f3282bc1931b18 Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Wed, 8 Apr 2026 22:58:27 +0100 Subject: [PATCH 12/16] Updated the example in README.md --- README.md | 54 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d940afc8..664d9d4e 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,19 @@ 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 idempotent ticket creation via `createTicketIdempotent` and `createTicketIdempotentAsync`. +Either method may throw a `ZendeskResponseIdempotencyConflictException` if the same idempotency key +is used in two requests with non-identical payloads. ### Usage Example +The following example illustrates a usage pattern for publishing updates to a Zendesk ticket +that tracks some application specific issue. It ensures that only one ticket is created per +issue, even if multiple updates are published concurrently for the same issue, or if the update is +retried due to a transient failure after the ticket has already been created. + +Note that it's intentionally slightly more complicated than it would probably be in real life +so that we can demonstrate non-trivial handling of a `ZendeskResponseIdempotencyConflictException`. + ```java class FooIssueService { private final Zendesk zendesk; @@ -50,31 +60,43 @@ class FooIssueService { // ... - public void postIssueUpdate(FooIssue issue, String update) { - Ticket ticket = new Ticket(issue.getRequesterId(), issue.getTitle(), new Comment(update)); - - // Must map 1-to-1 with the issue, so that retries for the same issue use the same key - String idempotencyKey = String.format("issue-%s", issue.getId()); + public void postIssueUpdate(FooIssue issue, String update) { + // Fast path pre-check, would be unsafe without idempotency b/c TOCTOU. + Optional optTicketId = issueRepository.getTicketId(issue.getId()); + if (optTicketId.isPresent()) { + postIssueComment(optTicketId.get(), update); + return; + } + + // Must map the issue 1-to-1, so that retries for the same issue use the same key. + String idempotencyKey = String.format("foo-issue-%s", issue.getId()); try { - // 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. - result = zendesk.createTicketIdempotent(ticket, idempotencyKey); - ticket = result.get(); + issueRepository.reserveTicketId(issue.getId()); + IdempotentResult result = zendesk.createTicketIdempotent( + new Ticket(issue.getRequesterId(), issue.getTitle(), new Comment(update)), + idempotencyKey); + if (!result.isDuplicateRequest()) { + Ticket ticket = result.get(); 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); + // We assume that `getTicketId` will retry internally if the reservation is still + // fresh and the ticket id has not yet been saved in order to limit potential + // race conditions. + long existingTicketId = issueRepository.getTicketId(issue.getId()).orElseThrow( + () -> new IllegalStateException( + String.format("Existing ticket not found for issue %s", issue.getId()), e)); + postIssueComment(existingTicketId, update); } } + + private void postIssueComment(long ticketId, String update) { + Comment comment = zendesk.createComment(ticketId, new Comment(update)); + logger.info("Added comment (id = {}) to ticket (id = {})", comment.getId(), ticketId); + } } ``` From 141bfbfa521ca6fa41e15b377367374ee5225a1c Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Thu, 9 Apr 2026 12:57:50 +0100 Subject: [PATCH 13/16] Changed the example in README.md to be completely stateless --- README.md | 76 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 664d9d4e..c4291f2c 100644 --- a/README.md +++ b/README.md @@ -49,54 +49,76 @@ that tracks some application specific issue. It ensures that only one ticket is issue, even if multiple updates are published concurrently for the same issue, or if the update is retried due to a transient failure after the ticket has already been created. -Note that it's intentionally slightly more complicated than it would probably be in real life -so that we can demonstrate non-trivial handling of a `ZendeskResponseIdempotencyConflictException`. - ```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) { + + // Simple use case: the ticket payload depends only on the issue itself + public void postIssueUpdateSimple(FooIssue issue, String update) { + IdempotentResult result = zendesk.createTicketIdempotent( + toTicketSimple(issue), + toIdempotencyKey(issue)); + + if (!result.isDuplicateRequest()) { + logger.info("Created new ticket (id = {})", result.get().getId()); + } + + postIssueComment(result.get().getId(), update); + } + + // Advanced use case: the ticket payload depends on the update + public void postIssueUpdateAdvanced(FooIssue issue, String update) { // Fast path pre-check, would be unsafe without idempotency b/c TOCTOU. - Optional optTicketId = issueRepository.getTicketId(issue.getId()); - if (optTicketId.isPresent()) { - postIssueComment(optTicketId.get(), update); + Optional optTicket = findTicket(issue); + if (optTicket.isPresent()) { + postIssueComment(optTicket.get().getId(), update); return; } - // Must map the issue 1-to-1, so that retries for the same issue use the same key. - String idempotencyKey = String.format("foo-issue-%s", issue.getId()); - try { - issueRepository.reserveTicketId(issue.getId()); IdempotentResult result = zendesk.createTicketIdempotent( - new Ticket(issue.getRequesterId(), issue.getTitle(), new Comment(update)), - idempotencyKey); + toTicket(issue, update), + toIdempotencyKey(issue)); if (!result.isDuplicateRequest()) { - Ticket ticket = result.get(); - issueRepository.saveTicketId(issue.getId(), ticket.getId()); - logger.info("Created new ticket (id = {})", ticket.getId()); + logger.info("Created new ticket (id = {})", result.get().getId()); } } catch (ZendeskResponseIdempotencyConflictException e) { - // We assume that `getTicketId` will retry internally if the reservation is still - // fresh and the ticket id has not yet been saved in order to limit potential - // race conditions. - long existingTicketId = issueRepository.getTicketId(issue.getId()).orElseThrow( - () -> new IllegalStateException( - String.format("Existing ticket not found for issue %s", issue.getId()), e)); - postIssueComment(existingTicketId, update); + Ticket ticket = findTicket(issue).orElseThrow( + () -> new IllegalStateException( + String.format("Ticket not found for issue %s", issue.getId()), e)); + postIssueComment(ticket.getId(), update); } } + private static void toTicketSimple(FooIssue issue) { + return toTicketAdvanced(issue, "See comments for details"); + } + + private static void toTicketAdvanced(FooIssue issue, String update) { + Ticket ticket = new Ticket(issue.getRequesterId(), issue.getTitle(), new Comment(update)); + ticket.setExternalId(toIdempotencyKey(issue)); + return ticket; + } + + private static String toIdempotencyKey(FooIssue issue) { + // Must map the issue 1-to-1, so that retries for the same issue use the same key. + return String.format("foo-issue-%s", issue.getId()); + } + private void postIssueComment(long ticketId, String update) { Comment comment = zendesk.createComment(ticketId, new Comment(update)); logger.info("Added comment (id = {}) to ticket (id = {})", comment.getId(), ticketId); } + + private Optional findTicket(FooIssue issue) { + Iterator ticketsIt = zendesk.getTicketsByExternalId(issue.getId()).iterator(); + return ticketsIt.hasNext() + ? Optional.of(ticketsIt.next()) + : Optional.empty(); + } } ``` From 62564ac9143fd34a68d87618f1a0e90adcfdd478 Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Thu, 9 Apr 2026 13:01:07 +0100 Subject: [PATCH 14/16] Fixed a typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c4291f2c..674acd87 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ class FooIssueService { try { IdempotentResult result = zendesk.createTicketIdempotent( - toTicket(issue, update), + toTicketAdvanced(issue, update), toIdempotencyKey(issue)); if (!result.isDuplicateRequest()) { From e06722867cf9f3ab6a04064bc6fb233090c62a53 Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Thu, 9 Apr 2026 13:10:43 +0100 Subject: [PATCH 15/16] Fixed the example in `README.md` (this time for sure) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 674acd87..45d1c412 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,11 @@ class FooIssueService { } } - private static void toTicketSimple(FooIssue issue) { + private static Ticket toTicketSimple(FooIssue issue) { return toTicketAdvanced(issue, "See comments for details"); } - private static void toTicketAdvanced(FooIssue issue, String update) { + private static Ticket toTicketAdvanced(FooIssue issue, String update) { Ticket ticket = new Ticket(issue.getRequesterId(), issue.getTitle(), new Comment(update)); ticket.setExternalId(toIdempotencyKey(issue)); return ticket; From 2ff644600f115feaa5125bb65c13d96f462acc1a Mon Sep 17 00:00:00 2001 From: Aleksei Averchenko Date: Mon, 13 Apr 2026 11:29:12 +0100 Subject: [PATCH 16/16] Fixed issues in `AGENTS.md` --- AGENTS.md | 55 +++++++++++++++++-------------------------------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e56087b0..ba392610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,56 +4,45 @@ This document provides guidance for AI agents and developers working on this pro ## Java Version Requirements -This project **compiles with Java 11** (as specified in `pom.xml` with `maven.compiler.source` and `maven.compiler.target` set to 11) but **must maintain Java 8 API compatibility**. +This project **compiles with Java 11** (as specified in `pom.xml` with `maven.compiler.source` and +`maven.compiler.target` set to 11) but **must maintain Java 8 API compatibility**. -### CRITICAL: Java 8 API Compatibility - -**The project enforces Java 8 API compatibility via animal-sniffer, even though it compiles with Java 11.** - -This means: -- ✅ **Allowed:** Java 8 language features (lambdas, method references, streams, Optional, etc.) -- ✅ **Allowed:** Java 11 compiler and build tools -- ❌ **NOT Allowed:** APIs added in Java 9+ (e.g., `Objects.requireNonNullElse()`, `List.of()`, `Map.of()`, `String.isBlank()`, etc.) - -When modernizing code or adding features: -1. Use Java 8 language syntax features freely (lambdas, streams, etc.) -2. Only use APIs available in Java 8 (check the Javadoc version!) -3. The animal-sniffer plugin will fail the build if you use Java 9+ APIs -4. If in doubt, verify API availability at https://docs.oracle.com/javase/8/docs/api/ +For example, you're allowed to use Java 11 compiler features in the code base (such as type inference +with `var`) but not any new standard library features introduced after Java 8, such as `VarHandle`. +This is enforced at build time using the `animal-sniffer` enforcer plugin. ### Running Maven Commands -When running Maven commands, ensure you're using Java 11 by setting `JAVA_HOME`: - -```bash -JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn -``` +**NB:** When running Maven commands, ensure you're using the Java 11 version of the JDK to avoid +any build issues and ensure compatibility. The precise way to do so depends on developer machine +setup. ### Common Commands **Build the project:** ```bash -JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn clean install +mvn verify ``` **Run tests:** ```bash -JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn test +mvn test ``` -**Apply code formatting (Spotless):** +**Apply code formatting:** ```bash -JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn spotless:apply +mvn spotless:apply ``` -**Check code formatting:** +**Check code formatting without applying changes:** ```bash -JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn spotless:check +mvn spotless:check ``` ## Code Formatting -This project uses [Spotless](https://github.com/diffplug/spotless) with google-java-format for code formatting. +This project uses [Spotless](https://github.com/diffplug/spotless) with google-java-format for code +formatting. - All Java code must be formatted before committing - Run `mvn spotless:apply` to format code automatically @@ -71,14 +60,4 @@ Key dependencies include: - Jackson for JSON serialization/deserialization - SLF4J for logging - JUnit 4 for testing -- WireMock for HTTP mocking in tests - -## Enforcement Rules - -The project uses Maven Enforcer Plugin to ensure: -- Bytecode version matches Java 11 -- **API compatibility with Java 8 (via animal-sniffer)** - This is enforced at build time and will fail if Java 9+ APIs are used - -### Why Java 8 API Compatibility? - -This library is designed to be usable by applications running on Java 8 JVMs, even though it's built with Java 11 tooling. This is a common pattern for libraries that want maximum compatibility while benefiting from modern build tools. \ No newline at end of file +- WireMock for HTTP mocking in tests \ No newline at end of file