diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ba392610 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# Agent Instructions for zendesk-java-client + +This document provides guidance for AI agents and developers working on this project. + +## 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**. + +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 + +**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 +mvn verify +``` + +**Run tests:** +```bash +mvn test +``` + +**Apply code formatting:** +```bash +mvn spotless:apply +``` + +**Check code formatting without applying changes:** +```bash +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 \ 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 diff --git a/README.md b/README.md index a6c4bd8e..45d1c412 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,95 @@ 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 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. + +```java +class FooIssueService { + + private final Zendesk zendesk; + private final Logger logger = LoggerFactory.getLogger(FooIssueService.class); + + // 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 optTicket = findTicket(issue); + if (optTicket.isPresent()) { + postIssueComment(optTicket.get().getId(), update); + return; + } + + try { + IdempotentResult result = zendesk.createTicketIdempotent( + toTicketAdvanced(issue, update), + toIdempotencyKey(issue)); + + if (!result.isDuplicateRequest()) { + logger.info("Created new ticket (id = {})", result.get().getId()); + } + } catch (ZendeskResponseIdempotencyConflictException e) { + Ticket ticket = findTicket(issue).orElseThrow( + () -> new IllegalStateException( + String.format("Ticket not found for issue %s", issue.getId()), e)); + postIssueComment(ticket.getId(), update); + } + } + + private static Ticket toTicketSimple(FooIssue issue) { + return toTicketAdvanced(issue, "See comments for details"); + } + + 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; + } + + 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(); + } +} +``` + Community ------------- 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..76b04d31 --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/IdempotencyUtil.java @@ -0,0 +1,87 @@ +package org.zendesk.client.v2; + +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.RequestBuilder; +import org.asynchttpclient.Response; +import org.zendesk.client.v2.model.IdempotentResult; + +/** + * Utility class for handling Zendesk API idempotency keys. + * + *

Provides methods to add idempotency headers to requests and process idempotency-related + * response headers. Supports the Zendesk API's idempotency feature which allows safe retries of + * create operations without creating duplicate resources. + * + * @see + * Zendesk API Idempotency + * @since 1.5.0 + */ +public class IdempotencyUtil { + + static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; + static final String IDEMPOTENCY_LOOKUP_HEADER = "x-idempotency-lookup"; + static final String IDEMPOTENCY_LOOKUP_HIT = "hit"; + static final String IDEMPOTENCY_LOOKUP_MISS = "miss"; + static final String IDEMPOTENCY_ERROR_NAME = "IdempotentRequestError"; + + public static RequestBuilder addIdempotencyHeader(RequestBuilder builder, String idempotencyKey) { + // https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency + return builder.setHeader(IDEMPOTENCY_KEY_HEADER, idempotencyKey); + } + + public static AsyncCompletionHandler> wrapHandler( + AsyncCompletionHandler handler) { + return new AsyncCompletionHandler<>() { + @Override + public IdempotentResult onCompleted(Response response) throws Exception { + T entity = handler.onCompleted(response); + boolean duplicateRequest = isDuplicateResponse(response); + + return new IdempotentResult<>(entity, duplicateRequest); + } + + @Override + public void onThrowable(Throwable t) { + handler.onThrowable(t); + } + }; + } + + 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) { + idempotencyLookup = ""; + } + + switch (idempotencyLookup) { + case IDEMPOTENCY_LOOKUP_HIT: + return true; + case IDEMPOTENCY_LOOKUP_MISS: + return false; + default: + throw new IllegalArgumentException( + String.format( + "Unexpected value of the idempotency lookup header: %s", idempotencyLookup)); + } + } + + 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..0321cbc3 100644 --- a/src/main/java/org/zendesk/client/v2/Zendesk.java +++ b/src/main/java/org/zendesk/client/v2/Zendesk.java @@ -56,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; @@ -441,6 +442,19 @@ 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)); } @@ -3474,8 +3488,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 +3507,15 @@ private ListenableFuture submit( return client.executeRequest(request, handler); } + private ListenableFuture> submitIdempotent( + RequestBuilder builder, AsyncCompletionHandler handler, 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 { @Override public void onThrowable(Throwable t) { @@ -3514,10 +3536,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) { @@ -3531,17 +3550,17 @@ 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; } }; } @@ -3557,15 +3576,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; } }; } @@ -3584,7 +3598,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( @@ -3592,13 +3607,9 @@ public T onCompleted(Response response) throws Exception { } return mapper.convertValue( mapper.readTree(response.getResponseBodyAsStream()).get(name), clazz); - } else if (isRateLimitResponse(response)) { - throw new ZendeskResponseRateLimitException(response); - } - if (response.getStatusCode() == 404) { - return null; } - throw new ZendeskResponseException(response); + + return null; } } @@ -3676,18 +3687,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; } } @@ -3763,22 +3771,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; } }; } @@ -3788,21 +3793,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; } }; } @@ -3813,17 +3815,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; } }; } @@ -3890,7 +3889,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{}", @@ -3917,12 +3916,43 @@ 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(); + + if (200 <= statusCode && statusCode < 300) { + return true; + } + + 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 boolean isRateLimitResponse(Response response) { - return response.getStatusCode() == 429; + private void checkStatusCode(Response response) { + checkStatusCode(response, false); // always returns `true` + } + + private boolean isIdempotencyConflict(Response response) { + try { + return IdempotencyUtil.isIdempotencyConflict(response, mapper); + } catch (JsonProcessingException e) { + ZendeskResponseException exception = new ZendeskResponseException(response); + exception.addSuppressed(e); + throw exception; + } } ////////////////////////////////////////////////////////////////////// @@ -3935,14 +3965,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..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 new file mode 100644 index 00000000..2ec8884f --- /dev/null +++ b/src/main/java/org/zendesk/client/v2/ZendeskResponseIdempotencyConflictException.java @@ -0,0 +1,37 @@ +package org.zendesk.client.v2; + +import org.asynchttpclient.Response; + +/** + * Exception thrown when the Zendesk API returns an idempotency conflict error. + * + *

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

To resolve this error, either use a new idempotency key or ensure the request parameters match + * the original request. + * + * @see + * Zendesk API Idempotency + * @since 1.5.0 + */ +public class ZendeskResponseIdempotencyConflictException extends ZendeskResponseException { + + private static final long serialVersionUID = 1L; + + public ZendeskResponseIdempotencyConflictException(Response res) { + super(res); + } + + public ZendeskResponseIdempotencyConflictException( + int statusCode, String statusText, String body) { + super(statusCode, statusText, body); + } + + public ZendeskResponseIdempotencyConflictException( + ZendeskResponseIdempotencyConflictException cause) { + super(cause); + } +} diff --git a/src/main/java/org/zendesk/client/v2/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/IdempotentResult.java b/src/main/java/org/zendesk/client/v2/model/IdempotentResult.java new file mode 100644 index 00000000..32009914 --- /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/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java b/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java new file mode 100644 index 00000000..9a1dce2f --- /dev/null +++ b/src/test/java/org/zendesk/client/v2/CreateTicketIdempotentTest.java @@ -0,0 +1,178 @@ +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.Function; +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( + 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( + ticket -> { + assertThat(ticket).isNotNull(); + assertThat(ticket.getId()).isEqualTo(TICKET_ID); + }); + } + + @Test + public void idempotencyLookupInvalid() throws JsonProcessingException { + stubPostTicket(createExpectedResponse("InvalidValue")); + assertThatThrownBy(() -> client.createTicketIdempotent(createTicket(), TICKET_KEY)) + .isExactlyInstanceOf(ZendeskException.class); + } + + @Test + public void idempotencyLookupAbsent() throws JsonProcessingException { + stubPostTicket(createExpectedResponse(null)); + + assertThatThrownBy(() -> 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(() -> 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/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 {