Model HTTP Contracts
This guide covers the core job tapik solves: declaring an HTTP contract once so the same model can drive generated clients, generated server interfaces, and documentation.
|
If you have not run tapik yet, start with Quickstart first. |
Start with one API type per contract area
tapik discovers endpoints from concrete classes and objects that implement API.
The most common pattern is one singleton object per API area:
object CatalogApi : API {
val getProduct by endpoint { ... }
val createProduct by endpoint { ... }
}
That keeps discovery predictable and gives generators one obvious place to group related endpoints.
Let the endpoint property name carry the identity
The delegated endpoint form uses the property name as the endpoint identifier:
val getProduct by endpoint(
description = "Get a product by ID",
details = "Returns localized product data when the product exists."
) {
get("products" / path.string("productId"))
}
That identifier flows into metadata and generated code, so descriptive property names matter.
Use description for the short summary that should appear in generated docs. Use details only for behavior that a user of the endpoint needs to know but that does not fit in the summary.
Build the request shape from the URI outward
tapik contracts usually read best when you define reusable URI pieces first:
private val products = "api" / "v1" / "products"
private val productId = path.string("productId")
private val locale = query.string("locale").optional("en-US")
private val page = query.int("page").optional(1)
Then use those pieces in the endpoint:
val getProduct by endpoint(
description = "Get a product by ID",
details = "Returns localized product data when the product exists."
) {
get(products / productId + locale)
.input(header.uuid("X-Request-Id"))
.output(Status.Ok) { jsonBody<ProductView>("product") }
.output(Status.NotFound) { jsonBody<ProblemDetails>("problem") }
}
This is the pattern to follow:
-
path variables model required URI segments,
-
query parameters model optional or filter-like inputs,
-
headers model protocol metadata,
-
bodies model the typed payload on the wire.
Add bodies only when the request really has one
For request bodies, keep the endpoint honest about transport details:
val createProduct by endpoint(
description = "Create a product",
details = "Accepts a JSON request body and returns the created product."
) {
post(products)
.input(header.Accept(MediaType.Json)) {
jsonBody<CreateProductRequest>("createProductRequest")
}
.output(Status.Created, headersOf(Header.Location)) {
jsonBody<ProductView>("product")
}
.output(Status.BadRequest) {
jsonBody<ProblemDetails>("problem")
}
}
The request body and the output bodies are separate declarations because they solve different problems:
-
input bodies describe what the caller must send,
-
output bodies describe what the endpoint may return,
-
response headers belong on outputs, not on the endpoint as a whole.
Model protocol branches, not internal states
Outputs are where tapik becomes most valuable. They define the observable branches of the endpoint contract.
val getAvatar by endpoint(
description = "Get a product image",
details = "Returns a raw PNG image for the requested product."
) {
get(products / productId / "image" + query.int("size"))
.output {
rawBody(mediaType = MediaType.Custom("image", "png"))
}
}
When an endpoint has more than one meaningful outcome, declare them explicitly:
val authenticate by endpoint(
description = "Validate a session token",
details = "Returns plain-text auth errors for unauthorized or forbidden responses."
) {
post("api" / "v1" / "auth")
.output(Status.BadRequest, headersOf(Header.ContentType(MediaType.Json))) {
jsonBody<ProblemDetails>("problem")
}
.output(anyStatus(Status.Unauthorized, Status.Forbidden)) {
stringBody("authError")
}
.output(unmatchedStatus) { EmptyBody }
}
Do this only when the branch matters to callers. tapik will preserve that shape in generated code.
Reuse definitions, not entire walls of DSL
The DSL is easiest to maintain when shared wire-level concepts are extracted once:
private val requestId = header.uuid("X-Request-Id")
private val locale = query.string("locale").optional("en-US")
private val productId = path.string("productId")
Reuse is useful when it preserves meaning. If a helper hides the actual HTTP contract, it is usually too abstract for documentation and generation-oriented code.
Keep the source readable for the people who maintain it
Good tapik contracts are concise, but they are not cryptic. A reader should be able to answer three questions directly from the source:
-
what request shape does this endpoint accept,
-
what responses can it produce,
-
why does this endpoint exist in the API at all.
If those answers are clear in the contract, the generated artifacts usually become clear too.