p1: stabilize route overriding, app.graphql, add_middleware, and PyO3 native handlers
swadhinbiswas committed 3 months ago
f85aaeb
1 parent 7438445
commit f85aaeb
15 files changed
+1233 -194
Showing 15 changed files+1233-194
crates/justapi-py/pyproject.tomlcrates/justapi-py/python/justapi/__init__.pycrates/justapi-py/python/justapi/_native_helper.pycrates/justapi-py/python/justapi/app.pycrates/justapi-py/python/justapi/test_body_schema_parsed.pycrates/justapi-py/python/justapi/test_db_concurrent.pycrates/justapi-py/python/justapi/test_db_saturated.pycrates/justapi-py/python/justapi/test_responses.pycrates/justapi-py/python/justapi/test_scheduler.pycrates/justapi-py/python/justapi/test_schema_hardened.pycrates/justapi-py/src/database.rscrates/justapi-py/src/native/app.rscrates/justapi-py/src/native/handlers.rscrates/justapi-py/src/native/types.rscrates/justapi-py/src/request.rs
▼crates/justapi-py/pyproject.toml
+7-0
| @@ -43,6 +43,13 @@ dependencies = [] | ||
43 | 43 | pydantic = ["pydantic>=2.0"] |
44 | 44 | jinja = ["jinja2>=3.0"] |
45 | 45 | full = ["pydantic>=2.0", "jinja2>=3.0"] |
46 | +test = [ | |
47 | + "pydantic>=2.0", | |
48 | + "jinja2>=3.0", | |
49 | + "websockets>=12.0", | |
50 | + "pytest>=7.0", | |
51 | + "pytest-asyncio>=0.21", | |
52 | +] | |
46 | 53 | |
47 | 54 | [project.urls] |
48 | 55 | Homepage = "https://github.com/anomalyco/justapi" |
49 | 56 | |
▼crates/justapi-py/python/justapi/__init__.py
+130-20
| @@ -72,21 +72,100 @@ import sys | ||
72 | 72 | import types as _types |
73 | 73 | |
74 | 74 | |
75 | +class Field: | |
76 | + """Per-field metadata for a :class:`Schema` field. | |
77 | + | |
78 | + Carries validation constraints that are emitted as JSON Schema keywords | |
79 | + and enforced by the Rust-native ``jsonschema`` validator (no Python | |
80 | + round-trip on the hot path). Mirrors the common Pydantic ``Field(...)`` | |
81 | + surface so existing models port cheaply. | |
82 | + | |
83 | + class User(Schema): | |
84 | + name: str = Field(min_length=1, max_length=50, regex=r"^[a-z]+$") | |
85 | + age: int | None = Field(default=None, gt=0, le=120) | |
86 | + email: str = Field(format="email") | |
87 | + role: str = Field(enum=["admin", "user"]) | |
88 | + address: Address # nested Schema (emits a $ref) | |
89 | + orders: list[Order] # array of nested Schema | |
90 | + | |
91 | + Numeric bounds: ``gt``/``ge``/``lt``/``le`` (or ``exclusive_minimum`` / | |
92 | + ``minimum`` etc.). String bounds: ``min_length``/``max_length`` / | |
93 | + ``regex`` (``pattern``) / ``format`` (``email``, ``date-time``, ...). | |
94 | + ``enum`` restricts to a fixed set of values. ``default`` sets the value | |
95 | + used when the field is omitted (and removes it from ``required``). | |
96 | + """ | |
97 | + | |
98 | + __slots__ = ( | |
99 | + "default", "gt", "ge", "lt", "le", | |
100 | + "min_length", "max_length", "regex", "format", | |
101 | + "enum", "description", | |
102 | + ) | |
103 | + | |
104 | + def __init__( | |
105 | + self, | |
106 | + *, | |
107 | + default=dataclasses.MISSING, | |
108 | + gt=None, ge=None, lt=None, le=None, | |
109 | + min_length=None, max_length=None, | |
110 | + regex=None, format=None, | |
111 | + enum=None, description=None, | |
112 | + ): | |
113 | + self.default = default | |
114 | + self.gt = gt | |
115 | + self.ge = ge | |
116 | + self.lt = lt | |
117 | + self.le = le | |
118 | + self.min_length = min_length | |
119 | + self.max_length = max_length | |
120 | + self.regex = regex | |
121 | + self.format = format | |
122 | + self.enum = enum | |
123 | + self.description = description | |
124 | + | |
125 | + def apply(self, prop: dict) -> dict: | |
126 | + """Layer this field's constraints onto a base JSON Schema property.""" | |
127 | + if self.gt is not None: | |
128 | + prop["exclusiveMinimum"] = self.gt | |
129 | + if self.ge is not None: | |
130 | + prop["minimum"] = self.ge | |
131 | + if self.lt is not None: | |
132 | + prop["exclusiveMaximum"] = self.lt | |
133 | + if self.le is not None: | |
134 | + prop["maximum"] = self.le | |
135 | + if self.min_length is not None: | |
136 | + prop["minLength"] = self.min_length | |
137 | + if self.max_length is not None: | |
138 | + prop["maxLength"] = self.max_length | |
139 | + if self.regex is not None: | |
140 | + prop["pattern"] = self.regex | |
141 | + if self.format is not None: | |
142 | + prop["format"] = self.format | |
143 | + if self.enum is not None: | |
144 | + prop["enum"] = list(self.enum) | |
145 | + if self.description is not None: | |
146 | + prop["description"] = self.description | |
147 | + return prop | |
148 | + | |
149 | + | |
75 | 150 | class Schema: |
76 | 151 | """Base class for defining Rust-native validation schemas. |
77 | 152 | |
78 | - Subclass this and add type-annotated fields: | |
153 | + Subclass this and add type-annotated fields. Constraints are expressed | |
154 | + with :class:`Field`; nested models are expressed by typing a field as | |
155 | + another ``Schema`` subclass (or ``list[OtherSchema]``). The resulting | |
156 | + JSON Schema (with ``$ref`` / ``$defs`` for nesting) is compiled in | |
157 | + Rust and validated with zero Python round-trips. | |
79 | 158 | |
80 | 159 | class UserSchema(Schema): |
81 | - name: str | |
82 | - email: str | |
160 | + name: str = Field(min_length=1, max_length=50) | |
161 | + email: str = Field(format="email") | |
83 | 162 | age: int | None = None |
84 | - | |
85 | - The resulting JSON Schema is compiled in Rust and validated | |
86 | - with zero Python round-trips. | |
163 | + address: Address | |
164 | + orders: list[Order] | |
87 | 165 | """ |
88 | 166 | |
89 | 167 | _field_annotations: dict[str, tuple[type, typing.Any]] |
168 | + _defs: dict[str, dict] | |
90 | 169 | |
91 | 170 | def __init_subclass__(cls, **kwargs): |
92 | 171 | super().__init_subclass__(**kwargs) |
| @@ -101,9 +180,15 @@ class Schema: | ||
101 | 180 | for field_name, field_type in annotations.items(): |
102 | 181 | if field_name.startswith('_'): |
103 | 182 | continue |
104 | - default = cls.__dict__.get(field_name, dataclasses.MISSING) | |
183 | + raw = cls.__dict__.get(field_name, dataclasses.MISSING) | |
184 | + # A Field(...) carries its own default; a bare value is the default. | |
185 | + if isinstance(raw, Field): | |
186 | + default = raw.default | |
187 | + else: | |
188 | + default = raw | |
105 | 189 | fields[field_name] = (field_type, default) |
106 | 190 | cls._field_annotations = fields |
191 | + cls._defs = {} | |
107 | 192 | |
108 | 193 | @classmethod |
109 | 194 | def _schema_json(cls) -> str: |
| @@ -112,11 +197,24 @@ class Schema: | ||
112 | 197 | |
113 | 198 | @classmethod |
114 | 199 | def _build_schema(cls) -> dict: |
115 | - """Build a JSON Schema dict from field annotations.""" | |
200 | + """Build a JSON Schema dict, collecting nested ``$defs``.""" | |
201 | + defs: dict[str, dict] = {} | |
202 | + schema = cls._build_schema_with_defs(defs) | |
203 | + if defs: | |
204 | + schema["$defs"] = defs | |
205 | + return schema | |
206 | + | |
207 | + @classmethod | |
208 | + def _build_schema_with_defs(cls, defs: dict) -> dict: | |
209 | + """Build this model's schema, lifting any nested models into ``defs``.""" | |
116 | 210 | properties = {} |
117 | 211 | required = [] |
118 | 212 | for field_name, (field_type, default) in cls._field_annotations.items(): |
119 | - prop = _type_to_json_schema(field_type) | |
213 | + prop, _nested = _type_to_json_schema(field_type, defs) | |
214 | + # Apply per-field constraints from a Field(...) descriptor. | |
215 | + raw = cls.__dict__.get(field_name) | |
216 | + if isinstance(raw, Field): | |
217 | + prop = raw.apply(prop) | |
120 | 218 | properties[field_name] = prop |
121 | 219 | if default is dataclasses.MISSING: |
122 | 220 | required.append(field_name) |
| @@ -128,8 +226,13 @@ class Schema: | ||
128 | 226 | } |
129 | 227 | |
130 | 228 | |
131 | -def _type_to_json_schema(tp: type) -> dict: | |
132 | - """Convert a Python type annotation to a JSON Schema property.""" | |
229 | +def _type_to_json_schema(tp: type, defs: dict) -> tuple[dict, bool]: | |
230 | + """Convert a Python type annotation to a JSON Schema property. | |
231 | + | |
232 | + ``defs`` accumulates nested ``Schema`` definitions (keyed by class name) | |
233 | + so callers can lift them into a top-level ``$defs``. Returns | |
234 | + ``(property, unused)`` — the bool is reserved and currently always False. | |
235 | + """ | |
133 | 236 | origin = typing.get_origin(tp) |
134 | 237 | args = typing.get_args(tp) |
135 | 238 | |
| @@ -137,25 +240,32 @@ def _type_to_json_schema(tp: type) -> dict: | ||
137 | 240 | if origin is typing.Union or origin is _types.UnionType: |
138 | 241 | non_none = [a for a in args if a is not type(None)] |
139 | 242 | if len(non_none) == 1: |
140 | - return _type_to_json_schema(non_none[0]) | |
243 | + return _type_to_json_schema(non_none[0], defs) | |
244 | + | |
245 | + # A nested Schema subclass becomes a $ref into $defs. | |
246 | + if isinstance(tp, type) and issubclass(tp, Schema): | |
247 | + name = tp.__name__ | |
248 | + if name not in defs: | |
249 | + defs[name] = tp._build_schema_with_defs(defs) | |
250 | + return ({"$ref": f"#/$defs/{name}"}, False) | |
141 | 251 | |
142 | 252 | if tp is str: |
143 | - return {"type": "string"} | |
253 | + return ({"type": "string"}, False) | |
144 | 254 | if tp is int: |
145 | - return {"type": "integer"} | |
255 | + return ({"type": "integer"}, False) | |
146 | 256 | if tp is float: |
147 | - return {"type": "number"} | |
257 | + return ({"type": "number"}, False) | |
148 | 258 | if tp is bool: |
149 | - return {"type": "boolean"} | |
259 | + return ({"type": "boolean"}, False) | |
150 | 260 | if tp is bytes: |
151 | - return {"type": "string", "contentEncoding": "base64"} | |
261 | + return ({"type": "string", "contentEncoding": "base64"}, False) | |
152 | 262 | if origin is list: |
153 | 263 | item_type = args[0] if args else str |
154 | - return {"type": "array", "items": _type_to_json_schema(item_type)} | |
264 | + return ({"type": "array", "items": _type_to_json_schema(item_type, defs)[0]}, False) | |
155 | 265 | if origin is dict: |
156 | 266 | value_type = args[1] if len(args) > 1 else str |
157 | - return {"type": "object", "additionalProperties": _type_to_json_schema(value_type)} | |
158 | - return {"type": "string"} | |
267 | + return ({"type": "object", "additionalProperties": _type_to_json_schema(value_type, defs)[0]}, False) | |
268 | + return ({"type": "string"}, False) | |
159 | 269 | |
160 | 270 | |
161 | 271 | def pydantic_schema(model_class) -> str: |
162 | 272 | |
▼crates/justapi-py/python/justapi/_native_helper.py
+58-18
| @@ -12,6 +12,21 @@ try: | ||
12 | 12 | except Exception: # pragma: no cover - orjson is optional |
13 | 13 | _dumps = lambda obj: json.dumps(obj, default=str).encode("utf-8") |
14 | 14 | |
15 | +# Import the Rust validator once, at module-load time. Importing it lazily | |
16 | +# *inside* `validate_body` (via a relative `from ._justapi import ...`) | |
17 | +# fails when that function is invoked from a GIL-pool worker thread | |
18 | +# ("attempted relative import with no known parent package"), which silently | |
19 | +# disables request-body validation on the live server path. Binding it | |
20 | +# here — on the main thread, with the package context intact — avoids | |
21 | +# that and keeps the Rust-native validator active under real concurrency. | |
22 | +try: | |
23 | + from ._justapi import validate_value as _rust_validate_value | |
24 | +except Exception: # pragma: no cover - defensive | |
25 | + try: | |
26 | + from justapi._justapi import validate_value as _rust_validate_value | |
27 | + except Exception: | |
28 | + _rust_validate_value = None | |
29 | + | |
15 | 30 | from justapi._justapi import validate_value |
16 | 31 | |
17 | 32 | _trace_id_var = contextvars.ContextVar("trace_id", default=None) |
| @@ -61,10 +76,26 @@ def wrap_result(result): | ||
61 | 76 | |
62 | 77 | if hasattr(result, "to_dict"): |
63 | 78 | return result.to_dict() |
64 | - if isinstance(result, dict) and ("body" in result or "status" in result): | |
65 | - if "body" in result and isinstance(result["body"], str): | |
66 | - result["body"] = result["body"].encode("utf-8") | |
67 | - return result | |
79 | + if isinstance(result, dict): | |
80 | + # Treat as a response envelope only when it carries a `body`, an explicit | |
81 | + # `__response__` sentinel, or is `status`-only (`{"status": 204}` / | |
82 | + # `{"status": 200, "headers": [...]}`). A data dict with a `"status"` | |
83 | + # field *plus other keys* (`{"status": "ok", "products": 5}`) must be | |
84 | + # serialized as normal JSON, otherwise its body is silently dropped | |
85 | + # (BUG-1, PRODUCTION_PLAN.md P0.3). | |
86 | + keys = set(result.keys()) | |
87 | + is_envelope = ( | |
88 | + "body" in result | |
89 | + or result.get("__response__") is True | |
90 | + or ( | |
91 | + "status" in result | |
92 | + and keys <= {"status", "headers", "__response__"} | |
93 | + ) | |
94 | + ) | |
95 | + if is_envelope: | |
96 | + if "body" in result and isinstance(result["body"], str): | |
97 | + result["body"] = result["body"].encode("utf-8") | |
98 | + return result | |
68 | 99 | |
69 | 100 | body = _dumps(result) |
70 | 101 | if isinstance(body, str): |
| @@ -96,11 +127,13 @@ def call_handler(handler, request): | ||
96 | 127 | |
97 | 128 | return result |
98 | 129 | except Exception as e: |
99 | - print(f"ERROR in call_handler: {repr(e)}") | |
100 | - traceback.print_exc() | |
130 | + if type(e).__name__ not in ("HTTPException", "RequestValidationError"): | |
131 | + print(f"ERROR in call_handler: {repr(e)}") | |
132 | + traceback.print_exc() | |
101 | 133 | raise e |
102 | 134 | |
103 | 135 | |
136 | + | |
104 | 137 | def call_batch_handler(handler, requests): |
105 | 138 | """Call a native Python batch handler with a list of request dicts. |
106 | 139 | |
| @@ -124,6 +157,21 @@ def call_batch_handler(handler, requests): | ||
124 | 157 | raise e |
125 | 158 | |
126 | 159 | |
160 | +def parse_body(body_bytes): | |
161 | + """Parse a request body's JSON into a Python object (dict/list/scalar). | |
162 | + | |
163 | + Returns ``None`` for an empty body or invalid JSON. Used by the dispatch | |
164 | + layer to attach the already-validated/parsed body to ``Request`` so schema | |
165 | + routes receive the parsed object instead of re-parsing raw bytes. | |
166 | + """ | |
167 | + if not body_bytes: | |
168 | + return None | |
169 | + try: | |
170 | + return json.loads(body_bytes.decode("utf-8")) | |
171 | + except (UnicodeDecodeError, json.JSONDecodeError): | |
172 | + return None | |
173 | + | |
174 | + | |
127 | 175 | def validate_body(schema_fn, body_bytes): |
128 | 176 | """Validate a request body against a route's body schema. |
129 | 177 | |
| @@ -144,19 +192,11 @@ def validate_body(schema_fn, body_bytes): | ||
144 | 192 | |
145 | 193 | # Schema subclass: validate via its JSON Schema through the Rust engine. |
146 | 194 | schema_json = getattr(schema_fn, "_schema_json", None) |
147 | - if callable(schema_json): | |
195 | + if callable(schema_json) and _rust_validate_value is not None: | |
148 | 196 | try: |
149 | - from ._justapi import validate_value | |
150 | - except Exception: | |
151 | - try: | |
152 | - from justapi._justapi import validate_value | |
153 | - except Exception: | |
154 | - validate_value = None | |
155 | - if validate_value is not None: | |
156 | - try: | |
157 | - return validate_value(schema_json(), json.dumps(body_data)) | |
158 | - except Exception as e: # pragma: no cover - defensive | |
159 | - return [f"schema validation error: {e}"] | |
197 | + return _rust_validate_value(schema_json(), json.dumps(body_data)) | |
198 | + except Exception as e: # pragma: no cover - defensive | |
199 | + return [f"schema validation error: {e}"] | |
160 | 200 | |
161 | 201 | # Legacy callable schema. |
162 | 202 | result = schema_fn(body_data) |
163 | 203 | |
▼crates/justapi-py/python/justapi/app.py
+91-1
| @@ -50,6 +50,24 @@ def _builtin_metrics(self): | ||
50 | 50 | return PlainTextResponse(body) |
51 | 51 | |
52 | 52 | |
53 | +def _builtin_graphql(app, request=None): | |
54 | + if request is None: | |
55 | + request = {} | |
56 | + method = request.get("method", "GET") | |
57 | + body = request.get("body", b"") | |
58 | + if isinstance(body, str): | |
59 | + body = body.encode("utf-8") | |
60 | + status, content_type, res_body = app._app.graphql_handle(method, body) | |
61 | + if "text/html" in content_type: | |
62 | + return HTMLResponse(res_body, status_code=status) | |
63 | + try: | |
64 | + data = json.loads(res_body) | |
65 | + except Exception: | |
66 | + data = res_body | |
67 | + return JSONResponse(data, status_code=status) | |
68 | + | |
69 | + | |
70 | + | |
53 | 71 | def _builtin_openapi(app): |
54 | 72 | """Serve the generated OpenAPI 3.1 document for the Python app.""" |
55 | 73 | return JSONResponse(build_openapi(app)) |
| @@ -243,6 +261,15 @@ class JustAPIApp: | ||
243 | 261 | # The home page opens the interactive API reference (Scalar) so the |
244 | 262 | # docs are the first thing a visitor sees. |
245 | 263 | self.get("/", lambda: _builtin_scalar(self), include_in_schema=False, name="builtin_index") |
264 | + # Built-in GraphQL & Federation Gateway routes. | |
265 | + self.get("/graphql", lambda request=None: _builtin_graphql(self, request), include_in_schema=False, name="builtin_graphql_get") | |
266 | + self.post("/graphql", lambda request=None: _builtin_graphql(self, request), include_in_schema=False, name="builtin_graphql_post") | |
267 | + | |
268 | + def graphql(self, path: str = "/graphql"): | |
269 | + """Register GraphQL & Federation Gateway routes on the specified path (default /graphql).""" | |
270 | + self.get(path, lambda request=None: _builtin_graphql(self, request), include_in_schema=False, name="graphql_get") | |
271 | + self.post(path, lambda request=None: _builtin_graphql(self, request), include_in_schema=False, name="graphql_post") | |
272 | + | |
246 | 273 | |
247 | 274 | def _record(self, method, path, handler, *, body_schema=None, schema=None, |
248 | 275 | experimental=False, **kw): |
| @@ -282,6 +309,56 @@ class JustAPIApp: | ||
282 | 309 | return func |
283 | 310 | return decorator |
284 | 311 | |
312 | + def add_middleware(self, middleware_class, **options): | |
313 | + """Add a middleware class (compatible with Starlette / FastAPI middleware syntax).""" | |
314 | + name = getattr(middleware_class, "__name__", "") | |
315 | + if "CORS" in name or "cors" in name.lower(): | |
316 | + allow_origins = options.get("allow_origins", ["*"]) | |
317 | + allow_methods = options.get("allow_methods", ["*"]) | |
318 | + allow_headers = options.get("allow_headers", ["*"]) | |
319 | + allow_credentials = options.get("allow_credentials", False) | |
320 | + self.add_cors( | |
321 | + allow_origins=allow_origins, | |
322 | + allow_methods=allow_methods, | |
323 | + allow_headers=allow_headers, | |
324 | + allow_credentials=allow_credentials, | |
325 | + ) | |
326 | + else: | |
327 | + try: | |
328 | + instance = middleware_class(app=self, **options) | |
329 | + self.middlewares.append(instance) | |
330 | + except Exception: | |
331 | + self.middlewares.append(middleware_class) | |
332 | + | |
333 | + def add_cors( | |
334 | + self, | |
335 | + allow_origins=None, | |
336 | + allow_methods=None, | |
337 | + allow_headers=None, | |
338 | + allow_credentials=False, | |
339 | + ): | |
340 | + """Configure CORS origins, methods, and headers for the application.""" | |
341 | + origins = allow_origins or ["*"] | |
342 | + methods = allow_methods or ["*"] | |
343 | + headers = allow_headers or ["*"] | |
344 | + | |
345 | + def cors_middleware(request, call_next): | |
346 | + response = call_next(request) | |
347 | + if isinstance(response, dict): | |
348 | + response = JSONResponse(response) | |
349 | + if hasattr(response, "headers"): | |
350 | + response.headers["Access-Control-Allow-Origin"] = ( | |
351 | + origins[0] if len(origins) == 1 else "*" | |
352 | + ) | |
353 | + response.headers["Access-Control-Allow-Methods"] = ", ".join(methods) | |
354 | + response.headers["Access-Control-Allow-Headers"] = ", ".join(headers) | |
355 | + if allow_credentials: | |
356 | + response.headers["Access-Control-Allow-Credentials"] = "true" | |
357 | + return response | |
358 | + | |
359 | + self.middlewares.append(cors_middleware) | |
360 | + | |
361 | + | |
285 | 362 | def add_exception_handler(self, exc_class, handler): |
286 | 363 | self.exception_handlers[exc_class] = handler |
287 | 364 | |
| @@ -912,6 +989,7 @@ class JustAPIApp: | ||
912 | 989 | max_lifetime=None, |
913 | 990 | health_check_interval=None, |
914 | 991 | isolation=None, |
992 | + request_acquire_timeout=None, | |
915 | 993 | ): |
916 | 994 | def _build(existing): |
917 | 995 | return Database( |
| @@ -928,6 +1006,9 @@ class JustAPIApp: | ||
928 | 1006 | if health_check_interval is not None |
929 | 1007 | else existing.health_check_interval, |
930 | 1008 | isolation=isolation if isolation is not None else existing.isolation, |
1009 | + request_acquire_timeout=request_acquire_timeout | |
1010 | + if request_acquire_timeout is not None | |
1011 | + else existing.request_acquire_timeout, | |
931 | 1012 | ) |
932 | 1013 | |
933 | 1014 | if isinstance(db, str): |
| @@ -940,6 +1021,9 @@ class JustAPIApp: | ||
940 | 1021 | max_lifetime=max_lifetime, |
941 | 1022 | health_check_interval=health_check_interval, |
942 | 1023 | isolation=isolation, |
1024 | + request_acquire_timeout=3.0 | |
1025 | + if request_acquire_timeout is None | |
1026 | + else request_acquire_timeout, | |
943 | 1027 | ) |
944 | 1028 | else: |
945 | 1029 | db = _build(db) |
| @@ -957,8 +1041,14 @@ class JustAPIApp: | ||
957 | 1041 | max_lifetime=db.max_lifetime, |
958 | 1042 | health_check_interval=db.health_check_interval, |
959 | 1043 | isolation=db.isolation, |
1044 | + request_acquire_timeout=db.request_acquire_timeout, | |
960 | 1045 | ) |
961 | - self._app.set_database(db) | |
1046 | + # Hand the config to Rust as a plain `dict` (via the Python-side | |
1047 | + # `config_dict()`, which reads the real `Database` fields). Reading | |
1048 | + # `Option`/`f64` fields off a `Database` instance from Rust hits a | |
1049 | + # pyo3 reconstruction quirk and returns defaults, so we never pass the | |
1050 | + # object directly across the FFI. | |
1051 | + self._app.set_database(db.config_dict()) | |
962 | 1052 | |
963 | 1053 | def connect_database(self): |
964 | 1054 | """Connect the configured database pool eagerly, so `app.db` is usable |
965 | 1055 | |
▼crates/justapi-py/python/justapi/test_body_schema_parsed.py
+68-0
| @@ -0,0 +1,68 @@ | ||
1 | +"""Regression test for P1.1 / BUG-3 — `body_schema` routes must receive the | |
2 | +parsed/validated body as a Python object, not raw bytes. | |
3 | + | |
4 | +When a route is registered with a `justapi.Schema` body_schema, the dispatch | |
5 | +layer parses the JSON body on the fast path and attaches it to the `Request` | |
6 | +object, so `request.json()` and `request["body"]` return the parsed dict | |
7 | +(already validated). Legacy callable schemas keep the original behaviour. | |
8 | +""" | |
9 | +import json | |
10 | +import sys | |
11 | + | |
12 | +import pytest | |
13 | + | |
14 | +from justapi import JustAPIApp, Schema, Request | |
15 | +from justapi.testing import AsyncTestClient | |
16 | + | |
17 | + | |
18 | +class CreateProduct(Schema): | |
19 | + name: str | |
20 | + price: float | |
21 | + stock: int = 0 | |
22 | + | |
23 | + | |
24 | +def captured(): | |
25 | + state = {} | |
26 | + | |
27 | + async def create(req: Request): | |
28 | + # `request.json()` should already be the validated/parsed dict, not raw | |
29 | + # bytes. It must equal the posted payload (minus server-side mutation). | |
30 | + body = req.json() | |
31 | + state["json_type"] = type(body).__name__ | |
32 | + state["json_value"] = body | |
33 | + # `request["body"]` should also return the parsed dict for schema routes. | |
34 | + state["getitem_type"] = type(req["body"]).__name__ | |
35 | + state["validated_body"] = req.validated_body | |
36 | + return {"ok": True, "name": body["name"], "price": body["price"]} | |
37 | + | |
38 | + return create, state | |
39 | + | |
40 | + | |
41 | +@pytest.mark.asyncio | |
42 | +async def test_body_schema_delivers_parsed_dict(): | |
43 | + app = JustAPIApp() | |
44 | + handler, state = captured() | |
45 | + app.post("/products", handler, body_schema=CreateProduct) | |
46 | + | |
47 | + async with AsyncTestClient(app) as client: | |
48 | + payload = {"name": "Widget", "price": 12.5, "stock": 3} | |
49 | + resp = await client.post("/products", json.dumps(payload).encode()) | |
50 | + assert resp["status"] == 200, resp.get("status") | |
51 | + data = json.loads(resp["body"]) | |
52 | + assert data["name"] == "Widget" | |
53 | + assert data["price"] == 12.5 | |
54 | + | |
55 | + # The handler must have received a parsed dict, not raw bytes. | |
56 | + assert state["json_type"] == "dict", state["json_type"] | |
57 | + assert state["getitem_type"] == "dict", state["getitem_type"] | |
58 | + assert state["validated_body"] == payload or state["validated_body"] == { | |
59 | + "name": "Widget", | |
60 | + "price": 12.5, | |
61 | + "stock": 3, | |
62 | + } | |
63 | + assert state["json_value"]["name"] == "Widget" | |
64 | + print("PASS: body_schema route received parsed dict (not raw bytes)") | |
65 | + | |
66 | + | |
67 | +if __name__ == "__main__": | |
68 | + sys.exit(0 if __import__("asyncio").run(test_body_schema_delivers_parsed_dict()) is None else 1) | |
0 | 69 | |
▼crates/justapi-py/python/justapi/test_db_concurrent.py
+72-0
| @@ -0,0 +1,72 @@ | ||
1 | +"""Server-path concurrent-write durability test (P2.2 regression guard). | |
2 | + | |
3 | +Uses the async test client with many concurrent coroutines issuing INSERTs, | |
4 | +then asserts every row persisted. This exercises the same `app.db.query` | |
5 | +write path a real server handler uses, under genuine concurrency — without | |
6 | +the single-worker GIL-pool TCP stall that a raw `oha -c 50` against the | |
7 | +blocking server would hit. | |
8 | +""" | |
9 | +import os | |
10 | +import tempfile | |
11 | + | |
12 | +import pytest | |
13 | + | |
14 | +from justapi import JustAPIApp | |
15 | +from justapi.testing import AsyncTestClient | |
16 | + | |
17 | + | |
18 | +@pytest.mark.asyncio | |
19 | +async def test_concurrent_writes_via_test_client(): | |
20 | + tmpdir = tempfile.mkdtemp() | |
21 | + db_path = os.path.join(tmpdir, "svc_concurrent.db") | |
22 | + open(db_path, "w").close() | |
23 | + try: | |
24 | + app = JustAPIApp() | |
25 | + app.set_database(f"sqlite://{db_path}") | |
26 | + | |
27 | + @app.post("/items") | |
28 | + async def create(request): | |
29 | + body = request.json() | |
30 | + # Fire-and-forget style: the write must persist under concurrency. | |
31 | + app.db.query( | |
32 | + "INSERT INTO items(name, qty) VALUES (?, ?)", | |
33 | + [body["name"], body["qty"]], | |
34 | + ) | |
35 | + return {"ok": True} | |
36 | + | |
37 | + @app.get("/items/{id}") | |
38 | + async def read(request): | |
39 | + row = app.db.query( | |
40 | + "SELECT * FROM items WHERE id = ?", | |
41 | + [int(request["path_params"]["id"])], | |
42 | + ) | |
43 | + return row[0] if row else {"status": 404} | |
44 | + | |
45 | + async with AsyncTestClient(app, database=f"sqlite://{db_path}") as c: | |
46 | + # Create the table on the same file pool the handlers use. | |
47 | + app.db.query( | |
48 | + "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)" | |
49 | + ) | |
50 | + import asyncio | |
51 | + | |
52 | + async def insert_one(v): | |
53 | + return await c.post( | |
54 | + "/items", f'{{"name":"w{v}","qty":{v}}}'.encode() | |
55 | + ) | |
56 | + | |
57 | + results = await asyncio.gather( | |
58 | + *(insert_one(v) for v in range(100)), | |
59 | + return_exceptions=True, | |
60 | + ) | |
61 | + errors = [r for r in results if isinstance(r, Exception)] | |
62 | + assert not errors, f"{len(errors)} inserts errored: {errors[:3]}" | |
63 | + | |
64 | + # Count persisted rows directly. | |
65 | + rows = app.db.query("SELECT COUNT(*) AS c FROM items") | |
66 | + # 100 concurrent inserts, all must persist (P2.2 regression: | |
67 | + # old py.detach+block_on path silently dropped ~49/50 writes). | |
68 | + assert rows[0]["c"] == 100, f"expected 100 rows, got {rows[0]['c']}" | |
69 | + finally: | |
70 | + if os.path.exists(db_path): | |
71 | + os.unlink(db_path) | |
72 | + os.rmdir(tmpdir) | |
0 | 73 | |
▼crates/justapi-py/python/justapi/test_db_saturated.py
+97-0
| @@ -0,0 +1,97 @@ | ||
1 | +"""Gap #2 regression: saturated DB pool fails fast with 503 (backpressure). | |
2 | + | |
3 | +When every pooled connection is busy, a request that cannot acquire one within | |
4 | +`request_acquire_timeout` must be rejected immediately with `503 Service | |
5 | +Unavailable` — not hang until the (long) `acquire_timeout` elapses and then | |
6 | +return a generic 500. This guards the production failure mode where a momentary | |
7 | +connection crunch silently stalls the whole server for ~30s. | |
8 | + | |
9 | +Uses a real HTTP server (the same dispatch path as production, including the | |
10 | +auto-transaction begin) so the saturation actually exercises `begin_request`. | |
11 | +""" | |
12 | +import os | |
13 | +import subprocess | |
14 | +import sys | |
15 | +import tempfile | |
16 | +import threading | |
17 | +import time | |
18 | +import urllib.error | |
19 | +import urllib.request | |
20 | + | |
21 | +import pytest | |
22 | + | |
23 | +from justapi import Database, JustAPIApp | |
24 | + | |
25 | + | |
26 | +def _server_script(db_path, port): | |
27 | + """Return source that boots a server with a 1-conn, 1s fast-fail pool.""" | |
28 | + return ( | |
29 | + "import asyncio\n" | |
30 | + "from justapi import Database, JustAPIApp\n" | |
31 | + "app = JustAPIApp()\n" | |
32 | + f'app.set_database(Database("sqlite://{db_path}", max_connections=1, request_acquire_timeout=1.0))\n' | |
33 | + "\n" | |
34 | + "@app.post('/slow')\n" | |
35 | + "async def slow(request):\n" | |
36 | + " await asyncio.sleep(3)\n" | |
37 | + " return {'ok': True}\n" | |
38 | + "\n" | |
39 | + "@app.post('/other')\n" | |
40 | + "async def other(request):\n" | |
41 | + " return {'ok': True}\n" | |
42 | + "\n" | |
43 | + f"app.run('127.0.0.1:{port}')\n" | |
44 | + ) | |
45 | + | |
46 | + | |
47 | +def _post(port, path, timeout=10): | |
48 | + req = urllib.request.Request( | |
49 | + f"http://127.0.0.1:{port}{path}", | |
50 | + data=b"{}", | |
51 | + headers={"Content-Type": "application/json"}, | |
52 | + method="POST", | |
53 | + ) | |
54 | + try: | |
55 | + return urllib.request.urlopen(req, timeout=timeout).status | |
56 | + except urllib.error.HTTPError as e: | |
57 | + return e.code | |
58 | + | |
59 | + | |
60 | +def test_saturated_pool_fast_fails_503(): | |
61 | + tmpdir = tempfile.mkdtemp() | |
62 | + db_path = os.path.join(tmpdir, "svc_saturated.db") | |
63 | + open(db_path, "w").close() | |
64 | + port = 9887 | |
65 | + proc = None | |
66 | + try: | |
67 | + proc = subprocess.Popen( | |
68 | + [sys.executable, "-c", _server_script(db_path, port)], | |
69 | + stdout=subprocess.PIPE, | |
70 | + stderr=subprocess.PIPE, | |
71 | + ) | |
72 | + time.sleep(1.0) | |
73 | + | |
74 | + # /slow grabs the only connection and holds it 3s. | |
75 | + slow_thread = threading.Thread(target=lambda: _post(port, "/slow", timeout=10)) | |
76 | + slow_thread.start() | |
77 | + time.sleep(0.3) # let /slow acquire the connection first | |
78 | + | |
79 | + start = time.monotonic() | |
80 | + other_status = _post(port, "/other", timeout=10) | |
81 | + elapsed = time.monotonic() - start | |
82 | + | |
83 | + slow_thread.join() | |
84 | + | |
85 | + assert other_status == 503, f"expected 503 under saturation, got {other_status}" | |
86 | + # Fast-fail: must NOT wait the full 30s acquire_timeout. | |
87 | + assert elapsed < 3.0, f"expected fast 503, but took {elapsed:.2f}s" | |
88 | + finally: | |
89 | + if proc is not None: | |
90 | + proc.terminate() | |
91 | + try: | |
92 | + proc.communicate(timeout=5) | |
93 | + except Exception: | |
94 | + proc.kill() | |
95 | + if os.path.exists(db_path): | |
96 | + os.unlink(db_path) | |
97 | + os.rmdir(tmpdir) | |
0 | 98 | |
▼crates/justapi-py/python/justapi/test_responses.py
+33-0
| @@ -67,3 +67,36 @@ def test_response_end_to_end_via_testclient(): | ||
67 | 67 | resp2 = client.get("/go") |
68 | 68 | assert resp2["status"] == 307 |
69 | 69 | assert resp2["headers"].get("location") == "/set" |
70 | + | |
71 | + | |
72 | +def test_status_key_in_body_not_dropped(): | |
73 | + # BUG-1 (PRODUCTION_PLAN.md P0.3): a response dict that contains a | |
74 | + # top-level "status" field must NOT be misclassified as a legacy | |
75 | + # {"status", "body"} envelope. Its body must be returned intact. | |
76 | + app = JustAPIApp() | |
77 | + | |
78 | + @app.get("/with_status") | |
79 | + def with_status(req): | |
80 | + return {"status": "ok", "products": 5} | |
81 | + | |
82 | + client = JustAPITestClient(app) | |
83 | + resp = client.get("/with_status") | |
84 | + assert resp["status"] == 200 | |
85 | + # orjson produces compact JSON (no spaces); the key point is the "status" | |
86 | + # field is preserved in the body, not dropped as a legacy envelope. | |
87 | + assert resp["body"] == b'{"status":"ok","products":5}' | |
88 | + | |
89 | + | |
90 | +def test_explicit_envelope_still_works(): | |
91 | + # A real envelope (with "body") is still passed through unchanged. | |
92 | + app = JustAPIApp() | |
93 | + | |
94 | + @app.get("/env") | |
95 | + def env(req): | |
96 | + return {"__response__": True, "status": 201, "body": "created"} | |
97 | + | |
98 | + client = JustAPITestClient(app) | |
99 | + resp = client.get("/env") | |
100 | + assert resp["status"] == 201 | |
101 | + assert resp["body"] == b"created" | |
102 | + | |
70 | 103 | |
▼crates/justapi-py/python/justapi/test_scheduler.py
+8-2
| @@ -13,7 +13,7 @@ def test_scheduler_interval_and_cron_fire(): | ||
13 | 13 | app = JustAPIApp() |
14 | 14 | fired = [] |
15 | 15 | |
16 | - @app.get("/") | |
16 | + @app.get("/ping") | |
17 | 17 | def root(): |
18 | 18 | return {"ok": True} |
19 | 19 | |
| @@ -33,7 +33,13 @@ def test_scheduler_interval_and_cron_fire(): | ||
33 | 33 | sched = Scheduler() |
34 | 34 | sched.start() |
35 | 35 | try: |
36 | - time.sleep(3.6) | |
36 | + # Poll until both jobs have fired enough times (tolerant of scheduler | |
37 | + # dispatch jitter / worker-pool contention) instead of a fixed sleep. | |
38 | + deadline = time.time() + 8.0 | |
39 | + while time.time() < deadline: | |
40 | + if fired.count("every") >= 2 and fired.count("cron") >= 1: | |
41 | + break | |
42 | + time.sleep(0.1) | |
37 | 43 | finally: |
38 | 44 | sched.stop() |
39 | 45 | |
40 | 46 | |
▼crates/justapi-py/python/justapi/test_schema_hardened.py
+169-0
| @@ -0,0 +1,169 @@ | ||
1 | +"""Unit tests for the hardened `justapi.Schema` validator (P3 gap #3). | |
2 | + | |
3 | +Covers: Field constraints (min/max length, numeric bounds, regex, | |
4 | +enum, format), nested Schema models via $ref/$defs, and array-of-model. | |
5 | +The validation itself runs in Rust (jsonschema) with zero Python | |
6 | +round-trips on the hot path. | |
7 | + | |
8 | +Also covers the regression for gap #3's server-path bug: request-body | |
9 | +validation is performed by `justapi_core::validate::CompiledValidator`, | |
10 | +which must register the built-in `format` checkers — otherwise `format` | |
11 | +violations were silently accepted on the live HTTP path (only the | |
12 | +in-process `validate_value` path enforced them). | |
13 | +""" | |
14 | +import json | |
15 | +import threading | |
16 | +import time | |
17 | +import urllib.error | |
18 | +import urllib.request | |
19 | + | |
20 | +from justapi import JustAPIApp, Schema, Field | |
21 | +from justapi._justapi import validate_value | |
22 | + | |
23 | + | |
24 | +class Address(Schema): | |
25 | + street: str = Field(min_length=1, max_length=200) | |
26 | + zip: str = Field(regex=r"^\d{5}$") | |
27 | + | |
28 | + | |
29 | +class Order(Schema): | |
30 | + id: int = Field(ge=1) | |
31 | + total: float = Field(ge=0.0, le=1_000_000) | |
32 | + | |
33 | + | |
34 | +class User(Schema): | |
35 | + name: str = Field(min_length=1, max_length=50, regex=r"^[a-zA-Z ]+$") | |
36 | + email: str = Field(format="email") | |
37 | + age: int | None = Field(default=None, gt=0, le=120) | |
38 | + role: str = Field(enum=["admin", "user"]) | |
39 | + address: Address | |
40 | + orders: list[Order] | |
41 | + | |
42 | + | |
43 | +def _schema(): | |
44 | + return User._schema_json() | |
45 | + | |
46 | + | |
47 | +def _check(value): | |
48 | + return validate_value(_schema(), json.dumps(value)) | |
49 | + | |
50 | + | |
51 | +def test_valid_nested_payload_passes(): | |
52 | + good = { | |
53 | + "name": "Bob Smith", | |
54 | + "email": "[email protected]", | |
55 | + "age": 30, | |
56 | + "role": "admin", | |
57 | + "address": {"street": "1 Main", "zip": "12345"}, | |
58 | + "orders": [{"id": 1, "total": 9.99}], | |
59 | + } | |
60 | + assert _check(good) == [], "expected valid payload to pass" | |
61 | + | |
62 | + | |
63 | +def test_nested_bad_values_reported(): | |
64 | + bad = { | |
65 | + "name": "x", | |
66 | + "email": "nope", | |
67 | + "age": 200, | |
68 | + "role": "root", | |
69 | + "address": {"street": "", "zip": "abc"}, | |
70 | + "orders": [{"id": 0, "total": -1}], | |
71 | + } | |
72 | + errs = _check(bad) | |
73 | + # name too short, email bad, age>max, role bad, street empty, | |
74 | + # zip bad, order id<min, order total<min => >=7 distinct errors. | |
75 | + assert len(errs) >= 7, f"expected many errors, got {errs}" | |
76 | + joined = "\n".join(errs).lower() | |
77 | + assert "email" in joined | |
78 | + assert "maximum" in joined or "120" in joined | |
79 | + | |
80 | + | |
81 | +def test_schema_emits_defs_and_refs(): | |
82 | + s = json.loads(_schema()) | |
83 | + # Nested models are lifted into $defs and referenced via $ref. | |
84 | + assert "$defs" in s | |
85 | + assert "Address" in s["$defs"] | |
86 | + assert "Order" in s["$defs"] | |
87 | + assert s["properties"]["address"] == {"$ref": "#/$defs/Address"} | |
88 | + assert s["properties"]["orders"]["type"] == "array" | |
89 | + assert s["properties"]["orders"]["items"] == {"$ref": "#/$defs/Order"} | |
90 | + | |
91 | + | |
92 | +def test_format_keyword_enforced(): | |
93 | + # email format is asserted by the Rust-side format registry. | |
94 | + assert validate_value('{"type":"string","format":"email"}', '"nope"') != [] | |
95 | + assert validate_value('{"type":"string","format":"email"}', '"[email protected]"') == [] | |
96 | + # uuid / date-time / ipv4 also enforced. | |
97 | + assert validate_value('{"type":"string","format":"uuid"}', '"bad"') != [] | |
98 | + assert ( | |
99 | + validate_value( | |
100 | + '{"type":"string","format":"uuid"}', | |
101 | + '"123e4567-e89b-12d3-a456-426614174000"', | |
102 | + ) | |
103 | + == [] | |
104 | + ) | |
105 | + | |
106 | + | |
107 | +def test_optional_field_not_required(): | |
108 | + s = json.loads(_schema()) | |
109 | + # age is Optional (default None) => not in required. | |
110 | + assert "age" not in s["required"] | |
111 | + # A payload without age / orders / address should still validate | |
112 | + # (age optional, but address + orders are required here). | |
113 | + partial = { | |
114 | + "name": "Solo", | |
115 | + "email": "[email protected]", | |
116 | + "role": "user", | |
117 | + "address": {"street": "9 Ave", "zip": "99999"}, | |
118 | + "orders": [], | |
119 | + } | |
120 | + assert _check(partial) == [] | |
121 | + | |
122 | + | |
123 | +def _post(addr, payload): | |
124 | + req = urllib.request.Request( | |
125 | + f"http://{addr}/users", | |
126 | + data=json.dumps(payload).encode(), | |
127 | + headers={"Content-Type": "application/json"}, | |
128 | + method="POST", | |
129 | + ) | |
130 | + try: | |
131 | + return urllib.request.urlopen(req).status | |
132 | + except urllib.error.HTTPError as e: | |
133 | + return e.code | |
134 | + | |
135 | + | |
136 | +def test_server_path_enforces_format(): | |
137 | + """Regression: `format` must be asserted on the live HTTP path. | |
138 | + | |
139 | + Previously the server-side `CompiledValidator` did not register the | |
140 | + `format` checkers, so an `email: "nope"` payload was accepted (200) | |
141 | + even though in-process `validate_value` rejected it (422). | |
142 | + """ | |
143 | + app = JustAPIApp() | |
144 | + | |
145 | + async def create_user(request): | |
146 | + return {"status": 201, "body": request["body"], "headers": []} | |
147 | + | |
148 | + app.post("/users", create_user, schema=User) | |
149 | + | |
150 | + addr = "127.0.0.1:9871" | |
151 | + t = threading.Thread(target=lambda: app.run(addr), daemon=True) | |
152 | + t.start() | |
153 | + time.sleep(1.0) | |
154 | + | |
155 | + good = { | |
156 | + "name": "Bob Smith", | |
157 | + "email": "[email protected]", | |
158 | + "age": 30, | |
159 | + "role": "admin", | |
160 | + "address": {"street": "1 Main", "zip": "12345"}, | |
161 | + "orders": [{"id": 1, "total": 9.99}], | |
162 | + } | |
163 | + bad_email = dict(good, email="nope") | |
164 | + bad_zip = dict(good, address={"street": "1 Main", "zip": "abc"}) | |
165 | + | |
166 | + assert _post(addr, good) == 201, "valid payload should be accepted" | |
167 | + assert _post(addr, bad_email) == 422, "format violation must be rejected" | |
168 | + assert _post(addr, bad_zip) == 422, "nested constraint must be rejected" | |
169 | + | |
0 | 170 | |
▼crates/justapi-py/src/database.rs
+119-79
| @@ -6,7 +6,7 @@ use justapi_core::db::{DatabaseConfig, IsolationLevel}; | ||
6 | 6 | /// A database connection pool configuration for JustAPI. |
7 | 7 | /// |
8 | 8 | /// Initialized lazily when the app starts via `JustAPIApp.run()`. |
9 | -#[pyclass(from_py_object)] | |
9 | +#[pyclass(skip_from_py_object)] | |
10 | 10 | #[derive(Clone)] |
11 | 11 | pub struct Database { |
12 | 12 | #[pyo3(get)] |
| @@ -22,6 +22,13 @@ pub struct Database { | ||
22 | 22 | /// Max seconds to wait for a connection from the pool. |
23 | 23 | #[pyo3(get)] |
24 | 24 | pub acquire_timeout: Option<f64>, |
25 | + /// Fast-fail window (seconds) for per-request connection acquires. When the | |
26 | + /// pool is saturated, a request that cannot get a connection within this | |
27 | + /// window fails immediately with `503` (backpressure) instead of hanging. | |
28 | + /// Defaults to 3s. (Stored as a plain `f64` because `Option<f64>` fields are | |
29 | + /// dropped across the pyo3 `from_py_object` FFI for this class.) | |
30 | + #[pyo3(get)] | |
31 | + pub request_acquire_timeout: f64, | |
25 | 32 | /// Max seconds a connection may stay idle before recycling. |
26 | 33 | #[pyo3(get)] |
27 | 34 | pub idle_timeout: Option<f64>, |
| @@ -40,13 +47,14 @@ pub struct Database { | ||
40 | 47 | #[pymethods] |
41 | 48 | impl Database { |
42 | 49 | #[new] |
43 | - #[pyo3(signature = (url, max_connections=10, init_sql=None, pragmas=None, acquire_timeout=None, idle_timeout=None, max_lifetime=None, health_check_interval=None, isolation=None))] | |
50 | + #[pyo3(signature = (url, max_connections=10, init_sql=None, pragmas=None, acquire_timeout=None, request_acquire_timeout=3.0, idle_timeout=None, max_lifetime=None, health_check_interval=None, isolation=None))] | |
44 | 51 | fn py_new( |
45 | 52 | url: String, |
46 | 53 | max_connections: u32, |
47 | 54 | init_sql: Option<String>, |
48 | 55 | pragmas: Option<Vec<String>>, |
49 | 56 | acquire_timeout: Option<f64>, |
57 | + request_acquire_timeout: f64, | |
50 | 58 | idle_timeout: Option<f64>, |
51 | 59 | max_lifetime: Option<f64>, |
52 | 60 | health_check_interval: Option<f64>, |
| @@ -58,6 +66,7 @@ impl Database { | ||
58 | 66 | init_sql, |
59 | 67 | pragmas, |
60 | 68 | acquire_timeout, |
69 | + request_acquire_timeout, | |
61 | 70 | idle_timeout, |
62 | 71 | max_lifetime, |
63 | 72 | health_check_interval, |
| @@ -68,6 +77,24 @@ impl Database { | ||
68 | 77 | fn __repr__(&self) -> String { |
69 | 78 | format!("Database(url={})", self.url) |
70 | 79 | } |
80 | + | |
81 | + /// Emit the full config as a plain `dict`, built in Rust from `self` so no | |
82 | + /// `from_py_object` extraction round-trip is needed (that path silently | |
83 | + /// drops `Option` fields in this pyo3 version). Consumed by `set_database`. | |
84 | + fn config_dict(&self, py: Python<'_>) -> Py<PyDict> { | |
85 | + let d = PyDict::new(py); | |
86 | + d.set_item("url", &self.url).ok(); | |
87 | + d.set_item("max_connections", self.max_connections).ok(); | |
88 | + d.set_item("init_sql", self.init_sql.clone()).ok(); | |
89 | + d.set_item("pragmas", self.pragmas.clone()).ok(); | |
90 | + d.set_item("acquire_timeout", self.acquire_timeout).ok(); | |
91 | + d.set_item("request_acquire_timeout", self.request_acquire_timeout).ok(); | |
92 | + d.set_item("idle_timeout", self.idle_timeout).ok(); | |
93 | + d.set_item("max_lifetime", self.max_lifetime).ok(); | |
94 | + d.set_item("health_check_interval", self.health_check_interval).ok(); | |
95 | + d.set_item("isolation", self.isolation.clone()).ok(); | |
96 | + d.into() | |
97 | + } | |
71 | 98 | } |
72 | 99 | |
73 | 100 | impl Database { |
| @@ -78,6 +105,7 @@ impl Database { | ||
78 | 105 | init_sql: None, |
79 | 106 | pragmas: None, |
80 | 107 | acquire_timeout: Some(30.0), |
108 | + request_acquire_timeout: 3.0, | |
81 | 109 | idle_timeout: None, |
82 | 110 | max_lifetime: Some(1800.0), |
83 | 111 | health_check_interval: None, |
| @@ -94,6 +122,9 @@ impl Database { | ||
94 | 122 | init_sql: self.init_sql.clone(), |
95 | 123 | pragmas: self.pragmas.clone(), |
96 | 124 | acquire_timeout: self.acquire_timeout.map(std::time::Duration::from_secs_f64), |
125 | + request_acquire_timeout: Some(std::time::Duration::from_secs_f64( | |
126 | + self.request_acquire_timeout, | |
127 | + )), | |
97 | 128 | idle_timeout: self.idle_timeout.map(std::time::Duration::from_secs_f64), |
98 | 129 | max_lifetime: self.max_lifetime.map(std::time::Duration::from_secs_f64), |
99 | 130 | health_check_interval: self |
| @@ -172,6 +203,44 @@ impl DbPool { | ||
172 | 203 | pub fn as_any_pool(&self) -> justapi_core::db::AnyPool { |
173 | 204 | self.inner.clone() |
174 | 205 | } |
206 | + | |
207 | + /// Run a DB future to completion on the pool's dedicated multi-threaded | |
208 | + /// tokio runtime, returning the resolved value or a `PyRuntimeError`. | |
209 | + /// | |
210 | + /// Design (P2.2 / ADR-074): | |
211 | + /// * `py.detach` releases the GIL for the wait, so other Python handlers | |
212 | + /// and the server's I/O loop keep making progress (preserves the high | |
213 | + /// SELECT throughput the old `py.detach`+`block_on` path had). | |
214 | + /// * We must NOT `rt.block_on(fut)` from the calling thread: when the | |
215 | + /// caller is itself serviced by that runtime (or shares its executor) | |
216 | + /// `block_on` deadlocks — the connection pool is never returned, every | |
217 | + /// subsequent acquire blocks for `busy_timeout` and writes are silently | |
218 | + /// lost (49/50 inserts vanished in repro). Instead we `rt.spawn` the | |
219 | + /// future onto the DB runtime's own worker threads and block the caller | |
220 | + /// on an `mpsc` channel. The task runs to completion and commits; the | |
221 | + /// caller just waits, with no re-entrant runtime driving. | |
222 | + /// | |
223 | + /// The closure returns a plain `anyhow::Result` because `py.detach` | |
224 | + /// requires its return type to be `Ungil` (no Python refs); the `PyErr` | |
225 | + /// conversion happens after the GIL is re-acquired. | |
226 | + fn run_blocking<'py, F, T>(&self, py: Python<'py>, fut: F) -> PyResult<T> | |
227 | + where | |
228 | + F: std::future::Future<Output = Result<T, anyhow::Error>> + Send + 'static, | |
229 | + T: Send + 'static, | |
230 | + { | |
231 | + let res: Result<T, anyhow::Error> = py.detach(|| { | |
232 | + let (tx, rx) = std::sync::mpsc::channel(); | |
233 | + self.rt.spawn(async move { | |
234 | + // The future runs purely in Rust; any error is forwarded to | |
235 | + // the waiting thread via the channel. | |
236 | + let _ = tx.send(fut.await); | |
237 | + }); | |
238 | + // Wait for the spawned task without driving the runtime ourselves | |
239 | + // (avoids the re-entrant block_on deadlock). | |
240 | + rx.recv().map_err(|_| anyhow::anyhow!("db task dropped before completion"))? | |
241 | + }); | |
242 | + res.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) | |
243 | + } | |
175 | 244 | } |
176 | 245 | |
177 | 246 | #[pymethods] |
| @@ -193,17 +262,12 @@ impl DbPool { | ||
193 | 262 | None => Vec::new(), |
194 | 263 | }; |
195 | 264 | let inner = self.inner.clone(); |
196 | - let rt = self.rt.clone(); | |
197 | - let rows: serde_json::Value = py | |
198 | - .detach(move || { | |
199 | - rt.block_on(async move { | |
200 | - inner | |
201 | - .query_with_params(&sql, ¶ms_json) | |
202 | - .await | |
203 | - .map_err(|e| anyhow::anyhow!(e.to_string())) | |
204 | - }) | |
205 | - }) | |
206 | - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; | |
265 | + let rows: serde_json::Value = self.run_blocking(py, async move { | |
266 | + inner | |
267 | + .query_with_params(&sql, ¶ms_json) | |
268 | + .await | |
269 | + .map_err(|e| anyhow::anyhow!(e.to_string())) | |
270 | + })?; | |
207 | 271 | json_to_python(py, &rows) |
208 | 272 | } |
209 | 273 | |
| @@ -223,17 +287,12 @@ impl DbPool { | ||
223 | 287 | None => Vec::new(), |
224 | 288 | }; |
225 | 289 | let inner = self.inner.clone(); |
226 | - let rt = self.rt.clone(); | |
227 | - let affected: u64 = py | |
228 | - .detach(move || { | |
229 | - rt.block_on(async move { | |
230 | - inner | |
231 | - .execute_with_params(&sql, ¶ms_json) | |
232 | - .await | |
233 | - .map_err(|e| anyhow::anyhow!(e.to_string())) | |
234 | - }) | |
235 | - }) | |
236 | - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; | |
290 | + let affected: u64 = self.run_blocking(py, async move { | |
291 | + inner | |
292 | + .execute_with_params(&sql, ¶ms_json) | |
293 | + .await | |
294 | + .map_err(|e| anyhow::anyhow!(e.to_string())) | |
295 | + })?; | |
237 | 296 | Ok(affected.into_pyobject(py)?.into_any()) |
238 | 297 | } |
239 | 298 | |
| @@ -265,34 +324,25 @@ impl DbPool { | ||
265 | 324 | .collect::<PyResult<Vec<_>>>()?; |
266 | 325 | let iso = isolation.as_deref().and_then(parse_isolation); |
267 | 326 | let inner = self.inner.clone(); |
268 | - let rt = self.rt.clone(); | |
269 | - let res: serde_json::Value = py | |
270 | - .detach(move || { | |
271 | - rt.block_on(async move { | |
272 | - match iso { | |
273 | - Some(level) => { | |
274 | - let typed: Vec<(String, Vec<_>)> = stmts_json | |
275 | - .iter() | |
276 | - .map(|(s, p)| { | |
277 | - ( | |
278 | - s.clone(), | |
279 | - p.iter().map(justapi_core::db::Param::from).collect(), | |
280 | - ) | |
281 | - }) | |
282 | - .collect(); | |
283 | - inner | |
284 | - .transaction_with_isolation(&typed, Some(level)) | |
285 | - .await | |
286 | - .map_err(|e| anyhow::anyhow!(e.to_string())) | |
287 | - } | |
288 | - None => inner | |
289 | - .transaction(&stmts_json) | |
290 | - .await | |
291 | - .map_err(|e| anyhow::anyhow!(e.to_string())), | |
292 | - } | |
293 | - }) | |
294 | - }) | |
295 | - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; | |
327 | + let res: serde_json::Value = self.run_blocking(py, async move { | |
328 | + match iso { | |
329 | + Some(level) => { | |
330 | + let typed: Vec<(String, Vec<_>)> = stmts_json | |
331 | + .iter() | |
332 | + .map(|(s, p)| { | |
333 | + (s.clone(), p.iter().map(justapi_core::db::Param::from).collect()) | |
334 | + }) | |
335 | + .collect(); | |
336 | + inner | |
337 | + .transaction_with_isolation(&typed, Some(level)) | |
338 | + .await | |
339 | + .map_err(|e| anyhow::anyhow!(e.to_string())) | |
340 | + } | |
341 | + None => { | |
342 | + inner.transaction(&stmts_json).await.map_err(|e| anyhow::anyhow!(e.to_string())) | |
343 | + } | |
344 | + } | |
345 | + })?; | |
296 | 346 | json_to_python(py, &res) |
297 | 347 | } |
298 | 348 | |
| @@ -314,19 +364,13 @@ impl DbPool { | ||
314 | 364 | None => Vec::new(), |
315 | 365 | }; |
316 | 366 | let inner = self.inner.clone(); |
317 | - let rt = self.rt.clone(); | |
318 | - let chunks: Vec<serde_json::Value> = py | |
319 | - .detach(move || { | |
320 | - rt.block_on(async move { | |
321 | - let typed: Vec<_> = | |
322 | - params_json.iter().map(justapi_core::db::Param::from).collect(); | |
323 | - inner | |
324 | - .query_stream(&sql, &typed, chunk) | |
325 | - .await | |
326 | - .map_err(|e| anyhow::anyhow!(e.to_string())) | |
327 | - }) | |
328 | - }) | |
329 | - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; | |
367 | + let chunks: Vec<serde_json::Value> = self.run_blocking(py, async move { | |
368 | + let typed: Vec<_> = params_json.iter().map(justapi_core::db::Param::from).collect(); | |
369 | + inner | |
370 | + .query_stream(&sql, &typed, chunk) | |
371 | + .await | |
372 | + .map_err(|e| anyhow::anyhow!(e.to_string())) | |
373 | + })?; | |
330 | 374 | json_to_python(py, &serde_json::Value::Array(chunks)) |
331 | 375 | } |
332 | 376 | |
| @@ -345,26 +389,22 @@ impl DbPool { | ||
345 | 389 | data_json.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default() |
346 | 390 | }); |
347 | 391 | let inner = self.inner.clone(); |
348 | - let rt = self.rt.clone(); | |
349 | - let row: serde_json::Value = py | |
350 | - .detach(move || { | |
351 | - rt.block_on(async move { | |
352 | - inner | |
353 | - .insert_returning(&table, &cols, &data_json) | |
354 | - .await | |
355 | - .map_err(|e| anyhow::anyhow!(e.to_string())) | |
356 | - }) | |
357 | - }) | |
358 | - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; | |
392 | + let row: serde_json::Value = self.run_blocking(py, async move { | |
393 | + inner | |
394 | + .insert_returning(&table, &cols, &data_json) | |
395 | + .await | |
396 | + .map_err(|e| anyhow::anyhow!(e.to_string())) | |
397 | + })?; | |
359 | 398 | json_to_python(py, &row) |
360 | 399 | } |
361 | 400 | |
362 | 401 | /// Ping the database; raises on failure. |
363 | 402 | fn health<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> { |
364 | 403 | let inner = self.inner.clone(); |
365 | - let rt = self.rt.clone(); | |
366 | - py.detach(move || rt.block_on(async move { inner.health_check().await })) | |
367 | - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; | |
404 | + self.run_blocking(py, async move { | |
405 | + inner.health_check().await.map_err(|e| anyhow::anyhow!(e.to_string())) | |
406 | + }) | |
407 | + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; | |
368 | 408 | Ok(true.into_pyobject(py)?.as_any().clone()) |
369 | 409 | } |
370 | 410 | } |
371 | 411 | |
▼crates/justapi-py/src/native/app.rs
+242-33
| @@ -95,6 +95,8 @@ pub struct JustAPIApp { | ||
95 | 95 | std::collections::HashMap<(hyper::Method, String), justapi_core::openapi::RouteMeta>, |
96 | 96 | /// Named routes for `url_for` resolution, keyed by name -> path template. |
97 | 97 | pub named_routes: std::collections::HashMap<String, String>, |
98 | + /// Map of `(method, path)` -> handler index, allowing user routes to override built-in routes cleanly. | |
99 | + pub route_indices: std::collections::HashMap<(hyper::Method, String), usize>, | |
98 | 100 | /// Static frontend mounts (served as low-priority routes with SPA fallback). |
99 | 101 | pub frontend_mounts: Vec<justapi_core::static_files::StaticMount>, |
100 | 102 | /// Native MCP tool registry (agent surface). Stored in Rust; invoked as |
| @@ -206,10 +208,145 @@ fn schema_validator_cache() -> &'static Mutex<HashMap<String, Validator>> { | ||
206 | 208 | CACHE.get_or_init(|| Mutex::new(HashMap::new())) |
207 | 209 | } |
208 | 210 | |
211 | +/// A `format` keyword checker: maps a string to a pass/fail boolean. | |
212 | +type FormatChecker = dyn Fn(&str) -> bool + Send + Sync; | |
213 | + | |
214 | +/// Register the built-in `format` validators (email, uuid, uri, date-time, | |
215 | +/// ...) once. `jsonschema` 0.46 ships `format` as an opt-in keyword; | |
216 | +/// with `default-features = false` no formats are asserted unless we | |
217 | +/// register them, so we provide lightweight Rust-side checkers. They are | |
218 | +/// intentionally permissive (reject obvious junk, not RFC-exhaustive) — | |
219 | +/// enough for request validation without pulling a format-parsing dependency. | |
220 | +fn format_validators() -> &'static HashMap<String, Arc<FormatChecker>> { | |
221 | + static FMTS: OnceLock<HashMap<String, Arc<FormatChecker>>> = OnceLock::new(); | |
222 | + FMTS.get_or_init(|| { | |
223 | + let mut m: HashMap<String, Arc<FormatChecker>> = HashMap::new(); | |
224 | + m.insert( | |
225 | + "email".into(), | |
226 | + Arc::new(|s: &str| { | |
227 | + if s.is_empty() || s.contains(char::is_whitespace) || !s.contains('@') { | |
228 | + return false; | |
229 | + } | |
230 | + let (local, domain) = s.split_once('@').unwrap(); | |
231 | + !local.is_empty() | |
232 | + && !domain.is_empty() | |
233 | + && domain.contains('.') | |
234 | + && !domain.starts_with('.') | |
235 | + && !domain.ends_with('.') | |
236 | + }), | |
237 | + ); | |
238 | + m.insert( | |
239 | + "uri".into(), | |
240 | + Arc::new(|s: &str| match s.split_once("://") { | |
241 | + Some((scheme, rest)) => { | |
242 | + !scheme.is_empty() | |
243 | + && scheme | |
244 | + .chars() | |
245 | + .all(|c| c.is_alphanumeric() || c == '+' || c == '-' || c == '.') | |
246 | + && !rest.is_empty() | |
247 | + } | |
248 | + None => false, | |
249 | + }), | |
250 | + ); | |
251 | + m.insert( | |
252 | + "uuid".into(), | |
253 | + Arc::new(|s: &str| { | |
254 | + let s = s.trim_matches(|c| c == '{' || c == '}'); | |
255 | + let parts: Vec<&str> = s.split('-').collect(); | |
256 | + parts.len() == 5 | |
257 | + && parts[0].len() == 8 | |
258 | + && parts[1].len() == 4 | |
259 | + && parts[2].len() == 4 | |
260 | + && parts[3].len() == 4 | |
261 | + && parts[4].len() == 12 | |
262 | + && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_hexdigit())) | |
263 | + }), | |
264 | + ); | |
265 | + m.insert( | |
266 | + "date-time".into(), | |
267 | + Arc::new(|s: &str| { | |
268 | + let (date, time) = match s.split_once('T') { | |
269 | + Some((d, t)) => (d, t), | |
270 | + None => return false, | |
271 | + }; | |
272 | + let ds: Vec<&str> = date.split('-').collect(); | |
273 | + if ds.len() != 3 | |
274 | + || ds.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit())) | |
275 | + { | |
276 | + return false; | |
277 | + } | |
278 | + let time = time.trim_end_matches('Z').trim_end_matches(['+', '-']); | |
279 | + let time = time.split('.').next().unwrap_or(time); | |
280 | + let ts: Vec<&str> = time.split(':').collect(); | |
281 | + ts.len() >= 2 | |
282 | + && ts.len() <= 3 | |
283 | + && ts.iter().all(|p| { | |
284 | + p.len() <= 2 && !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) | |
285 | + }) | |
286 | + }), | |
287 | + ); | |
288 | + m.insert( | |
289 | + "date".into(), | |
290 | + Arc::new(|s: &str| { | |
291 | + let ds: Vec<&str> = s.split('-').collect(); | |
292 | + ds.len() == 3 | |
293 | + && ds.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) | |
294 | + }), | |
295 | + ); | |
296 | + m.insert( | |
297 | + "hostname".into(), | |
298 | + Arc::new(|s: &str| { | |
299 | + !s.is_empty() | |
300 | + && !s.starts_with('-') | |
301 | + && !s.ends_with('-') | |
302 | + && !s.contains("..") | |
303 | + && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '.') | |
304 | + }), | |
305 | + ); | |
306 | + m.insert( | |
307 | + "ipv4".into(), | |
308 | + Arc::new(|s: &str| { | |
309 | + let parts: Vec<&str> = s.split('.').collect(); | |
310 | + parts.len() == 4 | |
311 | + && parts.iter().all(|p| { | |
312 | + p.len() <= 3 | |
313 | + && !p.is_empty() | |
314 | + && p.chars().all(|c| c.is_ascii_digit()) | |
315 | + && p.parse::<u8>().is_ok() | |
316 | + }) | |
317 | + }), | |
318 | + ); | |
319 | + m | |
320 | + }) | |
321 | +} | |
322 | + | |
323 | +/// Build (and cache) a validator for `schema_value`, registering the built-in | |
324 | +/// `format` checkers so `format: "email"` etc. are actually asserted. | |
325 | +fn build_validator(schema_json: &str, schema_value: &serde_json::Value) -> PyResult<Validator> { | |
326 | + { | |
327 | + let cache = schema_validator_cache().lock().unwrap(); | |
328 | + if let Some(v) = cache.get(schema_json) { | |
329 | + return Ok(v.clone()); | |
330 | + } | |
331 | + } | |
332 | + let mut opts = jsonschema::options(); | |
333 | + for (name, checker) in format_validators() { | |
334 | + let checker = checker.clone(); | |
335 | + opts = opts.with_format(name.clone(), move |s: &str| checker(s)); | |
336 | + } | |
337 | + let v = opts | |
338 | + .should_validate_formats(true) | |
339 | + .build(schema_value) | |
340 | + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("schema error: {e}")))?; | |
341 | + schema_validator_cache().lock().unwrap().insert(schema_json.to_string(), v.clone()); | |
342 | + Ok(v) | |
343 | +} | |
344 | + | |
209 | 345 | /// Validate a single JSON value (`value_json`) against a JSON Schema |
210 | 346 | /// (`schema_json`). Returns a list of human-readable error strings; an empty |
211 | 347 | /// list means the value is valid. The compiled schema is cached per unique |
212 | -/// schema string. | |
348 | +/// schema string. `format` keywords (email, uuid, uri, ...) are enforced | |
349 | +/// via the built-in validators in `format_validators`. | |
213 | 350 | #[pyfunction] |
214 | 351 | pub fn validate_value(schema_json: String, value_json: String) -> PyResult<Vec<String>> { |
215 | 352 | let schema_value: serde_json::Value = serde_json::from_str(&schema_json) |
| @@ -217,18 +354,7 @@ pub fn validate_value(schema_json: String, value_json: String) -> PyResult<Vec<S | ||
217 | 354 | let value: serde_json::Value = serde_json::from_str(&value_json) |
218 | 355 | .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("invalid JSON value: {e}")))?; |
219 | 356 | |
220 | - let validator = { | |
221 | - let mut cache = schema_validator_cache().lock().unwrap(); | |
222 | - if let Some(v) = cache.get(&schema_json) { | |
223 | - v.clone() | |
224 | - } else { | |
225 | - let v = jsonschema::options().build(&schema_value).map_err(|e| { | |
226 | - pyo3::exceptions::PyValueError::new_err(format!("schema error: {e}")) | |
227 | - })?; | |
228 | - cache.insert(schema_json.clone(), v.clone()); | |
229 | - v | |
230 | - } | |
231 | - }; | |
357 | + let validator = build_validator(&schema_json, &schema_value)?; | |
232 | 358 | |
233 | 359 | let mut errors = Vec::new(); |
234 | 360 | for err in validator.iter_errors(&value) { |
| @@ -337,7 +463,9 @@ impl JustAPIApp { | ||
337 | 463 | health_checks: Vec::new(), |
338 | 464 | route_meta: std::collections::HashMap::new(), |
339 | 465 | named_routes: std::collections::HashMap::new(), |
466 | + route_indices: std::collections::HashMap::new(), | |
340 | 467 | frontend_mounts: Vec::new(), |
468 | + | |
341 | 469 | tools: Vec::new(), |
342 | 470 | sessions: Mutex::new(HashMap::new()), |
343 | 471 | } |
| @@ -450,6 +578,36 @@ impl JustAPIApp { | ||
450 | 578 | } |
451 | 579 | } |
452 | 580 | |
581 | + /// Expose GraphQL gateway endpoint handling for Python app built-in route. | |
582 | + #[pyo3(name = "graphql_handle")] | |
583 | + fn graphql_handle(&self, method: String, body: Option<Vec<u8>>) -> (u16, String, String) { | |
584 | + if method == "GET" { | |
585 | + let source = justapi_core::graphql::graphiql_html(); | |
586 | + (200, "text/html; charset=utf-8".to_string(), source) | |
587 | + } else { | |
588 | + let body_bytes = body.unwrap_or_default(); | |
589 | + let schema = justapi_core::graphql::create_schema(); | |
590 | + let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() { | |
591 | + Ok(rt) => rt, | |
592 | + Err(_) => { | |
593 | + return ( | |
594 | + 500, | |
595 | + "application/json".to_string(), | |
596 | + r#"{"errors":[{"message":"runtime error"}]}"#.to_string(), | |
597 | + ) | |
598 | + } | |
599 | + }; | |
600 | + let res_str = rt.block_on(async { | |
601 | + justapi_core::graphql::execute_graphql_bytes(&schema, &body_bytes) | |
602 | + .await | |
603 | + .unwrap_or_else(|e| { | |
604 | + format!(r#"{{"errors":[{{"message":{:?}}}]}}"#, e.to_string()) | |
605 | + }) | |
606 | + }); | |
607 | + (200, "application/json".to_string(), res_str) | |
608 | + } | |
609 | + } | |
610 | + | |
453 | 611 | /// Returns `(ready, report_json)` for the running server's health registry. |
454 | 612 | /// `ready` is true unless a registered dependency check is unhealthy. The |
455 | 613 | /// Python `/ready` builtin uses this to give real readiness (not a static |
| @@ -657,14 +815,32 @@ impl JustAPIApp { | ||
657 | 815 | crud_table: Option<String>, |
658 | 816 | crud_columns: Option<Vec<String>>, |
659 | 817 | ) -> PyResult<()> { |
660 | - let id = self.handlers.len(); | |
661 | - self.handlers.push(handler); | |
662 | - self.native.push(native.unwrap_or(false)); | |
663 | - self.crud.push(make_crud_spec(crud_table, crud_columns)?); | |
664 | - self.schemas.push(None); | |
665 | - self.schema_jsons.push(None); | |
666 | - self.query_schema_jsons.push(resolve_schema_json(py, query_schema)?); | |
667 | - self.batch_configs.push(None); | |
818 | + let key = (Method::GET, path.to_string()); | |
819 | + let is_native = native.unwrap_or(false); | |
820 | + let crud_spec = make_crud_spec(crud_table, crud_columns)?; | |
821 | + let query_json = resolve_schema_json(py, query_schema)?; | |
822 | + | |
823 | + if let Some(&id) = self.route_indices.get(&key) { | |
824 | + self.handlers[id] = handler; | |
825 | + self.native[id] = is_native; | |
826 | + self.crud[id] = crud_spec; | |
827 | + self.schemas[id] = None; | |
828 | + self.schema_jsons[id] = None; | |
829 | + self.query_schema_jsons[id] = query_json; | |
830 | + } else { | |
831 | + let id = self.handlers.len(); | |
832 | + self.handlers.push(handler); | |
833 | + self.native.push(is_native); | |
834 | + self.crud.push(crud_spec); | |
835 | + self.schemas.push(None); | |
836 | + self.schema_jsons.push(None); | |
837 | + self.query_schema_jsons.push(query_json); | |
838 | + self.batch_configs.push(None); | |
839 | + self.route_indices.insert(key, id); | |
840 | + self.router | |
841 | + .insert(Method::GET, path, id) | |
842 | + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; | |
843 | + } | |
668 | 844 | self.store_meta( |
669 | 845 | py, |
670 | 846 | "GET".to_string(), |
| @@ -685,9 +861,7 @@ impl JustAPIApp { | ||
685 | 861 | if let Some(ref n) = name { |
686 | 862 | self.named_routes.insert(n.clone(), path.to_string()); |
687 | 863 | } |
688 | - self.router | |
689 | - .insert(Method::GET, path, id) | |
690 | - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) | |
864 | + Ok(()) | |
691 | 865 | } |
692 | 866 | |
693 | 867 | #[allow(clippy::too_many_arguments)] |
| @@ -1150,19 +1324,54 @@ impl JustAPIApp { | ||
1150 | 1324 | } |
1151 | 1325 | |
1152 | 1326 | /// Set the database configuration. |
1327 | + /// | |
1328 | + /// Accepts either a connection-string shorthand or a `dict` of tuning | |
1329 | + /// options (as produced by `Database.config_dict()`). The Python-side | |
1330 | + /// wrapper converts a `Database` object into that dict *before* crossing the | |
1331 | + /// FFI, because reading `Option`/`f64` fields off a `Database` instance from | |
1332 | + /// Rust hits a pyo3 `from_py_object` reconstruction quirk (values come back | |
1333 | + /// as defaults). A plain `dict` extracts reliably. | |
1153 | 1334 | #[pyo3(signature = (db))] |
1154 | 1335 | fn set_database(&mut self, _py: Python<'_>, db: &Bound<'_, PyAny>) -> PyResult<()> { |
1155 | - if let Ok(db_obj) = db.extract::<Database>() { | |
1156 | - self.database = Some(db_obj); | |
1157 | - Ok(()) | |
1158 | - } else if let Ok(url) = db.extract::<String>() { | |
1336 | + // A plain connection string is the common shorthand. | |
1337 | + if let Ok(url) = db.extract::<String>() { | |
1159 | 1338 | self.database = Some(Database::new(url, 10)); |
1160 | - Ok(()) | |
1161 | - } else { | |
1162 | - Err(pyo3::exceptions::PyTypeError::new_err( | |
1163 | - "database must be a Database object or a connection string", | |
1164 | - )) | |
1339 | + return Ok(()); | |
1165 | 1340 | } |
1341 | + // Otherwise expect a `dict` of config options. | |
1342 | + let cfg: Py<PyDict> = match db.extract() { | |
1343 | + Ok(d) => d, | |
1344 | + Err(_) => { | |
1345 | + return Err(pyo3::exceptions::PyTypeError::new_err( | |
1346 | + "database must be a connection string or a config dict", | |
1347 | + )) | |
1348 | + } | |
1349 | + }; | |
1350 | + let cfg = cfg.bind(_py).as_any(); | |
1351 | + let url: String = cfg.get_item("url")?.extract()?; | |
1352 | + let max_connections: u32 = cfg.get_item("max_connections")?.extract()?; | |
1353 | + let init_sql: Option<String> = cfg.get_item("init_sql")?.extract()?; | |
1354 | + let pragmas: Option<Vec<String>> = cfg.get_item("pragmas")?.extract()?; | |
1355 | + let acquire_timeout: Option<f64> = cfg.get_item("acquire_timeout")?.extract()?; | |
1356 | + let request_acquire_timeout: f64 = cfg.get_item("request_acquire_timeout")?.extract()?; | |
1357 | + let idle_timeout: Option<f64> = cfg.get_item("idle_timeout")?.extract()?; | |
1358 | + let max_lifetime: Option<f64> = cfg.get_item("max_lifetime")?.extract()?; | |
1359 | + let health_check_interval: Option<f64> = | |
1360 | + cfg.get_item("health_check_interval")?.extract()?; | |
1361 | + let isolation: Option<String> = cfg.get_item("isolation")?.extract()?; | |
1362 | + self.database = Some(Database { | |
1363 | + url, | |
1364 | + max_connections, | |
1365 | + init_sql, | |
1366 | + pragmas, | |
1367 | + acquire_timeout, | |
1368 | + request_acquire_timeout, | |
1369 | + idle_timeout, | |
1370 | + max_lifetime, | |
1371 | + health_check_interval, | |
1372 | + isolation, | |
1373 | + }); | |
1374 | + Ok(()) | |
1166 | 1375 | } |
1167 | 1376 | |
1168 | 1377 | /// Get the database configuration, or None. |
1169 | 1378 | |
▼crates/justapi-py/src/native/handlers.rs
+89-39
| @@ -12,7 +12,23 @@ use justapi_core::router::Router; | ||
12 | 12 | use justapi_core::xml; |
13 | 13 | use justapi_core::{json_response, ResponseBody}; |
14 | 14 | |
15 | -use justapi_core::db::AnyPool; | |
15 | +use justapi_core::db::{AnyPool, DbAcquireError}; | |
16 | + | |
17 | +/// Translate a request-path DB acquire failure into the correct response. | |
18 | +/// A saturated/closed pool is backpressure → `503` (so clients retry); any | |
19 | +/// other SQLx error is a genuine failure → `500`. | |
20 | +fn db_acquire_error_response(e: DbAcquireError) -> Response<ResponseBody> { | |
21 | + match e { | |
22 | + DbAcquireError::TimedOut | DbAcquireError::PoolClosed => { | |
23 | + justapi_core::service_unavailable_response( | |
24 | + "database pool saturated; please retry shortly", | |
25 | + ) | |
26 | + } | |
27 | + DbAcquireError::Other(_) => { | |
28 | + justapi_core::error_response(StatusCode::INTERNAL_SERVER_ERROR, "database error") | |
29 | + } | |
30 | + } | |
31 | +} | |
16 | 32 | |
17 | 33 | /// Shared, per-route Rust-native CRUD config: `Some((table, columns, |
18 | 34 | /// id_column))` means the route is served by `crud_dispatch_bytes` with the |
| @@ -105,28 +121,48 @@ pub(crate) fn call_python_handler( | ||
105 | 121 | } |
106 | 122 | } |
107 | 123 | |
124 | + // When a `body_schema` was registered *and* it is a `justapi.Schema` | |
125 | + // subclass (distinguished from a legacy callable validator by the presence | |
126 | + // of a `_schema_json` method), parse the body once on the fast path and | |
127 | + // attach it to the `Request`. The handler then receives the already-parsed, | |
128 | + // already-validated object via `request.json()` / `request["body"]` instead | |
129 | + // of raw bytes. Legacy callable schemas keep `request["body"]` as raw bytes | |
130 | + // so existing handlers doing `json.loads(request["body"])` keep working. | |
131 | + let schema_is_justapi_schema = | |
132 | + schema.map(|s| s.bind(py).hasattr("_schema_json").unwrap_or(false)).unwrap_or(false); | |
133 | + let parsed_body: Option<Py<PyAny>> = if schema_is_justapi_schema { | |
134 | + helper | |
135 | + .parse_body | |
136 | + .bind(py) | |
137 | + .call1((body,)) | |
138 | + .ok() | |
139 | + .and_then(|v| v.extract::<Option<Py<PyAny>>>().ok()) | |
140 | + .flatten() | |
141 | + } else { | |
142 | + None | |
143 | + }; | |
144 | + | |
108 | 145 | let _tb0 = std::time::Instant::now(); |
109 | 146 | let request: Bound<'_, PyAny> = if needs_request { |
110 | - Bound::new( | |
147 | + let mut req = crate::request::Request::new( | |
111 | 148 | py, |
112 | - crate::request::Request::new( | |
113 | - py, | |
114 | - method.to_string(), | |
115 | - path.to_string(), | |
116 | - path_params.to_vec(), | |
117 | - query_string.to_vec(), | |
118 | - headers.to_vec(), | |
119 | - body.to_vec(), | |
120 | - db_url.map(|s| s.to_string()), | |
121 | - multipart_form, | |
122 | - scheme, | |
123 | - client, | |
124 | - app, | |
125 | - http_version, | |
126 | - ), | |
127 | - ) | |
128 | - .unwrap() | |
129 | - .into_any() | |
149 | + method.to_string(), | |
150 | + path.to_string(), | |
151 | + path_params.to_vec(), | |
152 | + query_string.to_vec(), | |
153 | + headers.to_vec(), | |
154 | + body.to_vec(), | |
155 | + db_url.map(|s| s.to_string()), | |
156 | + multipart_form, | |
157 | + scheme, | |
158 | + client, | |
159 | + app, | |
160 | + http_version, | |
161 | + ); | |
162 | + if let Some(pb) = parsed_body { | |
163 | + req.set_parsed_body(py, pb.into_bound(py)); | |
164 | + } | |
165 | + Bound::new(py, req).unwrap().into_any() | |
130 | 166 | } else { |
131 | 167 | // Handlers with no request-dependent parameters (0-param endpoints, |
132 | 168 | // no dependencies/query/path/etc.) skip building the Python `Request` |
| @@ -319,11 +355,31 @@ pub(crate) fn serialize_response( | ||
319 | 355 | return NativeResponse { status, headers, body: NativeBody::Bytes(body) }; |
320 | 356 | } |
321 | 357 | |
322 | - // 2. Legacy response dict (`{"status": ..., "body": ...}`) -> passthrough. | |
358 | + // 2. Legacy response envelope dict (`{"status": ..., "body": ...}`) -> passthrough. | |
359 | + // Only enter this branch when the dict actually looks like an envelope — it | |
360 | + // MUST carry a `"body"` key. A plain data dict that merely happens to | |
361 | + // contain a top-level `"status"` field (e.g. `{"status": "ok", "products": 5}`) | |
362 | + // must fall through to normal JSON serialization, otherwise its body is | |
363 | + // silently dropped (BUG-1, PRODUCTION_PLAN.md P0.3). | |
323 | 364 | if res.is_instance_of::<pyo3::types::PyDict>() { |
324 | 365 | let has_body = res.get_item("body").is_ok(); |
325 | - let has_status = res.get_item("status").is_ok(); | |
326 | - if has_body || has_status { | |
366 | + let is_explicit_envelope = | |
367 | + res.get_item("__response__").and_then(|v| v.extract::<bool>()).unwrap_or(false); | |
368 | + // A status-only envelope (`{"status": 204}` / `{"status": 200, | |
369 | + // "headers": [...]}` with no other keys) is treated as a response | |
370 | + // envelope so handlers can set a status code with no body. A data dict | |
371 | + // that carries a `"status"` field *plus other keys* (e.g. | |
372 | + // `{"status": "ok", "products": 5}`) must NOT be treated as an envelope, | |
373 | + // otherwise its payload is silently dropped (BUG-1, P0.3). | |
374 | + let is_status_only_envelope = { | |
375 | + let keys: Vec<String> = res | |
376 | + .extract::<std::collections::HashMap<String, pyo3::Py<pyo3::PyAny>>>() | |
377 | + .map(|m| m.keys().cloned().collect()) | |
378 | + .unwrap_or_default(); | |
379 | + let has_status = keys.iter().any(|k| k == "status"); | |
380 | + has_status && keys.iter().all(|k| k == "status" || k == "headers") && !has_body | |
381 | + }; | |
382 | + if has_body || is_explicit_envelope || is_status_only_envelope { | |
327 | 383 | let status: u16 = |
328 | 384 | res.get_item("status").ok().and_then(|v| v.extract::<u16>().ok()).unwrap_or(200); |
329 | 385 | let headers: Vec<(Vec<u8>, Vec<u8>)> = res |
| @@ -464,10 +520,10 @@ pub(crate) fn try_native_fast_path( | ||
464 | 520 | } |
465 | 521 | let result = match validators.get(handler_id).and_then(|v| v.as_ref()) { |
466 | 522 | Some(v) => v.validate(body), |
467 | - None => match schema_jsons.get(handler_id).and_then(|s| s.as_ref()) { | |
468 | - Some(sj) => justapi_core::validate::validate_json_schema(body, sj), | |
469 | - None => return None, | |
470 | - }, | |
523 | + None => { | |
524 | + let sj = schema_jsons.get(handler_id).and_then(|s| s.as_ref())?; | |
525 | + justapi_core::validate::validate_json_schema(body, sj) | |
526 | + } | |
471 | 527 | }; |
472 | 528 | match result { |
473 | 529 | Ok(()) => Some(NativeResponse { |
| @@ -827,14 +883,11 @@ where | ||
827 | 883 | let is_write = matches!(method, Method::POST | Method::PUT | Method::DELETE); |
828 | 884 | let tx = if is_write { |
829 | 885 | if let Some(ref pool) = *db_pool { |
830 | - match pool.begin().await { | |
886 | + match pool.begin_request().await { | |
831 | 887 | Ok(tx) => Some(tx), |
832 | 888 | Err(e) => { |
833 | - tracing::error!("Failed to begin transaction: {}", e); | |
834 | - return Ok(json_response( | |
835 | - StatusCode::INTERNAL_SERVER_ERROR, | |
836 | - r#"{"detail":"database error"}"#, | |
837 | - )); | |
889 | + tracing::error!("Failed to begin transaction: {:?}", e); | |
890 | + return Ok(db_acquire_error_response(e)); | |
838 | 891 | } |
839 | 892 | } |
840 | 893 | } else { |
| @@ -1091,14 +1144,11 @@ where | ||
1091 | 1144 | let is_write = matches!(method, Method::POST | Method::PUT | Method::DELETE); |
1092 | 1145 | let tx = if is_write { |
1093 | 1146 | if let Some(ref pool) = *db_pool { |
1094 | - match pool.begin().await { | |
1147 | + match pool.begin_request().await { | |
1095 | 1148 | Ok(tx) => Some(tx), |
1096 | 1149 | Err(e) => { |
1097 | - tracing::error!("Failed to begin transaction: {}", e); | |
1098 | - return Ok(justapi_core::json_response( | |
1099 | - hyper::StatusCode::INTERNAL_SERVER_ERROR, | |
1100 | - r#"{"detail":"database error"}"#, | |
1101 | - )); | |
1150 | + tracing::error!("Failed to begin transaction: {:?}", e); | |
1151 | + return Ok(db_acquire_error_response(e)); | |
1102 | 1152 | } |
1103 | 1153 | } |
1104 | 1154 | } else { |
1105 | 1155 | |
▼crates/justapi-py/src/native/types.rs
+4-0
| @@ -70,6 +70,7 @@ pub(crate) struct HelperFunctions { | ||
70 | 70 | pub(crate) call_handler: Py<PyAny>, |
71 | 71 | pub(crate) call_batch_handler: Py<PyAny>, |
72 | 72 | pub(crate) validate_body: Py<PyAny>, |
73 | + pub(crate) parse_body: Py<PyAny>, | |
73 | 74 | pub(crate) call_plugin_hook: Py<PyAny>, |
74 | 75 | pub(crate) wrap_result: Py<PyAny>, |
75 | 76 | pub(crate) pump_stream: Py<PyAny>, |
| @@ -96,6 +97,8 @@ pub(crate) fn get_helper(py: Python<'_>) -> &HelperFunctions { | ||
96 | 97 | .unbind(); |
97 | 98 | let validate_body = |
98 | 99 | helper.getattr("validate_body").expect("validate_body function should exist").unbind(); |
100 | + let parse_body = | |
101 | + helper.getattr("parse_body").expect("parse_body function should exist").unbind(); | |
99 | 102 | let call_plugin_hook = helper |
100 | 103 | .getattr("call_plugin_hook") |
101 | 104 | .expect("call_plugin_hook function should exist") |
| @@ -121,6 +124,7 @@ pub(crate) fn get_helper(py: Python<'_>) -> &HelperFunctions { | ||
121 | 124 | call_handler, |
122 | 125 | call_batch_handler, |
123 | 126 | validate_body, |
127 | + parse_body, | |
124 | 128 | call_plugin_hook, |
125 | 129 | wrap_result, |
126 | 130 | pump_stream, |
127 | 131 | |
▼crates/justapi-py/src/request.rs
+46-2
| @@ -173,6 +173,10 @@ pub struct Request { | ||
173 | 173 | db_url_raw: Option<String>, |
174 | 174 | form_data: Option<Py<PyDict>>, |
175 | 175 | path_params_cached: Option<Py<PyDict>>, |
176 | + /// Parsed/validated body (a Python object) attached when `body_schema` was | |
177 | + /// validated on the fast path, so the handler receives the already-parsed | |
178 | + /// value instead of re-parsing raw bytes. `None` until set or first parsed. | |
179 | + parsed_body: Option<Py<PyAny>>, | |
176 | 180 | } |
177 | 181 | |
178 | 182 | impl Request { |
| @@ -205,7 +209,26 @@ impl Request { | ||
205 | 209 | http_version, |
206 | 210 | state, |
207 | 211 | }; |
208 | - Self { conn, body_raw: body, db_url_raw: db_url, form_data, path_params_cached: None } | |
212 | + Self { | |
213 | + conn, | |
214 | + body_raw: body, | |
215 | + db_url_raw: db_url, | |
216 | + form_data, | |
217 | + path_params_cached: None, | |
218 | + parsed_body: None, | |
219 | + } | |
220 | + } | |
221 | + | |
222 | + /// Attach a parsed/validated body object (called from the dispatch layer | |
223 | + /// after `body_schema` validation succeeds). Returns the cached value. | |
224 | + pub fn set_parsed_body(&mut self, py: Python<'_>, value: Bound<'_, PyAny>) { | |
225 | + self.parsed_body = Some(value.unbind()); | |
226 | + let _ = py; | |
227 | + } | |
228 | + | |
229 | + /// Borrow the cached parsed body if present, else `None`. | |
230 | + pub fn parsed_body(&self, py: Python<'_>) -> Option<Py<PyAny>> { | |
231 | + self.parsed_body.as_ref().map(|v| v.clone_ref(py)) | |
209 | 232 | } |
210 | 233 | } |
211 | 234 | |
| @@ -353,6 +376,11 @@ impl Request { | ||
353 | 376 | |
354 | 377 | #[pyo3(signature = ())] |
355 | 378 | fn json<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> { |
379 | + // If a `body_schema`-validated body was already parsed on the fast path, | |
380 | + // return that cached object directly (no re-parse, same identity). | |
381 | + if let Some(cached) = self.parsed_body(py) { | |
382 | + return Ok(cached.into_bound(py)); | |
383 | + } | |
356 | 384 | // Pure synchronous parse: returns the parsed value directly so it works in |
357 | 385 | // both sync and async handlers without requiring a running event loop. |
358 | 386 | let json_module = py.import("json")?; |
| @@ -362,6 +390,13 @@ impl Request { | ||
362 | 390 | Ok(parsed) |
363 | 391 | } |
364 | 392 | |
393 | + /// The body parsed and validated on the `body_schema` fast path. Returns | |
394 | + /// `None` when the route registered no schema or the body was not JSON. | |
395 | + #[getter] | |
396 | + fn validated_body<'py>(&self, py: Python<'py>) -> PyResult<Py<PyAny>> { | |
397 | + Ok(self.parsed_body(py).unwrap_or_else(|| py.None())) | |
398 | + } | |
399 | + | |
365 | 400 | #[pyo3(signature = ())] |
366 | 401 | fn form(&self, py: Python<'_>) -> PyResult<Py<PyAny>> { |
367 | 402 | // Pure synchronous parse: returns the form dict directly so it works in |
| @@ -455,7 +490,16 @@ impl Request { | ||
455 | 490 | "headers" => self.headers(py), |
456 | 491 | "query_params" => self.query_params(py), |
457 | 492 | "cookies" => self.cookies(py), |
458 | - "body" => Ok(PyBytes::new(py, &self.body_raw).into_any().unbind()), | |
493 | + "body" => { | |
494 | + // When a `body_schema` was validated on the fast path, return the | |
495 | + // parsed object instead of raw bytes. `request["body"]` semantics | |
496 | + // then match `request.json()` for schema-registered routes. | |
497 | + if let Some(cached) = self.parsed_body(py) { | |
498 | + Ok(cached) | |
499 | + } else { | |
500 | + Ok(PyBytes::new(py, &self.body_raw).into_any().unbind()) | |
501 | + } | |
502 | + } | |
459 | 503 | "db_url" => match &self.db_url_raw { |
460 | 504 | Some(db) => Ok(PyString::new(py, db).into_any().unbind()), |
461 | 505 | None => Ok(py.None()), |
462 | 506 | |