Typed HTTP endpoints for Kotlin. Describe an endpoint once; serve it, document it, test it and generate its client from that one description.
Getting started · Cookbook · Reference manual · Modules · What it costs · All docs
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"))
}Cut from ReadmeExample.kt,
which is compiled on every build; imports and the rest of the service are there.
getBookmark is a value, and the server route, the OpenAPI document, a typed
test client and a generated Kotlin client are all read from it. The handler
receives an already-decoded Long, so a request for /bookmarks/abc is
answered with a 400 before the handler runs. Because the 404 is declared, the
handler must return Outcome<NoSuchBookmark, Bookmark>, and one that forgets
the failure does not compile.
Who this is for. Pelican fits a Kotlin service on JDK 21 or newer whose HTTP contract is consumed in several ways (a published OpenAPI document, clients, tests) and has drifted before, running on Pekko HTTP or free to choose its server. It is probably not for a small internal API with one caller in the same repository, a service on Spring, Ktor or another server Pelican has no module for, a contract negotiated as an OpenAPI file before any code, or a service that cannot take on a young library maintained mainly by one person. Choosing between Pelican and the alternatives has the full comparison and the rest of the cases where it is the wrong choice.
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 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 that an HTTP contract usually lives in four places. The handler decides what the server actually accepts and returns. An OpenAPI file, written by hand or scanned from annotations, says what it accepts and returns. Client models say it again, and tests say it a fourth time with URL strings and JSON literals. Each copy is maintained separately, so they drift: a parameter is renamed in the handler but not the document, a 404 is returned that no client knows about, a test keeps passing against a URL nobody serves.
In Pelican there is one copy. The route, the document and both clients are read off the same endpoint value, so they cannot describe different APIs, and the parts that the type system can check are checked by the compiler.
getBookmark is one endpoint of a small bookmarks service. Its other two show
more of what a description carries: refined query parameters with a default, a
header, a JSON body, a 201 and a declared 401. They are cut from the same
ReadmeExample.kt,
which is compiled on every build.
@JvmInline value class Slug(val value: String)
val slug = StringCodec
.matching(Regex("[a-z0-9-]{1,40}"), "a slug: lowercase letters, digits and dashes")
.map(::Slug, Slug::value)
val limit = queryParam("limit", IntCodec.between(1, 100), description = "How many to return").default(20)
val tag = queryParam("tag", slug, description = "Only bookmarks with this tag").optional()
val apiKey = headerParam("X-Api-Key", StringCodec.nonEmpty())
val newBookmark = jsonBody<CreateBookmark>(description = "The bookmark to save")
val badKey = errorJson<ApiError>(401, "Missing or bad API key")
val listBookmarks = endpoint(limit, tag) {
get("bookmarks")
summary = "List bookmarks, newest first"
jsonArray<Bookmark>() // chunked `[{...},{...}]`, flushed as produced
}
val createBookmark = endpoint(apiKey, newBookmark) {
post("bookmarks")
summary = "Save a bookmark"
json<Bookmark>(status = 201) orFail badKey
}
val routes = listOf(
listBookmarks streamedNow { (max, tag) -> // max: Int, tag: Slug?
Bookmarks.list(max, tag).asSequence()
},
createBookmark handledOrFail { (key, req) -> // key: String, req: CreateBookmark
if (key != "let-me-in") badKey(ApiError(401, "Bad API key"))
else ok(Bookmarks.add(req))
},
)The file binds these with the Jetty binders. On the Pekko backend the binders
have the same names, and streamedNow returns a Pekko Source where Jetty
takes a Sequence.
None of these values holds a handler or does any work. They are data, and four things are read from them.
The handler receives decoded, typed arguments. max is an Int, tag a
Slug? and req a CreateBookmark; no handler sees a string. A request for
/bookmarks/abc, a limit of 0, a tag with a space in it or a missing
X-Api-Key is answered with a 400 before any handler runs, and an absent
limit arrives as 20. IntCodec.between(1, 100) is both the check the
server makes and the minimum/maximum in the published schema, and the
slug's pattern is the schema's pattern, so the document cannot promise a
range or a format the server does not enforce.
Every response is part of the endpoint's type. orFail badKey makes
createBookmark's handler return Outcome<ApiError, Bookmark>, just as
orFail bookmarkMissing did for getBookmark, and the 201 is declared
rather than set by hand. A handler that returns a bare Bookmark, or forgets
that a declared failure exists, does not compile:
getBookmark handledOrFail { id -> Bookmarks.find(id)!! }
e: Return type mismatch: expected 'Outcome<NoSuchBookmark, Bookmark>', actual 'Bookmark'.
getUser handledNow { id -> "a string" }
e: Return type mismatch: expected 'User', actual 'String'.
These are the compiler's own messages. DoesNotCompileTest compiles each
mistake on every build and checks the message, so they cannot go out of date.
The consequence is that the failures a caller can receive are a closed,
declared set: the document lists them, the generated client gives the caller a
sealed type to match on, and an exception nobody declared becomes an opaque
500 with a reference in the log rather than a leaked stack trace.
The OpenAPI document is generated from the same value the server routes
on. /openapi.json (OpenAPI 3.1.0 by default, or 3.2.0) and the Swagger UI
page beside it are built from these values, not from a parallel file or from
annotations a scanner reads. If the route changes, the document changes in the
same commit, because there is nothing else to edit.
Tests call endpoint values, and URLs are pinned separately. The typed test client is built from the same description:
val bookmark: Bookmark = app.call(getBookmark, 1L)
app.outcome(getBookmark, 9_999L) shouldBeError NoSuchBookmark(9_999L, "No bookmark 9999")
app.collect(listBookmarks, In2(20, Slug("streams"))).single().title shouldBe "Pekko"
app.request(listBookmarks, In2(20, Slug("streams"))) shouldBuild "GET /bookmarks?limit=20&tag=streams"The first three lines contain no path and no JSON, so renaming bookmarkId or
changing Bookmark stops the test compiling instead of turning it into a 404
at run time. That same property means a behaviour test cannot notice when the
public URL moves, because the client moves with the server. The last line is
the guard for that: a URL pin, kept in a file of its own, compares against a
literal string and is the one test that should fail on a rename.
pelican-test-golden goes further and fails when a change to the published
document would break existing callers, such as a new required body field.
Some mistakes are beyond the type system: a declared path parameter missing
from the path, a codec missing for a body type, a security scope the scheme
never granted. Those are checked when the endpoint value or the Api is
constructed, so they fail at start-up rather than on the first request.
Pelican is a library, not an application framework. It does not own main,
it has no registry or annotation scanning, and nothing has to run at start-up
to discover endpoints. A set of routes is a List you build, split across
files or filter like any other value.
Descriptions are independent of the server. A backend module interprets them onto a web server you can mount beside the handlers you already have:
- Pekko HTTP (
pelican-pekko): the descriptions become a PekkoRoute.toRoute(system)concatenates with your existing routes, andstart(system, port = 8080)serves on an actor system the service already runs. - Jetty 12 (
pelican-jetty): the descriptions become a Jetty coreHandler, with no Pekko or Scala on the classpath.toHandler()passes on any request it does not answer, so it sits in aHandler.Sequencebeside handlers of your own. See Jetty, kotlinx and the BOM.
Because a Pelican route passes on what it does not describe, you can adopt it
one endpoint at a time in an existing service. A parity suite runs the same
descriptions against both backends and checks that they answer identically,
down to producing the same OpenAPI document; the few recorded differences are
listed under On Jetty. Changing backend changes
the binder imports and the type a streaming handler returns (a Pekko Source
or a Kotlin Sequence), not the descriptions.
pelican-core depends on the Kotlin standard library and jackson-core's
streaming parser and nothing else, and a test enforces that. Every JSON
library, server and HTTP client lives in a separate module, so a build takes
only what it uses.
A hand-written handler is the most direct way to answer a request, and for a small internal API it may be all you need. What it does not give you is a second, machine-checked statement of what it does. The document, the client and the tests each restate the contract, and keeping them in step is a matter of discipline and review.
With Pelican you write the description and the handler. The decoding, the 400s for bad input, the document, the test client and the generated client come from the description, and the compiler checks that the handler agrees with it. The same endpoints, by hand shows two endpoints written both ways, against Pekko HTTP with swagger annotations, so the difference can be read rather than taken on trust.
The costs are real:
- A DSL to learn. Inputs, outputs and failures are Pelican's types, and some HTTP behaviour you would write directly is instead declared.
- Two backends. If your service runs on Spring MVC, Ktor, Vert.x or anything other than Pekko HTTP or Jetty 12, there is no module for it today.
- An interpreter between the request and your code. Its cost is measured against hand-written routes on the same backend in What it costs. On the endpoints measured it is a few hundred nanoseconds on Pekko and below what the Jetty benchmark can resolve, but running synchronous handlers on a virtual thread, the default on JDK 24 and newer, costs considerably more on Pekko and can be turned off.
- A new library. 1.0.0 is the first stable release.
Choosing between Pelican and the alternatives compares it with http4k's contracts, Ktor's OpenAPI plugins, Spring, Micronaut, Quarkus, tapir and writing the document first, and ends with the cases where Pelican is the wrong choice. For evidence from outside this repository, petshop and surefold-bank are two larger services that use Pelican; petshop's README says where it helped and where it did not.
The current release is 2.0.0, on Maven Central under the group
dev.surefold. Up to 1.2.0 the group was io.github.matthewjones372; from
2.0.0 the packages are dev.surefold.pelican, the Gradle plugin is
dev.surefold.pelican, and the old coordinates relocate to the new ones. Code
compiled against 1.2.0 has to be recompiled. Its library modules are compiled for Java 21,
so a service using them needs JDK 21 or newer; pelican-streams and
pelican-streams-jetty need 25. Below JDK 24, synchronous handlers default to
a pool of platform threads rather than virtual threads; see
Where a handler runs. The steps below
use Pekko HTTP and Jackson; Jetty, kotlinx and the BOM
covers the other backend and codec.
To see it running before writing anything, clone the repository and run the greeting example below:
git clone https://github.com/surefold/pelican && cd pelican && ./gradlew :example:runFirstEndpointOnPekkoIt serves the endpoint on port 8080, with its Swagger UI at
http://127.0.0.1:8080/api-docs.
The common case on Pekko, and what the first endpoint below needs: the Pekko backend, the Jackson codec, the OpenAPI document with Swagger UI, and the typed test client.
// build.gradle.kts
val pelicanVersion = "2.0.0"
dependencies {
// The backend. Brings pelican-core; compiles against Pekko HTTP.
implementation("dev.surefold:pelican-pekko:$pelicanVersion")
// The codec: JSON bodies with Jackson, and the schemas the document derives.
implementation("dev.surefold:pelican-jackson:$pelicanVersion")
// /openapi.json and Swagger UI beside the endpoints. Brings pelican-openapi.
implementation("dev.surefold: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("dev.surefold:pelican-test-pekko:$pelicanVersion")
}Either Scala cross-build of Pekko works; pick _2.13 or _3 and use it on
every Pekko line.
That block is a choice, not a bundle. Pelican is a set of small modules, and a
build takes only the ones it uses. Every module brings pelican-core, which
holds the endpoint DSL and depends on nothing but the Kotlin standard library
and jackson-core. On top of it a service picks:
- one backend, which serves the descriptions (
pelican-pekkoorpelican-jetty); - one codec, which any endpoint with a JSON body needs
(
pelican-jacksonorpelican-kotlinx); - anything else only when it is wanted: the OpenAPI document and Swagger UI
(
pelican-pekko-docs), test clients, client transports, metrics, health, OIDC, MCP.
No module brings in a server you did not choose, and the backends do not bring
the OpenAPI generator or a codec. The smallest JSON service on Pekko drops
pelican-pekko-docs and pelican-test-pekko from the block above, and adding
a capability later is adding its module.
The one pelicanVersion value keeps the modules at the same version, or
pelican-bom does (below). Modules lists every
module, what each one brings with it, and the minimum set for other setups:
a generated client, MCP tools, metrics.
One endpoint, served with its documentation. This is the file the example
module compiles as
FirstEndpoint.kt
with the Pekko binders of
FirstEndpointOnPekko.kt.
import dev.surefold.pelican.api
import dev.surefold.pelican.div
import dev.surefold.pelican.endpoint
import dev.surefold.pelican.jackson.JacksonCodecs
import dev.surefold.pelican.openapi.docs
import dev.surefold.pelican.pathParam
import dev.surefold.pelican.pekko.docs.startWithDocs
import dev.surefold.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 and open http://127.0.0.1:8080/api-docs for a Swagger UI page built
from greet; the server listens on loopback unless given a host. The
clone-and-run command above serves the same thing.
And its test, with no socket:
import dev.surefold.pelican.test.pekko.inMemory
import dev.surefold.pelican.test.shouldBuild
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"On Pekko in the cookbook covers serving on an actor system you already run and the Scala 3 cross-build.
The optional plugin, dev.surefold.pelican, runs the generators
as Gradle tasks: OpenAPI documents, Kotlin clients, endpoints imported from
someone else's document, and the checks against committed copies. It is
published to Maven Central rather than the Gradle Plugin Portal, so the build
needs to be told once:
// settings.gradle.kts
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}// build.gradle.kts
plugins { id("dev.surefold.pelican") version "2.0.0" }Beside Pekko and Jackson, Pelican has:
- the Jetty 12 family:
pelican-jetty,pelican-jetty-docs,pelican-jetty-mcp,pelican-test-jetty,pelican-streams-jettyandpelican-client-jetty; pelican-kotlinx, a codec for@Serializablepayloads;pelican-bom, which manages the versions of Pelican's modules so a build writes the version once.
The cookbook and the other guides are written against these, so their
dependency blocks use the BOM and their imports name
dev.surefold.pelican.jetty. On Pekko, import the binders from
dev.surefold.pelican.pekko: pelican-jetty has every binder
pelican-pekko has, under the same name, so only streaming handlers change
(a Pekko Source where Jetty takes a Sequence).
The BOM manages versions and nothing else. Importing it adds no module to the classpath: you still declare each module you want, without a version, and leave out the ones you do not.
dependencies {
implementation(platform("dev.surefold:pelican-bom:2.0.0"))
implementation("dev.surefold:pelican-jetty")
implementation("dev.surefold:pelican-jackson")
testImplementation("dev.surefold:pelican-test-jetty")
}It covers Pelican's own modules only, not Jackson, Pekko or Jetty, and not the
Gradle plugin, whose version stays in plugins { }. See
Version alignment and the BOM.
A third backend, on Gannet (pelican-gannet, pelican-gannet-docs,
pelican-test-gannet), is on main and in no release; see
Gannet backend.
Each of these is covered in full in the reference manual, with complete recipes in the cookbook.
- Inputs and validation. Path, query, header and cookie parameters, form
bodies and multipart uploads, with codecs for primitives,
UUID,java.time,URIand enums. Refinements such asbetweenandmatchingare enforced and published together. See Refined inputs. - Several responses. More than one declared failure, more than one
success (a
201beside a202), and one response rendered in several media types chosen byAccept. See Declared failures and More than one successful response.pelican-arrowconverts Arrow'sEitherto and fromOutcome. - Streaming. NDJSON, server-sent events, a chunked JSON array and raw bytes, with back-pressure from the socket to the source. See Streaming.
- Response headers and webhooks. Headers declared per response and checked
when set; webhooks published under
webhookswith a sender in the generated client. See Response headers and Webhooks. - Who is calling. Security schemes in the document, typed callers, and
pelican-oidcfor OpenID Connect tokens and sign-in to pages. See Who is calling. - Running in production. Filters with typed attributes, Micrometer
(
pelican-metrics) and OpenTelemetry (pelican-metrics-otel), live and ready probes (pelican-health), body limits, CORS, and opaque 500s. See Filters and Errors. - Testing. The typed client in memory or over a socket, URL pins,
golden files, and
pelican-test-wiremockfor stubbing somebody else's API with its endpoint values. See Testing. - Clients and imports. A generated Kotlin client for callers who cannot depend on your descriptions, and importing an OpenAPI document somebody else wrote into descriptions you can serve, call or stub.
- Other consumers of the same descriptions. MCP tools a model can call, served through the same handlers, and a standalone JSON Schema for one type.
Modules lists every module and what it depends on.
The interpreter is measured with JMH against the same endpoint written directly against each backend, and the results, their error bars and their limits are in What it costs. In short: on Pekko, Pelican with its handler on the dispatcher is cheaper than an idiomatic hand-written route and under a hundred nanoseconds behind the fastest one; on Jetty the difference is inside the measurement's error, and allocation is within half a kilobyte either way. Running a synchronous handler on a virtual thread, the default on both backends on JDK 24 and newer, is the largest cost measured on Pekko and adds about 9.8KB per request on Jetty; a handler that never blocks can opt out. These are in-memory measurements on one machine, not a statement about production capacity.
From 1.0.0, the public API of the shipped modules is stable, and a breaking
change waits for a major release. The build enforces what that covers: the
.api dump beside each module is the binary contract, checked by apiCheck,
and StillCompilesTest compiles pinned call sites of the reified inline DSL
(json<T>(), pathParam<T>() and the rest), which a bytecode dump cannot see.
Not covered: anything internal, the exact bytes of the emitted document (pin
what your callers depend on with golden files), and
modules that are not in a release. The full statement is under
Stability, supported Kotlin, JDK and Pekko
versions are under Versions, and every change is
recorded in the changelog.
./gradlew build # every module: tests, detekt, spotless, coverage
./gradlew :example:runFirstEndpointOnPekko # the greeting above, on Pekko, on :8080
./gradlew :example:runFirstEndpoint # the same on Jetty
./gradlew :example:runReadmeExample # the bookmarks service
./gradlew :example:run # the fuller orders API: streaming, SSE, raw bodies
./gradlew :example:runShop # a bookshop: three domain failures, three declared responses
./gradlew :example:runSecured # a filter enforcing the security the descriptions declare
./gradlew :example:runTelemetry # meters and spans from one set of descriptions
./gradlew :example:runMcp # the orders API with its tools served on /mcp
./gradlew :example:generateOrdersDocument # the OpenAPI document, with no server started
./gradlew :example:generateOrdersClient # the generated Kotlin client, likewiseEvery example takes a port with --args=8081.
- The docs index: every guide, with reading orders for evaluating, using and going deeper.
- Getting started: installing 2.0.0 and a first endpoint.
- A whole service, in one file: models, inputs, endpoints, handlers, server and docs, compiled on every build.
- Cookbook: complete recipes, roughly in the order you need them.
- Reference manual: every feature, the reasoning behind it, and its limitations.
- What it costs: the benchmark report.
- Roadmap: what is not built yet.
- Contributing: building, testing and the compatibility gates.
- License: Apache 2.0. See LICENSE.