swadhinbiswas

[email protected]
2026-07-08 11:00:00 +0600 committed NaN years ago
9ae675f
commit 9ae675f
23 files changed
+2206 -0
▼crates/justapi-py/LICENSE
+21-0
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
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
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
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
1
+def serve(*args, **kwargs): ...
2
+def serve_with_app(*args, **kwargs): ...
0
3
▼crates/justapi-py/python/justapi/_justapi.pyi
+1-0
1
+def serve(*args, **kwargs): ...
0
2
▼crates/justapi-py/python/justapi/_native_helper.py
+161-0
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
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
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
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
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
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
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
1
+def compile_protobuf(proto_path: str, out_dir: str): ...
0
2
▼crates/justapi-py/python/justapi/params.py
+21-0
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
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
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
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
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
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
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
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
1
+def get_current_trace_id() -> str | None: ...
2
+def get_current_span_id() -> str | None: ...
0
3