HTTP Client¶
Drive your app's REST API like a real client. Type-safe responses, full headers + status access, multipart uploads, WebSocket support.
HTTP client — wizard-synced snippet
Gradle
Stove configuration
Test DSL
In 30 seconds
Add stove-http. Register httpClient { HttpClientSystemOptions(baseUrl = "http://localhost:8080") }. Drive endpoints with get<T>, post<T>, put<T>, patch<T>, delete<T>. Each returns parsed body via Jackson (or your converter). For full status + headers + body, use Response / ExpectBody variants.
Configure¶
Options:
| Field | Default | Use |
|---|---|---|
baseUrl |
required | base for all relative URIs |
contentConverter |
JacksonConverter(StoveSerde.jackson.default) |
use GsonConverter, or your own custom converter, or pass your app's ObjectMapper for alignment |
timeout |
30.seconds |
HTTP request timeout |
createClient |
jsonHttpClient(timeout, contentConverter) |
custom Ktor HttpClient factory |
Test DSL by verb¶
GET¶
stove {
http {
// body-only (typed)
get<UserResponse>("/users/123") {
it.id shouldBe 123
it.name shouldBe "John Doe"
}
// query params
get<String>("/api/search", queryParams = mapOf("q" to "stove")) {
it shouldContain "stove"
}
// headers + token
get<SecureData>(
"/secure",
headers = mapOf("X-Trace-Id" to "abc"),
token = "jwt".some()
) {
it.isAuthorized shouldBe true
}
// list response
getMany<ProductResponse>("/products", queryParams = mapOf("page" to "1")) {
it.size shouldBe 10
}
// full response (status + headers + body)
getResponse<UserResponse>("/users/123") { response ->
response.status shouldBe 200
response.headers["Content-Type"] shouldContain "application/json"
response.body().id shouldBe 123
}
// bodiless (just status)
getResponse("/health") {
it.status shouldBe 200
}
}
}
POST / PUT / PATCH / DELETE¶
stove {
http {
// POST expecting JSON back
postAndExpectJson<UserResponse>("/users") {
CreateUserRequest(name = "John", email = "john@example.com")
} { user ->
user.id shouldNotBe null
}
// POST with full response
postAndExpectBody<ProductResponse>(
uri = "/products",
body = CreateProductRequest(name = "Laptop", price = 999.99).some()
) { response ->
response.status shouldBe 201
response.headers["Location"] shouldNotBe null
}
// POST bodiless
postAndExpectBodilessResponse(
uri = "/products/activate",
body = ActivateRequest(productId = 123).some()
) {
it.status shouldBe 200
}
// PUT
putAndExpectJson<UserResponse>("/users/123") {
UpdateUserRequest(name = "Jane Doe")
} { it.name shouldBe "Jane Doe" }
// PATCH
patchAndExpectBody<UserResponse>(
uri = "/users/123",
body = mapOf("email" to "new@example.com").some()
) { it.body().email shouldBe "new@example.com" }
// DELETE
deleteAndExpectBodilessResponse("/users/123") {
it.status shouldBe 204
}
}
}
Multipart upload¶
stove {
http {
postMultipartAndExpectResponse<UploadResponse>(
uri = "/products/import",
body = listOf(
StoveMultiPartContent.Text("productName", "Laptop"),
StoveMultiPartContent.File(
param = "file",
fileName = "products.csv",
content = csvBytes,
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE
)
)
) { response ->
response.status shouldBe 200
response.body().uploadedFiles.size shouldBe 1
}
}
}
Escape hatch: raw Ktor client¶
stove {
http {
client { baseUrl ->
val response = get {
url(baseUrl.buildString() + "/custom")
header("X-Custom", "value")
}
response.status.value shouldBe 200
}
}
}
CRUD example¶
test("CRUD on products") {
stove {
var productId: Long? = null
http {
// create
postAndExpectBody<ProductResponse>(
uri = "/products",
body = CreateProductRequest(name = "Laptop", price = 999.99).some()
) { response ->
response.status shouldBe 201
productId = response.body().id
}
// read
get<ProductResponse>("/products/$productId") {
it.name shouldBe "Laptop"
}
// update
putAndExpectJson<ProductResponse>("/products/$productId") {
UpdateProductRequest(price = 899.99)
} { it.price shouldBe 899.99 }
// delete
deleteAndExpectBodilessResponse("/products/$productId") {
it.status shouldBe 204
}
// verify gone
getResponse<ErrorResponse>("/products/$productId") {
it.status shouldBe 404
}
}
}
}
Error scenarios¶
stove {
http {
postAndExpectBody<ValidationErrorResponse>(
uri = "/users",
body = InvalidUserRequest().some()
) {
it.status shouldBe 400
it.body().errors shouldContain "name is required"
}
getResponse<ErrorResponse>("/secure") { it.status shouldBe 401 }
getResponse<ErrorResponse>("/users/999999") { it.status shouldBe 404 }
}
}
WebSocket¶
webSocket opens a session inside http { }. Same connection lifetime as the lambda.
stove {
http {
webSocket("/chat") {
send("Hello, WebSocket!")
val response = receiveText()
response shouldBe "Echo: Hello, WebSocket!"
}
}
}
Send / receive¶
webSocket("/endpoint") {
// send
send("Hello") // text
send(byteArrayOf(1, 2, 3)) // binary
send(StoveWebSocketMessage.Text("via sealed class"))
// receive
val text = receiveText()
val bytes = receiveBinary()
val typed = receive() // StoveWebSocketMessage.Text | Binary | null
// with timeout
val opt = receiveTextWithTimeout(5.seconds) // Option<String>
}
Batch + streaming¶
webSocket("/broadcast") {
// batch with timeout
val msgs = collectTexts(count = 5, timeout = 10.seconds)
msgs.size shouldBe 5
// streaming via Flow
incomingTexts().take(10).toList() shouldHaveSize 10
incoming().take(5).collect { msg ->
when (msg) {
is StoveWebSocketMessage.Text -> println(msg.content)
is StoveWebSocketMessage.Binary -> println(msg.content.size)
}
}
}
Auth, headers, close, raw access¶
webSocket(uri = "/secure", token = "jwt".some()) { /* ... */ }
webSocket(
uri = "/chat",
headers = mapOf("X-Trace-Id" to "abc")
) {
send("hi")
close("test done")
}
// raw Ktor session for advanced frame control
webSocketRaw("/advanced") {
send(Frame.Text("raw"))
for (frame in incoming) {
when (frame) {
is Frame.Text -> println(frame.readText())
is Frame.Close -> break
else -> {}
}
}
}
Multi-system patterns¶
HTTP + DB¶
stove {
var userId: Long = 0
http {
postAndExpectBody<UserResponse>(
"/users",
body = CreateUserRequest(name = "John").some()
) { userId = it.body().id }
}
postgresql {
shouldQuery<User>(
query = "SELECT * FROM users WHERE id = $userId",
mapper = { row -> User(row.long("id"), row.string("name")) }
) { users ->
users.first().name shouldBe "John"
}
}
}
HTTP + Kafka¶
stove {
http {
postAndExpectBodilessResponse(
"/orders",
body = CreateOrderRequest(amount = 100.0).some()
) { it.status shouldBe 201 }
}
kafka {
shouldBePublished<OrderCreatedEvent> {
actual.amount == 100.0
}
}
}
HTTP + WireMock¶
stove {
wiremock {
mockGet("/external/data", 200, ExternalData(value = "test").some())
}
http {
get<ResponseData>("/data") { it.value shouldBe "test" }
}
}
Pairs well with¶
- WireMock. Mock outbound HTTP at the boundary
- Recipes · order flow. Full multi-system flow
- Bridge. Drive a domain service directly when there's no HTTP path