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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions CLAUDE.md
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ticket> 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<Ticket> optTicket = findTicket(issue);
if (optTicket.isPresent()) {
postIssueComment(optTicket.get().getId(), update);
return;
}

try {
IdempotentResult<Ticket> 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<Ticket> findTicket(FooIssue issue) {
Iterator<Ticket> ticketsIt = zendesk.getTicketsByExternalId(issue.getId()).iterator();
return ticketsIt.hasNext()
? Optional.of(ticketsIt.next())
: Optional.empty();
}
}
```

Community
-------------

Expand Down
87 changes: 87 additions & 0 deletions src/main/java/org/zendesk/client/v2/IdempotencyUtil.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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 <a href="https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency">
* Zendesk API Idempotency</a>
* @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 <T> AsyncCompletionHandler<IdempotentResult<T>> wrapHandler(
AsyncCompletionHandler<T> handler) {
return new AsyncCompletionHandler<>() {
@Override
public IdempotentResult<T> 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 = "<absent>";
}

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");
}
}
Loading