|
| 1 | +# Web Framework Integration (ASGI/WSGI) |
| 2 | + |
| 3 | +This guide covers the optimized ASGI and WSGI modules for integrating Python web frameworks with erlang_python. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +The `py_asgi` and `py_wsgi` modules provide high-performance request handling by using optimized C-level marshalling between Erlang and Python. These modules bypass the generic `py:call()` path with specialized NIFs that: |
| 8 | + |
| 9 | +- **Pre-intern keys** - Python string keys are interned once at startup, eliminating per-request string allocation and hashing overhead |
| 10 | +- **Cache constants** - Common values like HTTP methods, versions, and schemes are reused across requests |
| 11 | +- **Pool responses** - Thread-local response pooling reduces memory allocation during request processing |
| 12 | +- **Direct NIF path** - Specialized NIF functions avoid the overhead of generic Python call marshalling |
| 13 | + |
| 14 | +### Performance |
| 15 | + |
| 16 | +Compared to generic `py:call()`-based handling: |
| 17 | + |
| 18 | +| Optimization | ASGI | WSGI | |
| 19 | +|--------------|------|------| |
| 20 | +| Interned keys | +15-20% | +15-20% | |
| 21 | +| Response pooling | +20-25% | N/A | |
| 22 | +| Direct NIF | +25-30% | +25-30% | |
| 23 | +| **Total** | ~60-80% | ~60-80% | |
| 24 | + |
| 25 | +## ASGI Support |
| 26 | + |
| 27 | +### Basic Usage |
| 28 | + |
| 29 | +```erlang |
| 30 | +%% Build ASGI scope from your HTTP server (e.g., Cowboy) |
| 31 | +Scope = #{ |
| 32 | + type => <<"http">>, |
| 33 | + http_version => <<"1.1">>, |
| 34 | + method => <<"GET">>, |
| 35 | + scheme => <<"http">>, |
| 36 | + path => <<"/api/users">>, |
| 37 | + query_string => <<"id=123">>, |
| 38 | + headers => [[<<"host">>, <<"localhost:8080">>]], |
| 39 | + server => {<<"localhost">>, 8080}, |
| 40 | + client => {<<"127.0.0.1">>, 54321} |
| 41 | +}, |
| 42 | + |
| 43 | +%% Execute ASGI application |
| 44 | +case py_asgi:run(<<"myapp">>, <<"application">>, Scope, Body) of |
| 45 | + {ok, {Status, Headers, ResponseBody}} -> |
| 46 | + %% Send response |
| 47 | + cowboy_req:reply(Status, Headers, ResponseBody, Req); |
| 48 | + {error, Reason} -> |
| 49 | + %% Handle error |
| 50 | + cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req) |
| 51 | +end. |
| 52 | +``` |
| 53 | + |
| 54 | +### API Reference |
| 55 | + |
| 56 | +#### `py_asgi:run/4` |
| 57 | + |
| 58 | +```erlang |
| 59 | +-spec run(Module, Callable, Scope, Body) -> Result when |
| 60 | + Module :: binary(), |
| 61 | + Callable :: binary(), |
| 62 | + Scope :: scope(), |
| 63 | + Body :: binary(), |
| 64 | + Result :: {ok, {integer(), [{binary(), binary()}], binary()}} | {error, term()}. |
| 65 | +``` |
| 66 | + |
| 67 | +Execute an ASGI application with default options. |
| 68 | + |
| 69 | +- `Module` - Python module containing the ASGI application (e.g., `<<"myapp">>`) |
| 70 | +- `Callable` - Name of the ASGI callable (typically `<<"application">>` or `<<"app">>`) |
| 71 | +- `Scope` - ASGI scope map (see Scope Fields below) |
| 72 | +- `Body` - Request body as binary |
| 73 | + |
| 74 | +#### `py_asgi:run/5` |
| 75 | + |
| 76 | +```erlang |
| 77 | +-spec run(Module, Callable, Scope, Body, Opts) -> Result when |
| 78 | + Module :: binary(), |
| 79 | + Callable :: binary(), |
| 80 | + Scope :: scope(), |
| 81 | + Body :: binary(), |
| 82 | + Opts :: map(), |
| 83 | + Result :: {ok, {integer(), [{binary(), binary()}], binary()}} | {error, term()}. |
| 84 | +``` |
| 85 | + |
| 86 | +Execute an ASGI application with options. |
| 87 | + |
| 88 | +Options: |
| 89 | +- `runner` - Custom Python runner module (default: `<<"hornbeam_asgi_runner">>`) |
| 90 | + |
| 91 | +#### `py_asgi:build_scope/1,2` |
| 92 | + |
| 93 | +```erlang |
| 94 | +-spec build_scope(Scope) -> {ok, reference()} | {error, term()}. |
| 95 | +-spec build_scope(Scope, Opts) -> {ok, reference()} | {error, term()}. |
| 96 | +``` |
| 97 | + |
| 98 | +Build an optimized Python scope dict with interned keys. The returned reference can be passed to multiple ASGI calls for further optimization when handling many requests with similar scopes. |
| 99 | + |
| 100 | +### Scope Fields |
| 101 | + |
| 102 | +| Field | Type | Required | Description | |
| 103 | +|-------|------|----------|-------------| |
| 104 | +| `type` | binary | Yes | Request type (`<<"http">>` or `<<"websocket">>`) | |
| 105 | +| `asgi` | map | No | ASGI version info (default: `#{<<"version">> => <<"3.0">>}`) | |
| 106 | +| `http_version` | binary | No | HTTP version (`<<"1.0">>`, `<<"1.1">>`, `<<"2">>`) | |
| 107 | +| `method` | binary | No | HTTP method (`<<"GET">>`, `<<"POST">>`, etc.) | |
| 108 | +| `scheme` | binary | No | URL scheme (`<<"http">>` or `<<"https">>`) | |
| 109 | +| `path` | binary | Yes | Request path | |
| 110 | +| `raw_path` | binary | No | Raw path (defaults to `path`) | |
| 111 | +| `query_string` | binary | No | Query string without leading `?` | |
| 112 | +| `root_path` | binary | No | Root path for mounted apps | |
| 113 | +| `headers` | list | No | List of `[Name, Value]` header pairs | |
| 114 | +| `server` | tuple | No | Server `{Host, Port}` tuple | |
| 115 | +| `client` | tuple | No | Client `{Host, Port}` tuple | |
| 116 | +| `state` | map | No | Request state dict | |
| 117 | +| `extensions` | map | No | ASGI extensions | |
| 118 | + |
| 119 | +## WSGI Support |
| 120 | + |
| 121 | +### Basic Usage |
| 122 | + |
| 123 | +```erlang |
| 124 | +%% Build WSGI environ from your HTTP server |
| 125 | +Environ = #{ |
| 126 | + <<"REQUEST_METHOD">> => <<"GET">>, |
| 127 | + <<"SCRIPT_NAME">> => <<>>, |
| 128 | + <<"PATH_INFO">> => <<"/api/users">>, |
| 129 | + <<"QUERY_STRING">> => <<"id=123">>, |
| 130 | + <<"SERVER_NAME">> => <<"localhost">>, |
| 131 | + <<"SERVER_PORT">> => <<"8080">>, |
| 132 | + <<"SERVER_PROTOCOL">> => <<"HTTP/1.1">>, |
| 133 | + <<"wsgi.url_scheme">> => <<"http">>, |
| 134 | + <<"wsgi.input">> => Body |
| 135 | +}, |
| 136 | + |
| 137 | +%% Execute WSGI application |
| 138 | +case py_wsgi:run(<<"myapp">>, <<"application">>, Environ) of |
| 139 | + {ok, {Status, Headers, ResponseBody}} -> |
| 140 | + %% Parse status line (e.g., "200 OK") |
| 141 | + StatusCode = parse_status(Status), |
| 142 | + cowboy_req:reply(StatusCode, Headers, ResponseBody, Req); |
| 143 | + {error, Reason} -> |
| 144 | + cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req) |
| 145 | +end. |
| 146 | +``` |
| 147 | + |
| 148 | +### API Reference |
| 149 | + |
| 150 | +#### `py_wsgi:run/3` |
| 151 | + |
| 152 | +```erlang |
| 153 | +-spec run(Module, Callable, Environ) -> Result when |
| 154 | + Module :: binary(), |
| 155 | + Callable :: binary(), |
| 156 | + Environ :: environ(), |
| 157 | + Result :: {ok, {binary(), [{binary(), binary()}], binary()}} | {error, term()}. |
| 158 | +``` |
| 159 | + |
| 160 | +Execute a WSGI application with default options. |
| 161 | + |
| 162 | +- `Module` - Python module containing the WSGI application |
| 163 | +- `Callable` - Name of the WSGI callable |
| 164 | +- `Environ` - WSGI environ map (see Environ Fields below) |
| 165 | + |
| 166 | +Note: WSGI returns the status as a binary string (e.g., `<<"200 OK">>`), not an integer. |
| 167 | + |
| 168 | +#### `py_wsgi:run/4` |
| 169 | + |
| 170 | +```erlang |
| 171 | +-spec run(Module, Callable, Environ, Opts) -> Result when |
| 172 | + Module :: binary(), |
| 173 | + Callable :: binary(), |
| 174 | + Environ :: environ(), |
| 175 | + Opts :: map(), |
| 176 | + Result :: {ok, {binary(), [{binary(), binary()}], binary()}} | {error, term()}. |
| 177 | +``` |
| 178 | + |
| 179 | +Execute a WSGI application with options. |
| 180 | + |
| 181 | +Options: |
| 182 | +- `runner` - Custom Python runner module (default: `<<"hornbeam_wsgi_runner">>`) |
| 183 | + |
| 184 | +### Environ Fields |
| 185 | + |
| 186 | +| Field | Type | Required | Description | |
| 187 | +|-------|------|----------|-------------| |
| 188 | +| `<<"REQUEST_METHOD">>` | binary | Yes | HTTP method | |
| 189 | +| `<<"SCRIPT_NAME">>` | binary | Yes | Initial portion of URL path (can be empty) | |
| 190 | +| `<<"PATH_INFO">>` | binary | Yes | Remainder of URL path | |
| 191 | +| `<<"QUERY_STRING">>` | binary | No | Query string without leading `?` | |
| 192 | +| `<<"CONTENT_TYPE">>` | binary | No | Content-Type header value | |
| 193 | +| `<<"CONTENT_LENGTH">>` | binary | No | Content-Length header value | |
| 194 | +| `<<"SERVER_NAME">>` | binary | Yes | Server hostname | |
| 195 | +| `<<"SERVER_PORT">>` | binary | Yes | Server port as string | |
| 196 | +| `<<"SERVER_PROTOCOL">>` | binary | Yes | Protocol version (e.g., `<<"HTTP/1.1">>`) | |
| 197 | +| `<<"wsgi.version">>` | tuple | No | WSGI version tuple (default: `{1, 0}`) | |
| 198 | +| `<<"wsgi.url_scheme">>` | binary | Yes | URL scheme (`<<"http">>` or `<<"https">>`) | |
| 199 | +| `<<"wsgi.input">>` | binary | Yes | Request body | |
| 200 | +| `<<"wsgi.errors">>` | any | No | Error stream | |
| 201 | +| `<<"wsgi.multithread">>` | boolean | No | Default: `true` | |
| 202 | +| `<<"wsgi.multiprocess">>` | boolean | No | Default: `true` | |
| 203 | +| `<<"wsgi.run_once">>` | boolean | No | Default: `false` | |
| 204 | +| `<<"HTTP_*">>` | binary | No | HTTP headers with `HTTP_` prefix | |
| 205 | + |
| 206 | +## Custom Runner Modules |
| 207 | + |
| 208 | +Both ASGI and WSGI support custom runner modules for advanced use cases. |
| 209 | + |
| 210 | +### ASGI Runner |
| 211 | + |
| 212 | +```python |
| 213 | +# custom_asgi_runner.py |
| 214 | +async def run_asgi(app, scope, body): |
| 215 | + """ |
| 216 | + Custom ASGI runner. |
| 217 | +
|
| 218 | + Args: |
| 219 | + app: The ASGI application callable |
| 220 | + scope: ASGI scope dict |
| 221 | + body: Request body bytes |
| 222 | +
|
| 223 | + Returns: |
| 224 | + Tuple of (status_code, headers, body) |
| 225 | + """ |
| 226 | + # Custom pre-processing |
| 227 | + scope['state']['custom_key'] = 'value' |
| 228 | + |
| 229 | + # Call the ASGI app |
| 230 | + response = await default_asgi_handler(app, scope, body) |
| 231 | + |
| 232 | + # Custom post-processing |
| 233 | + return response |
| 234 | +``` |
| 235 | + |
| 236 | +```erlang |
| 237 | +%% Use custom runner |
| 238 | +py_asgi:run(<<"myapp">>, <<"app">>, Scope, Body, #{ |
| 239 | + runner => <<"custom_asgi_runner">> |
| 240 | +}). |
| 241 | +``` |
| 242 | + |
| 243 | +### WSGI Runner |
| 244 | + |
| 245 | +```python |
| 246 | +# custom_wsgi_runner.py |
| 247 | +def run_wsgi(app, environ): |
| 248 | + """ |
| 249 | + Custom WSGI runner. |
| 250 | +
|
| 251 | + Args: |
| 252 | + app: The WSGI application callable |
| 253 | + environ: WSGI environ dict |
| 254 | +
|
| 255 | + Returns: |
| 256 | + Tuple of (status, headers, body) |
| 257 | + """ |
| 258 | + # Custom pre-processing |
| 259 | + environ['custom.key'] = 'value' |
| 260 | + |
| 261 | + # Call the WSGI app |
| 262 | + response = default_wsgi_handler(app, environ) |
| 263 | + |
| 264 | + # Custom post-processing |
| 265 | + return response |
| 266 | +``` |
| 267 | + |
| 268 | +```erlang |
| 269 | +%% Use custom runner |
| 270 | +py_wsgi:run(<<"myapp">>, <<"app">>, Environ, #{ |
| 271 | + runner => <<"custom_wsgi_runner">> |
| 272 | +}). |
| 273 | +``` |
| 274 | + |
| 275 | +## Sub-interpreter and Free-threading Support |
| 276 | + |
| 277 | +Both `py_asgi` and `py_wsgi` fully support Python's sub-interpreter and free-threading modes: |
| 278 | + |
| 279 | +### Sub-interpreters (Python 3.12+) |
| 280 | + |
| 281 | +Each request can run in an isolated sub-interpreter, providing: |
| 282 | +- Isolated global state between requests |
| 283 | +- No GIL contention between interpreters |
| 284 | +- True parallelism for CPU-bound Python code |
| 285 | + |
| 286 | +### Free-threading (Python 3.13+) |
| 287 | + |
| 288 | +With Python 3.13's experimental free-threading build: |
| 289 | +- No Global Interpreter Lock (GIL) |
| 290 | +- True multi-threaded parallelism |
| 291 | +- Best performance for concurrent requests |
| 292 | + |
| 293 | +The modules automatically detect and use the optimal mode based on your Python installation. |
| 294 | + |
| 295 | +## Integration Examples |
| 296 | + |
| 297 | +### Cowboy Integration |
| 298 | + |
| 299 | +```erlang |
| 300 | +-module(my_asgi_handler). |
| 301 | +-behaviour(cowboy_handler). |
| 302 | + |
| 303 | +-export([init/2]). |
| 304 | + |
| 305 | +init(Req0, State) -> |
| 306 | + Method = cowboy_req:method(Req0), |
| 307 | + Path = cowboy_req:path(Req0), |
| 308 | + QS = cowboy_req:qs(Req0), |
| 309 | + Headers = cowboy_req:headers(Req0), |
| 310 | + {ok, Body, Req1} = cowboy_req:read_body(Req0), |
| 311 | + |
| 312 | + Scope = #{ |
| 313 | + type => <<"http">>, |
| 314 | + method => Method, |
| 315 | + path => Path, |
| 316 | + query_string => QS, |
| 317 | + headers => maps:to_list(Headers), |
| 318 | + scheme => <<"http">> |
| 319 | + }, |
| 320 | + |
| 321 | + case py_asgi:run(<<"myapp">>, <<"app">>, Scope, Body) of |
| 322 | + {ok, {Status, RespHeaders, RespBody}} -> |
| 323 | + Req = cowboy_req:reply(Status, maps:from_list(RespHeaders), RespBody, Req1), |
| 324 | + {ok, Req, State}; |
| 325 | + {error, _Reason} -> |
| 326 | + Req = cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req1), |
| 327 | + {ok, Req, State} |
| 328 | + end. |
| 329 | +``` |
| 330 | + |
| 331 | +### Elli Integration |
| 332 | + |
| 333 | +```erlang |
| 334 | +-module(my_wsgi_handler). |
| 335 | +-behaviour(elli_handler). |
| 336 | + |
| 337 | +-export([handle/2, handle_event/3]). |
| 338 | + |
| 339 | +handle(Req, _Args) -> |
| 340 | + Environ = #{ |
| 341 | + <<"REQUEST_METHOD">> => elli_request:method(Req), |
| 342 | + <<"PATH_INFO">> => elli_request:path(Req), |
| 343 | + <<"QUERY_STRING">> => elli_request:query_str(Req), |
| 344 | + <<"SERVER_NAME">> => <<"localhost">>, |
| 345 | + <<"SERVER_PORT">> => <<"8080">>, |
| 346 | + <<"SERVER_PROTOCOL">> => <<"HTTP/1.1">>, |
| 347 | + <<"wsgi.url_scheme">> => <<"http">>, |
| 348 | + <<"wsgi.input">> => elli_request:body(Req) |
| 349 | + }, |
| 350 | + |
| 351 | + case py_wsgi:run(<<"myapp">>, <<"application">>, Environ) of |
| 352 | + {ok, {Status, Headers, Body}} -> |
| 353 | + StatusCode = parse_wsgi_status(Status), |
| 354 | + {StatusCode, Headers, Body}; |
| 355 | + {error, _} -> |
| 356 | + {500, [], <<"Internal Server Error">>} |
| 357 | + end. |
| 358 | + |
| 359 | +handle_event(_, _, _) -> ok. |
| 360 | + |
| 361 | +parse_wsgi_status(Status) -> |
| 362 | + [CodeBin | _] = binary:split(Status, <<" ">>), |
| 363 | + binary_to_integer(CodeBin). |
| 364 | +``` |
| 365 | + |
| 366 | +## See Also |
| 367 | + |
| 368 | +- [Getting Started](getting-started.md) - Basic usage guide |
| 369 | +- [Asyncio](asyncio.md) - Async event loop integration |
| 370 | +- [Threading](threading.md) - Thread support and callbacks |
| 371 | +- [Scalability](scalability.md) - Performance tuning |
0 commit comments