Skip to content

How to add custom HTTP routes

Register handlers on an App and the server serves them next to its own API: a webhook that starts a run, a health check for your load balancer, a small JSON endpoint over your data. Handlers are FastAPI-shaped functions; routing and the HTTP server are in Rust.

Register a route

from dataclasses import dataclass
from cereyan import App, HTTPError, Request, Response
import cereyan

app = App("intake")

@dataclass
class Order:
    order_id: int
    amount: float

@app.get("/api/ext/ping")
def ping() -> dict:
    return {"ok": True}

@app.get("/api/ext/orders/{order_id}")
def order(order_id: int, verbose: bool = False) -> dict:
    if order_id < 1:
        raise HTTPError(404, "no such order")
    return {"order_id": order_id, "verbose": verbose}

@app.post("/api/ext/orders")
def receive(order: Order) -> tuple[dict, int]:
    run = cereyan.client.run("ingest_order", order_id=order.order_id, amount=order.amount)
    return {"run_id": run["id"]}, 201

@app.get("/api/ext/raw")
def raw(request: Request) -> Response:
    return Response(request.headers.get("user-agent", ""), media_type="text/plain")

assert [r.path for r in app.routes] == ["/api/ext/ping", "/api/ext/orders/{order_id}", "/api/ext/orders", "/api/ext/raw"]

@app.get, @app.post, @app.put, @app.patch, @app.delete, and @app.route(method, path) register a handler. The routes are served when the module is served with cereyan serve or app.serve().

How arguments bind

Parameter Bound from
Name in the path template ({order_id}) The path segment, coerced through the type hint
Other scalar parameter The query string; list[T] collects repeated keys; missing ones use the default or answer 422
Dataclass, TypedDict, or pydantic model The JSON body
Annotated Request The raw request: method, path, path_params, query, headers, body, json(), text()

Coercion follows the same rules as flow parameters; a value that does not coerce answers 422 with the reason.

What to return

Return Response
dict or list JSON, 200
(value, status) The value with that status
str text/plain
bytes application/octet-stream
Response(body, status, headers, media_type) As given
None JSON null

Raise HTTPError(status, message) for an error; an unhandled exception answers 500 with the traceback in the server log.

Call it

import json, urllib.request

with urllib.request.urlopen(served.url + "/api/ext/ping") as resp:
    assert json.loads(resp.read()) == {"ok": True}

body = json.dumps({"order_id": 7, "amount": 99.5}).encode()
req = urllib.request.Request(served.url + "/api/ext/orders", data=body, method="POST", headers={"content-type": "application/json"})
with urllib.request.urlopen(req) as resp:
    assert resp.status == 201
    run_id = json.loads(resp.read())["run_id"]
assert served.wait_run(run_id)["state"]["type"] == "Completed"

Async handlers

async def handlers are supported and run on one shared event loop, so a blocking call inside one blocks every other async handler. Keep them non-blocking, or use a sync handler, which runs on its own thread. An async handler that takes longer than 30 seconds answers 504.

from cereyan import App

app = App("async-intake")

@app.get("/api/ext/status")
async def status() -> dict:
    return {"ok": True}

Where routes appear

The Settings page lists every registered route with its source. Routes under /api/ are protected by the API token like the built-in API; routes elsewhere are open. Starting runs from a route goes through cereyan.client, so the run records created_by = client.

Related: Secure the server, the webhook route example.