Related:#28 will eventually replace RequestRebuilder’s query handling with a QueryParams multimap, and #30 will unify the pagination stacks; the items below are independent interim cleanups to the current code.
While reading through the pagination package I spotted a few small simplifications worth folding
in together, since they all sit in the same subsystem. Each one removes redundant state or a
provably-dead branch and is strictly behavior-preserving: no public signatures move, and the
existing tests should stay green. Grouping them in one ticket so they can land as a single small PR.
sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/Paginator.kt:148-232 — drop the started flag and collapse advance()'s request selection
The iterator carries a started boolean purely to pick initialRequest on the first fetch. If we
instead seed nextRequest with initialRequest, the first fetch reads nextRequest like every
later one, and the nextRequest ?: finishedPage.nextPageRequest() fallback becomes dead (by the time
advance() runs again, nextRequest already holds exactly that page's nextPageRequest()). That
lets the two-stage request selection collapse into a single guard.
Ordering must be preserved: keep pagesFetched++afterhttpClient.execute(request), and keep
the nextRequest = … reassignment after a successful strategy.parse(…). Do not hoist either
ahead of the fetch. The sync iterator deliberately supports retry — if execute() throws, a caller
can catch it and call hasNext() again, which re-enters advance() and re-attempts the same
request. Mutating pagesFetched or nextRequest before the fetch would burn the page budget or
advance the cursor on a failed exchange, silently breaking that retry path.
Old code:
// null = first iteration (use initialRequest); else the next-page request scheduled by// the previous page's strategy.parse() call.privatevar nextRequest:Request?=null// true once we have fetched at least one page. Used to distinguish "never fetched" from// "exhausted after fetching" — both have currentPage == null right after iteration ends.privatevar started:Boolean=false// true after iteration is definitively over; prevents further fetches.privatevar done:Boolean=false// Number of pages (HTTP exchanges) fetched so far. Iteration stops once this reaches// maxPages, guarding against servers that never advance their paging cursor.privatevar pagesFetched:Long=0LoverridefunhasNext(): Boolean {
while (true) {
val page = currentPage
if (page !=null&& currentItemIndex < page.items.size) {
returntrue
}
if (done) {
returnfalse
}
if (!advance()) {
returnfalse
}
}
}
overridefunnext(): T {
if (!hasNext()) {
throwNoSuchElementException("Paginator iterator exhausted.")
}
val page = currentPage!!val item = page.items[currentItemIndex]
currentItemIndex++return item
}
/** * Fetches the next page. Returns `true` if a new page was loaded into * [currentPage]; `false` if iteration is now done.*/privatefunadvance(): Boolean {
if (pagesFetched >= maxPages) {
// Safety cap reached: stop before fetching a page we would otherwise yield,// even if the previous page still reports hasNext.
done =true
currentPage =nullreturnfalse
}
val request:Request?=if (!started) {
initialRequest
} else {
// We have a current page that's exhausted — see if it can produce a next.val finishedPage = currentPage
if (finishedPage ==null||!finishedPage.hasNext) {
null
} else {
nextRequest ?: finishedPage.nextPageRequest()
}
}
if (request ==null) {
done =true
currentPage =nullreturnfalse
}
val response:Response= httpClient.execute(request)
pagesFetched++val page:Page<T> =try {
strategy.parse(response, initialRequest)
} finally {
response.close()
}
started =true
currentPage = page
currentItemIndex =0// Compute the next request now so we don't have to retain the (closed) response.// If this page has no next, nextRequest stays null; advance() will treat that as done.
nextRequest =if (page.hasNext) page.nextPageRequest() elsenullreturntrue
}New code:
// The next request to fetch: seeded with initialRequest, then replaced after each page// with that page's nextPageRequest(), or null once a page reports no successor (which// ends the stream on the following advance()).privatevar nextRequest:Request?= initialRequest
// true after iteration is definitively over; prevents further fetches.privatevar done:Boolean=false// Number of pages (HTTP exchanges) fetched so far. Iteration stops once this reaches// maxPages, guarding against servers that never advance their paging cursor.privatevar pagesFetched:Long=0LoverridefunhasNext(): Boolean {
while (true) {
val page = currentPage
if (page !=null&& currentItemIndex < page.items.size) {
returntrue
}
if (done) {
returnfalse
}
if (!advance()) {
returnfalse
}
}
}
overridefunnext(): T {
if (!hasNext()) {
throwNoSuchElementException("Paginator iterator exhausted.")
}
val page = currentPage!!val item = page.items[currentItemIndex]
currentItemIndex++return item
}
/** * Fetches the next page. Returns `true` if a new page was loaded into * [currentPage]; `false` if iteration is now done.*/privatefunadvance(): Boolean {
val request = nextRequest
if (request ==null|| pagesFetched >= maxPages) {
// Either no next request is scheduled (the previous page had no successor) or// the safety cap is reached — stop before fetching a page we would otherwise// yield, even if the previous page still reports hasNext.
done =true
currentPage =nullreturnfalse
}
val response:Response= httpClient.execute(request)
pagesFetched++val page:Page<T> =try {
strategy.parse(response, initialRequest)
} finally {
response.close()
}
currentPage = page
currentItemIndex =0// Compute the next request now so we don't have to retain the (closed) response.// If this page has no next, nextRequest goes null; the next advance() ends the stream.
nextRequest =if (page.hasNext) page.nextPageRequest() elsenullreturntrue
}Usage — before → after:
val paginator =Paginator(httpClient, initialRequest, strategy, maxPages =100)
for (item in paginator.iterateAll()) {
handle(item)
}
paginator.streamAll().forEach(::handle)val paginator =Paginator(httpClient, initialRequest, strategy, maxPages =100)
for (item in paginator.iterateAll()) {
handle(item)
}
paginator.streamAll().forEach(::handle)call sites unchanged — behavior-preserving (the edits are entirely inside the private
PaginatorIterator).
Why:started only encodes "has the first page been fetched", which is redundant once
nextRequest is seeded with initialRequest; the nextRequest ?: finishedPage.nextPageRequest()
fallback then re-derives a value advance() already computed at the end of the previous page. Seeding
the field collapses the request selection to one guard and removes both the flag and the dead
re-derivation.
API / Build: No public-API change — the edits live entirely inside the private
PaginatorIterator, so apiCheck is unaffected (no apiDump needed). Java-8 bytecode and
explicit-API strict mode are likewise unaffected.
sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/RequestRebuilder.kt:109-125 — parse query params with substringBefore / substringAfter, matching setQueryParam
getQueryParam hand-rolls indexOf('=') plus two conditional substring calls, while its sibling
setQueryParam already extracts the key with segment.substringBefore('=', segment). Aligning the
two reads better and keeps the edge-case handling in one recognizable idiom.
Old code:
fungetQueryParam(
url:URL,
name:String,
): String? {
val existing = url.query ?:returnnullif (existing.isEmpty()) returnnullfor (segment in existing.split('&')) {
if (segment.isEmpty()) continueval eq = segment.indexOf('=')
val rawKey =if (eq >=0) segment.substring(0, eq) else segment
if (decodeOrRaw(rawKey) == name) {
val rawValue =if (eq >=0) segment.substring(eq +1) else""return decodeOrRaw(rawValue)
}
}
returnnull
}New code:
fungetQueryParam(
url:URL,
name:String,
): String? {
val existing = url.query ?:returnnullif (existing.isEmpty()) returnnullfor (segment in existing.split('&')) {
if (segment.isEmpty()) continueval key = segment.substringBefore('=', segment)
if (decodeOrRaw(key) == name) {
return decodeOrRaw(segment.substringAfter('=', ""))
}
}
returnnull
}Edge cases are identical to the index-juggling version (substringBefore/substringAfter split on
the first= and fall back to the supplied default when there is none):
segment | old (rawKey, rawValue) | new (key, value) |
|---|
flag (no =) | ("flag", "") | ("flag", "") |
key= (empty value) | ("key", "") | ("key", "") |
k=a=b (= in value) | ("k", "a=b") | ("k", "a=b") |
Usage — before → after:
// PageNumberPaginationStrategy.ktval executedPageParam:String?=RequestRebuilder.getQueryParam(executedUrl, pageParam)
// PageNumberPaginationStrategy.ktval executedPageParam:String?=RequestRebuilder.getQueryParam(executedUrl, pageParam)
call sites unchanged — behavior-preserving (sole caller is PageNumberPaginationStrategy).
Why: Reusing the same substringBefore('=', segment) key extraction as setQueryParam removes
the manual offset bookkeeping and reads more clearly, with identical first-= split and empty-value
semantics.
API / Build:RequestRebuilder is an internal object, so this touches no public-API surface;
apiCheck is unaffected.
sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationStrategy.kt:57-62 — drop the empty-string guards around the Link-header join
The join already produces the right input for extractNextUrl in every case, so the two empty-string
guards are dead: an empty values(...) list joins to "", and extractNextUrl("") already returns
null because splitLinkValues("") yields an empty list. The whole thing folds into one expression.
Old code:
// Some servers emit multiple Link headers (one per link-value) instead of a single// comma-separated header. Join with ',' so a single parser handles both shapes.val linkValues:List<String> = response.headers.values(linkHeader)
val combined:String=if (linkValues.isEmpty()) ""else linkValues.joinToString(separator =",")
val nextUrlString:String?=if (combined.isEmpty()) nullelse extractNextUrl(combined)
New code:
// Some servers emit multiple Link headers (one per link-value) instead of a single// comma-separated header. Join with ',' so a single parser handles both shapes; an// empty values list joins to "", and extractNextUrl("") already returns null.val nextUrlString:String?=
extractNextUrl(response.headers.values(linkHeader).joinToString(separator =","))Usage — before → after:
val strategy =LinkHeaderPaginationStrategy(itemsExtractor = { resp -> parseItems(resp) })
val paginator =Paginator(httpClient, firstPageRequest, strategy)
paginator.iterateAll().forEach(::handle)val strategy =LinkHeaderPaginationStrategy(itemsExtractor = { resp -> parseItems(resp) })
val paginator =Paginator(httpClient, firstPageRequest, strategy)
paginator.iterateAll().forEach(::handle)call sites unchanged — behavior-preserving (the change is internal to parse()).
Why: Both guards are provably dead — an empty list joins to "" and extractNextUrl("") is
already null — so the two intermediate vals and the unreachable branches collapse into a single
extractNextUrl(...) call. The separator = "," argument is retained so the join still uses a bare
comma rather than joinToString's default ", ".
API / Build:parse(...) keeps its signature (it overrides PaginationStrategy), so there is no
public-API change.
Related:#28 will eventually replace RequestRebuilder’s query handling with a
QueryParamsmultimap, and #30 will unify the pagination stacks; the items below are independent interim cleanups to the current code.While reading through the
paginationpackage I spotted a few small simplifications worth foldingin together, since they all sit in the same subsystem. Each one removes redundant state or a
provably-dead branch and is strictly behavior-preserving: no public signatures move, and the
existing tests should stay green. Grouping them in one ticket so they can land as a single small PR.
sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/Paginator.kt:148-232 — drop the
startedflag and collapseadvance()'s request selectionThe iterator carries a
startedboolean purely to pickinitialRequeston the first fetch. If weinstead seed
nextRequestwithinitialRequest, the first fetch readsnextRequestlike everylater one, and the
nextRequest ?: finishedPage.nextPageRequest()fallback becomes dead (by the timeadvance()runs again,nextRequestalready holds exactly that page'snextPageRequest()). Thatlets the two-stage request selection collapse into a single guard.
Ordering must be preserved: keep
pagesFetched++afterhttpClient.execute(request), and keepthe
nextRequest = …reassignment after a successfulstrategy.parse(…). Do not hoist eitherahead of the fetch. The sync iterator deliberately supports retry — if
execute()throws, a callercan catch it and call
hasNext()again, which re-entersadvance()and re-attempts the samerequest. Mutating
pagesFetchedornextRequestbefore the fetch would burn the page budget oradvance the cursor on a failed exchange, silently breaking that retry path.
Old code:
New code:
Usage — before → after:
call sites unchanged — behavior-preserving (the edits are entirely inside the private
PaginatorIterator).Why:
startedonly encodes "has the first page been fetched", which is redundant oncenextRequestis seeded withinitialRequest; thenextRequest ?: finishedPage.nextPageRequest()fallback then re-derives a value
advance()already computed at the end of the previous page. Seedingthe field collapses the request selection to one guard and removes both the flag and the dead
re-derivation.
API / Build: No public-API change — the edits live entirely inside the private
PaginatorIterator, soapiCheckis unaffected (noapiDumpneeded). Java-8 bytecode andexplicit-API strict mode are likewise unaffected.
sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/RequestRebuilder.kt:109-125 — parse query params with
substringBefore/substringAfter, matchingsetQueryParamgetQueryParamhand-rollsindexOf('=')plus two conditionalsubstringcalls, while its siblingsetQueryParamalready extracts the key withsegment.substringBefore('=', segment). Aligning thetwo reads better and keeps the edge-case handling in one recognizable idiom.
Old code:
New code:
Edge cases are identical to the index-juggling version (
substringBefore/substringAftersplit onthe first
=and fall back to the supplied default when there is none):segment(rawKey, rawValue)(key, value)flag(no=)("flag", "")("flag", "")key=(empty value)("key", "")("key", "")k=a=b(=in value)("k", "a=b")("k", "a=b")Usage — before → after:
call sites unchanged — behavior-preserving (sole caller is
PageNumberPaginationStrategy).Why: Reusing the same
substringBefore('=', segment)key extraction assetQueryParamremovesthe manual offset bookkeeping and reads more clearly, with identical first-
=split and empty-valuesemantics.
API / Build:
RequestRebuilderis aninternal object, so this touches no public-API surface;apiCheckis unaffected.sdk-core/src/main/kotlin/org/dexpace/sdk/core/pagination/LinkHeaderPaginationStrategy.kt:57-62 — drop the empty-string guards around the
Link-header joinThe join already produces the right input for
extractNextUrlin every case, so the two empty-stringguards are dead: an empty
values(...)list joins to"", andextractNextUrl("")already returnsnullbecausesplitLinkValues("")yields an empty list. The whole thing folds into one expression.Old code:
New code:
Usage — before → after:
call sites unchanged — behavior-preserving (the change is internal to
parse()).Why: Both guards are provably dead — an empty list joins to
""andextractNextUrl("")isalready
null— so the two intermediate vals and the unreachable branches collapse into a singleextractNextUrl(...)call. Theseparator = ","argument is retained so the join still uses a barecomma rather than
joinToString's default", ".API / Build:
parse(...)keeps its signature (it overridesPaginationStrategy), so there is nopublic-API change.