From ac18370593ef1e4876c7c6abd803d4f02f3225a7 Mon Sep 17 00:00:00 2001 From: FITAHIANA Nomeniavo Joe <24nomeniavo@gmail.com> Date: Fri, 21 Aug 2026 19:44:08 +0300 Subject: [PATCH 1/4] feat: set cookie --- docs/docs/api/response.md | 69 +++++++++++++++++++++++--------- oxapy/__init__.py | 33 ++++++---------- oxapy/__init__.pyi | 28 +++++++++++++ src/response.rs | 83 ++++++++++++++++++++++++++++++--------- tests/__init__.py | 4 +- 5 files changed, 159 insertions(+), 58 deletions(-) diff --git a/docs/docs/api/response.md b/docs/docs/api/response.md index 0ed6f39..f404d12 100644 --- a/docs/docs/api/response.md +++ b/docs/docs/api/response.md @@ -31,11 +31,11 @@ With the default `application/json` content type the body is serialized with `or ### Properties -| Property | Type | Description | -| --- | --- | --- | -| `status` | `Status` | The response status; settable | -| `body` | `str` | The response body as a UTF-8 string | -| `headers` | `list[tuple[str, str]]` | Headers as key-value tuples | +| Property | Type | Description | +| --------- | ----------------------- | ----------------------------------- | +| `status` | `Status` | The response status; settable | +| `body` | `str` | The response body as a UTF-8 string | +| `headers` | `list[tuple[str, str]]` | Headers as key-value tuples | ### Methods @@ -64,6 +64,39 @@ response.append_header("Set-Cookie", "sessionid=abc123") response.append_header("Set-Cookie", "theme=dark") ``` +#### set_cookie + +```python +set_cookie( + name: str, + value: str, + max_age: int = 3600, + path: str = "/", + domain: str = "", + httponly: bool = True, + secure: bool = True, + samesite: str = "Lax", +) -> None +``` + +Adds a cookie to the response via the `Set-Cookie` header. If a `Set-Cookie` header already exists the new cookie is appended; otherwise a new header is inserted. + +| Parameter | Default | Description | +| ---------- | ------- | ----------------------------------- | +| `name` | — | The cookie name | +| `value` | — | The cookie value | +| `max_age` | `3600` | Max-Age in seconds | +| `path` | `"/"` | Path attribute | +| `domain` | `""` | Domain attribute (omitted if empty) | +| `httponly` | `True` | HttpOnly flag | +| `secure` | `True` | Secure flag | +| `samesite` | `"Lax"` | SameSite attribute | + +```python +response.set_cookie("session", "abc123", max_age=3600, httponly=True, secure=True) +response.set_cookie("theme", "dark", max_age=86400) +``` + ## Redirect ### Constructor @@ -87,14 +120,14 @@ def old(request): The server converts handler results with `convert_to_response`: -| Return value | Result | -| --- | --- | -| `Response` | Used as-is | -| `str` | `text/plain` response | -| `dict` / JSON-serializable object | JSON response | -| `Status` | JSON response with an empty body and that status | -| `(str, Status)` | `text/plain` body with the given status | -| `(obj, Status)` | JSON body with the given status | +| Return value | Result | +| --------------------------------- | ------------------------------------------------ | +| `Response` | Used as-is | +| `str` | `text/plain` response | +| `dict` / JSON-serializable object | JSON response | +| `Status` | JSON response with an empty body and that status | +| `(str, Status)` | `text/plain` body with the given status | +| `(obj, Status)` | JSON body with the given status | Anything else raises a `ValueError`. @@ -108,11 +141,11 @@ FileStreaming(path: str, buf_size: int = 8192, status: Status = Status.OK, conte Streams a file in chunks for large files without loading the entire file into memory. -| Parameter | Default | Description | -| --- | --- | --- | -| `path` | — | Path to the file | -| `buf_size` | `8192` | Read buffer size in bytes | -| `status` | `Status.OK` | Response status code | +| Parameter | Default | Description | +| -------------- | ---------------------------- | ------------------------- | +| `path` | — | Path to the file | +| `buf_size` | `8192` | Read buffer size in bytes | +| `status` | `Status.OK` | Response status code | | `content_type` | `"application/octet-stream"` | The `Content-Type` header | ```python diff --git a/oxapy/__init__.py b/oxapy/__init__.py index 0813992..386ec35 100644 --- a/oxapy/__init__.py +++ b/oxapy/__init__.py @@ -217,7 +217,7 @@ class Session: Args: secret (bytes): The secret key used for HMAC signing and verification. max_age (int): Session expiration in seconds. Defaults to 1 week (604800s). - same_site (str): SameSite cookie attribute. Defaults to ``"Strict"``. + same_site (str): SameSite cookie attribute. Defaults to ``"Lax"``. Returns: A middleware function to be registered via ``router.middleware()``. @@ -271,16 +271,13 @@ def __call__(self, request, next, **kwargs) -> Response: if current_state != initial_state: signed_cookie = _sign_session(self.secret, self.max_age, request.session) - response.insert_header( - "set-cookie", - ( - f"session={signed_cookie}; " - f"Path=/; " - f"HttpOnly; " - f"Secure; " - f"SameSite={self.same_site}; " - f"Max-Age={self.max_age}" - ), + response.set_cookie( + name="session", + value=signed_cookie, + httponly=True, + secure=True, + same_site=self.same_site, + max_age=self.max_age, ) return response @@ -430,15 +427,11 @@ def __call__(self, request, next, **kwargs) -> Response: 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}" - ), + response.set_cookie( + name=self.cookie_name, + value=signed, + max_age=self.cookie_max_age, + httponly=False, ) return response diff --git a/oxapy/__init__.pyi b/oxapy/__init__.pyi index d124276..a63bee6 100644 --- a/oxapy/__init__.pyi +++ b/oxapy/__init__.pyi @@ -942,6 +942,34 @@ class Response: response.append_header("Set-Cookie", "theme=dark") ``` """ + def set_cookie(self, name: builtins.str, value: builtins.str, max_age: builtins.int = 3600, path: builtins.str = '/', domain: builtins.str = '', httponly: builtins.bool = True, secure: builtins.bool = True, samesite: builtins.str = 'Lax') -> None: + r""" + Add a cookie to the response via the ``Set-Cookie`` header. + + Builds a ``Set-Cookie`` header from the provided parameters and inserts + it into the response. If a ``Set-Cookie`` header already exists the new + cookie is appended (allowing multiple cookies). + + Args: + name (str): The cookie name. + value (str): The cookie value. + max_age (int, optional): Max-Age in seconds. Defaults to 3600. + path (str, optional): Path attribute. Defaults to ``"/"``. + domain (str, optional): Domain attribute. Empty string means omitted. + httponly (bool, optional): HttpOnly flag. Defaults to ``True``. + secure (bool, optional): Secure flag. Defaults to ``True``. + samesite (str, optional): SameSite attribute. Defaults to ``"Lax"``. + + Returns: + None + + Example: + ```python + response = Response("OK") + response.set_cookie("session", "abc123", max_age=3600, httponly=True, secure=True) + response.set_cookie("theme", "dark", max_age=86400) + ``` + """ @typing.final class Route: diff --git a/src/response.rs b/src/response.rs index 3308568..a32fd57 100644 --- a/src/response.rs +++ b/src/response.rs @@ -196,22 +196,72 @@ impl Response { self.headers.append(header_name, header_value); Ok(()) } -} -impl Response { - pub fn set_body(mut self, body: String) -> Self { - self.body = ResponseBody::Bytes(Bytes::from(body)); - self - } + /// Add a cookie to the response via the ``Set-Cookie`` header. + /// + /// Builds a ``Set-Cookie`` header from the provided parameters and inserts + /// it into the response. If a ``Set-Cookie`` header already exists the new + /// cookie is appended (allowing multiple cookies). + /// + /// Args: + /// name (str): The cookie name. + /// value (str): The cookie value. + /// max_age (int, optional): Max-Age in seconds. Defaults to 3600. + /// path (str, optional): Path attribute. Defaults to ``"/"``. + /// domain (str, optional): Domain attribute. Empty string means omitted. + /// httponly (bool, optional): HttpOnly flag. Defaults to ``True``. + /// secure (bool, optional): Secure flag. Defaults to ``True``. + /// samesite (str, optional): SameSite attribute. Defaults to ``"Lax"``. + /// + /// Returns: + /// None + /// + /// Example: + /// ```python + /// response = Response("OK") + /// response.set_cookie("session", "abc123", max_age=3600, httponly=True, secure=True) + /// response.set_cookie("theme", "dark", max_age=86400) + /// ``` + #[pyo3(signature=(name, value, max_age=3600, path="/", domain="", httponly=true, secure=true, samesite="Lax"))] + pub fn set_cookie( + &mut self, + name: &str, + value: &str, + max_age: i64, + path: &str, + domain: &str, + httponly: bool, + secure: bool, + samesite: &str, + ) -> PyResult<()> { + let mut cookie_header = + format!("{name}={value}; Path={path}; Max-Age={max_age}; SameSite={samesite}"); + + if !domain.is_empty() { + cookie_header.push_str(&format!("; Domain={domain}")); + } + if httponly { + cookie_header.push_str("; HttpOnly"); + } + if secure { + cookie_header.push_str("; Secure"); + } - 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(()) } +} + +impl Response { + pub fn set_body(mut self, body: String) -> Self { + self.body = ResponseBody::Bytes(Bytes::from(body)); + self + } fn from_str(s: String, status: Status, content_type: HeaderValue) -> PyResult { Ok(Self { @@ -302,16 +352,16 @@ impl Redirect { /// ``` #[new] #[gen_stub(override_return_type(type_repr = "typing_extensions.Self", imports = ("typing_extensions",)))] - fn new(location: String) -> PyClassInitializer { + fn new(location: String) -> PyResult> { let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "text/html".parse().unwrap()); - headers.insert(LOCATION, location.parse().unwrap()); - PyClassInitializer::from(Response { + headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/html")); + headers.insert(LOCATION, location.parse().into_py_exception()?); + Ok(PyClassInitializer::from(Response { status: Status::MOVED_PERMANENTLY, body: ResponseBody::Bytes(Bytes::new()), headers, }) - .add_subclass(Self) + .add_subclass(Self)) } } @@ -450,10 +500,7 @@ impl FileStreaming { let stream = stream::iter(chunk_iter).map(|bytes| Ok(Frame::data(bytes))); let body = StreamBody::new(Box::pin(stream)); let mut headers = HeaderMap::new(); - headers.insert( - CONTENT_TYPE, - HeaderValue::from_str(content_type).into_py_exception()?, - ); + headers.insert(CONTENT_TYPE, content_type.parse().into_py_exception()?); headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); Ok(PyClassInitializer::from(Response { status, diff --git a/tests/__init__.py b/tests/__init__.py index 5b7b4c6..8d4b2ad 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -3,6 +3,6 @@ def test_multiple_cookies(): res = Response("ok") - res.insert_header("Set-Cookie", "userId=123;Path=/") - res.append_header("Set-Cookie", "theme=dark;Path=/") + res.set_cookie("userId", "123") + res.set_cookie("theme", "dark") assert len([h for h in res.headers if h[0] == "set-cookie"]) == 2 From fd247576be5154617779db1959ca98e176a1edd8 Mon Sep 17 00:00:00 2001 From: FITAHIANA Nomeniavo Joe <24nomeniavo@gmail.com> Date: Fri, 21 Aug 2026 20:45:44 +0300 Subject: [PATCH 2/4] chore: update todo.md --- TODO.md | 93 ++++++++------------------------------------------------- 1 file changed, 12 insertions(+), 81 deletions(-) diff --git a/TODO.md b/TODO.md index 9aed075..d8cf7e6 100644 --- a/TODO.md +++ b/TODO.md @@ -14,16 +14,7 @@ Roadmap based on feature gap analysis against Flask, FastAPI, Django, Litestar, - [ ] Support async handlers for WebSocket - [ ] Tests: echo server, broadcast, concurrent connections -### 2. Dependency Injection - -- [ ] Add `Depends()` function that registers a callable dependency -- [ ] Resolve dependency graph per-request (with caching per-request scope) -- [ ] Support nested dependencies (`Depends(get_db)` -> `Depends(get_user)`) -- [ ] Support overriding dependencies in tests (`app.override(Depends(get_db), mock_db)`) -- [ ] Integrate with OpenAPI schema generation (if implemented) -- [ ] Tests: nested deps, override in tests, caching - -### 3. OpenAPI / Swagger Auto-Generation +### 2. OpenAPI / Swagger Auto-Generation - [ ] Generate OpenAPI 3.x schema from route definitions, serializers, and type hints - [ ] Serve Swagger UI at `/docs` and ReDoc at `/redoc` @@ -31,16 +22,7 @@ Roadmap based on feature gap analysis against Flask, FastAPI, Django, Litestar, - [ ] Support `response_model` parameter on route decorators - [ ] Tests: schema correctness, UI serving -### 4. Signature-Based Request Validation - -- [ ] Inspect handler function signatures at registration time -- [ ] Auto-parse and validate query params, headers, cookies from type hints -- [ ] Auto-parse request body from Pydantic/dataclass/serializer models -- [ ] Return structured validation errors (422 Unprocessable Entity) -- [ ] Support `Annotated[type, Query()]`, `Annotated[type, Header()]`, etc. -- [ ] Tests: query validation, header extraction, body parsing, error responses - -### 5. Background Tasks +### 3. Background Tasks - [ ] Add `BackgroundTasks` class with `add_task(func, *args, **kwargs)` - [ ] Execute tasks after response is sent to client @@ -48,16 +30,7 @@ Roadmap based on feature gap analysis against Flask, FastAPI, Django, Litestar, - [ ] Pass `BackgroundTasks` instance to handler via DI or parameter - [ ] Tests: task execution after response, async tasks -### 6. Lifespan Events (Startup/Shutdown) - -- [ ] Add `on_startup(func)` and `on_shutdown(func)` to `HttpServer` -- [ ] Support async startup/shutdown hooks -- [ ] Execute startup hooks before accepting connections -- [ ] Execute shutdown hooks on Ctrl+C (before stopping) -- [ ] Support `@app.on_startup` / `@app.on_shutdown` decorators -- [ ] Tests: hook execution order, async hooks - -### 7. Generic Streaming Responses +### 4. Generic Streaming Responses - [ ] Add `StreamingResponse` class (generalize `FileStreaming`) - [ ] Accept async generators, sync generators, or `StreamBody` as body source @@ -65,7 +38,7 @@ Roadmap based on feature gap analysis against Flask, FastAPI, Django, Litestar, - [ ] Use cases: SSE, NDJSON, LLM token streaming, real-time logs - [ ] Tests: async generator streaming, chunked delivery -### 8. Test Client +### 5. Test Client - [ ] Add `TestClient(app)` that makes in-process HTTP requests - [ ] No real TCP server needed (use hyper's in-process service) @@ -78,53 +51,39 @@ Roadmap based on feature gap analysis against Flask, FastAPI, Django, Litestar, ## Important (P1) -### 9. GZip Response Compression +### 6. GZip Response Compression - [ ] Add `GZipMiddleware` that compresses responses based on `Accept-Encoding` - [ ] Configurable minimum response size threshold - [ ] Support gzip and/or brotli - [ ] Skip compression for streaming responses -### 10. Trusted Host / HTTPS Redirect +### 7. Trusted Host / HTTPS Redirect - [ ] Add `TrustedHostMiddleware` that validates `Host` header - [ ] Add `HTTPSRedirectMiddleware` that redirects HTTP to HTTPS - [ ] Configurable allowed hosts list -### 11. Client IP Address +### 8. Client IP Address - [ ] Expose `request.client.host` on the `Request` object - [ ] Extract from `hyper`'s connected socket info - [ ] Support `X-Forwarded-For` / `X-Real-IP` behind reverse proxy (configurable) -### 12. Response Cookies API +### 9. Response Cookies API -- [ ] Add `response.set_cookie(name, value, max_age, path, domain, httponly, secure, samesite)` +- [x] Add `response.set_cookie(name, value, max_age, path, domain, httponly, secure, samesite)` - [ ] Add `response.delete_cookie(name, path, domain)` -- [ ] Type-safe API instead of manual `insert_header("set-cookie", "...")` +- [x] Type-safe API instead of manual `insert_header("set-cookie", "...")` -### 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) - -### 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 - -### 15. OAuth2 / Security Utilities +### 13. OAuth2 / Security Utilities - [ ] Add `OAuth2PasswordBearer(tokenUrl="/token")` dependency - [ ] Add `HTTPBasic` dependency for HTTP Basic auth - [ ] Add `APIKeyHeader` / `APIKeyQuery` dependencies - [ ] Support OAuth2 scopes -### 16. Content Negotiation +### 14 Content Negotiation - [ ] Inspect `Accept` header to determine response format - [ ] Support multiple serializers per route (JSON, XML, MessagePack) @@ -132,34 +91,6 @@ Roadmap based on feature gap analysis against Flask, FastAPI, Django, Litestar, --- -## Nice-to-Have (P2) - -### 17. CLI Runner - -- [ ] Add `oxapy run app:main` command -- [ ] Auto-detect uvicorn-like reload in dev -- [ ] Support `--host`, `--port`, `--reload` flags - -### 18. Rate Limiting - -- [ ] Add `RateLimitMiddleware` with configurable limits -- [ ] Support per-IP and per-route limits -- [ ] Use in-memory store or pluggable backend (Redis) - -### 19. Settings / Environment Configuration - -- [ ] Add `Settings` base class (pydantic-settings style) -- [ ] Load from `.env` files and environment variables -- [ ] Type validation at startup - -### 20. i18n / Localization - -- [ ] Add `gettext`-style translation function -- [ ] Support locale detection from `Accept-Language` header -- [ ] Date/number formatting per locale - ---- - ## Bug Fixes & Quality ### Stubs From 9f8d680fcb989f6bc3934f8143766fac082c355b Mon Sep 17 00:00:00 2001 From: FITAHIANA Nomeniavo Joe <24nomeniavo@gmail.com> Date: Wed, 26 Aug 2026 10:43:57 +0300 Subject: [PATCH 3/4] fix: class Session --- oxapy/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/oxapy/__init__.py b/oxapy/__init__.py index 386ec35..0f89cca 100644 --- a/oxapy/__init__.py +++ b/oxapy/__init__.py @@ -217,7 +217,7 @@ class Session: Args: secret (bytes): The secret key used for HMAC signing and verification. max_age (int): Session expiration in seconds. Defaults to 1 week (604800s). - same_site (str): SameSite cookie attribute. Defaults to ``"Lax"``. + samesite (str): SameSite cookie attribute. Defaults to ``"Lax"``. Returns: A middleware function to be registered via ``router.middleware()``. @@ -247,10 +247,10 @@ def main(): ``` """ - def __init__(self, secret: bytes, max_age: int = 3600 * 24 * 7, same_site="Lax"): + def __init__(self, secret: bytes, max_age: int = 3600 * 24 * 7, samesite="Lax"): self.secret = secret self.max_age = max_age - self.same_site = same_site + self.samesite = samesite def __call__(self, request, next, **kwargs) -> Response: cookie = request.get_cookie("session") @@ -276,7 +276,7 @@ def __call__(self, request, next, **kwargs) -> Response: value=signed_cookie, httponly=True, secure=True, - same_site=self.same_site, + samesite=self.samesite, max_age=self.max_age, ) From 59510997a02f71f96021db88796050d60c47b681 Mon Sep 17 00:00:00 2001 From: FITAHIANA Nomeniavo Joe <24nomeniavo@gmail.com> Date: Wed, 26 Aug 2026 10:44:21 +0300 Subject: [PATCH 4/4] test: update app.py --- tests/app.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/app.py b/tests/app.py index 85082aa..aafac9e 100644 --- a/tests/app.py +++ b/tests/app.py @@ -1,16 +1,22 @@ -from oxapy import Oxapy, Router, get +import multiprocessing +workers = multiprocessing.cpu_count() -@get("/hello/{name}") -async def hello(_req, name): - return f"Hello, {name}!" +from oxapy import Oxapy, Router, get, post -async def main(): - await Oxapy(("127.0.0.1", 5555)).attach(Router().route(hello)).async_mode().run() +def main(): + ( + Oxapy(("0.0.0.0", 3000)) + .attach( + Router() + .route(get("/", lambda _: "")) + .route(get("/user/{id:int}", lambda _, id: str(id))) + .route(post("/user", lambda _: "")) + ) + .run(workers=workers) + ) if __name__ == "__main__": - import asyncio - - asyncio.run(main()) + main()