Skip to content

Commit 9ef242f

Browse files
committed
feat: add lifecycle-bound SSE stream and typed per-endpoint adapter
Introduce SseStream: an AutoCloseable Iterable<ServerSentEvent> that owns the underlying HTTP response. Closing the stream — explicitly, via use {} / try-with-resources, or implicitly when iteration runs to completion — closes the response and releases its connection, so a partial consume never strands the body. This mirrors the close-on-partial-consume invariant PagedIterable enforces. The previously doc-only "do not iterate twice" warning is now an enforced single-pass guard, and iteration after close is rejected. Add a reusable per-endpoint adapter, TypedSseStream<T>, that maps raw events to typed models via a caller-supplied SseEventMapper. The mapper receives the event name and joined data and returns a decoded value, Skip, or a Done sentinel; it is the seam where the Serde SPI is invoked and where per-API done-sentinel and error-envelope conventions live. Mapping is applied lazily, one element at a time, so a partial consume decodes only the events taken. Closing the typed adapter propagates to the underlying stream. Both surfaces are hand-written runtime primitives usable today; a code generator can target them later without embedding any per-API convention in core. Closes #35 Closes #62
1 parent b16a0d5 commit 9ef242f

7 files changed

Lines changed: 817 additions & 0 deletions

File tree

sdk-core/api/sdk-core.api

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,6 +1560,58 @@ public final class org/dexpace/sdk/core/http/sse/ServerSentEventReader {
15601560
public final class org/dexpace/sdk/core/http/sse/ServerSentEvents {
15611561
public static final fun readServerSentEvents (Lorg/dexpace/sdk/core/io/BufferedSource;)Lkotlin/sequences/Sequence;
15621562
public static final fun readServerSentEventsAsIterable (Lorg/dexpace/sdk/core/io/BufferedSource;)Ljava/lang/Iterable;
1563+
public static final fun sseStream (Lorg/dexpace/sdk/core/http/response/Response;)Lorg/dexpace/sdk/core/http/sse/SseStream;
1564+
public static final fun typed (Lorg/dexpace/sdk/core/http/sse/SseStream;Lorg/dexpace/sdk/core/http/sse/SseEventMapper;)Lorg/dexpace/sdk/core/http/sse/TypedSseStream;
1565+
}
1566+
1567+
public abstract interface class org/dexpace/sdk/core/http/sse/SseEventMapper {
1568+
public static final field Companion Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Companion;
1569+
public static fun done ()Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Result;
1570+
public abstract fun map (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Result;
1571+
public static fun skip ()Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Result;
1572+
public static fun value (Ljava/lang/Object;)Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Result;
1573+
}
1574+
1575+
public final class org/dexpace/sdk/core/http/sse/SseEventMapper$Companion {
1576+
public final fun done ()Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Result;
1577+
public final fun skip ()Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Result;
1578+
public final fun value (Ljava/lang/Object;)Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Result;
1579+
}
1580+
1581+
public abstract class org/dexpace/sdk/core/http/sse/SseEventMapper$Result {
1582+
}
1583+
1584+
public final class org/dexpace/sdk/core/http/sse/SseEventMapper$Result$Done : org/dexpace/sdk/core/http/sse/SseEventMapper$Result {
1585+
public static final field INSTANCE Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Result$Done;
1586+
}
1587+
1588+
public final class org/dexpace/sdk/core/http/sse/SseEventMapper$Result$Skip : org/dexpace/sdk/core/http/sse/SseEventMapper$Result {
1589+
public static final field INSTANCE Lorg/dexpace/sdk/core/http/sse/SseEventMapper$Result$Skip;
1590+
}
1591+
1592+
public final class org/dexpace/sdk/core/http/sse/SseEventMapper$Result$Value : org/dexpace/sdk/core/http/sse/SseEventMapper$Result {
1593+
public fun <init> (Ljava/lang/Object;)V
1594+
public final fun getModel ()Ljava/lang/Object;
1595+
}
1596+
1597+
public final class org/dexpace/sdk/core/http/sse/SseStream : java/lang/AutoCloseable, java/lang/Iterable, kotlin/jvm/internal/markers/KMappedMarker {
1598+
public static final field Companion Lorg/dexpace/sdk/core/http/sse/SseStream$Companion;
1599+
public synthetic fun <init> (Lorg/dexpace/sdk/core/http/sse/ServerSentEventReader;Ljava/io/Closeable;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
1600+
public fun close ()V
1601+
public static final fun from (Lorg/dexpace/sdk/core/io/BufferedSource;Ljava/io/Closeable;)Lorg/dexpace/sdk/core/http/sse/SseStream;
1602+
public static final fun fromReader (Lorg/dexpace/sdk/core/http/sse/ServerSentEventReader;Ljava/io/Closeable;)Lorg/dexpace/sdk/core/http/sse/SseStream;
1603+
public fun iterator ()Ljava/util/Iterator;
1604+
}
1605+
1606+
public final class org/dexpace/sdk/core/http/sse/SseStream$Companion {
1607+
public final fun from (Lorg/dexpace/sdk/core/io/BufferedSource;Ljava/io/Closeable;)Lorg/dexpace/sdk/core/http/sse/SseStream;
1608+
public final fun fromReader (Lorg/dexpace/sdk/core/http/sse/ServerSentEventReader;Ljava/io/Closeable;)Lorg/dexpace/sdk/core/http/sse/SseStream;
1609+
}
1610+
1611+
public final class org/dexpace/sdk/core/http/sse/TypedSseStream : java/lang/AutoCloseable, java/lang/Iterable, kotlin/jvm/internal/markers/KMappedMarker {
1612+
public fun <init> (Lorg/dexpace/sdk/core/http/sse/SseStream;Lorg/dexpace/sdk/core/http/sse/SseEventMapper;)V
1613+
public fun close ()V
1614+
public fun iterator ()Ljava/util/Iterator;
15631615
}
15641616

15651617
public final class org/dexpace/sdk/core/instrumentation/ClientLogger {

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/sse/ServerSentEventExtensions.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
package org.dexpace.sdk.core.http.sse
1111

12+
import org.dexpace.sdk.core.http.response.Response
1213
import org.dexpace.sdk.core.io.BufferedSource
1314

1415
/**
@@ -53,3 +54,36 @@ public fun BufferedSource.readServerSentEvents(): Sequence<ServerSentEvent> {
5354
*/
5455
public fun BufferedSource.readServerSentEventsAsIterable(): Iterable<ServerSentEvent> =
5556
readServerSentEvents().asIterable()
57+
58+
/**
59+
* Opens an [SseStream] over this response's body, binding the stream's lifecycle to the
60+
* response: closing the returned stream (explicitly, via `use {}` / try-with-resources, or
61+
* implicitly when iteration completes) closes the response and releases its connection.
62+
*
63+
* Use this on a streaming response to consume events without stranding the body on a partial
64+
* consume:
65+
*
66+
* ```
67+
* response.sseStream().use { stream ->
68+
* for (event in stream) { /* handle event */ }
69+
* }
70+
* ```
71+
*
72+
* @throws IllegalStateException if the response has no body.
73+
*/
74+
public fun Response.sseStream(): SseStream {
75+
val responseBody = checkNotNull(body) { "Response has no body to stream as Server-Sent Events" }
76+
return SseStream.from(responseBody.source(), this)
77+
}
78+
79+
/**
80+
* Wraps this raw [SseStream] in a [TypedSseStream] that maps each event to a model `T` via
81+
* [mapper], decoding lazily on consume. The returned adapter inherits this stream's lifecycle:
82+
* closing it closes this stream (and the response).
83+
*
84+
* The [mapper] is the per-endpoint seam — it is where the [org.dexpace.sdk.core.serde.Serde]
85+
* SPI is called and where done-sentinel / error-envelope conventions live.
86+
*
87+
* @param mapper Maps a raw event to a [SseEventMapper.Result].
88+
*/
89+
public fun <T> SseStream.typed(mapper: SseEventMapper<T>): TypedSseStream<T> = TypedSseStream(this, mapper)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
package org.dexpace.sdk.core.http.sse
9+
10+
/**
11+
* Maps a raw [ServerSentEvent] to a typed model, or signals that the event should be skipped
12+
* or that the stream is finished.
13+
*
14+
* This is the per-endpoint seam between core's format-agnostic SSE plumbing and an API's
15+
* own conventions. A mapper receives the event's `event:` name (or `null` if the server sent
16+
* no `event:` field) and the joined `data:` payload, and returns:
17+
*
18+
* - a decoded value `T` — yielded to the caller;
19+
* - [Skip] — the event carries no model (a keep-alive comment, a bare `id:` cursor, an
20+
* ignored event type); iteration silently moves on;
21+
* - [Done] — a sentinel event marking end of stream (e.g. OpenAI's `data: [DONE]`); iteration
22+
* terminates cleanly and the underlying [SseStream] closes.
23+
*
24+
* The mapper is the place to call the [org.dexpace.sdk.core.serde.Serde] SPI — for example
25+
* `serde.deserializer.deserialize(data, MyDto::class.java)` — and the place to translate an
26+
* error-envelope event into a thrown exception. Core deliberately holds **no** sentinel or
27+
* error conventions; those are per-API and live entirely in the caller-supplied mapper.
28+
*
29+
* Mapping is applied **lazily, one element at a time**, as the typed iterator is pulled, so a
30+
* partially consumed stream only decodes the events actually taken.
31+
*
32+
* Kotlin callers may pass a lambda; Java callers may use a lambda or implement this interface.
33+
*
34+
* @param T The model type events decode into.
35+
*/
36+
public fun interface SseEventMapper<out T> {
37+
/**
38+
* Maps one event to a [Result].
39+
*
40+
* @param eventName The event's `event:` field, or `null` if the server omitted it.
41+
* @param data The event's `data:` lines joined with `\n` (empty string if the event had
42+
* no `data:` field). Joining matches the conventional WHATWG client-side reconstruction.
43+
* @return [value] to yield a decoded model, [Skip] to drop the event, or [Done] to end
44+
* the stream.
45+
* @throws RuntimeException the mapper may throw (e.g. on a decode failure or a mapped
46+
* error-envelope); the exception propagates to the consumer's pull.
47+
*/
48+
public fun map(
49+
eventName: String?,
50+
data: String,
51+
): Result<T>
52+
53+
/**
54+
* The outcome of mapping a single SSE event: a decoded [value], [Skip], or [Done].
55+
*
56+
* @param T The model type.
57+
*/
58+
public sealed class Result<out T> {
59+
/**
60+
* A successfully decoded model to yield to the consumer.
61+
*
62+
* @property model The decoded value.
63+
*/
64+
public class Value<out T>(public val model: T) : Result<T>()
65+
66+
/**
67+
* The event carries no model and should be silently skipped (keep-alives, bare
68+
* cursors, ignored event types). Iteration continues with the next event.
69+
*/
70+
public object Skip : Result<Nothing>()
71+
72+
/**
73+
* The event is a done-sentinel: iteration terminates cleanly and the backing
74+
* [SseStream] closes. No model is yielded for the sentinel event itself.
75+
*/
76+
public object Done : Result<Nothing>()
77+
}
78+
79+
public companion object {
80+
/** Wraps [model] in a [Result.Value]. */
81+
@JvmStatic
82+
public fun <T> value(model: T): Result<T> = Result.Value(model)
83+
84+
/** The shared [Result.Skip] singleton, exposed for Java callers. */
85+
@JvmStatic
86+
public fun skip(): Result<Nothing> = Result.Skip
87+
88+
/** The shared [Result.Done] singleton, exposed for Java callers. */
89+
@JvmStatic
90+
public fun done(): Result<Nothing> = Result.Done
91+
}
92+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright (c) 2026 dexpace and Omar Aljarrah
3+
*
4+
* Licensed under the MIT License. See LICENSE in the project root.
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
package org.dexpace.sdk.core.http.sse
9+
10+
import org.dexpace.sdk.core.io.BufferedSource
11+
import java.io.Closeable
12+
import java.util.concurrent.atomic.AtomicBoolean
13+
import java.util.concurrent.locks.ReentrantLock
14+
import kotlin.concurrent.withLock
15+
16+
/**
17+
* An [AutoCloseable] [Iterable] of [ServerSentEvent] whose lifecycle is bound to the
18+
* underlying HTTP response.
19+
*
20+
* `SseStream` closes the [resource] it was opened over — typically the
21+
* [org.dexpace.sdk.core.http.response.Response] (or its body) — whenever the stream is
22+
* [closed][close], whether that close is explicit, via a `use {}` / try-with-resources block,
23+
* or implicit when iteration runs to completion. This mirrors the close-on-partial-consume
24+
* invariant [org.dexpace.sdk.core.http.paging.PagedIterable] enforces: a consumer that pulls
25+
* only the first few events and walks away never strands the response body or its pooled
26+
* connection.
27+
*
28+
* Unlike the bare [Sequence] returned by [BufferedSource.readServerSentEvents], an `SseStream`
29+
* **owns** the response. The [ServerSentEventReader] it drives is single-pass and stateful, so
30+
* the stream is single-pass too:
31+
*
32+
* - [iterator] may be called **once**. A second call throws [IllegalStateException]; this
33+
* surfaces the previously documented "do not iterate twice" warning as an enforced guard.
34+
* - Once [close] has run, [iterator] (and any further pulls from an in-flight iterator) throw
35+
* [IllegalStateException] — the is-closed guard.
36+
* - When the backing reader reports end-of-stream the iterator terminates cleanly **and**
37+
* closes the stream, releasing the response without a separate [close] call.
38+
*
39+
* `close()` is idempotent and propagates to [resource] exactly once. The underlying response
40+
* `close()` is itself expected to be idempotent, so a redundant [close] here is harmless.
41+
*
42+
* **Threading**: not thread-safe for iteration — drive a single iterator from one thread, as
43+
* with the backing reader. [close] is safe to call from another thread (e.g. to cancel a
44+
* long-lived stream); it takes a lock and flips an atomic guard, so a concurrent [close]
45+
* races cleanly with iteration and the iterating thread observes the closed state on its next
46+
* pull.
47+
*
48+
* @property reader The WHATWG parser driving the byte stream. Owned by this stream.
49+
* @property resource The response (or body) whose lifecycle this stream governs; closed once
50+
* when the stream closes.
51+
*/
52+
public class SseStream private constructor(
53+
private val reader: ServerSentEventReader,
54+
private val resource: Closeable,
55+
) : AutoCloseable, Iterable<ServerSentEvent> {
56+
private val closed = AtomicBoolean(false)
57+
private val iteratorTaken = AtomicBoolean(false)
58+
private val closeLock = ReentrantLock()
59+
60+
/**
61+
* Returns the single-pass iterator over the stream's events.
62+
*
63+
* Each pull advances the backing [ServerSentEventReader]. When the reader signals
64+
* end-of-stream the iterator terminates and the stream is [closed][close] eagerly, so a
65+
* fully consumed stream needs no explicit `close()`.
66+
*
67+
* @throws IllegalStateException if the stream is already closed, or if [iterator] was
68+
* already called on this instance (the stream is single-pass).
69+
*/
70+
override fun iterator(): Iterator<ServerSentEvent> {
71+
check(!closed.get()) { "SseStream is closed" }
72+
check(iteratorTaken.compareAndSet(false, true)) {
73+
"SseStream is single-pass; iterator() may only be called once"
74+
}
75+
return SseIterator()
76+
}
77+
78+
/**
79+
* Closes the stream and the underlying [resource]. Idempotent: only the first call
80+
* propagates to [resource]; later calls are no-ops. Safe to call concurrently with
81+
* iteration.
82+
*/
83+
override fun close() {
84+
if (closed.compareAndSet(false, true)) {
85+
closeLock.withLock { resource.close() }
86+
}
87+
}
88+
89+
private inner class SseIterator : AbstractIterator<ServerSentEvent>() {
90+
override fun computeNext() {
91+
// An out-of-band close() (e.g. cancellation from another thread) ends iteration
92+
// cleanly rather than reading from a resource that is being torn down.
93+
if (closed.get()) {
94+
done()
95+
return
96+
}
97+
// Reader exceptions (mid-stream connection drops) propagate to the caller; the
98+
// stream stays open so the caller can still close()/use{} the response on the way
99+
// out. AbstractIterator transitions to FAILED, matching PagedIterable's contract.
100+
val event = reader.next()
101+
if (event == null) {
102+
// Clean end-of-stream: release the response without a separate close() call.
103+
done()
104+
close()
105+
return
106+
}
107+
setNext(event)
108+
}
109+
}
110+
111+
public companion object {
112+
/**
113+
* Opens an [SseStream] that parses [source] and, when closed, closes [resource].
114+
*
115+
* Typical use binds [resource] to the originating
116+
* [org.dexpace.sdk.core.http.response.Response] so closing the stream releases the
117+
* response body and its connection:
118+
*
119+
* ```
120+
* SseStream.from(response.body!!.source(), response).use { stream ->
121+
* for (event in stream) { /* handle event */ }
122+
* }
123+
* ```
124+
*
125+
* [source] and [resource] may be the same object when the source itself owns the
126+
* transport handle.
127+
*
128+
* @param source The byte stream to parse as Server-Sent Events.
129+
* @param resource The handle to close when the stream closes (response, body, etc.).
130+
*/
131+
@JvmStatic
132+
public fun from(
133+
source: BufferedSource,
134+
resource: Closeable,
135+
): SseStream = SseStream(ServerSentEventReader(source), resource)
136+
137+
/**
138+
* Opens an [SseStream] over a pre-built [reader], closing [resource] when the stream
139+
* closes. Use when the caller already holds a configured [ServerSentEventReader].
140+
*
141+
* @param reader The parser to drive. Owned by the returned stream.
142+
* @param resource The handle to close when the stream closes.
143+
*/
144+
@JvmStatic
145+
public fun fromReader(
146+
reader: ServerSentEventReader,
147+
resource: Closeable,
148+
): SseStream = SseStream(reader, resource)
149+
}
150+
}

0 commit comments

Comments
 (0)