Surefold

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.

The idea in one example (from the README)
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, except pelican-streams and pelican-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

ModuleAvailabilityWhat it gives you
pelican-pekko1.0.0Descriptions as Pekko HTTP routes (toRoute(system)) and start to bind them. Pekko is provided: you declare it.
pelican-jettymain onlyDescriptions as a Jetty 12 core Handler (toHandler()) and start. Ships jetty-server 12.1.13; no Pekko or Scala on the classpath.
pelican-jackson1.0.0JacksonCodecs: JSON bodies and the schemas the document derives.
pelican-kotlinxmain onlyKotlinxCodecs, for @Serializable payloads, on kotlinx-serialization-json 1.11.0.
pelican-pekko-docs / pelican-jetty-docs1.0.0 / main onlystartWithDocs: /openapi.json and a Swagger UI or Redoc page beside the endpoints. Brings pelican-openapi.
pelican-test-pekko / pelican-test-jetty1.0.0 / main onlyinMemory(): the typed test client through the real backend, no socket. Brings pelican-test.
pelican-pekko-mcp / pelican-jetty-mcp1.0.0 / main onlyMCP tools over Streamable HTTP on /mcp.
pelican-client-pekko / pelican-client-jetty1.0.0 / main onlyA transport for a generated client. No server.
pelican-test-golden, pelican-test-wiremock1.0.0Golden files of the published contract; WireMock stubbed with endpoint values.
pelican-health, pelican-metrics, pelican-metrics-otel, pelican-oidc, pelican-arrow1.0.0Probes, 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).

build.gradle.kts (1.0.0, from the README)
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.

settings.gradle.kts or build.gradle.kts
repositories {
    mavenCentral()
    maven("https://central.sonatype.com/repository/maven-snapshots/")
}
build.gradle.kts (snapshot, the stack petshop uses)
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:

settings.gradle.kts
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}
build.gradle.kts
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).

  1. 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")
    }
  2. Describe the endpoint and serve it

    The description (greet) imports nothing but core. The binder handledNow and startWithDocs are 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")
    }
  3. Run it

    Terminal
    ./gradlew run

    The server listens on loopback (127.0.0.1) unless you pass a host. To see the same thing without writing anything, clone the repository and run ./gradlew :example:runFirstEndpointOnPekko.

  4. 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 document

    The document is served at /openapi.json by default and the docs page at the docsPath you set (/docs if you leave it). Set either path to null to turn that page off.

  5. 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.

Imports that change on Jetty
import io.github.matthewjones372.pelican.jetty.docs.startWithDocs   // was ...pekko.docs.startWithDocs
import io.github.matthewjones372.pelican.jetty.handledNow           // was ...pekko.handledNow
FirstEndpointTest.kt, the Jetty half
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:

With kotlinx.serialization
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.

Endpoints.kt
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

Typed inputs (cookbook)
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.

A body, a header and a refined codec (ReadmeExample.kt)
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.

OutputHandler returns (Jetty)On the wire
json<T>(), json<T>(status = 201)Tone 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()Stringtext/plain
bytes(mediaType)Sequence<ByteArray>opaque bytes
empty()Unit204, 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.

Failures a caller was promised (cookbook)
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:

The compiler's own message (checked by DoesNotCompileTest)
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.

What an undeclared exception looks like
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.

BinderFor an endpoint whose output isThe lambda returns
handledNowTT
handledWithUnitUnit
handledOrFailOutcome<E, T>Outcome<E, T>
handledOneOfseveral successes (a or b)the same, under a name that reads right
streamedNow, streamedOrFaila streamSequence<T> (Jetty) or Source (Pekko)
bytesNow, bytesOrFailbytes()a stream of byte chunks
handledBy, handledByOrFail, handledByOneOf, streamedBy, streamedByOrFailas abovea 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.

Starting and stopping (reference)
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:

From descriptions, in code (reference)
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:

build.gradle.kts
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:

petshop/api/Docs.kt (abridged)
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:

petshop/registry/build.gradle.kts
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.

Calling a generated client (generated-client.md)
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:

petshop/app/Registry.kt (abridged)
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.

Tools as values, then served (mcp.md)
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) }
Choosing tools and supplying credentials
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.

schemas.md
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.

Refined inputs (reference)
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()
?limit=0 is a 400 the document already predicted
{"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.

Who is calling (reference)
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:

pelican-oidc
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.

example/filters/FilterExample.kt
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.

Streaming (cookbook)
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)) }
An SSE feed with an event name, an id and a keep-alive (petshop/api/Endpoints.kt)
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:

Service-wide settings (reference)
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:

Choosing the handler thread (reference)
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

Testing in three layers (cookbook, ClientContractTest.kt)
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

pelican-test assertions (reference)
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:

Calling as someone (reference)
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):

Over a bound port
// 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.

GoldenContractTest.kt
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
    }
}
A failure reads as the decision it is
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
Accepting everything a run produced
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:

petshop tests
@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.

cookbook
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.

petshop/api/Endpoints.kt and Handlers.kt (abridged)
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

cookbook
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

cookbook
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

cookbook
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

cookbook
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

cookbook (pelican-health)
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.

reference
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.

reference
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:

petshop/app/Wiring.kt and petshop/api/Handlers.kt (abridged)
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:

petshop/app/Wiring.kt
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:

reference
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.

petshop/api/Dtos.kt (abridged)
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:

petshop/loadtest PetshopLoadTest.kt (abridged)
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.

BackendCompared withResult
Pekko, handler on the dispatcher, GETfastest hand-written route+356ns, +784B per request
Pekko, handler on the dispatcher, GETidiomatic hand-written route375ns faster, 1.6KB less
Pekko, JSON POSThand-written extractStrictEntitytime within error, +2.0KB
Jetty, GET and POSThand-written Jetty core handlertime within error, +1.1 to 1.3KB
Pekko, 1 to 200 endpointsnonerouting 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 the Api catches them.
  • Six typed inputs, then the untyped Params bag.
  • 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, no anyOf or not.
  • 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 IllegalStateException naming two versions. Align every org.apache.pekko artifact with pekko-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.

Where next