Part of the Lex project — Library · Manifesto · All packages
One process, two listeners, named actors, effect-typed. Alice connects over WebSocket, joins a room, receives a broadcast sent via REST, and gets a DM — all in under 60 seconds.
bash examples/demo.sh # prereq: websocat (brew install websocat)A single ModelSchema drives request validation, database queries, DDL, TypeScript codegen, and Python codegen — with no duplication and no drift.
bash examples/one_schema.sh| Artifact | Python | Lex |
|---|---|---|
| Request validation | Pydantic model | order_schema |
| Response shaping | Pydantic model (or 2nd) | order_schema |
| Database model | SQLAlchemy ORM class | order_schema |
| OpenAPI spec | FastAPI auto-gen | order_schema |
| TypeScript client types | manual / tsc plugin | order_schema |
| Migration | Alembic revision file | diff(v1, v2) |
| Effect enforcement | none | [sql] in sig |
HTTP framework for the Lex language, built on lex-schema for request validation. Designed as a FastAPI-style toolkit for Lex: typed parameters, declarative routes, dependency injection, OpenAPI 3.1 export, Swagger UI, sub-routers, lifespan hooks, background tasks, and an effect-aware testing surface.
Requires lex-lang 0.9.0+ (native net.serve_fn closure handlers, the
Response record, net.serve_ws_fn). 0.9.1 is recommended for the lex test
runner, lex fmt, and Iter[T] lazy streaming.
| Module | Purpose |
|---|---|
src/ctx.lex |
Ctx — enriched request context (path params, query, headers, cookies) |
src/response.lex |
Response builders (json, text, html, created, not_found, problem, …) |
src/router.lex |
Route table + dispatcher; RouteMeta for tags / summary / status / response_model (#28); attach_meta, route_with_meta, with_response_model; route_stream + dispatch_outcome for streaming routes (#29) |
src/middleware.lex |
MwCors (with OPTIONS preflight), MwBodyLimit, MwRequestId, MwLogger, MwGzip, MwTrustedHost, MwCustom (user-defined hooks — #27) |
src/body.lex |
json_body, require_json_body, form_body, form_body_raw, raw_body |
src/multipart.lex |
multipart/form-data parsing — parse(c, limits), find_file, find_text, RFC 7578 (#25) |
src/openapi.lex |
Auto-generates OpenAPI 3.1 — tags, summaries, descriptions, operationIds, per-route success status |
src/ws.lex |
WebSocket server — serve(), path helpers, frame helpers |
src/testing.lex |
Pure test helpers: request builders + assert_* assertions |
src/test_fixtures.lex |
Sample validators for use in tests and examples |
src/web.lex |
Facade that groups all modules under one import |
| FastAPI parity (v0.2) | |
src/status.lex |
HTTP_* named status constants + is_success / is_error predicates |
src/params.lex |
Typed query / path / header extractors with constraints (FastAPI's Query/Path/Header) |
src/depends.lex |
inject1..inject4, bind, map, pure for dependency-injection composition |
src/sub_router.lex |
SubRouter — APIRouter equivalent with prefix + tags + per-route metadata |
src/lifespan.lex |
Startup / shutdown hook lists (FastAPI's lifespan) |
src/background.lex |
BackgroundTask + Reply for post-response work |
src/docs.lex |
Swagger UI / ReDoc HTML pages, auto-mountable at /docs and /redoc |
src/static_files.lex |
In-memory bundle (mount_map) and filesystem (mount_dir) static serving |
src/exceptions.lex |
Typed-error registry (FastAPI's exception_handler) |
src/serve.lex |
serve / serve_with / serve_quic — wrap net.serve_* with router dispatch; HTTP/1.1, HTTP/2, HTTP/3 entry points |
src/auth.lex |
JWT bearer (verify_bearer, issue) — wraps lex-crypto/jwt |
src/auth_basic.lex |
HTTP Basic — verify(c, check), passwords_equal, RFC 7617 WWW-Authenticate challenge (#26) |
src/auth_apikey.lex |
API key — verify_header / verify_query / verify_cookie (#26) |
src/auth_oauth2.lex |
OAuth2 — verify_oauth2_bearer, require_scopes, Password/AuthorizationCode/ClientCredentials flow schemes + OpenAPI emit (#26) |
The examples/ directory carries runnable apps that exercise the framework
end-to-end. Each shows a different slice of the surface — pick the one
closest to what you're building.
| File | What it builds | Modules exercised |
|---|---|---|
users_api.lex |
Smallest possible Users CRUD | router, body, middleware, openapi |
fastapi_style.lex |
Items API touching every new v0.2 module | every FastAPI-parity module |
with_lex_orm.lex |
Items API persisted via lex-orm (SQLite) | + lex-orm/connection, query, migrate |
url_shortener.lex |
POST /api/links + 302 redirects + click stats + Swagger UI | sub_router, params, exceptions, background, docs, lifespan |
jsonrpc_ws.lex |
JSON-RPC 2.0 over WebSocket on :9000, browser console on :8080 |
ws, router, lex-schema json_value |
webhook_receiver.lex |
Signed webhook ingestion with idempotency dedup + background processing | depends.bind, exceptions, background, params.header_str, RFC 7807 |
middleware_custom.lex |
Bearer-token gate + response-stamping via mw.custom |
middleware.custom (#27) |
auth_modes.lex |
HTTP Basic + API key (header / cookie) + JWT in one app | auth_basic, auth_apikey, auth (#26) |
upload.lex |
Multipart file upload — title text field + file upload, returns parsed metadata as JSON |
multipart.parse, multipart.find_file, multipart.find_text (#25) |
Run any of them with lex run --allow-effects io,net,time examples/<file> main
(some need additional effects — each file's header comment carries the exact
invocation).
import "../src/ctx" as ctx
import "../src/response" as resp
import "../src/router" as router
fn greet(c :: ctx.Ctx) -> resp.Response {
match ctx.path_param(c, "name") {
None => resp.bad_request("missing name"),
Some(name) => resp.json(str.concat("{\"hello\":\"", str.concat(name, "\"}"))),
}
}
fn app() -> router.Router {
router.new()
|> fn (r :: router.Router) -> router.Router {
router.route(r, "GET", "/greet/:name", greet)
}
}
fn handle(req :: ctx.RawRequest) -> [io, time] resp.Response {
router.dispatch(app(), req)
}
fn main() -> [net, io, time] Nil {
net.serve_fn(8080, handle)
}src/serve.lex exposes the lex-lang listener entry points under a single
namespace (the same way src/ws.lex groups WebSocket entry points). It's a
thin passthrough — callers still write the fn handle(req) { router.dispatch(app(), req) }
boilerplate the existing examples use:
import "../src/serve" as web_serve
fn handle(req :: ctx.RawRequest) -> [io, time] resp.Response {
router.dispatch(app(), req)
}
fn main() -> [net, io, time] Nil {
web_serve.serve(8080, handle)
}serve.lex's serve_with and serve_quic wrap
lex-lang#497 (net.serve_fn_with)
and lex-lang#496 (net.serve_quic_fn).
HTTP/2 over the same TCP listener (preface-detected, falls back to HTTP/1.1):
import "../src/serve" as web_serve
fn handle(req :: ctx.RawRequest) -> [io, time] resp.Response {
router.dispatch(app(), req)
}
fn main() -> [net, io, time] Nil {
let opts := { http2: true, inline_vm: false, host: "0.0.0.0" }
web_serve.serve_with(8080, handle, opts)
}HTTP/3 over QUIC (UDP). Mandatory TLS — pair with std.tls:
import "../src/serve" as web_serve
import "std.tls" as tls
fn handle(req :: ctx.RawRequest) -> [io, time] resp.Response {
router.dispatch(app(), req)
}
fn main() -> [net, io, time] Nil {
match tls.self_signed("localhost") {
Ok(t) => web_serve.serve_quic(4433, t, handle),
Err(_) => (),
}
}Production deployments use a CA-signed cert via tls.from_pem_files(cert, key)
and typically pair a TCP listener on :443 (HTTP/1.1 + 2) with a UDP listener
on :443 (HTTP/3) for client transport negotiation.
The QUIC path requires the lex binary to be built with cargo build --release --features quic — the default release omits it to keep the dep graph
slim. Without the feature, serve_quic returns a clear "compiled without
quic" error at startup.
lex-orm (a typed query builder +
migration runner on top of std.sql) shares lex-schema with lex-web — the
same ModelSchema value drives request validation and the persisted table
shape. The end-to-end pairing demo lives in examples/with_lex_orm.lex:
fn item_schema() -> s.ModelSchema {
{ title: "items", description: "",
fields: [
s.required_int("id", []),
s.required_str("name", [StrNonEmpty, StrMaxLen(64)]),
s.required_int("qty", [IntPositive]),
] }
}
fn list_items(c :: ctx.Ctx) -> [sql] resp.Response {
let plan := q.paginate(q.select(item_repo()), 1, 20)
match q.run_select(plan, db) {
Err(_) => resp.internal_error(),
Ok(items) => resp.json(serialize(items)),
}
}run_select carries the [sql] effect; the dispatcher propagates it through
to main. lex-orm v0.1+ runs against real std.sql (Postgres + SQLite) since
#4 landed.
The end-to-end demo lives in examples/fastapi_style.lex. The pieces:
fn items() -> sub_router.SubRouter {
sub_router.new("/items", ["items"])
|> fn (r :: sub_router.SubRouter) -> sub_router.SubRouter {
sub_router.route(r, "GET", "/", list_items)
}
|> fn (r :: sub_router.SubRouter) -> sub_router.SubRouter {
sub_router.with_summary(r, "List items, paginated")
}
|> fn (r :: sub_router.SubRouter) -> sub_router.SubRouter {
sub_router.handler_json(r, "POST", "/", v_item, create_item)
}
|> fn (r :: sub_router.SubRouter) -> sub_router.SubRouter {
sub_router.with_status(r, status.HTTP_201_CREATED())
}
}
fn app() -> router.Router {
sub_router.mount(router.new(), items())
}Bad input becomes a 422 problem+json response automatically — exactly what
FastAPI does:
match params.query_int(c, "page", Some(1), [IntPositive]) {
Err(r) => r,
Ok(page) => resp.json(...),
}params ships query_str, query_int, query_float, query_bool,
query_optional_str, query_optional_int, path_str, path_int,
path_float, header_str, bearer. Each takes a constraint list from
lex-schema (StrEmail, IntInRange(1, 100), …); failures collapse into a
single 422 with every failing constraint reported, not one-at-a-time.
Lex doesn't have decorators, so DI is a function-composition pattern. A
Dep[T] = (Ctx) -> Result[T, Response] and inject1..inject4 thread the
results into the handler:
fn current_user(c :: ctx.Ctx) -> Result[Str, resp.Response] {
match params.bearer(c) {
Err(r) => Err(r),
Ok(tok) => lookup(tok),
}
}
fn protected(c :: ctx.Ctx) -> resp.Response {
depends.inject1(c, current_user,
fn (cc :: ctx.Ctx, user :: Str) -> resp.Response {
resp.json(str.concat("{\"hello\":\"", str.concat(user, "\"}")))
})
}inject2 … inject4 compose multiple deps; bind, map, and pure build
chained deps inside a single function.
let ls := lifespan.new()
|> fn (l) { lifespan.on_startup(l, fn () { io.print("ready") }) }
let _ := lifespan.run_startup(ls)
let r := background.with_task(
resp.created_json("{}", "/users/42"),
background.task("welcome-email",
fn () -> [io, time] Nil { send_email() }))
# Mount Swagger UI + ReDoc
docs.mount(router, "/openapi.json", "My API")A registry of (error -> Option[Response]) matchers, scanned in registration
order. Domain errors stop being scattered match arms and become declarative
mappings:
type AppError = NotFound(Str) | Conflict(Str)
let reg := exceptions.new()
|> fn (r) { exceptions.add(r, fn (e) {
match e {
NotFound(what) => Some(resp.json_status(404, ...)),
_ => None,
} }) }
exceptions.handle(reg, NotFound("user_42")) # -> 404 Responseimport "../src/status" as status
resp.json_status(status.HTTP_201_CREATED(), body)
if status.is_error(r.status) { log_failure(r) }lex-web respects Lex's effect system. Effects propagate precisely:
| Function | Effects |
|---|---|
dispatch_pure |
none (for tests) |
dispatch |
[io, time] (logger + request-id middleware) |
middleware.run_post |
[io, time] |
lifespan.run_startup / run_shutdown |
[io, time] |
background.run_all |
[io, time] |
static_files.serve_from_dir |
[io] |
static_files.serve_from_map |
none |
| Handler closures | determined by what the handler body calls |
Use dispatch_pure in test suites — all tests stay effect-free and fast.
/users — exact static match
/users/:id — `:name` binds one non-empty segment
/files/*rest — `*name` binds all remaining segments (including slashes)
router.use_mw(r, mw.cors(["https://example.com"])) # also handles OPTIONS preflight (204)
router.use_mw(r, mw.body_limit(1_000_000))
router.use_mw(r, mw.request_id())
router.use_mw(r, mw.gzip(1_024))
router.use_mw(r, mw.trusted_host(["example.com", "api.example.com"]))
router.use_mw(r, mw.logger())Middlewares run in registration order. Pre-middleware (body_limit, trusted_host, cors-preflight) can short-circuit before the handler runs. Post-middleware (cors-headers, gzip, request_id, logger) always runs after.
Attach a lex-schema Validator to
a route and lex-web validates the JSON body automatically:
import "../src/test_fixtures" as tf
fn create_item(c :: ctx.Ctx) -> resp.Response {
match body.require_json_body(c, tf.item_validator()) {
Err(problem_resp) => problem_resp, # 422 RFC 7807 response
Ok(item_json) => resp.created_json("{\"id\":\"42\"}", "/items/42"),
}
}
router.handler_json(r, "POST", "/items", tf.item_validator(), create_item)Validators also drive openapi.export_openapi — routes with a Validator get a
requestBody schema automatically.
let doc := openapi.export_openapi_str(
app(), openapi.make_info("My API", "1.0.0"))OpenAPI output now includes:
- operationIds: derived from method + pattern (
getUsersIdforGET /users/:id) - tags: from
RouteMeta.tagsorsub_router.new("/...", [tags...]) - summary and description: from
RouteMeta(andsub_router.with_summaryetc.) - per-route success status: from
RouteMeta.status(e.g. 201 for POST routes)
import "../src/ws" as ws
fn on_message(conn :: WsConn, msg :: WsMessage) -> WsAction {
match msg {
WsText(frame) => ws.send(handle_frame(conn, frame)),
WsClose => WsNoOp,
_ => WsNoOp,
}
}
fn main() -> [net] Nil {
ws.serve(9000, "ocpp1.6", on_message)
}WsConn, WsMessage, and WsAction are global builtin types (no import
needed). See src/ws.lex for path helpers (last_segment, segment) and
frame helpers (text_frame, is_close).
import "../src/testing" as t
import "../src/router" as router
fn test_greet() -> Result[Unit, Str] {
let req := t.get("/greet/world")
let resp := router.dispatch_pure(app(), req)
t.all([
t.assert_status(resp, 200),
t.assert_body_contains(resp, "world"),
])
}
fn suite() -> List[Result[Unit, Str]] { [test_greet()] }
fn run_all() -> Int {
list.fold(suite(), 0, fn (n :: Int, r :: Result[Unit, Str]) -> Int {
match r { Ok(_) => n, Err(_) => n + 1 }
})
}Run with lex test (runs all tests/test_*.lex files automatically).
lex-web declares its lex-schema dependency in lex.toml:
[package]
name = "lex-web"
version = "0.2.0"
[dependencies]
lex-schema = { path = "../lex-schema" }Internal imports use the package name instead of relative paths:
import "lex-schema/validator" as vFor your own application, import lex-web modules relative to your file:
import "../src/ctx" as ctx # from tests/ or examples/
import "../src/router" as routerLex resolves imports by relative filesystem path. Import lex-web modules
relative to your file. For lex-schema types (Validator, Json, …) use
src/test_fixtures.lex or import via lex.toml package names — both approaches
are safe since lex-lang v0.9.0 (#358, path canonicalization).
Built under the principles of Trust Without Comprehension.