Skip to content

Commit 4bd6e2f

Browse files
committed
Merge remote-tracking branch 'origin/main' into build/publishing-convention-plugin
# Conflicts: # gradle.properties
2 parents f1da732 + b46dfa1 commit 4bd6e2f

100 files changed

Lines changed: 7413 additions & 929 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: CI
2+
3+
# Run the full Gradle build on every pull request and on pushes to main.
4+
# `./gradlew build` runs the complete quality gate: tests, ktlint, detekt,
5+
# apiCheck (binary-compatibility), explicit-API strict mode, allWarningsAsErrors,
6+
# and the aggregate 80% Kover line-coverage floor.
7+
on:
8+
pull_request:
9+
push:
10+
branches: [main]
11+
12+
# Cancel superseded runs on the same ref so only the latest commit of a branch/PR builds.
13+
concurrency:
14+
group: ci-${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
build:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- name: Check out repository
25+
uses: actions/checkout@v4
26+
with:
27+
# The styleguide directory is a git submodule; fetch it so the working tree matches local checkouts.
28+
submodules: recursive
29+
30+
# Install a single Temurin JDK 21. The build's Gradle daemon runs on this JVM, and the
31+
# foojay-resolver-convention plugin (settings.gradle.kts) auto-provisions the JDK 8 and
32+
# JDK 11 toolchains the Java-8 and JDK-11 modules require. JDK 21 also satisfies the
33+
# virtual-threads module's toolchain. JDK 21 (rather than the newest LTS) is deliberate:
34+
# detekt 1.23.x crashes on JDK 25+, so keeping the daemon on 21 keeps the detekt gate green.
35+
- name: Set up JDK 21
36+
uses: actions/setup-java@v4
37+
with:
38+
distribution: temurin
39+
java-version: '21'
40+
41+
# Built-in Gradle dependency and build caching, plus wrapper validation.
42+
- name: Set up Gradle
43+
uses: gradle/actions/setup-gradle@v4
44+
45+
- name: Build
46+
run: ./gradlew build

AUDIT.md

Lines changed: 149 additions & 0 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,12 @@ Layered, from the bottom up:
8585

8686
- **Java 8 bytecode everywhere except** `sdk-transport-jdkhttp` (11) and `sdk-async-virtualthreads` (21).
8787
Avoid `InputStream.transferTo` (9+), `Thread.threadId()` (19+), records, sealed `permits` in Java-8
88-
modules. A module that genuinely needs a newer JDK must override **both** `jvmToolchain(N)` **and**
88+
modules. A module that genuinely needs a newer JDK must override **all three** of `jvmToolchain(N)`,
89+
the `java { sourceCompatibility / targetCompatibility = VERSION_N + toolchain }` block, and
8990
`compilerOptions { jvmTarget.set(JvmTarget.JVM_N) }` in its own build script — overriding only the
90-
toolchain produces Java-8-format bytecode referencing newer stdlib symbols (`NoSuchMethodError` on JDK 8).
91+
toolchain produces Java-8-format bytecode referencing newer stdlib symbols (`NoSuchMethodError` on JDK 8),
92+
and omitting the `java {}` block trips Gradle's `compileJava`/`compileKotlin` JVM-target validation. See
93+
`docs/architecture.md` (Cross-Compile Toolchain Discipline).
9194
- **MIT license header in every source file.** Each `.kt`, `.java`, and `.kts` file starts with the 6-line
9295
`Copyright (c) 2026 dexpace and Omar Aljarrah` / `SPDX-License-Identifier: MIT` block — copy it from any
9396
existing file when creating new ones. Nothing enforces this automatically; it is a review convention.

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
<h1 align="center">Java SDKs Platform</h1>
99

10+
[![CI](https://github.com/dexpace/java-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/dexpace/java-sdk/actions/workflows/ci.yml)
1011
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
1112
[![Kotlin](https://img.shields.io/badge/kotlin-2.3.21-7F52FF.svg?logo=kotlin&logoColor=white)](https://kotlinlang.org)
1213
![JDK](https://img.shields.io/badge/JDK-8%2B-437291.svg?logo=openjdk&logoColor=white)
@@ -246,25 +247,28 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
246247
|---|---|
247248
| `client` | `HttpClient`, `AsyncHttpClient` — the two transport SPIs (sync and async). |
248249
| `http.request` | `Request`, `RequestBody`, `FileRequestBody`, `LoggableRequestBody`, `Method`. |
249-
| `http.response` | `Response`, `ResponseBody`, `LoggableResponseBody`, `Status` (a value-carrying class with a total `fromCode`), `HttpResponseException`. |
250-
| `http.response.exception` | Typed `HttpException` hierarchy (`BadRequestException`, `RequestTimeoutException`, `TooManyRequestsException`, `ServiceUnavailableException`, …) with `retryable` derived from `RetryUtils.isRetryable`, plus `NetworkException` and `HttpExceptionFactory`. |
250+
| `http.response` | `Response`, `ResponseBody`, `LoggableResponseBody`, `Status` (a value-carrying class with a total `fromCode`), plus the raw-vs-parsed seam: `ResponseHandler<T>` (with dep-free `string()`/`empty()` handlers) and a lazy, parse-once `ParsedResponse<T>`. |
251+
| `http.response.exception` | Typed `HttpException` hierarchy (`BadRequestException`, `RequestTimeoutException`, `TooManyRequestsException`, `ServiceUnavailableException`, …) with `isRetryable` derived from `RetryUtils.isRetryable` and exposed via the `Retryable` interface, plus `NetworkException` and `HttpExceptionFactory`. |
251252
| `http.common` | `Headers`, `HttpHeaderName` (interned), `MediaType`, `Protocol`, `HttpRange`, `ETag`, `RequestConditions`. |
252253
| `http.context` | `CallContext``DispatchContext``RequestContext``ExchangeContext` chain, `ContextStore`. |
253254
| `http.pipeline` | Sync (`HttpStep` / `HttpPipeline` / `HttpPipelineBuilder` / `PipelineNext` / `Stage`) and async (`AsyncHttpStep` / `AsyncHttpPipeline` / `AsyncHttpPipelineBuilder` / `AsyncPipelineNext`) pipeline machinery, plus `AsyncPipelineBridges`. |
254255
| `http.pipeline.steps` | Concrete steps: `RetryStep`, `RedirectStep`, `AuthStep`, `KeyCredentialAuthStep`, `BearerTokenAuthStep`, `InstrumentationStep`, `SetDateStep`, and their `*Options` / `*Condition` types. |
255256
| `http.auth` | `Credential` sealed hierarchy (`KeyCredential`, `NamedKeyCredential`, `BearerToken`), `BearerTokenProvider`, `AuthScheme`, `AuthMetadata`, RFC 7235 challenge parser, `BasicChallengeHandler`, `DigestChallengeHandler`, `CompositeChallengeHandler`. |
256257
| `http.sse` | `ServerSentEventReader` (WHATWG spec), `ServerSentEvent`, `ServerSentEventListener`, `BufferedSource.readServerSentEvents()`. |
257258
| `http.paging` | `PagedIterable<T>`, `PagedResponse<T>`, `PagingOptions` with `byPage()` and `stream()` accessors. |
258-
| `pagination` | `Paginator<T>` (with a `maxPages` safety cap) over cursor / page-number / token / link-header `PaginationStrategy` implementations, plus `Page<T>` / `SimplePage<T>`. |
259+
| `pagination` | `Paginator<T>` (with a `maxPages` safety cap) over cursor / page-number / link-header `PaginationStrategy` implementations, plus `Page<T>` / `SimplePage<T>`. Token-style APIs use `CursorPaginationStrategy` with the query-param name set (e.g. `"page_token"`). |
259260
| `pipeline` | Recovery-aware primitives: `RequestPipeline`, `ResponsePipeline`, `ExecutionPipeline` over a sealed `ResponseOutcome`, with steps (`pipeline.step`, `pipeline.step.retry`) like `RetryStep`, `ResponseRecoveryStep`, `IdempotencyKeyStep`, `ClientIdentityStep`. |
260-
| `serde` | `Serde`, `Serializer`, `Deserializer` abstractions and `Tristate<T>` (absent / null / present). |
261+
| `serde` | `Serde`, `Serializer`, `Deserializer` abstractions, `Tristate<T>` (absent / null / present), and `SerdeException` (the unchecked failure adapters translate codec errors into). |
261262
| `io` | `Source`, `Sink`, `Buffer`, `BufferedSource`, `BufferedSink`, `IoProvider`, `Io`, `TeeSink`. |
262263
| `instrumentation` | `ClientLogger` (zero-alloc disabled path), `LoggingEvent`, `UrlRedactor`, `Tracer` / `NoopTracer`, `Span` / `NoopSpan`, `InstrumentationContext`. |
263264
| `instrumentation.metrics` | `Meter`, `LongCounter`, `DoubleHistogram`, `NoopMeter`. |
264265
| `config` | `Configuration` (system-property + env-var layered lookup), `ConfigurationBuilder`. |
265266
| `util` | `Clock`, `Uuids` (non-blocking v4), `DateTimeRfc1123`, `RetryUtils`, `ProxyOptions`, `Futures`. |
266267
| `generics` | `Builder<T>` — the generic builder interface every SDK builder implements. |
267268

269+
Token-style APIs (`next_page_token`, `pageToken`, …) are served by `CursorPaginationStrategy`:
270+
construct it with the desired query-param name, e.g. `CursorPaginationStrategy(items, extractor, "page_token")`.
271+
268272
## Building
269273

270274
```bash

docs/architecture.md

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ concerns.
2626
- [Cross-Cutting Design Decisions](#cross-cutting-design-decisions)
2727
- [Zero Dependencies](#zero-dependencies)
2828
- [JDK 8 Compatibility](#jdk-8-compatibility)
29+
- [Cross-Compile Toolchain Discipline](#cross-compile-toolchain-discipline)
2930
- [Immutability and Builders](#immutability-and-builders)
3031
- [Virtual Thread Safety](#virtual-thread-safety)
3132
- [Internal Visibility](#internal-visibility)
@@ -271,6 +272,10 @@ Shipped pillar/step implementations live in `http.pipeline.steps`: `DefaultRedir
271272
`DefaultRetryStep`, `AuthStep` (+ `BearerTokenAuthStep` / `KeyCredentialAuthStep`),
272273
`DefaultInstrumentationStep`, and the redirect/retry option types.
273274

275+
For why this layer uses ordered stages with pillar-uniqueness rather than nested `HttpClient`
276+
decorators — and the one cost that buys (the `next.copy()` re-drive contract) — see
277+
[Pipeline Mechanism](pipelines.md#why-ordered-stages-not-nested-decorators).
278+
274279
#### Recovery-aware primitives (`org.dexpace.sdk.core.pipeline`)
275280

276281
A lower-level layer that threads a sealed `ResponseOutcome` so recovery steps observe and
@@ -346,9 +351,12 @@ Two complementary surfaces for walking multi-page responses.
346351
|-----------------------------------------------------------------|-----------------------------------------------------------------------|
347352
| `Paginator<T>` | Lazily iterates pages by re-issuing requests through an `HttpClient`; carries a `maxPages` safety cap |
348353
| `PaginationStrategy<T>` | Computes the next-page request (or stops) from the current page |
349-
| `CursorPaginationStrategy` / `PageNumberPaginationStrategy` / `TokenPaginationStrategy` / `LinkHeaderPaginationStrategy` | The four shipped strategies |
354+
| `CursorPaginationStrategy` / `PageNumberPaginationStrategy` / `LinkHeaderPaginationStrategy` | The shipped strategies |
350355
| `PagedIterable<T>` | First/next-page fetcher abstraction over `PagedResponse`, with its own `maxPages` cap |
351356

357+
Token-style APIs (`next_page_token`, `pageToken`, …) are handled by `CursorPaginationStrategy`
358+
constructed with the query-param name set (e.g. `"page_token"`), so no separate token strategy is needed.
359+
352360
### Serialization
353361

354362
**Package**: `org.dexpace.sdk.core.serde`
@@ -505,6 +513,58 @@ All code targets Java 8 bytecode (`jvmTarget = "1.8"`). Specific implications:
505513
- `ReentrantLock` (Java 5+) replaces `synchronized` for future-proofing with virtual threads
506514
- No `java.net.http.HttpClient` (Java 11+); the `HttpClient` interface is transport-agnostic
507515

516+
### Cross-Compile Toolchain Discipline
517+
518+
Most modules compile against Java 8 bytecode, but two need a newer JDK: `sdk-transport-jdkhttp`
519+
targets 11 (`java.net.http.HttpClient` was finalised in JEP 321) and `sdk-async-virtualthreads`
520+
targets 21 (virtual threads). Each of those modules raises its target by overriding **three**
521+
things in its own build script:
522+
523+
```kotlin
524+
kotlin {
525+
jvmToolchain(21) // which JDK compiles the module
526+
}
527+
528+
java {
529+
sourceCompatibility = JavaVersion.VERSION_21 // Java-source level
530+
targetCompatibility = JavaVersion.VERSION_21 // bytecode version `compileJava` emits
531+
toolchain {
532+
languageVersion.set(JavaLanguageVersion.of(21))
533+
}
534+
}
535+
536+
tasks.withType<KotlinCompile>().configureEach {
537+
compilerOptions {
538+
jvmTarget.set(JvmTarget.JVM_21) // bytecode version `compileKotlin` emits
539+
}
540+
}
541+
```
542+
543+
(`sdk-transport-jdkhttp` does the same with `11`/`VERSION_11`/`JVM_11`.) **All three** overrides
544+
are mandatory. The `java {}` block governs `compileJava` and keeps Gradle's JVM-target validation
545+
between `compileJava` and `compileKotlin` happy; a module that sets only the Kotlin toolchain and
546+
`jvmTarget` but omits the `java {}` block will trip that validation or compile its Java sources at
547+
the wrong level.
548+
549+
The root build registers a `plugins.withId("org.jetbrains.kotlin.jvm")` callback that sets
550+
`jvmTarget` to `JVM_1_8` for every Kotlin module by default. A module that bumps only the
551+
toolchain — say to JDK 21 — but leaves `jvmTarget` at the inherited `1.8` will compile *against*
552+
the JDK 21 standard library while *emitting* Java-8-format class files. The result links fine on
553+
the build machine but references methods that do not exist on a Java 8 runtime, so a downstream
554+
Java 8 consumer fails at call time with `NoSuchMethodError`. Setting `jvmTarget` to match the
555+
toolchain makes the Kotlin compiler reject newer-than-target stdlib references at compile time
556+
instead, turning that runtime failure into a build error.
557+
558+
This per-module override is the current, deliberately safe arrangement. The discipline matters
559+
under a hypothetical future consolidation onto a single newer toolchain (for build speed, or to
560+
sidestep the detekt-1.23.x crash on JDK 25+). If every module were compiled by, say, JDK 17 while
561+
the Java-8-target modules kept `jvmTarget = JVM_1_8`, those modules would again be compiling
562+
against a newer stdlib than they emit bytecode for. Guarding that arrangement requires a
563+
`--release 8` / `-Xjdk-release=8` flag on the Java-8-target modules so the compiler bounds the
564+
*visible* API to Java 8, not just the bytecode version. As long as each module that needs a newer
565+
runtime carries its own matched `jvmToolchain` + `jvmTarget` pair, no `--release` guard is needed;
566+
adopt one only if the toolchain is ever unified.
567+
508568
### Immutability and Builders
509569

510570
All HTTP model classes follow the same pattern:
@@ -575,8 +635,8 @@ responds in a uniform way:
575635
subsequent blocking call also surfaces it.
576636
3. Throws `InterruptedIOException` (or the operation's natural failure exception with
577637
`InterruptedException` added as a suppressed cause).
578-
4. Classifies the interruption as **non-retryable**`HttpResponseException.isRetryable`
579-
returns `false` for an interrupt-driven failure.
638+
4. Classifies the interruption as **non-retryable**an interrupt-driven failure is not a
639+
`Retryable` condition, so the retry step never re-issues it.
580640

581641
Loops bounded by user input (retry attempts, paged iteration, server-sent-event
582642
consumption, drain loops in body logging) check `Thread.currentThread().isInterrupted` at
@@ -653,8 +713,8 @@ they should construct a fresh one.
653713
|----------------------|-------------------------------------------------------------------------------------------------|
654714
| `io` | Source, Sink, BufferedSource, BufferedSink, Buffer, IoProvider, Io, TeeSink (internal) |
655715
| `http.request` | Request, RequestBody, FileRequestBody, LoggableRequestBody, Method |
656-
| `http.response` | Response, ResponseBody, LoggableResponseBody, Status, HttpResponseException |
657-
| `http.response.exception` | HttpException, HttpExceptionFactory, RequestTimeoutException (and siblings), NetworkException |
716+
| `http.response` | Response, ResponseBody, LoggableResponseBody, Status |
717+
| `http.response.exception` | HttpException, HttpExceptionFactory, Retryable, RequestTimeoutException (and siblings), NetworkException |
658718
| `http.common` | Headers, MediaType, CommonMediaTypes, Protocol, ETag, HttpRange, RequestConditions |
659719
| `http.auth` | Credential, KeyCredential, BearerToken, ChallengeHandler, Basic/Digest/CompositeChallengeHandler, AuthChallengeParser |
660720
| `http.context` | CallContext, DispatchContext, RequestContext, ExchangeContext, ContextStore |

docs/http-body-logging-and-concurrency.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and the concurrency decisions behind them.
1414
- [Architecture](#architecture)
1515
- [LoggableRequestBody — Tee-Write Strategy](#loggablerequestbody--tee-write-strategy)
1616
- [LoggableResponseBody — Drain-Once Strategy](#loggableresponsebody--drain-once-strategy)
17+
- [Logged body size vs. the body the consumer receives](#logged-body-size-vs-the-body-the-consumer-receives)
1718
- [Reading a Snapshot](#reading-a-snapshot)
1819
- [Internal Stream Utilities](#internal-stream-utilities)
1920
- [Concurrency Design](#concurrency-design)
@@ -240,6 +241,45 @@ retains whatever bytes were read before the failure and caches the exception:
240241
post-mortem logging that records "what we got" alongside the exception.
241242
- `captureException` surfaces the cached exception (or `null`) without triggering a drain.
242243

244+
### Logged body size vs. the body the consumer receives
245+
246+
When `HttpLogLevel.BODY_AND_HEADERS` is enabled, the instrumentation step
247+
(`DefaultInstrumentationStep` / `DefaultAsyncInstrumentationStep`) wraps the response body in a
248+
`LoggableResponseBody` bounded to `HttpInstrumentationOptions.bodyPreviewMaxBytes` (default
249+
8 KiB, `DEFAULT_BODY_PREVIEW_MAX_BYTES`). Two consequences follow that are easy to miss when
250+
reading the logs:
251+
252+
**1. The body delivered downstream can be larger than the logged preview.** The cap bounds only
253+
the in-memory *capture*, not the body. For a response larger than `bodyPreviewMaxBytes`, the
254+
step buffers the preview prefix and the wrapper then streams the full body to the consumer — it
255+
replays the captured prefix and continues from the live tail (see the bounded-capture diagram
256+
above). The preview you see in the log is a prefix; the consumer still reads every byte.
257+
258+
**2. The logged size fields measure different things.** The step emits two size-related fields
259+
on the `http.response` event, and they are not the same number for an over-cap body:
260+
261+
| Field | Source | What it reports |
262+
|---------------------------|----------------------------------------------|---------------------------------------------------------------------------------|
263+
| `response.body.size` | `loggableBody.snapshot(bodyPreviewMaxBytes)` | Size of the **captured preview** — bounded by `bodyPreviewMaxBytes` |
264+
| `response.body.preview` | the same captured bytes, decoded as UTF-8 | The preview text (a prefix for an over-cap body) |
265+
| `response.content.length` | `response.body.contentLength()` | The body's **true** length when the origin declared one (`Content-Length`); `-1` for unknown-length / streaming bodies |
266+
267+
So `response.body.size` is the *captured/preview* size, **not** necessarily the full body size.
268+
When a body exceeds the cap, `response.body.size` saturates near `bodyPreviewMaxBytes` while
269+
`response.content.length` still shows the real length. Read `content.length` (not
270+
`body.size`) when you need the full size, and treat `body.preview` as a prefix that may be
271+
truncated. The two agree only when the whole body fit within the cap — exactly the case where
272+
`contentLength()` itself returns the captured size (see **`contentLength()`** above).
273+
274+
**Streaming / unknown-length bodies (async path).** `DefaultAsyncInstrumentationStep` skips the
275+
capture entirely when `contentLength() < 0`, because the bounded drain would run on the
276+
future-completion thread and a slow producer could stall it. Such bodies stream to the consumer
277+
unwrapped, so they carry **no** `response.body.size` / `response.body.preview` fields at all —
278+
absence of those fields is expected for chunked or streaming responses, not a logging bug. The
279+
synchronous `DefaultInstrumentationStep` drains known-length and unknown-length bodies alike (it
280+
runs on the caller's thread), but the size-vs-preview distinction above applies to it just the
281+
same.
282+
243283
### Reading a Snapshot
244284

245285
The only logging output is a raw `ByteArray`:

0 commit comments

Comments
 (0)