Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
84 changes: 84 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 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**.

### 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

When running Maven commands, ensure you're using Java 11 by setting `JAVA_HOME`:
Comment thread
aleksei-averchenko-wise marked this conversation as resolved.
Outdated

```bash
JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn <command>
```

### Common Commands

**Build the project:**
```bash
JAVA_HOME=$(/usr/libexec/java_home -v 11) mvn clean install
Comment thread
aleksei-averchenko-wise marked this conversation as resolved.
Outdated
```

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