patx/wred
Preserve explicit tuple response content types in 0.11
Commit 968b24c · patx · 2026-10-19T10:36:22-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 2398dd0..6a75fce 100644
--- a/README.md
+++ b/README.md
@@ -137,13 +137,15 @@ signed, not encrypted. Wred doesn't supply a session property.
Dicts and lists become `application/json` with HTTP 200. Bytes default to
`application/octet-stream`. Custom responses use `(body, content_type, status[, headers])`.
-Tuple bodies must be bytes, dicts, or lists; dicts and lists always use `application/json`.
+Tuple bodies must be bytes, dicts, or lists. Dicts and lists are JSON-encoded while
+preserving the tuple's explicit content type.
Headers are string pairs; repeated `Set-Cookie` headers are supported.
* `{'hello': 'world'}` -> JSON object, 200
* `[{'id': 1}, {'id': 2}]` -> JSON array, 200
* `b'hello', 'text/plain', 200` -> Text response
* `{'id': 3}, 'application/json', 201, [('Location', '/items/3')]` -> Created resource with a custom header
+* `{'error': 'Invalid'}, 'application/problem+json', 400` -> JSON with a custom media type
* `b'', 'text/plain', 303, [('Location', '/')]` -> Redirect
* `b'', 'application/json', 204` -> No content
diff --git a/docs/tutorial/api-reference.html b/docs/tutorial/api-reference.html
index 9be6121..cd00cb0 100644
--- a/docs/tutorial/api-reference.html
+++ b/docs/tutorial/api-reference.html
@@ -106,7 +106,7 @@ query = request.query
if not isinstance(data, dict):
return {'error': 'Send a JSON object'}, 'application/json', 400</code></pre></div><p>The body is cached; the parsed JSON value is not. Each <code>json()</code> call parses those bytes again. Malformed JSON, including invalid UTF-8 JSON input, raises <code>orjson.JSONDecodeError</code>. Parsing does not depend on the Content-Type header. See <a href="https://github.com/ijl/orjson">orjson’s reference</a> for its supported JSON behavior.</p>
<h2 id="responses">Handler return values</h2>
-<div class="table-wrap"><table><thead><tr><th scope="col">Return</th><th scope="col">Body / content type / status</th></tr></thead><tbody><tr><td><code>dict</code> or <code>list</code></td><td>Serialized by orjson; <code>application/json</code>; 200.</td></tr><tr><td><code>bytes</code></td><td>Sent directly; <code>application/octet-stream</code>; 200.</td></tr><tr><td><code>(body, content_type, status)</code></td><td>Explicit media type and status. A dict/list body always uses <code>application/json</code>.</td></tr><tr><td><code>(body, content_type, status, headers)</code></td><td>As above, with an iterable of string header pairs.</td></tr><tr><td>Callable ASGI response</td><td>Wred awaits <code>response(scope, receive, send)</code>; the response owns the outgoing messages.</td></tr></tbody></table></div><div class="code-block"><div class="code-heading"><span class="code-label">Alternative returns inside handlers</span><span class="code-actions"></span></div><pre tabindex="0"><code># JSON creation response with a Location header.
+<div class="table-wrap"><table><thead><tr><th scope="col">Return</th><th scope="col">Body / content type / status</th></tr></thead><tbody><tr><td><code>dict</code> or <code>list</code></td><td>Serialized by orjson; <code>application/json</code>; 200.</td></tr><tr><td><code>bytes</code></td><td>Sent directly; <code>application/octet-stream</code>; 200.</td></tr><tr><td><code>(body, content_type, status)</code></td><td>Explicit media type and status. A dict/list body is JSON-encoded while preserving the supplied content type.</td></tr><tr><td><code>(body, content_type, status, headers)</code></td><td>As above, with an iterable of string header pairs.</td></tr><tr><td>Callable ASGI response</td><td>Wred awaits <code>response(scope, receive, send)</code>; the response owns the outgoing messages.</td></tr></tbody></table></div><div class="code-block"><div class="code-heading"><span class="code-label">Alternative returns inside handlers</span><span class="code-actions"></span></div><pre tabindex="0"><code># JSON creation response with a Location header.
return {'id': 'n1'}, 'application/json', 201, [('Location', '/notes/n1')]
# Empty deletion response.
diff --git a/docs/tutorial/first-api.html b/docs/tutorial/first-api.html
index f430cb1..6247b8f 100644
--- a/docs/tutorial/first-api.html
+++ b/docs/tutorial/first-api.html
@@ -100,7 +100,7 @@ async def echo(request):
return {'error': 'Title must contain 1–100 characters'}, 'application/json', 400
return {'title': title.strip()}</code></pre></div><div class="code-block"><div class="code-heading"><span class="code-label">Terminal</span><span class="code-actions"></span></div><pre tabindex="0"><code>curl -i http://127.0.0.1:8000/api/echo \
-H 'Content-Type: application/json' \
- -d '{"title":"My first note"}'</code></pre></div><p>The response body is <code>{"title":"My first note"}</code>. A JSON array, missing title, or invalid JSON gets 400. Schema validation is your application’s decision; a few explicit checks are perfectly respectable.</p><h2 id="response-shapes">Choose the response you mean</h2><div class="table-wrap"><table><thead><tr><th scope="col">Return value</th><th scope="col">Result</th></tr></thead><tbody><tr><td><code>{"ok": True}</code></td><td>JSON, 200</td></tr><tr><td><code>{"id": "n1"}, "application/json", 201</code></td><td>JSON, 201</td></tr><tr><td><code>b"", "text/plain", 303, [("Location", "/")]</code></td><td>Redirect</td></tr><tr><td><code>b"", "application/json", 204</code></td><td>Empty response</td></tr><tr><td>A callable ASGI response</td><td>The response object sends its own messages</td></tr></tbody></table></div><p>Custom tuple bodies can be bytes, dicts, or lists. Dicts and lists use JSON even if you give them another content type. Exceptions propagate to the server or your middleware; Wred doesn’t convert them into a reassuringly vague success.</p>
+ -d '{"title":"My first note"}'</code></pre></div><p>The response body is <code>{"title":"My first note"}</code>. A JSON array, missing title, or invalid JSON gets 400. Schema validation is your application’s decision; a few explicit checks are perfectly respectable.</p><h2 id="response-shapes">Choose the response you mean</h2><div class="table-wrap"><table><thead><tr><th scope="col">Return value</th><th scope="col">Result</th></tr></thead><tbody><tr><td><code>{"ok": True}</code></td><td>JSON, 200</td></tr><tr><td><code>{"id": "n1"}, "application/json", 201</code></td><td>JSON, 201</td></tr><tr><td><code>b"", "text/plain", 303, [("Location", "/")]</code></td><td>Redirect</td></tr><tr><td><code>b"", "application/json", 204</code></td><td>Empty response</td></tr><tr><td>A callable ASGI response</td><td>The response object sends its own messages</td></tr></tbody></table></div><p>Custom tuple bodies can be bytes, dicts, or lists. Dicts and lists are JSON-encoded while preserving the tuple’s explicit content type. Exceptions propagate to the server or your middleware; Wred doesn’t convert them into a reassuringly vague success.</p>
<nav class="chapter-nav" aria-label="Previous and next chapters"><a href="index.html">← Build something with Wred.</a><a href="notebook.html">Run the notebook →</a></nav>
<footer>Written for Wred 0.10. See <a href="releases.html">release notes</a> for changes and migration guidance.</footer>
</main>
diff --git a/docs/tutorial/search-index.js b/docs/tutorial/search-index.js
index 7b497b5..a835d56 100644
--- a/docs/tutorial/search-index.js
+++ b/docs/tutorial/search-index.js
@@ -1,2 +1,2 @@
// Generated by build_search.py; edit the HTML pages, then rebuild.
-const DOCS_SEARCH_INDEX = [{"url":"api-reference.html","title":"API reference","headings":[{"anchor":"imports","title":"Imports and scope"},{"anchor":"app","title":"App()"},{"anchor":"route","title":"App.route(path, methods=(\"GET\",))"},{"anchor":"mounted-paths","title":"Mounted paths and root_path"},{"anchor":"request","title":"Request(scope, receive, send)"},{"anchor":"headers","title":"request.headers → dict[str, str]"},{"anchor":"query","title":"request.query → dict[str, str]"},{"anchor":"body","title":"await request.body() → bytes"},{"anchor":"json","title":"await request.json() → Any"},{"anchor":"responses","title":"Handler return values"},{"anchor":"low-level","title":"Low-level App helpers"},{"anchor":"send-response","title":"await app.send_response(send, response, status=200) → None"},{"anchor":"match-path","title":"app.match_path(pattern, parts) → dict[str, str] | None"}],"text":"API reference The signatures, return values, and edge cases in one place. The API is small enough that this page does not require its own executive summary. These docs cover Wred 0.10, where request.query is a cached property. Upgrading from 0.9? Remove the parentheses; see migration notes . On this page Imports and scope App() App.route(path, methods=(\"GET\",)) Mounted paths and root_path Request(scope, receive, send) request.headers → dict[str, str] request.query → dict[str, str] await request.body() → bytes await request.json() → Any Handler return values Low-level App helpers Imports and scope Python from wred import App, Request This reference describes the implementation in wred.py for Wred 0.10. Handlers normally use App and the request they receive. The low-level dispatch helpers are documented at the end for readers working directly with ASGI. Related guides: first API , errors and troubleshooting , ASGI middleware . App() Create an HTTP-only ASGI application. The constructor takes no configuration arguments. Python app = App() Export the instance, or a middleware wrapper around it, to your server. The ASGI entry point is await app(scope, receive, send) ; its return value is None . It creates a Request, finds a handler, awaits the handler, and sends the response. Scope types other than http raise RuntimeError before receiving or sending messages. Use an outer application for lifespan, WebSockets, or other protocol handling. See the ASGI specification for the callable interface. App.route(path, methods=(\"GET\",)) Python @app.route('/users/{user_id}', methods=['GET', 'PATCH']) async def user(request, user_id): return {'id': user_id, 'method': request.method} Argument Meaning path: str A route such as /users or /users/{user_id} . Use a leading slash and valid Python parameter names. methods: list[str] | tuple[str, ...] Defaults to (\"GET\",) . Registration uppercases each method name. Decorated handler An async callable accepting the request and any path parameters as keyword arguments. The decorator returns the original handler. Parameters occupy a whole path segment and arrive as strings. Convert and validate them in the handler. There are no typed converters or catch-all path parameters. Trailing slashes are ignored at registration and dispatch. /users and /users/ reach the same route without a redirect. A fixed route wins when it has a handler for the incoming method. If it does not, a matching dynamic route with that method may handle the request. Dynamic routes are tried in registration order. Keep overlapping patterns intentional. Registering the same path and method again replaces that handler. Other registered methods remain. No matching path/method returns {\"error\":\"Not Found\"} with 404. HEAD, OPTIONS, redirects, and 405 responses are not synthesized. Routes are case-sensitive. Wred does not implement named routes, URL generation, subrouters, or an application mount() method. Use an outer ASGI router for composition. Mounted paths and root_path When the incoming path starts with the scope’s root_path on a segment boundary, Wred removes that prefix for route matching. The original scope and request.path retain the incoming path. Registered route Incoming scope Result /users path=\"/api/users\", root_path=\"/api\" Matches /users ; request.path remains /api/users . /users/{user_id} path=\"/api/users/42\", root_path=\"/api\" Passes user_id=\"42\" . The prefix and the server/router configuration must agree. See the Starlette mount example . Arbitrarily trimming slashes is not a deployment strategy, although it is a traditional source of one. Request(scope, receive, send) Wred creates this object for each HTTP request. Direct construction is mainly useful when working at the ASGI boundary. The constructor stores the supplied scope and callables; it does not read the body. Attribute Value request.path: str The original scope[\"path\"] , without the query string. It is not rewritten to the mount-relative route. request.method: str The incoming scope[\"method\"] . request.scope: dict The supplied ASGI scope, including client, scheme, root_path, and any middleware state. request.receive The ASGI receive callable. Consuming events yourself affects subsequent body reads. request.send The ASGI send callable. Normally let the return-value handling or a callable response send the response. Wred has no built-in request.user , request.session , request.cookies , request.form() , or request.is_disconnected() interface. Middleware or your own decorators can add context. The notebook’s request.user is added by its authentication decorator. request.headers → dict[str, str] A property, not a coroutine. On first access it decodes ASGI header bytes with Latin-1, lowercases names, and caches the resulting dict. Repeated Cookie fields are joined with ; ; repeated headers with other names keep their last value. Inside a handler authorization = request.headers.get('authorization', '') content_type = request.headers.get('content-type', '') This is a convenience view, not a multi-value header container. Use request.scope.get(\"headers\", []) for the original byte pairs when duplicate fields matter. Changing the cached dict does not rewrite those original ASGI header pairs. request.query → dict[str, str] A cached_property , like headers . Parse the query string on first attribute access and cache the resulting dict. Blank values are preserved; repeated keys keep the last value. The parser strictly decodes UTF-8, including percent-encoded values. Invalid UTF-8 raises UnicodeDecodeError . Inside a handler # /search?q=first&q=last&empty= query = request.query # {'q': 'last', 'empty': ''} No await is needed. Results are strings. Apply your own numeric ranges, field-count limits, and repeated-key policy. Repeated accesses return the cached dict, so mutate a copy if you need a separate working view. await request.body() → bytes Read successive http.request messages until more_body is false, join their bytes, and cache the complete body. Later calls return the cached bytes. Inside a handler body = await request.body() The body is buffered in memory. Wred imposes no body-size limit; configure a limit at the server, proxy, or a suitable middleware layer. An incoming event other than http.request , including http.disconnect , raises ConnectionError . await request.json() → Any Read the cached body and parse it with orjson.loads() . An empty body returns {} . The result may be an object, array, string, number, boolean, or None ; Wred does not enforce an object schema. Inside a handler data = await request.json() if not isinstance(data, dict): return {'error': 'Send a JSON object'}, 'application/json', 400 The body is cached; the parsed JSON value is not. Each json() call parses those bytes again. Malformed JSON, including invalid UTF-8 JSON input, raises orjson.JSONDecodeError . Parsing does not depend on the Content-Type header. See orjson’s reference for its supported JSON behavior. Handler return values Return Body / content type / status dict or list Serialized by orjson; application/json ; 200. bytes Sent directly; application/octet-stream ; 200. (body, content_type, status) Explicit media type and status. A dict/list body always uses application/json . (body, content_type, status, headers) As above, with an iterable of string header pairs. Callable ASGI response Wred awaits response(scope, receive, send) ; the response owns the outgoing messages. Alternative returns inside handlers # JSON creation response with a Location header. return {'id': 'n1'}, 'application/json', 201, [('Location', '/notes/n1')] # Empty deletion response. return b'', 'application/json', 204 # Text: encode it yourself. return 'Hello'.encode('utf-8'), 'text/plain; charset=utf-8', 200 Tuple bodies must be bytes, dicts, or lists. Bare strings, integers, booleans, and None are unsupported response bodies. If you need a JSON scalar, use a callable response such as Starlette’s JSONResponse . Use the tuple’s media type field to set Content-Type. Additional headers are lowercased and encoded with ASCII names and Latin-1 values. A list of pairs preserves repeated Set-Cookie headers. Wred sends one response-start message followed by one body message for its own return-value handling; it does not add a Content-Length header itself. Your ASGI server handles HTTP transport details. A callable response can stream instead; see files and SSE . Low-level App helpers Most handlers should return a response value. The methods below support dispatch and explicit ASGI integrations; use them when you actually own the message flow. Sending a response yourself and then returning another one is not a two-for-one offer. await app.send_response(send, response, status=200) → None Send a supported body or response tuple through an ASGI send callable. A tuple’s status takes precedence over the status argument. This helper handles serialization and the two outgoing messages; it does not invoke callable response objects. Normal app dispatch invokes callable responses separately. app.match_path(pattern, parts) → dict[str, str] | None Compare a tuple of pattern segments with a list of incoming segments. Return string parameters when segment counts and fixed segments match, otherwise None . Inputs are already split; this helper does not normalize trailing slashes or remove a root path. Python app.match_path(('', 'users', '{user_id}'), ['', 'users', '42']) # {'user_id': '42'} app.routes and app.pattern_routes hold dispatch tables. Register handlers with route() rather than editing these tables directly. ← Documentation overview Errors & troubleshooting → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"asgi-middleware.html","title":"Write a little ASGI middleware","headings":[{"anchor":"contract","title":"Three arguments, two directions"},{"anchor":"context","title":"Add request context and timing"},{"anchor":"rules","title":"Keep the wrapper well behaved"},{"anchor":"proxy","title":"Use Uvicorn’s proxy-header middleware with another server"},{"anchor":"auth-context","title":"Authentication middleware can use the same scope"}],"text":"Write a little ASGI middleware Sometimes you need a request ID, a timing header, or a place to attach context. This does not require founding another framework. On this page Three arguments, two directions Add request context and timing Keep the wrapper well behaved Use Uvicorn’s proxy-header middleware with another server Authentication middleware can use the same scope Three arguments, two directions An ASGI app is called with (scope, receive, send) . The scope describes the connection. receive() supplies request events; send() emits response events. Middleware can wrap either callable and then delegate to the inner app. This example assigns a request ID, makes it available to handlers, and adds response headers when http.response.start is sent. It measures time until response headers, not the complete download time. Names are useful when they mean things. Add request context and timing Save as context_app.py , then run weft context_app:app . context_app.py Download from time import perf_counter from uuid import uuid4 from wred import App class RequestContext: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope['type'] != 'http': return await self.app(scope, receive, send) request_id = uuid4().hex started = perf_counter() scope = {**scope, 'state': {**scope.get('state', {}), 'request_id': request_id}} async def send_with_context(message): if message['type'] == 'http.response.start': elapsed_ms = (perf_counter() - started) * 1000 message = { **message, 'headers': [ *message.get('headers', []), (b'x-request-id', request_id.encode('ascii')), (b'server-timing', f'app;dur={elapsed_ms:.2f}'.encode('ascii')), ], } await send(message) await self.app(scope, receive, send_with_context) api = App() @api.route('/api/hello') async def hello(request): return {'request_id': request.scope['state']['request_id']} app = RequestContext(api) Terminal curl -i http://127.0.0.1:8000/api/hello The body’s request_id matches X-Request-ID . Server-Timing reports the elapsed milliseconds up to response start. Each request keeps its own variables inside __call__ ; shared instance attributes would invite concurrent requests to exchange identities. Keep the wrapper well behaved Pass through protocols you don’t handle. That lets a lifespan-aware outer app keep doing its job. Copy the scope when adding request-local data. Preserve existing state entries. Preserve existing response headers, including repeated Set-Cookie fields. If you modify the response body, update or remove Content-Length as appropriate. Don’t consume the request body unless you also arrange to replay it to the inner app. Our wrapper leaves the body alone. It also passes lifespan through rather than implementing it; wrapping a bare Wred app still leaves you with an HTTP-only app. The pure ASGI middleware guide describes this pattern. Middleware can also come from other packages: the interoperability is in the ASGI call, not in matching logos. Use Uvicorn’s proxy-header middleware with another server You can reuse Uvicorn’s ProxyHeadersMiddleware while serving with Weft. Install uvicorn in addition to the notebook’s dependencies, save this as proxy_app.py at the repository root, and run weft proxy_app:app --host 127.0.0.1 --lifespan on . proxy_app.py Download from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from example_app.app import create_app # Use only behind a trusted proxy connecting from IPv4 loopback. app = ProxyHeadersMiddleware(create_app(), trusted_hosts=['127.0.0.1']) It accepts forwarded scheme and client information only from the listed proxy peers. For this example, the reverse proxy must connect over IPv4 loopback. Keep the backend private; a client that reaches it directly from a trusted address can supply those headers too. This is an alternative to Weft’s --forwarded-allow-ips option used in the deployment chapters. Pick one owner for proxy-header processing. See the Uvicorn implementation . Authentication middleware can use the same scope Starlette’s authentication middleware places authentication information in scope[\"user\"] and scope[\"auth\"] . A Wred handler can read those keys directly. The middleware’s authentication backend and your authorization rules remain application choices. The notebook uses its own @protected decorator instead. That decorator adds request.user itself. Don’t assume the property exists in unrelated Wred apps, or that wrapping with authentication middleware automatically protects every route. ← Borrow Starlette’s middleware Serve a frontend and other responses → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"caddy.html","title":"Put Caddy in front","headings":[{"anchor":"backend","title":"Keep the Python listener private"},{"anchor":"caddyfile","title":"Give your domain a Caddyfile"},{"anchor":"validate","title":"Validate, reload, and check"},{"anchor":"local","title":"Try the proxy locally"},{"anchor":"boundaries","title":"Keep the responsibilities obvious"}],"text":"Put Caddy in front HTTPS, a reverse proxy, and a configuration file you can read before your coffee gets cold. Caddy is annoyingly good at this. On this page Keep the Python listener private Give your domain a Caddyfile Validate, reload, and check Try the proxy locally Keep the responsibilities obvious Keep the Python listener private These deployment chapters use the notebook at the site root, including its static files. Use a host with Caddy installed and your app dependencies in a virtualenv. First run the backend on IPv4 loopback: Terminal # Run from the repository root with the notebook dependencies installed. weft example_app.asgi:app --host 127.0.0.1 --port 8000 \\ --lifespan on --forwarded-allow-ips 127.0.0.1 Accept forwarded headers only from the loopback proxy. That gives the notebook the external HTTPS scheme for cookie flags and same-origin checks, and the actual client address for its rate limiter. Use your supervisor to keep this process running; the operations chapter includes systemd. Give your domain a Caddyfile Replace notes.example.com with your domain. Point its DNS records at this host and make ports 80 and 443 reachable. Keep Caddy’s certificate storage writable and persistent. With those prerequisites, automatic HTTPS handles certificate issuance, renewal, and HTTP redirects. Caddyfile Download notes.example.com { request_body { max_size 1MB } encode zstd gzip reverse_proxy 127.0.0.1:8000 } request_body caps uploads at about one megabyte. reverse_proxy preserves the request path and forwards the Host and proxy headers. The notebook still sees /api/notes , which is fortunate because that’s where we put it. Validate, reload, and check Save the site configuration in the Caddyfile used by your installation, commonly /etc/caddy/Caddyfile . Validate it before applying it: Terminal sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile sudo systemctl reload caddy curl -i https://notes.example.com/api/health Use the Caddy CLI reload workflow if your installation isn’t managed by systemd. Expect JSON 200 from health. Visit the site, log in, and check that the refresh cookie has Secure under HTTPS. A green health check cannot inspect your cookies on your behalf. Try the proxy locally For an HTTP-only local smoke check, replace the domain line with http://localhost:8080 , save as Caddyfile.local , and run: Terminal caddy run --config Caddyfile.local --adapter caddyfile Open http://localhost:8080/ . This explicitly disables automatic HTTPS for that local site. Use the domain configuration for public HTTPS. Keep the responsibilities obvious Caddy terminates TLS and limits request bodies. Weft runs the ASGI app. The notebook handles its database lifecycle and authorization. You still need a shared rate-limit strategy if you want one limit across workers; this Caddyfile does not add one. Choose whether compression belongs in Caddy or an application middleware layer. For streams, check proxy timeouts and disconnect behavior with a real client. For a separate frontend build, use explicit static and API routing while preserving the API prefix. ← Serve a frontend and other responses Put Nginx in front → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"configuration.html","title":"Configuration & architecture","headings":[{"anchor":"ownership","title":"Who configures what?"},{"anchor":"notebook-settings","title":"Notebook environment variables"},{"anchor":"recipe-settings","title":"Recipe-only settings"},{"anchor":"protocols","title":"Separate HTTP from lifespan"},{"anchor":"proxy-settings","title":"Configure forwarding at one layer"}],"text":"Configuration & architecture There is no Wred settings object to memorize. Configure the component that actually owns the behavior. A small victory for both readability and your remaining memory. On this page Who configures what? Notebook environment variables Recipe-only settings Separate HTTP from lifespan Configure forwarding at one layer Who configures what? Concern Owner and documentation Routes, methods, validation, error responses Your Wred application. See the API reference . Host, port, workers, reload, transport timeouts Your ASGI server. Wred has no server configuration or CLI. CORS, signed sessions, request context The middleware stack. See Starlette middleware and custom ASGI middleware . Database connections and credentials Your application and database client. The notebook’s settings are below. HTTPS, body limits, proxy routing Your edge server. See Caddy or Nginx . Process supervision and environment files Your deployment system. See operations . App() takes no settings and Wred reads no environment variables. A variable called WRED_DEBUG will do exactly what an unread environment variable usually does: occupy a small amount of memory. Notebook environment variables Variable Default Read by MONGODB_URI mongodb://127.0.0.1:27017 example_app.app.create_app() MONGODB_DATABASE wred_notebook example_app.app.create_app() WRED_TEST_MONGODB_URI Unset: database integration tests are skipped. tests/test_notebook.py ; not the running app. Explicit mongodb_uri and database_name arguments override the two app environment values. Passing database=... overrides both by supplying an application-owned database object. A notebook entry point in the repository root from example_app.app import create_app app = create_app( mongodb_uri='mongodb://127.0.0.1:27017', database_name='my_notebook', ) The factory and its environment variables belong to the example application, not the Wred package. Its wrapper calls the database’s async initialize() and close() methods through lifespan. Recipe-only settings Setting Used by Meaning SESSION_SECRET examples/session_app.py Required signing secret, shared by all workers. No built-in default. COOKIE_SECURE examples/session_app.py Defaults to 1 . Set 0 only for the local HTTP recipe. These two settings configure the standalone Starlette session example. The notebook’s refresh cookie uses the incoming ASGI scheme instead. The similar-looking cookies are under different management. Separate HTTP from lifespan Wred’s inner app handles HTTP. The notebook’s outer app handles startup and shutdown; Starlette can also be the outer lifecycle owner when composing apps. Pass unhandled protocols through middleware to the component that owns them. A bare Wred app should use your server’s unsupported-lifespan detection or an explicit lifespan-off setting. The notebook should run with lifespan enabled so database startup failures are visible. --lifespan on is shown in its Weft commands. Register routes before serving traffic. One worker’s in-memory objects do not become shared state merely because another worker was launched with the same command. Configure forwarding at one layer The deployment examples bind Weft to 127.0.0.1 and use --forwarded-allow-ips 127.0.0.1 . Their proxies preserve the public Host and supply the scheme and client address. If you use the alternative Uvicorn middleware recipe, let that wrapper own this processing instead. Correct scheme information matters for Secure cookies, redirects, and the notebook’s same-origin checks. A proxy configuration is part of application behavior, not a decorative YAML-adjacent accessory. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"data-and-auth.html","title":"Notes, accounts, and ownership","headings":[{"anchor":"request","title":"Follow one create request"},{"anchor":"api","title":"The notebook’s API"},{"anchor":"tokens","title":"Access tokens and refresh cookies"},{"anchor":"curl","title":"Try the auth flow with curl"},{"anchor":"ownership","title":"Check ownership where the data changes"},{"anchor":"limits","title":"Know what is local to a worker"}],"text":"Notes, accounts, and ownership The notebook’s auth is application code you can read. A radical alternative to discovering your security model through decorators with five undocumented arguments. On this page Follow one create request The notebook’s API Access tokens and refresh cookies Try the auth flow with curl Check ownership where the data changes Know what is local to a worker Follow one create request The browser posts JSON to /api/notes with an Authorization: Bearer … header. The @protected(database) decorator checks the token before the handler runs and sets request.user and request.login_id . read_note() rejects unknown fields and validates the title, body, and pinned flag. The handler generates an ID and timestamps, adds the owner from the authenticated user, and calls database.create_note() . A successful create returns JSON 201 with a Location header. The owner comes from the authenticated user, never from a client-supplied owner field. Otherwise, congratulations: you have built an impersonation API. example_app/notes.py · create route excerpt @app.route('/api/notes', methods=['POST']) @protected(database) async def create_note(request): try: data = await read_note(request) except ValueError as error: return {'error': str(error)}, 'application/json', 400 note_id = token_urlsafe(9) now = datetime.now(UTC).isoformat(timespec='microseconds') note = { 'id': note_id, 'owner': request.user['id'], **data, 'created_at': now, 'updated_at': now, } await database.create_note(note) return ( note, 'application/json', 201, [('Location', f'/api/notes/{note_id}')], ) The notebook’s API Method and path Behavior POST /api/signup Create an account; 201, or 409 for an existing username. POST /api/login Return an access token and set the refresh cookie. POST /api/refresh Use the refresh cookie to issue another access token. GET /api/me Return the authenticated user. POST /api/logout Revoke the login and expire its refresh cookie. GET / POST /api/notes List private notes or create a note. GET / PUT / PATCH / DELETE /api/notes/{note_id} Read, replace editable fields, partially update, or delete a note. GET /api/notes/export Download the authenticated user’s notes as JSON. The fixed /api/notes/export route wins over /api/notes/{note_id} , even though it is registered afterward. Listing accepts q , pinned=true|false , limit from 1 to 50, and a nonnegative offset . Access tokens and refresh cookies Passwords use PBKDF2-HMAC-SHA256 with a random salt and 600,000 iterations. Hashing runs through asyncio.to_thread() so the event loop can keep serving other requests. Unknown usernames still perform a dummy hash. Access and refresh tokens are random opaque strings. MongoDB stores SHA-256 digests of those tokens. Access tokens last one hour; the login’s refresh window lasts seven days. The database checks expiry during authentication instead of waiting for MongoDB’s TTL cleanup to get around to it. The browser keeps the access token in memory. The refresh token lives in an HttpOnly, SameSite=Strict cookie scoped to /api , with Secure added when the ASGI scheme is HTTPS. Refresh reuses the cookie while issuing new access tokens; it does not rotate the refresh token on each call. MongoDB stores each login and its access credentials in one document. Logout deletes that document, revoking the login’s access tokens as well. The browser’s renewLogin() shares one refresh promise across concurrent requests and refuses to replay an old account’s pending request under a newly logged-in account. Try the auth flow with curl With the notebook running, use a separate shell. The password here is demonstration data. Use something you haven’t published in a tutorial for your real account. Terminal curl -sS http://127.0.0.1:8000/api/signup \\ -H 'Content-Type: application/json' \\ -d '{\"username\":\"ada\",\"password\":\"a sufficiently long example password\"}' curl -sS -c notebook.cookies http://127.0.0.1:8000/api/login \\ -H 'Content-Type: application/json' \\ -H 'X-Notebook-Request: 1' \\ -d '{\"username\":\"ada\",\"password\":\"a sufficiently long example password\"}' Copy the access_token from the login JSON into a shell variable, then create and list notes: Terminal read -r -s NOTEBOOK_TOKEN # Paste the access token, then press Enter. curl -i http://127.0.0.1:8000/api/notes \\ -H \"Authorization: Bearer $NOTEBOOK_TOKEN\" \\ -H 'Content-Type: application/json' \\ -d '{\"title\":\"Ship it\",\"body\":\"After reading the deployment chapter.\",\"pinned\":true}' curl -sS 'http://127.0.0.1:8000/api/notes?limit=5&offset=0' \\ -H \"Authorization: Bearer $NOTEBOOK_TOKEN\" curl -sS -b notebook.cookies -X POST http://127.0.0.1:8000/api/refresh \\ -H 'X-Notebook-Request: 1' Login and refresh require X-Notebook-Request: 1 . Browser requests also face the app’s same-origin checks. A cookie jar contains credentials; keep it private and remove it when you finish. Check ownership where the data changes List and export operations filter by owner. Reading another user’s existing note returns 403; a missing ID returns 404. Updates and deletes include both the note ID and owner in the MongoDB write filter, so an earlier ownership check isn’t the sole line of defense. The application limits titles to 100 characters and bodies to 10,000 characters. PATCH preserves omitted fields; PUT requires a title and fills omitted body and pinned values with their defaults. Both reject fields outside title , body , and pinned . Know what is local to a worker The rate limiter stores timestamps in memory per application instance. A client can get three successful calls to the demo endpoint per ten-second window per worker . Multiple workers don’t magically share a Python dict, however enthusiastically you deploy them. Database-backed sessions survive app restarts; rate-limit buckets do not. For shared throttling, use a gateway or a shared store. Before deploying an adapted auth system, decide how you will handle account recovery, token abuse, CSRF, and operational monitoring. The notebook demonstrates a design; it isn’t an identity provider hiding in a trench coat. ← Run the notebook Borrow Starlette’s middleware → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"development.html","title":"Development & contributing","headings":[{"anchor":"setup","title":"Set up a checkout"},{"anchor":"checks","title":"Run the existing checks"},{"anchor":"changes","title":"Keep changes reviewable"},{"anchor":"docs","title":"Edit and preview these docs"},{"anchor":"reporting","title":"Report a bug with a reproduction"},{"anchor":"license","title":"License"}],"text":"Development & contributing Make a small change, demonstrate the behavior, and explain it clearly. The framework is tiny; the pull request does not need a cinematic universe. On this page Set up a checkout Run the existing checks Keep changes reviewable Edit and preview these docs Report a bug with a reproduction License Set up a checkout Terminal git clone https://gitman.io/git/patx/wred cd wred python -m venv .venv . .venv/bin/activate python -m pip install -e '.[server]' pymongo servestatic For the core framework alone, python -m pip install -e . installs Wred and orjson. Starlette and itsdangerous are only needed for the tutorial recipes using them. Run the existing checks The repository uses Python’s unittest runner. From the repository root: Terminal python -m unittest tests.test_wred python -m unittest tests.test_example_app python -m unittest discover -s tests The core suite exercises request parsing, routes, responses, mounted paths, and protocol boundaries. The example-app suite needs PyMongo and ServeStatic installed. The database integration suite also imports those dependencies and is skipped unless its MongoDB URI is set. Use a disposable MongoDB instance for integration checks: Terminal WRED_TEST_MONGODB_URI='mongodb://127.0.0.1:27017' python -m unittest tests.test_notebook The integration suite creates uniquely named databases beginning with wred_test_ and drops them during cleanup. The supplied credentials need permission for that work. Use a test database server, not production with a hopeful expression. Keep changes reviewable Start with the observable behavior you want to change. Keep Wred’s core focused on HTTP routing and responses; middleware and application modules are good homes for optional behavior. Explain any API or compatibility changes in the accompanying docs. When adding a regression check, exercise the actual affected boundary and describe what would fail without the fix. Prefer a small meaningful case to a second copy of the implementation dressed as a test. Edit and preview these docs The documentation is plain HTML with shared CSS and JavaScript in docs/tutorial/ . There is no framework build. Serve the docs directory to browse it locally: Terminal python -m http.server 8001 --bind 127.0.0.1 --directory docs Open http://127.0.0.1:8001/tutorial/ . The downloadable frontend example needs its ASGI server for its API; serving the documentation itself does not start that application. After changing page text or adding a page, rebuild the offline search index: Terminal python docs/tutorial/build_search.py node --check docs/tutorial/tutorial.js git diff --check Pages and navigation still work without JavaScript. Code-copy buttons and full-text search use the shared script; the search index is a local JavaScript file, so it also works without fetching a service. Report a bug with a reproduction Include the Wred and Python versions, the ASGI server and relevant options, a minimal app, the request you sent, the expected and actual responses, and the traceback if there is one. For proxy issues, say whether the same request works against the backend directly. Remove passwords, tokens, cookies, database credentials, and personal data from examples. Use the project repository for ordinary bug reports and contributions. See deployment boundaries for vulnerability-reporting guidance. License Wred is distributed under the BSD 3-Clause license. The complete terms are in the repository’s LICENSE file; retain that file and the applicable notices when redistributing. This paragraph is a signpost to the license, not a replacement for it. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"errors.html","title":"Errors & troubleshooting","headings":[{"anchor":"exceptions","title":"Exceptions Wred can raise"},{"anchor":"expected-errors","title":"Return expected errors explicitly"},{"anchor":"statuses","title":"Where HTTP statuses come from"},{"anchor":"debugging","title":"A short debugging sequence"},{"anchor":"streaming","title":"After a response has started"}],"text":"Errors & troubleshooting An exception and an HTTP error response are different things. Your logs would appreciate being invited to this distinction. On this page Exceptions Wred can raise Return expected errors explicitly Where HTTP statuses come from A short debugging sequence After a response has started Exceptions Wred can raise Exception Typical trigger What to do orjson.JSONDecodeError Invalid JSON in await request.json() . Catch at the input boundary and return a 400 response. UnicodeDecodeError Invalid UTF-8 in the raw or percent-decoded query string. Reject the query with 400 if it is part of your public input handling. ConnectionError A body read receives a disconnect or another non-request event. Handle cancellation/disconnection where relevant; do not assume a response can still reach the client. TypeError An unsupported response body, or a serialization/type error. Return bytes, a dict/list, a supported tuple, or a callable ASGI response. ValueError For example, unpacking a tuple with fewer than three response fields. Use exactly the documented three- or four-item response tuple. UnicodeEncodeError A custom header name is not ASCII or a value cannot be encoded as Latin-1. Use HTTP-compatible headers and encode internationalized values with the appropriate header format. RuntimeError A non-HTTP scope reaches a bare Wred app. Route that protocol to an outer app or configure lifespan appropriately. Wred lets these exceptions propagate. Your middleware or ASGI server decides how to log and respond to an unhandled exception. Do not depend on a bare Wred app to generate a JSON 500 response. Return expected errors explicitly Add to an existing app import orjson @app.route('/api/items', methods=['POST']) async def create_item(request): try: data = await request.json() except orjson.JSONDecodeError: return {'error': 'Invalid JSON'}, 'application/json', 400 if not isinstance(data, dict): return {'error': 'Send a JSON object'}, 'application/json', 400 return {'accepted': True} Catch the exception you expect at the boundary you understand. A blanket except Exception: return {\"ok\": True} makes dashboards look lovely and everything else substantially worse. Where HTTP statuses come from Status Wred behavior 200 Default for supported handler return values. 400 / 401 / 403 / 409 / 429 Application or middleware decisions. The notebook provides examples. 404 Wred’s fallback for an unmatched path or method. 405 Not generated automatically. Register a handler or use another routing layer if you need it. 500 Unhandled-error behavior is supplied by the server or middleware. See the error middleware example . HTTP status is part of the response, not a declaration made by throwing a Starlette exception. Wred does not install Starlette’s exception handlers for you. A short debugging sequence Call the backend directly from its host and inspect the exact status, headers, and body with curl -i . Confirm the server imports the wrapper you intended, not just the inner Wred instance. Check the route path and method, then the proxy or mount prefix. Inspect the server traceback. Turn off public debug tracebacks before deployment. Repeat through the public proxy to isolate scheme, Host, cookie, and forwarding issues. The operations troubleshooting table covers 502s, database startup failures, login problems, and buffered streams. After a response has started A callable response owns its send operations. If it raises after sending headers or part of a stream, Wred propagates the exception without attempting a second response. Validate inputs and permissions before sending the first message. An error handler cannot change a status code already sent to the client. Time travel remains an optional dependency that nobody has successfully published. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"faq.html","title":"Frequently asked questions","headings":[{"anchor":"scope","title":"What does Wred deliberately leave out?"},{"anchor":"server","title":"Do I have to use Weft?"},{"anchor":"requests","title":"Can I use Starlette’s Request APIs on a Wred request?"},{"anchor":"methods","title":"Why does POST or HEAD return 404?"},{"anchor":"responses","title":"Why can I parse JSON null but not return None?"},{"anchor":"forms","title":"How do I handle forms or uploaded files?"},{"anchor":"lifespan","title":"Where do startup tasks and database cleanup go?"},{"anchor":"streams","title":"Can Wred stream or handle WebSockets?"},{"anchor":"concurrency","title":"Can handlers call blocking code or launch background jobs?"},{"anchor":"limits","title":"Are the notebook’s rate limits global?"},{"anchor":"performance","title":"Will my app get the README’s benchmark numbers?"}],"text":"Frequently asked questions The questions that usually arrive just after the first successful curl request, when optimism starts acquiring requirements. On this page What does Wred deliberately leave out? Do I have to use Weft? Can I use Starlette’s Request APIs on a Wred request? Why does POST or HEAD return 404? Why can I parse JSON null but not return None? How do I handle forms or uploaded files? Where do startup tasks and database cleanup go? Can Wred stream or handle WebSockets? Can handlers call blocking code or launch background jobs? Are the notebook’s rate limits global? Will my app get the README’s benchmark numbers? What does Wred deliberately leave out? Authentication, sessions, database integration, schemas, templates, multipart/form parsing, CORS, CSRF protection, dependency injection, generated API docs, background jobs, WebSockets, and built-in static or streaming responses. The tutorial shows how to compose several of these from application code and ASGI libraries. The API reference describes what the core does. A feature’s absence is permission to choose a component, not an invitation to pretend the feature is unnecessary. Do I have to use Weft? No. wred[server] is a convenience extra for Weft. Install the base package and your preferred ASGI server if you want something else. Wred is ASGI, not a WSGI application; use the correct interface when a server supports both. Can I use Starlette’s Request APIs on a Wred request? Wrapping an application with Starlette middleware does not replace the request object Wred creates. Read middleware data through request.scope , or add your own application adapter. See Request’s attributes . request.query is a synchronous cached property. request.body() and request.json() must be awaited. Python will not reinterpret this distinction as a stylistic preference. Why does POST or HEAD return 404? A route defaults to GET. Register methods explicitly. Wred uses the same 404 fallback for unknown paths and unsupported methods; it does not supply automatic HEAD, OPTIONS, or 405 behavior. CORS middleware can handle browser preflights. Why can I parse JSON null but not return None? Input parsing accepts any JSON value. Wred’s direct response handling supports dicts, lists, and bytes, plus the documented tuple forms. Return a callable JSON response object when you need a JSON scalar, or return b\"\", \"application/json\", 204 for no content. How do I handle forms or uploaded files? Wred has no form or multipart parser. Choose a parser or ASGI component deliberately, install its dependencies, and arrange who reads the receive stream. A second consumer cannot reread events already consumed by the first. Enforce body and file limits before buffering uploads. Where do startup tasks and database cleanup go? In a lifespan-aware outer ASGI app. The notebook’s NotebookApp initializes and closes its database there. A bare Wred instance rejects lifespan. See the actual wrapper and protocol configuration . Can Wred stream or handle WebSockets? A handler can return a callable ASGI response such as Starlette’s StreamingResponse for HTTP streaming or SSE. WebSocket scopes are rejected by Wred itself; route them to a separate ASGI app with an outer router if you need them. Can handlers call blocking code or launch background jobs? Handlers are awaited on the event loop. Calling a blocking database client or doing expensive CPU work directly there blocks other requests in that worker. Use an async client, an appropriate thread for blocking I/O, or separate workers for expensive work. A detached task is not a durable job queue. If work must survive a restart, use a durable queue and workers that own it. The operating system is unimpressed by how important your untracked task felt. Are the notebook’s rate limits global? No. They are per app instance and per worker. Database sessions are persistent; the in-memory timestamp buckets are not. Use shared application storage or an edge limiter for a shared policy. See the Nginx example . Will my app get the README’s benchmark numbers? Those numbers describe one workload on one machine with a specific server configuration. Database calls, passwords, middleware, payload size, concurrency, and client behavior all change throughput and latency. Measure the work your application actually does. A hello-world benchmark remains impressively good at saying hello. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"first-api.html","title":"Your first API","headings":[{"anchor":"install","title":"Make a small project"},{"anchor":"routes","title":"Give it some routes"},{"anchor":"run","title":"Run it and ask it something"},{"anchor":"validation","title":"Accept JSON without accepting absolutely anything"},{"anchor":"response-shapes","title":"Choose the response you mean"}],"text":"Your first API Install a server, return a dict, and make a request. This is the part where a larger framework might ask you to generate a project, three directories, and a personal philosophy. On this page Make a small project Give it some routes Run it and ask it something Accept JSON without accepting absolutely anything Choose the response you mean Make a small project Start in a new directory and install Wred 0.10 or newer. The server extra installs Weft. Terminal mkdir hello-wred cd hello-wred python -m venv .venv . .venv/bin/activate python -m pip install 'wred[server]>=0.10' Give it some routes Save this as first_api.py . An async handler receives a Wred request. Returning a dict or list produces JSON with status 200. first_api.py Download from wred import App app = App() @app.route('/') async def hello(request): return {'hello': 'world'} @app.route('/api/health') async def health(request): return {'status': 'ok'} @app.route('/api/hello/{name}') async def greet(request, name): return {'hello': name, 'language': request.query.get('lang', 'en')} name is a string, passed by keyword. request.query is a synchronous cached property and returns strings too. Wred will not infer that a query parameter is an integer because you looked at it meaningfully. Methods default to GET. Fixed paths win over parameterized paths for the same method; trailing slashes are ignored. Unknown paths and unsupported methods return JSON 404. HEAD, OPTIONS, and 405 handling aren’t added automatically. Run it and ask it something Terminal weft first_api:app Leave the server running. In a second terminal: Terminal curl -sS http://127.0.0.1:8000/ # {\"hello\":\"world\"} curl -sS 'http://127.0.0.1:8000/api/hello/Ada?lang=en' # {\"hello\":\"Ada\",\"language\":\"en\"} The Python module is first_api ; the exported ASGI application is app . That’s what the colon means. We have now exhausted the mystery of first_api:app . Accept JSON without accepting absolutely anything Save the next example as validation.py , stop the first server, and run weft validation:app . This endpoint echoes a validated title; it does not store anything. validation.py Download import orjson from wred import App app = App() @app.route('/api/echo', methods=['POST']) async def echo(request): try: data = await request.json() except orjson.JSONDecodeError: return {'error': 'Invalid JSON'}, 'application/json', 400 if not isinstance(data, dict): return {'error': 'Send a JSON object'}, 'application/json', 400 title = data.get('title') if not isinstance(title, str) or not 1 <= len(title.strip()) <= 100: return {'error': 'Title must contain 1–100 characters'}, 'application/json', 400 return {'title': title.strip()} Terminal curl -i http://127.0.0.1:8000/api/echo \\ -H 'Content-Type: application/json' \\ -d '{\"title\":\"My first note\"}' The response body is {\"title\":\"My first note\"} . A JSON array, missing title, or invalid JSON gets 400. Schema validation is your application’s decision; a few explicit checks are perfectly respectable. Choose the response you mean Return value Result {\"ok\": True} JSON, 200 {\"id\": \"n1\"}, \"application/json\", 201 JSON, 201 b\"\", \"text/plain\", 303, [(\"Location\", \"/\")] Redirect b\"\", \"application/json\", 204 Empty response A callable ASGI response The response object sends its own messages Custom tuple bodies can be bytes, dicts, or lists. Dicts and lists use JSON even if you give them another content type. Exceptions propagate to the server or your middleware; Wred doesn’t convert them into a reassuringly vague success. ← Build something with Wred. Run the notebook → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"frontend.html","title":"Serve a frontend and other responses","headings":[{"anchor":"same-origin","title":"Start with one origin"},{"anchor":"router","title":"Use Starlette for the outer routing"},{"anchor":"servestatic","title":"Or use the notebook’s ServeStatic wrapper"},{"anchor":"responses","title":"Return a file or stream"},{"anchor":"browser","title":"Borrow the notebook’s fetch habits"}],"text":"Serve a frontend and other responses Your JavaScript needs somewhere to live. Your API needs somewhere to return JSON. They can share an origin without sharing an existential crisis. On this page Start with one origin Use Starlette for the outer routing Or use the notebook’s ServeStatic wrapper Return a file or stream Borrow the notebook’s fetch habits Start with one origin The notebook serves its frontend and API together, so browser code can use fetch(\"/api/notes\") and same-origin cookies. A frontend on port 5173 and an API on port 8000 are different origins, even on the same machine. During development, use your frontend tool’s API proxy or configure a deliberately cross-origin API. The notebook’s auth checks expect the same-origin arrangement. Opening an HTML file through file:// is not an ingenious CORS workaround. Use Starlette for the outer routing Install starlette . Save this as frontend_app.py and create a sibling frontend/ directory containing the HTML below. Run weft frontend_app:app , then open http://127.0.0.1:8000/ . frontend_app.py Download from pathlib import Path from starlette.applications import Starlette from starlette.routing import Mount from starlette.staticfiles import StaticFiles from wred import App api = App() @api.route('/hello') async def hello(request): return {'hello': 'world'} app = Starlette(routes=[ Mount('/api', app=api), Mount('/', app=StaticFiles(directory=Path(__file__).with_name('frontend'), html=True)), ]) frontend/index.html Download <!doctype html> <html lang=\"en\"> <head> <meta charset=\"utf-8\"> <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> <title>Wred frontend example</title> </head> <body> <h1>Hello from Wred</h1> <p id=\"result\" role=\"status\">Loading…</p> <script> async function load() { const result = document.getElementById('result'); try { const response = await fetch('/api/hello'); if (!response.ok) throw new Error(`HTTP ${response.status}`); result.textContent = JSON.stringify(await response.json()); } catch (error) { result.textContent = `Could not load the API: ${error.message}`; } } load(); </script> </body> </html> The outer Starlette router owns composition and lifespan. Wred registers /hello ; the mount makes it /api/hello . Wred respects the ASGI root path. The final / mount catches static paths, so keep it last. StaticFiles handles the directory. Its HTML mode can serve an index; it does not automatically make every unknown URL an SPA route. Configure an explicit fallback if your client router needs one. Or use the notebook’s ServeStatic wrapper The notebook already wraps its Wred app with ServeStaticASGI in example_app/app.py . You don’t need to add the Starlette router above to run that example. Both approaches delegate static files to a library built for them. For a built frontend, Caddy or Nginx can serve the build directory and proxy /api/ to Python. Preserve the prefix when the Wred routes already include /api . Strip it only when the app is intentionally mounted with matching root-path handling. Random slash removal is a surprisingly effective 404 generator. Return a file or stream Wred also accepts callable ASGI response objects. This independent sample uses Starlette’s FileResponse and StreamingResponse . Save it as responses_app.py and place the sample notes.json in a sibling data/ directory. responses_app.py Download import asyncio from pathlib import Path from starlette.responses import FileResponse, StreamingResponse from wred import App app = App() @app.route('/download') async def download(request): return FileResponse( Path(__file__).with_name('data') / 'notes.json', filename='notes.json', media_type='application/json', ) @app.route('/events') async def events(request): async def messages(): for number in range(5): yield f'data: {number}\\n\\n' await asyncio.sleep(1) return StreamingResponse( messages(), media_type='text/event-stream', headers={'Cache-Control': 'no-cache'}, ) Terminal weft responses_app:app From another terminal: Terminal curl -i http://127.0.0.1:8000/download curl -N http://127.0.0.1:8000/events FileResponse streams the known file and sets download headers. The events route sends five SSE messages. curl -N disables client output buffering. Authenticate and validate before starting a response; once headers are sent, an exception can’t retroactively turn it into a JSON 400. See the Starlette response reference . Keep large downloads and long-lived streams in mind when choosing proxy buffering and timeout settings. Borrow the notebook’s fetch habits example_app/static/app.js has one fetch helper for JSON, auth headers, refresh, errors, and empty 204 responses. It checks response.ok : fetch resolves normally for HTTP 400 and 500, which is considerate of HTTP and occasionally inconsiderate of your expectations. Render user text with textContent , keep credentials out of URLs, and handle empty responses before trying to parse JSON. The notebook also tracks request sequences so a slow search result cannot overwrite a newer one. ← Write a little ASGI middleware Put Caddy in front → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"index.html","title":"Wred documentation","headings":[{"anchor":"reference","title":"Look something up"},{"anchor":"what-you-build","title":"The app we’re actually building"},{"anchor":"before-starting","title":"Bring these things"},{"anchor":"chapters","title":"Work through the tutorial"},{"anchor":"using-examples","title":"Copy the bits you need"}],"text":"Wred documentation Build an app, look up an API, or find out which component owns the configuration. A tutorial and reference for a framework that still fits in one Python file. These docs cover Wred 0.10, where request.query is a cached property. Upgrading from 0.9? Remove the parentheses; see migration notes . Look something up Following a lesson is useful. Finding the exact method signature without rereading a lesson is also useful. We have bravely decided to provide both. Installation & quick start — Python requirements, extras, servers, and editable installs. API reference — App, routes, Request, response formats, and ASGI helpers. Configuration & architecture — settings ownership and the notebook’s environment variables. Errors & troubleshooting — exceptions, HTTP statuses, and debugging. Frequently asked questions — the usual surprises, catalogued. Deployment boundaries — body limits, proxy trust, credentials, and input handling. Development & contributing — checks, source changes, docs, and bug reports. Release notes & upgrading — the 0.10 query-property change and the optional server extra. The search box looks through these pages and the tutorial. Everything runs locally in the browser; no search account, index service, or ritual sacrifice required. The app we’re actually building The repository includes a notebook with accounts, private notes, search, pagination, JSON export, a browser interface, and MongoDB storage. We’ll run it, follow a request through its files, then explore the bits you might want to borrow for your own app. Wred handles HTTP routes and responses. Your application chooses the database, authentication, middleware, and frontend. It’s a small framework with a very healthy relationship with the word “elsewhere.” Browser → Caddy or Nginx → ASGI server → Middleware → Wred + your code The chapters move from local development to deployment. The middleware and frontend recipes are independent examples; they don’t silently rewrite the notebook app while you’re looking away. Bring these things Python 3.11 or newer, a terminal, and enough JavaScript to recognize fetch() . A running MongoDB instance for the notebook chapters. The first API and most middleware examples work without it. A Linux or macOS shell for the commands shown. On Windows, use WSL or adapt virtualenv activation to PowerShell. Examples use Weft through wred[server] . Wred itself only requires orjson . Starlette, PyMongo, ServeStatic, and the other extras belong to the examples that use them. Work through the tutorial 1. Your first API Routes, requests, validation, JSON, and response tuples. 2. Run the notebook Start MongoDB, run the example, and follow the source files. 3. Notes, accounts, and ownership Trace note creation, bearer tokens, refresh cookies, and ownership. 4. Borrow Starlette’s middleware Add CORS, host checks, compression, errors, and signed sessions. 5. Write a little ASGI middleware Build request context and reuse proxy-header middleware. 6. Serve a frontend and other responses Compose static files, API mounts, downloads, and event streams. 7. Put Caddy in front Deploy with automatic HTTPS and a private Python backend. 8. Put Nginx in front Configure TLS, forwarding, body limits, and throttling. 9. Keep it running Supervise workers, manage configuration, and diagnose failures. Copy the bits you need Python and configuration examples have download links. Save each example with the filename shown. Run the standalone Python examples from the directory you saved them in; stop one server with Ctrl+C before starting another on port 8000. From a checkout of this repository, you can also run the standalone examples directly: Terminal python -m venv .venv . .venv/bin/activate python -m pip install -e '.[server]' starlette itsdangerous cd docs/tutorial/examples weft first_api:app The notebook chapters use the repository root instead. Each chapter says which directory and dependencies it expects, because scavenger hunts are a terrible package manager. Your first API → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"installation.html","title":"Installation & quick start","headings":[{"anchor":"requirements","title":"Requirements"},{"anchor":"virtualenv","title":"Create an isolated environment"},{"anchor":"quick-start","title":"Save an app and run it"},{"anchor":"extras","title":"Choose your dependencies"},{"anchor":"another-server","title":"Use another ASGI server"},{"anchor":"checkout","title":"Work from a checkout"},{"anchor":"versions","title":"Check what you installed"}],"text":"Installation & quick start Pick a Python, make a virtualenv, and install the parts you need. Your dependency tree is allowed to have sunlight reaching the bottom. These docs cover Wred 0.10, where request.query is a cached property. Upgrading from 0.9? Remove the parentheses; see migration notes . On this page Requirements Create an isolated environment Save an app and run it Choose your dependencies Use another ASGI server Work from a checkout Check what you installed Requirements Wred requires Python 3.11 or newer and orjson . It is an ASGI application framework; an ASGI server runs it. The optional server extra installs weft-server , whose executable is weft . Wred is packaged as a single Python module. There is no project generator, settings module, or Wred CLI. You can spend the time saved choosing a name for your app. That should comfortably consume the afternoon. Create an isolated environment Terminal mkdir hello-wred cd hello-wred python -m venv .venv . .venv/bin/activate python -m pip install 'wred[server]' Commands assume a POSIX shell and a python command pointing to Python 3.11+. Use python3 if that is how your system names it. In Windows PowerShell, activate with .venv\\Scripts\\Activate.ps1 . A virtualenv also avoids modifying an externally managed system Python. See the Python virtualenv guide and pip install reference . Save an app and run it app.py from wred import App app = App() @app.route('/') async def hello(request): return {'hello': 'world'} Terminal weft app:app Leave that process running. In a second terminal: Terminal curl -sS http://127.0.0.1:8000/ # {\"hello\":\"world\"} app:app means the app object in app.py . Continue with Your first API for routes and validation. Choose your dependencies Install What it adds python -m pip install wred Wred and orjson. Bring an ASGI server yourself. python -m pip install \"wred[server]\" Wred, orjson, and the Weft server. python -m pip install starlette The optional middleware, routing, and response classes used in several chapters. python -m pip install itsdangerous Needed for the Starlette signed-cookie session example. python -m pip install pymongo servestatic The notebook example’s database client and static-file wrapper. These examples are separate application choices. Installing Starlette does not transform Wred’s request into a Starlette request, despite the excellent opportunities for confusion. Use another ASGI server You can install another ASGI server alongside the base package. For example, with Uvicorn: Terminal python -m pip install wred uvicorn uvicorn app:app --host 127.0.0.1 --port 8000 --lifespan off The bare Wred app handles HTTP only, so this command disables Uvicorn’s lifespan handling. The notebook wrapper implements lifespan and should run with it enabled. See your server’s own options for workers, reload, TLS, and forwarding. Work from a checkout Terminal git clone https://gitman.io/git/patx/wred cd wred python -m venv .venv . .venv/bin/activate python -m pip install -e '.[server]' pymongo servestatic Editable installation points Python at your checkout. The notebook is in example_app/ ; installing the Wred package alone does not install that application. Follow Run the notebook for MongoDB and startup. Check what you installed Terminal python -m pip show wred python -c 'import wred; print(wred.__file__)' The second command helps detect a local wred.py shadowing the installed module. Python imports the file it finds, not the file you had strong feelings about. See release notes and upgrading for the 0.9 server-extra change. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"nginx.html","title":"Put Nginx in front","headings":[{"anchor":"backend","title":"Use the same private backend"},{"anchor":"config","title":"Configure HTTPS and the proxy"},{"anchor":"reload","title":"Check the config before reloading"},{"anchor":"throttle","title":"Add a proxy rate limit when you need one"},{"anchor":"streams","title":"Let event streams reach the client"}],"text":"Put Nginx in front The other perfectly reasonable reverse proxy. It offers precise control and enough semicolons to make a JavaScript formatter feel underemployed. On this page Use the same private backend Configure HTTPS and the proxy Check the config before reloading Add a proxy rate limit when you need one Let event streams reach the client Use the same private backend Use this instead of the Caddy setup. The notebook listens on loopback; Nginx owns the public ports. Run from the repository root: Terminal # Run from the repository root with the notebook dependencies installed. weft example_app.asgi:app --host 127.0.0.1 --port 8000 \\ --lifespan on --forwarded-allow-ips 127.0.0.1 The app trusts forwarding information from 127.0.0.1 , not from arbitrary clients. Keep port 8000 private and forward the external scheme correctly. Configure HTTPS and the proxy Put these server blocks in a file included by Nginx’s http context. Replace the hostname and provision real certificate and key files at the configured paths first. The Nginx HTTPS guide covers TLS configuration. Unlike the Caddy example, these certificate paths do not cause certificates to appear by force of optimism. nginx.conf Download # Include these server blocks inside nginx's http context. # Replace the domain and certificate paths before enabling. server { listen 80; server_name notes.example.com; return 308 https://$host$request_uri; } server { listen 443 ssl; server_name notes.example.com; ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem; client_max_body_size 1m; client_body_timeout 15s; location / { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Connection \"\"; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_read_timeout 60s; proxy_send_timeout 60s; } } proxy_pass has no URI suffix, so it preserves paths such as /api/notes . The forwarded client header is overwritten with the peer address for this single-proxy setup. Host and scheme match the public request. client_max_body_size limits uploads before the application buffers them. The timeout values are starting points; choose values that match your workload. Check the config before reloading Terminal sudo nginx -t sudo systemctl reload nginx curl -i https://notes.example.com/api/health Check login through the public domain too. A 403 from the notebook’s origin check often means the forwarded scheme or Host is wrong. Don’t fix it by deleting the origin check and declaring victory. Add a proxy rate limit when you need one Nginx’s request limiter can share its zone across Nginx workers on one host. For an optional general API limit, add this declaration once in the http context: Nginx · http context limit_req_zone $binary_remote_addr zone=wred_clients:10m rate=10r/s; Then add the following inside the existing location / block, alongside the proxy directives: Nginx · location context limit_req zone=wred_clients burst=20 nodelay; limit_req_status 429; These numbers are illustrative, not a capacity plan delivered by a stranger’s HTML page. This general limit covers static files too; split locations for API-only or tighter login limits and preserve the required proxy settings. See the request-limit module . Let event streams reach the client For the independent events example , buffering can hide incremental output. In a dedicated location /events , include your normal proxy settings and add: Nginx · streaming location additions proxy_buffering off; proxy_read_timeout 300s; Choose a timeout longer than the interval between stream messages. Don’t disable buffering globally just because one endpoint needs it; ordinary responses have done nothing to deserve being dragged into this. ← Put Caddy in front Keep it running → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"notebook.html","title":"Run the notebook","headings":[{"anchor":"checkout","title":"Run from the repository root"},{"anchor":"mongodb","title":"Give it a database"},{"anchor":"try-it","title":"Take it for a spin"},{"anchor":"source-map","title":"Follow the files"},{"anchor":"lifespan","title":"The wrapper does the housekeeping"},{"anchor":"static","title":"How the browser gets its files"}],"text":"Run the notebook A real little app: accounts, a notes editor, MongoDB, and just enough moving parts to explain why people invent architecture diagrams. On this page Run from the repository root Give it a database Take it for a spin Follow the files The wrapper does the housekeeping How the browser gets its files Run from the repository root The notebook source lives in example_app/ ; it is not installed by pip install wred . Clone the repository, or use your existing checkout. Terminal git clone https://gitman.io/git/patx/wred cd wred python -m venv .venv . .venv/bin/activate python -m pip install -e '.[server]' pymongo servestatic Use a current PyMongo release that provides AsyncMongoClient . The app imports the native async client, not Motor and not a synchronous client wearing an async function as a disguise. Give it a database Have MongoDB listening on 127.0.0.1:27017 . An existing local instance is fine. If you already use Docker, this starts a local development database with a persistent volume. The published port binds to loopback: Terminal docker run -d --name wred-mongo \\ -p 127.0.0.1:27017:27017 \\ -v wred-mongo-data:/data/db mongo:8 This local example has no database credentials and binds the published port to loopback. Use your own authenticated URI for a remote database. Don’t promote the development database to the internet because the command was conveniently short. Terminal export MONGODB_URI='mongodb://127.0.0.1:27017' export MONGODB_DATABASE='wred_notebook' weft example_app.asgi:app --lifespan on Open http://127.0.0.1:8000/ . These environment values are the defaults; setting them explicitly makes the configuration visible. --lifespan on asks the server to require the example’s startup and shutdown protocol. Take it for a spin Create an account. Usernames accept 1–64 characters; passwords accept 15–128 characters. Write two notes, pin one, search for a word, and try the pagination controls. Export your notes. Log out and log back in to see the stored data. Click the rate-limit demonstration. A fresh client gets three accepted requests in ten seconds; the extra requests return 429. For a quick HTTP check, use a second terminal: Terminal curl -i http://127.0.0.1:8000/api/health # HTTP 200, {\"status\":\"ok\"} curl -i http://127.0.0.1:8000/api/notes # HTTP 401: notes require a bearer token Follow the files File What to read for example_app/asgi.py Exports the server entry point: app = create_app() . example_app/app.py Builds Wred, registers routes, wraps static files, and owns lifespan. example_app/storage.py Async MongoDB operations, indexes, note ownership filters, and session persistence. example_app/auth.py Password checks, bearer tokens, refresh cookies, and authentication decorators. example_app/notes.py Validation, CRUD, filtering, pagination, and export. example_app/pages.py Health, redirect, and rate-limit demonstration routes. example_app/rate_limit.py The application-owned sliding-window decorator. example_app/static/app.js Fetch calls, refresh coordination, editor state, and rendering. Open these files in your checkout alongside the tutorial. The source repository has the same layout. The constructor in example_app/app.py assembles the HTTP app: example_app/app.py · constructor excerpt self.http = App() rate_limit = RateLimiter() pages.register_routes(self.http, rate_limit) auth.register_routes(self.http, self.database, rate_limit) notes.register_routes(self.http, self.database) self.http = ServeStaticASGI( self.http, root=str(STATIC_ROOT), index_file=True, autorefresh=True, max_age=0, ) self.http.add_files(str(STATIC_ROOT), prefix='static/') The wrapper does the housekeeping NotebookApp handles lifespan.startup by initializing the database and creating indexes. It reports startup failure if that fails. Shutdown closes the client. Ordinary requests go to its self.http application. Wred itself handles HTTP only. Sending lifespan directly to a bare Wred app raises RuntimeError ; the notebook wrapper is the part that implements it. This is deliberate composition, which is a fancy way of saying the code that opens the database also closes it. create_app(database=...) also accepts an application-owned database object with the methods used by the routes, including async initialize() and close() . Each app has independent rate-limit state. example_app/app.py · lifespan excerpt async def __call__(self, scope, receive, send): if scope['type'] != 'lifespan': return await self.http(scope, receive, send) while True: message = await receive() if message['type'] == 'lifespan.startup': try: await self.database.initialize() except Exception as error: await self.database.close() await send({ 'type': 'lifespan.startup.failed', 'message': str(error), }) return await send({'type': 'lifespan.startup.complete'}) elif message['type'] == 'lifespan.shutdown': await self.database.close() await send({'type': 'lifespan.shutdown.complete'}) return How the browser gets its files The wrapper places ServeStaticASGI around Wred. It serves the static directory with an index file and also adds the /static/ asset prefix. Unmatched requests fall through to the API. The example enables autorefresh=True and max_age=0 for editing. For deployment, choose intentional cache settings or move built assets to your reverse proxy. The frontend chapter covers alternatives. ← Your first API Notes, accounts, and ownership → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"operations.html","title":"Keep it running","headings":[{"anchor":"service","title":"Run the notebook under systemd"},{"anchor":"environment","title":"Keep environment settings outside the checkout"},{"anchor":"workers","title":"Choose workers with the database in mind"},{"anchor":"debugging","title":"When it doesn’t work"},{"anchor":"next","title":"Make it your application"}],"text":"Keep it running The app works in your terminal. Now comes the traditional next step: making it work when your terminal is closed. On this page Run the notebook under systemd Keep environment settings outside the checkout Choose workers with the database in mind When it doesn’t work Make it your application Run the notebook under systemd This Linux example assumes the repository is deployed at /srv/wred , dependencies are installed in /srv/wred/.venv , and a dedicated wred user and group can read the application. Create those resources before enabling the service. Run behind either proxy from the previous chapters. Put this unit at /etc/systemd/system/wred.service . Its ExecStart uses Weft’s explicit loopback forwarding trust. wred.service Download [Unit] Description=Wred notebook After=network-online.target Wants=network-online.target [Service] Type=exec User=wred Group=wred WorkingDirectory=/srv/wred EnvironmentFile=/etc/wred/notebook.env Environment=PYTHONUNBUFFERED=1 ExecStart=/srv/wred/.venv/bin/weft example_app.asgi:app --host 127.0.0.1 --port 8000 --workers 2 --lifespan on --forwarded-allow-ips 127.0.0.1 Restart=on-failure RestartSec=3 TimeoutStopSec=45 NoNewPrivileges=true PrivateTmp=true [Install] WantedBy=multi-user.target Restart=on-failure restarts a failed process. Systemd supervises it; it doesn’t check whether your database credentials are good or your application is emotionally ready. See systemd.service . Keep environment settings outside the checkout Create /etc/wred/notebook.env with your database settings. The values below target the local development database; replace them for your deployment. Restrict access if the URI contains credentials. /etc/wred/notebook.env MONGODB_URI=mongodb://127.0.0.1:27017 MONGODB_DATABASE=wred_notebook Terminal sudo systemctl daemon-reload sudo systemctl enable --now wred sudo systemctl status wred sudo journalctl -u wred -f Keep production credentials out of Git and shell history. The service manager reads the environment file; this example does not require a dotenv package. Choose workers with the database in mind The unit starts two workers as an example. Each worker gets its own MongoDB client and connection pool. More workers mean more pools and separate in-memory rate-limit buckets. Benchmark your workload, including database calls and middleware, before multiplying workers because the CPU graph had room for more colors. Keep MongoDB reachable and authenticated appropriately, back up the data, and verify that you can restore it. A volume is persistence; it is not a backup with a more reassuring name. The notebook’s health endpoint returns a simple application response. It does not ping MongoDB on every request. Use startup checks, database monitoring, and a deliberately designed readiness check for your own deployment. When it doesn’t work Symptom Check first 502 from the proxy Is the backend running on the same host and port? Check the service log and request the backend’s health endpoint locally. Startup failure Can the service reach MongoDB? Did its virtualenv install PyMongo and ServeStatic? Is the working directory the repository root? 403 at login or refresh The custom header, same-origin browser request, public Host, and forwarded HTTPS scheme. 401 on note requests The access token, its expiry, and the login’s existence. The refresh cookie alone does not authorize CRUD routes. Static page instead of JSON A static catch-all or SPA fallback caught an API path. Put API routing first and inspect prefix stripping. Secure cookie missing over HTTPS The ASGI scheme is probably still HTTP. Fix trusted proxy handling. 429 changes between requests The notebook limiter is per worker. Use a shared limiter for a shared policy. SSE arrives all at once Client and proxy buffering. Use curl -N and inspect the streaming location. Different frontend port fails It is a different origin. Use a development proxy or design CORS and auth together. Make it your application Add routes in a module that exposes register_routes(app, ...) . Pass database and service objects explicitly, as the notebook does. Put shared HTTP behavior in middleware and resource lifecycle in the outer app. For blocking libraries, use an appropriate worker thread or replace them with an async client. Don’t start untracked tasks inside request handlers and call it a job queue. Durable work needs a durable queue and workers that own it. You now have routes, data, auth, a frontend, middleware, HTTPS, and a supervised process. Wred is still small. Your app was allowed to grow without filing a feature request. ← Put Nginx in front Back to the overview → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"releases.html","title":"Release notes & upgrading","headings":[{"anchor":"version-010","title":"0.10: query is a cached property"},{"anchor":"version-09","title":"0.9: the server is optional"},{"anchor":"normalization","title":"Why v0.09 corresponds to package version 0.9"},{"anchor":"upgrade","title":"Check an upgrade"}],"text":"Release notes & upgrading The changes that matter when updating an app. Version numbers are identifiers, even when Python packaging gives your leading zero a stern talking-to. On this page 0.10: query is a cached property 0.9: the server is optional Why v0.09 corresponds to package version 0.9 Check an upgrade 0.10: query is a cached property Request.query now uses cached_property , matching Request.headers . It parses on first access and returns the same cached dict on later accesses. Blank values, repeated-key handling, strict UTF-8 decoding, and body-reading behavior are unchanged. This is a breaking API change from published Wred 0.9: remove the call parentheses in handlers. No await is needed. The old call form tries to call a dict and raises TypeError , which is the dictionary's entirely reasonable objection to being treated as a function. Migration from Wred 0.9 # Wred 0.9 query = request.query() # Wred 0.10 query = request.query name = request.query.get('name', 'world') Upgrade with python -m pip install --upgrade \"wred[server]==0.10\" , or use wred==0.10 with your own ASGI server. Version 0.10 also adds this HTML tutorial, API reference, and browser-local documentation search. 0.9: the server is optional Wred 0.9 moves weft-server out of the required dependencies and into the server extra. The only required runtime dependency is orjson . The Python API remains imported from wred . Your setup What to install You run the weft command. python -m pip install \"wred[server]==0.9\" You already install another ASGI server. python -m pip install \"wred==0.9\" , plus that server in your application dependencies. You run the notebook from a checkout. Install the editable server extra plus PyMongo and ServeStatic, as shown in the notebook chapter . Record the extra in your dependency file if your app relies on Weft. Upgrading an existing environment may leave an already installed Weft package in place; a clean environment is the useful check for missing dependencies. Stale packages make excellent camouflage. Why v0.09 corresponds to package version 0.9 The v0.09 Git tag identifies the release whose source version was 0.09 . Flit normalizes that to package version 0.9 under Python’s version rules. The new v0.10 tag and source version 0.10 correspond to package version 0.10 . Use the package versions in dependency requirements. Python from importlib.metadata import version print(version('wred')) # Installed distribution metadata See the Python packaging version specification . These are version components, not decimal fractions. Check an upgrade Install your intended version and extras in a fresh virtualenv. Run your app’s checks with its actual server and middleware stack. Exercise startup, routing, body parsing, expected errors, and protected operations. Verify proxy scheme and cookie behavior if the deployment configuration changed. The 0.10 PyPI page provides distribution metadata. The repository history contains the source changes; this page does not invent summaries for older releases. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"security.html","title":"Deployment boundaries","headings":[{"anchor":"edge","title":"Bound request size and direct access"},{"anchor":"authentication","title":"Authentication and ownership are application rules"},{"anchor":"browser","title":"Keep browser credential rules coherent"},{"anchor":"data","title":"Validate data and render text safely"},{"anchor":"errors","title":"Keep diagnostics useful"},{"anchor":"reporting","title":"Report problems with enough context"}],"text":"Deployment boundaries Wred gives you HTTP primitives. You choose the protections around them. This page names those boundaries so they don’t become surprises with an incident number. On this page Bound request size and direct access Authentication and ownership are application rules Keep browser credential rules coherent Validate data and render text safely Keep diagnostics useful Report problems with enough context Bound request size and direct access request.body() buffers the entire body without a built-in size limit. Apply a limit in the ASGI server, middleware, or reverse proxy before the app reads it. The Caddy and Nginx examples include body limits. Bind a same-host backend to loopback and expose only the proxy. For separate hosts, use an appropriately restricted private connection. Trust forwarded headers only from the proxy peers you control. Authentication and ownership are application rules The notebook validates opaque bearer credentials and filters data changes by owner. Its custom request.user attribute comes from its decorator. An unrelated Wred application gets none of that automatically. Choose account recovery, credential expiry, revocation, and abuse controls for the application you are shipping. Read the notebook walkthrough before copying its login flow. A decorator name is not a security review, however reassuring its spelling. Keep browser credential rules coherent The notebook expects same-origin login and refresh requests with its custom header. Its refresh cookie is HttpOnly and SameSite=Strict, and gets Secure when the incoming ASGI scheme is HTTPS. Correct proxy scheme handling is therefore part of the login behavior. CORS controls browser cross-origin access. Enabling it alone does not remove the notebook’s same-origin checks or supply CSRF protection for another app. If you adapt the auth flow to another origin, revise the origin, cookie, credential, and CSRF behavior together. Starlette’s signed-cookie session recipe is separate: signing detects tampering but does not encrypt the contents. Store secrets on the server. Validate data and render text safely Validate JSON shape, string lengths, numbers, and allowed fields. Wred does not impose schemas. Keep ownership in authenticated server context rather than accepting it from an incoming JSON object. The notebook frontend uses textContent for user text. Avoid interpolating untrusted note contents into HTML. Keep credentials out of URLs, logs, screenshots, and committed environment files. Keep diagnostics useful Log unexpected failures at the server or middleware layer while returning intentional public error bodies. Disable public tracebacks in deployment. Preserve request IDs if you add them, and avoid logging passwords, bearer tokens, or cookie values. Check startup, login, a protected operation, and logout through the actual public domain. A successful backend health request proves considerably less than the whole application working. Report problems with enough context For ordinary bugs, see the reporting checklist . For a suspected vulnerability, use an appropriate private contact channel with the maintainer before posting exploit details publicly. These docs do not announce a separate disclosure program or response-time guarantee. Share the affected version and a minimal reproduction without production data or credentials. The project repository and its package metadata identify the maintainer. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"starlette-middleware.html","title":"Borrow Starlette’s middleware","headings":[{"anchor":"install","title":"Install what you use"},{"anchor":"stack","title":"Build an explicit stack"},{"anchor":"cors","title":"CORS belongs outside the error handler"},{"anchor":"other-layers","title":"Hosts, compression, and errors"},{"anchor":"sessions","title":"A separate signed-cookie session recipe"}],"text":"Borrow Starlette’s middleware ASGI middleware wraps an ASGI app. Wred is an ASGI app. No adapter ceremony, secret handshake, or framework conversion experience required. On this page Install what you use Build an explicit stack CORS belongs outside the error handler Hosts, compression, and errors A separate signed-cookie session recipe Install what you use Terminal python -m pip install 'wred[server]' starlette itsdangerous itsdangerous is needed for the session recipe below. Wred’s request is still a Wred request after wrapping; middleware-provided data is available in its ASGI scope . Build an explicit stack Save this as starlette_app.py and run weft starlette_app:app . Register routes on api ; export the completed middleware stack as app . starlette_app.py Download from starlette.middleware.cors import CORSMiddleware from starlette.middleware.errors import ServerErrorMiddleware from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.responses import JSONResponse from wred import App api = App() @api.route('/api/hello') async def hello(request): return {'hello': 'world'} async def server_error(request, exc): return JSONResponse({'error': 'Internal server error'}, status_code=500) # Requests enter the last wrapper first. app = GZipMiddleware(api, minimum_size=1000) app = TrustedHostMiddleware(app, allowed_hosts=['localhost', '127.0.0.1', 'api.example.com']) app = ServerErrorMiddleware(app, handler=server_error, debug=False) app = CORSMiddleware( app, allow_origins=['http://localhost:5173'], allow_methods=['GET', 'POST', 'PATCH', 'DELETE'], allow_headers=['Authorization', 'Content-Type'], expose_headers=['Location'], ) CORS → Server errors → Trusted host → Gzip → Wred The last wrapper receives the request first. Responses travel back through the layers. The wrapper accepting the request is the object your ASGI server must import. Exporting the inner api skips every layer, a very efficient way to misconfigure middleware. CORS belongs outside the error handler This sample allows a frontend at http://localhost:5173 . CORS is outermost so generated 500 responses can carry its headers. It also answers browser preflight requests before they reach Wred. For cookie credentials, set allow_credentials=True and explicit origins, methods, and headers. CORS grants browser access; it does not authenticate the caller. See Starlette’s CORS documentation and implementation . Terminal curl -i -X OPTIONS http://127.0.0.1:8000/api/hello \\ -H 'Origin: http://localhost:5173' \\ -H 'Access-Control-Request-Method: POST' \\ -H 'Access-Control-Request-Headers: Content-Type' Expect a successful preflight with access-control-allow-origin: http://localhost:5173 . The actual sample endpoint is GET-only; a successful preflight does not create a POST handler. The route list still has a job. The notebook intentionally requires same-origin login and refresh requests. Adding CORSMiddleware alone does not turn that auth flow into a cross-origin design. Keep its frontend on the same origin, or deliberately revise the origin checks, cookies, and CSRF model together. Hosts, compression, and errors TrustedHostMiddleware checks the Host header. It does not decide which proxy IPs to trust. Gzip compresses eligible responses above the configured threshold. Our tiny hello JSON is below 1,000 bytes, so it remains tiny hello JSON. ServerErrorMiddleware supplies a JSON 500 response while allowing the exception to be logged. Debug mode stays off here; a public traceback is a surprisingly detailed résumé for your server. See the middleware reference . If your reverse proxy handles compression, choose one compression layer. Configure HTTPS redirects only after the app receives the correct forwarded scheme; otherwise you may build a redirect loop with excellent uptime. A separate signed-cookie session recipe This is an alternative example, not a replacement for the notebook’s database-backed logins. Save as session_app.py . session_app.py Download import os from starlette.middleware.sessions import SessionMiddleware from wred import App api = App() @api.route('/visits') async def visits(request): session = request.scope['session'] session['visits'] = session.get('visits', 0) + 1 return {'visits': session['visits']} app = SessionMiddleware( api, secret_key=os.environ['SESSION_SECRET'], same_site='lax', https_only=os.environ.get('COOKIE_SECURE', '1') == '1', ) Terminal export SESSION_SECRET=\"$(python -c 'import secrets; print(secrets.token_hex(32))')\" # Local HTTP only. Leave COOKIE_SECURE unset for HTTPS deployments. COOKIE_SECURE=0 weft session_app:app In a second terminal: Terminal curl -sS -c visits.cookies http://127.0.0.1:8000/visits curl -sS -b visits.cookies -c visits.cookies http://127.0.0.1:8000/visits # {\"visits\":2} Keep the secret stable across workers and restarts. Session data is signed, not encrypted: store a harmless preference or counter, not a password. Wred reads it through request.scope[\"session\"] . There is no request.session property waiting to be discovered. See Starlette’s session implementation . ← Notes, accounts, and ownership Write a little ASGI middleware → Written for Wred 0.10. See release notes for changes and migration guidance."}];
+const DOCS_SEARCH_INDEX = [{"url":"api-reference.html","title":"API reference","headings":[{"anchor":"imports","title":"Imports and scope"},{"anchor":"app","title":"App()"},{"anchor":"route","title":"App.route(path, methods=(\"GET\",))"},{"anchor":"mounted-paths","title":"Mounted paths and root_path"},{"anchor":"request","title":"Request(scope, receive, send)"},{"anchor":"headers","title":"request.headers → dict[str, str]"},{"anchor":"query","title":"request.query → dict[str, str]"},{"anchor":"body","title":"await request.body() → bytes"},{"anchor":"json","title":"await request.json() → Any"},{"anchor":"responses","title":"Handler return values"},{"anchor":"low-level","title":"Low-level App helpers"},{"anchor":"send-response","title":"await app.send_response(send, response, status=200) → None"},{"anchor":"match-path","title":"app.match_path(pattern, parts) → dict[str, str] | None"}],"text":"API reference The signatures, return values, and edge cases in one place. The API is small enough that this page does not require its own executive summary. These docs cover Wred 0.10, where request.query is a cached property. Upgrading from 0.9? Remove the parentheses; see migration notes . On this page Imports and scope App() App.route(path, methods=(\"GET\",)) Mounted paths and root_path Request(scope, receive, send) request.headers → dict[str, str] request.query → dict[str, str] await request.body() → bytes await request.json() → Any Handler return values Low-level App helpers Imports and scope Python from wred import App, Request This reference describes the implementation in wred.py for Wred 0.10. Handlers normally use App and the request they receive. The low-level dispatch helpers are documented at the end for readers working directly with ASGI. Related guides: first API , errors and troubleshooting , ASGI middleware . App() Create an HTTP-only ASGI application. The constructor takes no configuration arguments. Python app = App() Export the instance, or a middleware wrapper around it, to your server. The ASGI entry point is await app(scope, receive, send) ; its return value is None . It creates a Request, finds a handler, awaits the handler, and sends the response. Scope types other than http raise RuntimeError before receiving or sending messages. Use an outer application for lifespan, WebSockets, or other protocol handling. See the ASGI specification for the callable interface. App.route(path, methods=(\"GET\",)) Python @app.route('/users/{user_id}', methods=['GET', 'PATCH']) async def user(request, user_id): return {'id': user_id, 'method': request.method} Argument Meaning path: str A route such as /users or /users/{user_id} . Use a leading slash and valid Python parameter names. methods: list[str] | tuple[str, ...] Defaults to (\"GET\",) . Registration uppercases each method name. Decorated handler An async callable accepting the request and any path parameters as keyword arguments. The decorator returns the original handler. Parameters occupy a whole path segment and arrive as strings. Convert and validate them in the handler. There are no typed converters or catch-all path parameters. Trailing slashes are ignored at registration and dispatch. /users and /users/ reach the same route without a redirect. A fixed route wins when it has a handler for the incoming method. If it does not, a matching dynamic route with that method may handle the request. Dynamic routes are tried in registration order. Keep overlapping patterns intentional. Registering the same path and method again replaces that handler. Other registered methods remain. No matching path/method returns {\"error\":\"Not Found\"} with 404. HEAD, OPTIONS, redirects, and 405 responses are not synthesized. Routes are case-sensitive. Wred does not implement named routes, URL generation, subrouters, or an application mount() method. Use an outer ASGI router for composition. Mounted paths and root_path When the incoming path starts with the scope’s root_path on a segment boundary, Wred removes that prefix for route matching. The original scope and request.path retain the incoming path. Registered route Incoming scope Result /users path=\"/api/users\", root_path=\"/api\" Matches /users ; request.path remains /api/users . /users/{user_id} path=\"/api/users/42\", root_path=\"/api\" Passes user_id=\"42\" . The prefix and the server/router configuration must agree. See the Starlette mount example . Arbitrarily trimming slashes is not a deployment strategy, although it is a traditional source of one. Request(scope, receive, send) Wred creates this object for each HTTP request. Direct construction is mainly useful when working at the ASGI boundary. The constructor stores the supplied scope and callables; it does not read the body. Attribute Value request.path: str The original scope[\"path\"] , without the query string. It is not rewritten to the mount-relative route. request.method: str The incoming scope[\"method\"] . request.scope: dict The supplied ASGI scope, including client, scheme, root_path, and any middleware state. request.receive The ASGI receive callable. Consuming events yourself affects subsequent body reads. request.send The ASGI send callable. Normally let the return-value handling or a callable response send the response. Wred has no built-in request.user , request.session , request.cookies , request.form() , or request.is_disconnected() interface. Middleware or your own decorators can add context. The notebook’s request.user is added by its authentication decorator. request.headers → dict[str, str] A property, not a coroutine. On first access it decodes ASGI header bytes with Latin-1, lowercases names, and caches the resulting dict. Repeated Cookie fields are joined with ; ; repeated headers with other names keep their last value. Inside a handler authorization = request.headers.get('authorization', '') content_type = request.headers.get('content-type', '') This is a convenience view, not a multi-value header container. Use request.scope.get(\"headers\", []) for the original byte pairs when duplicate fields matter. Changing the cached dict does not rewrite those original ASGI header pairs. request.query → dict[str, str] A cached_property , like headers . Parse the query string on first attribute access and cache the resulting dict. Blank values are preserved; repeated keys keep the last value. The parser strictly decodes UTF-8, including percent-encoded values. Invalid UTF-8 raises UnicodeDecodeError . Inside a handler # /search?q=first&q=last&empty= query = request.query # {'q': 'last', 'empty': ''} No await is needed. Results are strings. Apply your own numeric ranges, field-count limits, and repeated-key policy. Repeated accesses return the cached dict, so mutate a copy if you need a separate working view. await request.body() → bytes Read successive http.request messages until more_body is false, join their bytes, and cache the complete body. Later calls return the cached bytes. Inside a handler body = await request.body() The body is buffered in memory. Wred imposes no body-size limit; configure a limit at the server, proxy, or a suitable middleware layer. An incoming event other than http.request , including http.disconnect , raises ConnectionError . await request.json() → Any Read the cached body and parse it with orjson.loads() . An empty body returns {} . The result may be an object, array, string, number, boolean, or None ; Wred does not enforce an object schema. Inside a handler data = await request.json() if not isinstance(data, dict): return {'error': 'Send a JSON object'}, 'application/json', 400 The body is cached; the parsed JSON value is not. Each json() call parses those bytes again. Malformed JSON, including invalid UTF-8 JSON input, raises orjson.JSONDecodeError . Parsing does not depend on the Content-Type header. See orjson’s reference for its supported JSON behavior. Handler return values Return Body / content type / status dict or list Serialized by orjson; application/json ; 200. bytes Sent directly; application/octet-stream ; 200. (body, content_type, status) Explicit media type and status. A dict/list body is JSON-encoded while preserving the supplied content type. (body, content_type, status, headers) As above, with an iterable of string header pairs. Callable ASGI response Wred awaits response(scope, receive, send) ; the response owns the outgoing messages. Alternative returns inside handlers # JSON creation response with a Location header. return {'id': 'n1'}, 'application/json', 201, [('Location', '/notes/n1')] # Empty deletion response. return b'', 'application/json', 204 # Text: encode it yourself. return 'Hello'.encode('utf-8'), 'text/plain; charset=utf-8', 200 Tuple bodies must be bytes, dicts, or lists. Bare strings, integers, booleans, and None are unsupported response bodies. If you need a JSON scalar, use a callable response such as Starlette’s JSONResponse . Use the tuple’s media type field to set Content-Type. Additional headers are lowercased and encoded with ASCII names and Latin-1 values. A list of pairs preserves repeated Set-Cookie headers. Wred sends one response-start message followed by one body message for its own return-value handling; it does not add a Content-Length header itself. Your ASGI server handles HTTP transport details. A callable response can stream instead; see files and SSE . Low-level App helpers Most handlers should return a response value. The methods below support dispatch and explicit ASGI integrations; use them when you actually own the message flow. Sending a response yourself and then returning another one is not a two-for-one offer. await app.send_response(send, response, status=200) → None Send a supported body or response tuple through an ASGI send callable. A tuple’s status takes precedence over the status argument. This helper handles serialization and the two outgoing messages; it does not invoke callable response objects. Normal app dispatch invokes callable responses separately. app.match_path(pattern, parts) → dict[str, str] | None Compare a tuple of pattern segments with a list of incoming segments. Return string parameters when segment counts and fixed segments match, otherwise None . Inputs are already split; this helper does not normalize trailing slashes or remove a root path. Python app.match_path(('', 'users', '{user_id}'), ['', 'users', '42']) # {'user_id': '42'} app.routes and app.pattern_routes hold dispatch tables. Register handlers with route() rather than editing these tables directly. ← Documentation overview Errors & troubleshooting → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"asgi-middleware.html","title":"Write a little ASGI middleware","headings":[{"anchor":"contract","title":"Three arguments, two directions"},{"anchor":"context","title":"Add request context and timing"},{"anchor":"rules","title":"Keep the wrapper well behaved"},{"anchor":"proxy","title":"Use Uvicorn’s proxy-header middleware with another server"},{"anchor":"auth-context","title":"Authentication middleware can use the same scope"}],"text":"Write a little ASGI middleware Sometimes you need a request ID, a timing header, or a place to attach context. This does not require founding another framework. On this page Three arguments, two directions Add request context and timing Keep the wrapper well behaved Use Uvicorn’s proxy-header middleware with another server Authentication middleware can use the same scope Three arguments, two directions An ASGI app is called with (scope, receive, send) . The scope describes the connection. receive() supplies request events; send() emits response events. Middleware can wrap either callable and then delegate to the inner app. This example assigns a request ID, makes it available to handlers, and adds response headers when http.response.start is sent. It measures time until response headers, not the complete download time. Names are useful when they mean things. Add request context and timing Save as context_app.py , then run weft context_app:app . context_app.py Download from time import perf_counter from uuid import uuid4 from wred import App class RequestContext: def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope['type'] != 'http': return await self.app(scope, receive, send) request_id = uuid4().hex started = perf_counter() scope = {**scope, 'state': {**scope.get('state', {}), 'request_id': request_id}} async def send_with_context(message): if message['type'] == 'http.response.start': elapsed_ms = (perf_counter() - started) * 1000 message = { **message, 'headers': [ *message.get('headers', []), (b'x-request-id', request_id.encode('ascii')), (b'server-timing', f'app;dur={elapsed_ms:.2f}'.encode('ascii')), ], } await send(message) await self.app(scope, receive, send_with_context) api = App() @api.route('/api/hello') async def hello(request): return {'request_id': request.scope['state']['request_id']} app = RequestContext(api) Terminal curl -i http://127.0.0.1:8000/api/hello The body’s request_id matches X-Request-ID . Server-Timing reports the elapsed milliseconds up to response start. Each request keeps its own variables inside __call__ ; shared instance attributes would invite concurrent requests to exchange identities. Keep the wrapper well behaved Pass through protocols you don’t handle. That lets a lifespan-aware outer app keep doing its job. Copy the scope when adding request-local data. Preserve existing state entries. Preserve existing response headers, including repeated Set-Cookie fields. If you modify the response body, update or remove Content-Length as appropriate. Don’t consume the request body unless you also arrange to replay it to the inner app. Our wrapper leaves the body alone. It also passes lifespan through rather than implementing it; wrapping a bare Wred app still leaves you with an HTTP-only app. The pure ASGI middleware guide describes this pattern. Middleware can also come from other packages: the interoperability is in the ASGI call, not in matching logos. Use Uvicorn’s proxy-header middleware with another server You can reuse Uvicorn’s ProxyHeadersMiddleware while serving with Weft. Install uvicorn in addition to the notebook’s dependencies, save this as proxy_app.py at the repository root, and run weft proxy_app:app --host 127.0.0.1 --lifespan on . proxy_app.py Download from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from example_app.app import create_app # Use only behind a trusted proxy connecting from IPv4 loopback. app = ProxyHeadersMiddleware(create_app(), trusted_hosts=['127.0.0.1']) It accepts forwarded scheme and client information only from the listed proxy peers. For this example, the reverse proxy must connect over IPv4 loopback. Keep the backend private; a client that reaches it directly from a trusted address can supply those headers too. This is an alternative to Weft’s --forwarded-allow-ips option used in the deployment chapters. Pick one owner for proxy-header processing. See the Uvicorn implementation . Authentication middleware can use the same scope Starlette’s authentication middleware places authentication information in scope[\"user\"] and scope[\"auth\"] . A Wred handler can read those keys directly. The middleware’s authentication backend and your authorization rules remain application choices. The notebook uses its own @protected decorator instead. That decorator adds request.user itself. Don’t assume the property exists in unrelated Wred apps, or that wrapping with authentication middleware automatically protects every route. ← Borrow Starlette’s middleware Serve a frontend and other responses → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"caddy.html","title":"Put Caddy in front","headings":[{"anchor":"backend","title":"Keep the Python listener private"},{"anchor":"caddyfile","title":"Give your domain a Caddyfile"},{"anchor":"validate","title":"Validate, reload, and check"},{"anchor":"local","title":"Try the proxy locally"},{"anchor":"boundaries","title":"Keep the responsibilities obvious"}],"text":"Put Caddy in front HTTPS, a reverse proxy, and a configuration file you can read before your coffee gets cold. Caddy is annoyingly good at this. On this page Keep the Python listener private Give your domain a Caddyfile Validate, reload, and check Try the proxy locally Keep the responsibilities obvious Keep the Python listener private These deployment chapters use the notebook at the site root, including its static files. Use a host with Caddy installed and your app dependencies in a virtualenv. First run the backend on IPv4 loopback: Terminal # Run from the repository root with the notebook dependencies installed. weft example_app.asgi:app --host 127.0.0.1 --port 8000 \\ --lifespan on --forwarded-allow-ips 127.0.0.1 Accept forwarded headers only from the loopback proxy. That gives the notebook the external HTTPS scheme for cookie flags and same-origin checks, and the actual client address for its rate limiter. Use your supervisor to keep this process running; the operations chapter includes systemd. Give your domain a Caddyfile Replace notes.example.com with your domain. Point its DNS records at this host and make ports 80 and 443 reachable. Keep Caddy’s certificate storage writable and persistent. With those prerequisites, automatic HTTPS handles certificate issuance, renewal, and HTTP redirects. Caddyfile Download notes.example.com { request_body { max_size 1MB } encode zstd gzip reverse_proxy 127.0.0.1:8000 } request_body caps uploads at about one megabyte. reverse_proxy preserves the request path and forwards the Host and proxy headers. The notebook still sees /api/notes , which is fortunate because that’s where we put it. Validate, reload, and check Save the site configuration in the Caddyfile used by your installation, commonly /etc/caddy/Caddyfile . Validate it before applying it: Terminal sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile sudo systemctl reload caddy curl -i https://notes.example.com/api/health Use the Caddy CLI reload workflow if your installation isn’t managed by systemd. Expect JSON 200 from health. Visit the site, log in, and check that the refresh cookie has Secure under HTTPS. A green health check cannot inspect your cookies on your behalf. Try the proxy locally For an HTTP-only local smoke check, replace the domain line with http://localhost:8080 , save as Caddyfile.local , and run: Terminal caddy run --config Caddyfile.local --adapter caddyfile Open http://localhost:8080/ . This explicitly disables automatic HTTPS for that local site. Use the domain configuration for public HTTPS. Keep the responsibilities obvious Caddy terminates TLS and limits request bodies. Weft runs the ASGI app. The notebook handles its database lifecycle and authorization. You still need a shared rate-limit strategy if you want one limit across workers; this Caddyfile does not add one. Choose whether compression belongs in Caddy or an application middleware layer. For streams, check proxy timeouts and disconnect behavior with a real client. For a separate frontend build, use explicit static and API routing while preserving the API prefix. ← Serve a frontend and other responses Put Nginx in front → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"configuration.html","title":"Configuration & architecture","headings":[{"anchor":"ownership","title":"Who configures what?"},{"anchor":"notebook-settings","title":"Notebook environment variables"},{"anchor":"recipe-settings","title":"Recipe-only settings"},{"anchor":"protocols","title":"Separate HTTP from lifespan"},{"anchor":"proxy-settings","title":"Configure forwarding at one layer"}],"text":"Configuration & architecture There is no Wred settings object to memorize. Configure the component that actually owns the behavior. A small victory for both readability and your remaining memory. On this page Who configures what? Notebook environment variables Recipe-only settings Separate HTTP from lifespan Configure forwarding at one layer Who configures what? Concern Owner and documentation Routes, methods, validation, error responses Your Wred application. See the API reference . Host, port, workers, reload, transport timeouts Your ASGI server. Wred has no server configuration or CLI. CORS, signed sessions, request context The middleware stack. See Starlette middleware and custom ASGI middleware . Database connections and credentials Your application and database client. The notebook’s settings are below. HTTPS, body limits, proxy routing Your edge server. See Caddy or Nginx . Process supervision and environment files Your deployment system. See operations . App() takes no settings and Wred reads no environment variables. A variable called WRED_DEBUG will do exactly what an unread environment variable usually does: occupy a small amount of memory. Notebook environment variables Variable Default Read by MONGODB_URI mongodb://127.0.0.1:27017 example_app.app.create_app() MONGODB_DATABASE wred_notebook example_app.app.create_app() WRED_TEST_MONGODB_URI Unset: database integration tests are skipped. tests/test_notebook.py ; not the running app. Explicit mongodb_uri and database_name arguments override the two app environment values. Passing database=... overrides both by supplying an application-owned database object. A notebook entry point in the repository root from example_app.app import create_app app = create_app( mongodb_uri='mongodb://127.0.0.1:27017', database_name='my_notebook', ) The factory and its environment variables belong to the example application, not the Wred package. Its wrapper calls the database’s async initialize() and close() methods through lifespan. Recipe-only settings Setting Used by Meaning SESSION_SECRET examples/session_app.py Required signing secret, shared by all workers. No built-in default. COOKIE_SECURE examples/session_app.py Defaults to 1 . Set 0 only for the local HTTP recipe. These two settings configure the standalone Starlette session example. The notebook’s refresh cookie uses the incoming ASGI scheme instead. The similar-looking cookies are under different management. Separate HTTP from lifespan Wred’s inner app handles HTTP. The notebook’s outer app handles startup and shutdown; Starlette can also be the outer lifecycle owner when composing apps. Pass unhandled protocols through middleware to the component that owns them. A bare Wred app should use your server’s unsupported-lifespan detection or an explicit lifespan-off setting. The notebook should run with lifespan enabled so database startup failures are visible. --lifespan on is shown in its Weft commands. Register routes before serving traffic. One worker’s in-memory objects do not become shared state merely because another worker was launched with the same command. Configure forwarding at one layer The deployment examples bind Weft to 127.0.0.1 and use --forwarded-allow-ips 127.0.0.1 . Their proxies preserve the public Host and supply the scheme and client address. If you use the alternative Uvicorn middleware recipe, let that wrapper own this processing instead. Correct scheme information matters for Secure cookies, redirects, and the notebook’s same-origin checks. A proxy configuration is part of application behavior, not a decorative YAML-adjacent accessory. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"data-and-auth.html","title":"Notes, accounts, and ownership","headings":[{"anchor":"request","title":"Follow one create request"},{"anchor":"api","title":"The notebook’s API"},{"anchor":"tokens","title":"Access tokens and refresh cookies"},{"anchor":"curl","title":"Try the auth flow with curl"},{"anchor":"ownership","title":"Check ownership where the data changes"},{"anchor":"limits","title":"Know what is local to a worker"}],"text":"Notes, accounts, and ownership The notebook’s auth is application code you can read. A radical alternative to discovering your security model through decorators with five undocumented arguments. On this page Follow one create request The notebook’s API Access tokens and refresh cookies Try the auth flow with curl Check ownership where the data changes Know what is local to a worker Follow one create request The browser posts JSON to /api/notes with an Authorization: Bearer … header. The @protected(database) decorator checks the token before the handler runs and sets request.user and request.login_id . read_note() rejects unknown fields and validates the title, body, and pinned flag. The handler generates an ID and timestamps, adds the owner from the authenticated user, and calls database.create_note() . A successful create returns JSON 201 with a Location header. The owner comes from the authenticated user, never from a client-supplied owner field. Otherwise, congratulations: you have built an impersonation API. example_app/notes.py · create route excerpt @app.route('/api/notes', methods=['POST']) @protected(database) async def create_note(request): try: data = await read_note(request) except ValueError as error: return {'error': str(error)}, 'application/json', 400 note_id = token_urlsafe(9) now = datetime.now(UTC).isoformat(timespec='microseconds') note = { 'id': note_id, 'owner': request.user['id'], **data, 'created_at': now, 'updated_at': now, } await database.create_note(note) return ( note, 'application/json', 201, [('Location', f'/api/notes/{note_id}')], ) The notebook’s API Method and path Behavior POST /api/signup Create an account; 201, or 409 for an existing username. POST /api/login Return an access token and set the refresh cookie. POST /api/refresh Use the refresh cookie to issue another access token. GET /api/me Return the authenticated user. POST /api/logout Revoke the login and expire its refresh cookie. GET / POST /api/notes List private notes or create a note. GET / PUT / PATCH / DELETE /api/notes/{note_id} Read, replace editable fields, partially update, or delete a note. GET /api/notes/export Download the authenticated user’s notes as JSON. The fixed /api/notes/export route wins over /api/notes/{note_id} , even though it is registered afterward. Listing accepts q , pinned=true|false , limit from 1 to 50, and a nonnegative offset . Access tokens and refresh cookies Passwords use PBKDF2-HMAC-SHA256 with a random salt and 600,000 iterations. Hashing runs through asyncio.to_thread() so the event loop can keep serving other requests. Unknown usernames still perform a dummy hash. Access and refresh tokens are random opaque strings. MongoDB stores SHA-256 digests of those tokens. Access tokens last one hour; the login’s refresh window lasts seven days. The database checks expiry during authentication instead of waiting for MongoDB’s TTL cleanup to get around to it. The browser keeps the access token in memory. The refresh token lives in an HttpOnly, SameSite=Strict cookie scoped to /api , with Secure added when the ASGI scheme is HTTPS. Refresh reuses the cookie while issuing new access tokens; it does not rotate the refresh token on each call. MongoDB stores each login and its access credentials in one document. Logout deletes that document, revoking the login’s access tokens as well. The browser’s renewLogin() shares one refresh promise across concurrent requests and refuses to replay an old account’s pending request under a newly logged-in account. Try the auth flow with curl With the notebook running, use a separate shell. The password here is demonstration data. Use something you haven’t published in a tutorial for your real account. Terminal curl -sS http://127.0.0.1:8000/api/signup \\ -H 'Content-Type: application/json' \\ -d '{\"username\":\"ada\",\"password\":\"a sufficiently long example password\"}' curl -sS -c notebook.cookies http://127.0.0.1:8000/api/login \\ -H 'Content-Type: application/json' \\ -H 'X-Notebook-Request: 1' \\ -d '{\"username\":\"ada\",\"password\":\"a sufficiently long example password\"}' Copy the access_token from the login JSON into a shell variable, then create and list notes: Terminal read -r -s NOTEBOOK_TOKEN # Paste the access token, then press Enter. curl -i http://127.0.0.1:8000/api/notes \\ -H \"Authorization: Bearer $NOTEBOOK_TOKEN\" \\ -H 'Content-Type: application/json' \\ -d '{\"title\":\"Ship it\",\"body\":\"After reading the deployment chapter.\",\"pinned\":true}' curl -sS 'http://127.0.0.1:8000/api/notes?limit=5&offset=0' \\ -H \"Authorization: Bearer $NOTEBOOK_TOKEN\" curl -sS -b notebook.cookies -X POST http://127.0.0.1:8000/api/refresh \\ -H 'X-Notebook-Request: 1' Login and refresh require X-Notebook-Request: 1 . Browser requests also face the app’s same-origin checks. A cookie jar contains credentials; keep it private and remove it when you finish. Check ownership where the data changes List and export operations filter by owner. Reading another user’s existing note returns 403; a missing ID returns 404. Updates and deletes include both the note ID and owner in the MongoDB write filter, so an earlier ownership check isn’t the sole line of defense. The application limits titles to 100 characters and bodies to 10,000 characters. PATCH preserves omitted fields; PUT requires a title and fills omitted body and pinned values with their defaults. Both reject fields outside title , body , and pinned . Know what is local to a worker The rate limiter stores timestamps in memory per application instance. A client can get three successful calls to the demo endpoint per ten-second window per worker . Multiple workers don’t magically share a Python dict, however enthusiastically you deploy them. Database-backed sessions survive app restarts; rate-limit buckets do not. For shared throttling, use a gateway or a shared store. Before deploying an adapted auth system, decide how you will handle account recovery, token abuse, CSRF, and operational monitoring. The notebook demonstrates a design; it isn’t an identity provider hiding in a trench coat. ← Run the notebook Borrow Starlette’s middleware → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"development.html","title":"Development & contributing","headings":[{"anchor":"setup","title":"Set up a checkout"},{"anchor":"checks","title":"Run the existing checks"},{"anchor":"changes","title":"Keep changes reviewable"},{"anchor":"docs","title":"Edit and preview these docs"},{"anchor":"reporting","title":"Report a bug with a reproduction"},{"anchor":"license","title":"License"}],"text":"Development & contributing Make a small change, demonstrate the behavior, and explain it clearly. The framework is tiny; the pull request does not need a cinematic universe. On this page Set up a checkout Run the existing checks Keep changes reviewable Edit and preview these docs Report a bug with a reproduction License Set up a checkout Terminal git clone https://gitman.io/git/patx/wred cd wred python -m venv .venv . .venv/bin/activate python -m pip install -e '.[server]' pymongo servestatic For the core framework alone, python -m pip install -e . installs Wred and orjson. Starlette and itsdangerous are only needed for the tutorial recipes using them. Run the existing checks The repository uses Python’s unittest runner. From the repository root: Terminal python -m unittest tests.test_wred python -m unittest tests.test_example_app python -m unittest discover -s tests The core suite exercises request parsing, routes, responses, mounted paths, and protocol boundaries. The example-app suite needs PyMongo and ServeStatic installed. The database integration suite also imports those dependencies and is skipped unless its MongoDB URI is set. Use a disposable MongoDB instance for integration checks: Terminal WRED_TEST_MONGODB_URI='mongodb://127.0.0.1:27017' python -m unittest tests.test_notebook The integration suite creates uniquely named databases beginning with wred_test_ and drops them during cleanup. The supplied credentials need permission for that work. Use a test database server, not production with a hopeful expression. Keep changes reviewable Start with the observable behavior you want to change. Keep Wred’s core focused on HTTP routing and responses; middleware and application modules are good homes for optional behavior. Explain any API or compatibility changes in the accompanying docs. When adding a regression check, exercise the actual affected boundary and describe what would fail without the fix. Prefer a small meaningful case to a second copy of the implementation dressed as a test. Edit and preview these docs The documentation is plain HTML with shared CSS and JavaScript in docs/tutorial/ . There is no framework build. Serve the docs directory to browse it locally: Terminal python -m http.server 8001 --bind 127.0.0.1 --directory docs Open http://127.0.0.1:8001/tutorial/ . The downloadable frontend example needs its ASGI server for its API; serving the documentation itself does not start that application. After changing page text or adding a page, rebuild the offline search index: Terminal python docs/tutorial/build_search.py node --check docs/tutorial/tutorial.js git diff --check Pages and navigation still work without JavaScript. Code-copy buttons and full-text search use the shared script; the search index is a local JavaScript file, so it also works without fetching a service. Report a bug with a reproduction Include the Wred and Python versions, the ASGI server and relevant options, a minimal app, the request you sent, the expected and actual responses, and the traceback if there is one. For proxy issues, say whether the same request works against the backend directly. Remove passwords, tokens, cookies, database credentials, and personal data from examples. Use the project repository for ordinary bug reports and contributions. See deployment boundaries for vulnerability-reporting guidance. License Wred is distributed under the BSD 3-Clause license. The complete terms are in the repository’s LICENSE file; retain that file and the applicable notices when redistributing. This paragraph is a signpost to the license, not a replacement for it. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"errors.html","title":"Errors & troubleshooting","headings":[{"anchor":"exceptions","title":"Exceptions Wred can raise"},{"anchor":"expected-errors","title":"Return expected errors explicitly"},{"anchor":"statuses","title":"Where HTTP statuses come from"},{"anchor":"debugging","title":"A short debugging sequence"},{"anchor":"streaming","title":"After a response has started"}],"text":"Errors & troubleshooting An exception and an HTTP error response are different things. Your logs would appreciate being invited to this distinction. On this page Exceptions Wred can raise Return expected errors explicitly Where HTTP statuses come from A short debugging sequence After a response has started Exceptions Wred can raise Exception Typical trigger What to do orjson.JSONDecodeError Invalid JSON in await request.json() . Catch at the input boundary and return a 400 response. UnicodeDecodeError Invalid UTF-8 in the raw or percent-decoded query string. Reject the query with 400 if it is part of your public input handling. ConnectionError A body read receives a disconnect or another non-request event. Handle cancellation/disconnection where relevant; do not assume a response can still reach the client. TypeError An unsupported response body, or a serialization/type error. Return bytes, a dict/list, a supported tuple, or a callable ASGI response. ValueError For example, unpacking a tuple with fewer than three response fields. Use exactly the documented three- or four-item response tuple. UnicodeEncodeError A custom header name is not ASCII or a value cannot be encoded as Latin-1. Use HTTP-compatible headers and encode internationalized values with the appropriate header format. RuntimeError A non-HTTP scope reaches a bare Wred app. Route that protocol to an outer app or configure lifespan appropriately. Wred lets these exceptions propagate. Your middleware or ASGI server decides how to log and respond to an unhandled exception. Do not depend on a bare Wred app to generate a JSON 500 response. Return expected errors explicitly Add to an existing app import orjson @app.route('/api/items', methods=['POST']) async def create_item(request): try: data = await request.json() except orjson.JSONDecodeError: return {'error': 'Invalid JSON'}, 'application/json', 400 if not isinstance(data, dict): return {'error': 'Send a JSON object'}, 'application/json', 400 return {'accepted': True} Catch the exception you expect at the boundary you understand. A blanket except Exception: return {\"ok\": True} makes dashboards look lovely and everything else substantially worse. Where HTTP statuses come from Status Wred behavior 200 Default for supported handler return values. 400 / 401 / 403 / 409 / 429 Application or middleware decisions. The notebook provides examples. 404 Wred’s fallback for an unmatched path or method. 405 Not generated automatically. Register a handler or use another routing layer if you need it. 500 Unhandled-error behavior is supplied by the server or middleware. See the error middleware example . HTTP status is part of the response, not a declaration made by throwing a Starlette exception. Wred does not install Starlette’s exception handlers for you. A short debugging sequence Call the backend directly from its host and inspect the exact status, headers, and body with curl -i . Confirm the server imports the wrapper you intended, not just the inner Wred instance. Check the route path and method, then the proxy or mount prefix. Inspect the server traceback. Turn off public debug tracebacks before deployment. Repeat through the public proxy to isolate scheme, Host, cookie, and forwarding issues. The operations troubleshooting table covers 502s, database startup failures, login problems, and buffered streams. After a response has started A callable response owns its send operations. If it raises after sending headers or part of a stream, Wred propagates the exception without attempting a second response. Validate inputs and permissions before sending the first message. An error handler cannot change a status code already sent to the client. Time travel remains an optional dependency that nobody has successfully published. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"faq.html","title":"Frequently asked questions","headings":[{"anchor":"scope","title":"What does Wred deliberately leave out?"},{"anchor":"server","title":"Do I have to use Weft?"},{"anchor":"requests","title":"Can I use Starlette’s Request APIs on a Wred request?"},{"anchor":"methods","title":"Why does POST or HEAD return 404?"},{"anchor":"responses","title":"Why can I parse JSON null but not return None?"},{"anchor":"forms","title":"How do I handle forms or uploaded files?"},{"anchor":"lifespan","title":"Where do startup tasks and database cleanup go?"},{"anchor":"streams","title":"Can Wred stream or handle WebSockets?"},{"anchor":"concurrency","title":"Can handlers call blocking code or launch background jobs?"},{"anchor":"limits","title":"Are the notebook’s rate limits global?"},{"anchor":"performance","title":"Will my app get the README’s benchmark numbers?"}],"text":"Frequently asked questions The questions that usually arrive just after the first successful curl request, when optimism starts acquiring requirements. On this page What does Wred deliberately leave out? Do I have to use Weft? Can I use Starlette’s Request APIs on a Wred request? Why does POST or HEAD return 404? Why can I parse JSON null but not return None? How do I handle forms or uploaded files? Where do startup tasks and database cleanup go? Can Wred stream or handle WebSockets? Can handlers call blocking code or launch background jobs? Are the notebook’s rate limits global? Will my app get the README’s benchmark numbers? What does Wred deliberately leave out? Authentication, sessions, database integration, schemas, templates, multipart/form parsing, CORS, CSRF protection, dependency injection, generated API docs, background jobs, WebSockets, and built-in static or streaming responses. The tutorial shows how to compose several of these from application code and ASGI libraries. The API reference describes what the core does. A feature’s absence is permission to choose a component, not an invitation to pretend the feature is unnecessary. Do I have to use Weft? No. wred[server] is a convenience extra for Weft. Install the base package and your preferred ASGI server if you want something else. Wred is ASGI, not a WSGI application; use the correct interface when a server supports both. Can I use Starlette’s Request APIs on a Wred request? Wrapping an application with Starlette middleware does not replace the request object Wred creates. Read middleware data through request.scope , or add your own application adapter. See Request’s attributes . request.query is a synchronous cached property. request.body() and request.json() must be awaited. Python will not reinterpret this distinction as a stylistic preference. Why does POST or HEAD return 404? A route defaults to GET. Register methods explicitly. Wred uses the same 404 fallback for unknown paths and unsupported methods; it does not supply automatic HEAD, OPTIONS, or 405 behavior. CORS middleware can handle browser preflights. Why can I parse JSON null but not return None? Input parsing accepts any JSON value. Wred’s direct response handling supports dicts, lists, and bytes, plus the documented tuple forms. Return a callable JSON response object when you need a JSON scalar, or return b\"\", \"application/json\", 204 for no content. How do I handle forms or uploaded files? Wred has no form or multipart parser. Choose a parser or ASGI component deliberately, install its dependencies, and arrange who reads the receive stream. A second consumer cannot reread events already consumed by the first. Enforce body and file limits before buffering uploads. Where do startup tasks and database cleanup go? In a lifespan-aware outer ASGI app. The notebook’s NotebookApp initializes and closes its database there. A bare Wred instance rejects lifespan. See the actual wrapper and protocol configuration . Can Wred stream or handle WebSockets? A handler can return a callable ASGI response such as Starlette’s StreamingResponse for HTTP streaming or SSE. WebSocket scopes are rejected by Wred itself; route them to a separate ASGI app with an outer router if you need them. Can handlers call blocking code or launch background jobs? Handlers are awaited on the event loop. Calling a blocking database client or doing expensive CPU work directly there blocks other requests in that worker. Use an async client, an appropriate thread for blocking I/O, or separate workers for expensive work. A detached task is not a durable job queue. If work must survive a restart, use a durable queue and workers that own it. The operating system is unimpressed by how important your untracked task felt. Are the notebook’s rate limits global? No. They are per app instance and per worker. Database sessions are persistent; the in-memory timestamp buckets are not. Use shared application storage or an edge limiter for a shared policy. See the Nginx example . Will my app get the README’s benchmark numbers? Those numbers describe one workload on one machine with a specific server configuration. Database calls, passwords, middleware, payload size, concurrency, and client behavior all change throughput and latency. Measure the work your application actually does. A hello-world benchmark remains impressively good at saying hello. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"first-api.html","title":"Your first API","headings":[{"anchor":"install","title":"Make a small project"},{"anchor":"routes","title":"Give it some routes"},{"anchor":"run","title":"Run it and ask it something"},{"anchor":"validation","title":"Accept JSON without accepting absolutely anything"},{"anchor":"response-shapes","title":"Choose the response you mean"}],"text":"Your first API Install a server, return a dict, and make a request. This is the part where a larger framework might ask you to generate a project, three directories, and a personal philosophy. On this page Make a small project Give it some routes Run it and ask it something Accept JSON without accepting absolutely anything Choose the response you mean Make a small project Start in a new directory and install Wred 0.10 or newer. The server extra installs Weft. Terminal mkdir hello-wred cd hello-wred python -m venv .venv . .venv/bin/activate python -m pip install 'wred[server]>=0.10' Give it some routes Save this as first_api.py . An async handler receives a Wred request. Returning a dict or list produces JSON with status 200. first_api.py Download from wred import App app = App() @app.route('/') async def hello(request): return {'hello': 'world'} @app.route('/api/health') async def health(request): return {'status': 'ok'} @app.route('/api/hello/{name}') async def greet(request, name): return {'hello': name, 'language': request.query.get('lang', 'en')} name is a string, passed by keyword. request.query is a synchronous cached property and returns strings too. Wred will not infer that a query parameter is an integer because you looked at it meaningfully. Methods default to GET. Fixed paths win over parameterized paths for the same method; trailing slashes are ignored. Unknown paths and unsupported methods return JSON 404. HEAD, OPTIONS, and 405 handling aren’t added automatically. Run it and ask it something Terminal weft first_api:app Leave the server running. In a second terminal: Terminal curl -sS http://127.0.0.1:8000/ # {\"hello\":\"world\"} curl -sS 'http://127.0.0.1:8000/api/hello/Ada?lang=en' # {\"hello\":\"Ada\",\"language\":\"en\"} The Python module is first_api ; the exported ASGI application is app . That’s what the colon means. We have now exhausted the mystery of first_api:app . Accept JSON without accepting absolutely anything Save the next example as validation.py , stop the first server, and run weft validation:app . This endpoint echoes a validated title; it does not store anything. validation.py Download import orjson from wred import App app = App() @app.route('/api/echo', methods=['POST']) async def echo(request): try: data = await request.json() except orjson.JSONDecodeError: return {'error': 'Invalid JSON'}, 'application/json', 400 if not isinstance(data, dict): return {'error': 'Send a JSON object'}, 'application/json', 400 title = data.get('title') if not isinstance(title, str) or not 1 <= len(title.strip()) <= 100: return {'error': 'Title must contain 1–100 characters'}, 'application/json', 400 return {'title': title.strip()} Terminal curl -i http://127.0.0.1:8000/api/echo \\ -H 'Content-Type: application/json' \\ -d '{\"title\":\"My first note\"}' The response body is {\"title\":\"My first note\"} . A JSON array, missing title, or invalid JSON gets 400. Schema validation is your application’s decision; a few explicit checks are perfectly respectable. Choose the response you mean Return value Result {\"ok\": True} JSON, 200 {\"id\": \"n1\"}, \"application/json\", 201 JSON, 201 b\"\", \"text/plain\", 303, [(\"Location\", \"/\")] Redirect b\"\", \"application/json\", 204 Empty response A callable ASGI response The response object sends its own messages Custom tuple bodies can be bytes, dicts, or lists. Dicts and lists are JSON-encoded while preserving the tuple’s explicit content type. Exceptions propagate to the server or your middleware; Wred doesn’t convert them into a reassuringly vague success. ← Build something with Wred. Run the notebook → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"frontend.html","title":"Serve a frontend and other responses","headings":[{"anchor":"same-origin","title":"Start with one origin"},{"anchor":"router","title":"Use Starlette for the outer routing"},{"anchor":"servestatic","title":"Or use the notebook’s ServeStatic wrapper"},{"anchor":"responses","title":"Return a file or stream"},{"anchor":"browser","title":"Borrow the notebook’s fetch habits"}],"text":"Serve a frontend and other responses Your JavaScript needs somewhere to live. Your API needs somewhere to return JSON. They can share an origin without sharing an existential crisis. On this page Start with one origin Use Starlette for the outer routing Or use the notebook’s ServeStatic wrapper Return a file or stream Borrow the notebook’s fetch habits Start with one origin The notebook serves its frontend and API together, so browser code can use fetch(\"/api/notes\") and same-origin cookies. A frontend on port 5173 and an API on port 8000 are different origins, even on the same machine. During development, use your frontend tool’s API proxy or configure a deliberately cross-origin API. The notebook’s auth checks expect the same-origin arrangement. Opening an HTML file through file:// is not an ingenious CORS workaround. Use Starlette for the outer routing Install starlette . Save this as frontend_app.py and create a sibling frontend/ directory containing the HTML below. Run weft frontend_app:app , then open http://127.0.0.1:8000/ . frontend_app.py Download from pathlib import Path from starlette.applications import Starlette from starlette.routing import Mount from starlette.staticfiles import StaticFiles from wred import App api = App() @api.route('/hello') async def hello(request): return {'hello': 'world'} app = Starlette(routes=[ Mount('/api', app=api), Mount('/', app=StaticFiles(directory=Path(__file__).with_name('frontend'), html=True)), ]) frontend/index.html Download <!doctype html> <html lang=\"en\"> <head> <meta charset=\"utf-8\"> <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> <title>Wred frontend example</title> </head> <body> <h1>Hello from Wred</h1> <p id=\"result\" role=\"status\">Loading…</p> <script> async function load() { const result = document.getElementById('result'); try { const response = await fetch('/api/hello'); if (!response.ok) throw new Error(`HTTP ${response.status}`); result.textContent = JSON.stringify(await response.json()); } catch (error) { result.textContent = `Could not load the API: ${error.message}`; } } load(); </script> </body> </html> The outer Starlette router owns composition and lifespan. Wred registers /hello ; the mount makes it /api/hello . Wred respects the ASGI root path. The final / mount catches static paths, so keep it last. StaticFiles handles the directory. Its HTML mode can serve an index; it does not automatically make every unknown URL an SPA route. Configure an explicit fallback if your client router needs one. Or use the notebook’s ServeStatic wrapper The notebook already wraps its Wred app with ServeStaticASGI in example_app/app.py . You don’t need to add the Starlette router above to run that example. Both approaches delegate static files to a library built for them. For a built frontend, Caddy or Nginx can serve the build directory and proxy /api/ to Python. Preserve the prefix when the Wred routes already include /api . Strip it only when the app is intentionally mounted with matching root-path handling. Random slash removal is a surprisingly effective 404 generator. Return a file or stream Wred also accepts callable ASGI response objects. This independent sample uses Starlette’s FileResponse and StreamingResponse . Save it as responses_app.py and place the sample notes.json in a sibling data/ directory. responses_app.py Download import asyncio from pathlib import Path from starlette.responses import FileResponse, StreamingResponse from wred import App app = App() @app.route('/download') async def download(request): return FileResponse( Path(__file__).with_name('data') / 'notes.json', filename='notes.json', media_type='application/json', ) @app.route('/events') async def events(request): async def messages(): for number in range(5): yield f'data: {number}\\n\\n' await asyncio.sleep(1) return StreamingResponse( messages(), media_type='text/event-stream', headers={'Cache-Control': 'no-cache'}, ) Terminal weft responses_app:app From another terminal: Terminal curl -i http://127.0.0.1:8000/download curl -N http://127.0.0.1:8000/events FileResponse streams the known file and sets download headers. The events route sends five SSE messages. curl -N disables client output buffering. Authenticate and validate before starting a response; once headers are sent, an exception can’t retroactively turn it into a JSON 400. See the Starlette response reference . Keep large downloads and long-lived streams in mind when choosing proxy buffering and timeout settings. Borrow the notebook’s fetch habits example_app/static/app.js has one fetch helper for JSON, auth headers, refresh, errors, and empty 204 responses. It checks response.ok : fetch resolves normally for HTTP 400 and 500, which is considerate of HTTP and occasionally inconsiderate of your expectations. Render user text with textContent , keep credentials out of URLs, and handle empty responses before trying to parse JSON. The notebook also tracks request sequences so a slow search result cannot overwrite a newer one. ← Write a little ASGI middleware Put Caddy in front → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"index.html","title":"Wred documentation","headings":[{"anchor":"reference","title":"Look something up"},{"anchor":"what-you-build","title":"The app we’re actually building"},{"anchor":"before-starting","title":"Bring these things"},{"anchor":"chapters","title":"Work through the tutorial"},{"anchor":"using-examples","title":"Copy the bits you need"}],"text":"Wred documentation Build an app, look up an API, or find out which component owns the configuration. A tutorial and reference for a framework that still fits in one Python file. These docs cover Wred 0.10, where request.query is a cached property. Upgrading from 0.9? Remove the parentheses; see migration notes . Look something up Following a lesson is useful. Finding the exact method signature without rereading a lesson is also useful. We have bravely decided to provide both. Installation & quick start — Python requirements, extras, servers, and editable installs. API reference — App, routes, Request, response formats, and ASGI helpers. Configuration & architecture — settings ownership and the notebook’s environment variables. Errors & troubleshooting — exceptions, HTTP statuses, and debugging. Frequently asked questions — the usual surprises, catalogued. Deployment boundaries — body limits, proxy trust, credentials, and input handling. Development & contributing — checks, source changes, docs, and bug reports. Release notes & upgrading — the 0.10 query-property change and the optional server extra. The search box looks through these pages and the tutorial. Everything runs locally in the browser; no search account, index service, or ritual sacrifice required. The app we’re actually building The repository includes a notebook with accounts, private notes, search, pagination, JSON export, a browser interface, and MongoDB storage. We’ll run it, follow a request through its files, then explore the bits you might want to borrow for your own app. Wred handles HTTP routes and responses. Your application chooses the database, authentication, middleware, and frontend. It’s a small framework with a very healthy relationship with the word “elsewhere.” Browser → Caddy or Nginx → ASGI server → Middleware → Wred + your code The chapters move from local development to deployment. The middleware and frontend recipes are independent examples; they don’t silently rewrite the notebook app while you’re looking away. Bring these things Python 3.11 or newer, a terminal, and enough JavaScript to recognize fetch() . A running MongoDB instance for the notebook chapters. The first API and most middleware examples work without it. A Linux or macOS shell for the commands shown. On Windows, use WSL or adapt virtualenv activation to PowerShell. Examples use Weft through wred[server] . Wred itself only requires orjson . Starlette, PyMongo, ServeStatic, and the other extras belong to the examples that use them. Work through the tutorial 1. Your first API Routes, requests, validation, JSON, and response tuples. 2. Run the notebook Start MongoDB, run the example, and follow the source files. 3. Notes, accounts, and ownership Trace note creation, bearer tokens, refresh cookies, and ownership. 4. Borrow Starlette’s middleware Add CORS, host checks, compression, errors, and signed sessions. 5. Write a little ASGI middleware Build request context and reuse proxy-header middleware. 6. Serve a frontend and other responses Compose static files, API mounts, downloads, and event streams. 7. Put Caddy in front Deploy with automatic HTTPS and a private Python backend. 8. Put Nginx in front Configure TLS, forwarding, body limits, and throttling. 9. Keep it running Supervise workers, manage configuration, and diagnose failures. Copy the bits you need Python and configuration examples have download links. Save each example with the filename shown. Run the standalone Python examples from the directory you saved them in; stop one server with Ctrl+C before starting another on port 8000. From a checkout of this repository, you can also run the standalone examples directly: Terminal python -m venv .venv . .venv/bin/activate python -m pip install -e '.[server]' starlette itsdangerous cd docs/tutorial/examples weft first_api:app The notebook chapters use the repository root instead. Each chapter says which directory and dependencies it expects, because scavenger hunts are a terrible package manager. Your first API → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"installation.html","title":"Installation & quick start","headings":[{"anchor":"requirements","title":"Requirements"},{"anchor":"virtualenv","title":"Create an isolated environment"},{"anchor":"quick-start","title":"Save an app and run it"},{"anchor":"extras","title":"Choose your dependencies"},{"anchor":"another-server","title":"Use another ASGI server"},{"anchor":"checkout","title":"Work from a checkout"},{"anchor":"versions","title":"Check what you installed"}],"text":"Installation & quick start Pick a Python, make a virtualenv, and install the parts you need. Your dependency tree is allowed to have sunlight reaching the bottom. These docs cover Wred 0.10, where request.query is a cached property. Upgrading from 0.9? Remove the parentheses; see migration notes . On this page Requirements Create an isolated environment Save an app and run it Choose your dependencies Use another ASGI server Work from a checkout Check what you installed Requirements Wred requires Python 3.11 or newer and orjson . It is an ASGI application framework; an ASGI server runs it. The optional server extra installs weft-server , whose executable is weft . Wred is packaged as a single Python module. There is no project generator, settings module, or Wred CLI. You can spend the time saved choosing a name for your app. That should comfortably consume the afternoon. Create an isolated environment Terminal mkdir hello-wred cd hello-wred python -m venv .venv . .venv/bin/activate python -m pip install 'wred[server]' Commands assume a POSIX shell and a python command pointing to Python 3.11+. Use python3 if that is how your system names it. In Windows PowerShell, activate with .venv\\Scripts\\Activate.ps1 . A virtualenv also avoids modifying an externally managed system Python. See the Python virtualenv guide and pip install reference . Save an app and run it app.py from wred import App app = App() @app.route('/') async def hello(request): return {'hello': 'world'} Terminal weft app:app Leave that process running. In a second terminal: Terminal curl -sS http://127.0.0.1:8000/ # {\"hello\":\"world\"} app:app means the app object in app.py . Continue with Your first API for routes and validation. Choose your dependencies Install What it adds python -m pip install wred Wred and orjson. Bring an ASGI server yourself. python -m pip install \"wred[server]\" Wred, orjson, and the Weft server. python -m pip install starlette The optional middleware, routing, and response classes used in several chapters. python -m pip install itsdangerous Needed for the Starlette signed-cookie session example. python -m pip install pymongo servestatic The notebook example’s database client and static-file wrapper. These examples are separate application choices. Installing Starlette does not transform Wred’s request into a Starlette request, despite the excellent opportunities for confusion. Use another ASGI server You can install another ASGI server alongside the base package. For example, with Uvicorn: Terminal python -m pip install wred uvicorn uvicorn app:app --host 127.0.0.1 --port 8000 --lifespan off The bare Wred app handles HTTP only, so this command disables Uvicorn’s lifespan handling. The notebook wrapper implements lifespan and should run with it enabled. See your server’s own options for workers, reload, TLS, and forwarding. Work from a checkout Terminal git clone https://gitman.io/git/patx/wred cd wred python -m venv .venv . .venv/bin/activate python -m pip install -e '.[server]' pymongo servestatic Editable installation points Python at your checkout. The notebook is in example_app/ ; installing the Wred package alone does not install that application. Follow Run the notebook for MongoDB and startup. Check what you installed Terminal python -m pip show wred python -c 'import wred; print(wred.__file__)' The second command helps detect a local wred.py shadowing the installed module. Python imports the file it finds, not the file you had strong feelings about. See release notes and upgrading for the 0.9 server-extra change. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"nginx.html","title":"Put Nginx in front","headings":[{"anchor":"backend","title":"Use the same private backend"},{"anchor":"config","title":"Configure HTTPS and the proxy"},{"anchor":"reload","title":"Check the config before reloading"},{"anchor":"throttle","title":"Add a proxy rate limit when you need one"},{"anchor":"streams","title":"Let event streams reach the client"}],"text":"Put Nginx in front The other perfectly reasonable reverse proxy. It offers precise control and enough semicolons to make a JavaScript formatter feel underemployed. On this page Use the same private backend Configure HTTPS and the proxy Check the config before reloading Add a proxy rate limit when you need one Let event streams reach the client Use the same private backend Use this instead of the Caddy setup. The notebook listens on loopback; Nginx owns the public ports. Run from the repository root: Terminal # Run from the repository root with the notebook dependencies installed. weft example_app.asgi:app --host 127.0.0.1 --port 8000 \\ --lifespan on --forwarded-allow-ips 127.0.0.1 The app trusts forwarding information from 127.0.0.1 , not from arbitrary clients. Keep port 8000 private and forward the external scheme correctly. Configure HTTPS and the proxy Put these server blocks in a file included by Nginx’s http context. Replace the hostname and provision real certificate and key files at the configured paths first. The Nginx HTTPS guide covers TLS configuration. Unlike the Caddy example, these certificate paths do not cause certificates to appear by force of optimism. nginx.conf Download # Include these server blocks inside nginx's http context. # Replace the domain and certificate paths before enabling. server { listen 80; server_name notes.example.com; return 308 https://$host$request_uri; } server { listen 443 ssl; server_name notes.example.com; ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem; client_max_body_size 1m; client_body_timeout 15s; location / { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Connection \"\"; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_read_timeout 60s; proxy_send_timeout 60s; } } proxy_pass has no URI suffix, so it preserves paths such as /api/notes . The forwarded client header is overwritten with the peer address for this single-proxy setup. Host and scheme match the public request. client_max_body_size limits uploads before the application buffers them. The timeout values are starting points; choose values that match your workload. Check the config before reloading Terminal sudo nginx -t sudo systemctl reload nginx curl -i https://notes.example.com/api/health Check login through the public domain too. A 403 from the notebook’s origin check often means the forwarded scheme or Host is wrong. Don’t fix it by deleting the origin check and declaring victory. Add a proxy rate limit when you need one Nginx’s request limiter can share its zone across Nginx workers on one host. For an optional general API limit, add this declaration once in the http context: Nginx · http context limit_req_zone $binary_remote_addr zone=wred_clients:10m rate=10r/s; Then add the following inside the existing location / block, alongside the proxy directives: Nginx · location context limit_req zone=wred_clients burst=20 nodelay; limit_req_status 429; These numbers are illustrative, not a capacity plan delivered by a stranger’s HTML page. This general limit covers static files too; split locations for API-only or tighter login limits and preserve the required proxy settings. See the request-limit module . Let event streams reach the client For the independent events example , buffering can hide incremental output. In a dedicated location /events , include your normal proxy settings and add: Nginx · streaming location additions proxy_buffering off; proxy_read_timeout 300s; Choose a timeout longer than the interval between stream messages. Don’t disable buffering globally just because one endpoint needs it; ordinary responses have done nothing to deserve being dragged into this. ← Put Caddy in front Keep it running → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"notebook.html","title":"Run the notebook","headings":[{"anchor":"checkout","title":"Run from the repository root"},{"anchor":"mongodb","title":"Give it a database"},{"anchor":"try-it","title":"Take it for a spin"},{"anchor":"source-map","title":"Follow the files"},{"anchor":"lifespan","title":"The wrapper does the housekeeping"},{"anchor":"static","title":"How the browser gets its files"}],"text":"Run the notebook A real little app: accounts, a notes editor, MongoDB, and just enough moving parts to explain why people invent architecture diagrams. On this page Run from the repository root Give it a database Take it for a spin Follow the files The wrapper does the housekeeping How the browser gets its files Run from the repository root The notebook source lives in example_app/ ; it is not installed by pip install wred . Clone the repository, or use your existing checkout. Terminal git clone https://gitman.io/git/patx/wred cd wred python -m venv .venv . .venv/bin/activate python -m pip install -e '.[server]' pymongo servestatic Use a current PyMongo release that provides AsyncMongoClient . The app imports the native async client, not Motor and not a synchronous client wearing an async function as a disguise. Give it a database Have MongoDB listening on 127.0.0.1:27017 . An existing local instance is fine. If you already use Docker, this starts a local development database with a persistent volume. The published port binds to loopback: Terminal docker run -d --name wred-mongo \\ -p 127.0.0.1:27017:27017 \\ -v wred-mongo-data:/data/db mongo:8 This local example has no database credentials and binds the published port to loopback. Use your own authenticated URI for a remote database. Don’t promote the development database to the internet because the command was conveniently short. Terminal export MONGODB_URI='mongodb://127.0.0.1:27017' export MONGODB_DATABASE='wred_notebook' weft example_app.asgi:app --lifespan on Open http://127.0.0.1:8000/ . These environment values are the defaults; setting them explicitly makes the configuration visible. --lifespan on asks the server to require the example’s startup and shutdown protocol. Take it for a spin Create an account. Usernames accept 1–64 characters; passwords accept 15–128 characters. Write two notes, pin one, search for a word, and try the pagination controls. Export your notes. Log out and log back in to see the stored data. Click the rate-limit demonstration. A fresh client gets three accepted requests in ten seconds; the extra requests return 429. For a quick HTTP check, use a second terminal: Terminal curl -i http://127.0.0.1:8000/api/health # HTTP 200, {\"status\":\"ok\"} curl -i http://127.0.0.1:8000/api/notes # HTTP 401: notes require a bearer token Follow the files File What to read for example_app/asgi.py Exports the server entry point: app = create_app() . example_app/app.py Builds Wred, registers routes, wraps static files, and owns lifespan. example_app/storage.py Async MongoDB operations, indexes, note ownership filters, and session persistence. example_app/auth.py Password checks, bearer tokens, refresh cookies, and authentication decorators. example_app/notes.py Validation, CRUD, filtering, pagination, and export. example_app/pages.py Health, redirect, and rate-limit demonstration routes. example_app/rate_limit.py The application-owned sliding-window decorator. example_app/static/app.js Fetch calls, refresh coordination, editor state, and rendering. Open these files in your checkout alongside the tutorial. The source repository has the same layout. The constructor in example_app/app.py assembles the HTTP app: example_app/app.py · constructor excerpt self.http = App() rate_limit = RateLimiter() pages.register_routes(self.http, rate_limit) auth.register_routes(self.http, self.database, rate_limit) notes.register_routes(self.http, self.database) self.http = ServeStaticASGI( self.http, root=str(STATIC_ROOT), index_file=True, autorefresh=True, max_age=0, ) self.http.add_files(str(STATIC_ROOT), prefix='static/') The wrapper does the housekeeping NotebookApp handles lifespan.startup by initializing the database and creating indexes. It reports startup failure if that fails. Shutdown closes the client. Ordinary requests go to its self.http application. Wred itself handles HTTP only. Sending lifespan directly to a bare Wred app raises RuntimeError ; the notebook wrapper is the part that implements it. This is deliberate composition, which is a fancy way of saying the code that opens the database also closes it. create_app(database=...) also accepts an application-owned database object with the methods used by the routes, including async initialize() and close() . Each app has independent rate-limit state. example_app/app.py · lifespan excerpt async def __call__(self, scope, receive, send): if scope['type'] != 'lifespan': return await self.http(scope, receive, send) while True: message = await receive() if message['type'] == 'lifespan.startup': try: await self.database.initialize() except Exception as error: await self.database.close() await send({ 'type': 'lifespan.startup.failed', 'message': str(error), }) return await send({'type': 'lifespan.startup.complete'}) elif message['type'] == 'lifespan.shutdown': await self.database.close() await send({'type': 'lifespan.shutdown.complete'}) return How the browser gets its files The wrapper places ServeStaticASGI around Wred. It serves the static directory with an index file and also adds the /static/ asset prefix. Unmatched requests fall through to the API. The example enables autorefresh=True and max_age=0 for editing. For deployment, choose intentional cache settings or move built assets to your reverse proxy. The frontend chapter covers alternatives. ← Your first API Notes, accounts, and ownership → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"operations.html","title":"Keep it running","headings":[{"anchor":"service","title":"Run the notebook under systemd"},{"anchor":"environment","title":"Keep environment settings outside the checkout"},{"anchor":"workers","title":"Choose workers with the database in mind"},{"anchor":"debugging","title":"When it doesn’t work"},{"anchor":"next","title":"Make it your application"}],"text":"Keep it running The app works in your terminal. Now comes the traditional next step: making it work when your terminal is closed. On this page Run the notebook under systemd Keep environment settings outside the checkout Choose workers with the database in mind When it doesn’t work Make it your application Run the notebook under systemd This Linux example assumes the repository is deployed at /srv/wred , dependencies are installed in /srv/wred/.venv , and a dedicated wred user and group can read the application. Create those resources before enabling the service. Run behind either proxy from the previous chapters. Put this unit at /etc/systemd/system/wred.service . Its ExecStart uses Weft’s explicit loopback forwarding trust. wred.service Download [Unit] Description=Wred notebook After=network-online.target Wants=network-online.target [Service] Type=exec User=wred Group=wred WorkingDirectory=/srv/wred EnvironmentFile=/etc/wred/notebook.env Environment=PYTHONUNBUFFERED=1 ExecStart=/srv/wred/.venv/bin/weft example_app.asgi:app --host 127.0.0.1 --port 8000 --workers 2 --lifespan on --forwarded-allow-ips 127.0.0.1 Restart=on-failure RestartSec=3 TimeoutStopSec=45 NoNewPrivileges=true PrivateTmp=true [Install] WantedBy=multi-user.target Restart=on-failure restarts a failed process. Systemd supervises it; it doesn’t check whether your database credentials are good or your application is emotionally ready. See systemd.service . Keep environment settings outside the checkout Create /etc/wred/notebook.env with your database settings. The values below target the local development database; replace them for your deployment. Restrict access if the URI contains credentials. /etc/wred/notebook.env MONGODB_URI=mongodb://127.0.0.1:27017 MONGODB_DATABASE=wred_notebook Terminal sudo systemctl daemon-reload sudo systemctl enable --now wred sudo systemctl status wred sudo journalctl -u wred -f Keep production credentials out of Git and shell history. The service manager reads the environment file; this example does not require a dotenv package. Choose workers with the database in mind The unit starts two workers as an example. Each worker gets its own MongoDB client and connection pool. More workers mean more pools and separate in-memory rate-limit buckets. Benchmark your workload, including database calls and middleware, before multiplying workers because the CPU graph had room for more colors. Keep MongoDB reachable and authenticated appropriately, back up the data, and verify that you can restore it. A volume is persistence; it is not a backup with a more reassuring name. The notebook’s health endpoint returns a simple application response. It does not ping MongoDB on every request. Use startup checks, database monitoring, and a deliberately designed readiness check for your own deployment. When it doesn’t work Symptom Check first 502 from the proxy Is the backend running on the same host and port? Check the service log and request the backend’s health endpoint locally. Startup failure Can the service reach MongoDB? Did its virtualenv install PyMongo and ServeStatic? Is the working directory the repository root? 403 at login or refresh The custom header, same-origin browser request, public Host, and forwarded HTTPS scheme. 401 on note requests The access token, its expiry, and the login’s existence. The refresh cookie alone does not authorize CRUD routes. Static page instead of JSON A static catch-all or SPA fallback caught an API path. Put API routing first and inspect prefix stripping. Secure cookie missing over HTTPS The ASGI scheme is probably still HTTP. Fix trusted proxy handling. 429 changes between requests The notebook limiter is per worker. Use a shared limiter for a shared policy. SSE arrives all at once Client and proxy buffering. Use curl -N and inspect the streaming location. Different frontend port fails It is a different origin. Use a development proxy or design CORS and auth together. Make it your application Add routes in a module that exposes register_routes(app, ...) . Pass database and service objects explicitly, as the notebook does. Put shared HTTP behavior in middleware and resource lifecycle in the outer app. For blocking libraries, use an appropriate worker thread or replace them with an async client. Don’t start untracked tasks inside request handlers and call it a job queue. Durable work needs a durable queue and workers that own it. You now have routes, data, auth, a frontend, middleware, HTTPS, and a supervised process. Wred is still small. Your app was allowed to grow without filing a feature request. ← Put Nginx in front Back to the overview → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"releases.html","title":"Release notes & upgrading","headings":[{"anchor":"version-010","title":"0.10: query is a cached property"},{"anchor":"version-09","title":"0.9: the server is optional"},{"anchor":"normalization","title":"Why v0.09 corresponds to package version 0.9"},{"anchor":"upgrade","title":"Check an upgrade"}],"text":"Release notes & upgrading The changes that matter when updating an app. Version numbers are identifiers, even when Python packaging gives your leading zero a stern talking-to. On this page 0.10: query is a cached property 0.9: the server is optional Why v0.09 corresponds to package version 0.9 Check an upgrade 0.10: query is a cached property Request.query now uses cached_property , matching Request.headers . It parses on first access and returns the same cached dict on later accesses. Blank values, repeated-key handling, strict UTF-8 decoding, and body-reading behavior are unchanged. This is a breaking API change from published Wred 0.9: remove the call parentheses in handlers. No await is needed. The old call form tries to call a dict and raises TypeError , which is the dictionary's entirely reasonable objection to being treated as a function. Migration from Wred 0.9 # Wred 0.9 query = request.query() # Wred 0.10 query = request.query name = request.query.get('name', 'world') Upgrade with python -m pip install --upgrade \"wred[server]==0.10\" , or use wred==0.10 with your own ASGI server. Version 0.10 also adds this HTML tutorial, API reference, and browser-local documentation search. 0.9: the server is optional Wred 0.9 moves weft-server out of the required dependencies and into the server extra. The only required runtime dependency is orjson . The Python API remains imported from wred . Your setup What to install You run the weft command. python -m pip install \"wred[server]==0.9\" You already install another ASGI server. python -m pip install \"wred==0.9\" , plus that server in your application dependencies. You run the notebook from a checkout. Install the editable server extra plus PyMongo and ServeStatic, as shown in the notebook chapter . Record the extra in your dependency file if your app relies on Weft. Upgrading an existing environment may leave an already installed Weft package in place; a clean environment is the useful check for missing dependencies. Stale packages make excellent camouflage. Why v0.09 corresponds to package version 0.9 The v0.09 Git tag identifies the release whose source version was 0.09 . Flit normalizes that to package version 0.9 under Python’s version rules. The new v0.10 tag and source version 0.10 correspond to package version 0.10 . Use the package versions in dependency requirements. Python from importlib.metadata import version print(version('wred')) # Installed distribution metadata See the Python packaging version specification . These are version components, not decimal fractions. Check an upgrade Install your intended version and extras in a fresh virtualenv. Run your app’s checks with its actual server and middleware stack. Exercise startup, routing, body parsing, expected errors, and protected operations. Verify proxy scheme and cookie behavior if the deployment configuration changed. The 0.10 PyPI page provides distribution metadata. The repository history contains the source changes; this page does not invent summaries for older releases. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"security.html","title":"Deployment boundaries","headings":[{"anchor":"edge","title":"Bound request size and direct access"},{"anchor":"authentication","title":"Authentication and ownership are application rules"},{"anchor":"browser","title":"Keep browser credential rules coherent"},{"anchor":"data","title":"Validate data and render text safely"},{"anchor":"errors","title":"Keep diagnostics useful"},{"anchor":"reporting","title":"Report problems with enough context"}],"text":"Deployment boundaries Wred gives you HTTP primitives. You choose the protections around them. This page names those boundaries so they don’t become surprises with an incident number. On this page Bound request size and direct access Authentication and ownership are application rules Keep browser credential rules coherent Validate data and render text safely Keep diagnostics useful Report problems with enough context Bound request size and direct access request.body() buffers the entire body without a built-in size limit. Apply a limit in the ASGI server, middleware, or reverse proxy before the app reads it. The Caddy and Nginx examples include body limits. Bind a same-host backend to loopback and expose only the proxy. For separate hosts, use an appropriately restricted private connection. Trust forwarded headers only from the proxy peers you control. Authentication and ownership are application rules The notebook validates opaque bearer credentials and filters data changes by owner. Its custom request.user attribute comes from its decorator. An unrelated Wred application gets none of that automatically. Choose account recovery, credential expiry, revocation, and abuse controls for the application you are shipping. Read the notebook walkthrough before copying its login flow. A decorator name is not a security review, however reassuring its spelling. Keep browser credential rules coherent The notebook expects same-origin login and refresh requests with its custom header. Its refresh cookie is HttpOnly and SameSite=Strict, and gets Secure when the incoming ASGI scheme is HTTPS. Correct proxy scheme handling is therefore part of the login behavior. CORS controls browser cross-origin access. Enabling it alone does not remove the notebook’s same-origin checks or supply CSRF protection for another app. If you adapt the auth flow to another origin, revise the origin, cookie, credential, and CSRF behavior together. Starlette’s signed-cookie session recipe is separate: signing detects tampering but does not encrypt the contents. Store secrets on the server. Validate data and render text safely Validate JSON shape, string lengths, numbers, and allowed fields. Wred does not impose schemas. Keep ownership in authenticated server context rather than accepting it from an incoming JSON object. The notebook frontend uses textContent for user text. Avoid interpolating untrusted note contents into HTML. Keep credentials out of URLs, logs, screenshots, and committed environment files. Keep diagnostics useful Log unexpected failures at the server or middleware layer while returning intentional public error bodies. Disable public tracebacks in deployment. Preserve request IDs if you add them, and avoid logging passwords, bearer tokens, or cookie values. Check startup, login, a protected operation, and logout through the actual public domain. A successful backend health request proves considerably less than the whole application working. Report problems with enough context For ordinary bugs, see the reporting checklist . For a suspected vulnerability, use an appropriate private contact channel with the maintainer before posting exploit details publicly. These docs do not announce a separate disclosure program or response-time guarantee. Share the affected version and a minimal reproduction without production data or credentials. The project repository and its package metadata identify the maintainer. ← Documentation overview API reference → Written for Wred 0.10. See release notes for changes and migration guidance."},{"url":"starlette-middleware.html","title":"Borrow Starlette’s middleware","headings":[{"anchor":"install","title":"Install what you use"},{"anchor":"stack","title":"Build an explicit stack"},{"anchor":"cors","title":"CORS belongs outside the error handler"},{"anchor":"other-layers","title":"Hosts, compression, and errors"},{"anchor":"sessions","title":"A separate signed-cookie session recipe"}],"text":"Borrow Starlette’s middleware ASGI middleware wraps an ASGI app. Wred is an ASGI app. No adapter ceremony, secret handshake, or framework conversion experience required. On this page Install what you use Build an explicit stack CORS belongs outside the error handler Hosts, compression, and errors A separate signed-cookie session recipe Install what you use Terminal python -m pip install 'wred[server]' starlette itsdangerous itsdangerous is needed for the session recipe below. Wred’s request is still a Wred request after wrapping; middleware-provided data is available in its ASGI scope . Build an explicit stack Save this as starlette_app.py and run weft starlette_app:app . Register routes on api ; export the completed middleware stack as app . starlette_app.py Download from starlette.middleware.cors import CORSMiddleware from starlette.middleware.errors import ServerErrorMiddleware from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.responses import JSONResponse from wred import App api = App() @api.route('/api/hello') async def hello(request): return {'hello': 'world'} async def server_error(request, exc): return JSONResponse({'error': 'Internal server error'}, status_code=500) # Requests enter the last wrapper first. app = GZipMiddleware(api, minimum_size=1000) app = TrustedHostMiddleware(app, allowed_hosts=['localhost', '127.0.0.1', 'api.example.com']) app = ServerErrorMiddleware(app, handler=server_error, debug=False) app = CORSMiddleware( app, allow_origins=['http://localhost:5173'], allow_methods=['GET', 'POST', 'PATCH', 'DELETE'], allow_headers=['Authorization', 'Content-Type'], expose_headers=['Location'], ) CORS → Server errors → Trusted host → Gzip → Wred The last wrapper receives the request first. Responses travel back through the layers. The wrapper accepting the request is the object your ASGI server must import. Exporting the inner api skips every layer, a very efficient way to misconfigure middleware. CORS belongs outside the error handler This sample allows a frontend at http://localhost:5173 . CORS is outermost so generated 500 responses can carry its headers. It also answers browser preflight requests before they reach Wred. For cookie credentials, set allow_credentials=True and explicit origins, methods, and headers. CORS grants browser access; it does not authenticate the caller. See Starlette’s CORS documentation and implementation . Terminal curl -i -X OPTIONS http://127.0.0.1:8000/api/hello \\ -H 'Origin: http://localhost:5173' \\ -H 'Access-Control-Request-Method: POST' \\ -H 'Access-Control-Request-Headers: Content-Type' Expect a successful preflight with access-control-allow-origin: http://localhost:5173 . The actual sample endpoint is GET-only; a successful preflight does not create a POST handler. The route list still has a job. The notebook intentionally requires same-origin login and refresh requests. Adding CORSMiddleware alone does not turn that auth flow into a cross-origin design. Keep its frontend on the same origin, or deliberately revise the origin checks, cookies, and CSRF model together. Hosts, compression, and errors TrustedHostMiddleware checks the Host header. It does not decide which proxy IPs to trust. Gzip compresses eligible responses above the configured threshold. Our tiny hello JSON is below 1,000 bytes, so it remains tiny hello JSON. ServerErrorMiddleware supplies a JSON 500 response while allowing the exception to be logged. Debug mode stays off here; a public traceback is a surprisingly detailed résumé for your server. See the middleware reference . If your reverse proxy handles compression, choose one compression layer. Configure HTTPS redirects only after the app receives the correct forwarded scheme; otherwise you may build a redirect loop with excellent uptime. A separate signed-cookie session recipe This is an alternative example, not a replacement for the notebook’s database-backed logins. Save as session_app.py . session_app.py Download import os from starlette.middleware.sessions import SessionMiddleware from wred import App api = App() @api.route('/visits') async def visits(request): session = request.scope['session'] session['visits'] = session.get('visits', 0) + 1 return {'visits': session['visits']} app = SessionMiddleware( api, secret_key=os.environ['SESSION_SECRET'], same_site='lax', https_only=os.environ.get('COOKIE_SECURE', '1') == '1', ) Terminal export SESSION_SECRET=\"$(python -c 'import secrets; print(secrets.token_hex(32))')\" # Local HTTP only. Leave COOKIE_SECURE unset for HTTPS deployments. COOKIE_SECURE=0 weft session_app:app In a second terminal: Terminal curl -sS -c visits.cookies http://127.0.0.1:8000/visits curl -sS -b visits.cookies -c visits.cookies http://127.0.0.1:8000/visits # {\"visits\":2} Keep the secret stable across workers and restarts. Session data is signed, not encrypted: store a harmless preference or counter, not a password. Wred reads it through request.scope[\"session\"] . There is no request.session property waiting to be discovered. See Starlette’s session implementation . ← Notes, accounts, and ownership Write a little ASGI middleware → Written for Wred 0.10. See release notes for changes and migration guidance."}];
diff --git a/tests/test_wred.py b/tests/test_wred.py
index c4b0fa4..6eb5713 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -626,16 +626,16 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
[],
),
(
- ([], 'application/json', 201),
+ ([], 'text/plain', 201),
201,
- b'application/json',
+ b'text/plain',
b'[]',
[],
),
(
(
{'error': 'Unauthorized'},
- 'application/json',
+ 'application/problem+json',
401,
[
('WWW-Authenticate', 'Bearer'),
@@ -643,7 +643,7 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
],
),
401,
- b'application/json',
+ b'application/problem+json',
b'{"error":"Unauthorized"}',
[
(b'www-authenticate', b'Bearer'),
diff --git a/wred.py b/wred.py
index 047e5fb..1617e3e 100644
--- a/wred.py
+++ b/wred.py
@@ -5,7 +5,7 @@ ASGI-compatible web services. It prioritizes simplicity and performance over all
Copyright (c) 2026, Harrison Erd.
"""
__author__, __email__, __license__ = 'Harrison Erd', '[email protected]', 'BSD-3-Clause'
-__version__ = '0.10'
+__version__ = '0.11'
from collections.abc import Awaitable, Callable
from functools import cached_property
from typing import Any
@@ -93,8 +93,10 @@ class App:
body, content_type, status = response[:3]
if len(response) == 4:
headers = [(n.lower().encode('ascii'), v.encode('latin-1')) for n, v in response[3]]
+ elif isinstance(body, (dict, list)):
+ content_type = 'application/json'
if isinstance(body, (dict, list)):
- body, content_type = orjson.dumps(body), 'application/json'
+ body = orjson.dumps(body)
elif not isinstance(body, bytes):
raise TypeError("Response must be dict, list, bytes, or tuple")
await send({'type': 'http.response.start', 'status': status, 'headers': [(b'content-type', content_type.encode('latin-1'))] + headers})