Skip to content

Commit de34bd2

Browse files
authored
build: add a runnable sdk-example module as an end-to-end smoke test (#102)
PR: #102
1 parent 88dd66a commit de34bd2

7 files changed

Lines changed: 406 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ modules) to skip it. See that module's `build.gradle.kts` for the pipeline.
2929

3030
## Repository Layout
3131

32-
Ten Gradle modules (see `settings.gradle.kts`). `gradle/libs.versions.toml` is the single source of truth
33-
for dependency and plugin versions. Group `org.dexpace`, version `0.0.1-alpha.1`. (The tenth,
34-
`sdk-shrink-test`, is a test-only, unpublished R8 shrink-survival guard — not listed below.)
32+
Eleven Gradle modules (see `settings.gradle.kts`). `gradle/libs.versions.toml` is the single source of
33+
truth for dependency and plugin versions. Group `org.dexpace`, version `0.0.1-alpha.1`. (Two are
34+
unpublished and not listed below: `sdk-shrink-test`, a test-only R8 shrink-survival guard, and
35+
`sdk-example`, a runnable end-to-end usage sample.)
3536

3637
| Module | Purpose | JVM target |
3738
|---|---|---|

build.gradle.kts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ plugins {
4141
// `group` and `version` are set once in `gradle.properties` and applied by Gradle to the root
4242
// project and every subproject — see that file.
4343

44-
// Coverage: aggregate every Kover-enabled subproject through this root project's reports.
44+
// Coverage: aggregate every Kover-enabled *library* subproject through this root project's
45+
// reports. `sdk-example` is deliberately absent: it is sample code built around a `main()`, and
46+
// folding it into the aggregate would drag the 80% line-coverage floor down for code that exists
47+
// to be read and run, not unit-tested to the library standard. The example does not apply the
48+
// Kover plugin, so it contributes nothing to these reports; its own smoke test still runs under
49+
// `build` and proves the sample assembles and executes end-to-end.
4550
dependencies {
4651
kover(project(":sdk-core"))
4752
kover(project(":sdk-io-okio3"))
@@ -82,12 +87,15 @@ tasks.named("check") {
8287
dependsOn(tasks.named("koverVerify"))
8388
}
8489

85-
// Keep the test-only shrink-survival module out of the binary-compatibility snapshot. It ships no
86-
// public artifact, so it needs no committed `.api` file; without this exclusion apiCheck would
87-
// demand one (and apiDump would generate a spurious snapshot for an unpublished module). Mirrors
88-
// how the module is also left out of the kover aggregate below.
90+
// Keep the unpublished modules out of the binary-compatibility snapshot. Neither ships a public
91+
// artifact, so neither needs a committed `.api` file; without these exclusions apiCheck would
92+
// demand one (and apiDump would generate a spurious snapshot for an unpublished module). Both are
93+
// also left out of the kover aggregate above.
94+
// - `sdk-shrink-test`: the test-only R8 shrink-survival guard.
95+
// - `sdk-example`: the runnable end-to-end usage sample (an `application` module, no stable ABI).
8996
apiValidation {
9097
ignoredProjects += "sdk-shrink-test"
98+
ignoredProjects += "sdk-example"
9199
}
92100

93101
allprojects {

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ kotlinx-coroutines-slf4j = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-
2727
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
2828
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
2929
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
30+
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
31+
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "mockwebserver" }
3032
okhttp-mockwebserver-junit5 = { module = "com.squareup.okhttp3:mockwebserver3-junit5", version.ref = "mockwebserver" }
3133
reactor-core = { module = "io.projectreactor:reactor-core", version.ref = "reactor" }
3234
reactor-test = { module = "io.projectreactor:reactor-test", version.ref = "reactor" }

sdk-example/build.gradle.kts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
8+
plugins {
9+
kotlin("jvm")
10+
application
11+
}
12+
13+
group = "org.dexpace"
14+
version = "0.0.1-alpha.1"
15+
16+
// Java 8 bytecode and explicit-API strict mode are inherited from the root build script
17+
// (jvmToolchain(8), jvmTarget=1.8, allWarningsAsErrors, explicitApi=Strict). The sample wires
18+
// the Java-8 OkHttp transport, so it stays on the default toolchain — no override needed.
19+
//
20+
// Unlike the library modules this module applies NEITHER `maven-publish`/`signing` (it is a
21+
// usage sample, never released) NOR the Kover plugin (it is intentionally outside the aggregate
22+
// coverage floor — see the root build.gradle.kts rationale). The binary-compatibility validator
23+
// also skips it via `apiValidation.ignoredProjects` in the root build.
24+
25+
application {
26+
mainClass.set("org.dexpace.sdk.example.ExampleAppKt")
27+
}
28+
29+
dependencies {
30+
// Public contracts: HTTP models, the pipeline runtime + pillar steps, the I/O seam.
31+
implementation(project(":sdk-core"))
32+
// I/O adapter — the single `IoProvider` the sample installs at startup.
33+
implementation(project(":sdk-io-okio3"))
34+
// Transport adapter — the terminal `HttpClient` the pipeline dispatches to.
35+
implementation(project(":sdk-transport-okhttp"))
36+
// Serde adapter — typed request/response (de)serialization.
37+
implementation(project(":sdk-serde-jackson"))
38+
39+
// MockWebServer ships in the OkHttp project as a generic embedded HTTP server. The sample
40+
// drives it from `main()` so the end-to-end exchange runs deterministically with no network.
41+
// The plain `mockwebserver3` artifact is used (not the `-junit5` variant): the sample manages
42+
// the server lifecycle by hand from `main()` and the smoke test, so no JUnit 5 extension — and
43+
// none of the JUnit it would drag onto the runtime classpath — is needed here.
44+
implementation(libs.okhttp.mockwebserver)
45+
// okhttp-tls mints a self-signed certificate so the embedded server can speak HTTPS — the
46+
// AUTH pillar step refuses to stamp credentials over plaintext, so the sample uses TLS exactly
47+
// as a production caller would. `OkHttpClient` is configured directly here, hence the explicit
48+
// dependency on OkHttp itself.
49+
implementation(libs.okhttp)
50+
implementation(libs.okhttp.tls)
51+
52+
// SLF4J is `compileOnly` on every Kotlin module (added by the root build); the sample needs a
53+
// real binding at runtime so the pipeline's instrumentation logging has somewhere to go. NOP
54+
// keeps the console output limited to what the sample prints itself.
55+
runtimeOnly(libs.slf4j.nop)
56+
57+
testImplementation(kotlin("test"))
58+
testImplementation(libs.junit.jupiter)
59+
testRuntimeOnly(libs.slf4j.nop)
60+
}
61+
62+
tasks.test {
63+
useJUnitPlatform()
64+
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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+
8+
package org.dexpace.sdk.example
9+
10+
import mockwebserver3.MockResponse
11+
import mockwebserver3.MockWebServer
12+
import okhttp3.OkHttpClient
13+
import okhttp3.tls.HandshakeCertificates
14+
import okhttp3.tls.HeldCertificate
15+
import org.dexpace.sdk.core.client.HttpClient
16+
import org.dexpace.sdk.core.http.auth.KeyCredential
17+
import org.dexpace.sdk.core.http.common.CommonMediaTypes
18+
import org.dexpace.sdk.core.http.common.HttpHeaderName
19+
import org.dexpace.sdk.core.http.pipeline.HttpPipeline
20+
import org.dexpace.sdk.core.http.pipeline.HttpPipelineBuilder
21+
import org.dexpace.sdk.core.http.pipeline.steps.DefaultInstrumentationStep
22+
import org.dexpace.sdk.core.http.pipeline.steps.DefaultRedirectStep
23+
import org.dexpace.sdk.core.http.pipeline.steps.DefaultRetryStep
24+
import org.dexpace.sdk.core.http.pipeline.steps.HttpInstrumentationOptions
25+
import org.dexpace.sdk.core.http.pipeline.steps.HttpLogLevel
26+
import org.dexpace.sdk.core.http.pipeline.steps.KeyCredentialAuthStep
27+
import org.dexpace.sdk.core.http.request.Method
28+
import org.dexpace.sdk.core.http.request.Request
29+
import org.dexpace.sdk.core.http.request.RequestBody
30+
import org.dexpace.sdk.core.io.Io
31+
import org.dexpace.sdk.core.serde.deserialize
32+
import org.dexpace.sdk.io.OkioIoProvider
33+
import org.dexpace.sdk.serde.jackson.JacksonSerde
34+
import org.dexpace.sdk.transport.okhttp.OkHttpTransport
35+
import java.net.URL
36+
37+
/*
38+
* End-to-end usage sample for the dexpace SDK.
39+
*
40+
* This module exists as an executable smoke test of the assembled toolkit: it wires the four
41+
* pluggable seams together and proves they cooperate over a real HTTP exchange —
42+
*
43+
* - an OkioIoProvider installed into the Io seam,
44+
* - the OkHttpTransport as the terminal HttpClient,
45+
* - an HttpPipeline carrying one step per user-installable pillar (REDIRECT, RETRY, AUTH,
46+
* LOGGING),
47+
* - and JacksonSerde for typed request/response bodies.
48+
*
49+
* The request targets an embedded MockWebServer, so the sample is fully deterministic and needs
50+
* no network access — `./gradlew :sdk-example:run` produces the same output everywhere.
51+
*
52+
* The AUTH pillar refuses to stamp credentials over plaintext HTTP, so the embedded server speaks
53+
* HTTPS with a self-signed certificate (newTlsServer) and the transport is configured to trust it
54+
* (TlsServer.newTransportTrusting) — the sample uses TLS exactly as a production caller would.
55+
*
56+
* The wiring is deliberately split out of main() into small functions so the smoke test can
57+
* exercise the exact same code paths the sample runs.
58+
*/
59+
60+
/** HTTP 201 Created — the status the embedded server returns for the sample POST. */
61+
private const val HTTP_CREATED = 201
62+
63+
/** A typed request payload, serialized to JSON by the [JacksonSerde]. */
64+
public data class CreateUserRequest(
65+
val name: String,
66+
val email: String,
67+
)
68+
69+
/** A typed response payload, deserialized from JSON by the [JacksonSerde]. */
70+
public data class User(
71+
val id: Long,
72+
val name: String,
73+
val email: String,
74+
)
75+
76+
/**
77+
* Installs the Okio-backed [Io] provider. Install is idempotent for the same provider, so calling
78+
* this from both [main] and the smoke test is safe.
79+
*/
80+
public fun installIoProvider() {
81+
Io.installProvider(OkioIoProvider)
82+
}
83+
84+
/**
85+
* Mints a self-signed certificate for `localhost` and starts an HTTPS [MockWebServer] serving it.
86+
* The matching client trust material is returned alongside so the caller can build a transport
87+
* that trusts this exact certificate — see [newTransportTrusting].
88+
*/
89+
public fun newTlsServer(): TlsServer {
90+
val certificate =
91+
HeldCertificate.Builder()
92+
.addSubjectAlternativeName("localhost")
93+
.build()
94+
val serverCertificates =
95+
HandshakeCertificates.Builder()
96+
.heldCertificate(certificate)
97+
.build()
98+
val clientCertificates =
99+
HandshakeCertificates.Builder()
100+
.addTrustedCertificate(certificate.certificate)
101+
.build()
102+
103+
val server = MockWebServer()
104+
server.useHttps(serverCertificates.sslSocketFactory())
105+
return TlsServer(server, clientCertificates)
106+
}
107+
108+
/** An embedded HTTPS [MockWebServer] paired with the client trust material that accepts it. */
109+
public class TlsServer internal constructor(
110+
public val server: MockWebServer,
111+
private val clientCertificates: HandshakeCertificates,
112+
) {
113+
/**
114+
* Builds an [OkHttpTransport] over a BYO [OkHttpClient] that trusts this server's self-signed
115+
* certificate. The transport is SDK-managed, so closing it shuts the underlying client down.
116+
*/
117+
public fun newTransportTrusting(): OkHttpTransport {
118+
val client =
119+
OkHttpClient.Builder()
120+
// Demo only: trusts a single self-signed certificate so the sample needs no
121+
// network. Production callers should rely on the default system trust store and
122+
// not configure custom trust material here.
123+
.sslSocketFactory(
124+
clientCertificates.sslSocketFactory(),
125+
clientCertificates.trustManager,
126+
)
127+
.build()
128+
return OkHttpTransport.create(client)
129+
}
130+
}
131+
132+
/**
133+
* Assembles an [HttpPipeline] over [transport] with exactly one step on each user-installable
134+
* pillar stage. The SERDE pillar is reserved by the runtime and carries no user step — typed
135+
* (de)serialization happens explicitly at the call site via [JacksonSerde], as shown in
136+
* [createUser].
137+
*/
138+
public fun buildPipeline(transport: HttpClient): HttpPipeline =
139+
HttpPipelineBuilder(transport)
140+
// REDIRECT pillar — follow 3xx responses within a hop budget.
141+
.append(DefaultRedirectStep())
142+
// RETRY pillar — exponential backoff that honours `Retry-After`. This re-sends a request
143+
// when its method is idempotent or its body is replayable; the sample's POST carries a
144+
// replayable (in-memory) body, so it qualifies. A real caller retrying a non-idempotent
145+
// write should pair this with an idempotency key (see `IdempotencyKeyStep`) so a retried
146+
// POST cannot create a duplicate server-side.
147+
.append(DefaultRetryStep())
148+
// AUTH pillar — stamp a static API key into the `Authorization` header.
149+
.append(
150+
KeyCredentialAuthStep(
151+
KeyCredential(
152+
apiKey = "example-api-key",
153+
headerName = HttpHeaderName.AUTHORIZATION,
154+
prefix = "Bearer",
155+
),
156+
),
157+
)
158+
// LOGGING pillar — emit request/response diagnostics at header granularity.
159+
.append(
160+
DefaultInstrumentationStep(
161+
HttpInstrumentationOptions(logLevel = HttpLogLevel.HEADERS),
162+
),
163+
)
164+
.build()
165+
166+
/**
167+
* Serializes [request] to a JSON body, POSTs it through [pipeline] to [endpoint], and deserializes
168+
* the JSON response into a typed [User]. Throws if the server does not answer with a 2xx status.
169+
*
170+
* The returned [User] is fully materialized before the response is closed, so the caller does not
171+
* own any streaming resource.
172+
*/
173+
public fun createUser(
174+
pipeline: HttpPipeline,
175+
serde: JacksonSerde,
176+
endpoint: URL,
177+
request: CreateUserRequest,
178+
): User {
179+
val json = serde.serializer.serialize(request)
180+
val httpRequest =
181+
Request.builder()
182+
.method(Method.POST)
183+
.url(endpoint)
184+
.addHeader(HttpHeaderName.ACCEPT.toString(), CommonMediaTypes.APPLICATION_JSON.toString())
185+
.body(RequestBody.create(json, CommonMediaTypes.APPLICATION_JSON))
186+
.build()
187+
188+
pipeline.send(httpRequest).use { response ->
189+
val status = response.status
190+
val payload = response.body?.source()?.readUtf8().orEmpty()
191+
check(status.isSuccess) { "Unexpected status $status — body: $payload" }
192+
return serde.deserializer.deserialize<User>(payload)
193+
}
194+
}
195+
196+
/**
197+
* Runs the full sample against an embedded HTTPS [MockWebServer] and prints the typed round-trip.
198+
*
199+
* No arguments are read; nothing touches the network. The single canned response makes the output
200+
* stable across runs and machines.
201+
*/
202+
public fun main() {
203+
installIoProvider()
204+
val serde = JacksonSerde.withDefaults()
205+
206+
// ---- Demo scaffolding: fake the server so the sample is deterministic and network-free. ----
207+
// Everything in this `tls.server.use { ... }` block stands in for a real backend; a caller
208+
// wiring the SDK against a live API would not write any of it.
209+
val tls = newTlsServer()
210+
tls.server.use { server ->
211+
// Canned JSON the SDK will deserialize back into a typed `User`.
212+
server.enqueue(
213+
MockResponse.Builder()
214+
.code(HTTP_CREATED)
215+
.addHeader(
216+
HttpHeaderName.CONTENT_TYPE.toString(),
217+
CommonMediaTypes.APPLICATION_JSON.toString(),
218+
)
219+
.body("""{"id":1,"name":"Ada Lovelace","email":"ada@example.org"}""")
220+
.build(),
221+
)
222+
server.start()
223+
224+
// ---- SDK wiring: this is the copyable part a real caller would actually write. ----
225+
tls.newTransportTrusting().use { transport ->
226+
val pipeline = buildPipeline(transport)
227+
val endpoint = server.url("/v1/users").toUrl()
228+
val payload = CreateUserRequest(name = "Ada Lovelace", email = "ada@example.org")
229+
230+
println("POST $endpoint")
231+
println(" request : $payload")
232+
val user = createUser(pipeline, serde, endpoint, payload)
233+
println(" response: $user")
234+
println("Created user #${user.id} (${user.name}).")
235+
}
236+
}
237+
}

0 commit comments

Comments
 (0)