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 ListRequires 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, ListWraps 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{@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