diff --git a/AGENTS.md b/AGENTS.md index a5807b5..65a9c41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,11 +105,113 @@ The site is served from `https://j03-dev.github.io/oxapy/`. Two ways to publish: - Use relative links, and mind the depth prefix: from `tutorial/`, `guides/`, `advanced/`, or `api/` pages, links to a sibling category need `../` (e.g. `../guides/routing`); from `intro.md` use `./guides/routing`. The `onBrokenLinks: 'throw'` build is the safety net — never ship a doc change without running `npx docusaurus build`. - API pages (`docs/docs/api/*`) document the Python surface; guides (`docs/docs/guides/*`) teach usage with examples; `docs/docs/tutorial/notes-api.md` walks through a complete production-style app (SQLAlchemy + serializers + JWT + async mode). +## Project Structure + +``` +oxapy/ +├── Cargo.toml # Rust crate config (pyo3 cdylib + rlib) +├── pyproject.toml # Python packaging (maturin) +├── build.sh # Build helper script +├── src/ +│ ├── lib.rs # HttpServer, server loop, request dispatch, PyModule registration +│ ├── middleware.rs # Middleware chain builder (sequence-based wrapping) +│ ├── request.rs # Request struct, RequestBuilder, cookie/JSON/form parsing +│ ├── response.rs # Response struct, Redirect, FileStreaming, header manipulation +│ ├── routing.rs # Route, Router, HTTP method decorators (get/post/etc), matchit +│ ├── status.rs # Status enum (all HTTP status codes) +│ ├── into_response.rs # convert_to_response: normalizes handler returns to Response +│ ├── exceptions.rs # BadRequest/Unauthorized/Forbidden/NotFound/Conflict/InternalError +│ ├── cors.rs # CORS config and header injection +│ ├── jwt.rs # JWT encode/decode (jsonwebtoken crate) +│ ├── json.rs # JSON serialization (wraps orjson) +│ ├── multipart.rs # Multipart form/file parsing (multer crate) +│ ├── templating.rs # Tera template engine + render() function +│ └── serializer/ +│ ├── mod.rs # Serializer class (DRF-style: validate, create, save, update) +│ └── fields.rs # Field types (Char, Email, Integer, Boolean, Number, UUID, etc.) +├── oxapy/ +│ ├── __init__.py # Python re-exports + Oxapy (hot-reload), Session, CsrfProtect, static_file +│ ├── __init__.pyi # Auto-generated type stubs +│ ├── jwt/__init__.pyi # JWT stubs +│ ├── exceptions/__init__.pyi # Exception stubs +│ ├── serializer/__init__.pyi # Serializer stubs +│ └── templating/__init__.pyi # Template stubs +└── tests/ + ├── conftest.py # Test server fixture (Oxapy on port 9999, auth middleware demo) + ├── app.py # Minimal async Oxapy example + ├── test_http_server.py # Integration tests (ping, echo, forms, uploads, auth, redirects) + ├── test_session.py # JWT encode/decode tests + ├── test_response.py # Response/Redirect unit tests + ├── test_cors.py # CORS config tests + ├── test_serializer.py # Serializer tests + ├── test_exceptions.py # Exception tests + └── utils.py # Multipart test helper +``` + +## Architecture + +### Request Lifecycle + +``` +Client → TcpListener → RequestBuilder → Request::process() + → OPTIONS + CORS configured? → return preflight response + → iterate routers → router.find(method, uri) via matchit + → if match: create ProcessRequest → mpsc channel + → process_requests loop: + → build middleware chain (sequence-based wrapping) + → call_python_handler: middleware chain → Python handler + → convert_to_response (normalize return type) + → wrapper(request, response) [if HttpServer.wrap() configured] + → response.apply_cors(headers) + → send via oneshot channel → hyper Response → Client +``` + +### Middleware System + +Middleware is **sequence-based** and wraps handlers. Each middleware receives a `next` keyword argument: + +```python +def my_middleware(request, next, **kwargs): + # runs BEFORE handler + result = next(request, **kwargs) # calls next layer (or handler) + # result is the handler's return value + return result +``` + +- `Router.middleware(fn)` registers middleware; it applies to routes registered **after** it +- Chain is built recursively: last middleware wraps the handler, second-to-last wraps that, etc. +- Multiple `Router` instances with different middleware can be attached to the same server + +### Template System (Tera) + +- Templates are loaded via `Template.load(glob_pattern)` (Tera syntax) +- Custom functions must be registered **before** `load()`: `template.register_function("name", fn)` +- `render(request, "template.html", context)` auto-injects: + - `session` dict (if `Session` middleware is active and `request.session` exists) + - `csrf_token` string (if `CsrfProtect` middleware is active and `request.csrf_token` exists) + - `csrf_token` string (if `CsrfProtect` middleware is active and `request.csrf_token` exists) + +### Python Modules (oxapy/__init__.py) + +Pure Python features implemented in `oxapy/__init__.py`: +- **`Session(secret, max_age)`** — Signed cookie middleware (HMAC-SHA256) +- **`CsrfProtect(secret, ...)`** — CSRF protection middleware + `csrf_input()` template helper +- **`secure_join(base, *paths)`** — Path traversal protection +- **`static_file(path, directory)`** — Static file serving route +- **`send_file(path)`** — File response helper + +Rust-implemented features exposed via PyO3: +- `HttpServer`, `Router`, `Route`, `Request`, `Response`, `Status`, `Cors`, `Redirect`, `FileStreaming` +- HTTP method decorators: `get`, `post`, `put`, `patch`, `delete`, `head`, `options` +- `templating.Template`, `templating.render` +- `jwt.Jwt` (encode/decode), `exceptions.*`, `serializer.Serializer` + ## Code Style Guidelines ### General Project Structure - **Rust source**: `src/` directory with modular `.rs` files +- **Python source**: `oxapy/__init__.py` for pure-Python features, Rust for core server - **Python tests**: `tests/` directory - **Docs site**: `docs/` directory (Docusaurus; markdown in `docs/docs/`) diff --git a/TODO.md b/TODO.md index 6627ca1..d1dedd3 100644 --- a/TODO.md +++ b/TODO.md @@ -91,32 +91,25 @@ Roadmap based on feature gap analysis against Flask, FastAPI, Django, Litestar, - [ ] Add `response.delete_cookie(name, path, domain)` - [ ] Type-safe API instead of manual `insert_header("set-cookie", "...")` -### 13. CSRF Protection -- [ ] Add `CsrfMiddleware` that generates and validates CSRF tokens -- [ ] Support synchronizer token pattern (double submit cookie) -- [ ] Auto-exempt safe methods (GET, HEAD, OPTIONS) -- [ ] Configurable exempt routes/patterns -- [ ] Integrate with session middleware - -### 14. Per-Status Error Handlers +### 13. Per-Status Error Handlers - [ ] Add `@app.errorhandler(404)` decorator - [ ] Add `@app.exception_handler(ExceptionType)` decorator - [ ] Override default exception-to-status mapping - [ ] Support custom error pages (HTML) and error responses (JSON) -### 15. URL Reverse Routing (`url_for`) +### 14. URL Reverse Routing (`url_for`) - [ ] Register route names alongside path patterns - [ ] Add `url_for(route_name, **params)` function - [ ] Generate correct URLs with path parameters filled in - [ ] Useful for templates, redirects, and emails -### 16. OAuth2 / Security Utilities +### 15. OAuth2 / Security Utilities - [ ] Add `OAuth2PasswordBearer(tokenUrl="/token")` dependency - [ ] Add `HTTPBasic` dependency for HTTP Basic auth - [ ] Add `APIKeyHeader` / `APIKeyQuery` dependencies - [ ] Support OAuth2 scopes -### 17. Content Negotiation +### 16. Content Negotiation - [ ] Inspect `Accept` header to determine response format - [ ] Support multiple serializers per route (JSON, XML, MessagePack) - [ ] Default to JSON, fallback based on client preference @@ -125,32 +118,22 @@ Roadmap based on feature gap analysis against Flask, FastAPI, Django, Litestar, ## Nice-to-Have (P2) -### 18. Class-Based Views / Controllers -- [ ] Add `Controller` class that groups related route handlers -- [ ] Support shared middleware, pre/post hooks per controller -- [ ] Auto-register routes from controller methods - -### 19. Blueprint / Module System -- [ ] Add `Blueprint` class for splitting routes across files -- [ ] Support `app.register_blueprint(bp, prefix="/api/v1")` -- [ ] Auto-merge middleware and static files from blueprints - -### 20. CLI Runner +### 17. CLI Runner - [ ] Add `oxapy run app:main` command - [ ] Auto-detect uvicorn-like reload in dev - [ ] Support `--host`, `--port`, `--reload` flags -### 21. Rate Limiting +### 18. Rate Limiting - [ ] Add `RateLimitMiddleware` with configurable limits - [ ] Support per-IP and per-route limits - [ ] Use in-memory store or pluggable backend (Redis) -### 22. Settings / Environment Configuration +### 19. Settings / Environment Configuration - [ ] Add `Settings` base class (pydantic-settings style) - [ ] Load from `.env` files and environment variables - [ ] Type validation at startup -### 23. i18n / Localization +### 20. i18n / Localization - [ ] Add `gettext`-style translation function - [ ] Support locale detection from `Accept-Language` header - [ ] Date/number formatting per locale @@ -160,22 +143,22 @@ Roadmap based on feature gap analysis against Flask, FastAPI, Django, Litestar, ## Bug Fixes & Quality ### Stubs -- [ ] Fix `Session()` return type in `__init__.pyi` — should be `Callable`, not `Response` -- [ ] Remove `catcher` from `__init__.py.__all__` (doesn't exist) -- [ ] Remove `"from typing_extensions import Self"` from stub `__all__` -- [ ] Fix docstrings: `app_data()`, `attach()`, `wrap()` say `Returns: None` but return `self` +- [x] Fix `Session()` return type in `__init__.pyi` — should be `Callable`, not `Response` +- [x] Remove `catcher` from `__init__.py.__all__` (doesn't exist) +- [x] Remove `"from typing_extensions import Self"` from stub `__all__` +- [x] Fix docstrings: `app_data()`, `attach()`, `wrap()` say `Returns: None` but return `self` ### Security -- [ ] Change `SameSite=Lax` to `SameSite=Strict` on session cookies (or make configurable) +- [x] Change `SameSite=Lax` to `SameSite=Strict` on session cookies (or make configurable) - [ ] Add `Origin` / `Referer` header check in session middleware for state-changing methods -- [ ] Replace `unwrap()` in `insert_header` / `append_header` with proper error handling +- [x] Replace `unwrap()` in `insert_header` / `append_header` with proper error handling ### Performance -- [ ] Cache `Regex::new` in `parse_params_value` (slug parsing) — currently recompiles every call +- [x] Cache `Regex::new` in `parse_params_value` (slug parsing) — currently recompiles every call - [ ] Evaluate middleware chain `py.eval()` overhead — consider alternatives ### Safety -- [ ] Replace `unsafe { std::mem::transmute }` in `request.rs:286` with safe alternative (e.g., `ouroboros` or restructure lifetime) +- [x] Replace `unsafe { std::mem::transmute }` ### Cleanup - [ ] Remove `#![allow(unused_variables, non_snake_case)]` crate-level attribute — fix individually diff --git a/oxapy/__init__.py b/oxapy/__init__.py index c4c86e1..4f1b65e 100644 --- a/oxapy/__init__.py +++ b/oxapy/__init__.py @@ -1,4 +1,5 @@ import os +import secrets import threading import sys import subprocess @@ -203,7 +204,7 @@ def _verify_session(secret: bytes, cookie: str) -> dict[str, typing.Any] | None: return None -def _session_middleware(request, next, secret, max_age, **kwargs): +def _session_middleware(request, next, secret, max_age, same_site, **kwargs): cookie = request.get_cookie("session") session_data = {} @@ -229,7 +230,7 @@ def _session_middleware(request, next, secret, max_age, **kwargs): f"Path=/; " f"HttpOnly; " f"Secure; " - f"SameSite=Lax; " + f"SameSite={same_site}; " f"Max-Age={max_age}" ), ) @@ -237,7 +238,7 @@ def _session_middleware(request, next, secret, max_age, **kwargs): return response -def Session(secret: bytes, max_age: int = 3600 * 24 * 7): +def Session(secret: bytes, max_age: int = 3600 * 24 * 7, same_site="Lax"): r""" Create a session middleware for signed, client-side cookie storage. @@ -283,7 +284,151 @@ def main(): main() """ - return partial(_session_middleware, secret=secret, max_age=max_age) + return partial(_session_middleware, secret=secret, max_age=max_age, same_site=same_site) + +def _generate_csrf_token(length: int = 32) -> str: + return secrets.token_urlsafe(length) + + +def _sign_csrf_token(secret: bytes, token: str) -> str: + signature = hmac.new(secret, token.encode(), hashlib.sha256).hexdigest() + return f"{token}.{signature}" + + +def _verify_csrf_token(secret: bytes, signed: str) -> str | None: + try: + token, signature = signed.split(".", 1) + expected = hmac.new(secret, token.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(signature, expected): + return None + return token + except Exception: + return None + +class CsrfProtect: + r""" + CSRF protection middleware using the Double Submit Cookie pattern. + + Validates a signed token on state-changing requests (POST, PUT, DELETE, PATCH) + and sets a readable cookie on every response. + + The middleware stores the token on ``request.csrf_token``. The Rust ``render()`` + function automatically injects ``csrf_token`` into the template context, so + Tera templates can use ``{{ csrf_input(csrf_token) }}`` to render the hidden + ```` — no manual passing required. + + Args: + secret (bytes): HMAC signing key for the token. + cookie_name (str): Name of the cookie storing the signed token. + header_name (str): Request header to check for the token (AJAX). + field_name (str): Form/JSON field name for the token. + cookie_max_age (int): Cookie lifetime in seconds (default 1 hour). + safe_methods (tuple): HTTP methods that skip validation. + + Example: + ```python + from oxapy import HttpServer, Router, CsrfProtect, get, post, render + from oxapy import templating + + csrf = CsrfProtect(secret=b"my-secret-key") + + template = templating.Template() + template.load("./templates/**/*.html") + + @get("/form") + def form_view(request): + return render(request, "form.html") + + @post("/submit") + def submit(request): + return {"status": "ok"} + + router = Router() + router.middleware(csrf) + router.routes([form_view, submit]) + + HttpServer(("0.0.0.0", 8000)).template(template).attach(router).run() + ``` + + Templates: + ```html +
+ {{ csrf_input(csrf_token) }} + + +
+ ``` + + AJAX: + ```javascript + const token = document.cookie.match(/csrf_token=([^;]+)/)?.[1]; + fetch('/api/data', { + method: 'POST', + headers: { 'X-CSRF-Token': token }, + body: JSON.stringify({ key: 'value' }) + }); + ``` + """ + + def __init__( + self, + secret: bytes, + cookie_name: str = "csrf_token", + header_name: str = "x-csrf-token", + field_name: str = "_csrf_token", + cookie_max_age: int = 3600, + safe_methods: tuple[str, ...] = ("GET", "HEAD", "OPTIONS", "TRACE"), + ): + self.secret = secret + self.cookie_name = cookie_name + self.header_name = header_name + self.field_name = field_name + self.cookie_max_age = cookie_max_age + self.safe_methods = safe_methods + + def __call__(self, request, next, **kwargs): + raw_cookie = request.get_cookie(self.cookie_name) + token = None + if raw_cookie: + token = _verify_csrf_token(self.secret, raw_cookie) + + if token is None: + token = _generate_csrf_token() + + request.csrf_token = token + + if request.method.upper() in self.safe_methods: + response = convert_to_response(next(request, **kwargs)) + else: + submitted = request.headers.get(self.header_name) + if not submitted and self.field_name in request.form: + submitted = request.form[self.field_name] + if not submitted: + try: + body = request.json() + if isinstance(body, dict): + submitted = body.get(self.field_name) + except Exception: + pass + + if not submitted or not hmac.compare_digest(submitted, token): + raise exceptions.ForbiddenError("CSRF token missing or invalid") + + response = convert_to_response(next(request, **kwargs)) + + signed = _sign_csrf_token(self.secret, token) + response.insert_header( + "set-cookie", + ( + f"{self.cookie_name}={signed}; " + f"Path=/; " + f"Secure; " + f"SameSite=Lax; " + f"Max-Age={self.cookie_max_age}" + ), + ) + + return response def secure_join(base: str, *paths: str) -> str: @@ -351,6 +496,7 @@ def send_file(path: str) -> Response: "Request", "Cors", "Session", + "CsrfProtect", "Redirect", "FileStreaming", "File", diff --git a/oxapy/__init__.pyi b/oxapy/__init__.pyi index a609491..d124276 100644 --- a/oxapy/__init__.pyi +++ b/oxapy/__init__.pyi @@ -11,6 +11,7 @@ from . import serializer from . import templating __all__ = [ "Cors", + "CsrfProtect", "File", "FileStreaming", "HttpServer", @@ -389,7 +390,7 @@ class HttpServer: app_data (any): Any Python object to be stored as application data. Returns: - None + Self Example: ```python @@ -423,7 +424,7 @@ class HttpServer: router (Router): The router instance to attach. Returns: - None + Self Example: ```python @@ -454,7 +455,7 @@ class HttpServer: template (Template): An instance of Template for rendering HTML. Returns: - None + Self Example: ```python @@ -471,7 +472,7 @@ class HttpServer: cors (Cors): An instance of Cors with your desired CORS configuration. Returns: - None + Self Example: ```python @@ -488,7 +489,7 @@ class HttpServer: max_connections (int): Maximum number of concurrent connections. Returns: - None + Self Example: ```python @@ -506,7 +507,7 @@ class HttpServer: channel_capacity (int): The channel capacity. Returns: - None + Self Example: ```python @@ -1410,7 +1411,9 @@ class Status(enum.Enum): int: The status code """ -def Session(secret: bytes, max_age: builtins.int = 604800) -> Response: ... +def CsrfProtect(secret: bytes, cookie_name: builtins.str = 'csrf_token', header_name: builtins.str = 'x-csrf-token', field_name: builtins.str = '_csrf_token', cookie_max_age: builtins.int = 3600) -> typing.Any: ... + +def Session(secret: bytes, max_age: builtins.int = 604800) -> typing.Any: ... def convert_to_response(result: typing.Any) -> Response: r""" diff --git a/src/cors.rs b/src/cors.rs index 4a1c72c..efe86be 100644 --- a/src/cors.rs +++ b/src/cors.rs @@ -110,13 +110,14 @@ impl Cors { } impl Cors { - pub fn apply_headers(&self, response: &mut Response) { - response.insert_header("Access-Control-Allow-Origin", &self.origins.join(", ")); - response.insert_header("Access-Control-Allow-Methods", &self.methods.join(", ")); - response.insert_header("Access-Control-Allow-Headers", &self.headers.join(", ")); + pub fn apply_headers(&self, response: &mut Response) -> PyResult<()> { + response.insert_header("Access-Control-Allow-Origin", &self.origins.join(", "))?; + response.insert_header("Access-Control-Allow-Methods", &self.methods.join(", "))?; + response.insert_header("Access-Control-Allow-Headers", &self.headers.join(", "))?; if self.allow_credentials { - response.insert_header("Access-Control-Allow-Credentials", "true"); + response.insert_header("Access-Control-Allow-Credentials", "true")?; } - response.insert_header("Access-Control-Max-Age", &self.max_age.to_string()); + response.insert_header("Access-Control-Max-Age", &self.max_age.to_string())?; + Ok(()) } } diff --git a/src/into_response.rs b/src/into_response.rs index e6c5c56..7cc03fc 100644 --- a/src/into_response.rs +++ b/src/into_response.rs @@ -107,11 +107,13 @@ impl From for Response { } } -impl From for Response { - fn from(cors: Cors) -> Self { +impl TryFrom for Response { + type Error = PyErr; + + fn try_from(cors: Cors) -> Result { let mut response = Response::from(Status::NO_CONTENT); - cors.apply_headers(&mut response); - response + cors.apply_headers(&mut response)?; + Ok(response) } } diff --git a/src/lib.rs b/src/lib.rs index 9d5529b..92f2aed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,7 +50,7 @@ pyo3_stub_gen::export_verbatim!("oxapy", "from typing_extensions import Self"); pyo3_stub_gen::define_stub_info_gatherer!(stub_info); struct ProcessRequest { - match_route: Option>, + match_route: Option, middlewares: Option>, request: Arc, response_sender: oneshot::Sender, @@ -220,7 +220,7 @@ impl HttpServer { /// app_data (any): Any Python object to be stored as application data. /// /// Returns: - /// None + /// Self /// /// Example: /// ```python @@ -256,7 +256,7 @@ impl HttpServer { /// router (Router): The router instance to attach. /// /// Returns: - /// None + /// Self /// /// Example: /// ```python @@ -289,7 +289,7 @@ impl HttpServer { /// template (Template): An instance of Template for rendering HTML. /// /// Returns: - /// None + /// Self /// /// Example: /// ```python @@ -308,7 +308,7 @@ impl HttpServer { /// cors (Cors): An instance of Cors with your desired CORS configuration. /// /// Returns: - /// None + /// Self /// /// Example: /// ```python @@ -327,7 +327,7 @@ impl HttpServer { /// max_connections (int): Maximum number of concurrent connections. /// /// Returns: - /// None + /// Self /// /// Example: /// ```python @@ -347,7 +347,7 @@ impl HttpServer { /// channel_capacity (int): The channel capacity. /// /// Returns: - /// None + /// Self /// /// Example: /// ```python @@ -537,7 +537,7 @@ impl HttpServer { .await .unwrap_or_else(Response::from) .call_wrapper(&pr) - .apply_cors(&pr.cors); + .apply_cors(&pr.cors)?; let _ = pr.response_sender.send(response); }, _ = shutdown.wait() => break, @@ -547,15 +547,15 @@ impl HttpServer { } } -async fn call_python_handler<'l>( +async fn call_python_handler( middlewares: &Option>, - match_route: &Option>, + match_route: &Option, request: &Request, is_async: bool, ) -> PyResult { if let Some(match_route) = match_route { let mut result = Python::attach(|py| { - let route = match_route.value; + let route = &match_route.value; let params = &match_route.params; let kwargs = build_route_params(py, params)?; @@ -584,7 +584,7 @@ async fn call_python_handler<'l>( fn build_route_params<'py>( py: Python<'py>, - params: &matchit::Params, + params: &[(String, String)], ) -> PyResult> { let kwargs = PyDict::new(py); for (key, value) in params.iter() { @@ -643,11 +643,25 @@ fn send_file(path: &str) -> Response { #[gen_stub_pyfunction] #[pyfunction] #[pyo3(signature=(secret, max_age = 3600 * 24 * 7))] -fn Session(secret: Py, max_age: i32) -> Response { +fn Session(secret: Py, max_age: i32) -> Py { // the implementation of this function is in __init__.py todo!("dummy session_middleware fonction") } +#[gen_stub_pyfunction] +#[pyfunction] +#[pyo3(signature=(secret, cookie_name = "csrf_token", header_name = "x-csrf-token", field_name = "_csrf_token", cookie_max_age = 3600))] +fn CsrfProtect( + secret: Py, + cookie_name: &str, + header_name: &str, + field_name: &str, + cookie_max_age: i32, +) -> Py { + // the implementation of this function is in __init__.py + todo!("dummy CsrfProtect function") +} + #[pymodule] fn oxapy(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; @@ -672,6 +686,7 @@ fn oxapy(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(send_file, m)?)?; m.add_function(wrap_pyfunction!(static_file, m)?)?; m.add_function(wrap_pyfunction!(Session, m)?)?; + m.add_function(wrap_pyfunction!(CsrfProtect, m)?)?; exceptions::exceptions(m)?; jwt::jwt_submodule(m)?; diff --git a/src/request.rs b/src/request.rs index 0a33335..d286cbf 100644 --- a/src/request.rs +++ b/src/request.rs @@ -18,7 +18,7 @@ use crate::status::Status; use crate::{ Context, IntoPyException, ProcessRequest, json, multipart::File, templating::Template, }; -use crate::{middleware::Middleware, routing::MatchRoute}; +use crate::{middleware::Middleware, routing::MatchRoute, routing::OwnedMatchRoute}; use crate::{multipart::parse_multipart, response::Body}; /// HTTP request object containing information about the incoming request. @@ -255,7 +255,9 @@ impl Request { if self.method == "OPTIONS" && let Some(ref cors) = ctx.cors { - return Response::from((**cors).clone()).try_into(); + return Response::try_from((**cors).clone()) + .unwrap_or_else(Response::from) + .try_into(); } let method = self.method.clone(); @@ -283,10 +285,10 @@ impl Request { ) -> Result, hyper::http::Error> { let (response_sender, response_receiver) = oneshot::channel(); - let transmutate_route: MatchRoute<'static> = unsafe { std::mem::transmute(match_route) }; + let owned_match_route = OwnedMatchRoute::from(match_route); let process_request = ProcessRequest { - match_route: Some(transmutate_route), + match_route: Some(owned_match_route), middlewares, request: Arc::new(self), response_sender, diff --git a/src/response.rs b/src/response.rs index e5846b8..3308568 100644 --- a/src/response.rs +++ b/src/response.rs @@ -164,9 +164,11 @@ impl Response { /// response = Response("Hello") /// response.insert_header("Cache-Control", "no-cache") /// ``` - pub fn insert_header(&mut self, key: &str, value: &str) { - self.headers - .insert(HeaderName::from_str(key).unwrap(), value.parse().unwrap()); + pub fn insert_header(&mut self, key: &str, value: &str) -> PyResult<()> { + let header_name = HeaderName::from_str(key).into_py_exception()?; + let header_value = HeaderValue::from_str(value).into_py_exception()?; + self.headers.insert(header_name, header_value); + Ok(()) } /// Append a header to the response. @@ -188,9 +190,11 @@ impl Response { /// response.insert_header("Set-Cookie", "sessionid=abc123") /// response.append_header("Set-Cookie", "theme=dark") /// ``` - pub fn append_header(&mut self, key: &str, value: &str) { - self.headers - .append(HeaderName::from_str(key).unwrap(), value.parse().unwrap()); + pub fn append_header(&mut self, key: &str, value: &str) -> PyResult<()> { + let header_name = HeaderName::from_str(key).into_py_exception()?; + let header_value = HeaderValue::from_str(value).into_py_exception()?; + self.headers.append(header_name, header_value); + Ok(()) } } @@ -200,12 +204,13 @@ impl Response { self } - pub fn insert_or_append_cookie(&mut self, cookie_header: &str) { + pub fn insert_or_append_cookie(&mut self, cookie_header: &str) -> PyResult<()> { if self.headers.contains_key("Set-Cookie") { - self.append_header("Set-Cookie", cookie_header); + self.append_header("Set-Cookie", cookie_header)?; } else { - self.insert_header("Set-Cookie", cookie_header); + self.insert_header("Set-Cookie", cookie_header)?; } + Ok(()) } fn from_str(s: String, status: Status, content_type: HeaderValue) -> PyResult { @@ -244,11 +249,11 @@ impl Response { self } - pub(crate) fn apply_cors(mut self, cors: &Option>) -> Self { + pub(crate) fn apply_cors(mut self, cors: &Option>) -> PyResult { if let Some(cors) = cors { - cors.apply_headers(&mut self); + cors.apply_headers(&mut self)?; } - self + Ok(self) } } diff --git a/src/routing.rs b/src/routing.rs index ee7c37e..93a27c5 100644 --- a/src/routing.rs +++ b/src/routing.rs @@ -6,7 +6,28 @@ use pyo3_stub_gen::derive::*; use crate::{IntoPyException, middleware::Middleware}; -pub type MatchRoute<'l> = matchit::Match<'l, 'l, &'l Route>; +pub type MatchRoute<'l> = matchit::Match<'l, 'l, &'l Arc>; + +pub struct OwnedMatchRoute { + pub value: Arc, + pub params: Vec<(String, String)>, +} + +impl<'l> From> for OwnedMatchRoute { + fn from(match_route: MatchRoute) -> Self { + let p = match_route.params; + let mut params = Vec::with_capacity(p.len()); + + for (k, v) in p.iter() { + params.push((k.to_string(), v.to_string())); + } + + Self { + value: match_route.value.clone(), + params, + } + } +} /// A route definition that maps a URL path to a handler function. /// @@ -351,7 +372,7 @@ pub struct Router { pub base_path: Option, pub count: usize, pub middlewares: Option>, - pub routes: HashMap>, + pub routes: HashMap>>, } impl Router { @@ -469,7 +490,9 @@ impl Router { None => route.path.clone(), }; - method_router.insert(full_path, route).into_py_exception()?; + method_router + .insert(full_path, Arc::new(route)) + .into_py_exception()?; Ok(self.clone()) } diff --git a/src/templating.rs b/src/templating.rs index 66e5f37..7e2bd4c 100644 --- a/src/templating.rs +++ b/src/templating.rs @@ -120,7 +120,14 @@ impl Template { /// template.load("./templates/**/*.html") /// ``` #[pyo3(signature=(dir="./templates/**/*.html"))] - fn load(&mut self, dir: &str) -> PyResult<()> { + fn load(&mut self, dir: &str, py: Python<'_>) -> PyResult<()> { + let callable = py.eval( + c"lambda token: f''", + None, + None, + )?; + self.register_function("csrf_input".to_string(), callable.into())?; + if let Some(tera) = Arc::get_mut(&mut self.0) { tera.load_from_glob(dir).into_py_exception()?; Ok(()) @@ -232,6 +239,10 @@ fn render( ctx.set_item("session", session.clone_ref(py))?; } + if let Some(csrf_token) = request.ext.get("csrf_token") { + ctx.set_item("csrf_token", csrf_token.clone_ref(py))?; + } + let body = template.render(name, Some(ctx))?; let mut headers = HeaderMap::new(); diff --git a/tests/app.py b/tests/app.py index 5c11950..85082aa 100644 --- a/tests/app.py +++ b/tests/app.py @@ -2,13 +2,15 @@ @get("/hello/{name}") -def hello(_req, name): +async def hello(_req, name): return f"Hello, {name}!" -def main(): - Oxapy(("127.0.0.1", 5555)).attach(Router().route(hello)).run() +async def main(): + await Oxapy(("127.0.0.1", 5555)).attach(Router().route(hello)).async_mode().run() if __name__ == "__main__": - main() + import asyncio + + asyncio.run(main())