Skip to content

Commit 8038bef

Browse files
committed
docs: tidy pagination KDoc and restore strategy rationale comments
1 parent f5376d6 commit 8038bef

7 files changed

Lines changed: 72 additions & 31 deletions

File tree

sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/AsyncPaginator.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import java.util.function.Consumer
3131
*
3232
* Iteration is page-lazy in the same sense as [Paginator]: exactly one HTTP exchange happens
3333
* per page consumed. A new page is fetched only after the previous page has been drained to
34-
* the consumer and reports `hasNext` with a non-null next request. Empty pages still count
34+
* the consumer and the strategy reports a non-null next request. Empty pages still count
3535
* toward the [maxPages] budget.
3636
*
3737
* ## Termination

sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationStrategy.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,14 @@ public class LinkHeaderPaginationStrategy<T>
5454
initialRequest: Request,
5555
): PageInfo<T> {
5656
val items: List<T> = itemsExtractor(response)
57+
// Some servers emit multiple `Link` headers (one per link-value) instead of a single
58+
// comma-separated header. Joining the values with ',' normalizes both wire shapes into
59+
// one string so a single parser handles either. An empty header list joins to "", which
60+
// extractNextUrl already maps to null (no `rel="next"`).
5761
val nextUrlString: String? =
5862
extractNextUrl(response.headers.values(linkHeader).joinToString(separator = ","))
63+
// A `rel="next"` target that cannot be parsed into a valid URL ends the stream (null)
64+
// rather than aborting iteration with a MalformedURLException from resolveNextUrl.
5965
val nextUrl: URL? =
6066
if (nextUrlString.isNullOrEmpty()) null else resolveNextUrl(response, nextUrlString)
6167
val nextRequest: Request? =

sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/PagedIterable.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,5 @@ public class PagedIterable<T>
8181

8282
/** Sequential, ordered, unknown-size [Stream] of every [Page]. */
8383
@JvmOverloads
84-
public fun pageStream(options: PagingOptions = PagingOptions()): Stream<Page<T>> =
85-
walker(options).pageStream()
84+
public fun pageStream(options: PagingOptions = PagingOptions()): Stream<Page<T>> = walker(options).pageStream()
8685
}

sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/Paginator.kt

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,37 +16,36 @@ import java.util.stream.Stream
1616
* Generic, strategy-driven paginator over an [HttpClient].
1717
*
1818
* A `Paginator` executes [initialRequest] against [httpClient], delegates response parsing
19-
* to [strategy], and exposes the resulting stream of items via either [iterateAll] (a lazy
20-
* `Iterable<T>`) or [streamAll] (a Java 8 `Stream<T>`). Pagination state — cursor,
21-
* page number, link URL — is derived from each response by the strategy; the paginator
22-
* itself stays stateless.
19+
* to [strategy], and exposes the result two ways: as a stream of items — [iterateAll] (a lazy
20+
* `Iterable<T>`) / [streamAll] (a Java 8 `Stream<T>`) — or as a stream of whole pages —
21+
* [byPage] (a lazy `Iterable<Page<T>>`) / [pageStream] (a `Stream<Page<T>>`), where each
22+
* [Page] carries its items plus per-page status, headers, and request. Pagination state —
23+
* cursor, page number, link URL — is derived from each response by the strategy; the
24+
* paginator itself stays stateless.
2325
*
2426
* ## Laziness
2527
*
2628
* Iteration is page-lazy: a new page is fetched only when the consumer pulls past the
2729
* last item of the current page. Specifically, exactly one HTTP exchange happens per
2830
* page yielded. No prefetch, no batch fetch — empty pages count toward the fetch budget,
29-
* but advancing past an exhausted page with `hasNext = false` does not trigger a fetch.
31+
* but advancing past a page whose strategy returned a null next request does not trigger a fetch.
3032
*
3133
* ## Termination
3234
*
3335
* The iterator terminates when either:
3436
*
35-
* - the current page's items are exhausted AND `page.hasNext == false`, or
36-
* - the current page's items are exhausted AND `page.nextPageRequest()` returned `null`, or
37+
* - the current page's items are exhausted AND the strategy returned a `PageInfo` with a
38+
* `null` next request (end of stream), or
3739
* - [maxPages] pages have already been fetched (the safety cap).
3840
*
39-
* The first two conditions are semantically distinct but produce the same outcome: end of
40-
* stream.
41-
*
4241
* ## Safety cap
4342
*
4443
* [maxPages] defaults to `Long.MAX_VALUE` (effectively unbounded, matching `Iterable`
4544
* semantics). A misbehaving server that returns the same `rel="next"` link — or any other
4645
* unchanging paging cursor — on every page would otherwise drive an unbounded fetch loop.
4746
* **Production callers should set a finite cap.** Once [maxPages] pages have been yielded the
48-
* iterator stops fetching, even if the last page still reports `hasNext`. The cap counts
49-
* pages fetched (HTTP exchanges), not items.
47+
* iterator stops fetching, even if the strategy still reports a non-null next request. The cap
48+
* counts pages fetched (HTTP exchanges), not items.
5049
*
5150
* ## Response lifecycle
5251
*

sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PageTest.kt

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
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+
18
package org.dexpace.sdk.core.pagination
29

310
import org.dexpace.sdk.core.http.common.Headers
@@ -17,33 +24,47 @@ import kotlin.test.assertNull
1724
class PageTest {
1825
@BeforeTest fun setup() = installIoProvider()
1926

20-
private fun request(): Request = Request.builder().method(Method.GET).url(URL("https://api.example.com/items")).build()
27+
private fun request(): Request =
28+
Request.builder().method(
29+
Method.GET,
30+
).url(URL("https://api.example.com/items")).build()
2131

2232
@Test
2333
fun `explicit constructor exposes fields and defaults links to null`() {
24-
val page = Page(items = listOf("a", "b"), statusCode = 200, headers = Headers.builder().build(), request = request())
34+
val page =
35+
Page(items = listOf("a", "b"), statusCode = 200, headers = Headers.builder().build(), request = request())
2536
assertEquals(listOf("a", "b"), page.items)
2637
assertEquals(200, page.statusCode)
2738
assertNull(page.nextLink)
2839
assertNull(page.continuationToken)
40+
assertNull(page.firstLink)
41+
assertNull(page.previousLink)
42+
assertNull(page.lastLink)
2943
}
3044

3145
@Test
3246
fun `from snapshots status, headers and request and does not close the response`() {
3347
val closeCount = AtomicInteger(0)
34-
val body = object : ResponseBody() {
35-
override fun mediaType() = null
36-
override fun contentLength() = -1L
37-
override fun source() = throw UnsupportedOperationException("not needed")
38-
override fun close() { closeCount.incrementAndGet() }
39-
}
40-
val response = Response.builder()
41-
.request(request())
42-
.protocol(Protocol.HTTP_1_1)
43-
.status(Status.fromCode(204))
44-
.headers(Headers.builder().add("X-Total", "9").build())
45-
.body(body)
46-
.build()
48+
val body =
49+
object : ResponseBody() {
50+
override fun mediaType() = null
51+
52+
override fun contentLength() = -1L
53+
54+
override fun source() = throw UnsupportedOperationException("not needed")
55+
56+
override fun close() {
57+
closeCount.incrementAndGet()
58+
}
59+
}
60+
val response =
61+
Response.builder()
62+
.request(request())
63+
.protocol(Protocol.HTTP_1_1)
64+
.status(Status.fromCode(204))
65+
.headers(Headers.builder().add("X-Total", "9").build())
66+
.body(body)
67+
.build()
4768

4869
val page = Page.from(response, items = listOf(1, 2, 3), nextLink = "https://api.example.com/items?page=2")
4970

sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PageWalkerTest.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ class PageWalkerTest {
2626
@Test
2727
fun `maxPages caps the number of pages yielded`() {
2828
var produced = 0
29-
val walker = PageWalker({ produced++; page("x$produced") }, maxPages = 2)
29+
val walker =
30+
PageWalker({
31+
produced++
32+
page("x$produced")
33+
}, maxPages = 2)
3034
val collected = walker.pages().asSequence().toList()
3135
assertEquals(2, collected.size)
3236
assertEquals(2, produced) // never fetches a third page

sdk-core/src/test/kotlin/org/dexpace/sdk/core/pagination/PaginatorByPageTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ class PaginatorByPageTest {
4646
assertEquals(listOf("a", "b"), pages[0].items)
4747
assertEquals(listOf("c"), pages[1].items)
4848
assertEquals(200, pages[0].statusCode)
49+
assertEquals(200, pages[1].statusCode)
50+
assertEquals(2, client.callCount)
51+
}
52+
53+
@Test
54+
fun `pageStream yields one page per HTTP exchange`() {
55+
val client = StubHttpClient()
56+
client.on("https://api.example.com/items") { req -> textResponse(req, "items=a,b\ncursor=abc") }
57+
client.on("https://api.example.com/items?cursor=abc") { req -> textResponse(req, "items=c\ncursor=") }
58+
val paginator = Paginator(client, initialRequest(), CursorPaginationStrategy(extractor, "cursor"))
59+
60+
assertEquals(2L, paginator.pageStream().count())
4961
assertEquals(2, client.callCount)
5062
}
5163
}

0 commit comments

Comments
 (0)