Guide
Pelican
Typed HTTP endpoints for Kotlin. Describe an endpoint once, as a value, and serve it, document it, test it and generate its client from that one description.
- Artifact
- io.github.matthewjones372:pelican-*
- Latest
- 1.0.0
- Needs
- JDK 25, Kotlin 2.4.20
- Licence
- Apache 2.0
What it is
Pelican is a Kotlin library in which an HTTP endpoint is an ordinary value: its method and path, its typed inputs, and every response it can give, successes and failures alike. From that one value Pelican derives the server route, the OpenAPI document, a typed client for your tests and a generated Kotlin client for your callers.
The problem it addresses is a contract that lives in four places. The handler decides what the server accepts, an OpenAPI file says what it accepts, client models say it again and tests say it a fourth time with URL strings and JSON literals. Each copy drifts. In Pelican there is one copy, so the route, the document and both clients cannot describe different APIs.
data class Bookmark(val id: Long, val url: String, val title: String, val tags: List<String> = emptyList())
data class NoSuchBookmark(val id: Long, val message: String)
val bookmarkId = pathParam<Long>("bookmarkId", description = "The bookmark's id")
val bookmarkMissing = errorJson<NoSuchBookmark>(404, "No bookmark with that id")
val getBookmark = endpoint(bookmarkId) {
get("bookmarks" / bookmarkId)
json<Bookmark>() orFail bookmarkMissing
}
val route = getBookmark handledOrFail { id -> // id: Long, already decoded
Bookmarks.find(id)?.let { ok(it) }
?: bookmarkMissing(NoSuchBookmark(id, "No bookmark $id"))
}
The handler receives a decoded Long, so /bookmarks/abc is answered with a 400 before it runs. Because the 404 is declared, the handler must return Outcome<NoSuchBookmark, Bookmark>, and one that forgets the failure does not compile.
Pelican is a library, not a framework. It does not own main, it has no registry or annotation scanning, and a set of routes is a List you build, split and filter like any other value.
When it fits
- The service publishes an OpenAPI document other teams or tools rely on, and it has drifted before.
- Input constraints (ranges, patterns, defaults) should be enforced and documented by the same declaration.
- Declared failures matter to callers, and a handler that forgets a documented 404 should fail to compile.
- You want a typed test client, a generated client, golden-file checks or MCP tools from the same descriptions.
- The service runs on Pekko HTTP already, or is new and free to choose its server, on JDK 25 or newer.
When it does not
- A small internal API with one caller in the same repository: plain routes are simpler.
- A service on Spring, Ktor, Vert.x or another server Pelican has no module for. There are two backends, Pekko HTTP and Jetty 12.
- A contract negotiated as an OpenAPI file before any code exists: generate from the file instead.
- A consumer that needs OpenAPI 3.0 or Swagger 2.0. Pelican writes 3.1.0 (the default) and 3.2.0 only.
- A team that cannot take on a young library maintained mainly by one person. 1.0.0 is the first stable release.
Install
Pelican is on Maven Central under the group io.github.matthewjones372. The current release is 1.0.0. It is a set of small modules: a build takes one backend, one codec and anything else only when it is wanted. Every module brings pelican-core, which depends on nothing but the Kotlin standard library and jackson-core's streaming parser.
Requirements
- JDK 25 or newer for 1.0.0: its library modules are compiled to JDK 25 bytecode. On
main(not yet released) the floor is JDK 21, exceptpelican-streamsandpelican-streams-jetty. - Kotlin 2.4.20 is what 1.0.0 is built with. Pelican is not tested with older Kotlin compilers.
- Gradle: the repository builds with 9.8.0. The optional Gradle plugin needs Java 21 or newer.
Choosing a backend and a codec
| Module | Availability | What it gives you |
|---|---|---|
pelican-pekko | 1.0.0 | Descriptions as Pekko HTTP routes (toRoute(system)) and start to bind them. Pekko is provided: you declare it. |
pelican-jetty | main only | Descriptions as a Jetty 12 core Handler (toHandler()) and start. Ships jetty-server 12.1.13; no Pekko or Scala on the classpath. |
pelican-jackson | 1.0.0 | JacksonCodecs: JSON bodies and the schemas the document derives. |
pelican-kotlinx | main only | KotlinxCodecs, for @Serializable payloads, on kotlinx-serialization-json 1.11.0. |
pelican-pekko-docs / pelican-jetty-docs | 1.0.0 / main only | startWithDocs: /openapi.json and a Swagger UI or Redoc page beside the endpoints. Brings pelican-openapi. |
pelican-test-pekko / pelican-test-jetty | 1.0.0 / main only | inMemory(): the typed test client through the real backend, no socket. Brings pelican-test. |
pelican-pekko-mcp / pelican-jetty-mcp | 1.0.0 / main only | MCP tools over Streamable HTTP on /mcp. |
pelican-client-pekko / pelican-client-jetty | 1.0.0 / main only | A transport for a generated client. No server. |
pelican-test-golden, pelican-test-wiremock | 1.0.0 | Golden files of the published contract; WireMock stubbed with endpoint values. |
pelican-health, pelican-metrics, pelican-metrics-otel, pelican-oidc, pelican-arrow | 1.0.0 | Probes, Micrometer, OpenTelemetry, OpenID Connect, and Arrow's Either to Outcome. |
Pick Pekko for a service that already runs Pekko or Akka, since Pelican can serve on the actor system you have, or if you need a released backend today. Pick Jetty for a new service with no Pekko, accepting a snapshot until it is released. For the codec, choose by the annotations your model already carries: Jackson reads plain data classes, kotlinx needs @Serializable. Both write the same schemas and are held to the same bytes by a codec agreement test.
On 1.0.0: Pekko and Jackson
1.0.0 has no BOM, so one pelicanVersion value keeps the modules aligned. Pelican compiles against Pekko and ships none of it, so you name the Scala cross-build you run (_2.13 or _3, the same on every Pekko line).
val pelicanVersion = "1.0.0"
dependencies {
// The backend. Brings pelican-core; compiles against Pekko HTTP.
implementation("io.github.matthewjones372:pelican-pekko:$pelicanVersion")
// The codec: JSON bodies with Jackson, and the schemas the document derives.
implementation("io.github.matthewjones372:pelican-jackson:$pelicanVersion")
// /openapi.json and Swagger UI beside the endpoints. Brings pelican-openapi.
implementation("io.github.matthewjones372:pelican-pekko-docs:$pelicanVersion")
// Pekko itself. Pelican ships none, so name the Scala build you run.
implementation(platform("org.apache.pekko:pekko-bom_2.13:1.7.1"))
implementation("org.apache.pekko:pekko-actor-typed_2.13")
implementation("org.apache.pekko:pekko-stream_2.13")
implementation("org.apache.pekko:pekko-http_2.13:1.4.1")
// The typed test client run against a Pekko Api. Brings pelican-test.
testImplementation("io.github.matthewjones372:pelican-test-pekko:$pelicanVersion")
}
On a snapshot of main: Jetty, the BOM and kotlinx
The BOM manages versions and nothing else: you still declare each module, without a version. It covers Pelican's own modules only, not Jackson, Jetty or the Gradle plugin.
repositories {
mavenCentral()
maven("https://central.sonatype.com/repository/maven-snapshots/")
}
plugins {
kotlin("jvm") version "2.4.20"
kotlin("plugin.serialization") version "2.4.20" // only for pelican-kotlinx
}
dependencies {
implementation(platform("io.github.matthewjones372:pelican-bom:1.0.1-SNAPSHOT"))
implementation("io.github.matthewjones372:pelican-jetty")
implementation("io.github.matthewjones372:pelican-kotlinx") // or pelican-jackson
implementation("io.github.matthewjones372:pelican-jetty-docs")
testImplementation("io.github.matthewjones372:pelican-test-jetty")
}
The Gradle plugin
The optional plugin io.github.matthewjones372.pelican runs the generators as tasks: OpenAPI documents, Kotlin clients, endpoints imported from someone else's document, and checks against committed copies. It is on Maven Central, not the Gradle Plugin Portal, so tell the build once:
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
plugins { id("io.github.matthewjones372.pelican") version "1.0.0" }
Pin the plugin at the same version as the libraries: its generators and the runtime read the same descriptions and are not tested against each other across versions.
Your first service
One endpoint, served with its OpenAPI document and Swagger UI, on the released 1.0.0 stack. The program is the README's first endpoint, which the repository's example module compiles on every build (FirstEndpoint.kt with the Pekko binders of FirstEndpointOnPekko.kt).
Set up the build
Use the 1.0.0 block from Install and add the Kotlin and application plugins, on a JDK 25 toolchain.
build.gradle.kts plugins { kotlin("jvm") version "2.4.20" application } repositories { mavenCentral() } kotlin { jvmToolchain(25) } application { mainClass.set("GreetingsKt") } val pelicanVersion = "1.0.0" dependencies { implementation("io.github.matthewjones372:pelican-pekko:$pelicanVersion") implementation("io.github.matthewjones372:pelican-jackson:$pelicanVersion") implementation("io.github.matthewjones372:pelican-pekko-docs:$pelicanVersion") implementation(platform("org.apache.pekko:pekko-bom_2.13:1.7.1")) implementation("org.apache.pekko:pekko-actor-typed_2.13") implementation("org.apache.pekko:pekko-stream_2.13") implementation("org.apache.pekko:pekko-http_2.13:1.4.1") testImplementation("io.github.matthewjones372:pelican-test-pekko:$pelicanVersion") }Describe the endpoint and serve it
The description (
greet) imports nothing but core. The binderhandledNowandstartWithDocsare the only Pekko-specific lines.src/main/kotlin/Greetings.kt import io.github.matthewjones372.pelican.api import io.github.matthewjones372.pelican.div import io.github.matthewjones372.pelican.endpoint import io.github.matthewjones372.pelican.jackson.JacksonCodecs import io.github.matthewjones372.pelican.openapi.docs import io.github.matthewjones372.pelican.pathParam import io.github.matthewjones372.pelican.pekko.docs.startWithDocs import io.github.matthewjones372.pelican.pekko.handledNow data class Greeting(val message: String) val who = pathParam<String>("who", description = "Who to greet") val greet = endpoint(who) { get("hello" / who) summary = "Greet somebody by name" json<Greeting>() } fun greetings() = api( endpoints = listOf(greet handledNow { name -> Greeting("Hello, $name!") }), codecs = JacksonCodecs, ) { title = "Greetings" version = "1.0.0" } fun main() { val server = greetings().startWithDocs(port = 8080, docs = docs { docsPath = "/api-docs" }) println("Listening on ${server.baseUrl}, docs at ${server.baseUrl}/api-docs") }Run it
Terminal ./gradlew runThe server listens on loopback (
127.0.0.1) unless you pass ahost. To see the same thing without writing anything, clone the repository and run./gradlew :example:runFirstEndpointOnPekko.Call it
Terminal curl http://127.0.0.1:8080/hello/world # {"message":"Hello, world!"} curl http://127.0.0.1:8080/openapi.json # the OpenAPI 3.1.0 document, built from greet open http://127.0.0.1:8080/api-docs # Swagger UI, reading that documentThe document is served at
/openapi.jsonby default and the docs page at thedocsPathyou set (/docsif you leave it). Set either path tonullto turn that page off.Test it, with no socket
src/test/kotlin/GreetingsTest.kt import io.github.matthewjones372.pelican.test.pekko.inMemory import io.github.matthewjones372.pelican.test.shouldBuild import io.kotest.matchers.shouldBe private val app = greetings().inMemory("first-endpoint") // starts an actor system of that name app.call(greet, "world") shouldBe Greeting("Hello, world!") // shouldBe is Kotest's; use any matcher app.request(greet, "world") shouldBuild "GET /hello/world"The first line is behaviour and must survive a rename. The second pins the URL a caller holds and must break on one.
The same service on Jetty
On a snapshot of main, with pelican-jetty and pelican-jetty-docs, three imports change and nothing else. This is example/hello/FirstEndpoint.kt; run it from a clone with ./gradlew :example:runFirstEndpoint.
import io.github.matthewjones372.pelican.jetty.docs.startWithDocs // was ...pekko.docs.startWithDocs
import io.github.matthewjones372.pelican.jetty.handledNow // was ...pekko.handledNow
import io.github.matthewjones372.pelican.jetty.Handlers
import io.github.matthewjones372.pelican.test.jetty.inMemory
import io.github.matthewjones372.pelican.test.shouldBuild
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
class FirstEndpointTest {
private val app = greetings().inMemory(Handlers.onVirtualThreads)
@Test
fun `answers the greeting, by endpoint value`() {
app.call(greet, "world") shouldBe Greeting("Hello, world!")
}
@Test
fun `is served at the URL its callers were given`() {
app.request(greet, "world") shouldBuild "GET /hello/world"
}
}
With pelican-kotlinx instead of Jackson, annotate the payload and pass KotlinxCodecs:
import io.github.matthewjones372.pelican.kotlinx.KotlinxCodecs
import kotlinx.serialization.Serializable
@Serializable
data class Greeting(val message: String)
fun greetings() = api(
endpoints = listOf(greet handledNow { name -> Greeting("Hello, $name!") }),
codecs = KotlinxCodecs,
) {
title = "Greetings"
version = "1.0.0"
}
Core concepts
The examples below are cut from the cookbook and the example module. Binder imports are shown for Jetty (io.github.matthewjones372.pelican.jetty); on 1.0.0 import the same names from io.github.matthewjones372.pelican.pekko. Wildcard imports are refused by Pelican's own build, so the docs write one name per line.
Endpoints are values
endpoint(inputs) { } returns an Endpoint<I, O>. It holds no handler and does no work: the route, the document, the test client and the client generator all read it. Inputs listed on endpoint(...) fix the handler's argument types and register the inputs for decoding and for the document.
val getUser = endpoint(userId) {
get("users" / userId)
summary = "Fetch one user"
operationId = "getUser"
tag("users")
json<User>() orFail noSuchUser
} // Endpoint<Long, Outcome<ApiError, User>>
Paths are built with / from strings and path parameters. get, post, put, patch and delete set the method. Some mistakes are beyond the type system, such as a declared path parameter missing from the path or a codec missing for a body type: those fail when the endpoint or the Api is constructed, at start-up rather than on the first request.
Inputs: path, query, header and body
import io.github.matthewjones372.pelican.default
import io.github.matthewjones372.pelican.div
import io.github.matthewjones372.pelican.endpoint
import io.github.matthewjones372.pelican.headerParam
import io.github.matthewjones372.pelican.jetty.handledNow
import io.github.matthewjones372.pelican.optional
import io.github.matthewjones372.pelican.pathParam
import io.github.matthewjones372.pelican.queryParam
val userId = pathParam<Long>("userId")
val limit = queryParam<Int>("limit").default(25)
val status = queryParam<OrderStatus>("status").optional()
val trace = headerParam<String>("X-Trace-Id").optional()
val listOrders = endpoint(userId, limit, status, trace) {
get("users" / userId / "orders")
json<List<Order>>()
}
listOrders handledNow { (id, max, status, trace) ->
// Long Int OrderStatus? String?
Store.orders(id, max, status)
}
A JSON body is jsonBody<T>(), listed like any other input. Several inputs arrive destructured; a test passes them as In2, In3 and so on.
val apiKey = headerParam("X-Api-Key", StringCodec.nonEmpty())
val newBookmark = jsonBody<CreateBookmark>(description = "The bookmark to save")
val createBookmark = endpoint(apiKey, newBookmark) {
post("bookmarks")
summary = "Save a bookmark"
json<Bookmark>(status = 201) orFail badKey
}
Cookies (cookieParam), form bodies (formBody<T>()) and multipart parts (textPart, filePart) work the same way; see Recipes.
Outputs
The last expression of the builder is the endpoint's output. It decides the response's status, media type and schema.
| Output | Handler returns (Jetty) | On the wire |
|---|---|---|
json<T>(), json<T>(status = 201) | T | one JSON value |
jsonArray<T>() | Sequence<T> | a chunked [...], flushed as produced |
ndjson<T>() | Sequence<T> | one JSON value per line |
sse<T>(eventName, keepAlive, id, retry) | Sequence<T> | server-sent events |
text() | String | text/plain |
bytes(mediaType) | Sequence<ByteArray> | opaque bytes |
empty() | Unit | 204, no body |
On Pekko a stream is a Source<T, NotUsed> where Jetty takes a Sequence<T>, and raw bytes are Source<ByteString, NotUsed>.
Declared errors: errorJson and orFail
A failure a caller was promised is a value. Declare it with errorJson<T>(status, description) and list it on the output with orFail. It joins the endpoint's type, so the handler has to produce ok(value) or one of the declared failures.
import io.github.matthewjones372.pelican.ApiError
import io.github.matthewjones372.pelican.div
import io.github.matthewjones372.pelican.endpoint
import io.github.matthewjones372.pelican.errorJson
import io.github.matthewjones372.pelican.jetty.handledOrFail
import io.github.matthewjones372.pelican.ok
import io.github.matthewjones372.pelican.orFail
val noSuchUser = errorJson<ApiError>(404, "No user with that id")
val badApiKey = errorJson<ApiError>(401, "Missing or bad API key")
val placeOrder = endpoint(userId, apiKey, newOrder) {
post("users" / userId / "orders")
json<Order>(status = 201).orFail(badApiKey, noSuchUser)
}
placeOrder handledOrFail { (id, key, req) ->
when {
key != "let-me-in" -> badApiKey(ApiError(401, "Bad API key"))
Store.user(id) == null -> noSuchUser(ApiError(404, "No user $id"))
else -> ok(Store.create(id, req))
}
}
The status comes from the declaration, not the payload's type, which is why two failures may carry the same type. A handler that returns a bare value, or forgets a declared failure, is a compile error:
getBookmark handledOrFail { id -> Bookmarks.find(id)!! }
e: Return type mismatch: expected 'Outcome<NoSuchBookmark, Bookmark>', actual 'Bookmark'.
For a case nobody declared, throw a refusal: badRequest, unauthorized, forbidden, notFound, conflict and tooManyRequests each throw an ApiException and return Nothing. Any other exception becomes an opaque 500 with a reference that also appears in the log line, so a stack trace never reaches the caller.
500 {"status":500,"error":"Internal server error","detail":"Reference: f3ef2bdef43b"}
ERROR io.github.matthewjones372.pelican.jetty - Unhandled failure in POST /reports [ref f3ef2bdef43b]
java.sql.SQLException: connection to db-primary.internal:5432 refused
Handlers: handledOrFail and friends
A binder pairs a description with its implementation and returns a ServerEndpoint. Inside the lambda, this is the request's Params. Both backends have the same names, checked by BinderNameParityTest.
| Binder | For an endpoint whose output is | The lambda returns |
|---|---|---|
handledNow | T | T |
handledWith | Unit | Unit |
handledOrFail | Outcome<E, T> | Outcome<E, T> |
handledOneOf | several successes (a or b) | the same, under a name that reads right |
streamedNow, streamedOrFail | a stream | Sequence<T> (Jetty) or Source (Pekko) |
bytesNow, bytesOrFail | bytes() | a stream of byte chunks |
handledBy, handledByOrFail, handledByOneOf, streamedBy, streamedByOrFail | as above | a CompletionStage of the same |
-Now is a claim about when: the handler produces its value during the call. The …By family returns something that completes later, on a thread it chose.
Routes, the Api and servers
api(endpoints, codecs) { } assembles bound endpoints into an Api. Its block sets the document's metadata and the service-wide settings. A backend turns the Api into a server.
val server = ordersApi().start(port = 8080) // binds 127.0.0.1
val server = ordersApi().start(port = 8080, host = "0.0.0.0") // every interface, said out loud
server.use { it.block() } // AutoCloseable everywhere; block() returns when stop() does
The handle has the same members on both backends: port, baseUrl, host, stop(), stopAsync(), block() and close(). To mount Pelican beside handlers you already have, use Api.toHandler() on Jetty, which passes on any request it does not answer so it sits in a Handler.Sequence, or Api.toRoute(system) on Pekko, which concatenates with your routes. That is also how you adopt Pelican one endpoint at a time.
Docs and clients
The OpenAPI document
Three ways, all reading the same descriptions. Served by the app, as in the first service, through a -docs module. In code, with only pelican-core and pelican-openapi:
val spec = apiSpec(listOf(getUser, streamOrders, placeOrder), schemas = JacksonCodecs) {
title = "Orders"
}
spec.openApiJson() // String
spec.openApiYaml() // the same document, written as YAML
spec.openApi() // JsonObj: core's own tree, if you want to post-process it
spec.openApiJson(OpenApiVersion.V3_2_0) // the same descriptions, written against 3.2
An Api gives its own with api.spec(). At build time, with no server, the Gradle plugin loads a function returning an ApiSpec and writes what it returns:
plugins { id("io.github.matthewjones372.pelican") }
pelican {
documents {
create("orders") { specClass.set("example.GenerateOpenApiKt"); specFunction.set("ordersSpec") }
}
}
The docs page is Swagger UI unless the docs { } block says otherwise; Redoc and a RapiDoc reference with a theme { } are also available. Petshop serves Swagger UI at /api-docs and a themed RapiDoc reference at /reference:
val petshopDocs: Docs = docs {
docsPath = "/api-docs"
rapiDocReference("/reference") {
theme {
accent = TERRACOTTA
text = BARK
sidebar = CREAM
font("Nunito, sans-serif", stylesheet = NUNITO)
}
attributes("allow-server-selection" to "false", "allow-authentication" to "false")
}
}
A generated Kotlin client
A caller who cannot depend on your descriptions gets one generated Kotlin file: one class, one method per endpoint, a sealed <Operation>Failure per endpoint that declares failures. The plugin generates it and can check a committed copy on every ./gradlew check. Petshop's registry module does exactly that, with kotlinx annotations:
plugins {
// Checks on every build that the committed client still matches `registrySpec()`.
id("io.github.matthewjones372.pelican")
kotlin("plugin.serialization")
}
pelican {
clients {
create("registry") {
specClass.set("petshop.registry.RegistryKt")
specFunction.set("registrySpec")
packageName.set("petshop.registry.client")
codec.set("kotlinx")
outputDir.set(layout.projectDirectory.dir("src/main/generated"))
classpath.setFrom(sourceSets.named("main").map { it.runtimeClasspath }, codegen)
}
}
}
Each entry registers generate<Name>Client and check<Name>Client. With outputDir in a source root the check is wired into check; inside build/ (the default) it is not, and you add the directory to a source set yourself. Other settings are clientName, baseUrl, includeHidden and callStyle (blocking or suspending). The source set the spec is compiled in needs pelican-codegen.
fun place(client: OrdersClient, key: String): Order? =
when (val result = client.placeOrder(1L, CreateOrder("anvil"), xApiKey = key)) {
is Outcome.Ok -> result.value
is Outcome.Err -> when (val failure = result.failure) { // exhaustive
is PlaceOrderFailure.Unauthorized -> null
is PlaceOrderFailure.NotFound -> null
is PlaceOrderFailure.TooManyRequests -> null // failure.retryAfter: Long?
}
}
val client = OrdersClient("https://orders.internal", JacksonCodecs)
val orders = client.listOrders(1L, limit = 3).use { it.toList() } // Streamed<Order>
The constructor takes baseUrl, codecs, a transport (a ClientTransport), a timeout and a headers supplier. Petshop builds its registry client on Jetty's transport:
private val client = RegistryClient(
baseUrl = settings.baseUrl,
codecs = KotlinxCodecs,
transport = transport.traced(telemetry), // a JettyHttpTransport, with a client span per call
timeout = settings.timeout.toJavaDuration(),
)
MCP tools
The same descriptions become tools a model can call, served through the same handlers and filters. No MCP SDK is involved. pelican-mcp holds the tools as values, pelican-mcp-server speaks JSON-RPC 2.0 over stdio, and pelican-pekko-mcp or pelican-jetty-mcp serve /mcp over Streamable HTTP.
val tools = ordersSpec().mcpTools(options) // descriptions only
val dispatch = ordersApi().mcpDispatch(options) // descriptions, and callable
dispatch.call("placeOrder", arguments) // CompletionStage<ToolResult>
// Over stdio, what a desktop client launches as a subprocess
fun main() = mcpServe(ordersApi(), toolOptions)
// Over HTTP, on Jetty
val server = ordersApi().start(port = 8080) { handlerWithMcp(options = toolOptions) }
// Over HTTP, on Pekko
val server = ordersApi().start(port = 8080) { system -> routeWithMcp(system, toolOptions) }
mcpOptions {
include = { it.operationId in callable }
headers = mapOf("X-Api-Key" to System.getenv("ORDERS_API_KEY"))
}
A path parameter becomes a required argument, a refinement such as between(1, 100) becomes minimum/maximum, a body is one body argument, and a declared failure is a result carrying isError. Header parameters are not arguments: a model asked for a credential invents one, so supply it in mcpOptions.
A standalone JSON Schema
pelican-schema describes one type as JSON Schema 2020-12 with every pointer resolving inside the object it returns, for a validator or registry that does not hold your OpenAPI document. MCP uses it for every tool's schemas.
import io.github.matthewjones372.pelican.JsonObj
import io.github.matthewjones372.pelican.jackson.JacksonCodecs
import io.github.matthewjones372.pelican.schema.StandaloneSchemas
import kotlin.reflect.typeOf
val schema: JsonObj = StandaloneSchemas(JacksonCodecs).schema(typeOf<PaymentMethod>())
println(schema.render())
A whole service
The repository's a-whole-service.md is one compiled file with models, inputs, endpoints, a store, handlers and the server, in that order. The descriptions import only io.github.matthewjones372.pelican; the handler list is the one place that knows which server runs. A larger service keeps one such list per area and concatenates them.
Validation
Refining a codec rejects bad input before the handler runs and publishes the same rule in the schema, so the document cannot promise a range the server does not enforce.
val limit = queryParam("limit", IntCodec.between(1, 100), description = "How many to return").default(20)
@JvmInline value class Slug(val value: String)
val slug: PlainCodec<Slug> = StringCodec
.matching(Regex("[a-z0-9-]{1,40}"), "a slug: lowercase letters, digits and dashes")
.map(::Slug, Slug::value)
.describedAs("A URL-safe tag", example = "streams")
val tag = queryParam("tag", slug, description = "Only bookmarks with this tag").optional()
{"status":400,"error":"Invalid parameter","detail":"Cannot decode '0' for 'limit': expected a value between 1 and 100"}
Built in: nonEmpty(), nonBlank(), minLength(n), maxLength(n), matching(regex), atLeast(n), atMost(n), between(lo, hi), positive(). For your own rules, mapOrFail turns a rejected value into the same 400. For what only the handler knows (a date range that ends before it starts), throw badRequest(message).
Authentication
security(...) documents how callers authenticate; it does not check anything. A caller enforces: declare it under a scheme, require it on an endpoint, and bind the verifier on the Api.
data class Caller(val subject: String, val groups: Set<String>)
val bearer = bearerAuth()
val caller = authenticated(bearer) { id -> Caller(id.subject, id.groups) }
val getAccount = endpoint(accountId) {
get("accounts" / accountId)
authenticatedBy(caller) // 401 before the handler; the scheme required in the document
json<Account>()
}
getAccount handledNow { id -> accounts.of(this[caller], id) }
api(routes, codecs = JacksonCodecs) { authenticate(bearer, by = myVerifier) }
caller.optional() hands over Caller?. An endpoint whose scheme nothing verifies fails when the Api is built. For OpenID Connect tokens, pelican-oidc checks signature, issuer, audience, expiry and algorithm:
val pocketId = oidc(issuer = "https://id.home.arpa", audience = "lark-bank")
val caller = authenticated(pocketId.scheme) { id -> Caller(id.subject, id.groups) }
api(routes, codecs = JacksonCodecs) { authenticate(pocketId.scheme, pocketId) }
Filters
Filters run around every handler, outermost first, after the inputs are decoded. They reject by throwing and pass values to handlers through typed attributes. before { }, after { } and afterStatus { } cover the common shapes; Filter { params, next -> } is the general one.
import io.github.matthewjones372.pelican.Filter
import io.github.matthewjones372.pelican.api
import io.github.matthewjones372.pelican.attribute
import io.github.matthewjones372.pelican.before
import io.github.matthewjones372.pelican.headerParam
import io.github.matthewjones372.pelican.jackson.JacksonCodecs
import io.github.matthewjones372.pelican.optional
import io.github.matthewjones372.pelican.unauthorized
data class Caller(val subject: String, val plan: String)
val caller = attribute<Caller>("caller")
val authorization = headerParam<String>("Authorization", description = "Bearer token").optional()
val requireToken: Filter = before { p ->
val presented = p[authorization]?.removePrefix("Bearer ")
p[caller] = tokens[presented] ?: unauthorized("Present a bearer token")
}
val timing = Filter { params, next ->
val started = System.nanoTime()
next(params).thenApply { it.also { metrics.record(params.endpoint, System.nanoTime() - started) } }
}
api(routes, JacksonCodecs) {
filter(timing)
filter(requireToken)
}
// A handler reads what a filter stored by the same key:
getReport handledNow { (id, _) -> Report(id, "Q3", visibleTo = this[caller].subject) }
To filter only some endpoints, filteredBy(...) attaches filters to a list of bound endpoints, and onlyWhen narrows a filter by what the description says. Telemetry is a filter too: petshop adds filter(openTelemetry(telemetry)) from pelican-metrics-otel.
Streaming and server-sent events
NDJSON, server-sent events, a chunked JSON array and raw bytes are supported, with back-pressure from the socket to the source. The description is the same on both backends; core renders the wire format.
val streamOrders = endpoint(userId, limit) {
get("users" / userId / "orders" / "stream")
ndjson<Order>()
}
// pelican-jetty: a Sequence<T>, pulled one element per write
streamOrders streamedNow { (id, max) -> Store.orders(id, max, status = null).asSequence() }
// pelican-pekko: a Source<T, NotUsed>
streamOrders streamedNow { (id, max) -> Source.from(Store.orders(id, max, status = null)) }
val events = endpoint {
get("events")
tag("shop")
summary = "Arrivals, adoptions and returns, as the shop's events reach it"
// keepAlive so a proxy does not close a quiet stream.
sse<ShopEventDto>(eventName = "shop", id = { it.seq.toString() }, keepAlive = 15.seconds)
}
streamedOrFail streams with a declared failure decided before the first element. A test reads any stream with app.collect(...), and a generated client returns Streamed<T>.
Configuration: the api block
Pelican has no configuration file. Everything service-wide is set in the api { } block:
api(routes, JacksonCodecs) {
title = "Orders"
version = "1.0.0"
maxBodyBytes = 2 * 1024 * 1024 // default 8 MiB; over it is a 413
covers = allOrderEndpoints // every declared endpoint must be bound
cors = cors("https://app.example.com", "http://localhost:5173")
security = listOf(oauth.requires("orders:read"))
exposeInternalErrors = false // true only for a local run or a fixture
onError { reference, endpoint, throwable -> System.err.println("[$reference] $endpoint: $throwable") }
filter(timing)
}
Two mistakes are caught when the Api is constructed: two endpoints bound to the same method and path, and (with covers) a declared endpoint that was never bound. Ports and hosts come from your own config and are passed to start, as petshop does with startWithDocs(port = config.port, host = config.host, docs = petshopDocs).
Where a handler runs
A synchronous handler (handledNow, handledOrFail, handledWith, handledOneOf) runs off the server's own threads and may block. Handlers.default is a virtual thread per request on JDK 24 and newer; on JDK 21 to 23 (only possible on main) it is a pool of 200 platform threads. You can choose:
api.start(port = 8080) // Handlers.default
api.start(port = 8080, handlers = Handlers.onVirtualThreads) // virtual on any JDK, pinning included
api.start(port = 8080, handlers = Handlers.on(boundedPool)) // a service's own executor
api.start(port = 8080, handlers = Handlers.onDispatcher) // the server's thread that matched it
Testing
pelican-test reads the descriptions a third way, as a client. Tests call endpoint values, so they contain no paths and no JSON: rename an input or change its type and the tests stop compiling instead of starting to 404. Responses are decoded with the same codecs that encoded them, so a green test also proves the codec round-trips.
In-process tests
import io.github.matthewjones372.pelican.In3
import io.github.matthewjones372.pelican.In4
import io.github.matthewjones372.pelican.jetty.Handlers
import io.github.matthewjones372.pelican.test.jetty.inMemory
import io.github.matthewjones372.pelican.test.shouldBuild
import io.kotest.matchers.shouldBe
val app = ordersApi().inMemory(Handlers.onVirtualThreads)
// 1. behaviour: typed in, typed out
val user: User = app.call(getUser, 1L)
val orders: List<Order> = app.collect(streamOrders, In4(1L, 7, null, null))
app.response(placeOrder, In3(1L, "wrong", CreateOrder("anvil", 1))).status shouldBe 401
// 2. the contract: the URL a caller holds, pinned as a literal
app.request(getUser, 1L) shouldBuild "GET /users/1"
call returns the value, collect flattens a stream, response gives the raw status and body, and request builds without sending. In memory is not a shortcut: on Jetty each request is parsed by Jetty's own LocalConnector; on Pekko the route is sealed as a bound server seals it. What it does not cover is below the route (chunk framing, connections, TLS).
Asserting on an outcome
val bookmark = app.outcome(getBookmark, 1L).shouldBeOk() // returns Bookmark
app.outcome(getBookmark, 9_999L) shouldBeError NoSuchBookmark(9_999L, "No bookmark 9999")
// Two failures with the same payload type: assert on the declaration
app.outcome(placeOrder, In3(1L, "wrong", CreateOrder("anvil"))) shouldBeFailure badApiKey
The assertions throw plain AssertionError, so the module puts no matcher library on your classpath. To call as a signed-in user, bind TestCallers and use signedInAs:
val client = api(routes, codecs = JacksonCodecs) {
authenticate(bearer, TestCallers.of("ada" to setOf("customer")))
}.inMemory(Handlers.onVirtualThreads)
client.signedInAs("ada").call(getAccount, "acc-1")
Client-based tests over a socket
Assertions written against ApiClient run in memory or over a real connection. Petshop's end-to-end test starts the whole service and calls it with apiClient(baseUrl, codecs):
// The same suite, over a socket (reference)
class OverHttpContractTest : ClientContractTest() {
override fun open() = ordersApi().start(port = 0).client() // real connection
}
// petshop/app EndToEndSpec: the whole service, by endpoint value
apiClient(server.baseUrl, ShopCodecs).use { shop ->
shop.outcome(adoptPet, 1L).shouldBeOk().adopted shouldBe true
shop.outcome(adoptPet, 3L).shouldBeError() shouldBe NotChipped(3)
}
ApiClient(HttpClientTransport(url), codecs) points the same suite at a deployed service. ApiClient is AutoCloseable; closing an in-memory client stops what it started.
Golden tests
A typed test cannot see a contract change: add a required field and the test client sends it. pelican-test-golden records each endpoint's slice of the OpenAPI document and fails only on changes that break existing callers (a deleted endpoint, a new required field, a removed status). A change that costs callers nothing rewrites the file and passes.
import io.github.matthewjones372.pelican.test.golden.Golden
import org.junit.jupiter.api.Test
class GoldenContractTest {
private val golden = Golden()
@Test
fun `every endpoint publishes what it published`() {
golden.operations(ordersSpec()) // or api.spec(), as petshop does
}
}
placeOrder.json: 1 change breaks callers.
POST /users/{userId}/orders
✖ `currency` in the request body (application/json) is new and required
every caller that is not sending it is refused
PELICAN_GOLDEN_UPDATE=true ./gradlew test
Stubbing somebody else's service
pelican-test-wiremock stubs WireMock with endpoint values, so a contract change moves the stubs or stops them compiling. Petshop stubs its chip registry this way:
@JvmField
@RegisterExtension
val registry = PelicanWireMockExtension(KotlinxCodecs).apply {
stub(lookupChip) { petId -> ok(ChipRecord("98100000000000$petId", keeper = "Petshop")) }
stub(recordKeeper) { (number, keeper) -> ok(ChipRecord(number, keeper.keeper)) }
}
// one answer for one input, as a declared failure
registry.stub(lookupChip, 3L) answers noSuchChip(Problem("never chipped"))
Recipes
From the cookbook and petshop. Names such as User, Order, Store, apiKey and newOrder are the example's own.
Give ids their own type
Two inputs of the same type can be swapped without the compiler noticing. A value class over the codec fixes that and still documents as integer/int64.
import io.github.matthewjones372.pelican.LongCodec
import io.github.matthewjones372.pelican.map
import io.github.matthewjones372.pelican.pathParam
@JvmInline value class UserId(val value: Long)
val userId = pathParam("userId", LongCodec.map(::UserId, UserId::value))
Two failures with the same status
A status names one response unless its failures are tagged. The tag is written into the body as kind, and a client reads the failure back by it. Then map a sealed domain error to the declarations with an exhaustive when.
val petTaken = errorJson<ProblemDto.AlreadyAdopted>(409, "That pet has already been adopted").tagged("already_adopted")
val petBeingAdopted = errorJson<ProblemDto.BeingAdopted>(409, "That pet is being adopted; it may come back to the shelf")
.tagged("being_adopted")
val adoptionPending = json<PetDto>(202, description = "Recorded; the chip registry has yet to confirm it")
val adoptPet = endpoint(petId) {
post("pets" / petId / "adoption")
(json<PetDto>() or adoptionPending).orFail(
petMissing,
petTaken,
petBeingAdopted,
petNotChipped,
registryDown,
notRecorded,
)
}
adoptPet handledOrFail { id ->
shop.adopt(PetId(id), by = "the internet").fold(
{ failure ->
when (val problem = failure.toDto()) {
is ProblemDto.NoSuchPet -> petMissing(problem)
is ProblemDto.AlreadyAdopted -> petTaken(problem)
is ProblemDto.BeingAdopted -> petBeingAdopted(problem)
is ProblemDto.NotChipped -> petNotChipped(problem)
is ProblemDto.RegistryDown -> registryDown(problem)
is ProblemDto.NotRecorded -> notRecorded(problem)
}
},
{ pet -> if (pet.standing is Adopting) adoptionPending(pet.toDto()) else ok(pet.toDto()) },
)
}
A 201 or a 202, with a Location header on one
val orderAt = responseHeader<String>("Location", description = "Where the placed order lives")
val orderPlaced = json<Order>(status = 201, orderAt)
val orderQueued = json<Queued>(status = 202)
val submitOrder = endpoint(userId, apiKey, newOrder) {
post("users" / userId / "orders" / "submit")
orderPlaced or orderQueued orFail badApiKey
}
submitOrder handledOneOf { (id, key, req) ->
when {
key != "let-me-in" -> badApiKey(ApiError(401, "Bad API key"))
req.quantity > Store.BURST_LIMIT ->
orderQueued(Queued("ticket-$id-${req.item}", position = req.quantity))
else -> {
val order = Store.create(id, req)
orderPlaced(order, orderAt of "/users/$id/orders/${order.id}")
}
}
}
Response headers on every success
val location = responseHeader<String>("Location", "Where the new order lives")
val rateLeft = responseHeader<Int>("X-RateLimit-Remaining")
val retryAfter = responseHeader<Long>("Retry-After").optional()
val placeOrder = endpoint(newOrder) {
post("orders")
emits(location, rateLeft)
errorResponse(429, "Slow down", retryAfter)
json<Order>(status = 201)
}
placeOrder handledNow { req ->
val order = Store.create(1, req)
setHeader(location, "/orders/${order.id}")
setHeader(rateLeft, quota.remaining)
order
}
A form body
data class SignIn(val user: String, val remember: Boolean, val visits: Int)
data class Session(val user: String, val remember: Boolean, val visits: Int)
val credentials = formBody<SignIn>(description = "The sign-in form")
val signIn = endpoint(credentials) {
post("sign-in")
json<Session>()
}
signIn handledNow { form -> Session(form.user, form.remember, form.visits) }
// In a test, sent as a caller would send it:
app.call(signIn, SignIn("ada", remember = true, visits = 3))
formBody<T>() or jsonBody<T>() accepts either encoding. Form bodies carry scalars and arrays of scalars only; a nested object is refused when the endpoint is bound.
A file upload, streamed
data class Imported(val caption: String, val filename: String?, val lines: Int)
val caption = textPart("caption", StringCodec.nonEmpty(), description = "What to call it")
val upload = filePart("file", contentType = "text/csv", description = "One order per line")
val importOrders = endpoint(caption, upload) {
post("orders" / "import")
json<Imported>(status = 201)
}
importOrders handledNow { (caption, file) -> // String, UploadedFile
Imported(caption, file.filename, file.stream().bufferedReader().useLines { it.count() })
}
file.stream() reads the part straight off the request; consume it before the handler returns. bufferedFile(...) is the in-memory alternative for a small part, with a size limit.
Health probes
import io.github.matthewjones372.pelican.health.diskSpace
import io.github.matthewjones372.pelican.health.health
import io.github.matthewjones372.pelican.health.jdbc
import io.github.matthewjones372.pelican.health.noDeadlockedThreads
val health = health {
live("threads") { noDeadlockedThreads() }
ready("orders-db") { jdbc(dataSource) }
ready("disk", critical = false) { diskSpace("/var/data", minFreeBytes = 1L shl 30) }
}
api(endpoints = health.endpoints + routes, codecs = JacksonCodecs)
/health/live and /health/ready answer in application/health+json: 200 for pass or warn, 503 for fail.
CORS for a browser front end
CORS is enforced, not just described. What an origin may do is read off the endpoints: the allowed methods and headers come from what each path declares.
api(routes, JacksonCodecs) { cors = cors("https://app.example.com", "http://localhost:5173") }
cors(
"https://app.example.com",
additionalAllowedHeaders = listOf("X-Tenant"), // added to the derived set, never instead of it
exposedHeaders = listOf("X-Request-Id"),
allowCredentials = true,
maxAgeSeconds = 600,
)
From Arrow's Either
pelican-arrow turns Either into Outcome and back.
getUser handledOrFail { id -> service.find(id).toOutcome() } // Right → ok, Left → the single declared failure
placeOrder handledOrFail { req -> desk.place(req).toOutcome(badOrder) } // several failures: name the declaration
val placed: Either<PlaceOrderFailure, Order> = client.placeOrder(order).toEither() // and back, on the calling side
With other Surefold libraries
Petshop is the larger example: Pelican on Jetty with kotlinx, Lark for the application graph and streams, Kimney for DTO mapping and Proofload for load tests. What follows is what its sources show.
Lark: streams on virtual threads
Petshop binds a Forks() stream backend, whose streams run on virtual threads, and its /events SSE endpoint hands a Lark Stream to pelican-jetty by converting it to the Sequence that streamedNow takes:
import io.github.matthewjones372.lark.stream.Forks
import io.github.matthewjones372.lark.stream.StreamBackend
import io.github.matthewjones372.lark.stream.toSequence
import io.github.matthewjones372.pelican.jetty.streamedNow
/** Lark's streams run on virtual threads, and Jetty's client calls the registry. */
private val jetty: Module =
single<StreamBackend> { Forks() } +
closingOf { -> JettyHttpTransport() }
// in petshopApi(streams: StreamBackend, ...)
events streamedNow { watch().toSequence(streams) }
The server itself is a node in Lark's graph, released with the rest when the application stops:
single { api: Api, config: Settings ->
install({ api.startWithDocs(port = config.port, host = config.host, docs = petshopDocs) }) { server, _ ->
server.stop()
}
}
For a streamed upload, pelican-streams-jetty reads it as a Lark Stream and turns the run's end into the declared Outcome. It ships neither Lark nor Pekko, so you name lark-stream and a backend:
import io.github.matthewjones372.lark.stream.Forks
import io.github.matthewjones372.lark.stream.mapOrFail
import io.github.matthewjones372.lark.stream.run
import io.github.matthewjones372.lark.stream.runFold
import io.github.matthewjones372.pelican.streams.jetty.toOutcome
import io.github.matthewjones372.pelican.streams.jetty.toStream
tally handledByOrFail { rows ->
rows.toStream()
.mapOrFail { it.customer ?: raise(NoCustomer(it.id)) }
.runFold(Tally(0)) { seen, _ -> Tally(seen.counted + 1) }
.run(Forks())
.toOutcome(badRows)
}
pelican-streams and pelican-streams-jetty stay on JDK 25 because lark-stream 0.9.0 is compiled for 25.
Kimney: DTOs derived at compile time
Petshop keeps its domain types apart from the API's DTOs, and Kimney writes each mapping at compile time. A field one side cannot fill, or a domain failure with no case of the same name in ProblemDto, is a compile error on the call that uses it. The handler then maps the DTO to a declared failure, as in the recipe above.
import io.github.matthewjones372.kimney.into
import io.github.matthewjones372.kimney.transformInto
/** kimney unwraps `PetId`, so `id` is a plain number in the JSON. */
@Serializable
@SerialName("PetDto")
data class PetDto(val id: Long, val name: String, val species: SpeciesDto, val adopted: Boolean)
fun Pet.toDto(): PetDto = into<_, PetDto>().withFieldComputed(PetDto::adopted) { it.standing !is OnShelf }.transform()
/** A domain failure added without a case of the same name here stops the build at this call. */
fun PetShopError.toDto(): ProblemDto = transformInto()
Petshop applies the plugin as id("io.github.matthewjones372.kimney") version "0.3.0". Each DTO is @SerialNamed by its simple name, which is what the OpenAPI document's components are called under kotlinx.
Proofload: load tests through the typed client
Petshop's load tests start the whole service in-process and drive it with Pelican's typed client inside a Proofload scenario, so the load test has no URLs either, and a declared failure arrives as its type:
apiClient(server.baseUrl, ShopCodecs).use { client ->
val rush = scenario("the rush") {
exec(adoptTaken) { step ->
when (val answer = client.outcome(adoptPet, 1L)) {
is Outcome.Ok -> step.fail("the tortoise was sold twice")
is Outcome.Err ->
if (answer.error !is ProblemDto.AlreadyAdopted) step.fail("not the declared failure")
}
}
}
proofload.run(rush.at(200.perSecond, over = 10.seconds))
}
For a generated Pelican client, Proofload's own proofload-pelican module provides proofloadTransport(recorder, templates, timeout), a ClientTransport on the JDK HTTP client that records each call; see the Proofload guide. Petshop also uses pelican-test-wiremock in its load tests to stub the registry.
Costs, limits and status
What it costs
The interpreter is measured with JMH against the same endpoint written directly against each backend. These are in-memory measurements on one machine and two small endpoints, not a statement about production capacity.
| Backend | Compared with | Result |
|---|---|---|
Pekko, handler on the dispatcher, GET | fastest hand-written route | +356ns, +784B per request |
Pekko, handler on the dispatcher, GET | idiomatic hand-written route | 375ns faster, 1.6KB less |
Pekko, JSON POST | hand-written extractStrictEntity | time within error, +2.0KB |
Jetty, GET and POST | hand-written Jetty core handler | time within error, +1.1 to 1.3KB |
| Pekko, 1 to 200 endpoints | none | routing flat at 645 to 713ns |
The largest cost measured is running a synchronous handler on a virtual thread, the default on JDK 24 and newer. On Pekko's in-memory GET it is about 50 times the dispatcher figure (about 77µs); on Jetty it adds about 9.5KB of allocation per request. A handler that never blocks can opt out with Handlers.onDispatcher. For scale, median latency in the socket run was 283µs to 1.4ms.
Between codecs, neither is faster overall: kotlinx was fastest on 100B and 1KB bodies, Jackson reading bytes was fastest on a 64KB body (115µs against 180µs).
Gotchas and limits
- Some checks are at start-up, not compile time: a path parameter missing from the path, a missing codec, an unverified scheme, an unbound endpoint in
covers. A test that builds theApicatches them. - Six typed inputs, then the untyped
Paramsbag. - The compiler does not catch two inputs of the same type read in the wrong order. Give them distinct types.
- OpenAPI 3.1.0 and 3.2.0 only. No 3.0, no Swagger 2.0, no
callbacks, noanyOfornot. - No encoder for non-JSON representations.
negotiated(...)declares several renderings, but the writer for anything other than JSON is yours. - CORS is one policy on the
Api, not per endpoint. - kotlinx with a custom
Json: one that sets something the schema cannot describe, such as a naming strategy, is refused at construction. - Null properties are left out on both codecs, and an absent property reads back as null.
- The test client is for tests only: blocking, no retries, and it cannot upload a binary file part.
- Pekko version mismatches show up as Pekko's own
IllegalStateExceptionnaming two versions. Align everyorg.apache.pekkoartifact withpekko-bom.
Status and versioning
From 1.0.0 the public API of the released modules is stable, and a breaking change waits for a major release. Two things in the repository are the contract, checked on every build: the .api dump beside each module (checked by apiCheck), and StillCompilesTest, which compiles pinned call sites of the reified inline DSL such as json<T>() and pathParam<T>().
Outside the promise: anything internal, the exact bytes of the emitted document (pin what your callers hold with golden files), and modules no release contains yet (the Jetty family, pelican-kotlinx, pelican-bom), including every -SNAPSHOT. Every break is recorded in the changelog. Pin exact versions, not ranges or +.
The roadmap's first item is releasing what is on main. After that come one request pipeline in core, clearer error messages, a decision on filters that change responses, OpenAPI 3.2.0 as the default (blocked on swagger-parser), and a compile-time cover check.