Files
moloapp/plans/05-request-routing.md
T
retoor 54068f1953 docs: split plan into numbered detailed files with research
Ten sequential spec files plus index covering runtime,
observability, security, data, routing, realtime, supervisor,
clients, CLI ops, and workflow, each with best practices,
structure, rules, guidelines, specs, and cross-references.
2026-10-23 14:22:29 +02:00

21 KiB

05 - Request/Response, Routing, Views, Templates

Scope: plan.md sections 2, 6 partial, 10 step 5 partial. Topics: app, routers, views, validation/forms, negotiation, templates, middleware, DI, errors.

1. Purpose and philosophy

This file owns everything between "bytes parsed by the runtime" and "value returned by user code": the Application composition root, FastAPI-style routers, aiohttp-style class-based views, the single validation framework (Pydantic-style models plus HTML/JSON forms), the response hierarchy with content negotiation, the Jinja-like template engine, the middleware onion, dependency injection without globals, and the typed error hierarchy.

Philosophy:

  • One request object, one response hierarchy. Handlers never touch sockets; they take a Request and return a Response. Streaming, SSE and realtime build on the same pair (see 06-realtime.md).
  • Declarative over imperative. Routes, validation models, negotiation and DI bindings are declared; the framework binds, validates, negotiates and injects before user code runs.
  • A single validation framework feeds path, query, header, cookie and body binding, forms, and OpenAPI schema generation (see 08-clients-openapi.md). No second ad-hoc parser anywhere.
  • Templates are logic-light text renderers with secure defaults: auto-escape on for HTML/XML, explicit safe opt-out only.
  • Errors are typed values, not strings. Every framework exception maps to exactly one HTTP status and renders as JSON for API clients or HTML for browsers, chosen by negotiation.

2. Best practices (from research)

  • Routers group related endpoints and mount them on the app with a shared prefix, tags and dependencies. FastAPI documents APIRouter plus app.include_router(router) as the scaling pattern for bigger applications, where the include call applies prefix, tags, dependencies and responses to every route in the router (Source: https://fastapi.tiangolo.com/tutorial/bigger-applications/, retrieved 2026-10-23).
  • Class-based views derive from a base View holding the request and implement one coroutine per HTTP method (get, post, ...). aiohttp documents class MyView(web.View) with async def get(self) returning a response, registered on the router like a function handler (Source: https://docs.aiohttp.org/en/stable/web_quickstart.html, retrieved 2026-10-23).
  • Validation guarantees the output, not the input: untrusted data is parsed and coerced into a model instance whose fields conform to declared types and constraints, else a single error listing all failures is raised. Pydantic v2 states models this way: fields are annotated attributes on a BaseModel subclass, and a ValidationError is raised when data cannot be parsed into an instance (Source: https://pydantic.dev/docs/validation/latest/concepts/models/, retrieved 2026-10-23).
  • Templates center on one reusable Environment holding loaders, filters, tests and globals; syntax is {{ ... }} for expressions, {% ... %} for statements, {# ... #} for comments, with inheritance (extends, blocks), includes, for/if, and autoescaping selectable per template name/extension (Source: https://jinja.palletsprojects.com/en/stable/templates/, retrieved 2026-10-23).
  • Content negotiation is server-driven: the client sends Accept with media ranges and optional q weights, the server picks one proposal and answers with Content-Type, or 406 Not Acceptable when nothing matches (Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Content_negotiation, retrieved 2026-10-23; triangulated by RFC 9110, Section 12, which obsoletes RFC 7231, Source: https://www.rfc-editor.org/rfc/rfc9110.html, retrieved 2026-10-23).
  • Middleware forms an onion: each layer sees the request before the endpoint and the response after it, and may short-circuit. Ordering rule derived from plan.md layering: exception mapping outermost, then observability, security, session/auth, routing-adjacent concerns innermost.

3. Structure

Directories (one class or concern per file, deep over flat):

src/molo/app/          Application, sub-app mounting, lifespan, plugin registry
src/molo/routing/      Router, Route, path compiler, matchers, url_for
src/molo/http_msg/     Request, Response hierarchy, Headers, Query, Cookies view
src/molo/views/        View base, method dispatch, handler adapters
src/molo/validate/     Model, Field, validators, ValidationError, coercions
src/molo/forms/        Form, JSON binding, urlencoded/multipart binding
src/molo/negotiate/    Accept parser, q-sorting, format selection
src/molo/templates/    Environment, Loader, Lexer, Parser, Renderer, Filters, Tests
src/molo/middleware/   Middleware base, Chain, built-in middlewares owned here
src/molo/di/           Container, Scope, Provider, LuckilyNoGlobals
src/molo/errors/       HttpError hierarchy, handler, renderers (JSON, HTML)

Namespaces and key classes:

  • molo::app::Application: owns routers, middleware chain, DI container, template env, error handler. include_router(prefix, router), mount(path, sub_app), on_startup/on_shutdown hooks.
  • molo::routing::Router: get/post/put/patch/delete/head/options(path, handler); nested include_router; Route record (methods, compiled path, handler, name, validation models, tags).
  • molo::http_msg::Request: method, path, query, headers, cookies, get<T>(key) for query/form, json<T>(), form<T>(), body access with caps from 01-runtime.md/03-security.md; Response base plus HtmlResponse, JsonResponse, XmlResponse, TextResponse, RedirectResponse, FileResponse, StreamingResponse (streaming mechanics live in 06-realtime.md, the class shell lives here).
  • molo::views::View: constructed with Request; virtual get/post/... default to 405; dispatch() selects by method. Function handlers adapt to the same interface.
  • molo::validate::Model: field descriptors with type, constraints, default, required; validate() returns model or throws ValidationError listing every field failure. Used for path/query/body.
  • molo::forms::Form: binds application/json, application/x-www-form-urlencoded, multipart/form-data (file parts delegate to upload handling in 03-security.md) into a Model.
  • molo::negotiate::Negotiator: parses Accept, sorts by q, matches handler-declared formats.
  • molo::templates::Environment: loaders, filters, tests, globals, autoescape predicate, cache.
  • molo::middleware::Middleware: handle(Request, Next) -> Response; Chain composes in order.
  • molo::di::Container: request-scoped and app-scoped providers, constructor injection for views.
  • molo::errors::HttpError(status, code, message, details); ValidationHttpError (422); ErrorHandler maps exception to status plus negotiated JSON/HTML body.

4. Rules (hard, numbered)

  • R1: Every route handler is a coroutine taking Request (or a View holding one) and returning a Response. No handler touches sockets, the event loop, or globals.
  • R2: All input binding (path, query, header, cookie, JSON body, form body) goes through molo::validate. A second parser for the same input is a defect.
  • R3: ValidationError lists ALL field failures in one response, never fail-fast on the first field.
  • R4: Responses are chosen from the Response hierarchy only. Raw string returns from handlers are rejected at compile time (no implicit conversion to 200 text).
  • R5: Template autoescaping is ON for .html, .htm, .xml and for all streamed template output. safe bypass requires naming the value safe at the call site; no global disable flag.
  • R6: Content negotiation runs for every response with more than one declared format. Explicit ?format= or path suffix overrides Accept only when the route declares allow_format_override. No match yields 406 with a JSON or HTML body listing available types.
  • R7: Middleware order is fixed by category (errors outer, then observability, security, session/auth, user middleware, router innermost). Reordering across categories is a compile-time or startup error.
  • R8: DI provides DB, cache, auth, templates and settings. No service singletons, no file-static mutable state reachable from handlers. Request scope ends at response completion.
  • R9: Every framework exception maps to exactly one HTTP status via the error table in section 6. Unmapped exceptions become 500 and are logged with correlation ID; stack details never leak to clients.
  • R10: Route names are unique per application (including mounted sub-apps after path merge). Duplicate names fail at startup with the colliding paths quoted.
  • R11: All caps in section 6 are static constants with documented defaults. No autotuning, no silent growth.
  • R12: No feature in this file duplicates scope owned by a sibling: no TLS/auth/session logic (03-security.md), no ORM/KV/cache (04-data.md), no WebSocket/SSE/Socket.IO mechanics (06-realtime.md), no OpenAPI emission (08-clients-openapi.md), no CLI (09-cli-ops.md). This file exposes the hooks those siblings consume.

5. Development guidelines

  • Build in the order of section 7; each step ends green with integration tests through real HTTP.
  • Prefer small composable classes: one concern per file, behavior in exactly one base class, features composed through declared interfaces (plan.md section 9).
  • Headers follow the runtime: case-insensitive lookup, size and count caps enforced in 01-runtime.md before this layer sees them.
  • Keep the happy path allocation-light: route match without heap allocation per request where feasible; template rendering reuses the compiled template cache.
  • Every public API gets a usage example in the living example project (see 10-workflow-example.md) in the same change that introduces it.
  • Docs state current behavior with gaps listed next to the code that closes them; no aspirational APIs in headers.

6. Specifications (concrete behaviors, caps, formats)

Route matching:

  • Path syntax: /users/{id:int} with converters int, float, str (single segment), path (remainder, must be last). Unknown converter is a startup error naming the route.
  • Matching order: static segments first, then converters in registration order, then path catch-alls. Method mismatch on an existing path yields 405 with an Allow header, not 404.
  • Trailing slash: strict. /users and /users/ are different routes unless the route sets slash_insensitive=true, which registers both and canonical-redirects (308) to the declared form.
  • Sub-app mount merges paths: mounting app B at /app1 inside A prefixes all B routes with /app1; triple nesting yields /app1/app1/app1 automatically. Mounted apps keep their own DI scope but share the parent middleware chain plus their own appended layers.

Validation and forms:

  • Field constraints: min/max (numbers), min_len/max_len (strings/collections), pattern (regex), choices, gt/ge/lt/le, required, default, nullable. Coercion: strict for path params (no "1abc" to int), lenient-but-documented for query/form strings.
  • ValidationError JSON shape: {"detail":[{"loc":["query","age"],"msg":"...","type":"..."}]}, status 422. HTML rendering shows the same list in a 422 page.
  • Form caps: max fields 1000, max field name 256 chars, max non-file value 1 MiB, max JSON depth 32, max JSON document 10 MiB default (overridable per route down to 0 = reject bodies). File parts use the upload quotas from 03-security.md.

Negotiation:

  • Parse Accept into (range, q, params); sort by q desc, then specificity (exact beat type star beat star star), then handler declaration order. Missing Accept means */*.
  • Handler declares produces = {HTML, JSON} etc. First negotiated match wins. No match: 406 with {"detail":{"available":["text/html","application/json"]}} or the HTML equivalent.
  • Content-Type of requests must match a route-declared consumes set when declared, else 415.

Templates (Jinja-compatible subset, in-house engine):

  • Delimiters: {{ }}, {% %}, {# #}. Constructs: extends, block/endblock, include, if/ elif/else/endif, for/else/endfor with loop variable, set, macro, filters with |, tests with is, autoescape blocks. Undefined variables render per policy: chain default renders empty string and logs at debug; strict mode (dev default) throws UndefinedError (500 in prod rendering, startup warning listing the template).
  • Filters shipped: escape, safe, upper, lower, trim, striptags, urlencode, length, default, join, date, tojson. Tests shipped: defined, none, string, number, sequence, mapping, even, odd.
  • Caps: max template size 1 MiB, max include depth 16, max extends chain 8, max loop iterations 100000, max render output 32 MiB, compiled-template cache max 512 entries with LRU eviction. Recursion beyond depth aborts with 500 and a metric increment, never a stack overflow.
  • Loaders: filesystem directory, embedded assets (from 09-cli-ops.md embedder), in-memory dict. Dev reloads from disk per render when dev_reload=true; prod serves compiled cache only.

Middleware and DI:

  • Signature: Task<Response> handle(Request req, Next next). Next invokes the remainder exactly once; calling twice is a runtime error. Middleware may replace request/response but must preserve the correlation ID (owned by 02-observability.md).
  • Built-ins owned here: ExceptionMappingMiddleware (outermost), NegotiationMiddleware, CORSPassthrough is NOT here (03-security.md owns CORS policy; this file only preserves its headers), RequestIdEcho helpers defer to 02-observability.md. User middleware registers with an explicit category tag.
  • DI scopes: app (one per Application), request (one per request, destroyed after response). Providers are factories or values; circular dependencies fail at startup with the cycle quoted.

Errors:

  • Mapping table: 400 BadRequest, 401 Unauthorized, 403 Forbidden, 404 NotFound (includes url_for miss at render time), 405 MethodNotAllowed (sets Allow), 406 NotAcceptable, 408/413/414/431 from runtime caps pass through, 415 UnsupportedMediaType, 422 ValidationFailed, 429 from 03-security.md passes through, 500 InternalError, 503 Unavailable.
  • JSON error shape: {"error":{"code":"not_found","message":"...","request_id":"..."}}. HTML error shape: responsive error page with the same fields, no internals.
  • Redirects: RedirectResponse(url, permanent) emits 308/307 (method-preserving). Flash messages ride the session (03-security.md) via flash(request, level, message) and render once.

Global caps (static, overridable by config, never autotuned):

  • max routes per app 100000; max path length 2048 chars; max path params per route 32; max middleware layers 64; max sub-app nesting depth 8; max DI providers 4096.

7. Execution steps (in build order)

  • E1: Request, Headers, Query, cookie view, Response base plus Text/JSON/HTML/XML/Redirect. No routing yet; handlers invoked directly in tests.
  • E2: Path compiler plus Router (static, converters, 404/405, Allow header), url_for by name.
  • E3: Application, include_router with prefix/tags/dependencies, sub-app mount with path merge, startup validation (duplicate names, unknown converters, cycles).
  • E4: molo::validate (Model, Field, coercions, full-error ValidationError) bound to path, query, headers, cookies.
  • E5: molo::errors (hierarchy, mapping table, JSON plus HTML renderers, global handler).
  • E6: molo::negotiate plus NegotiationMiddleware, consumes/produces declarations, 406/415 paths.
  • E7: molo::views::View with method dispatch, function-handler adapter, redirects, flash helpers.
  • E8: Middleware Chain with category ordering enforcement and Next-once guard.
  • E9: molo::di (app and request scopes, cycle detection) wired into views and handlers.
  • E10: molo::forms (JSON, urlencoded, multipart field parts) on top of validation.
  • E11: Template engine: lexer, parser, renderer, Environment, loaders, filters/tests, autoescape, caps and compiled cache. TemplateResponse(template, context) as a Response subclass.
  • E12: Plugin/extension registry on Application (named hooks: route_added, app_started, error_render) with capability declarations so plugins cannot reach undeclared internals.

8. Validation (how to verify each step)

Integration tests only, through the real stack (real HTTP, routing, middleware), per plan.md section 8. Test files mirror routes: tests/routes/<router>/<route>_test.cpp.

  • V1: GET/POST round-trips return correct status, headers and bodies for each response subclass.
  • V2: static, converter and catch-all matches; 404 body shape; 405 with exact Allow value; url_for reverses every named route including converted params.
  • V3: prefixed includes; triple-nested mount produces /app1/app1/app1; duplicate names and unknown converters fail startup with quoted diagnostics.
  • V4: valid input binds; invalid input yields one 422 listing every bad field (path, query, header, cookie cases each covered).
  • V5: each mapped exception renders the exact JSON shape and the HTML page; unknown exception yields 500 JSON/HTML without internals; request_id present in both.
  • V6: q-weighted Accept fixtures pick the expected format; no-match yields 406 listing available types; wrong Content-Type yields 415.
  • V7: View dispatches each method; unimplemented method yields 405; redirects preserve method (307/308); flash message appears exactly once across two requests.
  • V8: middleware order test asserts onion sequence; double-Next call fails loudly; short-circuit returns without reaching the handler.
  • V9: request scope isolation across two concurrent requests; app scope shared; cycle fixture fails startup quoting the cycle.
  • V10: JSON, urlencoded and multipart forms bind to the same model; cap fixtures (1001 fields, depth 33, oversized value) are rejected with the documented status.
  • V11: template fixtures cover extends/blocks, include, if/for/else, filters, tests, macros; XSS fixture asserts escaping; safe fixture asserts opt-out; cap fixtures (depth 17, 100001 iterations) abort safely; dev reload picks up an edited file, prod serves cache.
  • V12: sample plugin receives hooks in order and is denied undeclared access.

9. References (sibling files this depends on or feeds)

  • Depends on 01-runtime.md: timeouts, body/header caps, cookies, compression, static serving. This file never re-enforces byte-level caps.
  • Depends on 02-observability.md: correlation IDs, log format, per-route metrics, tracing spans.
  • Depends on 03-security.md: CSRF, CORS policy, rate limits, session/flash storage, upload quotas, auth guards consumed by views and DI.
  • Depends on 04-data.md: ORM/KV/cache handles provided through DI; explicit invalidation hooks.
  • Feeds 06-realtime.md: Request/Response shells, router mounting and middleware/DI participation for WebSocket/SSE/Socket.IO views and streaming responses.
  • Feeds 07-supervisor-replication.md: request scope teardown points safe for background handoff.
  • Feeds 08-clients-openapi.md: routers plus validation models as the OpenAPI source; response formats as client expectations; test client drives Application without network.
  • Feeds 09-cli-ops.md: dev server hosts Application; embedder output plugs into template loaders; health endpoints are plain routes defined here.
  • Follows 10-workflow-example.md: specs, test layout, living example coverage for every API above.

10. Open questions

  • Q1: Exact lenient coercion table for query/form strings (e.g. "yes" to bool, empty string to null). Proposal: strict booleans (true/false/1/0 only), empty string never coerces to null.
  • Q2: Whether ?format= override defaults on or off. Proposal: off; routes opt in.
  • Q3: Template strict undefined policy in production: render empty plus warn, or 500. Proposal: empty plus warn and metric, 500 only in dev/test.
  • Q4: Plugin capability model granularity: coarse hook names now, fine-grained review before v1.

11. Sources (exact URLs with retrieval dates)