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/newscatcher/api/core/LoggingInterceptor.java b/src/main/java/com/newscatcher/api/core/LoggingInterceptor.java
new file mode 100644
index 0000000..7568199
--- /dev/null
+++ b/src/main/java/com/newscatcher/api/core/LoggingInterceptor.java
@@ -0,0 +1,104 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.newscatcher.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/newscatcher/api/core/NewscatcherApiApiException.java b/src/main/java/com/newscatcher/api/core/NewscatcherApiApiException.java
index 5aea8cc..58ad4a3 100644
--- a/src/main/java/com/newscatcher/api/core/NewscatcherApiApiException.java
+++ b/src/main/java/com/newscatcher/api/core/NewscatcherApiApiException.java
@@ -65,9 +65,9 @@ public Map> headers() {
return this.headers;
}
- @java.lang.Override
+ @Override
public String toString() {
return "NewscatcherApiApiException{" + "message: " + getMessage() + ", statusCode: " + statusCode + ", body: "
- + body + "}";
+ + ObjectMappers.stringify(body) + "}";
}
}
diff --git a/src/main/java/com/newscatcher/api/core/NullableNonemptyFilter.java b/src/main/java/com/newscatcher/api/core/NullableNonemptyFilter.java
index fe77d6f..8f70f6e 100644
--- a/src/main/java/com/newscatcher/api/core/NullableNonemptyFilter.java
+++ b/src/main/java/com/newscatcher/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/newscatcher/api/core/ObjectMappers.java b/src/main/java/com/newscatcher/api/core/ObjectMappers.java
index cb2c72f..8e78be8 100644
--- a/src/main/java/com/newscatcher/api/core/ObjectMappers.java
+++ b/src/main/java/com/newscatcher/api/core/ObjectMappers.java
@@ -4,6 +4,7 @@
package com.newscatcher.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;
@@ -33,4 +34,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/newscatcher/api/core/RequestOptions.java b/src/main/java/com/newscatcher/api/core/RequestOptions.java
index dd39cdf..a6cb9f7 100644
--- a/src/main/java/com/newscatcher/api/core/RequestOptions.java
+++ b/src/main/java/com/newscatcher/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/newscatcher/api/core/RetryInterceptor.java b/src/main/java/com/newscatcher/api/core/RetryInterceptor.java
index d084037..265fe1f 100644
--- a/src/main/java/com/newscatcher/api/core/RetryInterceptor.java
+++ b/src/main/java/com/newscatcher/api/core/RetryInterceptor.java
@@ -5,6 +5,9 @@
import java.io.IOException;
import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import java.util.Optional;
import java.util.Random;
import okhttp3.Interceptor;
@@ -12,7 +15,10 @@
public class RetryInterceptor implements Interceptor {
- private static final Duration ONE_SECOND = Duration.ofSeconds(1);
+ private static final Duration INITIAL_RETRY_DELAY = Duration.ofMillis(1000);
+ private static final Duration MAX_RETRY_DELAY = Duration.ofMillis(60000);
+ private static final double JITTER_FACTOR = 0.2;
+
private final ExponentialBackoff backoff;
private final Random random = new Random();
@@ -32,7 +38,7 @@ public Response intercept(Chain chain) throws IOException {
}
private Response retryChain(Response response, Chain chain) throws IOException {
- Optional nextBackoff = this.backoff.nextBackoff();
+ Optional nextBackoff = this.backoff.nextBackoff(response);
while (nextBackoff.isPresent()) {
try {
Thread.sleep(nextBackoff.get().toMillis());
@@ -42,7 +48,7 @@ private Response retryChain(Response response, Chain chain) throws IOException {
response.close();
response = chain.proceed(chain.request());
if (shouldRetry(response.code())) {
- nextBackoff = this.backoff.nextBackoff();
+ nextBackoff = this.backoff.nextBackoff(response);
} else {
return response;
}
@@ -51,6 +57,102 @@ private Response retryChain(Response response, Chain chain) throws IOException {
return response;
}
+ /**
+ * Calculates the retry delay from response headers, with fallback to exponential backoff.
+ * Priority: Retry-After > X-RateLimit-Reset > Exponential Backoff
+ */
+ private Duration getRetryDelayFromHeaders(Response response, int retryAttempt) {
+ // Check for Retry-After header first (RFC 7231), with no jitter
+ String retryAfter = response.header("Retry-After");
+ if (retryAfter != null) {
+ // Parse as number of seconds...
+ Optional secondsDelay = tryParseLong(retryAfter)
+ .map(seconds -> seconds * 1000)
+ .filter(delayMs -> delayMs > 0)
+ .map(delayMs -> Math.min(delayMs, MAX_RETRY_DELAY.toMillis()))
+ .map(Duration::ofMillis);
+ if (secondsDelay.isPresent()) {
+ return secondsDelay.get();
+ }
+
+ // ...or as an HTTP date; both are valid
+ Optional dateDelay = tryParseHttpDate(retryAfter)
+ .map(resetTime -> resetTime.toInstant().toEpochMilli() - System.currentTimeMillis())
+ .filter(delayMs -> delayMs > 0)
+ .map(delayMs -> Math.min(delayMs, MAX_RETRY_DELAY.toMillis()))
+ .map(Duration::ofMillis);
+ if (dateDelay.isPresent()) {
+ return dateDelay.get();
+ }
+ }
+
+ // Then check for industry-standard X-RateLimit-Reset header, with positive jitter
+ String rateLimitReset = response.header("X-RateLimit-Reset");
+ if (rateLimitReset != null) {
+ // Assume Unix timestamp in epoch seconds
+ Optional rateLimitDelay = tryParseLong(rateLimitReset)
+ .map(resetTimeSeconds -> (resetTimeSeconds * 1000) - System.currentTimeMillis())
+ .filter(delayMs -> delayMs > 0)
+ .map(delayMs -> Math.min(delayMs, MAX_RETRY_DELAY.toMillis()))
+ .map(this::addPositiveJitter)
+ .map(Duration::ofMillis);
+ if (rateLimitDelay.isPresent()) {
+ return rateLimitDelay.get();
+ }
+ }
+
+ // Fall back to exponential backoff, with symmetric jitter
+ long baseDelay = INITIAL_RETRY_DELAY.toMillis() * (1L << retryAttempt); // 2^retryAttempt
+ long cappedDelay = Math.min(baseDelay, MAX_RETRY_DELAY.toMillis());
+ return Duration.ofMillis(addSymmetricJitter(cappedDelay));
+ }
+
+ /**
+ * Attempts to parse a string as a long, returning empty Optional on failure.
+ */
+ private Optional tryParseLong(String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(Long.parseLong(value));
+ } catch (NumberFormatException e) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Attempts to parse a string as an HTTP date (RFC 1123), returning empty Optional on failure.
+ */
+ private Optional tryParseHttpDate(String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ try {
+ return Optional.of(ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME));
+ } catch (DateTimeParseException e) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Adds positive jitter (100-120% of original value) to prevent thundering herd.
+ * Used for X-RateLimit-Reset header delays.
+ */
+ private long addPositiveJitter(long delayMs) {
+ double jitterMultiplier = 1.0 + (random.nextDouble() * JITTER_FACTOR);
+ return (long) (delayMs * jitterMultiplier);
+ }
+
+ /**
+ * Adds symmetric jitter (90-110% of original value) to prevent thundering herd.
+ * Used for exponential backoff delays.
+ */
+ private long addSymmetricJitter(long delayMs) {
+ double jitterMultiplier = 1.0 + ((random.nextDouble() - 0.5) * JITTER_FACTOR);
+ return (long) (delayMs * jitterMultiplier);
+ }
+
private static boolean shouldRetry(int statusCode) {
return statusCode == 408 || statusCode == 429 || statusCode >= 500;
}
@@ -65,14 +167,14 @@ private final class ExponentialBackoff {
this.maxNumRetries = maxNumRetries;
}
- public Optional nextBackoff() {
- retryNumber += 1;
- if (retryNumber > maxNumRetries) {
+ public Optional nextBackoff(Response response) {
+ if (retryNumber >= maxNumRetries) {
return Optional.empty();
}
- int upperBound = (int) Math.pow(2, retryNumber);
- return Optional.of(ONE_SECOND.multipliedBy(random.nextInt(upperBound)));
+ Duration delay = getRetryDelayFromHeaders(response, retryNumber);
+ retryNumber += 1;
+ return Optional.of(delay);
}
}
}
diff --git a/src/main/java/com/newscatcher/api/core/Rfc2822DateTimeDeserializer.java b/src/main/java/com/newscatcher/api/core/Rfc2822DateTimeDeserializer.java
new file mode 100644
index 0000000..345f71c
--- /dev/null
+++ b/src/main/java/com/newscatcher/api/core/Rfc2822DateTimeDeserializer.java
@@ -0,0 +1,25 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.newscatcher.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/newscatcher/api/core/SseEvent.java b/src/main/java/com/newscatcher/api/core/SseEvent.java
new file mode 100644
index 0000000..28cbcc2
--- /dev/null
+++ b/src/main/java/com/newscatcher/api/core/SseEvent.java
@@ -0,0 +1,114 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.newscatcher.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/newscatcher/api/core/SseEventParser.java b/src/main/java/com/newscatcher/api/core/SseEventParser.java
new file mode 100644
index 0000000..da2e974
--- /dev/null
+++ b/src/main/java/com/newscatcher/api/core/SseEventParser.java
@@ -0,0 +1,228 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.newscatcher.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