From e66227afc4e9133fd403e7fbebfa4ae6689c1cdf Mon Sep 17 00:00:00 2001 From: default Date: Thu, 19 Feb 2026 21:24:58 +0000 Subject: [PATCH] EC-21: FEAT: Added new Java package and initialized the repo Refers Evercatch/evercatch-board#21 --- .gitignore | 13 + README.md | 229 +++++++++++++-- build.gradle | 101 +++++++ pom.xml | 180 ++++++++++++ settings.gradle | 1 + .../java/dev/evercatch/EvercatchClient.java | 264 ++++++++++++++++++ .../dev/evercatch/EvercatchException.java | 35 +++ .../java/dev/evercatch/http/HttpClient.java | 76 +++++ .../model/CreateDestinationRequest.java | 100 +++++++ .../java/dev/evercatch/model/Destination.java | 62 ++++ .../model/DestinationListResponse.java | 20 ++ src/main/java/dev/evercatch/model/Event.java | 70 +++++ .../evercatch/model/EventListResponse.java | 20 ++ .../evercatch/model/ListEventsRequest.java | 81 ++++++ src/main/java/dev/evercatch/model/Usage.java | 59 ++++ .../spring/EvercatchAutoConfiguration.java | 55 ++++ .../evercatch/spring/EvercatchProperties.java | 35 +++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../dev/evercatch/EvercatchClientTest.java | 243 ++++++++++++++++ 19 files changed, 1615 insertions(+), 30 deletions(-) create mode 100644 build.gradle create mode 100644 pom.xml create mode 100644 settings.gradle create mode 100644 src/main/java/dev/evercatch/EvercatchClient.java create mode 100644 src/main/java/dev/evercatch/EvercatchException.java create mode 100644 src/main/java/dev/evercatch/http/HttpClient.java create mode 100644 src/main/java/dev/evercatch/model/CreateDestinationRequest.java create mode 100644 src/main/java/dev/evercatch/model/Destination.java create mode 100644 src/main/java/dev/evercatch/model/DestinationListResponse.java create mode 100644 src/main/java/dev/evercatch/model/Event.java create mode 100644 src/main/java/dev/evercatch/model/EventListResponse.java create mode 100644 src/main/java/dev/evercatch/model/ListEventsRequest.java create mode 100644 src/main/java/dev/evercatch/model/Usage.java create mode 100644 src/main/java/dev/evercatch/spring/EvercatchAutoConfiguration.java create mode 100644 src/main/java/dev/evercatch/spring/EvercatchProperties.java create mode 100644 src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 src/test/java/dev/evercatch/EvercatchClientTest.java diff --git a/.gitignore b/.gitignore index 0be5d15..b85bacd 100644 --- a/.gitignore +++ b/.gitignore @@ -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] diff --git a/README.md b/README.md index 788863e..4eb8d0b 100644 --- a/README.md +++ b/README.md @@ -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 + + dev.evercatch + evercatch-java + 1.0.0 + +``` -### 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 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. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..a428bf0 --- /dev/null +++ b/build.gradle @@ -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 +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..73a91ca --- /dev/null +++ b/pom.xml @@ -0,0 +1,180 @@ + + + 4.0.0 + + dev.evercatch + evercatch-java + 1.0.0 + jar + + Evercatch Java SDK + Official Java SDK for Evercatch webhook infrastructure platform + https://evercatch.dev + + + + MIT License + https://opensource.org/licenses/MIT + + + + + + Evercatch Team + support@evercatch.dev + Evercatch + https://evercatch.dev + + + + + scm:git:git://git.psmattas.com/evercatch/evercatch-java.git + scm:git:ssh://git.psmattas.com/evercatch/evercatch-java.git + https://git.psmattas.com/evercatch/evercatch-java + + + + 17 + 17 + UTF-8 + 4.12.0 + 2.10.1 + 5.10.1 + 5.8.0 + 3.2.1 + + + + + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + + + + + com.google.code.gson + gson + ${gson.version} + + + + + org.springframework.boot + spring-boot-autoconfigure + ${spring-boot.version} + true + + + org.springframework.boot + spring-boot-configuration-processor + ${spring-boot.version} + true + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + com.squareup.okhttp3 + mockwebserver + ${okhttp.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.9.0 + true + + central + true + published + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.3 + + + attach-javadocs + + jar + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.3 + + + + + + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.1.0 + + + sign-artifacts + verify + + sign + + + + + + + + + diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..5029281 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'evercatch-java' diff --git a/src/main/java/dev/evercatch/EvercatchClient.java b/src/main/java/dev/evercatch/EvercatchClient.java new file mode 100644 index 0000000..e0dc247 --- /dev/null +++ b/src/main/java/dev/evercatch/EvercatchClient.java @@ -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. + * + *

Example usage:

+ *
{@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 events = client.listEvents(
+ *     ListEventsRequest.builder()
+ *         .provider("stripe")
+ *         .limit(50)
+ *         .build()
+ * );
+ * }
+ */ +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 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 listEvents(ListEventsRequest request) throws EvercatchException { + StringBuilder url = new StringBuilder(baseUrl).append("/events?"); + + Map 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. + * + *

Requires the Studio or Enterprise plan.

+ * + * @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 destinationIds) throws EvercatchException { + String url = baseUrl + "/events/" + eventId + "/replay"; + + Map 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()); + } + } +} diff --git a/src/main/java/dev/evercatch/EvercatchException.java b/src/main/java/dev/evercatch/EvercatchException.java new file mode 100644 index 0000000..2adde62 --- /dev/null +++ b/src/main/java/dev/evercatch/EvercatchException.java @@ -0,0 +1,35 @@ +package dev.evercatch; + +/** + * Exception thrown when Evercatch API operations fail. + * + *

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.

+ */ +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; + } +} diff --git a/src/main/java/dev/evercatch/http/HttpClient.java b/src/main/java/dev/evercatch/http/HttpClient.java new file mode 100644 index 0000000..9f45d08 --- /dev/null +++ b/src/main/java/dev/evercatch/http/HttpClient.java @@ -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(); + } +} diff --git a/src/main/java/dev/evercatch/model/CreateDestinationRequest.java b/src/main/java/dev/evercatch/model/CreateDestinationRequest.java new file mode 100644 index 0000000..3c0cb59 --- /dev/null +++ b/src/main/java/dev/evercatch/model/CreateDestinationRequest.java @@ -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. + * + *
{@code
+ * CreateDestinationRequest req = CreateDestinationRequest.builder()
+ *     .name("Production")
+ *     .url("https://myapp.com/webhooks")
+ *     .providers(List.of("stripe", "sendgrid"))
+ *     .eventTypes(List.of("payment.*", "email.delivered"))
+ *     .build();
+ * }
+ */ +public class CreateDestinationRequest { + + private final String name; + private final String url; + private final List providers; + + @SerializedName("event_types") + private final List eventTypes; + + private final Map 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 getProviders() { return providers; } + public List getEventTypes() { return eventTypes; } + public Map getHeaders() { return headers; } + + public static class Builder { + private String name; + private String url; + private List providers; + private List eventTypes; + private Map 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 providers) { + this.providers = providers; + return this; + } + + public Builder eventTypes(List eventTypes) { + this.eventTypes = eventTypes; + return this; + } + + public Builder headers(Map 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); + } + } +} diff --git a/src/main/java/dev/evercatch/model/Destination.java b/src/main/java/dev/evercatch/model/Destination.java new file mode 100644 index 0000000..3adcef1 --- /dev/null +++ b/src/main/java/dev/evercatch/model/Destination.java @@ -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 providers; + + @SerializedName("event_types") + private List eventTypes; + + private Map 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 getProviders() { return providers; } + public void setProviders(List providers) { this.providers = providers; } + + public List getEventTypes() { return eventTypes; } + public void setEventTypes(List eventTypes) { this.eventTypes = eventTypes; } + + public Map getHeaders() { return headers; } + public void setHeaders(Map 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 + "}"; + } +} diff --git a/src/main/java/dev/evercatch/model/DestinationListResponse.java b/src/main/java/dev/evercatch/model/DestinationListResponse.java new file mode 100644 index 0000000..67ce81c --- /dev/null +++ b/src/main/java/dev/evercatch/model/DestinationListResponse.java @@ -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 data; + private String nextCursor; + private Integer total; + + public List getData() { return data; } + public void setData(List 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; } +} diff --git a/src/main/java/dev/evercatch/model/Event.java b/src/main/java/dev/evercatch/model/Event.java new file mode 100644 index 0000000..5d04e8f --- /dev/null +++ b/src/main/java/dev/evercatch/model/Event.java @@ -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 + "'}"; + } +} diff --git a/src/main/java/dev/evercatch/model/EventListResponse.java b/src/main/java/dev/evercatch/model/EventListResponse.java new file mode 100644 index 0000000..ef1ee6f --- /dev/null +++ b/src/main/java/dev/evercatch/model/EventListResponse.java @@ -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 data; + private String nextCursor; + private Integer total; + + public List getData() { return data; } + public void setData(List 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; } +} diff --git a/src/main/java/dev/evercatch/model/ListEventsRequest.java b/src/main/java/dev/evercatch/model/ListEventsRequest.java new file mode 100644 index 0000000..19000f8 --- /dev/null +++ b/src/main/java/dev/evercatch/model/ListEventsRequest.java @@ -0,0 +1,81 @@ +package dev.evercatch.model; + +/** + * Parameters for filtering and paginating the events list. + * + *
{@code
+ * ListEventsRequest req = ListEventsRequest.builder()
+ *     .provider("stripe")
+ *     .eventType("payment.succeeded")
+ *     .limit(50)
+ *     .build();
+ * }
+ */ +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); + } + } +} diff --git a/src/main/java/dev/evercatch/model/Usage.java b/src/main/java/dev/evercatch/model/Usage.java new file mode 100644 index 0000000..13c2f41 --- /dev/null +++ b/src/main/java/dev/evercatch/model/Usage.java @@ -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 + "}"; + } +} diff --git a/src/main/java/dev/evercatch/spring/EvercatchAutoConfiguration.java b/src/main/java/dev/evercatch/spring/EvercatchAutoConfiguration.java new file mode 100644 index 0000000..f90eaab --- /dev/null +++ b/src/main/java/dev/evercatch/spring/EvercatchAutoConfiguration.java @@ -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. + * + *

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}.

+ * + *

Example {@code application.properties}:

+ *
+ * evercatch.api-key=ec_live_abc123
+ * 
+ * + *

Then inject the client normally:

+ *
{@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);
+ *         // ...
+ *     }
+ * }
+ * }
+ */ +@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()); + } +} diff --git a/src/main/java/dev/evercatch/spring/EvercatchProperties.java b/src/main/java/dev/evercatch/spring/EvercatchProperties.java new file mode 100644 index 0000000..0695c0d --- /dev/null +++ b/src/main/java/dev/evercatch/spring/EvercatchProperties.java @@ -0,0 +1,35 @@ +package dev.evercatch.spring; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Spring Boot configuration properties for the Evercatch SDK. + * + *

Set in {@code application.properties} or {@code application.yml}:

+ *
+ * # application.properties
+ * evercatch.api-key=ec_live_abc123
+ * evercatch.base-url=https://api.evercatch.dev/v1   # optional
+ * 
+ *
+ * # application.yml
+ * evercatch:
+ *   api-key: ec_live_abc123
+ *   base-url: https://api.evercatch.dev/v1  # optional
+ * 
+ */ +@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; } +} diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..5f9c3b2 --- /dev/null +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.evercatch.spring.EvercatchAutoConfiguration diff --git a/src/test/java/dev/evercatch/EvercatchClientTest.java b/src/test/java/dev/evercatch/EvercatchClientTest.java new file mode 100644 index 0000000..2576385 --- /dev/null +++ b/src/test/java/dev/evercatch/EvercatchClientTest.java @@ -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 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()); + } +} -- 2.49.1