Merge branch 'EC-21-Official-SDKs'

Refers: Evercatch/evercatch-board#21
This commit was merged in pull request #1.
This commit is contained in:
2026-02-19 21:27:52 +00:00
19 changed files with 1615 additions and 30 deletions

13
.gitignore vendored
View File

@@ -3,6 +3,19 @@
.env.* .env.*
!.env.example !.env.example
# Java / Maven / Gradle
target/
*.class
*.jar
*.war
*.ear
.gradle/
build/
!gradle-wrapper.jar
.gradletasknamecache
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

229
README.md
View File

@@ -1,49 +1,218 @@
# 📦 Repository Name # Evercatch Java SDK
> Short one-line description of what this repository does. Official Java SDK for [Evercatch](https://evercatch.dev) — webhook infrastructure platform.
--- ---
## 🧭 Overview ## Tech Stack
Describe what this service/module is responsible for within the Evercatch platform.
---
## 🛠️ Tech Stack
| Layer | Technology | | Layer | Technology |
| :--- | :--- | | :--- | :--- |
| Language | | | Language | Java 11+ |
| Framework | — | | HTTP client | OkHttp 4 |
| Key Dependencies | — | | JSON | Gson |
| Build | Maven / Gradle |
| Spring Boot | Auto-configuration (optional) |
--- ---
## 🚀 Getting Started ## Installation
### Prerequisites ### Maven
- Docker & Docker Compose ```xml
- Node.js / Python (specify version) <dependency>
<groupId>dev.evercatch</groupId>
<artifactId>evercatch-java</artifactId>
<version>1.0.0</version>
</dependency>
```
### Local Development ### Gradle
```bash ```gradle
# Clone the repo implementation 'dev.evercatch:evercatch-java:1.0.0'
git clone https://git.psmattas.com/Evercatch/REPO_NAME.git
cd REPO_NAME
# Copy environment variables
cp .env.example .env
# Start services
docker compose up -d
``` ```
--- ---
## 🌿 Branching & Commits ## Quick Start
```java
import dev.evercatch.EvercatchClient;
import dev.evercatch.model.*;
import java.util.List;
EvercatchClient client = new EvercatchClient("ec_live_abc123");
// Create a destination
Destination dest = client.createDestination(
CreateDestinationRequest.builder()
.name("Production")
.url("https://myapp.com/webhooks")
.providers(List.of("stripe", "sendgrid"))
.eventTypes(List.of("payment.*", "email.delivered"))
.build()
);
System.out.println("Created: " + dest.getId());
// List events
List<Event> events = client.listEvents(
ListEventsRequest.builder()
.provider("stripe")
.limit(50)
.build()
);
System.out.println("Events: " + events.size());
// Check usage
Usage usage = client.getUsage();
System.out.println("Plan: " + usage.getPlan());
System.out.println("Events this month: " + usage.getEventsThisMonth() + "/" + usage.getEventsLimit());
```
---
## API Reference
### Destinations
| Method | Description |
| :--- | :--- |
| `createDestination(request)` | Register a new webhook destination |
| `listDestinations()` | List all destinations |
| `getDestination(id)` | Get a destination by ID |
| `deleteDestination(id)` | Delete a destination |
### Events
| Method | Description |
| :--- | :--- |
| `listEvents(request)` | List events with optional filters |
| `getEvent(id)` | Get an event by ID |
| `replayEvent(eventId, destinationIds)` | Replay an event (Studio+ only) |
### Account
| Method | Description |
| :--- | :--- |
| `getUsage()` | Get current account usage statistics |
---
## Spring Boot Integration
Add the dependency, then set your API key in `application.properties`:
```properties
evercatch.api-key=ec_live_abc123
# Optional: override the default API base URL
# evercatch.base-url=https://api.evercatch.dev/v1
```
Or in `application.yml`:
```yaml
evercatch:
api-key: ec_live_abc123
```
An `EvercatchClient` bean is registered automatically — inject it wherever you need it:
```java
@Service
public class WebhookService {
private final EvercatchClient evercatch;
public WebhookService(EvercatchClient evercatch) {
this.evercatch = evercatch;
}
public void process(String eventId) throws EvercatchException {
Event event = evercatch.getEvent(eventId);
// ...
}
}
```
Override the auto-configured bean by declaring your own:
```java
@Bean
public EvercatchClient evercatchClient() {
return new EvercatchClient("ec_live_abc123", "https://custom-host/v1");
}
```
---
## Error Handling
All methods throw `EvercatchException` (a checked exception). The status code is available via `getStatusCode()`:
```java
try {
client.replayEvent(eventId, List.of(destId));
} catch (EvercatchException e) {
if (e.getStatusCode() == 402) {
System.out.println("Upgrade to Studio to replay events.");
} else {
throw e;
}
}
```
---
## Building from Source
```bash
# Maven
mvn clean package
# Run tests
mvn test
# Gradle
./gradlew build
# Run tests
./gradlew test
```
---
## Publishing
### Maven Central
```bash
# Build, sign, and stage
mvn clean deploy -P release
# Release from staging
mvn nexus-staging:release
```
### GitHub Packages
```bash
mvn deploy
# or
./gradlew publish
```
---
## Requirements
- Java 11 or higher
- Maven 3.6+ or Gradle 7.0+
---
## Branching & Commits
All work follows the Evercatch contribution guide defined in the org README. All work follows the Evercatch contribution guide defined in the org README.
@@ -54,7 +223,7 @@ See [Evercatch Org README](https://git.psmattas.com/Evercatch) for full conventi
--- ---
## ⚙️ CI/CD ## CI/CD
Automated via Jenkins. Merges to `main` trigger staging deployments. Automated via Jenkins. Merges to `main` trigger staging deployments.
@@ -66,7 +235,7 @@ Automated via Jenkins. Merges to `main` trigger staging deployments.
--- ---
## 📄 License ## License
**Copyright © 2026 Evercatch.** **Copyright © 2026 Evercatch.**
Proprietary and confidential. Unauthorised distribution is strictly prohibited. Proprietary and confidential. Unauthorised distribution is strictly prohibited.

101
build.gradle Normal file
View File

@@ -0,0 +1,101 @@
plugins {
id 'java-library'
id 'maven-publish'
id 'signing'
}
group = 'dev.evercatch'
version = '1.0.0'
description = 'Official Java SDK for Evercatch webhook infrastructure platform'
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
withSourcesJar()
withJavadocJar()
}
repositories {
mavenCentral()
}
ext {
okhttpVersion = '4.12.0'
gsonVersion = '2.10.1'
junitVersion = '5.10.1'
mockitoVersion = '5.8.0'
springBootVersion = '3.2.1'
}
dependencies {
// HTTP Client
api "com.squareup.okhttp3:okhttp:${okhttpVersion}"
// JSON Processing
api "com.google.code.gson:gson:${gsonVersion}"
// Spring Boot AutoConfiguration (optional — consumers pull it in themselves)
compileOnly "org.springframework.boot:spring-boot-autoconfigure:${springBootVersion}"
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor:${springBootVersion}"
// Testing
testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}"
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
testImplementation "com.squareup.okhttp3:mockwebserver:${okhttpVersion}"
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
}
publishing {
publications {
mavenJava(MavenPublication) {
from components.java
pom {
name = 'Evercatch Java SDK'
description = project.description
url = 'https://evercatch.dev'
licenses {
license {
name = 'MIT License'
url = 'https://opensource.org/licenses/MIT'
}
}
developers {
developer {
name = 'Evercatch Team'
email = 'support@evercatch.dev'
organization = 'Evercatch'
organizationUrl = 'https://evercatch.dev'
}
}
scm {
connection = 'scm:git:git://git.psmattas.com/evercatch/evercatch-java.git'
developerConnection = 'scm:git:ssh://git.psmattas.com/evercatch/evercatch-java.git'
url = 'https://git.psmattas.com/evercatch/evercatch-java'
}
}
}
}
repositories {
// Maven Central via Sonatype Central Publisher Portal
// Publishing is handled by the central-publishing-maven-plugin in pom.xml.
// Use `mvn deploy -P release` to publish.
}
}
signing {
def signingKey = findProperty('signingKey') ?: System.getenv('GPG_SIGNING_KEY')
def signingPassword = findProperty('signingPassword') ?: System.getenv('GPG_SIGNING_PASSWORD')
if (signingKey) {
useInMemoryPgpKeys(signingKey, signingPassword)
}
sign publishing.publications.mavenJava
}

180
pom.xml Normal file
View File

@@ -0,0 +1,180 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.evercatch</groupId>
<artifactId>evercatch-java</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Evercatch Java SDK</name>
<description>Official Java SDK for Evercatch webhook infrastructure platform</description>
<url>https://evercatch.dev</url>
<licenses>
<license>
<name>MIT License</name>
<url>https://opensource.org/licenses/MIT</url>
</license>
</licenses>
<developers>
<developer>
<name>Evercatch Team</name>
<email>support@evercatch.dev</email>
<organization>Evercatch</organization>
<organizationUrl>https://evercatch.dev</organizationUrl>
</developer>
</developers>
<scm>
<connection>scm:git:git://git.psmattas.com/evercatch/evercatch-java.git</connection>
<developerConnection>scm:git:ssh://git.psmattas.com/evercatch/evercatch-java.git</developerConnection>
<url>https://git.psmattas.com/evercatch/evercatch-java</url>
</scm>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<okhttp.version>4.12.0</okhttp.version>
<gson.version>2.10.1</gson.version>
<junit.version>5.10.1</junit.version>
<mockito.version>5.8.0</mockito.version>
<spring-boot.version>3.2.1</spring-boot.version>
</properties>
<dependencies>
<!-- HTTP Client -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>${gson.version}</version>
</dependency>
<!-- Spring Boot AutoConfiguration (optional) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<version>${spring-boot.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${spring-boot.version}</version>
<optional>true</optional>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>${okhttp.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
</plugin>
<!-- Sonatype Central Publisher Portal -->
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<version>0.9.0</version>
<extensions>true</extensions>
<configuration>
<publishingServerId>central</publishingServerId>
<autoPublish>true</autoPublish>
<waitUntil>published</waitUntil>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.3.0</version>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.6.3</version>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>release</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

1
settings.gradle Normal file
View File

@@ -0,0 +1 @@
rootProject.name = 'evercatch-java'

View File

@@ -0,0 +1,264 @@
package dev.evercatch;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import dev.evercatch.http.HttpClient;
import dev.evercatch.model.*;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.Response;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Evercatch API client for Java.
*
* <p>Example usage:</p>
* <pre>{@code
* EvercatchClient client = new EvercatchClient("ec_live_abc123");
*
* // Create a destination
* Destination dest = client.createDestination(
* CreateDestinationRequest.builder()
* .name("Production")
* .url("https://myapp.com/webhooks")
* .providers(List.of("stripe", "sendgrid"))
* .build()
* );
*
* // List recent events
* List<Event> events = client.listEvents(
* ListEventsRequest.builder()
* .provider("stripe")
* .limit(50)
* .build()
* );
* }</pre>
*/
public class EvercatchClient {
private static final String DEFAULT_BASE_URL = "https://api.evercatch.dev/v1";
private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
private final String baseUrl;
private final HttpClient httpClient;
private final Gson gson;
/**
* Creates a client using the default API base URL.
*
* @param apiKey your Evercatch API key (starts with {@code ec_live_} or {@code ec_test_})
*/
public EvercatchClient(String apiKey) {
this(apiKey, DEFAULT_BASE_URL);
}
/**
* Creates a client with a custom base URL (useful for self-hosted instances or testing).
*
* @param apiKey your Evercatch API key
* @param baseUrl custom API base URL
*/
public EvercatchClient(String apiKey, String baseUrl) {
if (apiKey == null || apiKey.trim().isEmpty()) {
throw new IllegalArgumentException("API key cannot be null or empty");
}
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.httpClient = new HttpClient(apiKey);
this.gson = new GsonBuilder()
.setDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
.create();
}
// -------------------------------------------------------------------------
// Destinations
// -------------------------------------------------------------------------
/**
* Creates a new webhook destination.
*
* @param request destination configuration
* @return the created {@link Destination}
* @throws EvercatchException if the API request fails
*/
public Destination createDestination(CreateDestinationRequest request) throws EvercatchException {
String url = baseUrl + "/destinations";
RequestBody body = RequestBody.create(gson.toJson(request), JSON_MEDIA_TYPE);
try (Response response = httpClient.post(url, body)) {
handleError(response, "create destination");
return gson.fromJson(response.body().string(), Destination.class);
} catch (IOException e) {
throw new EvercatchException("Network error creating destination", e);
}
}
/**
* Returns all webhook destinations for the account.
*
* @return list of destinations
* @throws EvercatchException if the API request fails
*/
public List<Destination> listDestinations() throws EvercatchException {
String url = baseUrl + "/destinations";
try (Response response = httpClient.get(url)) {
handleError(response, "list destinations");
DestinationListResponse resp = gson.fromJson(
response.body().string(), DestinationListResponse.class);
return resp.getData();
} catch (IOException e) {
throw new EvercatchException("Network error listing destinations", e);
}
}
/**
* Retrieves a single destination by ID.
*
* @param id destination ID
* @return the matching {@link Destination}
* @throws EvercatchException if the API request fails
*/
public Destination getDestination(String id) throws EvercatchException {
String url = baseUrl + "/destinations/" + id;
try (Response response = httpClient.get(url)) {
handleError(response, "get destination");
return gson.fromJson(response.body().string(), Destination.class);
} catch (IOException e) {
throw new EvercatchException("Network error getting destination", e);
}
}
/**
* Deletes a destination.
*
* @param id destination ID
* @throws EvercatchException if the API request fails
*/
public void deleteDestination(String id) throws EvercatchException {
String url = baseUrl + "/destinations/" + id;
try (Response response = httpClient.delete(url)) {
handleError(response, "delete destination");
} catch (IOException e) {
throw new EvercatchException("Network error deleting destination", e);
}
}
// -------------------------------------------------------------------------
// Events
// -------------------------------------------------------------------------
/**
* Lists webhook events with optional filters.
*
* @param request filter / pagination options (use {@code ListEventsRequest.builder().build()}
* for defaults)
* @return list of events
* @throws EvercatchException if the API request fails
*/
public List<Event> listEvents(ListEventsRequest request) throws EvercatchException {
StringBuilder url = new StringBuilder(baseUrl).append("/events?");
Map<String, String> params = new HashMap<>();
if (request.getProvider() != null) params.put("provider", request.getProvider());
if (request.getEventType() != null) params.put("event_type", request.getEventType());
if (request.getStatus() != null) params.put("status", request.getStatus());
if (request.getLimit() != null) params.put("limit", request.getLimit().toString());
if (request.getCursor() != null) params.put("cursor", request.getCursor());
params.forEach((k, v) -> url.append(k).append("=").append(v).append("&"));
try (Response response = httpClient.get(url.toString())) {
handleError(response, "list events");
EventListResponse resp = gson.fromJson(
response.body().string(), EventListResponse.class);
return resp.getData();
} catch (IOException e) {
throw new EvercatchException("Network error listing events", e);
}
}
/**
* Retrieves a single event by ID.
*
* @param id event ID
* @return the matching {@link Event}
* @throws EvercatchException if the API request fails
*/
public Event getEvent(String id) throws EvercatchException {
String url = baseUrl + "/events/" + id;
try (Response response = httpClient.get(url)) {
handleError(response, "get event");
return gson.fromJson(response.body().string(), Event.class);
} catch (IOException e) {
throw new EvercatchException("Network error getting event", e);
}
}
/**
* Replays an event to the specified destinations.
*
* <p>Requires the Studio or Enterprise plan.</p>
*
* @param eventId ID of the event to replay
* @param destinationIds destination IDs to replay to
* @throws EvercatchException if the API request fails or the plan does not support replay
*/
public void replayEvent(String eventId, List<String> destinationIds) throws EvercatchException {
String url = baseUrl + "/events/" + eventId + "/replay";
Map<String, Object> payload = new HashMap<>();
payload.put("destination_ids", destinationIds);
RequestBody body = RequestBody.create(gson.toJson(payload), JSON_MEDIA_TYPE);
try (Response response = httpClient.post(url, body)) {
if (response.code() == 402) {
throw new EvercatchException("Event replay requires Studio or Enterprise plan", 402);
}
handleError(response, "replay event");
} catch (IOException e) {
throw new EvercatchException("Network error replaying event", e);
}
}
// -------------------------------------------------------------------------
// Account
// -------------------------------------------------------------------------
/**
* Returns current account usage statistics.
*
* @return {@link Usage} summary
* @throws EvercatchException if the API request fails
*/
public Usage getUsage() throws EvercatchException {
String url = baseUrl + "/account/usage";
try (Response response = httpClient.get(url)) {
handleError(response, "get usage");
return gson.fromJson(response.body().string(), Usage.class);
} catch (IOException e) {
throw new EvercatchException("Network error getting usage", e);
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private void handleError(Response response, String operation) throws EvercatchException, IOException {
if (!response.isSuccessful()) {
String body = response.body() != null ? response.body().string() : "";
throw new EvercatchException(
String.format("Failed to %s (HTTP %d): %s", operation, response.code(), body),
response.code());
}
}
}

View File

@@ -0,0 +1,35 @@
package dev.evercatch;
/**
* Exception thrown when Evercatch API operations fail.
*
* <p>Wraps both HTTP-level errors (non-2xx responses) and network-level
* I/O errors so callers only need to handle a single checked exception type.</p>
*/
public class EvercatchException extends Exception {
private final int statusCode;
public EvercatchException(String message) {
super(message);
this.statusCode = -1;
}
public EvercatchException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}
public EvercatchException(String message, Throwable cause) {
super(message, cause);
this.statusCode = -1;
}
/**
* Returns the HTTP status code that triggered this exception, or {@code -1}
* if the failure was not caused by an HTTP response (e.g. a network error).
*/
public int getStatusCode() {
return statusCode;
}
}

View File

@@ -0,0 +1,76 @@
package dev.evercatch.http;
import okhttp3.*;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* Thin OkHttp wrapper that attaches Evercatch authentication headers to every
* outbound request.
*/
public class HttpClient {
private static final String SDK_VERSION = "1.0.0";
private static final String USER_AGENT = "evercatch-java/" + SDK_VERSION;
private final OkHttpClient client;
private final String apiKey;
public HttpClient(String apiKey) {
this.apiKey = apiKey;
this.client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
}
/** Package-private constructor for testing with a custom OkHttpClient. */
HttpClient(String apiKey, OkHttpClient client) {
this.apiKey = apiKey;
this.client = client;
}
public Response get(String url) throws IOException {
Request request = new Request.Builder()
.url(url)
.header("X-API-Key", apiKey)
.header("User-Agent", USER_AGENT)
.get()
.build();
return client.newCall(request).execute();
}
public Response post(String url, RequestBody body) throws IOException {
Request request = new Request.Builder()
.url(url)
.header("X-API-Key", apiKey)
.header("User-Agent", USER_AGENT)
.header("Content-Type", "application/json")
.post(body)
.build();
return client.newCall(request).execute();
}
public Response put(String url, RequestBody body) throws IOException {
Request request = new Request.Builder()
.url(url)
.header("X-API-Key", apiKey)
.header("User-Agent", USER_AGENT)
.header("Content-Type", "application/json")
.put(body)
.build();
return client.newCall(request).execute();
}
public Response delete(String url) throws IOException {
Request request = new Request.Builder()
.url(url)
.header("X-API-Key", apiKey)
.header("User-Agent", USER_AGENT)
.delete()
.build();
return client.newCall(request).execute();
}
}

View File

@@ -0,0 +1,100 @@
package dev.evercatch.model;
import com.google.gson.annotations.SerializedName;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Request object for creating a new webhook destination.
*
* <pre>{@code
* CreateDestinationRequest req = CreateDestinationRequest.builder()
* .name("Production")
* .url("https://myapp.com/webhooks")
* .providers(List.of("stripe", "sendgrid"))
* .eventTypes(List.of("payment.*", "email.delivered"))
* .build();
* }</pre>
*/
public class CreateDestinationRequest {
private final String name;
private final String url;
private final List<String> providers;
@SerializedName("event_types")
private final List<String> eventTypes;
private final Map<String, String> headers;
private CreateDestinationRequest(Builder builder) {
this.name = builder.name;
this.url = builder.url;
this.providers = builder.providers;
this.eventTypes = builder.eventTypes;
this.headers = builder.headers;
}
public static Builder builder() {
return new Builder();
}
public String getName() { return name; }
public String getUrl() { return url; }
public List<String> getProviders() { return providers; }
public List<String> getEventTypes() { return eventTypes; }
public Map<String, String> getHeaders() { return headers; }
public static class Builder {
private String name;
private String url;
private List<String> providers;
private List<String> eventTypes;
private Map<String, String> headers = new HashMap<>();
public Builder name(String name) {
this.name = name;
return this;
}
public Builder url(String url) {
this.url = url;
return this;
}
public Builder providers(List<String> providers) {
this.providers = providers;
return this;
}
public Builder eventTypes(List<String> eventTypes) {
this.eventTypes = eventTypes;
return this;
}
public Builder headers(Map<String, String> headers) {
this.headers = headers;
return this;
}
public Builder addHeader(String key, String value) {
this.headers.put(key, value);
return this;
}
public CreateDestinationRequest build() {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("name is required");
}
if (url == null || url.trim().isEmpty()) {
throw new IllegalArgumentException("url is required");
}
if (providers == null || providers.isEmpty()) {
throw new IllegalArgumentException("at least one provider is required");
}
return new CreateDestinationRequest(this);
}
}
}

View File

@@ -0,0 +1,62 @@
package dev.evercatch.model;
import com.google.gson.annotations.SerializedName;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* A webhook destination registered on Evercatch.
*/
public class Destination {
private String id;
private String name;
private String url;
private boolean enabled;
private List<String> providers;
@SerializedName("event_types")
private List<String> eventTypes;
private Map<String, String> headers;
@SerializedName("created_at")
private Date createdAt;
@SerializedName("updated_at")
private Date updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public List<String> getProviders() { return providers; }
public void setProviders(List<String> providers) { this.providers = providers; }
public List<String> getEventTypes() { return eventTypes; }
public void setEventTypes(List<String> eventTypes) { this.eventTypes = eventTypes; }
public Map<String, String> getHeaders() { return headers; }
public void setHeaders(Map<String, String> headers) { this.headers = headers; }
public Date getCreatedAt() { return createdAt; }
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
public Date getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
@Override
public String toString() {
return "Destination{id='" + id + "', name='" + name + "', url='" + url + "', enabled=" + enabled + "}";
}
}

View File

@@ -0,0 +1,20 @@
package dev.evercatch.model;
import java.util.List;
/** Wrapper for paginated destination list responses from the API. */
public class DestinationListResponse {
private List<Destination> data;
private String nextCursor;
private Integer total;
public List<Destination> getData() { return data; }
public void setData(List<Destination> data) { this.data = data; }
public String getNextCursor() { return nextCursor; }
public void setNextCursor(String nextCursor) { this.nextCursor = nextCursor; }
public Integer getTotal() { return total; }
public void setTotal(Integer total) { this.total = total; }
}

View File

@@ -0,0 +1,70 @@
package dev.evercatch.model;
import com.google.gson.JsonObject;
import com.google.gson.annotations.SerializedName;
import java.util.Date;
/**
* A webhook event captured by Evercatch.
*/
public class Event {
private String id;
private String provider;
@SerializedName("event_type")
private String eventType;
private String status;
private JsonObject payload;
private JsonObject headers;
@SerializedName("destination_id")
private String destinationId;
@SerializedName("retry_count")
private int retryCount;
@SerializedName("created_at")
private Date createdAt;
@SerializedName("processed_at")
private Date processedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getProvider() { return provider; }
public void setProvider(String provider) { this.provider = provider; }
public String getEventType() { return eventType; }
public void setEventType(String eventType) { this.eventType = eventType; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public JsonObject getPayload() { return payload; }
public void setPayload(JsonObject payload) { this.payload = payload; }
public JsonObject getHeaders() { return headers; }
public void setHeaders(JsonObject headers) { this.headers = headers; }
public String getDestinationId() { return destinationId; }
public void setDestinationId(String destinationId) { this.destinationId = destinationId; }
public int getRetryCount() { return retryCount; }
public void setRetryCount(int retryCount) { this.retryCount = retryCount; }
public Date getCreatedAt() { return createdAt; }
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
public Date getProcessedAt() { return processedAt; }
public void setProcessedAt(Date processedAt) { this.processedAt = processedAt; }
@Override
public String toString() {
return "Event{id='" + id + "', provider='" + provider + "', eventType='" + eventType
+ "', status='" + status + "'}";
}
}

View File

@@ -0,0 +1,20 @@
package dev.evercatch.model;
import java.util.List;
/** Wrapper for paginated event list responses from the API. */
public class EventListResponse {
private List<Event> data;
private String nextCursor;
private Integer total;
public List<Event> getData() { return data; }
public void setData(List<Event> data) { this.data = data; }
public String getNextCursor() { return nextCursor; }
public void setNextCursor(String nextCursor) { this.nextCursor = nextCursor; }
public Integer getTotal() { return total; }
public void setTotal(Integer total) { this.total = total; }
}

View File

@@ -0,0 +1,81 @@
package dev.evercatch.model;
/**
* Parameters for filtering and paginating the events list.
*
* <pre>{@code
* ListEventsRequest req = ListEventsRequest.builder()
* .provider("stripe")
* .eventType("payment.succeeded")
* .limit(50)
* .build();
* }</pre>
*/
public class ListEventsRequest {
private final String provider;
private final String eventType;
private final String status;
private final Integer limit;
private final String cursor;
private ListEventsRequest(Builder builder) {
this.provider = builder.provider;
this.eventType = builder.eventType;
this.status = builder.status;
this.limit = builder.limit;
this.cursor = builder.cursor;
}
public static Builder builder() {
return new Builder();
}
public String getProvider() { return provider; }
public String getEventType() { return eventType; }
public String getStatus() { return status; }
public Integer getLimit() { return limit; }
public String getCursor() { return cursor; }
public static class Builder {
private String provider;
private String eventType;
private String status;
private Integer limit;
private String cursor;
/** Filter by provider slug, e.g. {@code "stripe"}. */
public Builder provider(String provider) {
this.provider = provider;
return this;
}
/** Filter by event type, e.g. {@code "payment.succeeded"}. */
public Builder eventType(String eventType) {
this.eventType = eventType;
return this;
}
/** Filter by delivery status: {@code "delivered"}, {@code "failed"}, {@code "pending"}. */
public Builder status(String status) {
this.status = status;
return this;
}
/** Maximum number of results to return (default 20, max 100). */
public Builder limit(int limit) {
this.limit = limit;
return this;
}
/** Pagination cursor from the previous response's {@code nextCursor} field. */
public Builder cursor(String cursor) {
this.cursor = cursor;
return this;
}
public ListEventsRequest build() {
return new ListEventsRequest(this);
}
}
}

View File

@@ -0,0 +1,59 @@
package dev.evercatch.model;
import com.google.gson.annotations.SerializedName;
import java.util.Date;
/**
* Account usage statistics returned by {@code /account/usage}.
*/
public class Usage {
@SerializedName("events_this_month")
private int eventsThisMonth;
@SerializedName("events_limit")
private int eventsLimit;
@SerializedName("destinations_count")
private int destinationsCount;
@SerializedName("destinations_limit")
private int destinationsLimit;
@SerializedName("plan")
private String plan;
@SerializedName("period_start")
private Date periodStart;
@SerializedName("period_end")
private Date periodEnd;
public int getEventsThisMonth() { return eventsThisMonth; }
public void setEventsThisMonth(int eventsThisMonth) { this.eventsThisMonth = eventsThisMonth; }
public int getEventsLimit() { return eventsLimit; }
public void setEventsLimit(int eventsLimit) { this.eventsLimit = eventsLimit; }
public int getDestinationsCount() { return destinationsCount; }
public void setDestinationsCount(int destinationsCount) { this.destinationsCount = destinationsCount; }
public int getDestinationsLimit() { return destinationsLimit; }
public void setDestinationsLimit(int destinationsLimit) { this.destinationsLimit = destinationsLimit; }
public String getPlan() { return plan; }
public void setPlan(String plan) { this.plan = plan; }
public Date getPeriodStart() { return periodStart; }
public void setPeriodStart(Date periodStart) { this.periodStart = periodStart; }
public Date getPeriodEnd() { return periodEnd; }
public void setPeriodEnd(Date periodEnd) { this.periodEnd = periodEnd; }
@Override
public String toString() {
return "Usage{plan='" + plan + "', eventsThisMonth=" + eventsThisMonth
+ "/" + eventsLimit + ", destinations=" + destinationsCount + "/" + destinationsLimit + "}";
}
}

View File

@@ -0,0 +1,55 @@
package dev.evercatch.spring;
import dev.evercatch.EvercatchClient;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
/**
* Spring Boot auto-configuration for the Evercatch SDK.
*
* <p>A fully configured {@link EvercatchClient} bean is registered automatically when
* {@code evercatch.api-key} is present in the application's environment. Override the
* bean by declaring your own {@code @Bean} of type {@link EvercatchClient}.</p>
*
* <p>Example {@code application.properties}:</p>
* <pre>
* evercatch.api-key=ec_live_abc123
* </pre>
*
* <p>Then inject the client normally:</p>
* <pre>{@code
* @Service
* public class WebhookService {
*
* private final EvercatchClient evercatch;
*
* public WebhookService(EvercatchClient evercatch) {
* this.evercatch = evercatch;
* }
*
* public void handleEvent(String eventId) throws EvercatchException {
* Event event = evercatch.getEvent(eventId);
* // ...
* }
* }
* }</pre>
*/
@AutoConfiguration
@ConditionalOnClass(EvercatchClient.class)
@ConditionalOnProperty(prefix = "evercatch", name = "api-key")
@EnableConfigurationProperties(EvercatchProperties.class)
public class EvercatchAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public EvercatchClient evercatchClient(EvercatchProperties properties) {
if (properties.getBaseUrl() != null && !properties.getBaseUrl().isBlank()) {
return new EvercatchClient(properties.getApiKey(), properties.getBaseUrl());
}
return new EvercatchClient(properties.getApiKey());
}
}

View File

@@ -0,0 +1,35 @@
package dev.evercatch.spring;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Spring Boot configuration properties for the Evercatch SDK.
*
* <p>Set in {@code application.properties} or {@code application.yml}:</p>
* <pre>
* # application.properties
* evercatch.api-key=ec_live_abc123
* evercatch.base-url=https://api.evercatch.dev/v1 # optional
* </pre>
* <pre>
* # application.yml
* evercatch:
* api-key: ec_live_abc123
* base-url: https://api.evercatch.dev/v1 # optional
* </pre>
*/
@ConfigurationProperties(prefix = "evercatch")
public class EvercatchProperties {
/** Your Evercatch API key (starts with {@code ec_live_} or {@code ec_test_}). */
private String apiKey;
/** Override the default API base URL. Useful for self-hosted instances. */
private String baseUrl;
public String getApiKey() { return apiKey; }
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
}

View File

@@ -0,0 +1 @@
dev.evercatch.spring.EvercatchAutoConfiguration

View File

@@ -0,0 +1,243 @@
package dev.evercatch;
import com.google.gson.Gson;
import dev.evercatch.model.*;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.*;
import java.io.IOException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class EvercatchClientTest {
private MockWebServer server;
private EvercatchClient client;
private final Gson gson = new Gson();
@BeforeEach
void setUp() throws IOException {
server = new MockWebServer();
server.start();
client = new EvercatchClient("ec_test_key", server.url("/").toString());
}
@AfterEach
void tearDown() throws IOException {
server.shutdown();
}
// -------------------------------------------------------------------------
// Constructor validation
// -------------------------------------------------------------------------
@Test
void constructor_throwsOnNullApiKey() {
assertThrows(IllegalArgumentException.class, () -> new EvercatchClient(null));
}
@Test
void constructor_throwsOnEmptyApiKey() {
assertThrows(IllegalArgumentException.class, () -> new EvercatchClient(" "));
}
// -------------------------------------------------------------------------
// Destinations
// -------------------------------------------------------------------------
@Test
void createDestination_sendsCorrectRequest() throws Exception {
Destination dest = new Destination();
dest.setId("dst_001");
dest.setName("Production");
dest.setUrl("https://myapp.com/webhooks");
dest.setEnabled(true);
server.enqueue(new MockResponse()
.setResponseCode(201)
.setHeader("Content-Type", "application/json")
.setBody(gson.toJson(dest)));
CreateDestinationRequest req = CreateDestinationRequest.builder()
.name("Production")
.url("https://myapp.com/webhooks")
.providers(List.of("stripe"))
.build();
Destination result = client.createDestination(req);
assertEquals("dst_001", result.getId());
assertEquals("Production", result.getName());
RecordedRequest recorded = server.takeRequest();
assertEquals("POST", recorded.getMethod());
assertTrue(recorded.getPath().contains("/destinations"));
assertEquals("ec_test_key", recorded.getHeader("X-API-Key"));
assertNotNull(recorded.getHeader("User-Agent"));
}
@Test
void createDestination_throwsOnErrorResponse() {
server.enqueue(new MockResponse().setResponseCode(400).setBody("{\"error\":\"bad request\"}"));
CreateDestinationRequest req = CreateDestinationRequest.builder()
.name("Test")
.url("https://example.com")
.providers(List.of("stripe"))
.build();
EvercatchException ex = assertThrows(EvercatchException.class,
() -> client.createDestination(req));
assertEquals(400, ex.getStatusCode());
}
@Test
void listDestinations_returnsDestinations() throws Exception {
Destination d1 = new Destination(); d1.setId("dst_001");
Destination d2 = new Destination(); d2.setId("dst_002");
DestinationListResponse body = new DestinationListResponse();
body.setData(List.of(d1, d2));
server.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(gson.toJson(body)));
List<Destination> results = client.listDestinations();
assertEquals(2, results.size());
assertEquals("dst_001", results.get(0).getId());
}
@Test
void getDestination_returnsDestination() throws Exception {
Destination dest = new Destination(); dest.setId("dst_001");
server.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(gson.toJson(dest)));
Destination result = client.getDestination("dst_001");
assertEquals("dst_001", result.getId());
RecordedRequest recorded = server.takeRequest();
assertTrue(recorded.getPath().endsWith("/destinations/dst_001"));
}
@Test
void deleteDestination_sends204() throws Exception {
server.enqueue(new MockResponse().setResponseCode(204));
assertDoesNotThrow(() -> client.deleteDestination("dst_001"));
RecordedRequest recorded = server.takeRequest();
assertEquals("DELETE", recorded.getMethod());
}
// -------------------------------------------------------------------------
// Events
// -------------------------------------------------------------------------
@Test
void listEvents_withFilters_buildsCorrectUrl() throws Exception {
EventListResponse body = new EventListResponse();
body.setData(List.of());
server.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(gson.toJson(body)));
client.listEvents(ListEventsRequest.builder()
.provider("stripe")
.limit(25)
.build());
RecordedRequest recorded = server.takeRequest();
String path = recorded.getPath();
assertTrue(path.contains("provider=stripe"));
assertTrue(path.contains("limit=25"));
}
@Test
void getEvent_returnsEvent() throws Exception {
Event event = new Event(); event.setId("evt_001"); event.setProvider("stripe");
server.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(gson.toJson(event)));
Event result = client.getEvent("evt_001");
assertEquals("evt_001", result.getId());
assertEquals("stripe", result.getProvider());
}
@Test
void replayEvent_throwsOnPaymentRequired() {
server.enqueue(new MockResponse().setResponseCode(402));
EvercatchException ex = assertThrows(EvercatchException.class,
() -> client.replayEvent("evt_001", List.of("dst_001")));
assertEquals(402, ex.getStatusCode());
assertTrue(ex.getMessage().contains("Studio"));
}
// -------------------------------------------------------------------------
// Account / Usage
// -------------------------------------------------------------------------
@Test
void getUsage_returnsUsage() throws Exception {
Usage usage = new Usage();
usage.setEventsThisMonth(1500);
usage.setEventsLimit(10000);
usage.setPlan("studio");
server.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(gson.toJson(usage)));
Usage result = client.getUsage();
assertEquals(1500, result.getEventsThisMonth());
assertEquals("studio", result.getPlan());
}
// -------------------------------------------------------------------------
// Builder validation
// -------------------------------------------------------------------------
@Test
void createDestinationRequest_requiresName() {
assertThrows(IllegalArgumentException.class, () ->
CreateDestinationRequest.builder()
.url("https://example.com")
.providers(List.of("stripe"))
.build());
}
@Test
void createDestinationRequest_requiresUrl() {
assertThrows(IllegalArgumentException.class, () ->
CreateDestinationRequest.builder()
.name("Test")
.providers(List.of("stripe"))
.build());
}
@Test
void createDestinationRequest_requiresProviders() {
assertThrows(IllegalArgumentException.class, () ->
CreateDestinationRequest.builder()
.name("Test")
.url("https://example.com")
.build());
}
}