5 Commits

Author SHA1 Message Date
4ac6b66afe DOCS: Updated security Email 2026-03-03 12:53:43 +00:00
c1bc5d5265 EC-21: FEAT: Added gitea for package publishing
Refers: Evercatch/evercatch-board#21
2026-02-20 11:23:50 +00:00
91887eac05 EC-21: FIX: Updated License
to MIT

Refers: Evercatch/evercatch-board#21
2026-02-20 11:17:51 +00:00
b4fbca6e15 Merge branch 'EC-21-Official-SDKs'
Refers: Evercatch/evercatch-board#21
2026-02-19 21:27:52 +00:00
e66227afc4 EC-21: FEAT: Added new Java package and initialized the repo
All checks were successful
PR Management Bot / pr-bot (pull_request) Successful in 7s
PR Title Checker / Validate PR Title Format (pull_request) Successful in 2s
Refers Evercatch/evercatch-board#21
2026-02-19 21:24:58 +00:00
21 changed files with 1645 additions and 37 deletions

13
.gitignore vendored
View File

@@ -3,6 +3,19 @@
.env.*
!.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
__pycache__/
*.py[cod]

25
LICENSE
View File

@@ -1,8 +1,21 @@
Copyright (c) 2026 Evercatch. All rights reserved.
MIT License
This software and its source code are proprietary and confidential.
Unauthorised copying, distribution, modification, or use of this software,
in whole or in part, via any medium, is strictly prohibited without the
prior written permission of Evercatch.
Copyright (c) 2026 Evercatch
For licensing enquiries, contact: legal@evercatch.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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
Describe what this service/module is responsible for within the Evercatch platform.
---
## 🛠️ Tech Stack
## Tech Stack
| Layer | Technology |
| :--- | :--- |
| Language | |
| Framework | — |
| Key Dependencies | — |
| Language | Java 11+ |
| HTTP client | OkHttp 4 |
| JSON | Gson |
| Build | Maven / Gradle |
| Spring Boot | Auto-configuration (optional) |
---
## 🚀 Getting Started
## Installation
### Prerequisites
### Maven
- Docker & Docker Compose
- Node.js / Python (specify version)
```xml
<dependency>
<groupId>dev.evercatch</groupId>
<artifactId>evercatch-java</artifactId>
<version>1.0.0</version>
</dependency>
```
### Local Development
### Gradle
```bash
# Clone the repo
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
```gradle
implementation 'dev.evercatch:evercatch-java:1.0.0'
```
---
## 🌿 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.
@@ -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.
@@ -66,7 +235,7 @@ Automated via Jenkins. Merges to `main` trigger staging deployments.
---
## 📄 License
## License
**Copyright © 2026 Evercatch.**
Proprietary and confidential. Unauthorised distribution is strictly prohibited.

View File

@@ -5,7 +5,7 @@
If you discover a security vulnerability in any Evercatch repository,
please do **not** open a public issue.
Report it privately to: **security@evercatch.io**
Report it privately to: **security@evercatch.dev**
Include:
- A description of the vulnerability

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
}

190
pom.xml Normal file
View File

@@ -0,0 +1,190 @@
<?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>
<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>
<distributionManagement>
<repository>
<id>gitea</id>
<url>https://git.psmattas.com/api/packages/evercatch/maven</url>
</repository>
<snapshotRepository>
<id>gitea</id>
<url>https://git.psmattas.com/api/packages/evercatch/maven</url>
</snapshotRepository>
</distributionManagement>
<profiles>
<profile>
<id>release</id>
<build>
<plugins>
<!-- 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-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());
}
}