logging = Optional.empty();
+
public Builder environment(Environment environment) {
this.environment = environment;
return this;
@@ -143,6 +161,14 @@ public Builder httpClient(OkHttpClient httpClient) {
return this;
}
+ /**
+ * Configure logging for the SDK. Silent by default — no log output unless explicitly configured.
+ */
+ public Builder logging(LogConfig logging) {
+ this.logging = Optional.of(logging);
+ return this;
+ }
+
public ClientOptions build() {
OkHttpClient.Builder httpClientBuilder =
this.httpClient != null ? this.httpClient.newBuilder() : new OkHttpClient.Builder();
@@ -162,10 +188,20 @@ public ClientOptions build() {
.addInterceptor(new RetryInterceptor(this.maxRetries));
}
+ Logger logger = Logger.from(this.logging);
+ httpClientBuilder.addInterceptor(new LoggingInterceptor(logger));
+
this.httpClient = httpClientBuilder.build();
this.timeout = Optional.of(httpClient.callTimeoutMillis() / 1000);
- return new ClientOptions(environment, headers, headerSuppliers, httpClient, this.timeout.get());
+ return new ClientOptions(
+ environment,
+ headers,
+ headerSuppliers,
+ httpClient,
+ this.timeout.get(),
+ this.maxRetries,
+ this.logging);
}
/**
@@ -176,6 +212,10 @@ public static Builder from(ClientOptions clientOptions) {
builder.environment = clientOptions.environment();
builder.timeout = Optional.of(clientOptions.timeout(null));
builder.httpClient = clientOptions.httpClient();
+ builder.headers.putAll(clientOptions.headers);
+ builder.headerSuppliers.putAll(clientOptions.headerSuppliers);
+ builder.maxRetries = clientOptions.maxRetries();
+ builder.logging = clientOptions.logging();
return builder;
}
}
diff --git a/src/main/java/com/schematic/api/core/ConsoleLogger.java b/src/main/java/com/schematic/api/core/ConsoleLogger.java
new file mode 100644
index 0000000..89a22b6
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/ConsoleLogger.java
@@ -0,0 +1,51 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+import java.util.logging.Level;
+
+/**
+ * Default logger implementation that writes to the console using {@link java.util.logging.Logger}.
+ *
+ * Uses the "fern" logger name with a simple format of "LEVEL - message".
+ */
+public final class ConsoleLogger implements ILogger {
+
+ private static final java.util.logging.Logger logger = java.util.logging.Logger.getLogger("fern");
+
+ static {
+ if (logger.getHandlers().length == 0) {
+ java.util.logging.ConsoleHandler handler = new java.util.logging.ConsoleHandler();
+ handler.setFormatter(new java.util.logging.SimpleFormatter() {
+ @Override
+ public String format(java.util.logging.LogRecord record) {
+ return record.getLevel() + " - " + record.getMessage() + System.lineSeparator();
+ }
+ });
+ logger.addHandler(handler);
+ logger.setUseParentHandlers(false);
+ logger.setLevel(Level.ALL);
+ }
+ }
+
+ @Override
+ public void debug(String message) {
+ logger.log(Level.FINE, message);
+ }
+
+ @Override
+ public void info(String message) {
+ logger.log(Level.INFO, message);
+ }
+
+ @Override
+ public void warn(String message) {
+ logger.log(Level.WARNING, message);
+ }
+
+ @Override
+ public void error(String message) {
+ logger.log(Level.SEVERE, message);
+ }
+}
diff --git a/src/main/java/com/schematic/api/core/DoubleSerializer.java b/src/main/java/com/schematic/api/core/DoubleSerializer.java
new file mode 100644
index 0000000..2b47145
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/DoubleSerializer.java
@@ -0,0 +1,43 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import java.io.IOException;
+
+/**
+ * Custom serializer that writes integer-valued doubles without a decimal point.
+ * For example, {@code 24000.0} is serialized as {@code 24000} instead of {@code 24000.0}.
+ * Non-integer values like {@code 3.14} are serialized normally.
+ */
+class DoubleSerializer extends JsonSerializer {
+ private static final SimpleModule MODULE;
+
+ static {
+ MODULE = new SimpleModule()
+ .addSerializer(Double.class, new DoubleSerializer())
+ .addSerializer(double.class, new DoubleSerializer());
+ }
+
+ /**
+ * Gets a module wrapping this serializer as an adapter for the Jackson ObjectMapper.
+ *
+ * @return A {@link SimpleModule} to be plugged onto Jackson ObjectMapper.
+ */
+ public static SimpleModule getModule() {
+ return MODULE;
+ }
+
+ @Override
+ public void serialize(Double value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+ if (value != null && value == Math.floor(value) && !Double.isInfinite(value) && !Double.isNaN(value)) {
+ gen.writeNumber(value.longValue());
+ } else {
+ gen.writeNumber(value);
+ }
+ }
+}
diff --git a/src/main/java/com/schematic/api/core/ILogger.java b/src/main/java/com/schematic/api/core/ILogger.java
new file mode 100644
index 0000000..b206f54
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/ILogger.java
@@ -0,0 +1,38 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+/**
+ * Interface for custom logger implementations.
+ *
+ * Implement this interface to provide a custom logging backend for the SDK.
+ * The SDK will call the appropriate method based on the log level.
+ *
+ *
Example:
+ *
{@code
+ * public class MyCustomLogger implements ILogger {
+ * public void debug(String message) {
+ * System.out.println("[DBG] " + message);
+ * }
+ * public void info(String message) {
+ * System.out.println("[INF] " + message);
+ * }
+ * public void warn(String message) {
+ * System.out.println("[WRN] " + message);
+ * }
+ * public void error(String message) {
+ * System.out.println("[ERR] " + message);
+ * }
+ * }
+ * }
+ */
+public interface ILogger {
+ void debug(String message);
+
+ void info(String message);
+
+ void warn(String message);
+
+ void error(String message);
+}
diff --git a/src/main/java/com/schematic/api/core/LogConfig.java b/src/main/java/com/schematic/api/core/LogConfig.java
new file mode 100644
index 0000000..64f39bd
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/LogConfig.java
@@ -0,0 +1,98 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+/**
+ * Configuration for SDK logging.
+ *
+ * Use the builder to configure logging behavior:
+ *
{@code
+ * LogConfig config = LogConfig.builder()
+ * .level(LogLevel.DEBUG)
+ * .silent(false)
+ * .build();
+ * }
+ *
+ * Or with a custom logger:
+ *
{@code
+ * LogConfig config = LogConfig.builder()
+ * .level(LogLevel.DEBUG)
+ * .logger(new MyCustomLogger())
+ * .silent(false)
+ * .build();
+ * }
+ *
+ * Defaults:
+ *
+ * - {@code level} — {@link LogLevel#INFO}
+ * - {@code logger} — {@link ConsoleLogger} (writes to stderr via java.util.logging)
+ * - {@code silent} — {@code true} (no output unless explicitly enabled)
+ *
+ */
+public final class LogConfig {
+
+ private final LogLevel level;
+ private final ILogger logger;
+ private final boolean silent;
+
+ private LogConfig(LogLevel level, ILogger logger, boolean silent) {
+ this.level = level;
+ this.logger = logger;
+ this.silent = silent;
+ }
+
+ public LogLevel level() {
+ return level;
+ }
+
+ public ILogger logger() {
+ return logger;
+ }
+
+ public boolean silent() {
+ return silent;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private LogLevel level = LogLevel.INFO;
+ private ILogger logger = new ConsoleLogger();
+ private boolean silent = true;
+
+ private Builder() {}
+
+ /**
+ * Set the minimum log level. Only messages at this level or above will be logged.
+ * Defaults to {@link LogLevel#INFO}.
+ */
+ public Builder level(LogLevel level) {
+ this.level = level;
+ return this;
+ }
+
+ /**
+ * Set a custom logger implementation. Defaults to {@link ConsoleLogger}.
+ */
+ public Builder logger(ILogger logger) {
+ this.logger = logger;
+ return this;
+ }
+
+ /**
+ * Set whether logging is silent (disabled). Defaults to {@code true}.
+ * Set to {@code false} to enable log output.
+ */
+ public Builder silent(boolean silent) {
+ this.silent = silent;
+ return this;
+ }
+
+ public LogConfig build() {
+ return new LogConfig(level, logger, silent);
+ }
+ }
+}
diff --git a/src/main/java/com/schematic/api/core/LogLevel.java b/src/main/java/com/schematic/api/core/LogLevel.java
new file mode 100644
index 0000000..f9f4ad4
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/LogLevel.java
@@ -0,0 +1,36 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+/**
+ * Log levels for SDK logging configuration.
+ * Silent by default — no log output unless explicitly configured.
+ */
+public enum LogLevel {
+ DEBUG(1),
+ INFO(2),
+ WARN(3),
+ ERROR(4);
+
+ private final int value;
+
+ LogLevel(int value) {
+ this.value = value;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ /**
+ * Parse a log level from a string (case-insensitive).
+ *
+ * @param level the level string (debug, info, warn, error)
+ * @return the corresponding LogLevel
+ * @throws IllegalArgumentException if the string does not match any level
+ */
+ public static LogLevel fromString(String level) {
+ return LogLevel.valueOf(level.toUpperCase());
+ }
+}
diff --git a/src/main/java/com/schematic/api/core/Logger.java b/src/main/java/com/schematic/api/core/Logger.java
new file mode 100644
index 0000000..25e4696
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/Logger.java
@@ -0,0 +1,97 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+/**
+ * SDK logger that filters messages based on level and silent mode.
+ *
+ * Silent by default — no log output unless explicitly configured.
+ * Create via {@link LogConfig} or directly:
+ *
{@code
+ * Logger logger = new Logger(LogLevel.DEBUG, new ConsoleLogger(), false);
+ * logger.debug("request sent");
+ * }
+ */
+public final class Logger {
+
+ private static final Logger DEFAULT = new Logger(LogLevel.INFO, new ConsoleLogger(), true);
+
+ private final LogLevel level;
+ private final ILogger logger;
+ private final boolean silent;
+
+ public Logger(LogLevel level, ILogger logger, boolean silent) {
+ this.level = level;
+ this.logger = logger;
+ this.silent = silent;
+ }
+
+ /**
+ * Returns a default silent logger (no output).
+ */
+ public static Logger getDefault() {
+ return DEFAULT;
+ }
+
+ /**
+ * Creates a Logger from a {@link LogConfig}. If config is {@code null}, returns the default silent logger.
+ */
+ public static Logger from(LogConfig config) {
+ if (config == null) {
+ return DEFAULT;
+ }
+ return new Logger(config.level(), config.logger(), config.silent());
+ }
+
+ /**
+ * Creates a Logger from an {@code Optional}. If empty, returns the default silent logger.
+ */
+ public static Logger from(java.util.Optional config) {
+ return config.map(Logger::from).orElse(DEFAULT);
+ }
+
+ private boolean shouldLog(LogLevel messageLevel) {
+ return !silent && level.getValue() <= messageLevel.getValue();
+ }
+
+ public boolean isDebug() {
+ return shouldLog(LogLevel.DEBUG);
+ }
+
+ public boolean isInfo() {
+ return shouldLog(LogLevel.INFO);
+ }
+
+ public boolean isWarn() {
+ return shouldLog(LogLevel.WARN);
+ }
+
+ public boolean isError() {
+ return shouldLog(LogLevel.ERROR);
+ }
+
+ public void debug(String message) {
+ if (isDebug()) {
+ logger.debug(message);
+ }
+ }
+
+ public void info(String message) {
+ if (isInfo()) {
+ logger.info(message);
+ }
+ }
+
+ public void warn(String message) {
+ if (isWarn()) {
+ logger.warn(message);
+ }
+ }
+
+ public void error(String message) {
+ if (isError()) {
+ logger.error(message);
+ }
+ }
+}
diff --git a/src/main/java/com/schematic/api/core/LoggingInterceptor.java b/src/main/java/com/schematic/api/core/LoggingInterceptor.java
new file mode 100644
index 0000000..c7d2dd5
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/LoggingInterceptor.java
@@ -0,0 +1,104 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import okhttp3.Interceptor;
+import okhttp3.Request;
+import okhttp3.Response;
+
+/**
+ * OkHttp interceptor that logs HTTP requests and responses.
+ *
+ * Logs request method, URL, and headers (with sensitive values redacted) at debug level.
+ * Logs response status at debug level, and 4xx/5xx responses at error level.
+ * Does nothing if the logger is silent.
+ */
+public final class LoggingInterceptor implements Interceptor {
+
+ private static final Set SENSITIVE_HEADERS = new HashSet<>(Arrays.asList(
+ "authorization",
+ "www-authenticate",
+ "x-api-key",
+ "api-key",
+ "apikey",
+ "x-api-token",
+ "x-auth-token",
+ "auth-token",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "cookie",
+ "set-cookie",
+ "x-csrf-token",
+ "x-xsrf-token",
+ "x-session-token",
+ "x-access-token"));
+
+ private final Logger logger;
+
+ public LoggingInterceptor(Logger logger) {
+ this.logger = logger;
+ }
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ Request request = chain.request();
+
+ if (logger.isDebug()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("HTTP Request: ").append(request.method()).append(" ").append(request.url());
+ sb.append(" headers={");
+ boolean first = true;
+ for (String name : request.headers().names()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ sb.append(name).append("=");
+ if (SENSITIVE_HEADERS.contains(name.toLowerCase())) {
+ sb.append("[REDACTED]");
+ } else {
+ sb.append(request.header(name));
+ }
+ first = false;
+ }
+ sb.append("}");
+ sb.append(" has_body=").append(request.body() != null);
+ logger.debug(sb.toString());
+ }
+
+ Response response = chain.proceed(request);
+
+ if (logger.isDebug()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("HTTP Response: status=").append(response.code());
+ sb.append(" url=").append(response.request().url());
+ sb.append(" headers={");
+ boolean first = true;
+ for (String name : response.headers().names()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ sb.append(name).append("=");
+ if (SENSITIVE_HEADERS.contains(name.toLowerCase())) {
+ sb.append("[REDACTED]");
+ } else {
+ sb.append(response.header(name));
+ }
+ first = false;
+ }
+ sb.append("}");
+ logger.debug(sb.toString());
+ }
+
+ if (response.code() >= 400 && logger.isError()) {
+ logger.error("HTTP Error: status=" + response.code() + " url="
+ + response.request().url());
+ }
+
+ return response;
+ }
+}
diff --git a/src/main/java/com/schematic/api/core/NullableNonemptyFilter.java b/src/main/java/com/schematic/api/core/NullableNonemptyFilter.java
index f230447..db75c3e 100644
--- a/src/main/java/com/schematic/api/core/NullableNonemptyFilter.java
+++ b/src/main/java/com/schematic/api/core/NullableNonemptyFilter.java
@@ -14,6 +14,9 @@ public boolean equals(Object o) {
}
private boolean isOptionalEmpty(Object o) {
- return o instanceof Optional && !((Optional>) o).isPresent();
+ if (o instanceof Optional) {
+ return !((Optional>) o).isPresent();
+ }
+ return false;
}
}
diff --git a/src/main/java/com/schematic/api/core/ObjectMappers.java b/src/main/java/com/schematic/api/core/ObjectMappers.java
index fd6b153..245e74e 100644
--- a/src/main/java/com/schematic/api/core/ObjectMappers.java
+++ b/src/main/java/com/schematic/api/core/ObjectMappers.java
@@ -4,6 +4,7 @@
package com.schematic.api.core;
import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
@@ -17,6 +18,7 @@ public final class ObjectMappers {
.addModule(new Jdk8Module())
.addModule(new JavaTimeModule())
.addModule(DateTimeDeserializer.getModule())
+ .addModule(DoubleSerializer.getModule())
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
@@ -33,4 +35,12 @@ public static String stringify(Object o) {
return o.getClass().getName() + "@" + Integer.toHexString(o.hashCode());
}
}
+
+ public static Object parseErrorBody(String responseBodyString) {
+ try {
+ return JSON_MAPPER.readValue(responseBodyString, Object.class);
+ } catch (JsonProcessingException ignored) {
+ return responseBodyString;
+ }
+ }
}
diff --git a/src/main/java/com/schematic/api/core/RequestOptions.java b/src/main/java/com/schematic/api/core/RequestOptions.java
index 4618051..7249e3c 100644
--- a/src/main/java/com/schematic/api/core/RequestOptions.java
+++ b/src/main/java/com/schematic/api/core/RequestOptions.java
@@ -20,17 +20,25 @@ public final class RequestOptions {
private final Map> headerSuppliers;
+ private final Map queryParameters;
+
+ private final Map> queryParameterSuppliers;
+
private RequestOptions(
String apiKey,
Optional timeout,
TimeUnit timeoutTimeUnit,
Map headers,
- Map> headerSuppliers) {
+ Map> headerSuppliers,
+ Map queryParameters,
+ Map> queryParameterSuppliers) {
this.apiKey = apiKey;
this.timeout = timeout;
this.timeoutTimeUnit = timeoutTimeUnit;
this.headers = headers;
this.headerSuppliers = headerSuppliers;
+ this.queryParameters = queryParameters;
+ this.queryParameterSuppliers = queryParameterSuppliers;
}
public Optional getTimeout() {
@@ -53,6 +61,14 @@ public Map getHeaders() {
return headers;
}
+ public Map getQueryParameters() {
+ Map queryParameters = new HashMap<>(this.queryParameters);
+ this.queryParameterSuppliers.forEach((key, supplier) -> {
+ queryParameters.put(key, supplier.get());
+ });
+ return queryParameters;
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -68,6 +84,10 @@ public static class Builder {
private final Map> headerSuppliers = new HashMap<>();
+ private final Map queryParameters = new HashMap<>();
+
+ private final Map> queryParameterSuppliers = new HashMap<>();
+
public Builder apiKey(String apiKey) {
this.apiKey = apiKey;
return this;
@@ -94,8 +114,25 @@ public Builder addHeader(String key, Supplier value) {
return this;
}
+ public Builder addQueryParameter(String key, String value) {
+ this.queryParameters.put(key, value);
+ return this;
+ }
+
+ public Builder addQueryParameter(String key, Supplier value) {
+ this.queryParameterSuppliers.put(key, value);
+ return this;
+ }
+
public RequestOptions build() {
- return new RequestOptions(apiKey, timeout, timeoutTimeUnit, headers, headerSuppliers);
+ return new RequestOptions(
+ apiKey,
+ timeout,
+ timeoutTimeUnit,
+ headers,
+ headerSuppliers,
+ queryParameters,
+ queryParameterSuppliers);
}
}
}
diff --git a/src/main/java/com/schematic/api/core/Rfc2822DateTimeDeserializer.java b/src/main/java/com/schematic/api/core/Rfc2822DateTimeDeserializer.java
new file mode 100644
index 0000000..ed0ff30
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/Rfc2822DateTimeDeserializer.java
@@ -0,0 +1,25 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import java.io.IOException;
+import java.time.OffsetDateTime;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Custom deserializer that handles converting RFC 2822 (RFC 1123) dates into {@link OffsetDateTime} objects.
+ * This is used for fields with format "date-time-rfc-2822", such as Twilio's dateCreated, dateSent, dateUpdated.
+ */
+public class Rfc2822DateTimeDeserializer extends JsonDeserializer {
+
+ @Override
+ public OffsetDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+ String raw = parser.getValueAsString();
+ return ZonedDateTime.parse(raw, DateTimeFormatter.RFC_1123_DATE_TIME).toOffsetDateTime();
+ }
+}
diff --git a/src/main/java/com/schematic/api/core/SseEvent.java b/src/main/java/com/schematic/api/core/SseEvent.java
new file mode 100644
index 0000000..1640f67
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/SseEvent.java
@@ -0,0 +1,114 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Represents a Server-Sent Event with all standard fields.
+ * Used for event-level discrimination where the discriminator is at the SSE envelope level.
+ *
+ * @param The type of the data field
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final class SseEvent {
+ private final String event;
+ private final T data;
+ private final String id;
+ private final Long retry;
+
+ private SseEvent(String event, T data, String id, Long retry) {
+ this.event = event;
+ this.data = data;
+ this.id = id;
+ this.retry = retry;
+ }
+
+ @JsonProperty("event")
+ public Optional getEvent() {
+ return Optional.ofNullable(event);
+ }
+
+ @JsonProperty("data")
+ public T getData() {
+ return data;
+ }
+
+ @JsonProperty("id")
+ public Optional getId() {
+ return Optional.ofNullable(id);
+ }
+
+ @JsonProperty("retry")
+ public Optional getRetry() {
+ return Optional.ofNullable(retry);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SseEvent> sseEvent = (SseEvent>) o;
+ return Objects.equals(event, sseEvent.event)
+ && Objects.equals(data, sseEvent.data)
+ && Objects.equals(id, sseEvent.id)
+ && Objects.equals(retry, sseEvent.retry);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(event, data, id, retry);
+ }
+
+ @Override
+ public String toString() {
+ return "SseEvent{" + "event='"
+ + event + '\'' + ", data="
+ + data + ", id='"
+ + id + '\'' + ", retry="
+ + retry + '}';
+ }
+
+ public static Builder builder() {
+ return new Builder<>();
+ }
+
+ public static final class Builder {
+ private String event;
+ private T data;
+ private String id;
+ private Long retry;
+
+ private Builder() {}
+
+ public Builder event(String event) {
+ this.event = event;
+ return this;
+ }
+
+ public Builder data(T data) {
+ this.data = data;
+ return this;
+ }
+
+ public Builder id(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder retry(Long retry) {
+ this.retry = retry;
+ return this;
+ }
+
+ public SseEvent build() {
+ return new SseEvent<>(event, data, id, retry);
+ }
+ }
+}
diff --git a/src/main/java/com/schematic/api/core/SseEventParser.java b/src/main/java/com/schematic/api/core/SseEventParser.java
new file mode 100644
index 0000000..eafd44d
--- /dev/null
+++ b/src/main/java/com/schematic/api/core/SseEventParser.java
@@ -0,0 +1,228 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.schematic.api.core;
+
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import com.fasterxml.jackson.core.type.TypeReference;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Utility class for parsing Server-Sent Events with support for discriminated unions.
+ *
+ * Handles two discrimination patterns:
+ *
+ * - Data-level discrimination: The discriminator (e.g., 'type') is inside the JSON data payload.
+ * Jackson's polymorphic deserialization handles this automatically.
+ * - Event-level discrimination: The discriminator (e.g., 'event') is at the SSE envelope level.
+ * This requires constructing the full SSE envelope for Jackson to process.
+ *
+ */
+public final class SseEventParser {
+
+ private static final Set SSE_ENVELOPE_FIELDS = new HashSet<>(Arrays.asList("event", "data", "id", "retry"));
+
+ private SseEventParser() {
+ // Utility class
+ }
+
+ /**
+ * Parse an SSE event using event-level discrimination.
+ *
+ * Constructs the full SSE envelope object with event, data, id, and retry fields,
+ * then deserializes it to the target union type.
+ *
+ * @param eventType The SSE event type (from event: field)
+ * @param data The SSE data content (from data: field)
+ * @param id The SSE event ID (from id: field), may be null
+ * @param retry The SSE retry value (from retry: field), may be null
+ * @param unionClass The target union class
+ * @param discriminatorProperty The property name used for discrimination (e.g., "event")
+ * @param The target type
+ * @return The deserialized object
+ */
+ public static T parseEventLevelUnion(
+ String eventType, String data, String id, Long retry, Class unionClass, String discriminatorProperty) {
+ try {
+ // Determine if data should be parsed as JSON based on the variant's expected type
+ Object parsedData = parseDataForVariant(eventType, data, unionClass, discriminatorProperty);
+
+ // Construct the SSE envelope object
+ Map envelope = new HashMap<>();
+ envelope.put(discriminatorProperty, eventType);
+ envelope.put("data", parsedData);
+ if (id != null) {
+ envelope.put("id", id);
+ }
+ if (retry != null) {
+ envelope.put("retry", retry);
+ }
+
+ // Serialize to JSON and deserialize to target type
+ String envelopeJson = ObjectMappers.JSON_MAPPER.writeValueAsString(envelope);
+ return ObjectMappers.JSON_MAPPER.readValue(envelopeJson, unionClass);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to parse SSE event with event-level discrimination", e);
+ }
+ }
+
+ /**
+ * Parse an SSE event using data-level discrimination.
+ *
+ * Simply parses the data field as JSON and deserializes it to the target type.
+ * Jackson's polymorphic deserialization handles the discrimination automatically.
+ *
+ * @param data The SSE data content (from data: field)
+ * @param valueType The target type
+ * @param The target type
+ * @return The deserialized object
+ */
+ public static T parseDataLevelUnion(String data, Class valueType) {
+ try {
+ return ObjectMappers.JSON_MAPPER.readValue(data, valueType);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to parse SSE data with data-level discrimination", e);
+ }
+ }
+
+ /**
+ * Determines if the given discriminator property indicates event-level discrimination.
+ * Event-level discrimination occurs when the discriminator is an SSE envelope field.
+ *
+ * @param discriminatorProperty The discriminator property name
+ * @return true if event-level discrimination, false otherwise
+ */
+ public static boolean isEventLevelDiscrimination(String discriminatorProperty) {
+ return SSE_ENVELOPE_FIELDS.contains(discriminatorProperty);
+ }
+
+ /**
+ * Attempts to find the discriminator property from the union class's Jackson annotations.
+ *
+ * @param unionClass The union class to inspect
+ * @return The discriminator property name, or empty if not found
+ */
+ public static Optional findDiscriminatorProperty(Class> unionClass) {
+ try {
+ // Look for JsonTypeInfo on the class itself
+ JsonTypeInfo typeInfo = unionClass.getAnnotation(JsonTypeInfo.class);
+ if (typeInfo != null && !typeInfo.property().isEmpty()) {
+ return Optional.of(typeInfo.property());
+ }
+
+ // Look for inner Value interface with JsonTypeInfo
+ for (Class> innerClass : unionClass.getDeclaredClasses()) {
+ typeInfo = innerClass.getAnnotation(JsonTypeInfo.class);
+ if (typeInfo != null && !typeInfo.property().isEmpty()) {
+ return Optional.of(typeInfo.property());
+ }
+ }
+ } catch (Exception e) {
+ // Ignore reflection errors
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Parse the data field based on what the matching variant expects.
+ * If the variant expects a String for its data field, returns the raw string.
+ * Otherwise, parses the data as JSON.
+ */
+ private static Object parseDataForVariant(
+ String eventType, String data, Class> unionClass, String discriminatorProperty) {
+ if (data == null || data.isEmpty()) {
+ return data;
+ }
+
+ try {
+ // Try to find the variant class that matches this event type
+ Class> variantClass = findVariantClass(unionClass, eventType, discriminatorProperty);
+ if (variantClass != null) {
+ // Check if the variant expects a String for the data field
+ Field dataField = findField(variantClass, "data");
+ if (dataField != null && String.class.equals(dataField.getType())) {
+ // Variant expects String - return raw data
+ return data;
+ }
+ }
+
+ // Try to parse as JSON
+ return ObjectMappers.JSON_MAPPER.readValue(data, new TypeReference