p1: stabilize route overriding, app.graphql, add_middleware, and PyO3 native handlers

swadhinbiswas committed 3 months ago
7834e19
1 parent be6133d
commit 7834e19
15 files changed
+1233 -194
▼crates/justapi-py/pyproject.toml
+7-0
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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, &params_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, &params_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
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, &params_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, &params_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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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