Skip to content

Latest commit

History

203 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

dexpace

Java SDKs Platform

CILicenseKotlinJDKCoverage

A toolkit for building HTTP client libraries on the JVM. Dexpace is not an HTTP client: it is the machinery a client is made of. Immutable request and response models, a staged pipeline runtime, resilience steps, and seams for transport, I/O, serialization, and async runtimes.

Written in Kotlin, targeting JDK 8 bytecode. sdk-core has zero runtime dependencies beyond the Kotlin standard library and the SLF4J API (compile-only); every third-party library lives behind an adapter module, so consumers pay only for the runtime they use.

Current version 0.0.1-alpha.1. The public API is stabilising and breaking changes between alpha releases are expected. External pull requests are welcome.

Contents

Quick start · Installation · Design principles · Modules · Documentation · Usage · How do I… · Pipeline stages · Package map · Shrinking with R8 / ProGuard · Building · Dependencies · License

Quick start

Minimal path — one factory, one send.

When sdk-io-okio3 is on the classpath it registers itself automatically via ServiceLoader; no explicit Io.installProvider(...) call is needed unless you have multiple providers or need to override the default.

val transport =OkHttpTransport.builder().build()
val pipeline =HttpPipeline.of(transport)
pipeline.send(Request.get("https://api.example.com/v1/resource")).use { response ->
response.throwOnError() // throws HttpException on 4xx / 5xxprintln(response.body?.string())
}

With standard resilience (redirect following, retry with backoff, instrumentation):

val pipeline =HttpPipeline.standard(transport)

Typed round-trip with sdk-serde-jackson:

val serde =JacksonSerde.withDefaults()
val body =RequestBody.create(CreateUserRequest(name ="Ada", email ="ada@example.org"), serde)
pipeline.send(Request.post("https://api.example.com/v1/users", body)).use { response ->
response.throwOnError()
val user = response.parsedWith(jsonHandler(serde, User::class.java)).value()
println(user)
}

For a complete, runnable version of this wiring — an IoProvider, a transport, a serde, and a full pipeline driven against an embedded server — see the sdk-example module and run ./gradlew :sdk-example:run. The rest of this document covers the moving parts: transports, the async pipeline, runtime adapters, and body logging.

Installation

Published to Maven Central under group org.dexpace, version 0.0.1-alpha.1.

Gradle (Kotlin DSL):

repositories {
mavenCentral()
}
dependencies {
// Core contracts — always required
implementation("org.dexpace:sdk-core:0.0.1-alpha.1")
// I/O adapter (auto-registers via ServiceLoader; exactly one is needed)
implementation("org.dexpace:sdk-io-okio3:0.0.1-alpha.1")
// Transport — pick one (or bring your own HttpClient)
implementation("org.dexpace:sdk-transport-okhttp:0.0.1-alpha.1")
// implementation("org.dexpace:sdk-transport-jdkhttp:0.0.1-alpha.1") // JDK 11+// Serialization (optional — skip if you manage raw bytes yourself)
implementation("org.dexpace:sdk-serde-jackson:0.0.1-alpha.1")
}

Maven:

<dependency>
<groupId>org.dexpace</groupId>
<artifactId>sdk-core</artifactId>
<version>0.0.1-alpha.1</version>
</dependency>
<dependency>
<groupId>org.dexpace</groupId>
<artifactId>sdk-io-okio3</artifactId>
<version>0.0.1-alpha.1</version>
</dependency>
<dependency>
<groupId>org.dexpace</groupId>
<artifactId>sdk-transport-okhttp</artifactId>
<version>0.0.1-alpha.1</version>
</dependency>
<dependency>
<groupId>org.dexpace</groupId>
<artifactId>sdk-serde-jackson</artifactId>
<version>0.0.1-alpha.1</version>
</dependency>

Async runtime adapters (sdk-async-coroutines, sdk-async-reactor, sdk-async-netty, sdk-async-virtualthreads) are optional; add only the ones your project uses.

Design principles

  • The request/response model is async-first and immutable: private constructors, builders, newBuilder() copies, and Java-friendly factories (@JvmOverloads, @JvmStatic, @JvmField where applicable).
  • The pipeline runtime orders steps by stage, supports surgical type-based edits (insertAfter<T>, replace<T>, remove<T>), and enforces pillars: exactly one retry, redirect, auth, and instrumentation step per pipeline.
  • Resilience ships in the box. Retry honours Retry-After and backs off exponentially with jitter; redirects strip authorization headers and reject HTTPS→HTTP downgrades; auth covers KeyCredential, cached BearerToken, and RFC 7616 Digest (MD5, MD5-sess, SHA-256, SHA-256-sess); instrumentation provides structured logging, tracing, and metrics.
  • Body logging never disturbs the wire. Request bytes are captured through a TeeSink during the write; responses are drained once and re-read through peek() views, with race-safe consumed-once guards and cached drain errors.
  • Two seams keep the core dependency-free: IoProvider for streams, and HttpClient / AsyncHttpClient for transport. The core has no opinion about how bytes reach the wire.

Modules

ModuleMaven coordinatePurposeJVM target
sdk-coreorg.dexpace:sdk-coreContracts, pipeline runtime, sync + async pipelines, built-in steps. Zero runtime deps beyond SLF4J API and Kotlin stdlib.Java 8
sdk-io-okio3org.dexpace:sdk-io-okio3Okio 3.x implementation of IoProvider. Auto-registers via ServiceLoader.Java 8
sdk-async-coroutinesorg.dexpace:sdk-async-coroutinesKotlin coroutines adapter: suspend extensions, CoroutineScope.completableFutureOf, MDC propagation.Java 8
sdk-async-reactororg.dexpace:sdk-async-reactorReactor Mono / Flux adapter, including SSE → Flux with backpressure.Java 8
sdk-async-nettyorg.dexpace:sdk-async-nettyNetty io.netty.util.concurrent.Future adapter with bidirectional cancellation.Java 8
sdk-async-virtualthreadsorg.dexpace:sdk-async-virtualthreadsJDK 21+ virtual-thread executor adapter (AutoCloseable).Java 21
sdk-transport-okhttporg.dexpace:sdk-transport-okhttpOkHttp 5.x implementation of HttpClient + AsyncHttpClient.Java 8
sdk-transport-jdkhttporg.dexpace:sdk-transport-jdkhttpjava.net.http.HttpClient (JEP 321) implementation of HttpClient + AsyncHttpClient.Java 11
sdk-serde-jacksonorg.dexpace:sdk-serde-jacksonJackson 2.18 implementation of Serde with SDK-correct defaults (FAIL_ON_UNKNOWN_PROPERTIES=false, WRITE_DATES_AS_TIMESTAMPS=false) + Tristate<T> ser/de.Java 8

Each adapter module depends on sdk-core and exactly one third-party library. JDK 8 or newer is the baseline, with the two exceptions in the table: sdk-transport-jdkhttp needs JDK 11 and sdk-async-virtualthreads needs JDK 21. Local builds use Gradle 9.3.1 and Kotlin 2.3.21.

Two further modules build but are never published: sdk-example, the runnable end-to-end sample above, and sdk-shrink-test, a test-only harness that runs R8 against a consumer of the SDK to verify the toolkit survives downstream shrinking.

Documentation

DocumentDescription
Architecture overviewDesign, module structure, component responsibilities
HTTP layerRequest/response models, headers, media types, context system, HttpClient
I/O moduleI/O contracts and the IoProvider seam
HTTP body logging and concurrencyBody logging system, concurrency model, thread safety
Pipeline mechanismPipeline architecture, stages, step composition, async pipeline
Style guidesKotlin and Kotlin-on-JVM style guides this codebase follows

Usage

Choosing a transport

Bring your own HttpClient / AsyncHttpClient implementation, or use one of the two reference transports that ship with the project.

OkHttp: sdk-transport-okhttp

// BYO factory: pass your own preconfigured OkHttpClientval transport =OkHttpTransport.create(myOkHttpClient)
// OR SDK-managed builderval transport =OkHttpTransport.builder()
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(30))
.followRedirects(false) // default — SDK has DefaultRedirectStep
.build()

Implements both HttpClient (sync) and AsyncHttpClient (async, via OkHttp Call.enqueue). CompletableFuture.cancel() propagates to okhttp3.Call.cancel(). Java 8 bytecode.

java.net.http.HttpClient: sdk-transport-jdkhttp (JDK 11+)

// BYO factoryval transport =JdkHttpTransport.create(myJdkHttpClient)
// OR SDK-managed builderval transport =JdkHttpTransport.builder()
.connectTimeout(Duration.ofSeconds(5))
.responseTimeout(Duration.ofSeconds(30))
.httpVersion(JdkHttpTransport.HttpVersion.HTTP_2) // default
.build()

Implements both SPIs through HttpClient.sendAsync; CompletableFuture.cancel() aborts the underlying exchange natively. Java 11 bytecode, so consumers must be on JDK 11 or newer.

The full synchronous pipeline

The quick start above shows the minimal path. A production pipeline usually fills every pillar:

val pipeline =HttpPipelineBuilder(transport)
.append(SetDateStep())
.append(DefaultRetryStep(HttpRetryOptions(maxRetries =3)))
.append(DefaultRedirectStep())
.append(KeyCredentialAuthStep(KeyCredential("my-api-key")))
.append(DefaultInstrumentationStep(HttpInstrumentationOptions(logLevel =HttpLogLevel.HEADERS)))
.build()
val request =Request.builder()
.url("https://api.example.com/v1/resource")
.post(RequestBody.create("""{"key": "value"}""", CommonMediaTypes.APPLICATION_JSON))
.build()
pipeline.send(request).use { response ->if (response.isSuccessful) {
val bytes = response.body?.bytes()
// process
}
}

Asynchronous: AsyncHttpClient + AsyncHttpPipeline

val async =AsyncHttpPipelineBuilder(asyncTransport)
.append(/* AsyncHttpStep implementations */)
.build()
async.sendAsync(request).whenComplete { response, error ->if (error !=null) { /* handle */ }
else response.use { /* process */ }
}

Bridge a sync pipeline to async:

val async = syncPipeline.toAsync(Executors.newVirtualThreadPerTaskExecutor())

Kotlin coroutines: sdk-async-coroutines

importorg.dexpace.sdk.async.coroutines.sendval response = async.send(request) // suspend fun

Reactor: sdk-async-reactor

importorg.dexpace.sdk.async.reactor.sendMono
async.sendMono(request)
.doOnNext { /* process */ }
.subscribe()

Server-Sent Events as a Flux with backpressure:

response.body!!.source().readServerSentEventsAsFlux()
.doOnNext { event ->/* handle event */ }
.subscribe()

Netty: sdk-async-netty

importorg.dexpace.sdk.async.netty.executeNettyval nettyFuture = asyncClient.executeNetty(request, eventLoop)
nettyFuture.addListener { /* fire on event-loop thread */ }

Virtual threads: sdk-async-virtualthreads (JDK 21+)

val syncTransport =/* a blocking HttpClient */
syncTransport.asAsyncVirtualThreads().use { vt ->val future = vt.executeAsync(request)
// ...
} // close() releases the virtual-thread executor

Body logging

// Request: bytes captured during write via TeeSinkval loggedRequest =LoggableRequestBody(body)
// pass `loggedRequest` as the request body; transport calls writeTo()
logger.debug("request body: {}", loggedRequest.snapshot().take(8*1024))
// Response: drained lazily, drain errors cached, peek-based repeat readsval loggedResponse =LoggableResponseBody(response.body!!)
val preview = loggedResponse.snapshot(maxBytes =8*1024)
val full = loggedResponse.source().readByteArray() // still available

How do I…

Copy-paste snippets for the most common tasks. All snippets assume a built pipeline and, where serde is needed, a JacksonSerde.withDefaults() instance.

Stamp a static bearer token on every request

val pipeline =HttpPipelineBuilder(transport)
.append(KeyCredentialAuthStep(KeyCredential(apiKey ="my-token", prefix ="Bearer")))
.build()

Throw typed exceptions on 4xx / 5xx

Add throwOnHttpError() to the pipeline builder — it maps 4xx / 5xx error responses to typed HttpException subclasses (BadRequestException, TooManyRequestsException, etc.); a non-error 1xx / 3xx (e.g. an unfollowed redirect) or 2xx response passes through unchanged:

val pipeline =HttpPipelineBuilder(transport)
.appendStandardResilience() // redirect + retry + instrumentation
.throwOnHttpError() // map 4xx/5xx to HttpException after retry exhaustion
.build()

Or call response.throwOnError() at the call site instead (see the quick start).

Override the timeout for a single request

val opts =RequestOptions.builder()
.timeout(Duration.ofSeconds(60))
.maxRetries(0) // disable retry for this call
.build()
pipeline.send(Request.get("https://api.example.com/slow"), opts).use { response ->// ...
}

Page through a cursor-based resource

// The strategy rewrites the request URL for each page, setting `cursorQueryParam` to the cursor// from the previous page; the extractor reads the page's items and next cursor in a single pass.val paginator =Paginator(
httpClient = pipeline,
initialRequest =Request.get("https://api.example.com/items"),
strategy =CursorPaginationStrategy(
extractor = { response ->CursorResult(parseItems(response), nextToken(response)) },
cursorQueryParam ="page_token",
),
)
paginator.iterateAll().forEach { item ->println(item) }

The HttpPipeline implements HttpClient, so pass the pipeline directly wherever a paging strategy expects a transport — every page request then runs through the same resilience stack.

Send a JSON body

// RequestBody.create defaults the Content-Type to the serde's media type (application/json).val body =RequestBody.create(myPayload, serde)
val request =Request.builder()
.url("https://api.example.com/v1/resource")
.post(body)
.addHeader(HttpHeaderName.ACCEPT, CommonMediaTypes.APPLICATION_JSON)
.build()

Pipeline stages

Steps execute in declaration order of Stage.entries. Pillar stages (isPillar = true) admit exactly one step; non-pillar stages admit any number, ordered by append and prepend.

PRE_REDIRECT → REDIRECT (pillar) → POST_REDIRECT → RETRY (pillar) →
POST_RETRY → PRE_AUTH → AUTH (pillar) → POST_AUTH →
PRE_LOGGING → LOGGING (pillar) → POST_LOGGING → PRE_SERDE →
SERDE (pillar) → POST_SERDE → PRE_SEND → SEND (terminal — HttpClient.execute)

PRE_REDIRECT is the outermost stage: a step placed there (e.g. the one throwOnHttpError() installs) observes only the final response, after the redirect and retry loops have run.

See docs/pipelines.md for the step-author walkthrough.

Package map (sdk-core)

PackageHighlights
clientHttpClient, AsyncHttpClient — the two transport SPIs (sync and async).
http.requestRequest, RequestBody, FileRequestBody, LoggableRequestBody, Method.
http.responseResponse, 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>.
http.response.exceptionTyped HttpException hierarchy (BadRequestException, RequestTimeoutException, TooManyRequestsException, ServiceUnavailableException, …) with isRetryable derived from RetryUtils.isRetryable and exposed via the Retryable interface, plus NetworkException and HttpExceptionFactory.
http.commonHeaders, HttpHeaderName (interned), QueryParams (RFC 3986 query multimap), MediaType, Protocol, HttpRange, ETag, RequestConditions.
http.contextCallContextDispatchContextRequestContextExchangeContext chain, ContextStore.
http.pipelineSync (HttpStep / HttpPipeline / HttpPipelineBuilder / PipelineNext / Stage) and async (AsyncHttpStep / AsyncHttpPipeline / AsyncHttpPipelineBuilder / AsyncPipelineNext) pipeline machinery, plus AsyncPipelineBridges.
http.pipeline.stepsConcrete steps: RetryStep, RedirectStep, AuthStep, KeyCredentialAuthStep, BearerTokenAuthStep, InstrumentationStep, SetDateStep, and their *Options / *Condition types.
http.sseServerSentEventReader (WHATWG spec), ServerSentEvent, ServerSentEventListener, BufferedSource.readServerSentEvents().
authCredential sealed hierarchy (KeyCredential, NamedKeyCredential, BearerToken), BearerTokenProvider, AuthScheme, per-operation AuthRequirement / AuthDescriptor with AuthDescriptorResolver precedence ladder, RFC 7235 challenge parser, BasicChallengeHandler, DigestChallengeHandler, CompositeChallengeHandler.
paginationUnified paging surface: Page<T> (exposes the raw per-page Response; Closeable) / PageInfo<T>; Paginator<T> / AsyncPaginator<T> (strategy-driven, sync + async, each carrying a maxPages safety cap) exposing item-level (iterateAll / streamAll, which eager-close each page) and page-level views — sync byPage returns the auto-closing CloseablePages view (wrap in use {} / try-with-resources), async forEachPageAsync delivers a live page valid only during the consumer callback — over cursor / page-number / link-header PaginationStrategy implementations; PagedIterable<T> (functional, transport-agnostic first/next-page fetchers); and the internal PageWalker driver shared by the sync paths. Token-style APIs use CursorPaginationStrategy with the query-param name set (e.g. "page_token").
operationOperationParams — SPI projecting an operation's typed inputs (path / query / header / body) into a Request and the context chain, via toRequest(baseUrl) / toRequestContext(baseUrl, dispatch).
pipelineRecovery-aware primitives: RequestRecoveryChain, ResponseRecoveryChain, RecoveryChain over a sealed ResponseOutcome, with steps (pipeline.step, pipeline.step.retry) like RetryRecovery, ResponseRecoveryStep, IdempotencyKeyStep, ClientIdentityStep.
serdeSerde, Serializer, Deserializer abstractions, Tristate<T> (absent / null / present), and SerdeException (the unchecked failure adapters translate codec errors into).
ioSource, Sink, Buffer, BufferedSource, BufferedSink, IoProvider, Io, TeeSink.
instrumentationClientLogger (zero-alloc disabled path), LoggingEvent, UrlRedactor, Tracer / NoopTracer, Span / NoopSpan, InstrumentationContext.
instrumentation.metricsMeter, LongCounter, DoubleHistogram, NoopMeter.
configConfiguration (system-property + env-var layered lookup), ConfigurationBuilder.
utilClock, Uuids (non-blocking v4), DateTimeRfc1123, RetryUtils, ProxyOptions, Futures.
genericsBuilder<T> — the generic builder interface every SDK builder implements.

Token-style APIs (next_page_token, pageToken, …) are served by CursorPaginationStrategy: construct it with a CursorExtractor and the desired query-param name, e.g. CursorPaginationStrategy(extractor, "page_token").

Shrinking with R8 / ProGuard

Every published jar carries its own consumer keep-rules under META-INF/proguard/. R8 and the Android Gradle Plugin apply rules packaged there automatically, so a downstream application that shrinks its build inherits them with no extra configuration. The rules protect the parts a shrinker cannot prove reachable on its own: the SPI seams wired at runtime (IoProvider, the transport clients, the serde) and the immutable HTTP models and Tristate that reflective serializers bind by walking constructors and Kotlin metadata. The test-only sdk-shrink-test module runs R8 against a consumer of the SDK on every build to keep those rules honest.

Building

./gradlew build # build every module
./gradlew test# run all tests across modules
./gradlew koverHtmlReport # aggregate coverage report at build/reports/kover/html/
./gradlew apiCheck # binary-compatibility check against committed .api snapshots
./gradlew apiDump # regenerate .api snapshots after intentional API changes

Aggregate line coverage sits comfortably above the 80% floor; run koverHtmlReport for the current numbers.

Quality gates

All of these break the build:

  • explicitApi = ExplicitApiMode.Strict on every Kotlin module: every public declaration states its visibility and return type.
  • allWarningsAsErrors = true for every Kotlin compile task.
  • ktlint and detekt with ignoreFailures = false. Detekt is skipped on sdk-async-virtualthreads and sdk-transport-jdkhttp, whose JDK 21 / JDK 11 toolchains run analysis on a JDK 25 system JVM that detekt 1.23.x cannot parse; both build scripts link the upstream issue and the re-enable conditions. It runs everywhere else, including the JDK 8 transports.
  • kotlinx-binary-compatibility-validator gates the public API surface against committed .api snapshots.
  • Aggregate Kover line coverage has an 80% floor.
  • The sdk-shrink-test R8 guard, wired into check, fails the build if the shipped consumer keep-rules stop protecting the toolkit under shrinking. It needs a JDK 11 toolchain and fetches com.android.tools:r8 from Google's Maven repo.

Dependencies

ComponentVersionScope
Kotlin2.3.21All modules
Gradle9.3.1Build
SLF4J API2.0.18sdk-core (compileOnly)
Okio3.17.0sdk-io-okio3
kotlinx-coroutines1.11.0sdk-async-coroutines
Reactor Core3.8.5sdk-async-reactor
Netty Common4.2.13.Finalsdk-async-netty
OkHttp5.0.0sdk-transport-okhttp
mockwebserver35.0.0sdk-transport-okhttp, sdk-transport-jdkhttp (test-only)
Jackson2.18.2sdk-serde-jackson
Kover0.9.8Coverage (root project)

License

This project is licensed under the MIT License. Copyright © 2026 dexpace and Omar Aljarrah. Every source file carries an MIT license header.

About

Core components and tools for building and maintaining Java SDK libraries

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages