Colleen is a lightweight, type-safe web framework for Kotlin and Java, built on Undertow. It is designed for explicit application code, composable middleware, automatic request parameter extraction, and synchronous handlers on Java 21+.
fun getTodo(id: Path<Int>, service: TodoService): Todo =
service.find(id.value) ?: throw NotFound("Todo not found")
fun createTodo(body: Json<CreateTodo>, service: TodoService): Result<Todo> =
Result.created(service.create(body.value.title))
app.get("/todos/{id}", ::getTodo)
app.post("/todos", ::createTodo)- Type-safe function handlers: declare
Path<Int>,Query<String?>,Json<CreateTodo>, or services directly in a handler signature. - Explicit by default: no classpath scanning, no hidden autowiring, no global magic.
- Composable middleware: Koa-like onion middleware with symmetric after-logic, even when downstream handlers fail.
- OpenAPI built in: function and controller handlers can produce useful OpenAPI metadata
automatically, with Swagger UI at
/docs. - Realtime support: WebSocket and SSE are first-class APIs, not external add-ons.
- Kotlin first, Java friendly: concise Kotlin APIs with explicit Java-compatible overloads.
Colleen is a good fit when you want framework help with routing, binding, middleware, documentation, testing, and realtime endpoints, but still want application structure to stay visible in code.
- Quick Start
- A Small Todo API
- Core Concepts
- API Reference
- WebSocket and SSE
- OpenAPI
- Testing
- Java Support
- Configuration
- Production Notes
- Examples
- Java 21 or newer
- Kotlin or Java
- Maven or Gradle
Maven
<dependency>
<groupId>io.github.cymoo</groupId>
<artifactId>colleen</artifactId>
<version>0.4.7</version>
</dependency>Gradle (Kotlin DSL)
implementation("io.github.cymoo:colleen:0.4.7")import io.github.cymoo.colleen.*
fun main() {
val app = Colleen()
app.get("/") { "hello world" }
app.listen(8000)
}Open http://localhost:8000.
This example is intentionally small, but it demonstrates the main Colleen workflow: function-style handlers, type-safe parameter extraction, service injection, structured results, HTTP errors, and OpenAPI.
import io.github.cymoo.colleen.*
data class CreateTodo(val title: String)
data class Todo(val id: Int, val title: String, val completed: Boolean = false)
class TodoService {
private val todos = linkedMapOf(
1 to Todo(1, "Try Colleen"),
2 to Todo(2, "Open /docs"),
)
private var nextId = 3
fun list(completed: Boolean?): List<Todo> =
todos.values.filter { completed == null || it.completed == completed }
fun find(id: Int): Todo? = todos[id]
fun create(title: String): Todo {
val todo = Todo(nextId++, title)
todos[todo.id] = todo
return todo
}
fun complete(id: Int): Todo? {
val todo = todos[id] ?: return null
val updated = todo.copy(completed = true)
todos[id] = updated
return updated
}
fun delete(id: Int): Boolean = todos.remove(id) != null
}
fun listTodos(completed: Query<Boolean?>, service: TodoService): List<Todo> =
service.list(completed.value)
fun getTodo(id: Path<Int>, service: TodoService): Todo =
service.find(id.value) ?: throw NotFound("Todo not found")
fun createTodo(body: Json<CreateTodo>, service: TodoService): Result<Todo> =
Result.created(service.create(body.value.title))
fun completeTodo(id: Path<Int>, service: TodoService): Todo =
service.complete(id.value) ?: throw NotFound("Todo not found")
fun deleteTodo(id: Path<Int>, service: TodoService) {
if (!service.delete(id.value)) throw NotFound("Todo not found")
}
fun main() {
val app = Colleen()
app.provide(TodoService())
app.openApi(
title = "Todo API",
version = "1.0.0",
description = "A small Colleen API"
)
app.get("/todos", ::listTodos)
app.get("/todos/{id}", ::getTodo)
app.post("/todos", ::createTodo)
app.post("/todos/{id}/complete", ::completeTodo)
app.delete("/todos/{id}", ::deleteTodo)
app.listen(8000)
}Try it:
curl http://localhost:8000/todos
curl http://localhost:8000/todos/1
curl -X POST http://localhost:8000/todos \
-H 'Content-Type: application/json' \
-d '{"title":"Ship docs"}'OpenAPI JSON is served at /openapi.json; Swagger UI is served at /docs.
Colleen supports three handler styles. Use the simplest style that fits the route.
| Style | Best for | Example |
|---|---|---|
| Lambda | tiny inline routes | app.get("/") { "ok" } |
| Function-style | most application handlers | app.get("/todos/{id}", ::getTodo) |
| Controller | larger grouped APIs | app.addController(TodoController(service)) |
Function-style handlers are the recommended default for non-trivial routes because they are plain functions, easy to test, and expose useful type information to parameter binding and OpenAPI.
app.get("/todos") { }
app.post("/todos") { }
app.put("/todos/{id}") { }
app.delete("/todos/{id}") { }
app.patch("/todos/{id}") { }
app.head("/todos/{id}") { }
app.options("/todos") { }
app.all("/health") { }Path parameters use {name}. Wildcards use {path...}.
app.get("/todos/{id}", ::getTodo)
app.get("/files/{path...}") { ctx -> ctx.pathParam("path") }
app.get("/images/{name}.{ext}") { ctx -> "${ctx.pathParam("name")}.${ctx.pathParam("ext")}" }In complex segments, a parameter captures up to the first occurrence of the next
static delimiter. For example, /files/{name}-{version}.txt matches
/files/foo-bar-1.txt as name = "foo" and version = "bar-1".
Route priority is deterministic: static segments, complex segments, parameter segments, then wildcard segments.
Groups add a common registration-time path prefix and can carry middleware for a set of related routes.
app.group("/api") {
use(ApiKeyMiddleware())
get("/todos", ::listTodos) // GET /api/todos
post("/todos", ::createTodo) // POST /api/todos
group("/admin") {
use(AdminOnly())
get("/stats") { statsService.snapshot() } // GET /api/admin/stats
}
}Route groups are for organizing route declarations. Prefix middleware is different: it is registered once and matched at request time for any path under the prefix.
@Controller("/todos")
class TodoController(private val service: TodoService) {
@Get
fun list(completed: Query<Boolean?>): List<Todo> =
service.list(completed.value)
@Get("/{id}")
fun get(id: Path<Int>): Todo =
service.find(id.value) ?: throw NotFound("Todo not found")
@Post
fun create(body: Json<CreateTodo>): Result<Todo> =
Result.created(service.create(body.value.title))
}
app.addController(TodoController(TodoService()))Supported HTTP annotations: @Get, @Post, @Put, @Delete, and @Patch.
Use @Param("name") when a logical parameter name must be overridden, especially in Java.
Middleware has the signature (Context, Next) -> Unit.
val logger = Middleware { ctx, next ->
val start = System.currentTimeMillis()
println("-> ${ctx.method} ${ctx.path}")
next() // downstream exceptions are captured in ctx.error
val status = if (ctx.error != null) {
(ctx.error?.cause as? HttpException)?.status ?: 500
} else {
ctx.response.status
}
println("<- $status in ${System.currentTimeMillis() - start}ms")
}
app.use(logger)Colleen uses an onion model:
middleware1 before
middleware2 before
handler
middleware2 after
middleware1 after
If the before part of a middleware ran, its after part runs even when downstream code
throws. next() does not throw immediately; the captured exception is available as
ctx.error and is rethrown after the chain unwinds unless it is marked handled.
To short-circuit, return without calling next():
app.use { ctx, next ->
if (ctx.path == "/health") {
ctx.text("ok")
return@use
}
next()
}| Scope | API | When it runs |
|---|---|---|
| Global | app.use(middleware) |
every HTTP request |
| Prefix | app.use("/api", middleware) |
paths under /api |
| Conditional | app.use({ ctx -> ... }, middleware) |
when the predicate returns true |
| Per-route | app.get("/todos").use(middleware).handle { ... } |
one method + path |
| Group-level | app.group("/api") { use(middleware) } |
routes registered under the group |
app.use(RequestLogger())
app.use("/api", ApiKeyMiddleware())
app.use({ ctx -> ctx.accepts("json") }, JsonOnlyMiddleware())
app.get("/todos/{id}")
.use(AuthMiddleware())
.handle(::getTodo)Use route groups to keep route declarations together. Use prefix middleware when the same middleware should apply to paths that may be registered in different places.
Services are explicit. Register them before routes that depend on them.
app.provide(TodoService()) // existing singleton
app.provide { TodoService() } // lazy singleton
app.provide(singleton = false) { TodoService() } // transientResolve services explicitly from Context:
app.get("/todos") { ctx ->
ctx.getService<TodoService>().list(completed = null)
}Or declare them as function/controller parameters:
fun listTodos(service: TodoService): List<Todo> =
service.list(completed = null)Qualifiers distinguish multiple services of the same type:
object Primary
object Replica
app.provide(qualifier = Primary) { primaryDataSource }
app.provide(qualifier = Replica) { replicaDataSource }
fun report(@Qualifier("Replica") ds: DataSource): DataSource = dsServices in sub-apps are resolved from the current app first, then parents.
Throw HTTP exceptions from handlers or middleware:
throw BadRequest("Invalid input")
throw Unauthorized("Authentication required")
throw Forbidden("Access denied")
throw NotFound("Todo not found")
throw Conflict("Already exists")
throw TooManyRequests("Rate limit exceeded")Register type-based error handlers:
app.onError<ValidationException> { e, ctx ->
ctx.status(422).json(mapOf("error" to "validation_failed", "fields" to e.errors))
}
app.onError<HttpException> { e, ctx ->
ctx.status(e.status).json(mapOf("code" to e.code, "message" to e.message))
}If no custom handler matches, Colleen returns JSON or HTML based on content negotiation. Server errors are logged automatically.
app.post("/todos") { ctx ->
val body = ctx.json<CreateTodo>() ?: throw BadRequest("Missing body")
expect {
field("title", body.title)
.required()
.notBlank()
.maxSize(100)
}
Result.created(ctx.getService<TodoService>().create(body.title))
}Validation rules are optional by default, .required() makes a value mandatory, and
all field errors are collected into one ValidationException.
Sub-applications let you split a larger service into isolated Colleen apps. They are useful for API versions, admin/internal tools, feature modules, or plugin-style composition.
val api = Colleen()
api.get("/todos", ::listTodos)
val app = Colleen()
app.mount("/api", api)Inside the sub-app, routes are written relative to the mount path:
| Value | Example for GET /api/todos |
|---|---|
ctx.path |
/todos |
ctx.fullPath |
/api/todos |
ctx.pattern |
/todos |
ctx.fullPattern |
/api/todos |
Each sub-app has its own routes, middleware, services, error handlers, and config. Some request context is shared through the parent chain.
| Boundary | Behavior |
|---|---|
| Routes and middleware | isolated per app |
| Services | current app first, then parent apps |
| State | child contexts can read parent state |
| Error handlers | sub-app handlers first; parent handlers can handle propagated errors |
| Config | each app has its own config |
| Events | execution events can bubble to parent apps |
val root = Colleen()
root.provide(Database())
val admin = Colleen()
admin.use(AdminOnly())
admin.get("/stats") { ctx ->
ctx.getService<Database>().stats()
}
root.mount("/admin", admin)By default, unhandled exceptions in a sub-app can propagate to the parent app. Disable that when a sub-app should own its error boundary:
admin.config {
propagateExceptions = false
}Restrictions: mount sub-apps before they are started, and mount each app instance only once.
Events are synchronous observation hooks. They are useful for metrics, tracing, logging, and framework extensions.
app.on<Event.ResponseSent> { event ->
println("${event.ctx.method} ${event.ctx.fullPath} " +
"${event.ctx.response.status} ${event.total.inWholeMilliseconds}ms")
}Common events:
| Category | Events |
|---|---|
| Server lifecycle | ServerStarting, ServerStarted, ServerStopping, ServerStopped |
| Request lifecycle | RequestReceived, ResponseReady, ResponseSent |
| Execution | MiddlewareExecuting, MiddlewareExecuted, HandlerExecuting, HandlerExecuted, SubAppExecuting, SubAppExecuted |
| Exceptions | ExceptionCaught, ExceptionHandled |
Use middleware and handlers for control flow; use events for observation and extensions.
Declare these in function-style handlers or controller methods.
| Type | Source | Required/optional behavior |
|---|---|---|
Path<T> |
route path segment | required; conversion failure returns 400 |
Query<T> |
query string | nullability/defaults control required behavior |
Form<T> |
form field or form DTO | mirrors Query<T> for form data |
Json<T> |
JSON request body | parses with configured JSON mapper |
Header |
HTTP header | always nullable |
Cookie |
request cookie | always nullable |
Text |
body as text | nullable when empty |
Stream |
raw body stream | nullable, one-time readable |
UploadedFile |
multipart file | nullable when missing |
Context |
request context | injected directly |
| any other type | service container | resolved as a service |
Examples:
fun search(
q: Query<String?>,
limit: Query<Int> = Query(20),
tags: Query<List<String>>,
): List<Todo> = emptyList()
fun upload(file: UploadedFile): Map<String, Any?> =
mapOf("filename" to file.value?.filename, "size" to file.value?.size)Rules for Query<T> and Form<T>:
| Declaration | Missing input |
|---|---|
Query<String> |
400 Bad Request |
Query<String?> |
null |
Query<Int> = Query(1) |
default value |
Query<List<T>> |
emptyList() |
Query<Map<String, String>> |
emptyMap() |
Query<Map<String, List<String>>> |
emptyMap() |
Custom extractors implement ExtractorFactory and can also describe themselves for OpenAPI.
Custom extractors move request parsing out of handlers and turn it into a reusable, type-safe parameter.
import io.github.cymoo.colleen.*
import io.github.cymoo.colleen.openapi.*
import java.lang.reflect.Parameter
class BearerToken(value: String?) : ParamExtractor<String?>(value) {
companion object : ExtractorFactory<BearerToken> {
override fun build(paramName: String, param: Parameter): (Context) -> BearerToken {
return { ctx ->
val token = ctx.header("Authorization")
?.removePrefix("Bearer ")
?.trim()
?.takeIf { it.isNotEmpty() }
BearerToken(token)
}
}
override fun describeOpenApi(paramName: String, param: Parameter) = OpenApiParamSpec(
parameters = listOf(
OpenApiParameter(
name = "Authorization",
location = "header",
schema = mapOf("type" to "string"),
description = "Bearer token. Format: `Bearer <token>`",
)
)
)
}
fun require(): String =
value ?: throw Unauthorized("Bearer token is required")
}
fun me(token: BearerToken, service: UserService): UserProfile =
service.profile(token.require())build performs extraction, and describeOpenApi lets the extractor appear in
generated API docs. Add missingMessage when the framework, rather than handler
code like require(), should report missing required values for your extractor.
Most handlers only need a small subset of Context.
| API | Purpose |
|---|---|
ctx.method, ctx.path, ctx.fullPath |
request method and path |
ctx.pattern, ctx.fullPattern |
matched route pattern after routing |
ctx.pathParam("id") |
path parameter |
ctx.query("q"), ctx.queries() |
query parameters |
ctx.queries<T>() |
bind query parameters to a DTO |
ctx.form("name"), ctx.forms<T>() |
form values |
ctx.header("Authorization") |
request header |
ctx.accepts("json") |
content negotiation |
ctx.acceptsLang("zh-CN") |
language negotiation |
ctx.text(), ctx.json<T>() |
body parsing |
ctx.file("avatar") |
uploaded file |
ctx.getService<T>() |
required service |
ctx.getServiceOrNull<T>() |
optional service |
ctx.setState(key, value) |
request state |
ctx.getState<T>(key) |
required state |
ctx.getStateOrNull<T>(key) |
optional state |
Response helpers:
| API | Response |
|---|---|
ctx.status(201) |
set status |
ctx.header("X-Trace", id) |
set header |
ctx.text("ok") |
text/plain |
ctx.html(html) |
text/html |
ctx.json(data) |
JSON |
ctx.json(data, stream = true) |
streaming JSON |
ctx.bytes(bytes, contentType) |
binary body |
ctx.stream(input, contentType) |
streaming body |
ctx.sendFile(path, baseDir = "...") |
file response with negotiation |
ctx.redirect("/new") |
redirect |
ctx.sse { conn -> ... } |
Server-Sent Events |
Direct body parsing APIs return null for absent or empty input and throw BadRequest
for malformed input.
If a handler returns a value and the response has not already been written, Colleen maps the value to an HTTP response.
| Return value | Response |
|---|---|
Unit / Java void |
204 No Content |
String |
text/plain |
ByteArray |
application/octet-stream |
InputStream |
streaming octet-stream |
Map<*, *>, List<*> |
JSON |
| any other object | JSON |
Int, Long, Status |
status code with empty body |
Result<T> |
status + headers + mapped body |
ResponseBody |
raw response body |
null |
error; use Unit or ctx.json(null) |
app.get("/todos") { listOf(Todo(1, "Try Colleen")) }
app.post("/todos") { Result.created(Todo(2, "Write docs")) }
app.delete("/todos/{id}") { Result.noContent() }
app.get("/status") { Status(204) }| Middleware | Purpose |
|---|---|
ServeStatic |
static files with security checks and caching |
BasicAuth |
HTTP Basic authentication |
Cors |
CORS and preflight handling |
RateLimiter |
token-bucket rate limiting |
RequestId |
request ID propagation |
RequestLogger |
simple access logging |
SecurityHeaders |
common HTTP security headers |
SignedCookie |
signed cookies with key rotation |
Heartbeat |
health-check endpoint |
NoCache |
disable client/proxy caching |
Sunset |
RFC 8594 API deprecation headers |
app.use(RequestId())
app.use(RequestLogger())
app.use(Cors.permissive())
app.use(ServeStatic(root = "./public", baseUrl = "/static"))app.ws("/chat/{room}") { conn ->
val room = conn.pathParam("room")
val name = conn.query("name") ?: "anonymous"
conn.onMessage { text ->
conn.send("[$room] $name: $text")
}
conn.onBinary { bytes ->
conn.send(bytes)
}
}WebSocket middleware runs during the handshake:
app.wsUse("/chat") { ctx, next ->
if (ctx.header("Authorization") == null) {
ctx.status(401).text("Unauthorized")
return@wsUse
}
next()
}WsConnection can access path params, query params, handshake headers, services, and
state captured during middleware.
Controller-style WebSocket routes are also supported:
@Controller("/notifications")
class NotificationController {
@Ws("/live")
fun live(conn: WsConnection) {
conn.onMessage { msg -> conn.send("ack") }
}
}app.get("/events") { ctx ->
ctx.sse { conn ->
conn.keepAlive(15)
conn.onClose { reason -> println("closed: $reason") }
conn.send("hello")
}
}Use SSE for one-way server push. Use WebSocket for bidirectional realtime communication.
Enable OpenAPI and Swagger UI:
app.openApi(
title = "Todo API",
version = "1.0.0",
description = "A small Colleen API"
)Defaults:
| Option | Default |
|---|---|
path |
/openapi.json |
uiPath |
/docs |
uiHtml |
Swagger UI |
Function-style and controller handlers provide richer metadata than lambda handlers because Colleen can inspect their signatures.
@Tags("todos")
@Summary("Get a todo")
@Description("Returns one todo by id.")
@ParamDesc(name = "id", description = "Todo id")
@ResponseDesc(404, "Todo not found")
fun getTodo(id: Path<Int>, service: TodoService): Todo =
service.find(id.value) ?: throw NotFound("Todo not found")Schema annotations enrich DTOs:
data class Todo(
@Schema(description = "Todo id", example = "1")
val id: Int,
@Schema(description = "Task title", example = "Ship docs")
val title: String,
@Schema(hidden = true)
val internalVersion: Int = 0,
)Common annotations:
| Annotation | Purpose |
|---|---|
@Summary |
operation summary |
@Description |
operation description |
@Tags |
Swagger/ReDoc grouping |
@ParamDesc |
parameter description and required override |
@ResponseDesc |
response description by status |
@Schema |
field-level schema metadata |
@Hidden |
exclude a handler or controller |
OpenAPI includes mounted sub-app routes. Use @Hidden for annotation-based exclusion
and filter for path/method-based exclusion.
TestClient runs requests in-process through the same routing, middleware, extraction,
validation, DI, and error-handling pipeline as production.
val app = Colleen()
app.provide(TodoService())
app.post("/todos", ::createTodo)
val client = TestClient(app)
val response = client.post("/todos")
.json(mapOf("title" to "Write tests"))
.send()
response.assertStatus(201)
val todo = response.json<Todo>()!!
check(todo.title == "Write tests")Use TestClient for handler tests, middleware/security tests, and lightweight integration
tests without binding a network port.
Colleen is written in Kotlin but exposes Java-friendly APIs.
The main differences in Java are explicit runtime types, explicit parameter names, and method-reference wrapping.
import io.github.cymoo.colleen.*;
import static io.github.cymoo.colleen.lambda.ch;
class App {
static Todo getTodo(@Param("id") Path<Integer> id, TodoService service) {
var todo = service.find(id.value);
if (todo == null) throw new NotFound("Todo not found");
return todo;
}
public static void main(String[] args) {
var app = new Colleen();
app.provide(TodoService.class, new TodoService());
app.get("/todos/{id}", ch(App::getTodo));
app.listen(8000);
}
}Rules to remember:
| Kotlin | Java |
|---|---|
ctx.json<Todo>() |
ctx.json(Todo.class) |
ctx.getService<TodoService>() |
ctx.getService(TodoService.class) |
app.onError<BadRequest> { ... } |
app.onError(BadRequest.class, (e, ctx) -> { ... }) |
| Kotlin function reference | Java method reference wrapped with ch(...) |
| parameter names preserved | use @Param("name") or -parameters |
For generic JSON in Java, use TypeRef:
List<Todo> todos = ctx.json(TypeRef.listOf(Todo.class));
Map<String, Todo> byId = ctx.json(TypeRef.mapOf(String.class, Todo.class));app.config {
server {
host = "127.0.0.1"
port = 8000
useVirtualThreads = true
maxThreads = Runtime.getRuntime().availableProcessors() * 8
maxConcurrentRequests = 0
maxRequestSize = 30 * 1024 * 1024
maxFileSize = 10 * 1024 * 1024
fileSizeThreshold = 256 * 1024
shutdownTimeout = 30_000
idleTimeout = 30_000
}
ws {
idleTimeoutMs = 300_000
maxMessageSizeBytes = 64 * 1024
pingIntervalMs = 30_000
pingTimeoutMs = 10_000
maxConnections = 0
}
json {
pretty = false
includeNulls = false
failOnUnknownProperties = true
failOnNullForPrimitives = true
failOnEmptyBeans = false
acceptSingleValueAsArray = false
writeDatesAsTimestamps = false
dateFormat = null
writeEnumsUsingToString = false
readEnumsUsingToString = false
}
propagateExceptions = true
}Replace the JSON mapper when needed:
app.config {
jsonMapper(MyCustomJsonMapper())
}- Virtual threads: enabled by default on Java 21+. They make synchronous handlers practical for IO-heavy workloads, but benchmark your own workload before production.
- Limits: set explicit
maxConcurrentRequests,maxRequestSize, andmaxFileSizefor public services. - Streaming JSON: use
ctx.json(data, stream = true)for large payloads; avoid it for small responses. - Global middleware: every global middleware runs for every request. Prefer prefix middleware when possible.
- Structured logging: prefer
Event.ResponseSentwhen logs need final status, duration, and bytes sent. - Static files: use
sendFile(..., baseDir = "...")orServeStaticwhen paths include user input.
See examples/benchmark-api/README.md for reproducible benchmark profiles.
Start here:
| Example | What it shows |
|---|---|
| hello-world | The smallest runnable Colleen app. |
| todo-app | JSON CRUD API with validation and CORS. |
| testing | In-process requests with TestClient. |
| openapi | OpenAPI annotations and Swagger UI. |
| Example | What it shows |
|---|---|
| extractor | Built-in path, query, form, JSON, header, cookie, and file extraction. |
| custom-extractor | Domain-specific extractors such as bearer tokens and pagination. |
| validator | Validation DSL and aggregated field errors. |
| middleware-showcase | Built-in middleware and common middleware patterns. |
| auth-app | Authentication with custom middleware and service injection. |
| error-handling | Global error handlers and sub-app error propagation. |
| sub-app | Modular applications with mounted sub-apps. |
| event-system | Lifecycle and request/response events. |
| Example | What it shows |
|---|---|
| websocket | WebSocket routes, middleware, and controller-style handlers. |
| sse | Server-Sent Events with keep-alive and close handling. |
| upload-app | Multipart upload and file download. |
| serve-static | Static file serving with cache and security controls. |
| render-html | Pebble templates for HTML rendering. |
| Example | What it shows |
|---|---|
| jdbc | JDBC integration with SQLite and batch execution. |
| jooq-sqlite | jOOQ code generation and type-safe SQLite queries. |
| redis | Redis-backed response caching middleware. |
| auto-reload | Development-time auto reload workflow. |
| benchmark-api | Reproducible benchmark profiles and load scenarios. |
MIT