Feature guide¶
Routing and handlers¶
@app.route("/items/<int:item_id>", methods=("GET", "POST"))
def item(item_id: int):
return {"item_id": item_id, "method": request.method}
get, post, put, patch, and delete are shortcuts for route. Static
paths take priority over dynamic paths. Registering the same path and method,
or the same endpoint twice, raises ValueError.
Handlers may be synchronous functions or async def. Path parameters are
passed to arguments with matching names, and an argument named request
receives the Request object. Using the from higuma import request proxy
usually keeps handler signatures clearer.
Available converters are string, int, float, uuid, and path.
Use View or MethodView to group view logic in a class. MethodView derives
its allowed methods from implemented get(), post(), and similar methods.
Typed inputs and dependencies¶
from typing import Annotated
from higuma import Body, Depends, Header, PathParam, QueryParam
def pagination(limit: Annotated[int, QueryParam(ge=1, le=100)] = 20):
return limit
@app.post("/items/<uuid:item_id>", response_model=ItemOutput, status_code=201)
def create_item(
item_id: Annotated[UUID, PathParam()],
payload: Annotated[ItemInput, Body()],
limit: Annotated[int, Depends(pagination)],
trace_id: Annotated[str | None, Header("x-trace-id")] = None,
):
return {"id": item_id, **payload.__dict__}
QueryParam, Header, Cookie, PathParam, Body, Form, and File declare
the source and constraints. Conversion recursively supports dataclasses,
TypedDicts, UUIDs, dates, datetimes, enums, literals, collections, and optional
values. Invalid inputs return structured JSON 422 responses.
Depends supports nested dependencies, sync and async callables, yield cleanup,
and per-request caching. Tests can replace dependencies through
app.dependency_overrides. response_model validates and converts runtime output
and filters undeclared fields.
Dataclass instances are revalidated, and fields declared only on subclasses are excluded from output. This applies recursively inside collections and TypedDicts without modifying the original instance. Annotated constraints on nested values, including dataclass fields, are checked for both input and output.
Request data¶
@app.post("/search")
def search():
tags = request.args.getlist("tag")
payload = request.get_json(silent=True)
return {
"tags": tags,
"payload": payload,
"client": request.remote_addr,
}
args/query: aMultiDictthat preserves repeated keysheaders: case-insensitiveHeadersjson/get_json(): JSON body; a wrong media type raisesUnsupportedMediaTypeform/files: URL-encoded or multipart form databody/get_data()/text: the raw bodycookies: a read-only mappingpath_params/view_args: converted path parametersclient_addr/remote_addr: the direct peer, changed only by trusted proxy middlewarestate: a request-local dictionary shared by middleware and handlerssession/user: populated by the corresponding middleware
raw_headers is a duplicate-preserving sequence of byte pairs for WSGI/ASGI
interoperability.
Responses¶
A handler may return str, bytes-like data, dict, list, None, a
Response, or (body, status[, headers]).
@app.post("/items")
def create_item():
response = app.jsonify({"id": 1, "status": "created"}, status=201)
response.set_cookie("notice", "created", httponly=True, samesite="Lax")
return response
Choose HTMLResponse, PlainTextResponse, JSONResponse, RedirectResponse,
FileResponse, or TemplateResponse when the representation should be
explicit. Status codes and header names/values are validated both at creation
and after middleware mutation. Do not set Content-Length manually; leave it
to the Rust core.
StreamingResponse sends a sync or async iterable without buffering the whole
body. The request context captured at creation remains available until iteration
finishes. EventSourceResponse and ServerSentEvent format SSE fields and set
no-cache and proxy-buffering headers. BackgroundTask / BackgroundTasks run
small sync or async work after the body is sent. Use an external job queue when
durability or retries matter.
Blueprints and URL generation¶
api = Blueprint("api", __name__, url_prefix="/api")
@api.get("/users/<int:user_id>")
def user(user_id: int):
return {"id": user_id}
app.register_blueprint(api)
profile_url = app.url_for("api.user", user_id=42)
Registration can override url_prefix and add an endpoint namespace with
name_prefix. Blueprints support both HTTP and WebSocket routes.
Hooks, middleware, and lifecycle¶
@app.before_request
def start_timer():
request.state["started"] = time.monotonic()
@app.after_request
def add_timing(response):
elapsed = time.monotonic() - request.state["started"]
response.headers["server-timing"] = f"app;dur={elapsed * 1000:.2f}"
return response
@app.errorhandler(404)
def not_found(error):
return {"error": error.detail}, 404
Before hooks run in registration order; after hooks run in reverse. A response
from a before hook skips the handler. The first middleware added is outermost
on the request path and returns last on the response path. Register middleware
that needs a session inside, and therefore after, SessionMiddleware.
Higuma(..., lifespan=context_manager) enters and exits a sync or async context
manager once per worker, merging a yielded mapping into app.state.
on_startup and on_shutdown run in the same lifecycle and accept sync or async
functions. A mapping from context_processor is merged into the context used by
app.render_template(). When sharing loop-bound async clients, the application
must preserve affinity with the event loop that created the client.
Templates, static files, and downloads¶
@app.context_processor
def globals_for_templates():
return {"site_name": "higuma example"}
@app.get("/")
def page():
return app.render_template("index.html", title="Home")
The MiniJinja environment and compiled templates are shared in Rust. Call
app.clear_template_cache() to force a reload during development.
When static_folder is enabled, files are served under static_url_path.
Static responses, send_file(), and full-file FileResponse values support
ETags, If-None-Match, and single byte ranges for GET/HEAD. Rust seeks and
streams the file or selected range without reading it into Python memory.
Multipart uploads¶
from pathlib import Path
from secrets import token_hex
@app.post("/upload")
def upload():
uploaded = request.files["file"]
destination = Path("uploads") / f"{token_hex(8)}-{uploaded.filename}"
uploaded.save(destination)
return {"name": destination.name, "size": uploaded.size}, 201
UploadFile.filename is already normalized with secure_filename(). The
application must still use unique names and enforce allowed extensions/media
types, storage quotas, and malware scanning. Higuma(max_content_length=...)
limits the entire request.
WebSockets¶
@app.websocket(
"/ws/<string:room>",
allowed_origins=("https://example.com",),
)
def chat(ws, room):
while True:
ws.send_json({"room": room, "message": ws.receive_json()})
Origins default to same-origin. Queues are bounded, message size follows
max_content_length, and authentication decorators run before the HTTP 101
upgrade. Handle WebSocketDisconnect when a peer disconnects.
WSGI and ASGI mounts¶
app.mount_wsgi("/legacy", flask_app, name="legacy")
app.mount_asgi("/service", asgi_app, name="service")
The mounted application receives the path without the prefix. These are HTTP mounts; ASGI WebSocket and lifespan scopes are not forwarded. Use a unique name to avoid endpoint collisions.
OpenAPI¶
from dataclasses import dataclass
@dataclass
class CreateUser:
name: str
@app.post("/users", response_model=CreateUser, status_code=201, tags=("users",))
def create_user(payload: Annotated[CreateUser, Body()]):
return payload
/openapi.json and /docs are enabled by default. Annotations for primitives,
containers, unions/optionals/literals, dataclasses, TypedDicts, enums, UUIDs,
and dates/datetimes are converted to OpenAPI 3.1 schemas.
Parameter markers such as Annotated[..., Body()] drive both runtime validation
and OpenAPI. response_model also validates runtime output. The legacy
request_body= option and a return annotation by itself remain schema metadata;
they do not enable runtime validation.
SQLite ORM¶
class User(Model):
id = Integer(primary_key=True, autoincrement=True)
email = String(nullable=False, unique=True, index=True)
db = Database("sqlite:///app.db")
db.create_all(User)
with db.session() as session:
session.add(User(email="bear@example.com"))
A successful session block commits; an exception rolls back. filter_by()
binds values as parameters, and order_by() accepts model field names only.
Use offset() and limit() for pagination. Unfiltered delete() is rejected;
use delete_all() only for an intentional full-table delete.
The built-in ORM is a small SQLite-only data mapper. Consider a specialized database library when you need relationships, migrations, async queries, or a connection pool.
Test client¶
client = app.test_client()
response = client.post(
"/upload",
data={"caption": "bear"},
files={"file": ("bear.txt", b"hello", "text/plain")},
)
assert response.status_code == 201
The client persists cookies between requests and supports JSON, forms,
multipart data, HEAD, OPTIONS, and redirect history. with app.test_client() as
client: also runs lifespan and startup/shutdown hooks. It does not use a real
socket, so verify WebSockets, wire-level framing, proxies, and the supervisor
against a real server.