Skip to content
surefoldPublic

About

Type-safe HTTP for Kotlin on Jetty or Pekko HTTP. Describe an endpoint once, with its declared failures, and get the route, the OpenAPI document, typed clients and MCP tools from it.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1,007 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Pelican

Typed HTTP endpoints for Kotlin. Describe an endpoint once; serve it, document it, test it and generate its client from that one description.

Maven Central build coverage Kotlin 2.4.20 Apache 2.0

Getting started · Cookbook · Reference manual · Modules · What it costs · All docs


The idea, in one example

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.

Why one description

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.

A whole service, and what follows from it

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.

How it fits into a service

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 Pekko Route. toRoute(system) concatenates with your existing routes, and start(system, port = 8080) serves on an actor system the service already runs.
  • Jetty 12 (pelican-jetty): the descriptions become a Jetty core Handler, with no Pekko or Scala on the classpath. toHandler() passes on any request it does not answer, so it sits in a Handler.Sequence beside 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.

Compared with writing the handlers by hand

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.

Getting started

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

It serves the endpoint on port 8080, with its Swagger UI at http://127.0.0.1:8080/api-docs.

Choose the modules you need

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-pekko or pelican-jetty);
  • one codec, which any endpoint with a JSON body needs (pelican-jackson or pelican-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.

A first endpoint

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 Gradle plugin

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" }

Jetty, kotlinx and the BOM

Beside Pekko and Jackson, Pelican has:

  • the Jetty 12 family: pelican-jetty, pelican-jetty-docs, pelican-jetty-mcp, pelican-test-jetty, pelican-streams-jetty and pelican-client-jetty;
  • pelican-kotlinx, a codec for @Serializable payloads;
  • 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.

What else a description can say

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, URI and enums. Refinements such as between and matching are enforced and published together. See Refined inputs.
  • Several responses. More than one declared failure, more than one success (a 201 beside a 202), and one response rendered in several media types chosen by Accept. See Declared failures and More than one successful response. pelican-arrow converts Arrow's Either to and from Outcome.
  • 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 webhooks with a sender in the generated client. See Response headers and Webhooks.
  • Who is calling. Security schemes in the document, typed callers, and pelican-oidc for 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-wiremock for 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.

Performance

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.

Stability

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.

Running the examples

./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, likewise

Every example takes a port with --args=8081.

Further reading

About

Type-safe HTTP for Kotlin on Jetty or Pekko HTTP. Describe an endpoint once, with its declared failures, and get the route, the OpenAPI document, typed clients and MCP tools from it.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages