diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b1da5f1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to OxyRoute are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - unreleased + +### Removed (breaking) + +- The optional ASGI 3.0 compatibility bridge (`oxyroute.asgi`) was removed. `App` is no + longer an ASGI callable; `App.__call__`, `App._asgi3`, the `asgi_to_rsgi` helper and the + `WebSocket` helper class are gone. Run OxyRoute exclusively under + ``granian --interface rsgi``. +- The ASGI-based `@app.websocket(path)` decorator and the + `App._handle_asgi_websocket` plumbing were removed alongside the bridge. Native RSGI + WebSocket support will be reintroduced in a follow-up release. + +### Migration + +- If you ran `uvicorn` or `granian --interface asgi` against an OxyRoute app, switch to + `granian --interface rsgi`. RSGI is now the only supported transport. +- For unit tests that drove the app via `httpx.ASGITransport(app=app)`, import the + test-only shim from the test tree: + + ```python + from tests._rsgi_test_transport import asgi_test_app + transport = httpx.ASGITransport(app=asgi_test_app(app)) + ``` + + The shim is **only** built for the test suite; production code must not import it. + +## [0.2.0] - 2026-04 + +Initial public release. See `git log v0.2.0` for details. diff --git a/Cargo.lock b/Cargo.lock index c130e71..3206e50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,7 +351,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oxyroute" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64", "bytes", diff --git a/Cargo.toml b/Cargo.toml index b6928ea..189ecd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxyroute" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "RSGI web framework: Rust hot path, Python handlers" license = "MIT" diff --git a/README.md b/README.md index 6a25554..e9cd48f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ - **JWT (HMAC)** verification on the Rust path before your handler runs (`require_jwt`, HS256/384/512) - **Optional** `GET /openapi.json` with a minimal OpenAPI-style document - **Dependencies**: linear list of named factories (`Depends`, sync or async) passed as kwargs -- **Optional ASGI 3** bridge: `async def __call__(scope, receive, send)` for servers that speak ASGI (see [docs/asgi.md](docs/asgi.md)) - Native extension wheel (abi3) for **Python ≥ 3.10** Full documentation: **[docs/index.md](docs/index.md)** @@ -73,11 +72,11 @@ granian --interface rsgi examples.rsgi_app:app Per-worker setup (`__rsgi_init__`) is shown in [examples/rsgi_lifespan_app.py](examples/rsgi_lifespan_app.py) and [docs/rsgi.md](docs/rsgi.md#lifespan-optional). -ASGI and other servers are covered in [docs/asgi.md](docs/asgi.md). +OxyRoute v0.3.0 supports **only** Granian RSGI; the legacy ASGI bridge (`uvicorn` / `granian --interface asgi`) was removed. ## Project layout -- `oxyroute/` — Python package (`App`, `Depends`, optional ASGI bridge) +- `oxyroute/` — Python package (`App`, `Depends`) - `src/` — Rust extension (`_oxyroute`, routing, dispatch, JWT helpers) - `docs/` — detailed English documentation - `tests/` — pytest suite (run from a temp directory or an installed wheel so the source tree does not shadow the package; see [docs/development.md](docs/development.md)) diff --git a/docs/asgi.md b/docs/asgi.md deleted file mode 100644 index 529d768..0000000 --- a/docs/asgi.md +++ /dev/null @@ -1,30 +0,0 @@ -# Optional ASGI 3.0 bridge - -[← Documentation index](index.md) - -OxyRoute’s primary design is **RSGI** with Granian. For servers that only expose **ASGI 3.0** (`async def app(scope, receive, send)`), the Python `App` is also a callable: **`async def __call__(scope, receive, send)`**. - -The module `oxyroute.asgi` builds a minimal RSGI-shaped `scope` / `protocol` and forwards to the same native **`handle_rsgi`** as `__rsgi__`. That keeps a single request pipeline in the Rust code. - -## How responses get back to ASGI - -The RSGI response helpers on the protocol object are **synchronous** from the Rust side. The ASGI implementation bridges this through a thread-safe outgoing queue: sync `response_*` calls enqueue ASGI messages from the worker thread, and an async drain task on the main loop performs `send(...)` in order. - -**Why the bridge does not `await` on that loop directly:** Awaiting the native `handle_rsgi` coroutine on the same loop that must process outgoing messages can **deadlock**. The bridge therefore runs `handle_rsgi` in a **thread-pool** worker using `asyncio.run()` on a **separate** event loop, while the main ASGI loop drains the queued `http.response.*` events. - -**Implications:** This bridge is a **practical adapter**, not a full reimplementation of RSGI under every ASGI host. Prefer **RSGI/Granian** for production with OxyRoute if you can. With **Uvicorn**, use a **single worker** process unless you are sure the combination is safe in your app (the usual `workers=N` + in-process `asyncio` footguns can still apply to third-party `asyncio` use). - -## Limitations (current) - -- **HTTP** ASGI `scope` only; other scope types are ignored -- RSGI features beyond what the bridge constructs are not modeled -- Error handling matches the RSGI path; unexpected combinations of servers and runtimes should be **tested** in your environment - -## Local testing - -The **httpx** project’s `ASGITransport` is used in `tests/test_asgi.py` to exercise the bridge without a real TCP server. `tests/test_asgi_stress.py` runs **50** concurrent `GET` requests to guard against cross-thread/loop deadlocks in the bridge. - -## See also - -- [RSGI and Granian](rsgi.md) — the recommended integration -- [Installation](installation.md) — `httpx` in `oxyroute[dev]` diff --git a/docs/dependencies.md b/docs/dependencies.md index ef5ff2b..37403c5 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -6,7 +6,7 @@ OxyRoute supports a **linear** list of **named** dependency factories. At reques ### Request context (optional) -If a factory’s signature includes a parameter named `request`, the extension passes a **dict** (once per request, shared) with string keys: `method`, `path`, `query_string`, and `headers` (a flat `str` → `str` map, when the underlying RSGI scope exposes headers—see the ASGI bridge in `oxyroute.asgi`). Factories that do **not** declare `request` are still called with **no** extra arguments when they have no prior dependencies, preserving older behavior. +If a factory’s signature includes a parameter named `request`, the extension passes a **dict** (once per request, shared) with string keys: `method`, `path`, `query_string`, and `headers` (a flat `str` → `str` map, when the underlying RSGI scope exposes headers). Factories that do **not** declare `request` are still called with **no** extra arguments when they have no prior dependencies, preserving older behavior. ## Declaring on a route diff --git a/docs/development.md b/docs/development.md index 1dd16ab..6a2d378 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,7 +42,7 @@ pip install "oxyroute[dev]" # in your dev env, from source after maturin develo ## Granian RSGI (end-to-end) -`tests/test_granian_e2e.py` starts a real **Granian** subprocess with `--interface rsgi`, sends HTTP requests with **httpx**, then stops the server. It is part of the normal **pytest** run when `granian` is installed (`oxyroute[dev]` includes it). The same file runs in **CI** on every matrix combination (Linux, macOS, Windows), so the native RSGI path is exercised against a real server, not only the ASGI in-process transport. +`tests/test_granian_e2e.py` starts a real **Granian** subprocess with `--interface rsgi`, sends HTTP requests with **httpx**, then stops the server. It is part of the normal **pytest** run when `granian` is installed (`oxyroute[dev]` includes it). The same file runs in **CI** on every matrix combination (Linux, macOS, Windows), so the native RSGI path is exercised against a real server, not only the in-process httpx test transport. ## Continuous integration diff --git a/docs/feature.md b/docs/feature.md index 2326a0b..d2818c6 100644 --- a/docs/feature.md +++ b/docs/feature.md @@ -11,7 +11,7 @@ - Маршрутизация по методам и путям (`matchit`), path/query/json/body, **405 / Allow**, **HEAD** на GET, **OPTIONS**. - **JWT** (HS/RS/… через `jsonwebtoken`), iss/aud/leeway, cookie, **зависимости** с `request`, линейный порядок, `freeze`. - **Response** / dict с заголовками и cookies, частичный **OpenAPI**, Pydantic/schema для тела. -- Один **pre-route middleware** (`set_middleware`), **ASGI мост** только для `http` ([asgi](asgi.md)). +- Один **pre-route middleware** (`set_middleware`). - CI, PyPI, E2E Granian RSGI. Ниже — то, чего **нет** или что **слабо** относительно «больших» фреймворков. @@ -22,10 +22,10 @@ | Тема | Зазор | Комментарий | |------|--------|-------------| -| **WebSockets** | Частично (ASGI spike) | Есть `@app.websocket(...)` в ASGI bridge (см. [websocket.md](websocket.md)); RSGI/Rust-native WS path пока не реализован. | -| **SSE / длинный стрим ответа** | Частично | Есть `send_sse` (см. [sse.md](sse.md)); инкрементальный стрим зависит от `response_stream` в серверном протоколе, иначе fallback в буферизованный ответ. | +| **WebSockets** | Не реализованы | Поддержка ASGI WS была удалена в v0.3.0; native RSGI WebSocket — следующая работа. | +| **SSE / длинный стрим ответа** | Частично | Есть `send_sse` (см. [sse.md](sse.md)); инкрементальный стрим использует `response_stream` Granian RSGI. | | **HTTP/2 push, trailers** | Не в фокусе | Обычно на стороне сервера; фреймворк редко экспонирует. | -| **ASGI: lifespan, `websocket`, background** | Частично | Мост ASGI — **только `http`**; нет полноценного ASGI-приложения с lifecycle из спеки. | +| **ASGI совместимость** | Удалена в v0.3.0 | Поддерживается только RSGI (Granian `--interface rsgi`). | --- diff --git a/docs/http2.md b/docs/http2.md index 3535180..dcd5ac7 100644 --- a/docs/http2.md +++ b/docs/http2.md @@ -43,4 +43,3 @@ If you see `HTTP/1.1`, the fallback happened at server/proxy/TLS level, not in O ## See also - [RSGI and Granian](rsgi.md) -- [ASGI bridge](asgi.md) diff --git a/docs/index.md b/docs/index.md index 7d035fb..597028b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,11 +46,9 @@ Granian still invokes a Python `App` object; the “win” is doing routing, bod | [CSRF](csrf.md) | Double-submit, `apply_csrf`, `csrf_layer` + CORS | | [JWT](jwt.md) | `require_jwt`, HS* / RSA / EC PEM, `decode_jwt_hs` (HS* tests) | | [SSE](sse.md) | `send_sse`, event framing, streaming caveats | -| [WebSocket](websocket.md) | ASGI spike API and current limitations | | [HTTP/2 with Granian](http2.md) | Transport guarantees vs server/proxy responsibilities | | [Dependencies](dependencies.md) | `Depends`, `dependencies=[...]`, `freeze` | | [OpenAPI](openapi.md) | `openapi.json` route, title, `openapi_json()` | -| [ASGI bridge](asgi.md) | Optional `__call__` for ASGI 3, limitations | | [Development](development.md) | Tests, CI, PyPI releases (tag `v*`), clippy, pytest | | [Branching and PRs](development-workflow.md) | `dev` as base, issue branches, `Closes #N`, no mixing code with `ISSUE_BACKLOG` in one commit | | [Feature gaps (research)](feature.md) | What is missing vs a “full” HTTP framework (multipart, WS, sub-routers, etc.) — Russian | diff --git a/docs/rsgi.md b/docs/rsgi.md index a6f709c..e705cb6 100644 --- a/docs/rsgi.md +++ b/docs/rsgi.md @@ -53,10 +53,6 @@ app = create_app() - [`examples/rsgi_app.py`](../examples/rsgi_app.py) — minimal RSGI app - [`examples/rsgi_lifespan_app.py`](../examples/rsgi_lifespan_app.py) — subclass with `__rsgi_init__` / `__rsgi_del__` and `ready_at` used from handlers -## When to use ASGI instead - -If your host only speaks **ASGI 3.0** (for example `uvicorn` with stock ASGI), you can use the optional ASGI bridge: `App` is also a callable `async def __call__(scope, receive, send)`. See [asgi.md](asgi.md) for trade-offs and limitations. - ## See also - [Handlers](handlers.md) — what the Rust core passes into your functions diff --git a/docs/sse.md b/docs/sse.md index c75bd48..ab4caeb 100644 --- a/docs/sse.md +++ b/docs/sse.md @@ -29,8 +29,8 @@ async def events(protocol): ## Streaming behavior -- On RSGI protocols exposing `response_stream`, chunks are written incrementally. -- On transports without streaming support (e.g. current ASGI bridge test path), OxyRoute falls back to one buffered `response_str` body with SSE framing. +- On RSGI protocols exposing `response_stream` (Granian RSGI), chunks are written incrementally. +- On transports without streaming support (e.g. the in-process httpx test transport), OxyRoute falls back to one buffered `response_str` body with SSE framing. ## Caveats diff --git a/docs/websocket.md b/docs/websocket.md deleted file mode 100644 index 5aef718..0000000 --- a/docs/websocket.md +++ /dev/null @@ -1,49 +0,0 @@ -# WebSocket (ASGI spike) - -[← Documentation index](index.md) - -Current WebSocket support in OxyRoute is an **ASGI bridge spike**, not full RSGI-native support yet. - -## Current API - -Use `@app.websocket(path)` for exact-path handlers on `App.__call__` (ASGI entry): - -```python -from oxyroute import App - -app = App() - - -@app.websocket("/ws") -async def ws(sock): - await sock.accept() - text = await sock.receive_text() - await sock.send_text(f"echo:{text}") - await sock.close() -``` - -The handler receives a small `WebSocket` helper with: - -- `accept(subprotocol=None)` -- `receive()` / `receive_text()` -- `send_text(text)` / `send_bytes(data)` -- `close(code=1000)` - -## Scope and limitations - -- Works on the optional ASGI entry (`app(scope, receive, send)`). -- **Not** wired into Rust request routing (`run_rsgi`) yet. -- Path matching is currently exact string match (no path-params router for WS yet). -- No first-class dependency/JWT/middleware chain for WS handlers in this spike. - -## Design split (what lives where) - -- **Python/ASGI now:** websocket handshake + frame loop helper and handler dispatch. -- **Rust/RSGI later:** unified route table, WS protocol lifecycle in native path, shared middleware/auth story. - -This keeps a practical testable path now while preserving room for a proper RSGI-native implementation. - -## See also - -- [ASGI bridge](asgi.md) -- [Feature gaps](feature.md) diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index 65f25c5..52a4582 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -28,4 +28,4 @@ "send_sse", "sse_done", ] -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/oxyroute/app.py b/oxyroute/app.py index 4fa69a7..b5a0ed7 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import inspect import json from collections.abc import Callable, Mapping @@ -8,7 +7,6 @@ from typing import Any, TypeVar from . import _oxyroute -from .asgi import build_asgi_caller from .router import APIRouter, join_path F = TypeVar("F", bound=Callable[..., Any]) @@ -37,7 +35,8 @@ def Depends(call: Callable[..., Any]) -> _oxyroute.PyDepends: class App: """ Application object: pass a module instance to Granian, e.g. - ``granian app:app --interface rsgi`` or for ASGI ``granian app:app --interface asgi``. + ``granian app:app --interface rsgi``. RSGI is the only supported transport; + the legacy ASGI bridge was removed in v0.3.0. ``state`` is an empty ``types.SimpleNamespace`` for per-process data; set fields in ``__rsgi_init__`` or a factory, or on a subclass. In-memory data is not shared across @@ -48,10 +47,8 @@ def __init__(self, title: str = "OxyRoute", *, include_openapi: bool = True) -> self._app = _oxyroute.App(include_openapi=include_openapi) self._app.set_openapi_title(title) self.title = title - self._websocket_routes: dict[str, Callable[..., Any]] = {} # Per-process mutable bag for ``__rsgi_init__`` / factory setup (DB pool, clients, …). self.state: SimpleNamespace = SimpleNamespace() - self._asgi3: Callable[..., Any] = build_asgi_caller(self) def freeze(self) -> None: """After ``freeze()``, no more route registration (matches Rust app state).""" @@ -299,34 +296,6 @@ def options( jwt_cookie=jwt_cookie, ) - def websocket(self, path: str) -> Callable[[F], F]: - """ - Register an ASGI websocket handler for exact `path`. - - This route family is available on the optional ASGI bridge (`App.__call__`), not - on the RSGI request path. - """ - - def wrap(handler: F) -> F: - self._websocket_routes[path] = handler - return handler - - return wrap - - async def _handle_asgi_websocket(self, scope: dict[str, Any], receive: Any, send: Any) -> None: - path = str(scope.get("path", "/") or "/") - handler = self._websocket_routes.get(path) - if handler is None: - await send({"type": "websocket.close", "code": 1000}) - return - from .asgi import WebSocket - - ws = WebSocket(receive, send) - if inspect.iscoroutinefunction(handler): - await handler(ws) - return - await asyncio.get_running_loop().run_in_executor(None, lambda: handler(ws)) - def _route( self, method: str, @@ -393,11 +362,8 @@ async def __rsgi__(self, scope: Any, protocol: Any) -> None: return await self._app.handle_rsgi(scope, protocol) def handle_rsgi(self, scope: Any, protocol: Any) -> Any: - """Forward to the native app (used by the ASGI bridge).""" + """Forward to the native ``handle_rsgi`` coroutine.""" return self._app.handle_rsgi(scope, protocol) - async def __call__(self, scope: Any, receive: Any, send: Any) -> None: - return await self._asgi3(scope, receive, send) - def openapi_json(self) -> str: return self._app.openapi_json() diff --git a/pyproject.toml b/pyproject.toml index cfcf5e5..49768c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "oxyroute" -version = "0.2.0" +version = "0.3.0" description = "RSGI-first web framework: routing, JSON, and JWT in Rust (PyO3), Python handlers" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.10" diff --git a/src/state.rs b/src/state.rs index 9c8d76a..2a74e8b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -87,7 +87,7 @@ impl AppState { pub fn new() -> Self { let openapi = serde_json::json!({ "openapi": "3.0.0", - "info": { "title": "OxyRoute", "version": "0.2.0" }, + "info": { "title": "OxyRoute", "version": "0.3.0" }, "paths": {} }); Self { diff --git a/oxyroute/asgi.py b/tests/_rsgi_test_transport.py similarity index 89% rename from oxyroute/asgi.py rename to tests/_rsgi_test_transport.py index 7646699..0c5b85f 100644 --- a/oxyroute/asgi.py +++ b/tests/_rsgi_test_transport.py @@ -1,8 +1,12 @@ -""" -Optional ASGI 3.0 entry for ``uvicorn`` / ``granian --interface asgi``. +"""Test-only ASGI -> RSGI shim used by httpx.ASGITransport in the test suite. + +OxyRoute v0.3.0 dropped the runtime ASGI bridge from the package. The unit / integration +tests still drive the app in-process through ``httpx.ASGITransport`` for speed and +isolation; this helper re-uses the same bridge code as a **test fixture only**, so the +production package remains free of ASGI. -Builds a minimal RSGI-like ``scope`` / ``protocol`` and delegates to the same Rust -``handle_rsgi`` coroutine as ``__rsgi__``. Only ``type == "http"`` is supported. +Use :func:`build_test_app` (or :func:`asgi_test_app`) on an :class:`oxyroute.App` +instance to obtain an ASGI 3 callable that proxies into the native RSGI dispatch path. """ from __future__ import annotations @@ -26,7 +30,7 @@ def _close_blocking_loop_for_current_thread() -> None: class WebSocket: - """Small ASGI websocket helper used by the optional ASGI bridge.""" + """Small ASGI websocket helper retained for a handful of legacy tests.""" __slots__ = ("_accepted", "_closed", "_connected", "_receive", "_send") @@ -92,17 +96,6 @@ def _run_handle_rsgi_blocking( rscope: Any, proto: Any, ) -> None: - """Run ``handle_rsgi`` to completion on a thread-local event loop. - - The ASGI server's main loop must **not** ``await`` the native coroutine directly: the Rust - side calls synchronous ``protocol.response_*`` while this coroutine runs on a worker thread. - Response calls enqueue ASGI messages onto the main loop, where a dedicated drain task performs - ``send(...)`` in order. - - For performance, each worker thread lazily creates and then reuses one event loop instead of - allocating a fresh loop per request. - """ - async def _inner() -> None: c = app_rsgi(rscope, proto) await c @@ -115,14 +108,6 @@ async def _inner() -> None: loop.run_until_complete(_inner()) -def _hdr_from_asgi(raw: list[tuple[bytes, bytes]]) -> _HeaderView: - d: dict[str, str] = {} - for k, v in raw: - dk = k.decode("latin-1").lower() - d[dk] = v.decode("latin-1") - return _HeaderView(d) - - class _HeaderView: __slots__ = ("_d",) @@ -133,6 +118,14 @@ def get(self, k: str, default: str = "") -> str: return self._d.get(k.lower(), default) +def _hdr_from_asgi(raw: list[tuple[bytes, bytes]]) -> _HeaderView: + d: dict[str, str] = {} + for k, v in raw: + dk = k.decode("latin-1").lower() + d[dk] = v.decode("latin-1") + return _HeaderView(d) + + class _RsgiScope: __slots__ = ( "authority", @@ -266,7 +259,6 @@ def response_empty(self, status: int, headers: list) -> None: def _max_body_bytes() -> int: - # Keep semantics aligned with Rust path (`src/form.rs`): default 8 MiB, `0` means unlimited. default = 8 * 1024 * 1024 raw = os.getenv("OXYROUTE_MAX_BODY_BYTES", "").strip() if not raw: @@ -371,17 +363,16 @@ async def _drain_outgoing() -> None: try: await drain_task except Exception: - # Preserve the original request-path failure if there was one. if run_exc is None: raise if run_exc is not None: raise run_exc -def build_asgi_caller(framework_app: Any) -> Callable[..., Any]: - """ - Return ``async (scope, receive, send)`` using the framework app's - ``_oxyroute.App.handle_rsgi`` (via ``App`` wrapper if present). +def build_test_app(framework_app: Any) -> Callable[..., Any]: + """Return an ``async (scope, receive, send)`` ASGI3 callable wrapping the RSGI app. + + Used by tests with ``httpx.ASGITransport(app=build_test_app(my_app))``. """ def _rsgi(s: Any, p: Any) -> Any: @@ -403,3 +394,7 @@ async def asgi3(scope: dict[str, Any], receive: Any, send: Any) -> None: await asgi_to_rsgi(_rsgi, _ws, scope, receive, send) return asgi3 + + +asgi_test_app = build_test_app +"""Alias: ``asgi_test_app(app)`` returns an ASGI3 callable for httpx.ASGITransport.""" diff --git a/tests/test_405.py b/tests/test_405.py index bd12d96..a03f4b4 100644 --- a/tests/test_405.py +++ b/tests/test_405.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app def test_405_get_on_post_only_path() -> None: @@ -16,7 +17,7 @@ def p() -> str: return "x" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/p") assert r.status_code == 405, r.text @@ -34,7 +35,7 @@ def g() -> str: return "1" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.post("/g") assert r.status_code == 405 @@ -53,7 +54,7 @@ def a() -> str: return "a" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.post("/nope") assert r.status_code == 404 diff --git a/tests/test_api_router.py b/tests/test_api_router.py index bd02f66..edd660a 100644 --- a/tests/test_api_router.py +++ b/tests/test_api_router.py @@ -9,6 +9,7 @@ import pytest from oxyroute import APIRouter, App from oxyroute.router import join_path +from tests._rsgi_test_transport import asgi_test_app def test_join_path() -> None: @@ -34,7 +35,7 @@ def b(x: str) -> str: app.include_router(r, prefix="/v1") async def _go() -> None: - tr = httpx.ASGITransport(app=app) + tr = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=tr, base_url="http://t") as c: r1 = await c.get("/v1/a") r2 = await c.get("/v1/b/7") @@ -83,7 +84,7 @@ def c() -> str: app.include_router(outer, prefix="/v1") async def _go() -> None: - tr = httpx.ASGITransport(app=app) + tr = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=tr, base_url="http://t") as c: r0 = await c.get("/v1/inner/c") assert r0.status_code == 200 and r0.text == "c" diff --git a/tests/test_asgi.py b/tests/test_asgi.py deleted file mode 100644 index 4afeeb8..0000000 --- a/tests/test_asgi.py +++ /dev/null @@ -1,223 +0,0 @@ -"""ASGI 3.0 bridge to the same RSGI core as ``__rsgi__`` (optional server compatibility).""" - -from __future__ import annotations - -import asyncio - -import httpx -import oxyroute.asgi as asgi_mod -from oxyroute import App, Response - - -def test_asgi_get_plain_text() -> None: - app = App() - - @app.get("/p") - def p() -> str: - return "x" - - async def _run() -> None: - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: - r2 = await c.get("/p") - assert r2.status_code == 200 - assert r2.text == "x" - - asyncio.run(_run()) - - -def test_asgi_patch_json_body() -> None: - app = App() - - @app.patch("/x") - def patch_x(json: dict) -> str: - return f"p:{json.get('a')}" - - async def _run() -> None: - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: - r = await c.patch("/x", json={"a": 7}) - assert r.status_code == 200 - assert r.text == "p:7" - - asyncio.run(_run()) - - -def test_asgi_response_custom_headers_and_json_ct() -> None: - app = App() - - @app.get("/j") - def j() -> Response: - return Response( - body={"x": 1}, - status=201, - headers={"content-type": "application/json", "X-Trick": "z"}, - ) - - async def _run() -> None: - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: - r = await c.get("/j") - assert r.status_code == 201, r.text - assert r.headers["x-trick"] == "z" - assert "application/json" in (r.headers.get("content-type") or "") - assert r.json() == {"x": 1} - - asyncio.run(_run()) - - -def test_asgi_queue_drain_shutdown_when_executor_path_raises() -> None: - app = App() - - @app.get("/x") - def x() -> str: - return "ok" - - scope = { - "type": "http", - "http_version": "1.1", - "scheme": "http", - "method": "GET", - "path": "/x", - "query_string": b"", - "headers": [], - } - - async def receive() -> dict: - return {"type": "http.request", "body": b"", "more_body": False} - - sent: list[dict] = [] - - async def send(message: dict) -> None: - sent.append(message) - - original = asgi_mod._run_handle_rsgi_blocking - - def boom(*_args: object, **_kwargs: object) -> None: - raise RuntimeError("boom") - - asgi_mod._run_handle_rsgi_blocking = boom - - async def _run() -> None: - try: - await app(scope, receive, send) - raise AssertionError("expected RuntimeError") - except RuntimeError as exc: - assert "boom" in str(exc) - await asyncio.sleep(0) - hanging = [ - t - for t in asyncio.all_tasks() - if t is not asyncio.current_task() - and getattr(t.get_coro(), "__name__", "") == "_drain_outgoing" - ] - assert not hanging - - try: - asyncio.run(_run()) - finally: - asgi_mod._run_handle_rsgi_blocking = original - - -def test_asgi_chunked_request_body_joins_without_quadratic_path_assumption() -> None: - app = App() - - @app.patch("/x") - def patch_x(json: dict) -> str: - return f"p:{json.get('a')}" - - scope = { - "type": "http", - "http_version": "1.1", - "scheme": "http", - "method": "PATCH", - "path": "/x", - "query_string": b"", - "headers": [(b"content-type", b"application/json")], - } - - incoming = iter( - [ - {"type": "http.request", "body": b'{"a":', "more_body": True}, - {"type": "http.request", "body": b"7}", "more_body": False}, - ] - ) - sent: list[dict] = [] - - async def receive() -> dict: - return next(incoming) - - async def send(message: dict) -> None: - sent.append(message) - - async def _run() -> None: - await app(scope, receive, send) - - asyncio.run(_run()) - assert sent[0]["type"] == "http.response.start" - assert sent[0]["status"] == 200 - assert sent[1]["type"] == "http.response.body" - assert sent[1]["body"] == b"p:7" - - -def test_asgi_body_limit_enforced_during_chunk_read() -> None: - app = App() - - @app.post("/x") - def x(body: bytes) -> str: - return body.decode("utf-8") - - scope = { - "type": "http", - "http_version": "1.1", - "scheme": "http", - "method": "POST", - "path": "/x", - "query_string": b"", - "headers": [], - } - - incoming = iter( - [ - {"type": "http.request", "body": b"abc", "more_body": True}, - {"type": "http.request", "body": b"de", "more_body": False}, - ] - ) - sent: list[dict] = [] - - async def receive() -> dict: - return next(incoming) - - async def send(message: dict) -> None: - sent.append(message) - - original = asgi_mod._max_body_bytes - asgi_mod._max_body_bytes = lambda: 4 - - async def _run() -> None: - await app(scope, receive, send) - - try: - asyncio.run(_run()) - finally: - asgi_mod._max_body_bytes = original - - assert sent[0]["type"] == "http.response.start" - assert sent[0]["status"] == 413 - assert sent[1]["type"] == "http.response.body" - assert sent[1]["body"] == b'{"error":"payload too large"}' - - -def test_asgi_non_http_non_websocket_scope_is_ignored() -> None: - app = App() - sent: list[dict] = [] - - async def receive() -> dict: - return {"type": "lifespan.startup"} - - async def send(message: dict) -> None: - sent.append(message) - - scope = {"type": "lifespan"} - asyncio.run(app(scope, receive, send)) - assert sent == [] diff --git a/tests/test_asgi_body_limits.py b/tests/test_asgi_body_limits.py deleted file mode 100644 index 0676a3c..0000000 --- a/tests/test_asgi_body_limits.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Extended ASGI body-limit and chunk-read coverage.""" - -from __future__ import annotations - -import asyncio -import os -from collections import deque -from typing import Any - -import oxyroute.asgi as asgi_mod -from oxyroute import App - - -def test_max_body_bytes_default() -> None: - old = os.environ.pop("OXYROUTE_MAX_BODY_BYTES", None) - try: - assert asgi_mod._max_body_bytes() == 8 * 1024 * 1024 - finally: - if old is not None: - os.environ["OXYROUTE_MAX_BODY_BYTES"] = old - - -def test_max_body_bytes_invalid_and_negative_fallback_to_default() -> None: - old = os.environ.get("OXYROUTE_MAX_BODY_BYTES") - try: - os.environ["OXYROUTE_MAX_BODY_BYTES"] = "abc" - assert asgi_mod._max_body_bytes() == 8 * 1024 * 1024 - os.environ["OXYROUTE_MAX_BODY_BYTES"] = "-1" - assert asgi_mod._max_body_bytes() == 8 * 1024 * 1024 - finally: - if old is None: - os.environ.pop("OXYROUTE_MAX_BODY_BYTES", None) - else: - os.environ["OXYROUTE_MAX_BODY_BYTES"] = old - - -def test_max_body_bytes_zero_means_unlimited() -> None: - old = os.environ.get("OXYROUTE_MAX_BODY_BYTES") - try: - os.environ["OXYROUTE_MAX_BODY_BYTES"] = "0" - assert asgi_mod._max_body_bytes() > 10**12 - finally: - if old is None: - os.environ.pop("OXYROUTE_MAX_BODY_BYTES", None) - else: - os.environ["OXYROUTE_MAX_BODY_BYTES"] = old - - -def test_asgi_body_limit_exact_boundary_passes() -> None: - app = App() - - @app.post("/raw", read_json_body=False) - def raw(body: bytes) -> str: - return body.decode("utf-8") - - scope = { - "type": "http", - "http_version": "1.1", - "scheme": "http", - "method": "POST", - "path": "/raw", - "query_string": b"", - "headers": [], - } - incoming = deque( - [ - {"type": "http.request", "body": b"ab", "more_body": True}, - {"type": "http.request", "body": b"cd", "more_body": False}, - ] - ) - sent: list[dict[str, Any]] = [] - - async def receive() -> dict[str, Any]: - return incoming.popleft() - - async def send(msg: dict[str, Any]) -> None: - sent.append(msg) - - old = os.environ.get("OXYROUTE_MAX_BODY_BYTES") - try: - os.environ["OXYROUTE_MAX_BODY_BYTES"] = "4" - asyncio.run(app(scope, receive, send)) - finally: - if old is None: - os.environ.pop("OXYROUTE_MAX_BODY_BYTES", None) - else: - os.environ["OXYROUTE_MAX_BODY_BYTES"] = old - - assert sent[0]["status"] == 200 - assert sent[1]["body"] == b"abcd" - - -def test_asgi_body_limit_over_boundary_returns_413_and_json_ct() -> None: - app = App() - - @app.post("/raw", read_json_body=False) - def raw(body: bytes) -> str: # pragma: no cover - return body.decode("utf-8") - - scope = { - "type": "http", - "http_version": "1.1", - "scheme": "http", - "method": "POST", - "path": "/raw", - "query_string": b"", - "headers": [], - } - incoming = deque( - [ - {"type": "http.request", "body": b"ab", "more_body": True}, - {"type": "http.request", "body": b"cde", "more_body": False}, - ] - ) - sent: list[dict[str, Any]] = [] - - async def receive() -> dict[str, Any]: - return incoming.popleft() - - async def send(msg: dict[str, Any]) -> None: - sent.append(msg) - - old = os.environ.get("OXYROUTE_MAX_BODY_BYTES") - try: - os.environ["OXYROUTE_MAX_BODY_BYTES"] = "4" - asyncio.run(app(scope, receive, send)) - finally: - if old is None: - os.environ.pop("OXYROUTE_MAX_BODY_BYTES", None) - else: - os.environ["OXYROUTE_MAX_BODY_BYTES"] = old - - assert sent[0]["type"] == "http.response.start" - assert sent[0]["status"] == 413 - assert (b"content-type", b"application/json; charset=utf-8") in sent[0]["headers"] - assert sent[1]["body"] == b'{"error":"payload too large"}' - - -def test_asgi_http_disconnect_during_read_sends_nothing() -> None: - app = App() - - @app.post("/raw", read_json_body=False) - def raw(body: bytes) -> str: # pragma: no cover - return body.decode("utf-8") - - scope = { - "type": "http", - "http_version": "1.1", - "scheme": "http", - "method": "POST", - "path": "/raw", - "query_string": b"", - "headers": [], - } - incoming = deque( - [ - {"type": "http.request", "body": b"abc", "more_body": True}, - {"type": "http.disconnect"}, - ] - ) - sent: list[dict[str, Any]] = [] - - async def receive() -> dict[str, Any]: - return incoming.popleft() - - async def send(msg: dict[str, Any]) -> None: - sent.append(msg) - - asyncio.run(app(scope, receive, send)) - assert sent == [] diff --git a/tests/test_asgi_internals.py b/tests/test_asgi_internals.py deleted file mode 100644 index 98062a7..0000000 --- a/tests/test_asgi_internals.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Unit coverage for ASGI bridge helper internals.""" - -from __future__ import annotations - -import asyncio - -import oxyroute.asgi as asgi_mod -import pytest - - -def test_norm_headers_asgi_filters_invalid_pairs() -> None: - out = asgi_mod._norm_headers_asgi( - [ - ("A", "1"), - (b"B", b"2"), - ("C", b"3"), - ("D", 4), - ("bad",), - "x", - ] - ) - assert out == [(b"A", b"1"), (b"B", b"2"), (b"C", b"3")] - - -def test_hdr_from_asgi_lowercases_and_get_lax() -> None: - hv = asgi_mod._hdr_from_asgi([(b"Origin", b"https://a.example"), (b"X", b"1")]) - assert hv.get("origin") == "https://a.example" - assert hv.get("ORIGIN") == "https://a.example" - assert hv.get("missing", "d") == "d" - - -def test_rsgi_scope_shape() -> None: - hv = asgi_mod._hdr_from_asgi([]) - s = asgi_mod._RsgiScope("http", "GET", "/x", "a=1", hv) - assert s.proto == "http" - assert s.http_version == "1.1" - assert s.method == "GET" - assert s.path == "/x" - assert s.query_string == "a=1" - assert s.headers.get("none", "") == "" - - -def test_websocket_send_and_close_idempotent() -> None: - sent: list[dict] = [] - - async def receive() -> dict: - return {"type": "websocket.connect"} - - async def send(msg: dict) -> None: - sent.append(msg) - - ws = asgi_mod.WebSocket(receive, send) - - async def _run() -> None: - await ws.send_text("a") - await ws.send_bytes(b"x") - await ws.close(code=1001) - await ws.send_text("ignored") - await ws.close(code=1000) - - asyncio.run(_run()) - assert sent[0] == {"type": "websocket.send", "text": "a"} - assert sent[1] == {"type": "websocket.send", "bytes": b"x"} - assert sent[2] == {"type": "websocket.close", "code": 1001} - assert len(sent) == 3 - - -def test_websocket_receive_returns_raw_message() -> None: - incoming = iter([{"type": "websocket.receive", "text": "ok"}]) - - async def receive() -> dict: - return next(incoming) - - async def send(_msg: dict) -> None: - return None - - ws = asgi_mod.WebSocket(receive, send) - out = asyncio.run(ws.receive()) - assert out["type"] == "websocket.receive" - assert out["text"] == "ok" - - -def test_websocket_accept_subprotocol_and_idempotent_second_accept() -> None: - sent: list[dict] = [] - incoming = iter( - [ - {"type": "websocket.connect"}, - {"type": "websocket.receive", "text": "msg"}, - ] - ) - - async def receive() -> dict: - return next(incoming) - - async def send(msg: dict) -> None: - sent.append(msg) - - async def _run() -> None: - ws = asgi_mod.WebSocket(receive, send) - await ws.accept(subprotocol="chat") - await ws.accept(subprotocol="chat") # no-op - assert await ws.receive_text() == "msg" - - asyncio.run(_run()) - assert sent == [{"type": "websocket.accept", "subprotocol": "chat"}] - - -def test_websocket_accept_raises_on_unexpected_pre_accept_event() -> None: - async def receive() -> dict: - return {"type": "websocket.receive", "text": "x"} - - async def send(_msg: dict) -> None: - return None - - async def _run() -> None: - ws = asgi_mod.WebSocket(receive, send) - with pytest.raises(RuntimeError, match="unexpected websocket event before accept"): - await ws.accept() - - asyncio.run(_run()) - - -def test_websocket_receive_text_disconnect_raises() -> None: - async def receive() -> dict: - return {"type": "websocket.disconnect"} - - async def send(_msg: dict) -> None: - return None - - async def _run() -> None: - ws = asgi_mod.WebSocket(receive, send) - with pytest.raises(RuntimeError, match="disconnected"): - await ws.receive_text() - - asyncio.run(_run()) - - -def test_rsgi_protocol_response_bytes_and_empty_enqueue() -> None: - async def _run() -> None: - loop = asyncio.get_running_loop() - q: asyncio.Queue[dict | None] = asyncio.Queue() - p = asgi_mod._RsgiProtocol(b"", q, loop) - p.response_bytes(201, [("x", "1")], b"abc") - p.response_empty(204, []) - msg1 = await q.get() - msg2 = await q.get() - msg3 = await q.get() - msg4 = await q.get() - assert msg1 == { - "type": "http.response.start", - "status": 201, - "headers": [(b"x", b"1")], - } - assert msg2 == {"type": "http.response.body", "body": b"abc"} - assert msg3 == {"type": "http.response.start", "status": 204, "headers": []} - assert msg4 == {"type": "http.response.body"} - - asyncio.run(_run()) - - -def test_build_asgi_caller_non_callable_ws_handler_closes() -> None: - class _Framework: - async def handle_rsgi(self, _scope: object, _proto: object) -> None: - return None - - _handle_asgi_websocket = "not-callable" - - app = asgi_mod.build_asgi_caller(_Framework()) - sent: list[dict] = [] - - async def receive() -> dict: - return {"type": "websocket.connect"} - - async def send(msg: dict) -> None: - sent.append(msg) - - scope = {"type": "websocket", "path": "/x", "headers": [], "query_string": b""} - asyncio.run(app(scope, receive, send)) - assert sent == [{"type": "websocket.close", "code": 1000}] - - -def test_build_asgi_caller_uses_inner_app_when_handle_rsgi_missing() -> None: - called: list[str] = [] - - class _Inner: - def handle_rsgi(self, _scope: object, _proto: object) -> object: - called.append("inner") - - async def _noop() -> None: - return None - - return _noop() - - class _Framework: - def __init__(self) -> None: - self._app = _Inner() - - app = asgi_mod.build_asgi_caller(_Framework()) - sent: list[dict] = [] - incoming = iter([{"type": "http.request", "body": b"", "more_body": False}]) - - async def receive() -> dict: - return next(incoming) - - async def send(msg: dict) -> None: - sent.append(msg) - - scope = { - "type": "http", - "http_version": "1.1", - "scheme": "http", - "method": "GET", - "path": "/x", - "query_string": b"", - "headers": [], - } - asyncio.run(app(scope, receive, send)) - assert called == ["inner"] - - -def test_run_handle_rsgi_blocking_does_not_use_asyncio_run(monkeypatch: pytest.MonkeyPatch) -> None: - def _boom(*_args: object, **_kwargs: object) -> None: - raise AssertionError("asyncio.run must not be used in blocking bridge path") - - monkeypatch.setattr(asgi_mod.asyncio, "run", _boom) - - async def _coro(_scope: object, _proto: object) -> None: - return None - - try: - asgi_mod._run_handle_rsgi_blocking(_coro, object(), object()) - asgi_mod._run_handle_rsgi_blocking(_coro, object(), object()) - finally: - asgi_mod._close_blocking_loop_for_current_thread() diff --git a/tests/test_asgi_stress.py b/tests/test_asgi_stress.py deleted file mode 100644 index e6bad66..0000000 --- a/tests/test_asgi_stress.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Concurrent ASGI requests (issue #17) — no deadlock on many parallel clients.""" - -from __future__ import annotations - -import asyncio - -import httpx -from oxyroute import App - - -def test_asgi_50_concurrent_gets() -> None: - async def _run() -> None: - app = App() - - @app.get("/c") - def c() -> str: - return "ok" - - async def one(client: httpx.AsyncClient) -> int: - r = await client.get("/c") - return r.status_code - - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: - tasks = [one(client) for _ in range(50)] - codes = await asyncio.gather(*tasks) - assert all(c == 200 for c in codes) - - asyncio.run(_run()) diff --git a/tests/test_cors.py b/tests/test_cors.py index 05f7a90..c7ec48b 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App, CORSConfig, apply_cors +from tests._rsgi_test_transport import asgi_test_app def test_cors_preflight_204_allows_post() -> None: @@ -21,7 +22,7 @@ def _x() -> str: return "ok" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.request( "OPTIONS", @@ -48,7 +49,7 @@ def _hi() -> str: return "hello" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/hi", headers={"origin": "https://a.example"}) assert r.status_code == 200 diff --git a/tests/test_cors_units.py b/tests/test_cors_units.py index 556da39..68748dc 100644 --- a/tests/test_cors_units.py +++ b/tests/test_cors_units.py @@ -8,6 +8,7 @@ import httpx from oxyroute import App from oxyroute.cors import CORSConfig, apply_cors +from tests._rsgi_test_transport import asgi_test_app @dataclass @@ -160,7 +161,7 @@ def hi() -> str: return "ok" async def _run() -> None: - tr = httpx.ASGITransport(app=app) + tr = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=tr, base_url="http://t") as c: r = await c.get("/hi", headers={"origin": "https://a.example"}) assert r.status_code == 200 diff --git a/tests/test_csrf.py b/tests/test_csrf.py index b9372b9..09d66d0 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -8,6 +8,7 @@ import httpx from oxyroute import App from oxyroute.csrf import CSRFConfig, apply_csrf +from tests._rsgi_test_transport import asgi_test_app _HDR = "X-CSRF-Token" CK = "oxyroute_csrf" @@ -24,7 +25,7 @@ def _p() -> str: return "ok" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.post( "/p", @@ -48,7 +49,7 @@ def _p2() -> str: return "nope" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.post("/p") assert r.status_code == 403 @@ -68,7 +69,7 @@ def _p3() -> str: return "yes" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.post( "/p", diff --git a/tests/test_dep_chain.py b/tests/test_dep_chain.py index 04f00a8..0eb026e 100644 --- a/tests/test_dep_chain.py +++ b/tests/test_dep_chain.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app def test_dep_second_receives_first_by_name() -> None: @@ -22,7 +23,7 @@ def route(b: int) -> str: return f"v={b}" async def run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/x") assert r.status_code == 200 @@ -42,7 +43,7 @@ def route(trace_id: str) -> str: return trace_id async def run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/t", headers={"X-Trace": "z9"}) assert r.status_code == 200 @@ -65,7 +66,7 @@ def route(b: str) -> str: return b async def run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/a") assert r.status_code == 200 diff --git a/tests/test_form_body.py b/tests/test_form_body.py index fbcb4a2..15f80f0 100644 --- a/tests/test_form_body.py +++ b/tests/test_form_body.py @@ -7,6 +7,7 @@ import httpx from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app def test_urlencoded_form_fields() -> None: @@ -17,7 +18,7 @@ def form_route(form: dict) -> str: return f"{form.get('a')},{form.get('b')}" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.post( "/form", @@ -40,7 +41,7 @@ def up(form: dict, files: list) -> str: return f"note={form.get('note')};name={f0['name']};len={len(data)}" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.post( "/up", @@ -65,7 +66,7 @@ def b(form: dict) -> str: try: async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.post( "/b", diff --git a/tests/test_handler_errors_500.py b/tests/test_handler_errors_500.py index 557a1bc..7841c0a 100644 --- a/tests/test_handler_errors_500.py +++ b/tests/test_handler_errors_500.py @@ -8,6 +8,7 @@ import httpx from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app def _make_app() -> App: @@ -32,7 +33,7 @@ def test_500_no_exception_text_by_default() -> None: app = _make_app() async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: r = await c.get("/boom") assert r.status_code == 500 @@ -50,7 +51,7 @@ def test_500_includes_detail_when_debug_env() -> None: try: async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: r = await c.get("/boom") assert r.status_code == 500 @@ -68,7 +69,7 @@ def test_dependency_error_returns_500() -> None: app = _make_app() async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: r = await c.get("/dep") assert r.status_code == 500 diff --git a/tests/test_head_options.py b/tests/test_head_options.py index 43aea7e..129a78f 100644 --- a/tests/test_head_options.py +++ b/tests/test_head_options.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App, Response +from tests._rsgi_test_transport import asgi_test_app def test_asgi_head_same_path_as_get_empty_body() -> None: @@ -16,7 +17,7 @@ def h() -> str: return "hello" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: g = await c.get("/h") head = await c.request("HEAD", "/h") @@ -37,7 +38,7 @@ def x() -> str: return "y" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.request("HEAD", "/openapi.json") assert r.status_code == 200 @@ -56,7 +57,7 @@ def preflight() -> str: return "ok" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.request("OPTIONS", "/cors") assert r.status_code == 200 @@ -77,7 +78,7 @@ def r() -> Response: ) async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: g = await c.get("/r") head = await c.request("HEAD", "/r") diff --git a/tests/test_header_sanitization.py b/tests/test_header_sanitization.py index 1f61261..6d59ac9 100644 --- a/tests/test_header_sanitization.py +++ b/tests/test_header_sanitization.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App, HTTPException, Response +from tests._rsgi_test_transport import asgi_test_app def test_response_header_crlf_is_rejected_with_500() -> None: @@ -16,7 +17,7 @@ def h() -> Response: return Response(status=200, body="ok", headers={"X-Bad\r\nInjected": "1"}) async def _run() -> None: - tr = httpx.ASGITransport(app=app) + tr = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: r = await c.get("/h") assert r.status_code == 500 @@ -34,7 +35,7 @@ def c() -> Response: return Response(status=200, body="ok", cookies=["a=1\r\nX: y"]) async def _run() -> None: - tr = httpx.ASGITransport(app=app) + tr = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: r = await c.get("/c") assert r.status_code == 500 @@ -51,7 +52,7 @@ def e() -> str: raise HTTPException(400, "bad", headers={"X-Err\nInject": "1"}) async def _run() -> None: - tr = httpx.ASGITransport(app=app) + tr = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: r = await c.get("/e") assert r.status_code == 500 diff --git a/tests/test_http_exception.py b/tests/test_http_exception.py index 0b6d7fe..cae3e25 100644 --- a/tests/test_http_exception.py +++ b/tests/test_http_exception.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App, HTTPException +from tests._rsgi_test_transport import asgi_test_app def test_http_exception_404_string_detail() -> None: @@ -16,7 +17,7 @@ def x() -> str: raise HTTPException(404, "nope") async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/x") assert r.status_code == 404 @@ -33,7 +34,7 @@ def e() -> str: raise HTTPException(422, {"errors": [{"loc": "x", "msg": "bad"}]}) async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/e") assert r.status_code == 422 @@ -50,7 +51,7 @@ def h() -> str: raise HTTPException(400, "bad", headers={"X-App": "1"}) async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/h") assert r.status_code == 400 @@ -68,7 +69,7 @@ def b() -> str: raise ValueError("oops") async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/b") assert r.status_code == 500 diff --git a/tests/test_jwt_cookie.py b/tests/test_jwt_cookie.py index 66782be..2148b9b 100644 --- a/tests/test_jwt_cookie.py +++ b/tests/test_jwt_cookie.py @@ -8,6 +8,7 @@ import httpx import pytest from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app oxyjwt = pytest.importorskip("oxyjwt") @@ -40,7 +41,7 @@ def c(claims: dict) -> str: tok = _token() async def run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: r = await client.get( "/c", @@ -78,7 +79,7 @@ def b(claims: dict) -> str: ) async def run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: r = await client.get( "/b", diff --git a/tests/test_jwt_route_iss_aud.py b/tests/test_jwt_route_iss_aud.py index 417a419..2e95545 100644 --- a/tests/test_jwt_route_iss_aud.py +++ b/tests/test_jwt_route_iss_aud.py @@ -8,6 +8,7 @@ import httpx import pytest from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app oxyjwt = pytest.importorskip("oxyjwt") @@ -38,7 +39,7 @@ def ok(claims: dict) -> str: tok = _token(sub="u1", iss=ISS, aud=AUD, exp=now + 3600) async def run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/ok", headers={"Authorization": f"Bearer {tok}"}) assert r.status_code == 200 @@ -65,7 +66,7 @@ def p(claims: dict) -> str: tok = _token(sub="u1", iss="https://other", aud=AUD, exp=now + 3600) async def run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/p", headers={"Authorization": f"Bearer {tok}"}) assert r.status_code == 401 @@ -91,7 +92,7 @@ def p(claims: dict) -> str: tok = _token(sub="u1", iss=ISS, aud="other-aud", exp=now + 3600) async def run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/p", headers={"Authorization": f"Bearer {tok}"}) assert r.status_code == 401 diff --git a/tests/test_jwt_rs256.py b/tests/test_jwt_rs256.py index da48053..3632f5a 100644 --- a/tests/test_jwt_rs256.py +++ b/tests/test_jwt_rs256.py @@ -10,6 +10,7 @@ import jwt import pytest from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app _FIX = Path(__file__).resolve().parent / "fixtures" / "rsa" _PUB = (_FIX / "public_pkcs8.pem").read_text() @@ -32,7 +33,7 @@ def r(claims: object) -> str: return str(c.get("sub", "")) async def _go() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: o = await c.get("/r", headers={"Authorization": f"Bearer {tok}"}) assert o.status_code == 200 diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 90f65a1..5830f5f 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App, Response +from tests._rsgi_test_transport import asgi_test_app def test_middleware_cors_preflight_204_no_route_ran() -> None: @@ -30,7 +31,7 @@ def _x() -> str: return "ok" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.request( "OPTIONS", diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 1b6a386..9f7cffe 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -4,6 +4,7 @@ import httpx import pytest from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app def test_openapi_shows_route() -> None: @@ -38,7 +39,7 @@ def a() -> str: return "1" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: g = await c.get("/openapi.json") h = await c.request("HEAD", "/openapi.json") @@ -61,7 +62,7 @@ def z() -> str: return "z" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/openapi.json") assert r.status_code == 404 @@ -79,7 +80,7 @@ def b() -> str: return "b" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/openapi.json") assert r.status_code == 200, r.text diff --git a/tests/test_query_decode.py b/tests/test_query_decode.py index b62d188..98bc82c 100644 --- a/tests/test_query_decode.py +++ b/tests/test_query_decode.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app def test_query_value_percent_decoded() -> None: @@ -16,7 +17,7 @@ def echo(**kwargs) -> str: return (kwargs.get("query") or {}).get("m", "") async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/echo?m=hello%20world") assert r.status_code == 200 diff --git a/tests/test_routing_autocompile.py b/tests/test_routing_autocompile.py index 61a3c1f..19cbcc9 100644 --- a/tests/test_routing_autocompile.py +++ b/tests/test_routing_autocompile.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App +from tests._rsgi_test_transport import asgi_test_app def test_routes_added_after_first_request_still_resolve() -> None: @@ -16,7 +17,7 @@ def a() -> str: return "a" async def _run() -> None: - tr = httpx.ASGITransport(app=app) + tr = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: r1 = await c.get("/a") assert r1.status_code == 200 @@ -42,7 +43,7 @@ def x() -> str: return "ok" async def _run() -> None: - tr = httpx.ASGITransport(app=app) + tr = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=tr, base_url="http://test") as c: r = await c.get("/x") assert r.status_code == 405 diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py index f9fe812..49634d3 100644 --- a/tests/test_security_headers.py +++ b/tests/test_security_headers.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App, SecurityHeadersConfig +from tests._rsgi_test_transport import asgi_test_app def test_security_headers_merged_on_get() -> None: @@ -22,7 +23,7 @@ def _h() -> str: return "x" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="https://test") as c: r = await c.get("/h") assert r.status_code == 200 @@ -47,7 +48,7 @@ def _h2() -> str: return "x" async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/h") assert r.status_code == 200 @@ -70,7 +71,7 @@ def _h3() -> Response: ) async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/h") assert (r.headers.get("x-frame-options") or "") == "ALLOWALL" diff --git a/tests/test_sse.py b/tests/test_sse.py index 26a0376..ce6b44c 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -6,6 +6,7 @@ import httpx from oxyroute import App, send_sse +from tests._rsgi_test_transport import asgi_test_app def test_sse_response_body_and_content_type() -> None: @@ -17,7 +18,7 @@ async def events(protocol: object) -> object: return await send_sse(protocol, data) # type: ignore[arg-type] async def _run() -> None: - transport = httpx.ASGITransport(app=app) + transport = httpx.ASGITransport(app=asgi_test_app(app)) async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: r = await c.get("/events") assert r.status_code == 200 diff --git a/tests/test_websocket_asgi.py b/tests/test_websocket_asgi.py deleted file mode 100644 index 745e636..0000000 --- a/tests/test_websocket_asgi.py +++ /dev/null @@ -1,178 +0,0 @@ -"""ASGI websocket spike for issue #52.""" - -from __future__ import annotations - -import asyncio -import threading -from collections import deque -from typing import Any - -import pytest -from oxyroute import App - - -def test_asgi_websocket_echo_flow() -> None: - app = App() - - @app.websocket("/ws") - async def ws(sock: Any) -> None: - await sock.accept() - text = await sock.receive_text() - await sock.send_text(f"echo:{text}") - await sock.close(code=1000) - - incoming: deque[dict[str, Any]] = deque( - [ - {"type": "websocket.connect"}, - {"type": "websocket.receive", "text": "ping"}, - ] - ) - sent: list[dict[str, Any]] = [] - - async def receive() -> dict[str, Any]: - if incoming: - return incoming.popleft() - return {"type": "websocket.disconnect", "code": 1000} - - async def send(message: dict[str, Any]) -> None: - sent.append(message) - - scope = { - "type": "websocket", - "path": "/ws", - "headers": [], - "query_string": b"", - "scheme": "ws", - } - - async def _run() -> None: - await app(scope, receive, send) - - asyncio.run(_run()) - - assert sent[0]["type"] == "websocket.accept" - assert sent[1]["type"] == "websocket.send" - assert sent[1]["text"] == "echo:ping" - assert sent[2]["type"] == "websocket.close" - - -def test_asgi_websocket_sync_handler_runs_off_loop_thread() -> None: - app = App() - seen_thread: list[int] = [] - - @app.websocket("/ws-sync") - def ws_sync(sock: Any) -> None: - seen_thread.append(threading.get_ident()) - asyncio.run(sock.accept()) - asyncio.run(sock.close(code=1000)) - - incoming: deque[dict[str, Any]] = deque([{"type": "websocket.connect"}]) - sent: list[dict[str, Any]] = [] - - async def receive() -> dict[str, Any]: - if incoming: - return incoming.popleft() - return {"type": "websocket.disconnect", "code": 1000} - - async def send(message: dict[str, Any]) -> None: - sent.append(message) - - scope = { - "type": "websocket", - "path": "/ws-sync", - "headers": [], - "query_string": b"", - "scheme": "ws", - } - - async def _run() -> None: - loop_thread = threading.get_ident() - await app(scope, receive, send) - assert seen_thread - assert seen_thread[0] != loop_thread - - asyncio.run(_run()) - assert sent[0]["type"] == "websocket.accept" - assert sent[1]["type"] == "websocket.close" - - -def test_asgi_websocket_unknown_path_closes() -> None: - app = App() - sent: list[dict[str, Any]] = [] - - async def receive() -> dict[str, Any]: - return {"type": "websocket.connect"} - - async def send(message: dict[str, Any]) -> None: - sent.append(message) - - scope = { - "type": "websocket", - "path": "/missing", - "headers": [], - "query_string": b"", - "scheme": "ws", - } - - asyncio.run(app(scope, receive, send)) - assert sent == [{"type": "websocket.close", "code": 1000}] - - -def test_asgi_websocket_accept_disconnect_before_connect_raises() -> None: - app = App() - - @app.websocket("/ws") - async def ws(sock: Any) -> None: - await sock.accept() - - incoming: deque[dict[str, Any]] = deque([{"type": "websocket.disconnect", "code": 1000}]) - - async def receive() -> dict[str, Any]: - return incoming.popleft() - - async def send(_message: dict[str, Any]) -> None: - return None - - scope = { - "type": "websocket", - "path": "/ws", - "headers": [], - "query_string": b"", - "scheme": "ws", - } - - with pytest.raises(RuntimeError, match="disconnected before accept"): - asyncio.run(app(scope, receive, send)) - - -def test_asgi_websocket_receive_text_non_text_frame_raises() -> None: - app = App() - - @app.websocket("/ws") - async def ws(sock: Any) -> None: - await sock.accept() - await sock.receive_text() - - incoming: deque[dict[str, Any]] = deque( - [ - {"type": "websocket.connect"}, - {"type": "websocket.receive", "bytes": b"x"}, - ] - ) - - async def receive() -> dict[str, Any]: - return incoming.popleft() - - async def send(_message: dict[str, Any]) -> None: - return None - - scope = { - "type": "websocket", - "path": "/ws", - "headers": [], - "query_string": b"", - "scheme": "ws", - } - - with pytest.raises(RuntimeError, match="expected text websocket frame"): - asyncio.run(app(scope, receive, send)) diff --git a/uv.lock b/uv.lock index 4549bf4..c094191 100644 --- a/uv.lock +++ b/uv.lock @@ -392,7 +392,7 @@ wheels = [ [[package]] name = "oxyroute" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } [package.optional-dependencies]