From 27a960cddd379218d2f97bc335693085f385d6f1 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 16:36:07 +0300 Subject: [PATCH 1/3] build: add sdk-example, a runnable end-to-end usage sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now nothing exercised the assembled toolkit as a whole, and there was no executable reference showing how the pluggable seams fit together. Add `sdk-example`, an `application`-plugin module that wires the four pluggable pieces and issues a real HTTP exchange through the public API: - OkioIoProvider installed into the Io seam, - the OkHttp transport as the terminal HttpClient, - an HttpPipeline carrying one step per user-installable pillar (REDIRECT, RETRY, AUTH, LOGGING), - JacksonSerde for typed request/response bodies. The request runs against an embedded mockwebserver3 driven from `main()`, so the sample is deterministic and needs no network: `:sdk-example:run` serializes a typed request, POSTs it, and deserializes the typed response. The AUTH pillar refuses to stamp credentials over plaintext, so the embedded server speaks HTTPS with a self-signed certificate (okhttp-tls) and the transport is configured to trust it — the same shape a production caller would use. A smoke test drives the identical wiring under `build`. The module is intentionally not a published library, so it opts out of the gates that only make sense for the public ABI: - no `maven-publish`/`signing` — it is a sample, never released; - excluded from binary-compatibility checks via `apiValidation.ignoredProjects`, since application code has no stable ABI to snapshot; - left out of the Kover aggregate (it does not apply the plugin), so `main()`-centric sample code does not drag the 80% line-coverage floor down. Its smoke test still runs and proves the sample works. It keeps explicit-API strict mode, ktlint, detekt, and allWarningsAsErrors (inherited from the root build) and stays on the Java 8 toolchain. --- build.gradle.kts | 18 +- gradle/libs.versions.toml | 1 + sdk-example/build.gradle.kts | 61 +++++ .../org/dexpace/sdk/example/ExampleApp.kt | 226 ++++++++++++++++++ .../org/dexpace/sdk/example/ExampleAppTest.kt | 81 +++++++ settings.gradle.kts | 5 + 6 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 sdk-example/build.gradle.kts create mode 100644 sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt create mode 100644 sdk-example/src/test/kotlin/org/dexpace/sdk/example/ExampleAppTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 31cdd430..ec08a63c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,7 +41,12 @@ plugins { // `group` and `version` are set once in `gradle.properties` and applied by Gradle to the root // project and every subproject — see that file. -// Coverage: aggregate every Kover-enabled subproject through this root project's reports. +// Coverage: aggregate every Kover-enabled *library* subproject through this root project's +// reports. `sdk-example` is deliberately absent: it is sample code built around a `main()`, and +// folding it into the aggregate would drag the 80% line-coverage floor down for code that exists +// to be read and run, not unit-tested to the library standard. The example does not apply the +// Kover plugin, so it contributes nothing to these reports; its own smoke test still runs under +// `build` and proves the sample assembles and executes end-to-end. dependencies { kover(project(":sdk-core")) kover(project(":sdk-io-okio3")) @@ -82,12 +87,15 @@ tasks.named("check") { dependsOn(tasks.named("koverVerify")) } -// Keep the test-only shrink-survival module out of the binary-compatibility snapshot. It ships no -// public artifact, so it needs no committed `.api` file; without this exclusion apiCheck would -// demand one (and apiDump would generate a spurious snapshot for an unpublished module). Mirrors -// how the module is also left out of the kover aggregate below. +// Keep the unpublished modules out of the binary-compatibility snapshot. Neither ships a public +// artifact, so neither needs a committed `.api` file; without these exclusions apiCheck would +// demand one (and apiDump would generate a spurious snapshot for an unpublished module). Both are +// also left out of the kover aggregate above. +// - `sdk-shrink-test`: the test-only R8 shrink-survival guard. +// - `sdk-example`: the runnable end-to-end usage sample (an `application` module, no stable ABI). apiValidation { ignoredProjects += "sdk-shrink-test" + ignoredProjects += "sdk-example" } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68aaba7b..1b2800d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ kotlinx-coroutines-slf4j = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } okhttp-mockwebserver-junit5 = { module = "com.squareup.okhttp3:mockwebserver3-junit5", version.ref = "mockwebserver" } reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" } reactor-test = { module = "io.projectreactor:reactor-test", version.ref = "reactor" } diff --git a/sdk-example/build.gradle.kts b/sdk-example/build.gradle.kts new file mode 100644 index 00000000..c5866a61 --- /dev/null +++ b/sdk-example/build.gradle.kts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +plugins { + kotlin("jvm") + application +} + +group = "org.dexpace" +version = "0.0.1-alpha.1" + +// Java 8 bytecode and explicit-API strict mode are inherited from the root build script +// (jvmToolchain(8), jvmTarget=1.8, allWarningsAsErrors, explicitApi=Strict). The sample wires +// the Java-8 OkHttp transport, so it stays on the default toolchain — no override needed. +// +// Unlike the library modules this module applies NEITHER `maven-publish`/`signing` (it is a +// usage sample, never released) NOR the Kover plugin (it is intentionally outside the aggregate +// coverage floor — see the root build.gradle.kts rationale). The binary-compatibility validator +// also skips it via `apiValidation.ignoredProjects` in the root build. + +application { + mainClass.set("org.dexpace.sdk.example.ExampleAppKt") +} + +dependencies { + // Public contracts: HTTP models, the pipeline runtime + pillar steps, the I/O seam. + implementation(project(":sdk-core")) + // I/O adapter — the single `IoProvider` the sample installs at startup. + implementation(project(":sdk-io-okio3")) + // Transport adapter — the terminal `HttpClient` the pipeline dispatches to. + implementation(project(":sdk-transport-okhttp")) + // Serde adapter — typed request/response (de)serialization. + implementation(project(":sdk-serde-jackson")) + + // MockWebServer ships in the OkHttp project as a generic embedded HTTP server. The sample + // drives it from `main()` so the end-to-end exchange runs deterministically with no network. + implementation(libs.okhttp.mockwebserver.junit5) + // okhttp-tls mints a self-signed certificate so the embedded server can speak HTTPS — the + // AUTH pillar step refuses to stamp credentials over plaintext, so the sample uses TLS exactly + // as a production caller would. `OkHttpClient` is configured directly here, hence the explicit + // dependency on OkHttp itself. + implementation(libs.okhttp) + implementation(libs.okhttp.tls) + + // SLF4J is `compileOnly` on every Kotlin module (added by the root build); the sample needs a + // real binding at runtime so the pipeline's instrumentation logging has somewhere to go. NOP + // keeps the console output limited to what the sample prints itself. + runtimeOnly(libs.slf4j.nop) + + testImplementation(kotlin("test")) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.slf4j.nop) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt new file mode 100644 index 00000000..ca09750f --- /dev/null +++ b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.example + +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.dexpace.sdk.core.client.HttpClient +import org.dexpace.sdk.core.http.auth.KeyCredential +import org.dexpace.sdk.core.http.common.CommonMediaTypes +import org.dexpace.sdk.core.http.common.HttpHeaderName +import org.dexpace.sdk.core.http.pipeline.HttpPipeline +import org.dexpace.sdk.core.http.pipeline.HttpPipelineBuilder +import org.dexpace.sdk.core.http.pipeline.steps.DefaultInstrumentationStep +import org.dexpace.sdk.core.http.pipeline.steps.DefaultRedirectStep +import org.dexpace.sdk.core.http.pipeline.steps.DefaultRetryStep +import org.dexpace.sdk.core.http.pipeline.steps.HttpInstrumentationOptions +import org.dexpace.sdk.core.http.pipeline.steps.HttpLogLevel +import org.dexpace.sdk.core.http.pipeline.steps.KeyCredentialAuthStep +import org.dexpace.sdk.core.http.request.Method +import org.dexpace.sdk.core.http.request.Request +import org.dexpace.sdk.core.http.request.RequestBody +import org.dexpace.sdk.core.io.Io +import org.dexpace.sdk.core.serde.deserialize +import org.dexpace.sdk.io.OkioIoProvider +import org.dexpace.sdk.serde.jackson.JacksonSerde +import org.dexpace.sdk.transport.okhttp.OkHttpTransport +import java.net.URL + +/* + * End-to-end usage sample for the dexpace SDK. + * + * This module exists as an executable smoke test of the assembled toolkit: it wires the four + * pluggable seams together and proves they cooperate over a real HTTP exchange — + * + * - an OkioIoProvider installed into the Io seam, + * - the OkHttpTransport as the terminal HttpClient, + * - an HttpPipeline carrying one step per user-installable pillar (REDIRECT, RETRY, AUTH, + * LOGGING), + * - and JacksonSerde for typed request/response bodies. + * + * The request targets an embedded MockWebServer, so the sample is fully deterministic and needs + * no network access — `./gradlew :sdk-example:run` produces the same output everywhere. + * + * The AUTH pillar refuses to stamp credentials over plaintext HTTP, so the embedded server speaks + * HTTPS with a self-signed certificate (newTlsServer) and the transport is configured to trust it + * (TlsServer.newTransportTrusting) — the sample uses TLS exactly as a production caller would. + * + * The wiring is deliberately split out of main() into small functions so the smoke test can + * exercise the exact same code paths the sample runs. + */ + +/** HTTP 201 Created — the status the embedded server returns for the sample POST. */ +private const val HTTP_CREATED = 201 + +/** A typed request payload, serialized to JSON by the [JacksonSerde]. */ +public data class CreateUserRequest( + val name: String, + val email: String, +) + +/** A typed response payload, deserialized from JSON by the [JacksonSerde]. */ +public data class User( + val id: Long, + val name: String, + val email: String, +) + +/** + * Installs the Okio-backed [Io] provider. Install is idempotent for the same provider, so calling + * this from both [main] and the smoke test is safe. + */ +public fun installIoProvider() { + Io.installProvider(OkioIoProvider) +} + +/** + * Mints a self-signed certificate for `localhost` and starts an HTTPS [MockWebServer] serving it. + * The matching client trust material is returned alongside so the caller can build a transport + * that trusts this exact certificate — see [newTransportTrusting]. + */ +public fun newTlsServer(): TlsServer { + val certificate = + HeldCertificate.Builder() + .addSubjectAlternativeName("localhost") + .build() + val serverCertificates = + HandshakeCertificates.Builder() + .heldCertificate(certificate) + .build() + val clientCertificates = + HandshakeCertificates.Builder() + .addTrustedCertificate(certificate.certificate) + .build() + + val server = MockWebServer() + server.useHttps(serverCertificates.sslSocketFactory()) + return TlsServer(server, clientCertificates) +} + +/** An embedded HTTPS [MockWebServer] paired with the client trust material that accepts it. */ +public class TlsServer internal constructor( + public val server: MockWebServer, + private val clientCertificates: HandshakeCertificates, +) { + /** + * Builds an [OkHttpTransport] over a BYO [OkHttpClient] that trusts this server's self-signed + * certificate. The transport is SDK-managed, so closing it shuts the underlying client down. + */ + public fun newTransportTrusting(): OkHttpTransport { + val client = + OkHttpClient.Builder() + .sslSocketFactory( + clientCertificates.sslSocketFactory(), + clientCertificates.trustManager, + ) + .build() + return OkHttpTransport.create(client) + } +} + +/** + * Assembles an [HttpPipeline] over [transport] with exactly one step on each user-installable + * pillar stage. The SERDE pillar is reserved by the runtime and carries no user step — typed + * (de)serialization happens explicitly at the call site via [JacksonSerde], as shown in + * [createUser]. + */ +public fun buildPipeline(transport: HttpClient): HttpPipeline = + HttpPipelineBuilder(transport) + // REDIRECT pillar — follow 3xx responses within a hop budget. + .append(DefaultRedirectStep()) + // RETRY pillar — exponential backoff that honours `Retry-After`. + .append(DefaultRetryStep()) + // AUTH pillar — stamp a static API key into the `Authorization` header. + .append( + KeyCredentialAuthStep( + KeyCredential( + apiKey = "example-api-key", + headerName = HttpHeaderName.AUTHORIZATION, + prefix = "Bearer", + ), + ), + ) + // LOGGING pillar — emit request/response diagnostics at header granularity. + .append( + DefaultInstrumentationStep( + HttpInstrumentationOptions(logLevel = HttpLogLevel.HEADERS), + ), + ) + .build() + +/** + * Serializes [request] to a JSON body, POSTs it through [pipeline] to [endpoint], and deserializes + * the JSON response into a typed [User]. Throws if the server does not answer with a 2xx status. + * + * The returned [User] is fully materialized before the response is closed, so the caller does not + * own any streaming resource. + */ +public fun createUser( + pipeline: HttpPipeline, + serde: JacksonSerde, + endpoint: URL, + request: CreateUserRequest, +): User { + val json = serde.serializer.serialize(request) + val httpRequest = + Request.builder() + .method(Method.POST) + .url(endpoint) + .addHeader(HttpHeaderName.ACCEPT.toString(), CommonMediaTypes.APPLICATION_JSON.toString()) + .body(RequestBody.create(json, CommonMediaTypes.APPLICATION_JSON)) + .build() + + pipeline.send(httpRequest).use { response -> + val status = response.status + val payload = response.body?.source()?.readUtf8().orEmpty() + check(status.isSuccess) { "Unexpected status $status — body: $payload" } + return serde.deserializer.deserialize(payload) + } +} + +/** + * Runs the full sample against an embedded HTTPS [MockWebServer] and prints the typed round-trip. + * + * No arguments are read; nothing touches the network. The single canned response makes the output + * stable across runs and machines. + */ +public fun main() { + installIoProvider() + val serde = JacksonSerde.withDefaults() + + val tls = newTlsServer() + tls.server.use { server -> + // Canned JSON the SDK will deserialize back into a typed `User`. + server.enqueue( + MockResponse.Builder() + .code(HTTP_CREATED) + .addHeader( + HttpHeaderName.CONTENT_TYPE.toString(), + CommonMediaTypes.APPLICATION_JSON.toString(), + ) + .body("""{"id":1,"name":"Ada Lovelace","email":"ada@example.org"}""") + .build(), + ) + server.start() + + tls.newTransportTrusting().use { transport -> + val pipeline = buildPipeline(transport) + val endpoint = server.url("/v1/users").toUrl() + val payload = CreateUserRequest(name = "Ada Lovelace", email = "ada@example.org") + + println("POST $endpoint") + println(" request : $payload") + val user = createUser(pipeline, serde, endpoint, payload) + println(" response: $user") + println("Created user #${user.id} (${user.name}).") + } + } +} diff --git a/sdk-example/src/test/kotlin/org/dexpace/sdk/example/ExampleAppTest.kt b/sdk-example/src/test/kotlin/org/dexpace/sdk/example/ExampleAppTest.kt new file mode 100644 index 00000000..fbd2273b --- /dev/null +++ b/sdk-example/src/test/kotlin/org/dexpace/sdk/example/ExampleAppTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.example + +import mockwebserver3.MockResponse +import org.dexpace.sdk.core.http.common.CommonMediaTypes +import org.dexpace.sdk.core.http.common.HttpHeaderName +import org.dexpace.sdk.serde.jackson.JacksonSerde +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Smoke test that drives the sample's own wiring end-to-end against an embedded HTTPS + * [mockwebserver3.MockWebServer]: install the I/O provider, build the full pillar pipeline, + * serialize a typed request, dispatch it through the OkHttp transport over TLS, and deserialize + * the typed response. + * + * This is the deterministic proof that the assembled toolkit works together — the acceptance + * criterion behind the example module. It exercises [newTlsServer], [installIoProvider], + * [buildPipeline], and [createUser] exactly as [main] does. + */ +class ExampleAppTest { + private lateinit var tls: TlsServer + + @BeforeTest + fun setUp() { + installIoProvider() + tls = newTlsServer() + tls.server.start() + } + + @AfterTest + fun tearDown() { + tls.server.close() + } + + @Test + fun createUserRoundTripsTypedPayloadsThroughTheFullPipeline() { + tls.server.enqueue( + MockResponse.Builder() + .code(201) + .addHeader( + HttpHeaderName.CONTENT_TYPE.toString(), + CommonMediaTypes.APPLICATION_JSON.toString(), + ) + .body("""{"id":7,"name":"Ada Lovelace","email":"ada@example.org"}""") + .build(), + ) + + val serde = JacksonSerde.withDefaults() + + val user = + tls.newTransportTrusting().use { transport -> + val pipeline = buildPipeline(transport) + createUser( + pipeline = pipeline, + serde = serde, + endpoint = tls.server.url("/v1/users").toUrl(), + request = CreateUserRequest(name = "Ada Lovelace", email = "ada@example.org"), + ) + } + + assertEquals(User(id = 7, name = "Ada Lovelace", email = "ada@example.org"), user) + + val recorded = tls.server.takeRequest() + // The AUTH pillar step stamped the API key into the request. + assertEquals("Bearer example-api-key", recorded.headers[HttpHeaderName.AUTHORIZATION.toString()]) + // The body the transport sent is the serialized typed request. + assertEquals( + """{"name":"Ada Lovelace","email":"ada@example.org"}""", + recorded.body?.utf8(), + ) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2816337c..e54620d0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,3 +36,8 @@ include("sdk-serde-jackson") // downstream shrinking with its shipped consumer keep-rules. Kept out of the kover aggregate and // the binary-compatibility snapshot (see root build.gradle.kts). include("sdk-shrink-test") + +// Runnable end-to-end usage sample: an `application`-plugin module that wires an IoProvider, +// a transport, a serde, and a full HTTP pipeline against an embedded server. Unpublished, +// excluded from the binary-compatibility and coverage gates (see root build.gradle.kts). +include("sdk-example") From 42497b33abe41dc404627193782df6f5922effd5 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 22:21:35 +0300 Subject: [PATCH 2/3] docs: mark demo-only TLS trust and fence SDK wiring from server scaffolding in example --- .../src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt index ca09750f..e2afe849 100644 --- a/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt +++ b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt @@ -117,6 +117,9 @@ public class TlsServer internal constructor( public fun newTransportTrusting(): OkHttpTransport { val client = OkHttpClient.Builder() + // Demo only: trusts a single self-signed certificate so the sample needs no + // network. Production callers should rely on the default system trust store and + // not configure custom trust material here. .sslSocketFactory( clientCertificates.sslSocketFactory(), clientCertificates.trustManager, @@ -196,6 +199,9 @@ public fun main() { installIoProvider() val serde = JacksonSerde.withDefaults() + // ---- Demo scaffolding: fake the server so the sample is deterministic and network-free. ---- + // Everything in this `tls.server.use { ... }` block stands in for a real backend; a caller + // wiring the SDK against a live API would not write any of it. val tls = newTlsServer() tls.server.use { server -> // Canned JSON the SDK will deserialize back into a typed `User`. @@ -211,6 +217,7 @@ public fun main() { ) server.start() + // ---- SDK wiring: this is the copyable part a real caller would actually write. ---- tls.newTransportTrusting().use { transport -> val pipeline = buildPipeline(transport) val endpoint = server.url("/v1/users").toUrl() From 987c36ab2822e84a41cba04ea5b476dbdf3cae81 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 01:01:30 +0300 Subject: [PATCH 3/3] build: trim sdk-example to the plain mockwebserver3 artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Depend on the non-junit5 `mockwebserver3` in the sample: it manages the embedded server's lifecycle by hand from main() and the smoke test, so the JUnit 5 extension — and the JUnit it would otherwise drag onto the runtime classpath — is not needed. Also document the retry pillar's idempotency behavior in the sample (the POST is retried because its in-memory body is replayable, so point readers at IdempotencyKeyStep for non-idempotent writes) and update the module count in CLAUDE.md to eleven, noting sdk-example as an unpublished usage sample. --- CLAUDE.md | 7 ++++--- gradle/libs.versions.toml | 1 + sdk-example/build.gradle.kts | 5 ++++- .../src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt | 6 +++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e5fd972..c285d5fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,9 +29,10 @@ modules) to skip it. See that module's `build.gradle.kts` for the pipeline. ## Repository Layout -Ten Gradle modules (see `settings.gradle.kts`). `gradle/libs.versions.toml` is the single source of truth -for dependency and plugin versions. Group `org.dexpace`, version `0.0.1-alpha.1`. (The tenth, -`sdk-shrink-test`, is a test-only, unpublished R8 shrink-survival guard — not listed below.) +Eleven Gradle modules (see `settings.gradle.kts`). `gradle/libs.versions.toml` is the single source of +truth for dependency and plugin versions. Group `org.dexpace`, version `0.0.1-alpha.1`. (Two are +unpublished and not listed below: `sdk-shrink-test`, a test-only R8 shrink-survival guard, and +`sdk-example`, a runnable end-to-end usage sample.) | Module | Purpose | JVM target | |---|---|---| diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b2800d2..b670bde7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "mockwebserver" } okhttp-mockwebserver-junit5 = { module = "com.squareup.okhttp3:mockwebserver3-junit5", version.ref = "mockwebserver" } reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" } reactor-test = { module = "io.projectreactor:reactor-test", version.ref = "reactor" } diff --git a/sdk-example/build.gradle.kts b/sdk-example/build.gradle.kts index c5866a61..3d464d59 100644 --- a/sdk-example/build.gradle.kts +++ b/sdk-example/build.gradle.kts @@ -38,7 +38,10 @@ dependencies { // MockWebServer ships in the OkHttp project as a generic embedded HTTP server. The sample // drives it from `main()` so the end-to-end exchange runs deterministically with no network. - implementation(libs.okhttp.mockwebserver.junit5) + // The plain `mockwebserver3` artifact is used (not the `-junit5` variant): the sample manages + // the server lifecycle by hand from `main()` and the smoke test, so no JUnit 5 extension — and + // none of the JUnit it would drag onto the runtime classpath — is needed here. + implementation(libs.okhttp.mockwebserver) // okhttp-tls mints a self-signed certificate so the embedded server can speak HTTPS — the // AUTH pillar step refuses to stamp credentials over plaintext, so the sample uses TLS exactly // as a production caller would. `OkHttpClient` is configured directly here, hence the explicit diff --git a/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt index e2afe849..3f9d0102 100644 --- a/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt +++ b/sdk-example/src/main/kotlin/org/dexpace/sdk/example/ExampleApp.kt @@ -139,7 +139,11 @@ public fun buildPipeline(transport: HttpClient): HttpPipeline = HttpPipelineBuilder(transport) // REDIRECT pillar — follow 3xx responses within a hop budget. .append(DefaultRedirectStep()) - // RETRY pillar — exponential backoff that honours `Retry-After`. + // RETRY pillar — exponential backoff that honours `Retry-After`. This re-sends a request + // when its method is idempotent or its body is replayable; the sample's POST carries a + // replayable (in-memory) body, so it qualifies. A real caller retrying a non-idempotent + // write should pair this with an idempotency key (see `IdempotencyKeyStep`) so a retried + // POST cannot create a duplicate server-side. .append(DefaultRetryStep()) // AUTH pillar — stamp a static API key into the `Authorization` header. .append(