9ae675f
commit 9ae675f
23 files changed
+2206 -0
Showing 23 changed files+2206-0
crates/justapi-py/LICENSEcrates/justapi-py/README.mdcrates/justapi-py/python/justapi/__init__.pycrates/justapi-py/python/justapi/__init__.pyicrates/justapi-py/python/justapi/_hyperion.pyicrates/justapi-py/python/justapi/_justapi.pyicrates/justapi-py/python/justapi/_native_helper.pycrates/justapi-py/python/justapi/_native_helper.pyicrates/justapi-py/python/justapi/app.pycrates/justapi-py/python/justapi/app.pyicrates/justapi-py/python/justapi/background.pycrates/justapi-py/python/justapi/background.pyicrates/justapi-py/python/justapi/grpc_compiler.pycrates/justapi-py/python/justapi/grpc_compiler.pyicrates/justapi-py/python/justapi/params.pycrates/justapi-py/python/justapi/params.pyicrates/justapi-py/python/justapi/responses.pycrates/justapi-py/python/justapi/responses.pyicrates/justapi-py/python/justapi/templating.pycrates/justapi-py/python/justapi/templating.pyicrates/justapi-py/python/justapi/testing.pycrates/justapi-py/python/justapi/tracing.pycrates/justapi-py/python/justapi/tracing.pyi
▼crates/justapi-py/LICENSE
+21-0
| @@ -0,0 +1,21 @@ | ||
1 | +MIT License | |
2 | + | |
3 | +Copyright (c) 2024 JustAPI contributors | |
4 | + | |
5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | |
6 | +of this software and associated documentation files (the "Software"), to deal | |
7 | +in the Software without restriction, including without limitation the rights | |
8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
9 | +copies of the Software, and to permit persons to whom the Software is | |
10 | +furnished to do so, subject to the following conditions: | |
11 | + | |
12 | +The above copyright notice and this permission notice shall be included in all | |
13 | +copies or substantial portions of the Software. | |
14 | + | |
15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
21 | +SOFTWARE. | |
0 | 22 | |
▼crates/justapi-py/README.md
+154-0
| @@ -0,0 +1,154 @@ | ||
1 | +# justapi Runtime | |
2 | + | |
3 | +A production-grade Python web framework written in Rust. Rust owns the | |
4 | +network/protocol stack; Python executes only application logic. | |
5 | + | |
6 | +**10-20x faster than FastAPI** — see [BENCHMARKS.md](BENCHMARKS.md). | |
7 | + | |
8 | +## Project Status | |
9 | + | |
10 | +**Phase 40 — The JustAPI 2.0 "Singularity" Release** (in progress) | |
11 | + | |
12 | +All 39 prior phases complete. See [PLAN.md](PLAN.md) for the full roadmap. | |
13 | + | |
14 | +## Architecture | |
15 | + | |
16 | +``` | |
17 | +Kernel → epoll/io_uring → Connection Manager → tokio task | |
18 | + → TLS (rustls) → HTTP parse (hyper) → Router (matchit) | |
19 | + → Middleware chain (Rust: auth/CORS/rate-limit/compression) | |
20 | + → Python boundary (typed, zero-copy request view via PyO3) | |
21 | + → Application code (native Python handler) | |
22 | + → Rust serializer (serde_json/simd-json) | |
23 | + → Response write → Socket | |
24 | +``` | |
25 | + | |
26 | +justapi provides a single native Python API (Tier B) — the FastAPI | |
27 | +replacement that runs entirely in Rust with zero-copy Python integration. | |
28 | + | |
29 | +## Quick Start | |
30 | + | |
31 | +### Python Native API | |
32 | + | |
33 | +```python | |
34 | +from justapi import JustAPIApp | |
35 | + | |
36 | +app = JustAPIApp() | |
37 | + | |
38 | +@app.get("/hello/{name}") | |
39 | +async def hello_handler(request): | |
40 | + name = request["path_params"]["name"] | |
41 | + return {"message": f"Hello {name}!"} | |
42 | + | |
43 | +app.run("127.0.0.1:8080") | |
44 | +``` | |
45 | + | |
46 | +Install the package: | |
47 | + | |
48 | +```bash | |
49 | +pip install justapi | |
50 | +``` | |
51 | + | |
52 | +Or build from source with `maturin`: | |
53 | + | |
54 | +```bash | |
55 | +cd crates/justapi-py | |
56 | +maturin develop --release | |
57 | +``` | |
58 | + | |
59 | +### CLI | |
60 | + | |
61 | +```bash | |
62 | +# Run server with Rust-defined routes | |
63 | +justapi serve --addr 127.0.0.1:8080 | |
64 | + | |
65 | +# Generate a client SDK from your API | |
66 | +justapi gen-client --lang python | |
67 | +``` | |
68 | + | |
69 | +## Features | |
70 | + | |
71 | +| Area | Status | | |
72 | +|---|---| | |
73 | +| **HTTP/1.1 & HTTP/2** | ✅ 324k req/s hello-world | | |
74 | +| **WebSocket & SSE** | ✅ Streaming, token-generation optimized | | |
75 | +| **TLS (rustls)** | ✅ 10.7% overhead | | |
76 | +| **Middleware** | ✅ CORS, SecurityHeaders, JWT, RateLimiter | | |
77 | +| **JWT Auth** | ✅ RS256/ES256, per-route roles/scopes | | |
78 | +| **Serialization** | ✅ serde_json, simd-json optional | | |
79 | +| **Request Validation** | ✅ JSON Schema, Pydantic bridge | | |
80 | +| **OpenAPI Generation** | ✅ Auto-generated `/docs` and `/redoc` | | |
81 | +| **Database ORM** | ✅ SQLite/PostgreSQL/MySQL via sqlx | | |
82 | +| **gRPC** | ✅ Native Tonic integration | | |
83 | +| **GraphQL** | ✅ async-graphql, Apollo Federation | | |
84 | +| **Observability** | ✅ OTel tracing, Prometheus metrics, structured logging | | |
85 | +| **Resilience** | ✅ Circuit breakers, retry, fallback, bulkhead | | |
86 | +| **Rate Limiting** | ✅ GCRA algorithm, Redis-backed | | |
87 | +| **WASM Middleware** | ✅ wasmtime embedded plugins | | |
88 | +| **Agent DAG Engine** | ✅ DAG orchestration in Rust | | |
89 | +| **Background Tasks** | ✅ Thread-safe task scheduling | | |
90 | +| **Dependency Injection** | ✅ Litestar-style tiered DI | | |
91 | +| **Snapshot Testing** | ✅ Built-in test utilities | | |
92 | +| **Static Files** | ✅ Range requests, MIME detection | | |
93 | +| **Compression** | ✅ gzip/deflate/brotli/zstd | | |
94 | +| **Plugin System** | ✅ Third-party plugin API | | |
95 | +| **Memory Safety** | ✅ Miri-verified unsafe blocks, 6 fuzz targets | | |
96 | + | |
97 | +## Development | |
98 | + | |
99 | +### Prerequisites | |
100 | + | |
101 | +- Rust 1.85+ (edition 2021) | |
102 | +- Python 3.11+ | |
103 | + | |
104 | +### Building | |
105 | + | |
106 | +```bash | |
107 | +cargo build --workspace # debug build | |
108 | +cargo build --workspace --release # release build | |
109 | +cargo test --workspace # run all tests (236+) | |
110 | +cargo clippy --workspace --tests -- -D warnings # lint | |
111 | +cargo fmt --check # format check | |
112 | +cargo miri test -p justapi-core # memory safety (nightly) | |
113 | +``` | |
114 | + | |
115 | +### Benchmarking | |
116 | + | |
117 | +See [BENCHMARKS.md](BENCHMARKS.md) for methodology and results. | |
118 | + | |
119 | +```bash | |
120 | +# Install load generator | |
121 | +cargo install oha | |
122 | + | |
123 | +# Run baseline benchmarks | |
124 | +bash benchmarks/run_baselines.sh | |
125 | +``` | |
126 | + | |
127 | +## Project Structure | |
128 | + | |
129 | +``` | |
130 | +crates/ | |
131 | + justapi-core/ — networking, protocol, scheduler, memory | |
132 | + justapi-py/ — PyO3 bindings, native Python API | |
133 | + justapi-cli/ — `justapi` CLI binary | |
134 | + justapi-bench/ — internal benchmark harness | |
135 | +python/ | |
136 | + justapi/ — pip-installable Python package | |
137 | +fuzz/ — cargo-fuzz targets (6 targets) | |
138 | +docs/ — documentation | |
139 | +skills/ — reusable knowledge modules for development | |
140 | +benchmarks/ — workload scripts for performance testing | |
141 | +``` | |
142 | + | |
143 | +## Documentation | |
144 | + | |
145 | +- [PLAN.md](PLAN.md) — Living roadmap and current status | |
146 | +- [AGENTS.md](AGENTS.md) — Development rules and conventions | |
147 | +- [DECISIONS.md](DECISIONS.md) — Architecture decision records | |
148 | +- [BENCHMARKS.md](BENCHMARKS.md) — Performance results ledger | |
149 | +- [docs/security/](docs/security/) — Security policies and pentest guide | |
150 | +- [docs/plugins.md](docs/plugins.md) — Plugin development guide | |
151 | + | |
152 | +## License | |
153 | + | |
154 | +MIT | |
0 | 155 | |
▼crates/justapi-py/python/justapi/__init__.py
+167-0
| @@ -0,0 +1,167 @@ | ||
1 | +"""JustAPI Runtime — Python bindings. | |
2 | + | |
3 | +Usage: | |
4 | + import justapi | |
5 | + justapi.serve("127.0.0.1:8080") | |
6 | + | |
7 | + # Tier B Native API (FastAPI replacement) | |
8 | + from justapi import JustAPIApp, Schema | |
9 | + | |
10 | + class UserSchema(Schema): | |
11 | + name: str | |
12 | + email: str | |
13 | + age: int | None = None | |
14 | + | |
15 | + app = JustAPIApp() | |
16 | + app.get("/hello", lambda r: {"message": "hello"}) | |
17 | + app.post("/users", handler, schema=UserSchema) | |
18 | + app.run("127.0.0.1:8080") | |
19 | + | |
20 | + # Pydantic models are also supported: | |
21 | + from justapi import pydantic_schema | |
22 | + from pydantic import BaseModel | |
23 | + | |
24 | + app.post("/users", handler, schema=pydantic_schema(UserModel)) | |
25 | + | |
26 | +""" | |
27 | + | |
28 | +from . import testing as testing | |
29 | +from . import tracing as tracing | |
30 | + | |
31 | +from ._justapi import serve, TokenStreamResponse, WebSocket, Dag, DagNode, RateLimiter, RateLimitResult, Request, UploadFile # type: ignore[import-untyped] | |
32 | +from ._justapi import Database # type: ignore[import-untyped] | |
33 | +from .app import JustAPIApp, Depends, adaptive_batch, APIRouter, Controller, controller, route_get, route_post, route_put, route_patch, route_delete, route_query, route_sse, route_websocket, JustAPITestClient, RequestValidationError | |
34 | +from .templating import Jinja2Templates | |
35 | +from .background import BackgroundTasks | |
36 | +from .responses import Response, HTMLResponse, PlainTextResponse, JSONResponse, RedirectResponse, StreamingResponse | |
37 | +from .params import Param, Path, Query, Header, Cookie, Body, File, Form | |
38 | + | |
39 | +# --------------------------------------------------------------------------- | |
40 | +# Fast Schema — Rust-native JSON Schema validation | |
41 | +# --------------------------------------------------------------------------- | |
42 | + | |
43 | +import json | |
44 | +import typing | |
45 | +import dataclasses | |
46 | +import sys | |
47 | +import types as _types | |
48 | + | |
49 | + | |
50 | +class Schema: | |
51 | + """Base class for defining Rust-native validation schemas. | |
52 | + | |
53 | + Subclass this and add type-annotated fields: | |
54 | + | |
55 | + class UserSchema(Schema): | |
56 | + name: str | |
57 | + email: str | |
58 | + age: int | None = None | |
59 | + | |
60 | + The resulting JSON Schema is compiled in Rust and validated | |
61 | + with zero Python round-trips. | |
62 | + """ | |
63 | + | |
64 | + _field_annotations: dict[str, tuple[type, typing.Any]] | |
65 | + | |
66 | + def __init_subclass__(cls, **kwargs): | |
67 | + super().__init_subclass__(**kwargs) | |
68 | + annotations = getattr(cls, '__annotations__', {}) | |
69 | + fields = {} | |
70 | + for field_name, field_type in annotations.items(): | |
71 | + if field_name.startswith('_'): | |
72 | + continue | |
73 | + default = cls.__dict__.get(field_name, dataclasses.MISSING) | |
74 | + fields[field_name] = (field_type, default) | |
75 | + cls._field_annotations = fields | |
76 | + | |
77 | + @classmethod | |
78 | + def _schema_json(cls) -> str: | |
79 | + """Generate a JSON Schema string from the class field definitions.""" | |
80 | + return json.dumps(cls._build_schema()) | |
81 | + | |
82 | + @classmethod | |
83 | + def _build_schema(cls) -> dict: | |
84 | + """Build a JSON Schema dict from field annotations.""" | |
85 | + properties = {} | |
86 | + required = [] | |
87 | + for field_name, (field_type, default) in cls._field_annotations.items(): | |
88 | + prop = _type_to_json_schema(field_type) | |
89 | + properties[field_name] = prop | |
90 | + if default is dataclasses.MISSING: | |
91 | + required.append(field_name) | |
92 | + return { | |
93 | + "type": "object", | |
94 | + "properties": properties, | |
95 | + "required": required, | |
96 | + "additionalProperties": False, | |
97 | + } | |
98 | + | |
99 | + | |
100 | +def _type_to_json_schema(tp: type) -> dict: | |
101 | + """Convert a Python type annotation to a JSON Schema property.""" | |
102 | + origin = typing.get_origin(tp) | |
103 | + args = typing.get_args(tp) | |
104 | + | |
105 | + # Optional[X] is Union[X, None] | |
106 | + if origin is typing.Union or origin is _types.UnionType: | |
107 | + non_none = [a for a in args if a is not type(None)] | |
108 | + if len(non_none) == 1: | |
109 | + return _type_to_json_schema(non_none[0]) | |
110 | + | |
111 | + if tp is str: | |
112 | + return {"type": "string"} | |
113 | + if tp is int: | |
114 | + return {"type": "integer"} | |
115 | + if tp is float: | |
116 | + return {"type": "number"} | |
117 | + if tp is bool: | |
118 | + return {"type": "boolean"} | |
119 | + if tp is bytes: | |
120 | + return {"type": "string", "contentEncoding": "base64"} | |
121 | + if origin is list: | |
122 | + item_type = args[0] if args else str | |
123 | + return {"type": "array", "items": _type_to_json_schema(item_type)} | |
124 | + if origin is dict: | |
125 | + value_type = args[1] if len(args) > 1 else str | |
126 | + return {"type": "object", "additionalProperties": _type_to_json_schema(value_type)} | |
127 | + return {"type": "string"} | |
128 | + | |
129 | + | |
130 | +def pydantic_schema(model_class) -> str: | |
131 | + """Extract a JSON Schema string from a Pydantic BaseModel subclass. | |
132 | + | |
133 | + Usage: | |
134 | + from justapi import pydantic_schema | |
135 | + from pydantic import BaseModel | |
136 | + | |
137 | + class UserModel(BaseModel): | |
138 | + name: str | |
139 | + email: str | |
140 | + | |
141 | + app.post("/users", handler, schema=pydantic_schema(UserModel)) | |
142 | + """ | |
143 | + if hasattr(model_class, 'model_json_schema'): | |
144 | + # Pydantic v2 | |
145 | + schema = model_class.model_json_schema() | |
146 | + elif hasattr(model_class, 'schema_json'): | |
147 | + # Pydantic v1 | |
148 | + schema = model_class.schema() | |
149 | + else: | |
150 | + raise TypeError( | |
151 | + f"{model_class} does not appear to be a Pydantic BaseModel " | |
152 | + "(no model_json_schema or schema method)" | |
153 | + ) | |
154 | + return json.dumps(schema) | |
155 | + | |
156 | + | |
157 | +__all__ = [ | |
158 | + "serve", "JustAPIApp", "Depends", "Database", "Schema", "pydantic_schema", | |
159 | + "JustAPITestClient", "testing", "tracing", "Jinja2Templates", "BackgroundTasks", | |
160 | + "TokenStreamResponse", "WebSocket", "Dag", "DagNode", "RateLimiter", "RateLimitResult", | |
161 | + "adaptive_batch", "APIRouter", "Controller", "controller", | |
162 | + "route_get", "route_post", "route_put", "route_patch", "route_delete", "route_query", | |
163 | + "route_sse", "route_websocket", "Request", "Response", "HTMLResponse", | |
164 | + "PlainTextResponse", "JSONResponse", "RedirectResponse", "StreamingResponse", | |
165 | + "UploadFile", "RequestValidationError", "Param", "Path", "Query", "Header", | |
166 | + "Cookie", "Body", "File", "Form" | |
167 | +] | |
0 | 168 | |
▼crates/justapi-py/python/justapi/__init__.pyi
+13-0
| @@ -0,0 +1,13 @@ | ||
1 | +from . import testing as testing, tracing as tracing | |
2 | +from ._justapi import Dag as Dag, DagNode as DagNode, Database as Database, RateLimitResult as RateLimitResult, RateLimiter as RateLimiter, Request as Request, TokenStreamResponse as TokenStreamResponse, WebSocket as WebSocket, serve as serve | |
3 | +from .app import APIRouter as APIRouter, Controller as Controller, Depends as Depends, JustAPIApp as JustAPIApp, JustAPITestClient as JustAPITestClient, adaptive_batch as adaptive_batch, controller as controller, route_delete as route_delete, route_get as route_get, route_post as route_post, route_put as route_put, route_query as route_query, route_sse as route_sse, route_websocket as route_websocket | |
4 | +from .background import BackgroundTasks as BackgroundTasks | |
5 | +from .responses import HTMLResponse as HTMLResponse, JSONResponse as JSONResponse, PlainTextResponse as PlainTextResponse, RedirectResponse as RedirectResponse, Response as Response, StreamingResponse as StreamingResponse | |
6 | +from .templating import Jinja2Templates as Jinja2Templates | |
7 | + | |
8 | +__all__ = ['serve', 'JustAPIApp', 'Depends', 'Database', 'Schema', 'pydantic_schema', 'JustAPITestClient', 'testing', 'tracing', 'Jinja2Templates', 'BackgroundTasks', 'TokenStreamResponse', 'WebSocket', 'Dag', 'DagNode', 'RateLimiter', 'RateLimitResult', 'adaptive_batch', 'APIRouter', 'Controller', 'controller', 'route_get', 'route_post', 'route_put', 'route_delete', 'route_query', 'route_sse', 'route_websocket', 'Request', 'Response', 'HTMLResponse', 'PlainTextResponse', 'JSONResponse', 'RedirectResponse', 'StreamingResponse'] | |
9 | + | |
10 | +class Schema: | |
11 | + def __init_subclass__(cls, **kwargs) -> None: ... | |
12 | + | |
13 | +def pydantic_schema(model_class) -> str: ... | |
0 | 14 | |
▼crates/justapi-py/python/justapi/_hyperion.pyi
+2-0
| @@ -0,0 +1,2 @@ | ||
1 | +def serve(*args, **kwargs): ... | |
2 | +def serve_with_app(*args, **kwargs): ... | |
0 | 3 | |
▼crates/justapi-py/python/justapi/_justapi.pyi
+1-0
| @@ -0,0 +1 @@ | ||
1 | +def serve(*args, **kwargs): ... | |
0 | 2 | |
▼crates/justapi-py/python/justapi/_native_helper.py
+161-0
| @@ -0,0 +1,161 @@ | ||
1 | +import asyncio | |
2 | +import inspect | |
3 | +import json | |
4 | +import contextvars | |
5 | +import concurrent.futures | |
6 | +import threading | |
7 | +import traceback | |
8 | + | |
9 | +_trace_id_var = contextvars.ContextVar("trace_id", default=None) | |
10 | +_span_id_var = contextvars.ContextVar("span_id", default=None) | |
11 | + | |
12 | +def set_trace_context(trace_id, span_id): | |
13 | + _trace_id_var.set(trace_id) | |
14 | + _span_id_var.set(span_id) | |
15 | + | |
16 | +def get_trace_context(): | |
17 | + return { | |
18 | + "trace_id": _trace_id_var.get(), | |
19 | + "span_id": _span_id_var.get(), | |
20 | + } | |
21 | + | |
22 | +_loop = None | |
23 | +_loop_thread = None | |
24 | +_loop_lock = None | |
25 | + | |
26 | +def _start_loop(loop): | |
27 | + asyncio.set_event_loop(loop) | |
28 | + loop.run_forever() | |
29 | + | |
30 | +def _get_loop(): | |
31 | + global _loop, _loop_thread, _loop_lock | |
32 | + if _loop_lock is None: | |
33 | + # This itself is a slight race but much safer than creating loops | |
34 | + _loop_lock = threading.Lock() | |
35 | + | |
36 | + with _loop_lock: | |
37 | + if _loop is None: | |
38 | + _loop = asyncio.new_event_loop() | |
39 | + _loop_thread = threading.Thread(target=_start_loop, args=(_loop,), daemon=True) | |
40 | + _loop_thread.start() | |
41 | + return _loop | |
42 | + | |
43 | +def wrap_result(result): | |
44 | + if hasattr(result, "to_dict"): | |
45 | + return result.to_dict() | |
46 | + if isinstance(result, dict) and ("body" in result or "status" in result): | |
47 | + if "body" in result and isinstance(result["body"], str): | |
48 | + result["body"] = result["body"].encode("utf-8") | |
49 | + return result | |
50 | + | |
51 | + body = json.dumps(result, default=str).encode("utf-8") | |
52 | + return { | |
53 | + "status": 200, | |
54 | + "headers": [(b"content-type", b"application/json")], | |
55 | + "body": body, | |
56 | + } | |
57 | + | |
58 | +def call_handler(handler, request): | |
59 | + """Call a native Python handler with a request dict. | |
60 | + | |
61 | + The handler may be sync or async. Returns a response dict | |
62 | + with "status", "headers", and "body" keys. | |
63 | + """ | |
64 | + try: | |
65 | + result = handler(request) | |
66 | + if inspect.isawaitable(result) and not isinstance(result, concurrent.futures.Future): | |
67 | + loop = _get_loop() | |
68 | + return asyncio.run_coroutine_threadsafe(result, loop) | |
69 | + | |
70 | + # Streaming responses are returned unwrapped so the Rust side can | |
71 | + # pump the generator into an SSE / TokenStream response. | |
72 | + if type(result).__name__ in ("TokenStreamResponse", "StreamingResponse"): | |
73 | + return result | |
74 | + | |
75 | + return wrap_result(result) | |
76 | + except Exception as e: | |
77 | + print(f"ERROR in call_handler: {repr(e)}") | |
78 | + traceback.print_exc() | |
79 | + raise e | |
80 | + | |
81 | + | |
82 | +def call_batch_handler(handler, requests): | |
83 | + """Call a native Python batch handler with a list of request dicts. | |
84 | + | |
85 | + The handler may be sync or async. Returns a list of response dicts | |
86 | + with "status", "headers", and "body" keys. | |
87 | + """ | |
88 | + try: | |
89 | + result = handler(requests) | |
90 | + if inspect.isawaitable(result) and not isinstance(result, concurrent.futures.Future): | |
91 | + loop = _get_loop() | |
92 | + future = asyncio.run_coroutine_threadsafe(result, loop) | |
93 | + # The future will resolve to a list of results, we need to wrap them | |
94 | + # We can't do it directly here since it's a concurrent.futures.Future, | |
95 | + # so we'll just return the future and wrap the results in Rust. | |
96 | + return future | |
97 | + | |
98 | + return [wrap_result(r) for r in result] | |
99 | + except Exception as e: | |
100 | + print(f"ERROR in call_batch_handler: {repr(e)}") | |
101 | + traceback.print_exc() | |
102 | + raise e | |
103 | + | |
104 | + | |
105 | +def validate_body(schema_fn, body_bytes): | |
106 | + """Validate a request body against a schema function. | |
107 | + | |
108 | + The schema function receives the parsed JSON body and should return | |
109 | + a list of error strings, or None/[] if valid. | |
110 | + """ | |
111 | + if schema_fn is None: | |
112 | + return [] | |
113 | + | |
114 | + try: | |
115 | + body_str = body_bytes.decode("utf-8") | |
116 | + body_data = json.loads(body_str) | |
117 | + except (UnicodeDecodeError, json.JSONDecodeError) as e: | |
118 | + return [f"Invalid JSON body: {e}"] | |
119 | + | |
120 | + result = schema_fn(body_data) | |
121 | + if result is None: | |
122 | + return [] | |
123 | + return result | |
124 | + | |
125 | +def _pump_stream(generator, sender): | |
126 | + async def pump(): | |
127 | + try: | |
128 | + async for chunk in generator: | |
129 | + if isinstance(chunk, str): | |
130 | + chunk = chunk.encode("utf-8") | |
131 | + sender.send(chunk) | |
132 | + except Exception as e: | |
133 | + sender.send_error(str(e)) | |
134 | + finally: | |
135 | + sender.close() | |
136 | + | |
137 | + loop = _get_loop() | |
138 | + asyncio.run_coroutine_threadsafe(pump(), loop) | |
139 | + | |
140 | +def call_plugin_hook(plugin, hook_name): | |
141 | + """Call a plugin lifecycle hook (sync or async).""" | |
142 | + if not hasattr(plugin, hook_name): | |
143 | + return | |
144 | + hook = getattr(plugin, hook_name) | |
145 | + result = hook() | |
146 | + if inspect.isawaitable(result) and not isinstance(result, concurrent.futures.Future): | |
147 | + loop = _get_loop() | |
148 | + future = asyncio.run_coroutine_threadsafe(result, loop) | |
149 | + # block the current thread until the coroutine finishes, | |
150 | + # since plugin hooks (like startup) must finish before proceeding | |
151 | + future.result() | |
152 | + | |
153 | + | |
154 | +def run_ws_handler(coro): | |
155 | + """Schedule a WebSocket handler coroutine on the daemon event loop. | |
156 | + | |
157 | + The coroutine owns the WebSocket connection for its lifetime; we do not | |
158 | + block waiting for it to complete. | |
159 | + """ | |
160 | + loop = _get_loop() | |
161 | + asyncio.run_coroutine_threadsafe(coro, loop) | |
0 | 162 | |
▼crates/justapi-py/python/justapi/_native_helper.pyi
+8-0
| @@ -0,0 +1,8 @@ | ||
1 | +def set_trace_context(trace_id, span_id) -> None: ... | |
2 | +def get_trace_context(): ... | |
3 | +def wrap_result(result): ... | |
4 | +def call_handler(handler, request): ... | |
5 | +def call_batch_handler(handler, requests): ... | |
6 | +def validate_body(schema_fn, body_bytes): ... | |
7 | +def call_plugin_hook(plugin, hook_name) -> None: ... | |
8 | +def run_ws_handler(coro) -> None: ... | |
0 | 9 | |
▼crates/justapi-py/python/justapi/app.py
+803-0
| @@ -0,0 +1,803 @@ | ||
1 | +import inspect | |
2 | +import functools | |
3 | +import typing | |
4 | +from ._justapi import _JustAPIApp, TokenStreamResponse | |
5 | + | |
6 | +class RequestValidationError(Exception): | |
7 | + def __init__(self, errors): | |
8 | + self.errors = errors | |
9 | + super().__init__(str(errors)) | |
10 | + | |
11 | +class Depends: | |
12 | + def __init__(self, dependency: typing.Callable, use_cache: bool = True): | |
13 | + self.dependency = dependency | |
14 | + self.use_cache = use_cache | |
15 | + | |
16 | +def adaptive_batch(max_size: int = 32, window_ms: int = 10): | |
17 | + """ | |
18 | + Decorator to enable adaptive batching for this route. | |
19 | + The wrapped handler must accept a `requests: list[T]` and return a `list[R]` of the same size. | |
20 | + """ | |
21 | + def wrapper(func): | |
22 | + func.__batch_size__ = max_size | |
23 | + func.__batch_window_ms__ = window_ms | |
24 | + return func | |
25 | + return wrapper | |
26 | + | |
27 | +class JustAPIApp: | |
28 | + def __init__(self, dependencies: typing.List[Depends] = None): | |
29 | + self._app = _JustAPIApp() | |
30 | + self.dependencies = dependencies or [] | |
31 | + self.exception_handlers = {} | |
32 | + self.middlewares = [] | |
33 | + | |
34 | + def middleware(self, middleware_type: str = "http"): | |
35 | + def decorator(func): | |
36 | + if middleware_type == "http": | |
37 | + self.middlewares.append(func) | |
38 | + return func | |
39 | + return decorator | |
40 | + | |
41 | + def add_exception_handler(self, exc_class, handler): | |
42 | + self.exception_handlers[exc_class] = handler | |
43 | + | |
44 | + def _handle_exception(self, request, exc): | |
45 | + for cls in type(exc).mro(): | |
46 | + if cls in self.exception_handlers: | |
47 | + res = self.exception_handlers[cls](request, exc) | |
48 | + return res | |
49 | + raise exc | |
50 | + | |
51 | + async def _resolve_dependencies_list(self, deps: typing.List[Depends], request: dict): | |
52 | + if "_dep_cache" not in request: | |
53 | + request["_dep_cache"] = {} | |
54 | + | |
55 | + for dep in deps: | |
56 | + dep_sig = inspect.signature(dep.dependency) | |
57 | + dep_kwargs = await self._resolve_kwargs(dep_sig, request) | |
58 | + | |
59 | + if dep.use_cache and dep.dependency in request["_dep_cache"]: | |
60 | + continue | |
61 | + | |
62 | + if inspect.iscoroutinefunction(dep.dependency): | |
63 | + res = await dep.dependency(**dep_kwargs) | |
64 | + else: | |
65 | + res = dep.dependency(**dep_kwargs) | |
66 | + | |
67 | + if dep.use_cache: | |
68 | + request["_dep_cache"][dep.dependency] = res | |
69 | + | |
70 | + async def _resolve_kwargs(self, sig, request): | |
71 | + if "query_params" not in request: | |
72 | + import urllib.parse | |
73 | + qs = request.get("query_string", b"").decode("utf-8") | |
74 | + parsed = urllib.parse.parse_qs(qs) | |
75 | + # parse_qs returns lists, we just take the first item for now | |
76 | + request["query_params"] = {k: v[0] for k, v in parsed.items()} | |
77 | + | |
78 | + if "_dep_cache" not in request: | |
79 | + request["_dep_cache"] = {} | |
80 | + | |
81 | + kwargs = {} | |
82 | + for name, param in sig.parameters.items(): | |
83 | + if isinstance(param.default, Depends): | |
84 | + if param.default.use_cache and param.default.dependency in request["_dep_cache"]: | |
85 | + kwargs[name] = request["_dep_cache"][param.default.dependency] | |
86 | + continue | |
87 | + | |
88 | + dep_sig = inspect.signature(param.default.dependency) | |
89 | + dep_kwargs = await self._resolve_kwargs(dep_sig, request) | |
90 | + if inspect.iscoroutinefunction(param.default.dependency): | |
91 | + res = await param.default.dependency(**dep_kwargs) | |
92 | + else: | |
93 | + res = param.default.dependency(**dep_kwargs) | |
94 | + | |
95 | + if param.default.use_cache: | |
96 | + request["_dep_cache"][param.default.dependency] = res | |
97 | + kwargs[name] = res | |
98 | + | |
99 | + elif name == "request" or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "Request"): | |
100 | + kwargs[name] = request | |
101 | + else: | |
102 | + from .params import Param, Path, Query, Header, Cookie, Body, File, Form | |
103 | + | |
104 | + alias = name | |
105 | + param_type = None | |
106 | + default = inspect.Parameter.empty | |
107 | + | |
108 | + if isinstance(param.default, Param): | |
109 | + alias = param.default.alias or name | |
110 | + param_type = type(param.default) | |
111 | + default = param.default.default | |
112 | + else: | |
113 | + default = param.default | |
114 | + | |
115 | + val = None | |
116 | + found = False | |
117 | + | |
118 | + if param_type is Path or (param_type is None and alias in request.get("path_params", {})): | |
119 | + val = request.get("path_params", {}).get(alias) | |
120 | + if val is not None: found = True | |
121 | + | |
122 | + if not found and (param_type is Query or (param_type is None and alias in request.get("query_params", {}))): | |
123 | + val = request.get("query_params", {}).get(alias) | |
124 | + if val is not None: found = True | |
125 | + | |
126 | + if not found and param_type is Header: | |
127 | + headers = request.get("headers", []) | |
128 | + if isinstance(headers, list): | |
129 | + for hk, hv in headers: | |
130 | + if hk.decode("utf-8").lower() == alias.lower(): | |
131 | + val = hv.decode("utf-8") | |
132 | + found = True | |
133 | + break | |
134 | + | |
135 | + if not found and param_type is Cookie: | |
136 | + val = request.get("cookies", {}).get(alias) | |
137 | + if val is not None: found = True | |
138 | + | |
139 | + if not found and (param_type is File or param_type is Form or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "UploadFile")): | |
140 | + form_data = request.get("form") | |
141 | + if form_data: | |
142 | + val = form_data.get(alias) | |
143 | + if val is not None: found = True | |
144 | + | |
145 | + if not found and (param_type is Body or name == "body" or name == "payload"): | |
146 | + val = request.get("body") | |
147 | + if val is not None: found = True | |
148 | + | |
149 | + if not found and (name == "background_tasks" or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "BackgroundTasks")): | |
150 | + val = request.get("background_tasks") | |
151 | + found = True | |
152 | + | |
153 | + if found: | |
154 | + try: | |
155 | + if val is not None and param.annotation is int and isinstance(val, str): | |
156 | + kwargs[name] = int(val) | |
157 | + elif val is not None and param.annotation is float and isinstance(val, str): | |
158 | + kwargs[name] = float(val) | |
159 | + else: | |
160 | + kwargs[name] = val | |
161 | + except (ValueError, TypeError) as e: | |
162 | + raise RequestValidationError([{"loc": [alias], "msg": str(e), "type": "type_error"}]) | |
163 | + else: | |
164 | + if default is inspect.Parameter.empty: | |
165 | + raise RequestValidationError([{"loc": [alias], "msg": "field required", "type": "value_error.missing"}]) | |
166 | + kwargs[name] = default | |
167 | + return kwargs | |
168 | + | |
169 | + def _resolve_dependencies_list_sync(self, deps: typing.List[Depends], request: dict): | |
170 | + if "_dep_cache" not in request: | |
171 | + request["_dep_cache"] = {} | |
172 | + | |
173 | + for dep in deps: | |
174 | + dep_sig = inspect.signature(dep.dependency) | |
175 | + dep_kwargs = self._resolve_kwargs_sync(dep_sig, request) | |
176 | + | |
177 | + if dep.use_cache and dep.dependency in request["_dep_cache"]: | |
178 | + continue | |
179 | + | |
180 | + res = dep.dependency(**dep_kwargs) | |
181 | + if dep.use_cache: | |
182 | + request["_dep_cache"][dep.dependency] = res | |
183 | + | |
184 | + def _resolve_kwargs_sync(self, sig, request): | |
185 | + if "query_params" not in request: | |
186 | + import urllib.parse | |
187 | + qs = request.get("query_string", b"").decode("utf-8") | |
188 | + parsed = urllib.parse.parse_qs(qs) | |
189 | + request["query_params"] = {k: v[0] for k, v in parsed.items()} | |
190 | + | |
191 | + if "_dep_cache" not in request: | |
192 | + request["_dep_cache"] = {} | |
193 | + | |
194 | + kwargs = {} | |
195 | + for name, param in sig.parameters.items(): | |
196 | + if isinstance(param.default, Depends): | |
197 | + if param.default.use_cache and param.default.dependency in request["_dep_cache"]: | |
198 | + kwargs[name] = request["_dep_cache"][param.default.dependency] | |
199 | + continue | |
200 | + | |
201 | + dep_sig = inspect.signature(param.default.dependency) | |
202 | + dep_kwargs = self._resolve_kwargs_sync(dep_sig, request) | |
203 | + res = param.default.dependency(**dep_kwargs) | |
204 | + | |
205 | + if param.default.use_cache: | |
206 | + request["_dep_cache"][param.default.dependency] = res | |
207 | + kwargs[name] = res | |
208 | + | |
209 | + elif name == "request" or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "Request"): | |
210 | + kwargs[name] = request | |
211 | + else: | |
212 | + from .params import Param, Path, Query, Header, Cookie, Body, File, Form | |
213 | + | |
214 | + alias = name | |
215 | + param_type = None | |
216 | + default = inspect.Parameter.empty | |
217 | + | |
218 | + if isinstance(param.default, Param): | |
219 | + alias = param.default.alias or name | |
220 | + param_type = type(param.default) | |
221 | + default = param.default.default | |
222 | + else: | |
223 | + default = param.default | |
224 | + | |
225 | + val = None | |
226 | + found = False | |
227 | + | |
228 | + if param_type is Path or (param_type is None and alias in request.get("path_params", {})): | |
229 | + val = request.get("path_params", {}).get(alias) | |
230 | + if val is not None: found = True | |
231 | + | |
232 | + if not found and (param_type is Query or (param_type is None and alias in request.get("query_params", {}))): | |
233 | + val = request.get("query_params", {}).get(alias) | |
234 | + if val is not None: found = True | |
235 | + | |
236 | + if not found and param_type is Header: | |
237 | + headers = request.get("headers", []) | |
238 | + if isinstance(headers, list): | |
239 | + for hk, hv in headers: | |
240 | + if hk.decode("utf-8").lower() == alias.lower(): | |
241 | + val = hv.decode("utf-8") | |
242 | + found = True | |
243 | + break | |
244 | + | |
245 | + if not found and param_type is Cookie: | |
246 | + val = request.get("cookies", {}).get(alias) | |
247 | + if val is not None: found = True | |
248 | + | |
249 | + if not found and (param_type is File or param_type is Form or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "UploadFile")): | |
250 | + form_data = request.get("form") | |
251 | + if form_data: | |
252 | + val = form_data.get(alias) | |
253 | + if val is not None: found = True | |
254 | + | |
255 | + if not found and (param_type is Body or name == "body" or name == "payload"): | |
256 | + val = request.get("body") | |
257 | + if val is not None: found = True | |
258 | + | |
259 | + if not found and (name == "background_tasks" or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "BackgroundTasks")): | |
260 | + val = request.get("background_tasks") | |
261 | + found = True | |
262 | + | |
263 | + if found: | |
264 | + try: | |
265 | + if val is not None and param.annotation is int and isinstance(val, str): | |
266 | + kwargs[name] = int(val) | |
267 | + elif val is not None and param.annotation is float and isinstance(val, str): | |
268 | + kwargs[name] = float(val) | |
269 | + else: | |
270 | + kwargs[name] = val | |
271 | + except (ValueError, TypeError) as e: | |
272 | + raise RequestValidationError([{"loc": [alias], "msg": str(e), "type": "type_error"}]) | |
273 | + else: | |
274 | + if default is inspect.Parameter.empty: | |
275 | + raise RequestValidationError([{"loc": [alias], "msg": "field required", "type": "value_error.missing"}]) | |
276 | + kwargs[name] = default | |
277 | + return kwargs | |
278 | + def _apply_middlewares(self, base_handler, route_middlewares=None): | |
279 | + if not self.middlewares and not route_middlewares: | |
280 | + return base_handler | |
281 | + | |
282 | + all_middlewares = self.middlewares.copy() | |
283 | + if route_middlewares: | |
284 | + all_middlewares.extend(route_middlewares) | |
285 | + | |
286 | + chain = base_handler | |
287 | + # apply in order: the first middleware added is the outermost | |
288 | + for mw in reversed(all_middlewares): | |
289 | + def make_wrapper(mw_func, next_func): | |
290 | + async def wrapped(request): | |
291 | + return await mw_func(request, next_func) | |
292 | + return wrapped | |
293 | + chain = make_wrapper(mw, chain) | |
294 | + return chain | |
295 | + | |
296 | + def _wrap_handler(self, handler, route_dependencies=None, route_middlewares=None): | |
297 | + sig = inspect.signature(handler) | |
298 | + route_deps = route_dependencies or [] | |
299 | + all_deps = self.dependencies + route_deps | |
300 | + | |
301 | + is_async = inspect.iscoroutinefunction(handler) | |
302 | + for name, param in sig.parameters.items(): | |
303 | + if isinstance(param.default, Depends): | |
304 | + if inspect.iscoroutinefunction(param.default.dependency): | |
305 | + is_async = True | |
306 | + | |
307 | + for dep in all_deps: | |
308 | + if inspect.iscoroutinefunction(dep.dependency): | |
309 | + is_async = True | |
310 | + | |
311 | + has_bg = "background_tasks" in sig.parameters or any( | |
312 | + p.annotation.__name__ == "BackgroundTasks" for p in sig.parameters.values() if hasattr(p.annotation, "__name__") | |
313 | + ) | |
314 | + | |
315 | + if is_async: | |
316 | + async def async_wrapper(request): | |
317 | + try: | |
318 | + bg_tasks = None | |
319 | + if has_bg: | |
320 | + from .background import BackgroundTasks | |
321 | + bg_tasks = BackgroundTasks() | |
322 | + request["background_tasks"] = bg_tasks | |
323 | + | |
324 | + await self._resolve_dependencies_list(all_deps, request) | |
325 | + kwargs = await self._resolve_kwargs(sig, request) | |
326 | + | |
327 | + if inspect.iscoroutinefunction(handler): | |
328 | + result = await handler(**kwargs) | |
329 | + else: | |
330 | + result = handler(**kwargs) | |
331 | + | |
332 | + if bg_tasks: bg_tasks() | |
333 | + from ._native_helper import wrap_result | |
334 | + return wrap_result(result) | |
335 | + except Exception as e: | |
336 | + return self._handle_exception(request, e) | |
337 | + return self._apply_middlewares(async_wrapper, route_middlewares) | |
338 | + else: | |
339 | + def sync_wrapper(request): | |
340 | + try: | |
341 | + bg_tasks = None | |
342 | + if has_bg: | |
343 | + from .background import BackgroundTasks | |
344 | + bg_tasks = BackgroundTasks() | |
345 | + request["background_tasks"] = bg_tasks | |
346 | + | |
347 | + self._resolve_dependencies_list_sync(all_deps, request) | |
348 | + kwargs = self._resolve_kwargs_sync(sig, request) | |
349 | + result = handler(**kwargs) | |
350 | + if bg_tasks: bg_tasks() | |
351 | + from ._native_helper import wrap_result | |
352 | + return wrap_result(result) | |
353 | + except Exception as e: | |
354 | + return self._handle_exception(request, e) | |
355 | + | |
356 | + if self.middlewares or route_middlewares: | |
357 | + import asyncio | |
358 | + import functools | |
359 | + import contextvars | |
360 | + async def async_base_handler(request): | |
361 | + loop = asyncio.get_running_loop() | |
362 | + ctx = contextvars.copy_context() | |
363 | + return await loop.run_in_executor(None, ctx.run, sync_wrapper, request) | |
364 | + return self._apply_middlewares(async_base_handler, route_middlewares) | |
365 | + | |
366 | + return sync_wrapper | |
367 | + | |
368 | + def _resolve_batch_config(self, func): | |
369 | + batch_size = getattr(func, "__batch_size__", None) | |
370 | + batch_window_ms = getattr(func, "__batch_window_ms__", None) | |
371 | + return batch_size, batch_window_ms | |
372 | + | |
373 | + def _wrap_batch_handler(self, handler): | |
374 | + is_async = inspect.iscoroutinefunction(handler) | |
375 | + if is_async: | |
376 | + async def async_batch_wrapper(requests): | |
377 | + return await handler(requests) | |
378 | + return async_batch_wrapper | |
379 | + else: | |
380 | + def sync_batch_wrapper(requests): | |
381 | + return handler(requests) | |
382 | + return sync_batch_wrapper | |
383 | + | |
384 | + def get(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
385 | + if handler is None: | |
386 | + def decorator(func): | |
387 | + self._app.get(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares)) | |
388 | + return func | |
389 | + return decorator | |
390 | + self._app.get(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares)) | |
391 | + return handler | |
392 | + | |
393 | + def post(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
394 | + if handler is None: | |
395 | + def decorator(func): | |
396 | + batch_size, batch_window_ms = self._resolve_batch_config(func) | |
397 | + wrapped = self._wrap_batch_handler(func) if batch_size else self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares) | |
398 | + self._app.post(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms) | |
399 | + return func | |
400 | + return decorator | |
401 | + batch_size, batch_window_ms = self._resolve_batch_config(handler) | |
402 | + wrapped = self._wrap_batch_handler(handler) if batch_size else self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares) | |
403 | + self._app.post(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms) | |
404 | + return handler | |
405 | + | |
406 | + def put(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
407 | + if handler is None: | |
408 | + def decorator(func): | |
409 | + batch_size, batch_window_ms = self._resolve_batch_config(func) | |
410 | + wrapped = self._wrap_batch_handler(func) if batch_size else self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares) | |
411 | + self._app.put(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms) | |
412 | + return func | |
413 | + return decorator | |
414 | + batch_size, batch_window_ms = self._resolve_batch_config(handler) | |
415 | + wrapped = self._wrap_batch_handler(handler) if batch_size else self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares) | |
416 | + self._app.put(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms) | |
417 | + return handler | |
418 | + | |
419 | + def delete(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
420 | + if handler is None: | |
421 | + def decorator(func): | |
422 | + self._app.delete(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares)) | |
423 | + return func | |
424 | + return decorator | |
425 | + self._app.delete(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares)) | |
426 | + return handler | |
427 | + | |
428 | + def patch(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
429 | + if handler is None: | |
430 | + def decorator(func): | |
431 | + batch_size, batch_window_ms = self._resolve_batch_config(func) | |
432 | + wrapped = self._wrap_batch_handler(func) if batch_size else self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares) | |
433 | + self._app.patch(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms) | |
434 | + return func | |
435 | + return decorator | |
436 | + batch_size, batch_window_ms = self._resolve_batch_config(handler) | |
437 | + wrapped = self._wrap_batch_handler(handler) if batch_size else self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares) | |
438 | + self._app.patch(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms) | |
439 | + return handler | |
440 | + | |
441 | + def query(self, path: str, handler=None, body_schema=None, schema=None, experimental: bool = True, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
442 | + """Register a route for the HTTP QUERY method (RFC 10008). | |
443 | + | |
444 | + QUERY is safe and idempotent like GET, but carries a request body | |
445 | + like POST — ideal for queries too large for the URI. By default the | |
446 | + generated OpenAPI operation is tagged ``experimental``. | |
447 | + """ | |
448 | + if handler is None: | |
449 | + def decorator(func): | |
450 | + self._app.query(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares), body_schema, schema, experimental) | |
451 | + return func | |
452 | + return decorator | |
453 | + self._app.query(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares), body_schema, schema, experimental) | |
454 | + return handler | |
455 | + | |
456 | + def add_plugin(self, plugin): | |
457 | + self._app.use(plugin) | |
458 | + | |
459 | + def use(self, plugin): | |
460 | + self._app.use(plugin) | |
461 | + | |
462 | + def set_database(self, db): | |
463 | + self._app.set_database(db) | |
464 | + | |
465 | + def enable_gateway(self, config_path: str): | |
466 | + self._app.enable_gateway(config_path) | |
467 | + | |
468 | + def enable_circuit_breaker(self, failure_threshold: int = 5, reset_timeout_ms: int = 10000): | |
469 | + """ | |
470 | + Enables a per-route circuit breaker. | |
471 | + If a handler fails `failure_threshold` times in a row, the circuit opens, | |
472 | + immediately rejecting requests with 503 Service Unavailable for `reset_timeout_ms`. | |
473 | + """ | |
474 | + self._app.enable_circuit_breaker(failure_threshold, reset_timeout_ms) | |
475 | + | |
476 | + def enable_request_coalescing(self, headers: typing.List[str] = None): | |
477 | + """ | |
478 | + Enable request coalescing (singleflight). | |
479 | + | |
480 | + When many concurrent, identical requests arrive for the same route, only | |
481 | + one is allowed to reach the handler; the rest share its response. This | |
482 | + collapses thundering-herd traffic on hot, read-only endpoints (e.g. a | |
483 | + leaderboard or a model lookup) into a single upstream call. | |
484 | + | |
485 | + By default requests are keyed on ``(method, uri)``. Pass ``headers`` | |
486 | + (e.g. ``["accept"]``) to also key on those request header values, so | |
487 | + distinct representations of the same resource are not collapsed together. | |
488 | + """ | |
489 | + self._app.enable_request_coalescing(headers) | |
490 | + | |
491 | + def set_grpc_addr(self, addr: str): | |
492 | + self._app.set_grpc_addr(addr) | |
493 | + | |
494 | + def add_grpc_service(self, servicer, add_func_to_server): | |
495 | + server = _JustAPIGrpcMockServer(self) | |
496 | + add_func_to_server(servicer, server) | |
497 | + | |
498 | + def include_controller(self, controller_cls): | |
499 | + path_prefix = getattr(controller_cls, "__controller_path__", "") | |
500 | + controller_deps = getattr(controller_cls, "__controller_dependencies__", []) | |
501 | + | |
502 | + # Instantiate controller | |
503 | + instance = controller_cls() | |
504 | + | |
505 | + for name, method in inspect.getmembers(instance, predicate=inspect.ismethod): | |
506 | + # check if it has routing info | |
507 | + if hasattr(method, "__route_info__"): | |
508 | + route = method.__route_info__ | |
509 | + path = path_prefix + route["path"] | |
510 | + combined_deps = controller_deps + route["dependencies"] | |
511 | + | |
512 | + if route["method"] == "GET": | |
513 | + self.get(path, method, dependencies=combined_deps) | |
514 | + elif route["method"] == "POST": | |
515 | + self.post(path, method, body_schema=route["body_schema"], schema=route["schema"], dependencies=combined_deps) | |
516 | + elif route["method"] == "PUT": | |
517 | + self.put(path, method, body_schema=route["body_schema"], schema=route["schema"], dependencies=combined_deps) | |
518 | + elif route["method"] == "DELETE": | |
519 | + self.delete(path, method, dependencies=combined_deps) | |
520 | + elif route["method"] == "QUERY": | |
521 | + self.query(path, method, body_schema=route["body_schema"], schema=route["schema"], experimental=route.get("experimental", True), dependencies=combined_deps) | |
522 | + elif route["method"] == "SSE": | |
523 | + self._app.get(path, self._wrap_sse_handler(method)) | |
524 | + elif route["method"] == "WS": | |
525 | + self._app.websocket(path, method) | |
526 | + | |
527 | + def include_router(self, router, prefix: str = ""): | |
528 | + for route in router.routes: | |
529 | + path = prefix + route["path"] | |
530 | + # Combine dependencies: App -> Router -> Route | |
531 | + combined_deps = router.dependencies + route["dependencies"] | |
532 | + combined_mws = router.middlewares + route.get("middlewares", []) | |
533 | + | |
534 | + if route["method"] == "GET": | |
535 | + self.get(path, route["handler"], dependencies=combined_deps, middlewares=combined_mws) | |
536 | + elif route["method"] == "POST": | |
537 | + self.post(path, route["handler"], body_schema=route["body_schema"], schema=route["schema"], dependencies=combined_deps, middlewares=combined_mws) | |
538 | + elif route["method"] == "PUT": | |
539 | + self.put(path, route["handler"], body_schema=route["body_schema"], schema=route["schema"], dependencies=combined_deps, middlewares=combined_mws) | |
540 | + elif route["method"] == "PATCH": | |
541 | + self.patch(path, route["handler"], body_schema=route["body_schema"], schema=route["schema"], dependencies=combined_deps, middlewares=combined_mws) | |
542 | + elif route["method"] == "DELETE": | |
543 | + self.delete(path, route["handler"], dependencies=combined_deps, middlewares=combined_mws) | |
544 | + elif route["method"] == "QUERY": | |
545 | + self.query(path, route["handler"], body_schema=route.get("body_schema"), schema=route.get("schema"), experimental=route.get("experimental", True), dependencies=combined_deps, middlewares=combined_mws) | |
546 | + elif route["method"] == "SSE": | |
547 | + self._app.get(path, self._wrap_sse_handler(route["handler"])) | |
548 | + elif route["method"] == "WS": | |
549 | + self._app.websocket(path, route["handler"]) | |
550 | + | |
551 | + def run(self, addr: str): | |
552 | + self._app.run(addr) | |
553 | + | |
554 | + def _wrap_sse_handler(self, func): | |
555 | + """Wrap a generator/async-generator handler into a TokenStreamResponse.""" | |
556 | + if inspect.isasyncgenfunction(func): | |
557 | + def wrapper(request): | |
558 | + return TokenStreamResponse(func(request)) | |
559 | + return wrapper | |
560 | + | |
561 | + def wrapper(request): | |
562 | + gen = func(request) | |
563 | + | |
564 | + async def agen(): | |
565 | + for item in gen: | |
566 | + yield item | |
567 | + | |
568 | + return TokenStreamResponse(agen()) | |
569 | + return wrapper | |
570 | + | |
571 | + def sse(self, path: str, handler=None): | |
572 | + """Register a Server-Sent Events route. | |
573 | + | |
574 | + The handler may be a sync or async generator yielding strings/bytes. | |
575 | + Each yielded item is streamed to the client as an SSE event. | |
576 | + """ | |
577 | + if handler is None: | |
578 | + def decorator(func): | |
579 | + self._app.get(path, self._wrap_sse_handler(func)) | |
580 | + return func | |
581 | + return decorator | |
582 | + self._app.get(path, self._wrap_sse_handler(handler)) | |
583 | + return handler | |
584 | + | |
585 | + def websocket(self, path: str, handler=None): | |
586 | + """Register a WebSocket route. | |
587 | + | |
588 | + The handler is an async function receiving a single `WebSocket` object: | |
589 | + async def handler(ws): | |
590 | + await ws.accept() | |
591 | + msg = await ws.receive_text() | |
592 | + await ws.send_text(msg) | |
593 | + await ws.close() | |
594 | + """ | |
595 | + if handler is None: | |
596 | + def decorator(func): | |
597 | + self._app.websocket(path, func) | |
598 | + return func | |
599 | + return decorator | |
600 | + self._app.websocket(path, handler) | |
601 | + return handler | |
602 | + | |
603 | + @property | |
604 | + def _inner(self): | |
605 | + return self._app | |
606 | + | |
607 | +class APIRouter: | |
608 | + def __init__(self, prefix: str = "", dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
609 | + self.prefix = prefix | |
610 | + self.dependencies = dependencies or [] | |
611 | + self.middlewares = middlewares or [] | |
612 | + self.routes = [] | |
613 | + | |
614 | + def get(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
615 | + if handler is None: | |
616 | + def decorator(func): | |
617 | + self.routes.append({"method": "GET", "path": self.prefix + path, "handler": func, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
618 | + return func | |
619 | + return decorator | |
620 | + self.routes.append({"method": "GET", "path": self.prefix + path, "handler": handler, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
621 | + return handler | |
622 | + | |
623 | + def post(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
624 | + if handler is None: | |
625 | + def decorator(func): | |
626 | + self.routes.append({"method": "POST", "path": self.prefix + path, "handler": func, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
627 | + return func | |
628 | + return decorator | |
629 | + self.routes.append({"method": "POST", "path": self.prefix + path, "handler": handler, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
630 | + return handler | |
631 | + | |
632 | + def put(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
633 | + if handler is None: | |
634 | + def decorator(func): | |
635 | + self.routes.append({"method": "PUT", "path": self.prefix + path, "handler": func, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
636 | + return func | |
637 | + return decorator | |
638 | + self.routes.append({"method": "PUT", "path": self.prefix + path, "handler": handler, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
639 | + return handler | |
640 | + | |
641 | + def patch(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
642 | + if handler is None: | |
643 | + def decorator(func): | |
644 | + self.routes.append({"method": "PATCH", "path": self.prefix + path, "handler": func, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
645 | + return func | |
646 | + return decorator | |
647 | + self.routes.append({"method": "PATCH", "path": self.prefix + path, "handler": handler, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
648 | + return handler | |
649 | + | |
650 | + def delete(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
651 | + if handler is None: | |
652 | + def decorator(func): | |
653 | + self.routes.append({"method": "DELETE", "path": self.prefix + path, "handler": func, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
654 | + return func | |
655 | + return decorator | |
656 | + self.routes.append({"method": "DELETE", "path": self.prefix + path, "handler": handler, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
657 | + return handler | |
658 | + | |
659 | + def query(self, path: str, handler=None, body_schema=None, schema=None, experimental: bool = True, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None): | |
660 | + if handler is None: | |
661 | + def decorator(func): | |
662 | + self.routes.append({"method": "QUERY", "path": self.prefix + path, "handler": func, "body_schema": body_schema, "schema": schema, "experimental": experimental, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
663 | + return func | |
664 | + return decorator | |
665 | + self.routes.append({"method": "QUERY", "path": self.prefix + path, "handler": handler, "body_schema": body_schema, "schema": schema, "experimental": experimental, "dependencies": dependencies or [], "middlewares": middlewares or []}) | |
666 | + return handler | |
667 | + | |
668 | + def sse(self, path: str, handler=None, dependencies: typing.List[Depends] = None): | |
669 | + if handler is None: | |
670 | + def decorator(func): | |
671 | + self.routes.append({"method": "SSE", "path": self.prefix + path, "handler": func, "dependencies": dependencies or []}) | |
672 | + return func | |
673 | + return decorator | |
674 | + self.routes.append({"method": "SSE", "path": self.prefix + path, "handler": handler, "dependencies": dependencies or []}) | |
675 | + return handler | |
676 | + | |
677 | + def websocket(self, path: str, handler=None, dependencies: typing.List[Depends] = None): | |
678 | + if handler is None: | |
679 | + def decorator(func): | |
680 | + self.routes.append({"method": "WS", "path": self.prefix + path, "handler": func, "dependencies": dependencies or []}) | |
681 | + return func | |
682 | + return decorator | |
683 | + self.routes.append({"method": "WS", "path": self.prefix + path, "handler": handler, "dependencies": dependencies or []}) | |
684 | + return handler | |
685 | + | |
686 | +class Controller: | |
687 | + """Base class for controller classes.""" | |
688 | + pass | |
689 | + | |
690 | +def controller(path: str, dependencies: typing.List[Depends] = None): | |
691 | + def decorator(cls): | |
692 | + cls.__controller_path__ = path | |
693 | + cls.__controller_dependencies__ = dependencies or [] | |
694 | + return cls | |
695 | + return decorator | |
696 | + | |
697 | +def route_get(path: str, dependencies: typing.List[Depends] = None): | |
698 | + def decorator(func): | |
699 | + func.__route_info__ = {"method": "GET", "path": path, "dependencies": dependencies or []} | |
700 | + return func | |
701 | + return decorator | |
702 | + | |
703 | +def route_post(path: str, body_schema=None, schema=None, dependencies: typing.List[Depends] = None): | |
704 | + def decorator(func): | |
705 | + func.__route_info__ = {"method": "POST", "path": path, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or []} | |
706 | + return func | |
707 | + return decorator | |
708 | + | |
709 | +def route_put(path: str, body_schema=None, schema=None, dependencies: typing.List[Depends] = None): | |
710 | + def decorator(func): | |
711 | + func.__route_info__ = {"method": "PUT", "path": path, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or []} | |
712 | + return func | |
713 | + return decorator | |
714 | + | |
715 | +def route_patch(path: str, body_schema=None, schema=None, dependencies: typing.List[Depends] = None): | |
716 | + def decorator(func): | |
717 | + func.__route_info__ = {"method": "PATCH", "path": path, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or []} | |
718 | + return func | |
719 | + return decorator | |
720 | + | |
721 | +def route_delete(path: str, dependencies: typing.List[Depends] = None): | |
722 | + def decorator(func): | |
723 | + func.__route_info__ = {"method": "DELETE", "path": path, "dependencies": dependencies or []} | |
724 | + return func | |
725 | + return decorator | |
726 | + | |
727 | +def route_query(path: str, body_schema=None, schema=None, experimental: bool = True, dependencies: typing.List[Depends] = None): | |
728 | + def decorator(func): | |
729 | + func.__route_info__ = {"method": "QUERY", "path": path, "body_schema": body_schema, "schema": schema, "experimental": experimental, "dependencies": dependencies or []} | |
730 | + return func | |
731 | + return decorator | |
732 | + | |
733 | +def route_sse(path: str, dependencies: typing.List[Depends] = None): | |
734 | + def decorator(func): | |
735 | + func.__route_info__ = {"method": "SSE", "path": path, "dependencies": dependencies or []} | |
736 | + return func | |
737 | + return decorator | |
738 | + | |
739 | +def route_websocket(path: str, dependencies: typing.List[Depends] = None): | |
740 | + def decorator(func): | |
741 | + func.__route_info__ = {"method": "WS", "path": path, "dependencies": dependencies or []} | |
742 | + return func | |
743 | + return decorator | |
744 | + | |
745 | +class _JustAPIGrpcContext: | |
746 | + def __init__(self): | |
747 | + self._code = None | |
748 | + self._details = None | |
749 | + def set_code(self, code): | |
750 | + self._code = code | |
751 | + def set_details(self, details): | |
752 | + self._details = details | |
753 | + def abort(self, code, details): | |
754 | + raise Exception(f"gRPC Abort: {code} - {details}") | |
755 | + | |
756 | +class _JustAPIGrpcMockServer: | |
757 | + def __init__(self, app): | |
758 | + self.app = app | |
759 | + | |
760 | + def add_generic_rpc_handlers(self, generic_rpc_handlers): | |
761 | + for handler in generic_rpc_handlers: | |
762 | + if hasattr(handler, "_method_handlers"): | |
763 | + self._register_handlers(handler._method_handlers) | |
764 | + | |
765 | + def add_registered_method_handlers(self, service_name, method_handlers): | |
766 | + self._register_handlers(method_handlers) | |
767 | + | |
768 | + def _register_handlers(self, method_handlers): | |
769 | + import inspect | |
770 | + for method_name, m_handler in method_handlers.items(): | |
771 | + if not method_name.startswith("/"): | |
772 | + # If they pass raw names without the leading slash | |
773 | + pass | |
774 | + def make_wrapper(mh): | |
775 | + def wrapper(payload: bytes) -> bytes: | |
776 | + req = mh.request_deserializer(payload) | |
777 | + ctx = _JustAPIGrpcContext() | |
778 | + if mh.unary_unary is not None: | |
779 | + # Extract the actual method if it's bound | |
780 | + func = mh.unary_unary | |
781 | + if hasattr(func, "__func__"): | |
782 | + real_func = func.__func__ | |
783 | + else: | |
784 | + real_func = func | |
785 | + | |
786 | + if inspect.iscoroutinefunction(real_func): | |
787 | + import asyncio | |
788 | + res = asyncio.run(func(req, ctx)) | |
789 | + else: | |
790 | + res = func(req, ctx) | |
791 | + return mh.response_serializer(res) | |
792 | + else: | |
793 | + raise NotImplementedError("Only unary-unary gRPC is supported currently") | |
794 | + return wrapper | |
795 | + | |
796 | + self.app._app.add_grpc_service(method_name, make_wrapper(m_handler)) | |
797 | + | |
798 | +from ._justapi import JustAPITestClient as _JustAPITestClient | |
799 | + | |
800 | +class JustAPITestClient: | |
801 | + def __new__(cls, app, database=None): | |
802 | + inner = getattr(app, "_inner", app) | |
803 | + return _JustAPITestClient(inner, database=database) | |
0 | 804 | |
▼crates/justapi-py/python/justapi/app.pyi
+83-0
| @@ -0,0 +1,83 @@ | ||
1 | +import typing | |
2 | +from ._justapi import TokenStreamResponse as TokenStreamResponse | |
3 | +from _typeshed import Incomplete | |
4 | + | |
5 | +class RequestValidationError(Exception): | |
6 | + errors: Incomplete | |
7 | + def __init__(self, errors) -> None: ... | |
8 | + | |
9 | +class Depends: | |
10 | + dependency: Incomplete | |
11 | + use_cache: Incomplete | |
12 | + def __init__(self, dependency: typing.Callable, use_cache: bool = True) -> None: ... | |
13 | + | |
14 | +def adaptive_batch(max_size: int = 32, window_ms: int = 10): ... | |
15 | + | |
16 | +class JustAPIApp: | |
17 | + dependencies: Incomplete | |
18 | + exception_handlers: Incomplete | |
19 | + middlewares: Incomplete | |
20 | + def __init__(self, dependencies: list[Depends] = None) -> None: ... | |
21 | + def middleware(self, middleware_type: str = 'http'): ... | |
22 | + def add_exception_handler(self, exc_class, handler) -> None: ... | |
23 | + def get(self, path: str, handler=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
24 | + def post(self, path: str, handler=None, body_schema=None, schema=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
25 | + def put(self, path: str, handler=None, body_schema=None, schema=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
26 | + def delete(self, path: str, handler=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
27 | + def patch(self, path: str, handler=None, body_schema=None, schema=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
28 | + def query(self, path: str, handler=None, body_schema=None, schema=None, experimental: bool = True, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
29 | + def add_plugin(self, plugin) -> None: ... | |
30 | + def use(self, plugin) -> None: ... | |
31 | + def set_database(self, db) -> None: ... | |
32 | + def enable_gateway(self, config_path: str): ... | |
33 | + def enable_circuit_breaker(self, failure_threshold: int = 5, reset_timeout_ms: int = 10000): ... | |
34 | + def enable_request_coalescing(self, headers: list[str] = None): ... | |
35 | + def set_grpc_addr(self, addr: str): ... | |
36 | + def add_grpc_service(self, servicer, add_func_to_server) -> None: ... | |
37 | + def include_controller(self, controller_cls) -> None: ... | |
38 | + def include_router(self, router, prefix: str = ''): ... | |
39 | + def run(self, addr: str): ... | |
40 | + def sse(self, path: str, handler=None): ... | |
41 | + def websocket(self, path: str, handler=None): ... | |
42 | + | |
43 | +class APIRouter: | |
44 | + prefix: Incomplete | |
45 | + dependencies: Incomplete | |
46 | + middlewares: Incomplete | |
47 | + routes: Incomplete | |
48 | + def __init__(self, prefix: str = '', dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None) -> None: ... | |
49 | + def get(self, path: str, handler=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
50 | + def post(self, path: str, handler=None, body_schema=None, schema=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
51 | + def put(self, path: str, handler=None, body_schema=None, schema=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
52 | + def patch(self, path: str, handler=None, body_schema=None, schema=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
53 | + def delete(self, path: str, handler=None, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
54 | + def query(self, path: str, handler=None, body_schema=None, schema=None, experimental: bool = True, dependencies: list[Depends] = None, middlewares: list[typing.Callable] = None): ... | |
55 | + def sse(self, path: str, handler=None, dependencies: list[Depends] = None): ... | |
56 | + def websocket(self, path: str, handler=None, dependencies: list[Depends] = None): ... | |
57 | + | |
58 | +class Controller: ... | |
59 | + | |
60 | +def controller(path: str, dependencies: list[Depends] = None): ... | |
61 | +def route_get(path: str, dependencies: list[Depends] | None = None) -> typing.Callable: ... | |
62 | +def route_post(path: str, body_schema=None, schema=None, dependencies: list[Depends] | None = None) -> typing.Callable: ... | |
63 | +def route_put(path: str, body_schema=None, schema=None, dependencies: list[Depends] | None = None) -> typing.Callable: ... | |
64 | +def route_patch(path: str, body_schema=None, schema=None, dependencies: list[Depends] | None = None) -> typing.Callable: ... | |
65 | +def route_delete(path: str, dependencies: list[Depends] | None = None) -> typing.Callable: ... | |
66 | +def route_query(path: str, body_schema=None, schema=None, experimental: bool = True, dependencies: list[Depends] = None): ... | |
67 | +def route_sse(path: str, dependencies: list[Depends] = None): ... | |
68 | +def route_websocket(path: str, dependencies: list[Depends] = None): ... | |
69 | + | |
70 | +class _JustAPIGrpcContext: | |
71 | + def __init__(self) -> None: ... | |
72 | + def set_code(self, code) -> None: ... | |
73 | + def set_details(self, details) -> None: ... | |
74 | + def abort(self, code, details) -> None: ... | |
75 | + | |
76 | +class _JustAPIGrpcMockServer: | |
77 | + app: Incomplete | |
78 | + def __init__(self, app) -> None: ... | |
79 | + def add_generic_rpc_handlers(self, generic_rpc_handlers) -> None: ... | |
80 | + def add_registered_method_handlers(self, service_name, method_handlers) -> None: ... | |
81 | + | |
82 | +class JustAPITestClient: | |
83 | + def __new__(cls, app, database=None): ... | |
0 | 84 | |
▼crates/justapi-py/python/justapi/background.py
+32-0
| @@ -0,0 +1,32 @@ | ||
1 | +import inspect | |
2 | +import asyncio | |
3 | +import threading | |
4 | + | |
5 | +class BackgroundTasks: | |
6 | + """ | |
7 | + Collects functions to execute after a response is returned. | |
8 | + """ | |
9 | + def __init__(self): | |
10 | + self.tasks = [] | |
11 | + | |
12 | + def add_task(self, func, *args, **kwargs): | |
13 | + self.tasks.append((func, args, kwargs)) | |
14 | + | |
15 | + def __call__(self): | |
16 | + """Execute all background tasks.""" | |
17 | + for func, args, kwargs in self.tasks: | |
18 | + if inspect.iscoroutinefunction(func): | |
19 | + # Try to get the current event loop, otherwise start a new thread for it | |
20 | + try: | |
21 | + loop = asyncio.get_running_loop() | |
22 | + loop.create_task(func(*args, **kwargs)) | |
23 | + except RuntimeError: | |
24 | + # If no running loop, run in a new thread with its own loop | |
25 | + def run_async(f, a, kw): | |
26 | + new_loop = asyncio.new_event_loop() | |
27 | + asyncio.set_event_loop(new_loop) | |
28 | + new_loop.run_until_complete(f(*a, **kw)) | |
29 | + threading.Thread(target=run_async, args=(func, args, kwargs)).start() | |
30 | + else: | |
31 | + # Run sync functions in a background thread | |
32 | + threading.Thread(target=func, args=args, kwargs=kwargs).start() | |
0 | 33 | |
▼crates/justapi-py/python/justapi/background.pyi
+7-0
| @@ -0,0 +1,7 @@ | ||
1 | +from _typeshed import Incomplete | |
2 | + | |
3 | +class BackgroundTasks: | |
4 | + tasks: Incomplete | |
5 | + def __init__(self) -> None: ... | |
6 | + def add_task(self, func, *args, **kwargs) -> None: ... | |
7 | + def __call__(self) -> None: ... | |
0 | 8 | |
▼crates/justapi-py/python/justapi/grpc_compiler.py
+38-0
| @@ -0,0 +1,38 @@ | ||
1 | +import os | |
2 | +import sys | |
3 | +import subprocess | |
4 | + | |
5 | +def compile_protobuf(proto_path: str, out_dir: str): | |
6 | + """ | |
7 | + Compiles a .proto file into Python stubs using grpcio-tools. | |
8 | + """ | |
9 | + try: | |
10 | + import grpc_tools.protoc | |
11 | + except ImportError: | |
12 | + print("Error: grpcio-tools is required. Install it using 'pip install grpcio-tools'") | |
13 | + sys.exit(1) | |
14 | + | |
15 | + if not os.path.exists(out_dir): | |
16 | + os.makedirs(out_dir, exist_ok=True) | |
17 | + | |
18 | + protoc_args = [ | |
19 | + "grpc_tools.protoc", | |
20 | + f"--proto_path={os.path.dirname(proto_path) or '.'}", | |
21 | + f"--python_out={out_dir}", | |
22 | + f"--grpc_python_out={out_dir}", | |
23 | + f"--pyi_out={out_dir}", | |
24 | + proto_path, | |
25 | + ] | |
26 | + | |
27 | + print(f"Compiling {proto_path}...") | |
28 | + result = grpc_tools.protoc.main(protoc_args) | |
29 | + if result != 0: | |
30 | + print(f"Failed to compile {proto_path}") | |
31 | + sys.exit(result) | |
32 | + print("Protobuf compilation successful.") | |
33 | + | |
34 | +if __name__ == "__main__": | |
35 | + if len(sys.argv) < 3: | |
36 | + print("Usage: python compile_proto.py <proto_path> <out_dir>") | |
37 | + sys.exit(1) | |
38 | + compile_protobuf(sys.argv[1], sys.argv[2]) | |
0 | 39 | |
▼crates/justapi-py/python/justapi/grpc_compiler.pyi
+1-0
| @@ -0,0 +1 @@ | ||
1 | +def compile_protobuf(proto_path: str, out_dir: str): ... | |
0 | 2 | |
▼crates/justapi-py/python/justapi/params.py
+21-0
| @@ -0,0 +1,21 @@ | ||
1 | +from typing import Any, Optional | |
2 | + | |
3 | +class Param: | |
4 | + def __init__(self, default: Any = ..., alias: Optional[str] = None): | |
5 | + self.default = default | |
6 | + self.alias = alias | |
7 | + | |
8 | +class Path(Param): pass | |
9 | +class Query(Param): pass | |
10 | +class Header(Param): pass | |
11 | +class Cookie(Param): | |
12 | + pass | |
13 | + | |
14 | +class Body(Param): | |
15 | + pass | |
16 | + | |
17 | +class File(Param): | |
18 | + pass | |
19 | + | |
20 | +class Form(Param): | |
21 | + pass | |
0 | 22 | |
▼crates/justapi-py/python/justapi/params.pyi
+15-0
| @@ -0,0 +1,15 @@ | ||
1 | +from _typeshed import Incomplete | |
2 | +from typing import Any | |
3 | + | |
4 | +class Param: | |
5 | + default: Incomplete | |
6 | + alias: Incomplete | |
7 | + def __init__(self, default: Any = ..., alias: str | None = None) -> None: ... | |
8 | + | |
9 | +class Path(Param): ... | |
10 | +class Query(Param): ... | |
11 | +class Header(Param): ... | |
12 | +class Cookie(Param): ... | |
13 | +class Body(Param): ... | |
14 | +class File(Param): ... | |
15 | +class Form(Param): ... | |
0 | 16 | |
▼crates/justapi-py/python/justapi/responses.py
+85-0
| @@ -0,0 +1,85 @@ | ||
1 | +import json | |
2 | +import urllib.parse | |
3 | +from typing import Any, Mapping, Optional, Union | |
4 | +from ._justapi import TokenStreamResponse | |
5 | + | |
6 | +class Response: | |
7 | + media_type = None | |
8 | + charset = "utf-8" | |
9 | + | |
10 | + def __init__( | |
11 | + self, | |
12 | + content: Any = None, | |
13 | + status_code: int = 200, | |
14 | + headers: Optional[Mapping[str, str]] = None, | |
15 | + media_type: Optional[str] = None, | |
16 | + ) -> None: | |
17 | + self.status_code = status_code | |
18 | + if media_type is not None: | |
19 | + self.media_type = media_type | |
20 | + self.background = None | |
21 | + self.body = self.render(content) | |
22 | + self.headers = self.init_headers(headers) | |
23 | + | |
24 | + def render(self, content: Any) -> bytes: | |
25 | + if content is None: | |
26 | + return b"" | |
27 | + if isinstance(content, bytes): | |
28 | + return content | |
29 | + return str(content).encode(self.charset) | |
30 | + | |
31 | + def init_headers(self, headers: Optional[Mapping[str, str]] = None) -> list: | |
32 | + raw_headers = [] | |
33 | + if headers: | |
34 | + for k, v in headers.items(): | |
35 | + raw_headers.append((k.lower().encode("latin-1"), v.encode("latin-1"))) | |
36 | + if self.media_type is not None: | |
37 | + raw_headers.append((b"content-type", self.media_type.encode("latin-1"))) | |
38 | + return raw_headers | |
39 | + | |
40 | + def to_dict(self): | |
41 | + return { | |
42 | + "status": self.status_code, | |
43 | + "headers": self.headers, | |
44 | + "body": self.body, | |
45 | + } | |
46 | + | |
47 | +class HTMLResponse(Response): | |
48 | + media_type = "text/html" | |
49 | + | |
50 | +class PlainTextResponse(Response): | |
51 | + media_type = "text/plain" | |
52 | + | |
53 | +class JSONResponse(Response): | |
54 | + media_type = "application/json" | |
55 | + | |
56 | + def render(self, content: Any) -> bytes: | |
57 | + return json.dumps(content, default=str).encode(self.charset) | |
58 | + | |
59 | +class RedirectResponse(Response): | |
60 | + def __init__( | |
61 | + self, | |
62 | + url: str, | |
63 | + status_code: int = 307, | |
64 | + headers: Optional[Mapping[str, str]] = None, | |
65 | + ) -> None: | |
66 | + super().__init__(content=b"", status_code=status_code, headers=headers) | |
67 | + self.headers.append((b"location", urllib.parse.quote(url, safe=":/%#?=@[]!$&'()*+,;").encode("latin-1"))) | |
68 | + | |
69 | +class StreamingResponse(TokenStreamResponse): | |
70 | + def __init__( | |
71 | + self, | |
72 | + content: Any, | |
73 | + status_code: int = 200, | |
74 | + headers: Optional[Mapping[str, str]] = None, | |
75 | + media_type: Optional[str] = None, | |
76 | + ) -> None: | |
77 | + raw_headers = [] | |
78 | + if headers: | |
79 | + for k, v in headers.items(): | |
80 | + raw_headers.append((k.lower().encode("latin-1"), v.encode("latin-1"))) | |
81 | + if media_type is not None: | |
82 | + raw_headers.append((b"content-type", media_type.encode("latin-1"))) | |
83 | + | |
84 | + super().__init__(content, status_code, raw_headers) | |
85 | + | |
0 | 86 | |
▼crates/justapi-py/python/justapi/responses.pyi
+37-0
| @@ -0,0 +1,37 @@ | ||
1 | +from _typeshed import Incomplete | |
2 | +from typing import Any, Mapping, Optional | |
3 | +from ._justapi import TokenStreamResponse | |
4 | + | |
5 | +class Response: | |
6 | + media_type: Incomplete | |
7 | + charset: str | |
8 | + status_code: Incomplete | |
9 | + background: Incomplete | |
10 | + body: Incomplete | |
11 | + headers: Incomplete | |
12 | + def __init__(self, content: Any = None, status_code: int = 200, headers: Mapping[str, str] | None = None, media_type: str | None = None) -> None: ... | |
13 | + def render(self, content: Any) -> bytes: ... | |
14 | + def init_headers(self, headers: Mapping[str, str] | None = None) -> list: ... | |
15 | + def to_dict(self): ... | |
16 | + | |
17 | +class HTMLResponse(Response): | |
18 | + media_type: str | |
19 | + | |
20 | +class PlainTextResponse(Response): | |
21 | + media_type: str | |
22 | + | |
23 | +class JSONResponse(Response): | |
24 | + media_type: str | |
25 | + def render(self, content: Any) -> bytes: ... | |
26 | + | |
27 | +class RedirectResponse(Response): | |
28 | + def __init__(self, url: str, status_code: int = 307, headers: Optional[Mapping[str, str]] = None) -> None: ... | |
29 | + | |
30 | +class StreamingResponse(TokenStreamResponse): | |
31 | + def __init__( | |
32 | + self, | |
33 | + content: Any, | |
34 | + status_code: int = 200, | |
35 | + headers: Optional[Mapping[str, str]] = None, | |
36 | + media_type: Optional[str] = None, | |
37 | + ) -> None: ... | |
0 | 38 | |
▼crates/justapi-py/python/justapi/templating.py
+52-0
| @@ -0,0 +1,52 @@ | ||
1 | +import os | |
2 | +from typing import Any, Dict | |
3 | + | |
4 | +try: | |
5 | + import jinja2 | |
6 | +except ImportError: | |
7 | + jinja2 = None | |
8 | + | |
9 | +class Jinja2Templates: | |
10 | + """ | |
11 | + Jinja2 templating integration for JustAPI. | |
12 | + | |
13 | + Usage: | |
14 | + templates = Jinja2Templates(directory="templates") | |
15 | + | |
16 | + @app.get("/html") | |
17 | + def render_html(request): | |
18 | + return templates.TemplateResponse("index.html", {"request": request, "name": "World"}) | |
19 | + """ | |
20 | + | |
21 | + def __init__(self, directory: str): | |
22 | + if jinja2 is None: | |
23 | + raise RuntimeError( | |
24 | + "jinja2 is required to use Jinja2Templates. " | |
25 | + "Install it with: pip install jinja2" | |
26 | + ) | |
27 | + | |
28 | + self.directory = directory | |
29 | + self.env = jinja2.Environment( | |
30 | + loader=jinja2.FileSystemLoader(directory), | |
31 | + autoescape=True | |
32 | + ) | |
33 | + | |
34 | + def get_template(self, name: str) -> "jinja2.Template": | |
35 | + return self.env.get_template(name) | |
36 | + | |
37 | + def TemplateResponse(self, name: str, context: Dict[str, Any], status_code: int = 200, headers: list = None) -> dict: | |
38 | + """ | |
39 | + Render a template and return a dictionary that JustAPI can automatically convert to a response. | |
40 | + """ | |
41 | + template = self.get_template(name) | |
42 | + body = template.render(context) | |
43 | + | |
44 | + resp_headers = [(b"content-type", b"text/html; charset=utf-8")] | |
45 | + if headers: | |
46 | + resp_headers.extend(headers) | |
47 | + | |
48 | + return { | |
49 | + "status": status_code, | |
50 | + "headers": resp_headers, | |
51 | + "body": body.encode("utf-8"), | |
52 | + } | |
0 | 53 | |
▼crates/justapi-py/python/justapi/templating.pyi
+10-0
| @@ -0,0 +1,10 @@ | ||
1 | +import jinja2 | |
2 | +from _typeshed import Incomplete | |
3 | +from typing import Any | |
4 | + | |
5 | +class Jinja2Templates: | |
6 | + directory: Incomplete | |
7 | + env: Incomplete | |
8 | + def __init__(self, directory: str) -> None: ... | |
9 | + def get_template(self, name: str) -> jinja2.Template: ... | |
10 | + def TemplateResponse(self, name: str, context: dict[str, Any], status_code: int = 200, headers: list = None) -> dict: ... | |
0 | 11 | |
▼crates/justapi-py/python/justapi/testing.py
+475-0
| @@ -0,0 +1,475 @@ | ||
1 | +"""JustAPI testing utilities — async fixtures, DB helpers, and assertions. | |
2 | + | |
3 | +.. warning:: | |
4 | + Test functions **must not** have the same name as imported helpers. | |
5 | + E.g. ``from justapi.testing import test_db`` then ``def test_db():`` | |
6 | + will cause a name collision — rename your test to ``test_my_db:`` instead. | |
7 | + | |
8 | + | |
9 | + | |
10 | +Usage: | |
11 | + import pytest | |
12 | + import pytest_asyncio | |
13 | + from justapi import JustAPIApp | |
14 | + from justapi.testing import AsyncTestClient, assert_ok, assert_json | |
15 | + | |
16 | + @pytest_asyncio.fixture | |
17 | + async def app(): | |
18 | + app = JustAPIApp() | |
19 | + app.get("/ping", lambda r: {"pong": True}) | |
20 | + return app | |
21 | + | |
22 | + @pytest_asyncio.fixture | |
23 | + async def client(app): | |
24 | + async with AsyncTestClient(app) as c: | |
25 | + yield c | |
26 | + | |
27 | + @pytest.mark.asyncio | |
28 | + async def test_ping(client): | |
29 | + resp = await client.get("/ping") | |
30 | + assert_ok(resp) | |
31 | + assert_json(resp, {"pong": True}) | |
32 | + | |
33 | + # With database: | |
34 | + @pytest_asyncio.fixture | |
35 | + async def db_client(app): | |
36 | + async with AsyncTestClient(app, database="sqlite::memory:") as c: | |
37 | + yield c | |
38 | +""" | |
39 | + | |
40 | +import asyncio | |
41 | +import inspect | |
42 | +import json | |
43 | +import os | |
44 | +import subprocess | |
45 | +import sys | |
46 | +import tempfile | |
47 | +from functools import partial | |
48 | +from typing import Any, Awaitable, Callable, Optional | |
49 | + | |
50 | + | |
51 | +class AsyncTestClient: | |
52 | + """Async wrapper around *JustAPITestClient* for use in ``pytest-asyncio`` fixtures. | |
53 | + | |
54 | + Use as an async context manager: | |
55 | + | |
56 | + async with AsyncTestClient(app) as client: | |
57 | + resp = await client.get("/hello") | |
58 | + | |
59 | + To test database-backed routes, pass the *database* URL: | |
60 | + | |
61 | + async with AsyncTestClient(app, database="sqlite::memory:") as client: | |
62 | + resp = await client.post("/users", b'{"name":"Alice"}') | |
63 | + """ | |
64 | + | |
65 | + def __init__(self, app: "Any", database: Optional[str] = None) -> None: | |
66 | + self._app = app | |
67 | + self._database = database | |
68 | + self._client: Optional["Any"] = None | |
69 | + self._setup_hooks: list[Callable[["AsyncTestClient"], Awaitable[None]]] = [] | |
70 | + self._teardown_hooks: list[Callable[["AsyncTestClient"], Awaitable[None]]] = [] | |
71 | + | |
72 | + async def _build_client(self) -> None: | |
73 | + from justapi import JustAPITestClient | |
74 | + | |
75 | + inner_app = getattr(self._app, "_inner", self._app) | |
76 | + self._client = JustAPITestClient(inner_app, database=self._database) | |
77 | + | |
78 | + async def _destroy_client(self) -> None: | |
79 | + self._client = None | |
80 | + | |
81 | + async def __aenter__(self) -> "AsyncTestClient": | |
82 | + await self._build_client() | |
83 | + for hook in self._setup_hooks: | |
84 | + res = hook(self) | |
85 | + if res is not None: | |
86 | + await res | |
87 | + return self | |
88 | + | |
89 | + async def __aexit__(self, *exc_info: Any) -> None: | |
90 | + for hook in reversed(self._teardown_hooks): | |
91 | + res = hook(self) | |
92 | + if res is not None: | |
93 | + await res | |
94 | + await self._destroy_client() | |
95 | + | |
96 | + def on_setup(self, hook: Callable[["AsyncTestClient"], Awaitable[None]]) -> "AsyncTestClient": | |
97 | + self._setup_hooks.append(hook) | |
98 | + return self | |
99 | + | |
100 | + def on_teardown(self, hook: Callable[["AsyncTestClient"], Awaitable[None]]) -> "AsyncTestClient": | |
101 | + self._teardown_hooks.append(hook) | |
102 | + return self | |
103 | + | |
104 | + async def _run(self, method: str, path: str, body: bytes = b"") -> dict: | |
105 | + client = self._client | |
106 | + if client is None: | |
107 | + raise RuntimeError("AsyncTestClient not initialized") | |
108 | + | |
109 | + loop = asyncio.get_running_loop() | |
110 | + | |
111 | + if method in ("GET", "DELETE"): | |
112 | + fn = partial(getattr(client, method.lower()), path) | |
113 | + else: | |
114 | + fn = partial(getattr(client, method.lower()), path, body) | |
115 | + | |
116 | + return await loop.run_in_executor(None, fn) | |
117 | + | |
118 | + async def get(self, path: str) -> dict: | |
119 | + return await self._run("GET", path) | |
120 | + | |
121 | + async def post(self, path: str, body: bytes = b"") -> dict: | |
122 | + return await self._run("POST", path, body) | |
123 | + | |
124 | + async def put(self, path: str, body: bytes = b"") -> dict: | |
125 | + return await self._run("PUT", path, body) | |
126 | + | |
127 | + async def delete(self, path: str) -> dict: | |
128 | + return await self._run("DELETE", path) | |
129 | + | |
130 | + | |
131 | +# --------------------------------------------------------------------------- | |
132 | +# Database test helpers | |
133 | +# --------------------------------------------------------------------------- | |
134 | + | |
135 | + | |
136 | +class ManagedDb: | |
137 | + """Managed test database with automatic cleanup. | |
138 | + | |
139 | + Use as an async context manager: | |
140 | + | |
141 | + async with ManagedDb(url="sqlite://:memory:") as db: | |
142 | + app.set_database(db.url) | |
143 | + # run routes against in-memory SQLite | |
144 | + """ | |
145 | + | |
146 | + def __init__(self, url: str = "sqlite://:memory:", migrations_dir: Optional[str] = None): | |
147 | + self._url = url | |
148 | + self._migrations_dir = migrations_dir | |
149 | + self._cleanup_paths: list[str] = [] | |
150 | + | |
151 | + @property | |
152 | + def url(self) -> str: | |
153 | + return self._url | |
154 | + | |
155 | + async def run_migrations(self, directory: Optional[str] = None) -> None: | |
156 | + """Run SQL migrations against the test database using sqlx CLI.""" | |
157 | + migrations = directory or self._migrations_dir | |
158 | + if not migrations: | |
159 | + return | |
160 | + if not os.path.isdir(migrations): | |
161 | + raise FileNotFoundError(f"Migrations directory not found: {migrations}") | |
162 | + | |
163 | + loop = asyncio.get_running_loop() | |
164 | + | |
165 | + def _run(): | |
166 | + result = subprocess.run( | |
167 | + [sys.executable, "-m", "sqlx", "migrate", "run", | |
168 | + "--database-url", self._url, "--source", migrations], | |
169 | + capture_output=True, text=True, timeout=30, | |
170 | + ) | |
171 | + if result.returncode != 0: | |
172 | + raise RuntimeError( | |
173 | + f"Migration failed: {result.stderr.strip() or result.stdout.strip()}" | |
174 | + ) | |
175 | + | |
176 | + await loop.run_in_executor(None, _run) | |
177 | + | |
178 | + async def run_sql(self, sql: str) -> None: | |
179 | + """Execute raw SQL against the test database. | |
180 | + | |
181 | + For file-based databases (``sqlite:///path/to/db.db``), runs SQL | |
182 | + against the same file. For in-memory databases (``sqlite://:memory:``), | |
183 | + this opens a *separate* in-memory database — use only for schema | |
184 | + metadata that doesn't need to persist across connections. | |
185 | + """ | |
186 | + import sqlite3 | |
187 | + | |
188 | + path = _sqlite_path(self._url) | |
189 | + | |
190 | + loop = asyncio.get_running_loop() | |
191 | + | |
192 | + def _run(): | |
193 | + conn = sqlite3.connect(path) | |
194 | + try: | |
195 | + conn.executescript(sql) | |
196 | + conn.commit() | |
197 | + finally: | |
198 | + conn.close() | |
199 | + | |
200 | + await loop.run_in_executor(None, _run) | |
201 | + | |
202 | + async def __aenter__(self) -> "ManagedDb": | |
203 | + return self | |
204 | + | |
205 | + async def __aexit__(self, *exc_info: Any) -> None: | |
206 | + for path in self._cleanup_paths: | |
207 | + if os.path.exists(path): | |
208 | + os.unlink(path) | |
209 | + | |
210 | + | |
211 | +def test_db(url: str = "sqlite://:memory:", migrations_dir: Optional[str] = None) -> ManagedDb: | |
212 | + """Create a *ManagedDb* instance for use in pytest fixtures. | |
213 | + | |
214 | + Usage: | |
215 | + @pytest_asyncio.fixture | |
216 | + async def db(): | |
217 | + async with test_db() as db: | |
218 | + await db.run_sql("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)") | |
219 | + yield db | |
220 | + """ | |
221 | + return ManagedDb(url=url, migrations_dir=migrations_dir) | |
222 | + | |
223 | + | |
224 | +# --------------------------------------------------------------------------- | |
225 | +# DB fixture helper — configure app with test database and return AsyncTestClient | |
226 | +# --------------------------------------------------------------------------- | |
227 | + | |
228 | + | |
229 | +async def db_client( | |
230 | + app: "Any", | |
231 | + url: str = "sqlite://:memory:", | |
232 | + *, | |
233 | + seed_sql: Optional[str] = None, | |
234 | +) -> AsyncTestClient: | |
235 | + """Create an *AsyncTestClient* configured with a test database. | |
236 | + | |
237 | + Usage: | |
238 | + @pytest_asyncio.fixture | |
239 | + async def client(app): | |
240 | + async with await db_client(app, seed_sql="CREATE TABLE ...") as c: | |
241 | + yield c | |
242 | + """ | |
243 | + if seed_sql: | |
244 | + import sqlite3 | |
245 | + | |
246 | + db_path = _sqlite_path(url) | |
247 | + conn = sqlite3.connect(db_path) | |
248 | + try: | |
249 | + conn.executescript(seed_sql) | |
250 | + conn.commit() | |
251 | + finally: | |
252 | + conn.close() | |
253 | + | |
254 | + c = AsyncTestClient(app, database=url) | |
255 | + await c._build_client() | |
256 | + return c | |
257 | + | |
258 | + | |
259 | +# --------------------------------------------------------------------------- | |
260 | +# Snapshot testing | |
261 | +# --------------------------------------------------------------------------- | |
262 | + | |
263 | + | |
264 | +class Snapshot: | |
265 | + """Response body snapshot assertions. | |
266 | + | |
267 | + Snapshots are stored as JSON files under ``__snapshots__/`` alongside | |
268 | + the test file. The first run creates the snapshot; subsequent runs | |
269 | + compare the response against it. | |
270 | + | |
271 | + Usage: | |
272 | + from justapi.testing import Snapshot | |
273 | + | |
274 | + snapshot = Snapshot() | |
275 | + | |
276 | + async def test_get_users(client): | |
277 | + resp = await client.get("/users") | |
278 | + snapshot.assert_response(resp, "users_list") | |
279 | + | |
280 | + To update all snapshots to match new output:: | |
281 | + | |
282 | + SNAPSHOT_UPDATE=1 python -m pytest ... | |
283 | + """ | |
284 | + | |
285 | + def __init__(self, snap_dir: Optional[str] = None): | |
286 | + self._snap_dir = snap_dir | |
287 | + self._update = os.environ.get("SNAPSHOT_UPDATE", "").lower() in ( | |
288 | + "1", | |
289 | + "true", | |
290 | + "yes", | |
291 | + ) | |
292 | + | |
293 | + # -- Public API ------------------------------------------------------- | |
294 | + | |
295 | + def assert_response(self, resp: dict, name: Optional[str] = None) -> None: | |
296 | + """Assert that a response dict (status + headers + body) matches its | |
297 | + stored snapshot.""" | |
298 | + path = self._resolve_path(name) | |
299 | + current = _serialize_response(resp) | |
300 | + self._assert_match(path, current) | |
301 | + | |
302 | + def assert_match(self, value: Any, name: Optional[str] = None) -> None: | |
303 | + """Assert that an arbitrary JSON-serialisable value matches its | |
304 | + stored snapshot.""" | |
305 | + path = self._resolve_path(name) | |
306 | + current = json.dumps(value, indent=2, default=str, ensure_ascii=False) | |
307 | + self._assert_match(path, current) | |
308 | + | |
309 | + def assert_body(self, resp: dict, name: Optional[str] = None) -> None: | |
310 | + """Assert that the raw response body (as bytes) matches its stored | |
311 | + snapshot (stored as base64).""" | |
312 | + path = self._resolve_path(name) | |
313 | + import base64 | |
314 | + | |
315 | + current = base64.b64encode(bytes(resp["body"])).decode("ascii") | |
316 | + self._assert_match(path, current) | |
317 | + | |
318 | + # -- Internals -------------------------------------------------------- | |
319 | + | |
320 | + def _resolve_path(self, name: Optional[str] = None) -> str: | |
321 | + # Walk up the stack to find the first frame outside testing.py | |
322 | + frame = inspect.currentframe() | |
323 | + while frame: | |
324 | + mod_file = frame.f_globals.get("__file__", "") | |
325 | + if not mod_file.endswith("testing.py"): | |
326 | + break | |
327 | + frame = frame.f_back | |
328 | + | |
329 | + if name is not None: | |
330 | + snap_name = name | |
331 | + else: | |
332 | + snap_name = frame.f_code.co_name if frame else "unnamed" | |
333 | + | |
334 | + if self._snap_dir: | |
335 | + base = self._snap_dir | |
336 | + else: | |
337 | + caller_file = frame.f_globals.get("__file__", ".") if frame else "." | |
338 | + base = os.path.join(os.path.dirname(caller_file), "__snapshots__") | |
339 | + | |
340 | + os.makedirs(base, exist_ok=True) | |
341 | + return os.path.join(base, f"{snap_name}.snap") | |
342 | + | |
343 | + def _assert_match(self, path: str, current: str) -> None: | |
344 | + if self._update: | |
345 | + self._write(path, current) | |
346 | + return | |
347 | + | |
348 | + if not os.path.exists(path): | |
349 | + self._write(path, current) | |
350 | + return | |
351 | + | |
352 | + stored = self._read(path) | |
353 | + if stored != current: | |
354 | + msg_lines = [ | |
355 | + f"Snapshot mismatch: {path}", | |
356 | + "", | |
357 | + "--- stored", | |
358 | + "+++ current", | |
359 | + ] | |
360 | + for diff in _diff_lines(stored, current): | |
361 | + msg_lines.append(diff) | |
362 | + msg_lines.append("") | |
363 | + msg_lines.append("Run with SNAPSHOT_UPDATE=1 to update.") | |
364 | + raise AssertionError("\n".join(msg_lines)) | |
365 | + | |
366 | + @staticmethod | |
367 | + def _write(path: str, content: str) -> None: | |
368 | + with open(path, "w") as f: | |
369 | + f.write(content) | |
370 | + f.write("\n") | |
371 | + | |
372 | + @staticmethod | |
373 | + def _read(path: str) -> str: | |
374 | + with open(path) as f: | |
375 | + return f.read().rstrip("\n") | |
376 | + | |
377 | + | |
378 | +def _serialize_response(resp: dict) -> str: | |
379 | + import base64 | |
380 | + | |
381 | + body_b64 = base64.b64encode(bytes(resp.get("body", b""))).decode("ascii") | |
382 | + headers = {} | |
383 | + for k, v in resp.get("headers", {}).items(): | |
384 | + headers[k] = v | |
385 | + return json.dumps( | |
386 | + { | |
387 | + "status": resp.get("status", 200), | |
388 | + "headers": headers, | |
389 | + "body_base64": body_b64, | |
390 | + }, | |
391 | + indent=2, | |
392 | + ensure_ascii=False, | |
393 | + ) | |
394 | + | |
395 | + | |
396 | +def _diff_lines(a: str, b: str) -> list[str]: | |
397 | + """Simple line-by-line diff between two strings.""" | |
398 | + import difflib | |
399 | + | |
400 | + return list( | |
401 | + difflib.unified_diff( | |
402 | + a.splitlines(keepends=True), | |
403 | + b.splitlines(keepends=True), | |
404 | + fromfile="stored", | |
405 | + tofile="current", | |
406 | + lineterm="", | |
407 | + ) | |
408 | + ) | |
409 | + | |
410 | + | |
411 | +# --------------------------------------------------------------------------- | |
412 | +# Assertion helpers | |
413 | +# --------------------------------------------------------------------------- | |
414 | + | |
415 | + | |
416 | +def assert_ok(resp: dict) -> None: | |
417 | + assert resp["status"] == 200, ( | |
418 | + f"Expected 200, got {resp['status']}: {bytes(resp.get('body', b'')).decode(errors='replace')}" | |
419 | + ) | |
420 | + | |
421 | + | |
422 | +def assert_status(resp: dict, status: int) -> None: | |
423 | + assert resp["status"] == status, ( | |
424 | + f"Expected {status}, got {resp['status']}: {bytes(resp.get('body', b'')).decode(errors='replace')}" | |
425 | + ) | |
426 | + | |
427 | + | |
428 | +def assert_json(resp: dict, expected: Any) -> None: | |
429 | + body = json.loads(bytes(resp["body"])) | |
430 | + assert body == expected, f"Expected {expected!r}, got {body!r}" | |
431 | + | |
432 | + | |
433 | +def assert_header(resp: dict, name: str, expected: Optional[str] = None) -> None: | |
434 | + headers = {k.lower(): v for k, v in resp.get("headers", {}).items()} | |
435 | + if expected is not None: | |
436 | + value = headers.get(name.lower()) | |
437 | + assert value == expected, f"Expected header {name}={expected!r}, got {value!r}" | |
438 | + else: | |
439 | + assert name.lower() in headers, f"Expected header {name!r} not present" | |
440 | + | |
441 | + | |
442 | +def transaction_test_db(url: str = "sqlite://:memory:") -> str: | |
443 | + """Return a database URL suitable for testing (in-memory SQLite by default). | |
444 | + | |
445 | + Usage: | |
446 | + app.set_database(transaction_test_db()) | |
447 | + """ | |
448 | + return url | |
449 | + | |
450 | + | |
451 | +def _sqlite_path(url: str) -> str: | |
452 | + """Extract the file path from a ``sqlite://`` URL for use with ``sqlite3.connect``.""" | |
453 | + if url == "sqlite://:memory:" or url == "sqlite://?mode=memory": | |
454 | + return ":memory:" | |
455 | + if url.startswith("sqlite://"): | |
456 | + path = url[len("sqlite://"):] | |
457 | + # Strip query parameters | |
458 | + if "?" in path: | |
459 | + path = path.split("?")[0] | |
460 | + return path | |
461 | + return url | |
462 | + | |
463 | + | |
464 | +__all__ = [ | |
465 | + "AsyncTestClient", | |
466 | + "ManagedDb", | |
467 | + "Snapshot", | |
468 | + "test_db", | |
469 | + "db_client", | |
470 | + "assert_ok", | |
471 | + "assert_status", | |
472 | + "assert_json", | |
473 | + "assert_header", | |
474 | + "transaction_test_db", | |
475 | +] | |
0 | 476 | |
▼crates/justapi-py/python/justapi/tracing.py
+18-0
| @@ -0,0 +1,18 @@ | ||
1 | +import contextvars | |
2 | + | |
3 | +_trace_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( | |
4 | + "trace_id", default=None | |
5 | +) | |
6 | +_span_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( | |
7 | + "span_id", default=None | |
8 | +) | |
9 | + | |
10 | + | |
11 | +def get_current_trace_id() -> str | None: | |
12 | + """Return the current OpenTelemetry trace ID as a hex string, or None.""" | |
13 | + return _trace_id_var.get() | |
14 | + | |
15 | + | |
16 | +def get_current_span_id() -> str | None: | |
17 | + """Return the current OpenTelemetry span ID as a hex string, or None.""" | |
18 | + return _span_id_var.get() | |
0 | 19 | |
▼crates/justapi-py/python/justapi/tracing.pyi
+2-0
| @@ -0,0 +1,2 @@ | ||
1 | +def get_current_trace_id() -> str | None: ... | |
2 | +def get_current_span_id() -> str | None: ... | |
0 | 3 | |