A fully functional API server for Lean 4. Think of it as Express or FastAPI, for Lean.

Status: v0.1.0, the first release. Usable, and experimental: APIs may change.

- Routing: path parameters (/items/{item_id}, or typed like{id:nat}), route groups,404vs405withAllow, automaticHEADandOPTIONS. Conflicting routes are rejected at compile time.

- Requests: path and query parameters, headers, cookies, JSON and form bodies, multipart uploads. Invalid input is a 422that names the field (body.price,query.limit), as in FastAPI.

- Responses: JSON, text, 201 CreatedwithLocation,204 No Content, ETags and conditional requests, cookies. Errors are RFC 9457application/problem+json, and an exception is a500that reveals nothing.

- Middleware: cors,accessLog,requestId,recover,timeout,rateLimit,securityHeaders,health,trustedProxy, or your own.

- Auth: bearer tokens, Basic auth, session cookies, HS256 JWT, and password hashing (scrypt). A missing or bad credential is a 401withWWW-Authenticate.

- Also: Server-Sent Events, OpenAPI 3.1 with a /docspage, graceful shutdown, and an in-process test client.

In your lakefile.toml:

[[require]]

name = "leanapi"

git = "https://github.com/theoriclabs/leanapi"

rev = "v0.1.0"You need:

- the toolchain leanprover/lean4:v4.33.0;

- OpenSSL 3 (brew install openssl@3, orapt install libssl-dev).

import LeanApi

open LeanApi

def hello : Text := ⟨"Hello World!"⟩

def app : Api Unit := api! [.get "/" hello]

def main : IO Unit := app.listen 3000$ lake exe hello

listening on http://127.0.0.1:3000

$ curl localhost:3000

Hello World!

import LeanApi

open LeanApi Lean

structure Item where

name : String

price : Float

isOffer : Option Bool

instance : FromBody Item := .record (Item.mk <$> .req "name" <*> .req "price" <*> .opt "is_offer")

def readRoot : Json := json% {"Hello": "World"}

def readItem (itemId : Path Int) (q : QueryParam "q" (Option String)) : Json :=

json% {"item_id": $(itemId.val), "q": $(q.val)}

def updateItem (itemId : Path Int) (item : Body Item) : Json :=

json% {"item_name": $(item.val.name), "item_id": $(itemId.val)}

def app : Api Unit := api! [

.get "/" readRoot,

.get "/items/{item_id}" readItem,

.put "/items/{item_id}" updateItem ]

def main : IO Unit := app.listen 8000Each handler's arguments say where its inputs come from: Path (the {item_id} segment), QueryParam "q", Body. They arrive already decoded, and the handler just returns its answer. Invalid input never reaches it:

$ lake exe items

$ curl 'localhost:8000/items/5?q=somequery'

{"item_id":5,"q":"somequery"}

$ curl -X PUT localhost:8000/items/5 -H 'content-type: application/json' -d '{"name":"Foo","price":42.5}'

{"item_id":5,"item_name":"Foo"}

$ curl -X PUT localhost:8000/items/5 -H 'content-type: application/json' -d '{"name":"Foo"}'

{"detail":"request validation failed","errors":[{"loc":"body.price","msg":"field required"}],"status":422,...}

$ curl localhost:8000/items/abc

{"detail":"request validation failed","errors":[{"loc":"path.item_id","msg":"expected an integer"}],"status":422,...}

A small app: CORS and a request log as middleware, a list with a ?limit= query parameter, a POST with a JSON body, and a route that needs a bearer token.

import LeanApi

open LeanApi Lean

structure User where

id : Nat

name : String

deriving ToJson

structure State where

users : Array User := #[⟨1, "Ada"⟩]

tokens : List (String × Nat) := [("secret", 1)]

-- `Authorization: Bearer secret` is Ada; anything else is a 401.

instance : Authenticates State User :=

.sessions fun s token => (s.tokens.lookup token).bind fun id => s.users.find? (·.id == id)

structure NewUser where

name : String

instance : FromBody NewUser := .record (NewUser.mk <$> .req "name")

def listUsers (limit : QueryParam "limit" (Option Nat)) : Reads State (List User) :=

fun s => s.users.toList.take (limit.val.getD 10)

def createUser (body : Body NewUser) : Writes State (Created User) := fun s =>

let user : User := ⟨s.users.size + 1, body.val.name⟩

({ s with users := s.users.push user }, { val := user, location := some s!"/users/{user.id}" })

def me (user : Auth User) (agent : Header "user-agent" (Option String)) : Json :=

json% {"id": $(user.val.id), "name": $(user.val.name), "agent": $(agent.val)}

def app : Api State := api! [

.get "/users" listUsers,

.post "/users" createUser,

.get "/users/me" me ]

def main : IO Unit :=

app.listenWith {} 3000 (stack := Stack.of [

cors { origins := .list ["http://localhost:5173"] },

accessLog ])The types do the work:

- Auth Usermakes- /users/merequire a valid token. Nothing else in the handler checks it.

- Reads Statecan only read the state and- Writes Statecan change it. A- GEThandler that writes doesn't compile.

- Created Useranswers- 201with the- Locationheader.

$ lake exe users

$ curl -i localhost:3000/users/me

HTTP/1.1 401 Unauthorized

www-authenticate: Bearer realm="api"

...

$ curl localhost:3000/users/me -H 'authorization: Bearer secret'

{"agent":"curl/8.7.1","id":1,"name":"Ada"}

$ curl -X POST localhost:3000/users -H 'content-type: application/json' -d '{}'

{"detail":"request validation failed","errors":[{"loc":"body.name","msg":"field required"}],"status":422,...}

$ curl 'localhost:3000/users?limit=x'

{"detail":"request validation failed","errors":[{"loc":"query.limit","msg":"expected a natural number"}],"status":422,...}

For a bigger app (sign-up and login, sessions in cookies, ETags, pagination, CORS with credentials), see examples/notes.

lake exe hello # Hello World, on :3000

lake exe items # path, query and body, on :8000

lake exe users # middleware, headers and auth, on :3000

./examples/starter/smoke.sh # starts each one and checks the answers above- Throughput is modest. LeanAPI runs on Lean's built-in Std.Httpserver: roughly 2,000–3,500 requests per second for a trivial route on a laptop.

- OpenAPI is written by hand. Routes carry a description, and LeanAPI serves the document and a /docspage. It isn't generated from handler types yet.

- No WebSockets here. They live in a separate library.

lake build

lake build leanapi_tests && ./.lake/build/bin/leanapi_tests

./scripts/check_readme.sh # every Lean example in this README compiles, and matches examples/starterBusiness Source License 1.1. Copyright (c) 2026 Theoriclabs, Inc.