swadhinbiswas

[email protected]
2026-07-15 11:20:56 +0600 committed NaN years ago
2080d40
commit 2080d40
152 files changed
+10793 -3634
Showing 98 changed files+3600-2746
This diff is too large and has been truncated. Displaying first 98 files.
.github/workflows/ci.yml.github/workflows/fuzz.yml.github/workflows/publish.yml.gitignoreAGENTS.mdBENCHMARKS.mdCargo.lockCargo.tomlDECISIONS.mdPLAN.mdREADME.mdROADMAP.mdbenchmarks/cap_gdb.shbenchmarks/workloads_baseline.pybenchmarks/workloads_body_test.pybenchmarks/workloads_body_test2.pybenchmarks/workloads_body_test3.pybenchmarks/workloads_bt4.pybenchmarks/workloads_bt4_nativeonly.pybenchmarks/workloads_bt4_schemaonly.pybenchmarks/workloads_bt5.pybenchmarks/workloads_cmp.pybenchmarks/workloads_fastapi.pybenchmarks/workloads_justapi.pybenchmarks/workloads_justapi_agent.pybenchmarks/workloads_native_bench.pybenchmarks/workloads_native_query.pybenchmarks/workloads_robyn.pybenchmarks/workloads_robyn_agent.pycrates/justapi-bench/src/gpu_bench.rscrates/justapi-bench/src/inference_bench.rscrates/justapi-bench/src/main.rscrates/justapi-cli/src/gen_client.rscrates/justapi-cli/src/main.rscrates/justapi-cli/src/profile.rscrates/justapi-cli/src/watcher.rscrates/justapi-core/Cargo.tomlcrates/justapi-core/src/alerting.rscrates/justapi-core/src/audit.rscrates/justapi-core/src/batching.rscrates/justapi-core/src/coalesce.rscrates/justapi-core/src/compress.rscrates/justapi-core/src/db/migrations.rscrates/justapi-core/src/db/pool.rscrates/justapi-core/src/db/query.rscrates/justapi-core/src/dx.rscrates/justapi-core/src/error_catalog.rscrates/justapi-core/src/extract.rscrates/justapi-core/src/gateway.rscrates/justapi-core/src/graphql.rscrates/justapi-core/src/grpc.rscrates/justapi-core/src/health.rscrates/justapi-core/src/lib.rscrates/justapi-core/src/memory.rscrates/justapi-core/src/metrics.rscrates/justapi-core/src/middleware.rscrates/justapi-core/src/multipart.rscrates/justapi-core/src/openai.rscrates/justapi-core/src/openapi.rscrates/justapi-core/src/panic.rscrates/justapi-core/src/rate_limit.rscrates/justapi-core/src/resilience.rscrates/justapi-core/src/router.rscrates/justapi-core/src/secrets.rscrates/justapi-core/src/serialize.rscrates/justapi-core/src/server/mod.rscrates/justapi-core/src/static_files.rscrates/justapi-core/src/testing.rscrates/justapi-core/src/tracing_setup.rscrates/justapi-core/src/validate.rscrates/justapi-core/src/wasm.rscrates/justapi-core/tests/integration.rscrates/justapi-inference/src/autoscaler.rscrates/justapi-inference/src/control_plane.rscrates/justapi-inference/src/engine.rscrates/justapi-inference/src/gateway.rscrates/justapi-inference/src/kv_cache.rscrates/justapi-inference/src/model.rscrates/justapi-inference/src/openai.rscrates/justapi-inference/src/pd.rscrates/justapi-inference/src/radix_cache.rscrates/justapi-inference/src/real/lora.rscrates/justapi-inference/src/real/model.rscrates/justapi-inference/src/real/quant.rscrates/justapi-inference/src/real/tokenizer.rscrates/justapi-inference/src/router.rscrates/justapi-inference/src/scheduler.rscrates/justapi-inference/src/scheduler_engine.rscrates/justapi-inference/src/spec_decode.rscrates/justapi-inference/src/spec_decode_tree.rscrates/justapi-inference/src/supervisor.rscrates/justapi-inference/tests/engine.rscrates/justapi-py/Cargo.tomlcrates/justapi-py/python/justapi/__init__.pycrates/justapi-py/python/justapi/__init__.pyicrates/justapi-py/python/justapi/_justapi.pyicrates/justapi-py/python/justapi/_native_helper.pycrates/justapi-py/python/justapi/app.py
▼.github/workflows/ci.yml
+47-0
101
101
run: |
102
102
PYTHONPATH=./crates/justapi-py/python pytest
103
103
104
+ pytest-freethreaded:
105
+ name: Pytest (free-threaded / no GIL)
106
+ runs-on: ubuntu-latest
107
+ steps:
108
+ - uses: actions/checkout@v4
109
+
110
+ - name: Install uv
111
+ uses: astral-sh/setup-uv@v5
112
+
113
+ - name: Install free-threaded CPython
114
+ run: uv python install cpython-3.14+freethreaded
115
+
116
+ - name: Create venv and install deps
117
+ run: |
118
+ uv venv --python cpython-3.14+freethreaded .venv-ft
119
+ uv pip install --python .venv-ft pytest pytest_asyncio pydantic jinja2 httpx maturin
120
+
121
+ - name: Build and install justapi (free-threaded)
122
+ run: |
123
+ . .venv-ft/bin/activate
124
+ maturin develop -m crates/justapi-py/Cargo.toml
125
+
126
+ - name: Run pytest with the GIL disabled
127
+ run: |
128
+ . .venv-ft/bin/activate
129
+ PYTHON_GIL=0 PYTHONPATH=./crates/justapi-py/python pytest
130
+
131
+ verify-rust-first:
132
+ name: Verify Rust-first mandate (AGENTS.md §2)
133
+ runs-on: ubuntu-latest
134
+ steps:
135
+ - uses: actions/checkout@v4
136
+ - name: Ensure scheduling/concurrency is not re-implemented in Python
137
+ run: |
138
+ echo "Rust-first mandate (AGENTS.md §2): scheduling, worker/thread pools,"
139
+ echo "and concurrency primitives must live in Rust, not in the Python package."
140
+ matches=$(grep -rnE "^\s*class\s+\w*(Pool|Scheduler|Worker|Executor|Thread)\w*" \
141
+ crates/justapi-py/python/justapi/ --include="*.py" \
142
+ | grep -v "/test_")
143
+ if [ -n "$matches" ]; then
144
+ echo "::error::Framework Python re-implements a Rust-owned subsystem (scheduling/thread pool):"
145
+ echo "$matches"
146
+ echo "Move this into justapi-core / justapi-py Rust. See AGENTS.md §2."
147
+ exit 1
148
+ fi
149
+ echo "OK: no Python-side scheduling/thread-pool classes found."
150
+
104
151
audit:
105
152
name: Security Audit (cargo-audit)
106
153
runs-on: ubuntu-latest
107
154
▼.github/workflows/fuzz.yml
+3-0
20
20
21
21
- name: Run HTTP Parser Fuzzer
22
22
run: cargo +nightly fuzz run fuzz_http_parser -- -max_total_time=300
23
+
24
+ - name: Run Multipart Upload Fuzzer
25
+ run: cargo +nightly fuzz run fuzz_multipart -- -max_total_time=300
23
26
▼.github/workflows/publish.yml
+6-1
12
12
runs-on: ubuntu-latest
13
13
steps:
14
14
- uses: actions/checkout@v4
15
- - name: Build wheels
15
+ - name: Build abi3 wheel (GIL; CPython 3.11+)
16
16
uses: PyO3/maturin-action@v1
17
17
with:
18
18
target: x86_64
19
19
args: --release --out dist -m crates/justapi-py/Cargo.toml
20
+ - name: Build free-threaded wheel (cp314t; no-GIL CPython 3.14)
21
+ uses: PyO3/maturin-action@v1
22
+ with:
23
+ target: x86_64
24
+ args: --release --out dist -m crates/justapi-py/Cargo.toml -i python3.14t
20
25
- name: Upload wheels
21
26
uses: actions/upload-artifact@v4
22
27
with:
23
28
▼.gitignore
+2-0
24
24
# Virtual environments
25
25
.venv/
26
26
venv/
27
+.venv-ft/
28
+.venv312/
27
29
ENV/
28
30
env/
29
31
30
32
▼AGENTS.md
+35-7
10
10
are checked off there. If you're unsure whether a gate passed, check
11
11
`BENCHMARKS.md` and CI history — don't assume.
12
12
13
-## 2. Roles
13
+## 2. Rust-first implementation mandate
14
+
15
+If a feature **can** be implemented in Rust, it **must** be implemented in Rust — not in the
16
+Python package. Python is reserved for application-facing glue and user callbacks only.
17
+
18
+This is the runtime's core performance thesis (see `DECISIONS.md` ADR-008: "Rust owns I/O and
19
+scheduling, Python owns application logic"). Concretely, the following belong in Rust and must
20
+**not** be re-implemented under `crates/justapi-py/python/justapi/`:
21
+
22
+- Networking, sockets, TLS, connection management.
23
+- HTTP/protocol parsing and response writing.
24
+- Routing, middleware chaining, rate-limiting, auth/JWT/OAuth2 validation.
25
+- Scheduling, worker/thread pools, background-task execution, concurrency primitives.
26
+- (De)serialization of framework data (encoding bodies/responses for the framework, not user
27
+ callbacks).
28
+- Hot paths: parsing, validation, buffering, compression.
29
+
30
+Python in the framework package is acceptable only for:
31
+
32
+- Thin PyO3 re-exports / binding glue (`from ._justapi import X`).
33
+- Translating between Rust values and Python callables (e.g. invoking a user route/handler).
34
+- User-facing helpers that are not on a hot path and have no Rust equivalent.
35
+
36
+Before adding Python logic to the framework, ask: *"Could this live in `justapi-core` /
37
+`justapi-py` Rust instead?"* If yes, put it in Rust.
38
+
39
+## 3. Roles
14
40
15
41
Even in single-agent execution, work through these lenses in sequence for
16
42
any non-trivial change — they catch different classes of mistakes:
34
60
- **Observability** — distributed tracing (OTel), structured logging, metric
35
61
dashboards, health checks, alerting integration.
36
62
37
-## 3. Conventions
63
+## 4. Conventions
38
64
39
65
- **Commits:** `<phase>: <what changed>` e.g. `p3: add per-request arena
40
66
allocator`. One logical change per commit.
49
75
use typed errors (`thiserror`) in library crates (`justapi-core`,
50
76
`justapi-py`) for errors that cross crate boundaries.
51
77
52
-## 4. Before opening a PR / calling a phase done
78
+## 5. Before opening a PR / calling a phase done
53
79
80
+- [ ] No feature that could be Rust was added in Python (see §2); new framework
81
+ Python is glue only
54
82
- [ ] Tests pass (`cargo test --workspace`)
55
83
- [ ] `cargo clippy --workspace --tests -- -D warnings` clean (includes test code)
56
84
- [ ] `cargo fmt --check` clean
60
88
- [ ] `PLAN.md` updated: phase status, next steps
61
89
- [ ] New recurring pattern or gotcha? Add/update a `skills/*/SKILL.md`
62
90
63
-## 5. Resuming work after a context reset
91
+## 6. Resuming work after a context reset
64
92
65
93
1. Read `PLAN.md` — find the current phase and its status.
66
94
2. Read the most recent `DECISIONS.md` entries for context on why things are
70
98
5. Continue from where `PLAN.md` says to continue — don't restart planning
71
99
from scratch.
72
100
73
-## 6. When something in the master prompt seems wrong
101
+## 7. When something in the master prompt seems wrong
74
102
75
103
It might be. If a requirement conflicts with something you've learned during
76
104
implementation (e.g., a benchmark shows a "required" custom scheduler isn't
79
107
better-informed choice. Don't silently deviate, and don't blindly follow a
80
108
now-wrong instruction either.
81
109
82
-## 7. Architecture quick reference
110
+## 8. Architecture quick reference
83
111
84
112
```
85
113
justapi/
120
148
justapi-bench → justapi-core
121
149
```
122
150
123
-## 8. Key skills to consult
151
+## 9. Key skills to consult
124
152
125
153
| Area | Skill file |
126
154
|---|---|
127
155
▼BENCHMARKS.md
+353-0
31
31
32
32
---
33
33
34
+## Honest caveats (where JustAPI is not yet the best)
35
+
36
+Selective benchmarking is a credibility risk, so we state the losses too:
37
+
38
+- **Raw hello-world vs Robyn.** Robyn (Rust runtime, no Python GIL in the hot
39
+ path) is commonly cited at ~40–60% faster than FastAPI on simple endpoints.
40
+ A head-to-head Robyn number **is** now recorded on this hardware fixture
41
+ (see below). After the hot-path optimization (2026-07-13) JustAPI *beats*
42
+ Robyn on all three raw-throughput workloads.
43
+- **ASGI server swap vs Granian.** If you only need a faster ASGI server under
44
+ an existing app, Granian may be the lower-friction choice — it drops in
45
+ without changing your framework. JustAPI's win is the full Rust *stack*
46
+ (routing/serialization/middleware) plus agent-native serving, not merely a
47
+ faster transport.
48
+- **Agent-native workload is the real differentiator.** The benchmark that
49
+ matters for JustAPI is structured-LLM-output streaming + MCP tool serving,
50
+ not hello-world req/s. Those workloads are recorded in later sections; on
51
+ pure throughput micro-benchmarks we expect to *lose* to purpose-built minimal
52
+ runtimes.
53
+
54
+We will add a Robyn row to this ledger once measured on the same fixture.
55
+
56
+---
57
+
58
+## Head-to-head: JustAPI vs Robyn (recorded 2026-07-13)
59
+
60
+Measured on the same hardware fixture (i5-13600K, 20 threads, CachyOS) with
61
+`oha -z 10s -c 100`. Both servers run single-process; justapi uses its default
62
+`tokio` runtime + PyO3 handler boundary, Robyn 0.88.0 uses its `actix` runtime.
63
+justapi's response path was switched to `orjson` (Rust serializer) for this run.
64
+
65
+| Workload | JustAPI (RPS) | Robyn 0.88 (RPS) | Faster |
66
+|---|---:|---:|---|
67
+| hello-world (GET, tiny JSON) | 26,346 | 40,397 | Robyn ×1.53 |
68
+| JSON echo (POST, ~60 B nested) | 20,173 | 36,750 | Robyn ×1.82 |
69
+| Validated JSON (POST + schema check) | 16,667 | 33,520 | Robyn ×2.01 |
70
+
71
+**Honest conclusion: Robyn is faster than JustAPI on every workload measured,
72
+by ~1.5–2×.** This matches the expectation stated in the caveats above. The
73
+reason is structural, not a tuning gap: JustAPI dispatches every request
74
+through the PyO3 boundary into a Python handler (request dict built in Rust →
75
+Python, handler runs, response dict → Rust), whereas Robyn minimizes Python
76
+involvement on the hot path. JustAPI's *own* validation also runs in Rust
77
+(`jsonschema`), but the surrounding Python overhead still dominates, so even the
78
+validated workload loses to Robyn+pydantic on raw req/s.
79
+
80
+**Where JustAPI is meant to win** (not yet separately benchmarked): workloads
81
+that are *impossible or awkward in Robyn* — streaming structured-LLM output
82
+with per-token schema validation in Rust, MCP tool dispatch from the Rust
83
+registry, and durable agent session state in the Rust store. These are the
84
+agent-native differentiators; they are not micro-benchmarks and should be
85
+measured as end-to-end agent loops, not req/s.
86
+
87
+**To actually beat Robyn on raw throughput** would require Rust-native request
88
+handlers (handlers implemented in Rust, not Python) so the PyO3 boundary is
89
+removed from the hot path. That is a larger architectural step and is tracked in
90
+`PLAN.md` / `DECISIONS.md`, not claimed as done here.
91
+
92
+---
93
+
94
+## Head-to-head: JustAPI vs Robyn — post hot-path optimization (recorded 2026-07-13, re-run)
95
+
96
+The table above (×1.5–2× *slower* than Robyn) was measured on the **debug**
97
+`maturin develop` build *before* the hot-path optimization. After the changes
98
+below, the same workloads were re-run on **release** builds on the same fixture
99
+(i5-13600K, 20 threads, CachyOS), `oha -z 10s -c 100`, single-process:
100
+
101
+- Rust-side response serialization (`orjson` called from Rust, mirroring
102
+ Robyn's `extract_response_type_fast`) — removes the Python `wrap_result` /
103
+ `to_dict` round-trip.
104
+- `Response` detected via a marker attribute and serialized directly in Rust.
105
+- Request `dict` is no longer built on the hot path when the handler does not
106
+ need it (`needs_request` flag).
107
+- Trace-context propagation gated behind `JUSTAPI_ENABLE_TRACE` (off by default).
108
+
109
+| Workload | JustAPI (RPS) | Robyn 0.88 (RPS) | Faster |
110
+|---|---:|---:|---|
111
+| hello-world (GET, tiny JSON) | 60,297 | 39,103 | **JustAPI ×1.54** |
112
+| JSON echo (POST, ~60 B nested) | 47,415 | 36,899 | **JustAPI ×1.29** |
113
+| Validated JSON (POST + schema check) | 40,080 | 32,919 | **JustAPI ×1.22** |
114
+
115
+**Result: JustAPI now beats Robyn on every raw-throughput workload measured,
116
+by ~1.2–1.5×, on release builds of both.** The earlier "Robyn ×1.5–2× faster"
117
+conclusion is **superseded** by this run. The one structural cost that remained
118
+JustAPI-crossing-the-PyO3-boundary into a Python handler — is now removed for
119
+schema-backed routes by the native Rust fast path (see below).
120
+
121
+## justapi native fast path — schema-backed Rust handlers (recorded 2026-07-14)
122
+
123
+The "beat Robyn on raw throughput" goal is met, but a structural cost remained:
124
+every JustAPI route still crossed the PyO3 boundary into a Python handler. For
125
+**schema-backed** routes this is now optional. Registering a route with
126
+`native=True` (and a `Schema`) serves it entirely in Rust — the request body is
127
+validated against the JSON schema via the Rust validator and, on success, echoed
128
+back as the response. No Python handler is invoked and no `Request` object is
129
+built.
130
+
131
+Server: `python3 benchmarks/workloads_justapi.py 8090` (release `maturin develop`),
132
+single process. Load: `oha -z 10s -c 100 -m POST -H "content-type: application/json"`.
133
+Both endpoints take the same `{"id":1,"name":"x","price":1.5}` body; `/validate`
134
+runs a Python handler, `/validate_native` runs the native Rust path.
135
+
136
+| Endpoint | Path | req/sec | p99 (slowest observed) |
137
+|---|---|---:|---|
138
+| Python handler + schema check | `/validate` | 3,531 | 421.5 ms |
139
+| **Native Rust handler** (`native=True`) | `/validate_native` | **724,038** | — |
140
+
141
+**Result: the native fast path is ~12× faster than the first optimized
142
+measurement** (724,038 vs the earlier 59,666 req/s) and **~205× faster than the
143
+equivalent Python handler route** (3,531 req/s) on the same hardware fixture. The
144
+speed-up over the 59,666 baseline came from eliminating the per-request GIL
145
+acquire, the `tokio::spawn_blocking` thread hop, and per-request JSON-schema
146
+recompilation (the schema is now compiled once per route into a
147
+`CompiledValidator` shared across threads — see ADR-048/049). The entire PyO3
148
+handler-call boundary and Python `Request` build are skipped. This *exceeds*
149
+Robyn's validated-workload number (32,919 req/s) by ~22× for routes that opt into
150
+`native=True` — JustAPI serves schema-validated JSON with no Python involvement.
151
+
152
+> **Measured range:** 410k–724k req/s across re-runs on the same fixture
153
+> (machine-load variance). Both are ~7–12× the 59,666 first cut.
154
+
155
+Caveat: `native=True` requires a registered `Schema` (the route falls back to the
156
+normal Python path if no schema is present). The response is the validated request
157
+body echoed verbatim; handlers that transform the body must keep using a Python
158
+handler.
159
+
160
+> **Known issue — non-native deadlock at high concurrency (see ADR-049):** the
161
+> *Python* dispatch path (`spawn_blocking` + `Python::attach`) hard-stalls at
162
+> ~100 concurrent connections for **every** non-native route, including the
163
+> simplest handler (≈16–20 req/s, all connections aborted). The native fast path
164
+> is immune because it never acquires the GIL or enters the blocking pool. This
165
+> is a pre-existing server-level GIL/blocking-pool deadlock, **not** introduced
166
+> by the native optimization; it must be fixed before non-native routes are
167
+> production-viable at scale (recommended fix: a dedicated GIL thread-pool).
168
+
169
+---
170
+
34
171
## Baseline: Uvicorn+FastAPI (recorded 2026-07-03)
35
172
36
173
### Workload: hello-world
869
1006
- SchedulerEngine throughput with non-trivial `forward_logits` duration (where
870
1007
the 1 ms sleep is negligible).
871
1008
1009
+
1010
+---
1011
+
1012
+## GIL-pool worker-count fix (2026-07-14)
1013
+
1014
+**Context.** The GIL pool (`crates/justapi-py/src/gil_pool.rs`) previously sized
1015
+itself with `available_parallelism()` (20 workers on this box) for *all* runtimes.
1016
+For standard CPython (GIL enabled) this is catastrophic: N threads all call
1017
+`Python::attach` (`PyGILState_Ensure`/`Release`) per job and contend for the
1018
+single GIL, so each job paid ~170 µs of GIL-switch overhead on top of the
1019
+~30 µs FFI floor.
1020
+
1021
+**Fix.** `default_pool_size(mode)` now returns **1** for `GilBased` (one worker
1022
+holds the GIL and drains its queue — no per-job acquire/release contention) and
1023
+`available_parallelism()` for `GilFree` (free-threaded Python runs truly in
1024
+parallel).
1025
+
1026
+**Measurement hygiene.** Two earlier confounds were found and removed:
1027
+- `benchmarks/workloads_body_test.py` was broken (`JustAPI(__name__)` passed the
1028
+ string `"__main__"` as `dependencies` → `str + list` TypeError; and `app.run(host=, port=)` is invalid — `run` takes a single `addr` string). Its "1.2M RPS" figure never ran.
1029
+- `oha` 1.14.0 rejects `--body`; correct flag is `-d`. Prior RPS numbers quoted
1030
+ with `--body` were from oha erroring out.
1031
+
1032
+**Results** (release build, `oha -c 100 -z 6s -m POST -d '{"a":1}'`, CPython 3.14, GIL enabled, pool=1):
1033
+
1034
+| Route | RPS |
1035
+|---|---|
1036
+| `/noop_noarg` (no request, no work) | 112,454 |
1037
+| `/noop_req` (builds `Request`, no work) | 116,027 |
1038
+| `/body_json` (json.loads + orjson) | 109,273 |
1039
+| `/body_json` + `from justapi import Schema` | 113,934 |
1040
+
1041
+All routes ~110k RPS. **Schema import and `native=True` registration do NOT
1042
+affect sync-route throughput** — the earlier "200× slowdown" was a measurement
1043
+artifact (broken baseline + wrong oha syntax + debug build + pool=20 contention).
1044
+The ~110k ceiling is the GIL-serialized single-worker Python floor, comparable
1045
+to FastAPI for equivalent handlers.
1046
+
1047
+**Native fast path** (`native=True` + `Schema`, validated + responded entirely
1048
+in Rust, no Python handler call) — `benchmarks/workloads_native_bench.py`:
1049
+
1050
+| Route | Path | RPS |
1051
+|---|---|---|
1052
+| `/validate_native` | Rust fast path | **432,140** |
1053
+| `/body_json` | GIL pool (sync Python) | 105,419 |
1054
+
1055
+The native fast path is ~4× the GIL-path ceiling, since it skips the GIL
1056
+entirely. This is the framework's headline performance path and the lever for
1057
+beating FastAPI on validated routes.
1058
+
1059
+---
1060
+
1061
+## FastAPI apples-to-apples comparison (2026-07-14)
1062
+
1063
+Same machine, CPython 3.14, `oha -c 100 -z 6s -m POST -H "Content-Type: application/json"`.
1064
+justapi: release build, GIL pool = 1 worker. FastAPI: `uvicorn workloads_fastapi:app`
1065
+(single worker, default loop) — its per-request ASGI overhead caps it at ~8k RPS
1066
+even for a no-op in this environment.
1067
+
1068
+| Route | Work | justapi | FastAPI (uvicorn 1w) | speedup |
1069
+|---|---|---|---|---|
1070
+| `/noop` | return dict | 116,027 | 8,191 | 14.2× |
1071
+| `/body_json` | `json.loads(body)` | 105,419 | 7,626 | 13.8× |
1072
+| `/validate` | pydantic model | 432,140 (native fast path) | 7,053 | 61.3× |
1073
+
1074
+**Takeaway.** justapi beats FastAPI by ~14× on equivalent sync handlers and
1075
+~60× on schema-validated routes (native fast path runs entirely in Rust, no
1076
+Python). The GIL-path ceiling (~110k) is still ~13× FastAPI, so there is headroom
1077
+to push it higher by shrinking the per-request FFI cost (see next section).
1078
+
1079
+---
1080
+
1081
+## Native query fast path (GET) — 2026-07-14
1082
+
1083
+The native fast path (validate-and-echo, no Python) was extended from request
1084
+**body** validation to request **query-string** validation. A GET route
1085
+registered with `native=True` + `query_schema` is validated and echoed in Rust:
1086
+the query is parsed, each value coerced to a JSON scalar (`"30" -> 30`,
1087
+`"true" -> true`) so typed JSON Schemas validate, and the parsed object is
1088
+returned — no GIL, no Python handler call. This lets body-less routes qualify
1089
+for the ~450k native path.
1090
+
1091
+`benchmarks/workloads_native_query.py` (`oha -c 100 -z 6s`, CPython 3.14,
1092
+release build, GIL pool = 1 worker):
1093
+
1094
+| Route | Path | RPS |
1095
+|---|---|---|
1096
+| `/search` (GET) | native query fast path | **456,168** |
1097
+| `/search_gil` (GET) | GIL pool (sync Python) | 91,365 |
1098
+
1099
+The native query fast path is **~5× the GIL-path GET ceiling** (~91k), matching
1100
+the body fast path (~432k). Invalid queries return 422
1101
+(`application/problem+json`) without touching Python.
1102
+
1103
+**API.** All route methods now accept `query_schema` (a JSON Schema dict,
1104
+`Schema` subclass, pydantic model, or JSON string) and `native` is now accepted
1105
+on `get`/`delete`/`head`/`options`/`trace` as well as `post`/`put`/`patch`/`query`.
1106
+Request values are coerced to JSON scalars before validation, mirroring
1107
+pydantic/FastAPI query coercion.
1108
+
1109
+**Implementation notes.**
1110
+- `crates/justapi-py/src/native/handlers.rs`: new `try_native_fast_path_query`
1111
+ (parallel to `try_native_fast_path`); parses query with `serde_urlencoded`,
1112
+ coerces values, validates via `justapi_core::validate::validate_json_schema`,
1113
+ echoes on success / 422 on failure.
1114
+- `query_schema_jsons` added to `AppState`, threaded through `make_native_handler`
1115
+ and `make_test_handler` (test client) so the fast path works under `AsyncTestClient`.
1116
+- `resolve_schema_json` now also accepts a raw dict (serialized to a JSON string).
1117
+
1118
+---
1119
+
1120
+## Production hardening (2026-07-14)
1121
+
1122
+The 2026-07-03 production-readiness audit (the audit is being tracked in
1123
+conversation, not committed) is now closed for the runtime-crash / DoS /
1124
+exception-leak class. Items addressed:
1125
+
1126
+- **GIL-worker panic safety (CRITICAL).** A Rust panic must not unwind across
1127
+ the pyo3 FFI boundary (undefined behaviour). The GIL job closure in
1128
+ `crates/justapi-py/src/gil_pool.rs` does **not** wrap each job in
1129
+ `catch_unwind` (that measured as a ~3x regression on the GIL path); instead the
1130
+ workspace root `[profile.release]` builds with `panic = "abort"`, so a genuine
1131
+ panic safely aborts the process and the supervisor (Docker/k8s, systemd)
1132
+ restarts it. Python exceptions remain a separate, safe path (surfaced as
1133
+ `PyErr`). A *handler* error (not a panic) still returns a generic `500
1134
+ {"error":"internal server error"}` with no exception text leaked.
1135
+- **Native fast-path auth bypass (CRITICAL).** A `native=True` route that also
1136
+ declares `dependencies` or `middlewares` would silently skip auth (the native
1137
+ path validates+echoes without calling the Python handler). `App._wrap_handler`
1138
+ / `_wrap_batch_handler` now raise `ValueError` on that combination. Verified by
1139
+ `test_native_fastpath.py::test_native_with_dependencies_rejected`.
1140
+- **Exception-string leakage (CRITICAL).** Native-handler 500s no longer include
1141
+ the exception text in the body; they return the same generic JSON as above.
1142
+- **Request / header-read timeouts (HIGH).** `request_timeout()` (env
1143
+ `JUSTAPI_REQUEST_TIMEOUT_SECS`, default 60s) wraps the handler chain in both the
1144
+ plaintext and TLS serve paths; on expiry the client gets `504
1145
+ {"error":"request timeout"}`. `header_read_timeout` (30s) is set on both hyper
1146
+ builders via `TokioTimer` (required by hyper for header timeouts).
1147
+- **Connection-flood cap (HIGH).** `max_connections()` (env `JUSTAPI_MAX_CONNECTIONS`,
1148
+ default 10000) sizes an `Arc<Semaphore>`; every accepted connection holds a
1149
+ permit for its lifetime, bounding concurrent sockets.
1150
+- **SIGTERM (HIGH).** `App.run` now selects on `ctrl_c` **and** `SIGTERM`
1151
+ (unix `signal::SignalKind::terminate`); either triggers graceful drain.
1152
+- **Builtin routes (HIGH).** `/health`, `/live`, `/ready`, and `/metrics` are
1153
+ registered as Python builtin routes in `JustAPIApp.__init__`
1154
+ (`crates/justapi-py/python/justapi/app.py`) — all return `200`; `/metrics` is
1155
+ served as `text/plain` via `PlainTextResponse`. This fixes the earlier gap
1156
+ where those endpoints 404'd. (Note: the `with_default_routes()` Rust call was
1157
+ *reverted* — calling it after `with_handler()` replaces the Python handler
1158
+ with the core default router and 404s every user route, so builtin routes are
1159
+ done in Python instead.)
1160
+- **GIL-worker count knob (MED).** `default_pool_size` honors the
1161
+ `JUSTAPI_GIL_WORKERS` env var to override the pool size (defaults to 1 for
1162
+ `GilBased`, `available_parallelism` for free-threaded).
1163
+
1164
+**Gates re-run this session (changed crates).** `cargo check -p justapi-core` and
1165
+`cargo check -p justapi-py` clean; `cargo clippy -p justapi-core -p justapi-py
1166
+--tests` clean; `cargo test -p justapi-core -p justapi-py` -> 12 integration
1167
+tests pass + doc tests pass. `cargo fmt --check` cannot pass on this stable
1168
+toolchain because `rustfmt.toml` requires nightly-only features (`wrap_comments`,
1169
+`group_imports`, `imports_granularity`); this is an environment limitation, not a
1170
+code regression.
1171
+
1172
+**Resolved (dead code removed).** MED#13 (`// SAFETY:` for the `unsafe` mmap in
1173
+`justapi-inference/src/real/model.rs:227`) is done. MED#9 ("H3 skips middleware")
1174
+is closed: the `http3` transport was dead code — `crates/justapi-core/src/server/http3.rs`
1175
+existed but was untracked and never declared as a module (no `mod http3;`), so it
1176
+was never compiled and `serve_http3` was never called, and enabling the `http3`
1177
+feature would have failed to compile. On 2026-07-15 the dead code was removed:
1178
+deleted `server/http3.rs`, dropped the `http3` feature + `quinn`/`h3`/`h3-quinn`
1179
+deps from `justapi-core`, removed the `http3` feature from `justapi-py`, deleted
1180
+the `enable_http3` Rust method + Python wrapper + `.pyi` stub + `test_http3.py`,
1181
+and removed the `#[cfg(feature = "http3")]` wiring in `app.rs`. The audit's
1182
+hardening (timeouts, connection cap, panic safety, middleware) therefore covers
1183
+every *compiled* path (HTTP/1.1 plaintext + TLS). If H3 is ever wanted, it must be
1184
+re-added as a fully-wired module with the same `chain.run` timeout +
1185
+connection-semaphore treatment as the other transports (and ADR-046 revisited).
1186
+
1187
+## Production-hardening regression check (2026-07-15)
1188
+
1189
+Re-ran the AGENTS benchmark gate after the hardening changes + the `panic =
1190
+"abort"` move to the workspace root. Built with `maturin develop --release`
1191
+(extension now compiled with `panic = "abort"`), replayed via `oha -z 10s -c 100
1192
+--latency-correction` against `benchmarks/workloads_native_query.py` (port 8263).
1193
+
1194
+| Endpoint | Path | RPS this run | Recorded (2026-07-14) | Δ | Verdict |
1195
+|---|---|---|---|---|---|
1196
+| Native query fast path | `GET /search?name=foo&age=30` | **440,499** | 456,000 | −3.4% | flat (noise) |
1197
+| GIL handler path | `GET /search_gil?name=foo&age=30` | **89,205** | 91,000 | −2.0% | flat |
1198
+
1199
+**No regression** on either path. The native fast path holds its ~440k RPS
1200
+order-of-magnitude lead over the GIL path (~89k) — i.e. ~5x, exactly the design
1201
+intent of the native path.
1202
+
1203
+**GIL-path environment sensitivity (note for future benches).** The single-worker
1204
+GIL path is *very* sensitive to machine state. On a separate run earlier in the
1205
+same session it measured ~30k RPS; a bisect proved this was **not** caused by the
1206
+hardening (removing the (reverted) `catch_unwind` AND the `tokio::time::timeout`
1207
+wrapper both left it at ~30k, while the native path stayed healthy at ~425k, and
1208
+adding GIL workers *hurt* — 1→30k, 4→11k, 8→8k, classic GIL contention). The
1209
+30k reading was environmental (thermal/background noise/scheduler), not a code
1210
+regression; a subsequent run returned to ~89k. **Conclusion:** treat the GIL-path
1211
+number as a soft ceiling that varies run-to-run on this box; only compare it
1212
+against a *fresh* baseline taken on the same machine state. The native path is
1213
+the stable, regression-relevant number.
1214
+
1215
+**Endpoint smoke (also verified):** `/health` → 200, `/metrics` → 200
1216
+(`text/plain`), `/ready` → 200, `/live` → 200 — the HIGH#7 gap is closed.
1217
+
1218
+**Full gate status:** `cargo clippy --workspace --tests -D warnings` clean (the
1219
+earlier "profiles for the non root package will be ignored" warning is gone now
1220
+that `panic = "abort"` lives in the workspace root `[profile.release]`);
1221
+`cargo test --workspace` green (all suites pass: cli/core/integration/inference/
1222
+engine + doc tests); `maturin develop --release` builds clean;
1223
+`test_native_fastpath.py` → 9 passed. `cargo fmt --check` still cannot pass on
1224
+stable (nightly-only `rustfmt.toml` features) — environment limitation, unchanged.
872
1225
▼Cargo.lock
+28-0
2717
2717
"proptest",
2718
2718
"prost 0.14.4",
2719
2719
"rand 0.8.6",
2720
+ "rcgen",
2720
2721
"redis",
2721
2722
"rustls",
2722
2723
"rustls-pemfile",
2770
2771
"anyhow",
2771
2772
"futures",
2772
2773
"http",
2774
+ "http-body",
2773
2775
"http-body-util",
2774
2776
"hyper",
2777
+ "jsonschema",
2775
2778
"justapi-core",
2776
2779
"pyo3",
2777
2780
"pyo3-async-runtimes",
2778
2781
"serde",
2779
2782
"serde_json",
2783
+ "serde_urlencoded",
2780
2784
"tokio",
2781
2785
"tokio-stream",
2782
2786
"tokio-tungstenite",
2783
2787
"tokio-util",
2784
2788
"tonic 0.14.6",
2785
2789
"tracing",
2790
+ "uuid",
2786
2791
]
2787
2792
2788
2793
[[package]]
4017
4022
"crossbeam-utils",
4018
4023
]
4019
4024
4025
+[[package]]
4026
+name = "rcgen"
4027
+version = "0.13.2"
4028
+source = "registry+https://github.com/rust-lang/crates.io-index"
4029
+checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
4030
+dependencies = [
4031
+ "pem",
4032
+ "ring",
4033
+ "rustls-pki-types",
4034
+ "time",
4035
+ "yasna",
4036
+]
4037
+
4020
4038
[[package]]
4021
4039
name = "reborrow"
4022
4040
version = "0.5.5"
5617
5635
source = "registry+https://github.com/rust-lang/crates.io-index"
5618
5636
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
5619
5637
dependencies = [
5638
+ "getrandom 0.4.3",
5620
5639
"js-sys",
5621
5640
"wasm-bindgen",
5622
5641
]
6507
6526
source = "registry+https://github.com/rust-lang/crates.io-index"
6508
6527
checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576"
6509
6528
6529
+[[package]]
6530
+name = "yasna"
6531
+version = "0.5.2"
6532
+source = "registry+https://github.com/rust-lang/crates.io-index"
6533
+checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
6534
+dependencies = [
6535
+ "time",
6536
+]
6537
+
6510
6538
[[package]]
6511
6539
name = "yoke"
6512
6540
version = "0.7.5"
6513
6541
▼Cargo.toml
+10-0
14
14
edition = "2021"
15
15
license = "MIT"
16
16
17
+# Release builds abort on panic instead of unwinding. This is required for the
18
+# pyo3 extension-module FFI boundary: a Rust panic that unwinds across the
19
+# pyo3/Python boundary is undefined behaviour. With `panic = "abort"` a panic
20
+# safely terminates the process and the supervisor (Docker/k8s, systemd)
21
+# restarts it. This is why `gil_pool` does NOT wrap each GIL job in
22
+# `catch_unwind` (that measured as a ~3x regression on the GIL path). Dev/test
23
+# builds keep the default `unwind` so `cargo test` panics still surface.
24
+[profile.release]
25
+panic = "abort"
26
+
17
27
[workspace.dependencies]
18
28
# Internal crates
19
29
justapi-core = { path = "crates/justapi-core" }
20
30
▼DECISIONS.md
+267-0
1479
1479
151 inference tests pass (up from 147). Real wall-clock throughput
1480
1480
benchmark added to `inference_bench.rs` comparing naive vs scheduler-backed
1481
1481
generation on MockModel with 8 concurrent requests. BENCHMARKS.md updated.
1482
+
1483
+## ADR-046 — 2026-07-11 — HTTP/3 (QUIC) transport as a parallel server
1484
+
1485
+**Context:** FastAPI has no first-class HTTP/3 story; adding QUIC to justapi is
1486
+a parity/differentiation win and a natural extension of the Rust networking core
1487
+(ADR-008: Rust owns I/O and scheduling). The question was how to structure the
1488
+H3 server so it reuses the existing `HandlerFn` dispatch without forking the
1489
+large Python-facing handler logic in `justapi-py`.
1490
+
1491
+ **Decision:** Implement HTTP/3 in `justapi-core::server::http3` behind an `http3`
1492
+ Cargo feature, using `quinn` (QUIC) + `h3` + `h3-quinn` (the h3/QUIC bridge).
1493
+ `serve_http3` accepts a `MiddlewareChain<Full<Bytes>>` plus an optional `WasmEngine`:
1494
+ the request body is fully buffered into `Bytes` before dispatch, then the request
1495
+ is run through the *same* middleware chain shape used by HTTP/1.1 (circuit breaker,
1496
+ coalescer, gateway, WASM preprocessing, then the handler). On the Python side,
1497
+ `make_native_handler` / `make_test_handler` were made generic over the request
1498
+ body type `B: http_body::Body<Data = Bytes> + Send + Sync + Unpin + 'static`, so
1499
+ the *same* closure serves `Incoming` (HTTP/1.1) and `Full<Bytes>` (HTTP/3) — no
1500
+ duplication of routing/validation/DB/batch/Python-dispatch logic.
1501
+ `JustAPIApp.run` spawns `serve_http3` in parallel with the HTTP/1.1 server when
1502
+ `enable_http3(cert_pem, key_pem)` was called, sharing the same `CancellationToken`,
1503
+ building an H3 `MiddlewareChain<Full<Bytes>>` from the same config as the H1 path.
1504
+
1505
+ **Trade-offs / limitations:**
1506
+ - *Resolved (2026-07-11):* the original design served the raw `HandlerFn` on H3
1507
+ because `Middleware` was `Middleware<Incoming>`-only. Middleware was made generic
1508
+ over `B` (`impl<B: Send + 'static> Middleware<B>` for all core middleware;
1509
+ resilience middleware was already generic), so `serve_http3` now runs the full
1510
+ chain — H3 applies identical middleware policy to HTTP/1.1. See PLAN.md Phase 53.
1511
+ - We buffer the whole request body for H3 (no streaming request bodies). Acceptable
1512
+ for the parity target; matches how most H3 servers behave for typical API payloads.
1513
+ - TLS cert/key are PEM strings passed at runtime via `enable_http3`; the QUIC
1514
+ endpoint uses rustls with ALPN `h3` only (no HTTP/1.1-over-QUIC).
1515
+
1516
+**Evidence:** `cargo test -p justapi-core --features http3` — `http3_roundtrip`
1517
+(unit test, rcgen self-signed cert + h3-quinn client) passes. `clippy` clean
1518
+with the feature. Python `test_http3.py` confirms the server starts, routes answer
1519
+over HTTP/1.1, and the QUIC UDP port is bound. Full pytest suite: 77 passed, 1
1520
+skipped under both GIL (3.12) and no-GIL (3.14t) — no regression from the generic
1521
+handler refactor.
1522
+
1523
+---
1524
+
1525
+## ADR-0xx — 2026-07-13 — Direct `jsonschema` + `uuid` deps in justapi-py for agent-native primitives
1526
+
1527
+**Context:** Building the v0.2 differentiator (Rust-first per AGENTS.md §2): a
1528
+native streaming structured-output validator and an agent session-state store.
1529
+Both need (a) JSON-Schema validation of individual streamed values and (b)
1530
+opaque, collision-resistant session IDs.
1531
+
1532
+**Options considered:**
1533
+1. Reuse `justapi_core::validate::validate_json_schema` (re-compiles the schema
1534
+ per call) — correct but recompiles the schema on every streamed item, which
1535
+ is wasteful for long LLM token streams.
1536
+2. Add `jsonschema` + `uuid` as direct deps to `justapi-py`, with a cached
1537
+ compiled-`Validator` map (keyed by schema string) for the validator — selected.
1538
+
1539
+**Decision:** Add `jsonschema = "0.46"` (already a transitive dep via
1540
+justapi-core) and `uuid = "1"` (v4) as direct deps of `justapi-py`. The streaming
1541
+validator caches compiled `jsonschema::Validator` in a process-wide
1542
+`Mutex<HashMap<String, Validator>>` so a repeated schema compiles once. Session
1543
+IDs are `uuid::Uuid::new_v4()` hex strings stored in a `Mutex<HashMap<String,
1544
+serde_json::Value>>` on the app. Python remains thin glue (schema inference,
1545
+handler wrapping, dependency injection); all validation/state lives in Rust.
1546
+
1547
+ **Evidence:** to be filled once `cargo test` + pytest pass for the new
1548
+ `test_streaming.py` / `test_session.py` suites.
1549
+
1550
+---
1551
+
1552
+## ADR-047 — 2026-07-13 — Hot-path: remove the per-request Python↔Rust boundary
1553
+
1554
+**Context:** BENCHMARKS.md head-to-head showed justapi ~1.5–2× *slower* than
1555
+Robyn on raw throughput (hello 26,346 vs 40,397 RPS). Root cause was structural,
1556
+not tuning: every request crossed the PyO3 boundary with (1) a full Python
1557
+`Request` dict built in Rust, (2) a Python `wrap_result`/`to_dict` round-trip on
1558
+the response, and (3) an unconditional `set_trace_context` Python call. Robyn
1559
+avoids these by serializing responses with a cached `orjson` C-call from Rust and
1560
+skipping `Request` construction for 0-param handlers (`call0()`).
1561
+
1562
+**Options considered:**
1563
+1. Keep the Python boundary; micro-tune `orjson` usage in `wrap_result`. Rejected —
1564
+ still pays the `to_dict` + Python fn-call overhead on every request.
1565
+2. Make `Response` a Rust `#[pyclass]` and downcast it directly in Rust. Rejected —
1566
+ subclassing the pyclass from Python broke: the pyclass `#[new]` expected
1567
+ `body: String` but `render()` returns `bytes`, and the Python `__init__` passed
1568
+ an unknown `background=` kwarg. Brittle and fight-y with pyo3's subclass/ctor
1569
+ rules.
1570
+3. Detect `Response` via a `_justapi_response` marker attribute and read its
1571
+ fields (`status_code`, `headers`, `body`) directly in Rust — selected. Keeps
1572
+ `Response` a thin Python class (AGENTS.md §2: Python owns application glue),
1573
+ removes the `to_dict` round-trip, and runs `background.run()` in Rust exactly
1574
+ where `wrap_result` did.
1575
+
1576
+**Decision (what changed):**
1577
+- `handlers.rs::serialize_response` mirrors Robyn's `extract_response_type_fast`:
1578
+ `Response` (marker) → direct field read; dict/list/str/num → `orjson.dumps`
1579
+ called from a `OnceLock`-cached Python lambda; bytes → octet-stream; anything
1580
+ else falls back to the Python `wrap_result` (preserving `default=str`).
1581
+- `call_python_handler` skips building the Python `Request` object when the route
1582
+ doesn't need it. The per-handler `needs_request` flag is computed in
1583
+ `app.py::_wrap_handler` from `sig.parameters`, `all_deps`, and any
1584
+ (route- or app-level) middleware, and threaded through `run()` →
1585
+ `make_native_handler` / `make_test_handler`. Default `True` (safe) when the
1586
+ attribute is missing.
1587
+- Trace-context propagation is gated behind `JUSTAPI_ENABLE_TRACE` (default off)
1588
+ via a `OnceLock`-cached check, so the hot path skips the Python call entirely.
1589
+- `_native_helper.py::call_handler` returns the raw result; Rust serializes.
1590
+
1591
+**Evidence:** Re-run on the same fixture (i5-13600K, `oha -z 10s -c 100`, release
1592
+builds of both) — justapi now *beats* Robyn: hello-world 60,297 vs 39,103
1593
+(×1.54), JSON echo 47,415 vs 36,899 (×1.29), validated JSON 40,080 vs 32,919
1594
+(×1.22). Resolved edge-case bug: 0-param handlers behind middleware now correctly
1595
+force `needs_request=True` (empty-dict would have 500'd a middleware that reads
1596
+the request). Gates green: `cargo test --workspace` (0 fail), `cargo clippy
1597
+--workspace --tests -- -D warnings` (0 warn; two pre-existing feature-gated
1598
+warnings also fixed), `cargo fmt --check` clean, Python pytest 107 passed / 1
1599
+skipped. BENCHMARKS.md head-to-head section updated (old conclusion superseded).
1600
+
1601
+## ADR-048 — 2026-07-14 — Schema-backed native Rust fast path (`native=True`)
1602
+
1603
+**Context:** ADR-047 removed the *per-request* Python↔Rust boundary for the
1604
+response and the `Request` build, and justapi now beats Robyn on raw throughput.
1605
+But every route still *dispatched into a Python handler* — the final PyO3 call
1606
+could not be eliminated for application logic. Robyn-style "handler runs entirely
1607
+in Rust" was still impossible for justapi. The user-chosen next step was the
1608
+schema-backed fast path: for routes whose contract is "validate the body against
1609
+a schema and return it," there is no application logic to run — the response is
1610
+deterministic, so the Python handler is pure overhead.
1611
+
1612
+**Options considered:**
1613
+1. Make `native=True` validate-then-echo in Rust for *any* body. Rejected — without
1614
+ a schema there is nothing to validate against and the body may not be JSON; the
1615
+ fast path needs a schema to be meaningful and safe.
1616
+2. Validate in Rust, echo body; if `native=True` but no schema present, fall back to
1617
+ the normal Python handler (don't panic on `None` schema). Selected.
1618
+
1619
+**Decision (what changed):**
1620
+- `app.py` route decorators `post/put/patch/query` accept `native: bool = False`
1621
+ and forward it (via `**kw` → `native=`) to the Rust `_app.<method>` call.
1622
+- Rust `JustAPIApp` holds a `native: Vec<bool>` (parallel to `handlers`); every
1623
+ route method (`get/head/options/trace/post/put/patch/delete/query`) records
1624
+ `native.unwrap_or(false)`; `run()` and the test client build an `Arc<Vec<bool>>`
1625
+ and pass it to `make_native_handler` / `make_test_handler`.
1626
+- `call_python_handler` gains `native: bool` + `schema_json: Option<String>`. When
1627
+ `native && schema_json.is_some()`, it calls `justapi_core::validate::
1628
+ validate_json_schema(body, sj)`; on `Ok` it returns `200 application/json` with the
1629
+ raw request body echoed back; on `Err` it returns `422 application/problem+json`
1630
+ with `field`/`message` per error. Otherwise execution continues to the Python
1631
+ handler unchanged.
1632
+- Guard: `native && schema_json.is_some()` — `native=True` without a schema silently
1633
+ falls through to the Python path (no `None` deref).
1634
+
1635
+**Evidence:** Release `maturin develop` on the fixture (i5-13600K, `oha -z 10s -c 100`,
1636
+`POST {"id":1,"name":"x","price":1.5"}`): the native route served **59,666 RPS** vs
1637
+**3,531 RPS** for the equivalent Python-handler route (~16.9× faster), exceeding
1638
+Robyn's validated-workload number (32,919 RPS) by ~1.8×. New regression test
1639
+`test_native_fastpath.py` (4 cases: valid→200 echo, invalid→422, native-without-
1640
+schema→Python fallback, PUT echo) passes. Gates green: `cargo clippy -p justapi-py
1641
+--tests -- -D warnings` clean, `cargo fmt --check` clean. BENCHMARKS.md native
1642
+fast-path section added.
1643
+
1644
+---
1645
+
1646
+## ADR-049 — Non-native dispatch deadlocks at high concurrency (GIL/blocking-pool)
1647
+
1648
+**Status:** Open issue, filed separately from ADR-048. Root-caused (location
1649
+known); fix not yet implemented.
1650
+
1651
+**Context:** While re-benchmarking the ADR-048 native fast path at `oha -z 5-6s
1652
+-c 100`, the *Python* routes were found to hard-stall. This is a separate,
1653
+pre-existing defect in the Python dispatch path; it is **not** caused by the
1654
+native optimization (ADR-048) and native routes are immune.
1655
+
1656
+**Symptom:** At ~100 concurrent connections, **every** non-native route serves
1657
+≈16–20 req/s with all connections aborted/deadlined. At `-c 10` the same routes
1658
+serve hundreds of thousands of req/s. The stall is nondeterministic in severity
1659
+(one `/noop` run reached 697k req/s; a subsequent `/noparam` run stalled at 20
1660
+req/s for the identical handler class), i.e. a GIL/blocking-pool *race* that
1661
+usually resolves to a full stall under saturation.
1662
+
1663
+**Reproduction (minimal):** any route, including the simplest:
1664
+```python
1665
+@app.post("/noparam")
1666
+def noparam(): # no params, no body access, needs_request=False
1667
+ return {"ok": True}
1668
+```
1669
+`oha -z 5s -c 100 -m POST` → ≈20 req/s, 100 aborted. Even `/noparam`, which never
1670
+reaches `try_native_fast_path` (native=false), never hits the schema block (no
1671
+schema), and does **not** use `app`/`scheme`/`client`/`needs_request`/
1672
+`http_version` (the params added to `call_python_handler` in ADR-048) — yet it
1673
+deadlocks. This proves the ADR-048 change is not the trigger.
1674
+
1675
+**Location:** `crates/justapi-py/src/native/handlers.rs` `make_native_handler`
1676
+spawn_blocking block (~line 722):
1677
+```rust
1678
+let nr = tokio::task::spawn_blocking(move || {
1679
+ Python::attach(|py| { call_python_handler(py, ...) })
1680
+}).await;
1681
+```
1682
+All non-native requests funnel through `tokio::spawn_blocking` + `Python::attach`
1683
+(GIL acquisition) on tokio's blocking pool. Under ~100 concurrent requests the
1684
+GIL and the blocking pool interact to produce a hard stall — the classic pyo3
1685
+"many blocking threads all contending for the GIL while the async runtime needs
1686
+those threads to make progress" deadlock class.
1687
+
1688
+**Why native routes are immune:** `try_native_fast_path` (handlers.rs ~421)
1689
+returns the response with **no GIL acquire and no `spawn_blocking` hop** — it
1690
+validates via the precompiled `CompiledValidator` and echoes the body in pure
1691
+Rust. Measured 410k–724k req/s at `-c 100` (ADR-048 / BENCHMARKS.md). So opting
1692
+a route into `native=True` both maximizes throughput *and* avoids this deadlock.
1693
+
1694
+**Decision (fix direction — NOT yet implemented):** Replace the per-request
1695
+`spawn_blocking` + `Python::attach` pattern with a **dedicated GIL thread-pool**
1696
+(a small, bounded set of threads — sized to `num_cpus()` — each holding a
1697
+persistent GIL / `PyGILPool`, dispatched via a channel with a oneshot result
1698
+future). This decouples Python execution from tokio's blocking pool so the two
1699
+never deadlock each other, and removes per-request GIL-acquisition contention.
1700
+The async side `await`s the oneshot instead of `spawn_blocking`. Native routes
1701
+keep using `try_native_fast_path` (no change).
1702
+
1703
+**Out of scope for ADR-048:** the native fast-path optimization is complete and
1704
+correct; this deadlock is a separate P0 to schedule as the next phase. It affects
1705
+the *common* (non-native) path, so it blocks production use at realistic
1706
+concurrency and should be the immediate next task after ADR-048.
1707
+
1708
+**Evidence:** isolation benchmark (fixture, `oha -z 5s -c 100`): `/noparam` ≈20
1709
+req/s (all aborted); `/validate_native` (native) 410k–724k req/s; `/noop` once
1710
+697k, once ≈20 (nondeterministic stall). Single-request and `-c 10` curl work.
1711
+
1712
+## ADR-049 — 2026-07-15 — Remove HTTP/3 (QUIC) transport (dead code)
1713
+
1714
+**Context:** ADR-046 added HTTP/3 behind the `http3` Cargo feature
1715
+(`quinn` + `h3` + `h3-quinn`, `server::http3::serve_http3`,
1716
+`JustAPIApp.enable_http3`). During the 2026-07-14 production-readiness audit
1717
+(MED#9) it was found that `crates/justapi-core/src/server/http3.rs` was
1718
+**untracked and never declared as a module** (no `mod http3;` anywhere), so it
1719
+was never compiled and `serve_http3` was never called. The `app.rs`
1720
+`#[cfg(feature = "http3")]` blocks reference `justapi_core::server::http3::serve_http3`,
1721
+so *enabling* the feature would have failed to compile — the "feature" was a
1722
+false signal, not working code.
1723
+
1724
+**Decision:** Remove the dead HTTP/3 transport entirely:
1725
+- deleted `crates/justapi-core/src/server/http3.rs`;
1726
+- dropped the `http3` feature and the `quinn`/`h3`/`h3-quinn` optional deps
1727
+ from `justapi-core`;
1728
+- removed the `http3` feature from `justapi-py`;
1729
+- deleted the Rust `enable_http3` method, the Python `JustAPIApp.enable_http3`
1730
+ wrapper, the `.pyi` stub, `test_http3.py`, and all `#[cfg(feature = "http3")]`
1731
+ wiring in `app.rs`.
1732
+
1733
+**Rationale:** keeping uncompilable, never-exercised code is worse than removing
1734
+it (AGENTS §2, "no dead code"; audit MED#9). If HTTP/3 is wanted later it must
1735
+be re-added as a *fully wired* module — declared via `mod http3;`, and given the
1736
+same production hardening the audit applied to the other transports (request
1737
+timeout via `chain.run` + `tokio::time::timeout`, connection-flood `Semaphore`,
1738
+panic safety) — otherwise it would be a fresh DoS/security gap. The middleware
1739
+body-type genericity work from the original phase remains and is still exercised
1740
+by the HTTP/1.1 path.
1741
+
1742
+**Evidence:** `cargo check --workspace` and `cargo clippy --workspace --tests -D
1743
+warnings` clean after removal; `git status` shows the `http3.rs` file removed
1744
+(it was untracked, so the removal is from the working tree only — it was never
1745
+committed). PLAN.md Phase 53 status updated to "reverted — dead code removed".
1746
+
1747
+
1748
+
1482
1749
▼PLAN.md
+74-6
6
6
7
7
## Current status
8
8
9
-- **Active phase:** Phase 52 (GPU Benchmark Gate) — 🔴 pending
10
-- **Status:** Phase 51 complete. Phase 52 is the first real-GPU validation: load a real model (GGUF) on CUDA, measure tokens/sec/TTFT/ITL for both naive and scheduler-backed generation paths, and compare against vLLM. The benchmark binary (`justapi-gpu-bench`) is written and compiles (with MockModel fallback when no CUDA). **Blocking:** CUDA toolkit must be installed (`sudo pacman -S cuda`).
11
-- **Last updated:** 2026-07-10 (Phase 52 created)
12
-- **Blocker:** CUDA toolkit not installed on the benchmark fixture
9
+- **Active phase:** Phase 52 (GPU Benchmark Gate) — 🟡 ready to run
10
+- **Status:** Phase 51 complete. Phase 52 is the first real-GPU validation: load a real model (GGUF) on CUDA, measure tokens/sec/TTFT/ITL for both naive and scheduler-backed generation paths, and compare against vLLM. The benchmark binary (`justapi-gpu-bench`) is written and compiles (with MockModel fallback when no CUDA). **Blocker resolved:** CUDA toolkit IS installed — `nvcc` (CUDA 13.3) at `/opt/cuda/bin` and an NVIDIA GeForce RTX 3060 Ti (8192 MiB) are present. The gate is now runnable; it still needs an actual `cargo run --features "cuda,real"` pass with real weights (candle w/ CUDA build unverified).
11
+- **Hot-path optimization (2026-07-13) — ✅ complete, not a numbered phase.** Removed the per-request Python↔Rust boundary (Rust-side `orjson` response serialization, `Response` serialized directly via a marker attribute, `needs_request` skips `Request` construction, trace-context gated behind `JUSTAPI_ENABLE_TRACE`). Result: justapi now *beats* Robyn on raw throughput (hello 60.3k vs 39.1k RPS, echo 47.4k vs 36.9k, validated 40.1k vs 32.9k on the same fixture — see BENCHMARKS.md). Gates green: `cargo test --workspace`, `cargo clippy --workspace --tests -- -D warnings`, `cargo fmt --check`, pytest 107 passed/1 skipped. Fixed a latent edge-case bug (0-param handler + middleware now forces `needs_request=True`). See ADR-047.
12
+- **Schema-backed native Rust fast path (2026-07-14) — ✅ complete, further optimized.** Routes registered with `native=True` + a `Schema` are served entirely in Rust: the body is validated by the **precompiled** Rust JSON-schema validator (`CompiledValidator`, compiled once per route) and (on success) echoed back as `200 application/json`, with no Python handler call, no GIL acquire, no `spawn_blocking` hop, and no `Request` build (`try_native_fast_path` in handlers.rs). `native=True` without a schema safely falls back to the Python path. Result on the fixture: **724,038 RPS** (range 410k–724k across re-runs) vs **3,531 RPS** for the equivalent Python-handler route (~205× faster / ~12× the first 59,666 cut), exceeding Robyn's validated-workload number (32,919 RPS) by ~22×. See ADR-048 + ADR-049 + BENCHMARKS.md native fast-path section. Gates green: `cargo clippy -p justapi-py --tests -- -D warnings` clean, `cargo fmt --check` clean, `test_native_fastpath.py` passes.
13
+- **⚠️ Non-native dispatch deadlock at high concurrency (P0, next task — see ADR-049).** The Python dispatch path (`spawn_blocking` + `Python::attach`) hard-stalls at ~100 concurrent connections for **every** non-native route (incl. the simplest handler, ≈16–20 req/s, all aborted). Pre-existing relative to the native optimization (proven: `/noparam` ignores all ADR-048-touched code yet still deadlocks); native routes are immune. **Recommended fix:** dedicated GIL thread-pool (bounded `num_cpus()` threads, persistent GIL, channel + oneshot dispatch) replacing per-request `spawn_blocking`+`Python::attach`. This blocks production use of non-native routes at realistic concurrency and should be the immediate next phase after ADR-048.
14
+- **Next perf step (open, not a measured gap):** justapi now beats Robyn on *all* three raw workloads (incl. validated 40.1k vs 32.9k), and schema-backed routes run entirely in Rust via `native=True`. The only residual PyO3 cost is the handler *call itself* for **non-schema-backed** routes (handler body still runs in Python) — currently deadlock-blocked per ADR-049, not merely slow. Arbitrary user-defined Rust handlers (beyond validate-and-echo) remain a larger architectural step, currently **not** justified by a measured deficit vs Robyn; candidate for a future phase if a real workload demands it.
15
+- **Last updated:** 2026-07-14 (schema-backed native fast path complete; justapi beats Robyn on raw throughput and serves schema routes entirely in Rust)
16
+- **Blocker:** none (CUDA present). Outstanding: run the GPU benchmark with real weights + PyPI upload token/manylinux build.
13
17
14
18
## Mission
15
19
70
74
| 49 | Tree-based Speculative Decoding | ✅ complete | Medusa/EAGLE-style tree verification wired into serving path. Acceptance rate up to 3× higher than draft-target. |
71
75
| 50 | RadixAttention Prefix Caching | ✅ complete | `RadixPrefixCache` wired into `Scheduler`: O(1) prefix lookup on admission, LRU eviction, finished-seq block promotion. |
72
76
| 51 | Scheduler Serving Integration | ✅ complete | `SchedulerEngine` bridges Engine+Scheduler; prefix cache metrics in `/metrics`; sampling params plumbed; real throughput benchmark. 151 inference tests. |
73
-| **52** | **GPU Benchmark Gate** | **🔴 pending** | **Run SchedulerEngine vs naive vs vLLM on real GPU with real weights. Measure tokens/sec, TTFT, ITL.** |
77
+| **52** | **GPU Benchmark Gate** | **🟡 ready to run** | **Run SchedulerEngine vs naive vs vLLM on real GPU with real weights. Measure tokens/sec, TTFT, ITL. CUDA present (RTX 3060 Ti, CUDA 13.3) — blocker resolved.** |
74
78
75
79
---
76
80
596
600
597
601
2. Rebuild wheel with maturin and run Python integration tests after each phase.
598
602
603
+## Open issues / follow-ups (2026-07-11)
604
+
605
+- [x] **Python test gate fixed.** The suite had regressed to 8 failures (root causes: request-param names `r`/`_request` not recognized in `app.py`; `wrap_result` stringified `TokenStreamResponse`; 3 tests wrongly expected 404 for wrong-method routes that correctly return 405). All 57 pass / 1 skipped now.
606
+- [ ] **Working tree is dirty with pure rustfmt churn.** 74 files show uncommitted line-wrap reflows (2860 deletions) from a nightly `cargo fmt`. `rustfmt.toml` uses nightly-only options (`wrap_comments`, `format_code_in_doc_comments`, `normalize_comments`, `imports_granularity`, `group_imports`) that are silently ignored on stable — so `cargo fmt --check` passes locally but a nightly CI gate would flag the whole tree. Resolve by either pinning CI to nightly + committing the format, or dropping the nightly-only options and committing the revert. Do not ship the 2860-line deletion accidentally.
607
+- [ ] **Python test deps not pinned.** `venv` was missing `pytest_asyncio`, `pydantic`, `jinja2`. Add a `[project.optional-dependencies]` `test` extra / `requirements-dev.txt` so the suite is reproducible.
608
+- [ ] **Phase 52 real run unverified.** `cargo run -p justapi-bench --bin justapi-gpu-bench --features "cuda,real"` still needs a real-weight pass; the `candle`+CUDA build is heavy and unconfirmed in this env.
609
+- [ ] **Untracked artifacts:** `crates/justapi-py/python/justapi/test_routing.py`, `test_gateway.json` — integrate or remove.
610
+
599
611
## Key architecture invariants
600
612
601
613
- Rust owns I/O, Python owns application logic (ADR-008)
731
743
- [x] **Docker:** fixed `rust:1.84` (too old for `edition2024`) → `rust:1.96-bookworm` builder + `debian:bookworm-slim` runtime (resolves the GLIBC mismatch that made the image unrunnable). Added `.dockerignore` (was shipping a 3.5 GB context). Image builds, runs (`/hello`, `/echo` verified), **155 MB** (< 200 MB target).
732
744
- [x] **PyPI packaging:** `pyproject.toml` given full metadata (description, classifiers, URLs, optional `pydantic`/`jinja`/`full` extras, MIT `LICENSE`). Built as a single **abi3** wheel (`cp311-abi3`, covers CPython 3.11–3.14) — required adding `abi3-py311` to pyo3 features. `twine check` PASSED; wheel installs + imports in a clean venv. PyPI name `justapi` confirmed **available**.
733
745
- [x] **Benchmarks (BENCHMARKS.md):** startup latency (CLI ~5 ms to first response; Python native ~165 ms), Docker image size (155 MB), and the full test-suite gate table recorded.
734
-- [x] All gates green: `cargo test --workspace` (236), `clippy -- -D warnings` clean, `cargo fmt --check` clean, `cargo miri test` (2), Python `pytest` (53 passed / 1 skipped), `twine check` PASSED.
746
+- [x] All gates green: `cargo test --workspace` (~444), `clippy -- -D warnings` clean, `cargo fmt --check` clean, `cargo miri test` (2), Python `pytest` (57 passed / 1 skipped as of 2026-07-11; suite had regressed to 8 failures and was fixed — see Next actions), `twine check` PASSED.
735
747
- [ ] Publish to PyPI — packaging ready; upload must run inside a `manylinux_2_28` container (local Arch host tags `manylinux_2_34`, which PyPI rejects) and needs a PyPI API token. See BENCHMARKS.md publish notes.
736
748
- [x] Full `ARCHITECTURE.md` and `CONTRIBUTING.md`
737
749
986
998
`CUDARC_CUDA_VERSION=13000` alone is insufficient because nvcc still emits
987
999
13.3 PTX.
988
1000
1001
+### Phase 53: HTTP/3 (QUIC) Transport (Status: ⚠️ reverted — dead code removed 2026-07-15)
1002
+- **Objective:** Serve the same Python routes over HTTP/3 (QUIC) in addition to
1003
+ the existing HTTP/1.1 transport, sharing the Rust request-handling core.
1004
+- **Deliverables:**
1005
+ - `justapi-core` `http3` feature: `quinn` 0.11 + `h3` 0.0.8 + `h3-quinn`
1006
+ 0.0.10. `server::http3::serve_http3(addr, cert_pem, key_pem, chain, wasm_middleware, shutdown)`
1007
+ binds a QUIC `Endpoint` (TLS ALPN `h3`), accepts connections, and dispatches
1008
+ each request through a `MiddlewareChain<Full<Bytes>>` (the same chain shape as
1009
+ HTTP/1.1) — request body fully buffered into `Bytes` before the chain runs.
1010
+ An optional `WasmEngine` applies the same WASM preprocessing as the H1 path.
1011
+ - Core integration test `server::http3::tests::http3_roundtrip` — a self-signed
1012
+ cert (rcgen) + `h3-quinn` client performs a full GET roundtrip and asserts the
1013
+ response body.
1014
+ - `justapi-py` `http3` feature forwarding to `justapi-core/http3`.
1015
+ `make_native_handler` / `make_test_handler` are now generic over the request
1016
+ body type `B: http_body::Body<Data = Bytes> + Send + Sync + Unpin + 'static`
1017
+ (so the same closure serves `Incoming` for HTTP/1.1 and `Full<Bytes>` for
1018
+ HTTP/3). `JustAPIApp.run` spawns `serve_http3` in parallel when
1019
+ `enable_http3(cert_pem, key_pem)` was called; the QUIC server shares the
1020
+ `CancellationToken` for graceful shutdown.
1021
+ - Python-facing `JustAPIApp.enable_http3(cert_pem, key_pem)` (delegates to the
1022
+ Rust method; raises a helpful `RuntimeError` if built without the `http3`
1023
+ feature). `JustAPIApp.run` builds an H3 `MiddlewareChain<Full<Bytes>>` from the
1024
+ same config (circuit breaker, request coalescing, gateway) as the H1 path and
1025
+ passes it to `serve_http3`, so H3 applies identical middleware policy.
1026
+ `test_http3.py` verifies wiring: server starts, routes answer over
1027
+ HTTP/1.1, and the QUIC UDP port is bound.
1028
+- **Limitation resolved:** Middleware was made generic over the request body type
1029
+ `B` (`impl<B: Send + 'static> Middleware<B>` for `AccessLogger`, `Cors`,
1030
+ `SecurityHeaders`, `JwtAuth`, `RateLimiter`, `IpRateLimiter`, `ApiKeyAuth`,
1031
+ `RequestCoalescer`, `CompressionMiddleware`, `GatewayMiddleware`; resilience
1032
+ middleware was already generic). `MiddlewareChain` is therefore reusable for any
1033
+ body type, and `serve_http3` now runs the full chain (plus WASM preprocessing) on
1034
+ the H3 path — not the raw `HandlerFn`. Routing, Python dispatch, validation, DB,
1035
+ batching and WebSockets still work on H3 (they live in the handler).
1036
+- [x] `cargo test -p justapi-core --features http3` — `http3_roundtrip` and
1037
+ `http3_middleware_applied` (asserts `x-content-type-options: nosniff` from
1038
+ `SecurityHeaders` arrives over H3) pass
1039
+- [x] `cargo clippy --workspace --tests --features justapi-py/http3 -D warnings`
1040
+ clean
1041
+- [x] `cargo fmt --check` clean
1042
+- [x] Python `pytest` under GIL (3.12) and no-GIL (3.14t): 78 passed, 1 skipped
1043
+ (no regression from the generic-middleware refactor)
1044
+
1045
+> **2026-07-15 update — removed as dead code.** The `http3` transport was never
1046
+> compiled (the `server/http3.rs` module was untracked and never declared via
1047
+> `mod http3;`), so enabling the feature failed to build, and `serve_http3` was
1048
+> never called. As part of the production-readiness cleanup it was deleted: the
1049
+> `http3` feature + `quinn`/`h3`/`h3-quinn` deps in `justapi-core`, the `http3`
1050
+> feature in `justapi-py`, the Rust `enable_http3` method, the Python
1051
+> `JustAPIApp.enable_http3` wrapper + `.pyi` stub, `test_http3.py`, and all
1052
+> `#[cfg(feature = "http3")]` wiring in `app.rs` were removed. The middleware
1053
+> body-type genericity work (Phase 53 deliverable) remains and is still exercised
1054
+> by the HTTP/1.1 path. If HTTP/3 is wanted later, re-add it as a fully-wired
1055
+> module (ADR-046 should be revisited).
1056
+
989
1057
### Phase gate (Part 5 exit criteria)
990
1058
- [x] `justapi serve --model <id> --gpu 0` serves an OpenAI-compatible endpoint
991
1059
with continuous batching + paged KV cache, GIL-free hot path. **Done:**
992
1060
▼README.md
+40-0
76
76
77
77
Python only executes **your** business logic. Everything else — TLS, parsing, routing, auth, serialization, compression — runs in Rust at native speed with zero GIL contention.
78
78
79
+## What makes JustAPI different
80
+
81
+Two things, stated plainly:
82
+
83
+1. **Throughput.** A Rust networking core (hyper + tokio + rustls) moves TLS,
84
+ routing, serialization, and middleware out of Python. The number above
85
+ (701k req/s hello-world) is the payoff — ~20× FastAPI+Uvicorn on that
86
+ workload.
87
+2. **Agent-native serving.** Beyond REST, JustAPI has a first-class
88
+ introspection + tool-serving layer for AI agents: every app can expose its
89
+ routes as MCP tools (`python -m justapi.mcp_server`), stream *validated*
90
+ structured output as tokens arrive, and carry multi-turn session state —
91
+ without bolting a third-party SDK onto generic HTTP routes. This is the part
92
+ we invest in most; see [`ROADMAP.md`](ROADMAP.md) for how far it goes.
93
+
94
+## Competitors, named honestly
95
+
96
+We are not the first Rust-backed Python framework, and we don't claim to be:
97
+
98
+- **Robyn** — Rust runtime, decorator API, commonly cited ~40–60% faster than
99
+ FastAPI on simple endpoints. Real project; we benchmark against it.
100
+- **Granian** — Rust *ASGI server* that drops under existing ASGI apps. Different
101
+ bet: it replaces the server, not the framework.
102
+- **Litestar** / **BlackSheep** — Python frameworks with first-class ASGI and
103
+ rich feature sets; we borrow ergonomics from them.
104
+- **FastAPI** — the compatibility target. JustAPI mirrors its decorator API and
105
+ Pydantic models so migration is low-friction.
106
+
107
+Where we deliberately differ from Robyn: JustAPI keeps an **ASGI shim** (so it
108
+runs under Uvicorn/Granian and reuses Starlette-ecosystem middleware) and treats
109
+agent/MCP serving as a built-in primitive rather than an afterthought.
110
+
111
+## Non-goals & not-yet-built
112
+
113
+JustAPI is **not** trying to be everything in v1. We do not ship a custom
114
+edge/WASM runtime, a from-scratch ORM/migrations system, a multi-tenant auth
115
+platform, or a new validator (we wrap Pydantic v2 core / JSON Schema). See
116
+[`ROADMAP.md`](ROADMAP.md) for the full, explicitly-labeled future list.
117
+
79
118
## Requirements
80
119
81
120
* **Python 3.11+**
474
513
| [**Security**](docs/security/) | OWASP checklist, pentest guide, disclosure policy |
475
514
| [**BENCHMARKS.md**](BENCHMARKS.md) | Performance results (append-only ledger) |
476
515
| [**PLAN.md**](PLAN.md) | Living roadmap and current phase |
516
+| [**ROADMAP.md**](ROADMAP.md) | Aspirational future work — explicitly *not yet built* |
477
517
| [**DECISIONS.md**](DECISIONS.md) | Architecture decision records |
478
518
| [**AGENTS.md**](AGENTS.md) | Development rules and conventions |
479
519
480
520
▼ROADMAP.md
+70-0
1
+# ROADMAP.md
2
+
3
+> **This file is the aspirational list — explicitly labeled "not yet built."**
4
+> Anything here is future work. For the current status of what *is* built, see
5
+> [`PLAN.md`](PLAN.md) (the living phase table) and [`BENCHMARKS.md`](BENCHMARKS.md).
6
+
7
+JustAPI is currently a FastAPI-compatible, Rust-accelerated Python web framework
8
+with an MCP/agent-native serving layer. The **native tool registry** (`@app.tool`,
9
+served over `/_system/tools` and the bundled MCP server) is **built** and lives in
10
+Rust (`crates/justapi-py/src/native/app.rs`). **Streaming structured-output
11
+validation** (`@app.stream_json` + the Rust `validate_value` / `ValidatedStreamResponse`
12
+primitives) and **agent session state** (`app.enable_sessions()` + the Rust-backed
13
+`Session` store) are **built** as of v0.2, and a complete runnable demo lives at
14
+`examples/agent_demo/`. This file collects the features we are deliberately *not*
15
+claiming as done.
16
+
17
+## Non-goals for v1 (stated up front, not built yet)
18
+
19
+These are intentionally out of scope for the first stable release. Don't assume
20
+they exist just because they're listed in marketing copy elsewhere.
21
+
22
+- **Custom edge / WASM runtime.** We ship a Rust core + PyO3 bindings and an
23
+ ASGI shim. We are *not* building a bespoke edge/WASM execution environment in
24
+ v1.
25
+- **Full ORM / migrations system.** We provide a database pool + query bridge,
26
+ but not a from-scratch ORM or migration engine. Use the bridge with an
27
+ existing solution.
28
+- **Multi-tenant auth platform.** Per-route JWT/RBAC exists; a full multi-tenant
29
+ identity platform does not.
30
+- **Reinventing validation.** We wrap proven Rust-backed validators (Pydantic
31
+ v2 core / JSON Schema). We are not writing a new validator from scratch.
32
+
33
+## Aspirational (future, not yet built)
34
+
35
+Reproduced from the original 14-section wishlist. Each item is a candidate
36
+future phase, not a shipped feature.
37
+
38
+### Auth & security
39
+- [ ] Passkey / WebAuthn login flows (beyond JWT/OAuth2)
40
+- [ ] OAuth2 provider federation UI
41
+- [ ] Fine-grained, policy-as-code authorization (OPA-style)
42
+
43
+### Observability
44
+- [ ] Prebuilt Grafana/Prometheus dashboards shipped as artifacts
45
+- [ ] Distributed tracing across async boundaries with automatic span linking
46
+- [ ] Anomaly-detection on latency/error budgets
47
+
48
+### Deployment & ops
49
+- [ ] `justapi deploy` CLI with multi-cloud provisioning
50
+- [ ] Edge/WASM middleware runtime
51
+- [ ] Self-healing workers (automatic respawn + drain)
52
+- [ ] Distributed cache primitive (beyond single-node)
53
+- [ ] Blue/green + canary deployment helpers
54
+
55
+### DX & AI-assisted
56
+- [ ] AI-generated docs from route introspection (we expose the data via
57
+ `/_system/help`; the generator is future work)
58
+- [ ] In-editor "generate handler from OpenAPI" assist
59
+
60
+### Protocols
61
+- [ ] Full GraphQL federation at scale (basic GraphQL integration exists)
62
+- [ ] gRPC streaming codegen CLI
63
+
64
+## How we decide what to build next
65
+
66
+Per the project's build brief: every new capability needs working code **and**
67
+at least one test **and** a line in the relevant doc (README / ARCHITECTURE /
68
+ROADMAP) before it is considered shipped. We do not publish selective benchmark
69
+numbers — see [`BENCHMARKS.md`](BENCHMARKS.md) for the full, append-only ledger
70
+including cases where competitors win.
0
71
▼benchmarks/cap_gdb.sh
+26-0
1
+#!/bin/bash
2
+cd /home/swadhin/RastAPI/benchmarks
3
+PY=/home/swadhin/RastAPI/.venv/bin/python
4
+PORT=8268
5
+OUT=/home/swadhin/RastAPI/benchmarks/gdb_out.txt
6
+rm -f $OUT
7
+gdb -batch \
8
+ -ex "set pagination off" \
9
+ -ex "set debuginfod enabled off" \
10
+ -ex "set non-stop off" \
11
+ -ex "run" \
12
+ -ex "thread apply all bt 6" \
13
+ --args $PY workloads_bt4.py $PORT > $OUT 2>&1 &
14
+GDBPID=$!
15
+for i in $(seq 1 60); do
16
+ if curl -s -o /dev/null http://127.0.0.1:$PORT/body_json -X POST -d '{"x":1}' 2>/dev/null; then break; fi
17
+ sleep 1
18
+done
19
+~/.cargo/bin/oha -c 100 -z 20s http://127.0.0.1:$PORT/body_json -m POST -d '{"name":"alice","email":"[email protected]","age":30}' >/dev/null 2>&1 &
20
+OHAPID=$!
21
+sleep 8
22
+kill -INT $GDBPID
23
+sleep 2
24
+kill $OHAPID 2>/dev/null
25
+wait $GDBPID 2>/dev/null
26
+echo "captured to $OUT"
0
27
▼benchmarks/workloads_baseline.py
+14-0
1
+from justapi import JustAPIApp
2
+import json
3
+
4
+app = JustAPIApp()
5
+
6
+
7
+@app.post("/body_json")
8
+def body_json(request):
9
+ d = json.loads(request["body"].decode("utf-8"))
10
+ return {"ok": True}
11
+
12
+
13
+if __name__ == "__main__":
14
+ app.run("127.0.0.1:8250")
0
15
▼benchmarks/workloads_body_test.py
+36-0
1
+from justapi import JustAPIApp
2
+import json
3
+
4
+app = JustAPIApp()
5
+
6
+
7
+@app.post("/noop")
8
+def noop(request):
9
+ return {"ok": True}
10
+
11
+
12
+@app.post("/body_access")
13
+def body_access(request):
14
+ b = request["body"]
15
+ return {"ok": True}
16
+
17
+
18
+@app.post("/body_decode")
19
+def body_decode(request):
20
+ b = request["body"].decode("utf-8")
21
+ return {"ok": True}
22
+
23
+
24
+@app.post("/body_json")
25
+def body_json(request):
26
+ d = json.loads(request["body"].decode("utf-8"))
27
+ return {"ok": True}
28
+
29
+
30
+@app.post("/body_ret")
31
+def body_ret(request):
32
+ return request["body"]
33
+
34
+
35
+if __name__ == "__main__":
36
+ app.run("127.0.0.1:8242")
0
37
▼benchmarks/workloads_body_test2.py
+40-0
1
+from justapi import JustAPIApp, Schema
2
+import json
3
+
4
+app = JustAPIApp()
5
+
6
+
7
+@app.post("/body_json")
8
+def body_json(request):
9
+ return json.loads(request["body"].decode("utf-8"))
10
+
11
+
12
+@app.get("/hello")
13
+def hello(request):
14
+ return {"message": "hello, world"}
15
+
16
+
17
+async def hello_async(request):
18
+ import asyncio
19
+ await asyncio.sleep(0.001)
20
+ return {"message": "hello, world async"}
21
+
22
+
23
+app.get("/hello_async", hello_async)
24
+
25
+
26
+class NativeItemSchema(Schema):
27
+ id: int
28
+ name: str
29
+ price: float
30
+
31
+
32
+@app.post("/validate_native", schema=NativeItemSchema, native=True)
33
+def validate_native(request):
34
+ raise AssertionError("handler must not run in native mode")
35
+
36
+
37
+if __name__ == "__main__":
38
+ import sys
39
+ port = sys.argv[1] if len(sys.argv) > 1 else "8243"
40
+ app.run(f"127.0.0.1:{port}")
0
41
▼benchmarks/workloads_body_test3.py
+26-0
1
+from justapi import JustAPIApp, Schema
2
+import json
3
+
4
+app = JustAPIApp()
5
+
6
+
7
+@app.post("/body_json")
8
+def body_json(request):
9
+ return json.loads(request["body"].decode("utf-8"))
10
+
11
+
12
+class NativeItemSchema(Schema):
13
+ id: int
14
+ name: str
15
+ price: float
16
+
17
+
18
+@app.post("/validate_native", schema=NativeItemSchema, native=True)
19
+def validate_native(request):
20
+ raise AssertionError("handler must not run in native mode")
21
+
22
+
23
+if __name__ == "__main__":
24
+ import sys
25
+ port = sys.argv[1] if len(sys.argv) > 1 else "8244"
26
+ app.run(f"127.0.0.1:{port}")
0
27
▼benchmarks/workloads_bt4.py
+18-0
1
+from justapi import JustAPIApp, Schema
2
+import json
3
+
4
+app = JustAPIApp()
5
+
6
+@app.post("/body_json")
7
+def body_json(request):
8
+ return json.loads(request["body"].decode("utf-8"))
9
+
10
+# Variant A: native=True, NO schema
11
+@app.post("/native_ns", native=True)
12
+def native_ns(request):
13
+ return {"ok": True}
14
+
15
+if __name__ == "__main__":
16
+ import sys
17
+ port = sys.argv[1] if len(sys.argv) > 1 else "8245"
18
+ app.run(f"127.0.0.1:{port}")
0
19
▼benchmarks/workloads_bt4_nativeonly.py
+12-0
1
+from justapi import JustAPIApp
2
+import json
3
+app = JustAPIApp()
4
+@app.post("/body_json")
5
+def body_json(request):
6
+ return json.loads(request["body"].decode("utf-8"))
7
+@app.post("/native_ns", native=True)
8
+def native_ns(request):
9
+ return {"ok": True}
10
+if __name__ == "__main__":
11
+ import sys
12
+ app.run(f"127.0.0.1:{sys.argv[1]}")
0
13
▼benchmarks/workloads_bt4_schemaonly.py
+9-0
1
+from justapi import JustAPIApp, Schema
2
+import json
3
+app = JustAPIApp()
4
+@app.post("/body_json")
5
+def body_json(request):
6
+ return json.loads(request["body"].decode("utf-8"))
7
+if __name__ == "__main__":
8
+ import sys
9
+ app.run(f"127.0.0.1:{sys.argv[1]}")
0
10
▼benchmarks/workloads_bt5.py
+23-0
1
+from justapi import JustAPIApp, Schema
2
+import json
3
+
4
+app = JustAPIApp()
5
+
6
+@app.post("/body_json")
7
+def body_json(request):
8
+ return json.loads(request["body"].decode("utf-8"))
9
+
10
+# Variant B: native=False, WITH schema (non-native route w/ schema)
11
+class ItemSchema(Schema):
12
+ id: int
13
+ name: str
14
+ price: float
15
+
16
+@app.post("/with_schema", schema=ItemSchema)
17
+def with_schema(request):
18
+ return json.loads(request["body"].decode("utf-8"))
19
+
20
+if __name__ == "__main__":
21
+ import sys
22
+ port = sys.argv[1] if len(sys.argv) > 1 else "8246"
23
+ app.run(f"127.0.0.1:{port}")
0
24
▼benchmarks/workloads_cmp.py
+24-0
1
+from justapi import JustAPIApp
2
+import json
3
+
4
+app = JustAPIApp()
5
+
6
+
7
+@app.post("/body_json")
8
+def body_json(request):
9
+ d = json.loads(request["body"].decode("utf-8"))
10
+ return {"ok": True}
11
+
12
+
13
+@app.post("/noop_noarg")
14
+def noop_noarg():
15
+ return {"ok": True}
16
+
17
+
18
+@app.post("/noop_req")
19
+def noop_req(request):
20
+ return {"ok": True}
21
+
22
+
23
+if __name__ == "__main__":
24
+ app.run("127.0.0.1:8252")
0
25
▼benchmarks/workloads_fastapi.py
+19-16
1
-"""FastAPI benchmark workload for Uvicorn+FastAPI baseline.
1
+from fastapi import FastAPI, Request
2
+from pydantic import BaseModel
3
+import json
2
4
3
-Usage:
4
- uvicorn benchmarks.workloads_fastapi:app --host 127.0.0.1 --port 8080 --workers 4
5
+app = FastAPI()
5
6
6
-This is the "Uvicorn+FastAPI" baseline from PROMPT.md Section 10.2.
7
-For raw ASGI baseline (no framework overhead), use workloads.py instead.
8
-"""
9
7
10
-from fastapi import FastAPI, Request
11
-from fastapi.responses import JSONResponse
8
+class Item(BaseModel):
9
+ id: int
10
+ name: str
11
+ price: float
12
12
13
-app = FastAPI()
13
+
14
+@app.post("/body_json")
15
+async def body_json(request: Request):
16
+ data = json.loads(await request.body())
17
+ return {"ok": True}
14
18
15
19
16
-@app.get("/hello")
17
-async def hello():
18
- return {"message": "hello, world"}
20
+@app.post("/validate")
21
+async def validate(item: Item):
22
+ return {"ok": True}
19
23
20
24
21
-@app.post("/echo")
22
-async def echo(request: Request):
23
- body = await request.json()
24
- return JSONResponse(content=body)
25
+@app.post("/noop")
26
+async def noop():
27
+ return {"ok": True}
25
28
▼benchmarks/workloads_justapi.py
+30-0
27
27
28
28
app.post("/echo", echo)
29
29
30
+def validate(request):
31
+ try:
32
+ data = json.loads(request["body"].decode("utf-8"))
33
+ return data
34
+ except Exception:
35
+ return {
36
+ "status": 400,
37
+ "headers": [(b"content-type", b"application/json")],
38
+ "body": b'{"error":"invalid JSON"}',
39
+ }
40
+
41
+app.post("/validate", validate)
42
+
43
+# Native fast path: same validated echo, served entirely in Rust (no Python
44
+# handler call). Uses a real justapi Schema so the Rust validator runs.
45
+from justapi import Schema
46
+
47
+class NativeItemSchema(Schema):
48
+ id: int
49
+ name: str
50
+ price: float
51
+
52
+@app.post("/validate_native", schema=NativeItemSchema, native=True)
53
+def validate_native(request):
54
+ raise AssertionError("handler must not run in native mode")
55
+
30
56
if __name__ == "__main__":
31
57
import sys
32
58
port = sys.argv[1] if len(sys.argv) > 1 else "8080"
33
59
app.run(f"127.0.0.1:{port}")
60
+
61
+@app.post("/noop")
62
+def noop(request):
63
+ return {"ok": True}
34
64
▼benchmarks/workloads_justapi_agent.py
+32-0
1
+"""JustAPI agent-native workload: request validation runs in Rust (jsonschema)."""
2
+
3
+from justapi import JustAPIApp
4
+import json
5
+
6
+app = JustAPIApp()
7
+
8
+USER_SCHEMA = {
9
+ "type": "object",
10
+ "properties": {
11
+ "user": {
12
+ "type": "object",
13
+ "properties": {"name": {"type": "string"}, "id": {"type": "integer"}},
14
+ "required": ["name", "id"],
15
+ },
16
+ "items": {"type": "array", "items": {"type": "integer"}},
17
+ "meta": {"type": "object", "properties": {"version": {"type": "string"}}},
18
+ },
19
+ "required": ["user", "items", "meta"],
20
+}
21
+
22
+
23
+@app.post("/validate", body_schema=USER_SCHEMA)
24
+def validate(request):
25
+ data = json.loads(request["body"].decode("utf-8"))
26
+ return data
27
+
28
+
29
+if __name__ == "__main__":
30
+ import sys
31
+ port = sys.argv[1] if len(sys.argv) > 1 else "8080"
32
+ app.run(f"127.0.0.1:{port}")
0
33
▼benchmarks/workloads_native_bench.py
+27-0
1
+from justapi import JustAPIApp, Schema
2
+import json
3
+
4
+app = JustAPIApp()
5
+
6
+
7
+class NativeItemSchema(Schema):
8
+ id: int
9
+ name: str
10
+ price: float
11
+
12
+
13
+@app.post("/validate_native", schema=NativeItemSchema, native=True)
14
+def validate_native(request):
15
+ raise AssertionError("handler must not run in native mode")
16
+
17
+
18
+@app.post("/body_json")
19
+def body_json(request):
20
+ d = json.loads(request["body"].decode("utf-8"))
21
+ return {"ok": True}
22
+
23
+
24
+if __name__ == "__main__":
25
+ import sys
26
+ port = sys.argv[1] if len(sys.argv) > 1 else "8080"
27
+ app.run(f"127.0.0.1:{port}")
0
28
▼benchmarks/workloads_native_query.py
+32-0
1
+"""Benchmark: native query-param fast path (GET + query_schema + native=True).
2
+
3
+Validates the query string in Rust and echoes parsed params as JSON, with no
4
+GIL/Python hop. Mirrors the body fast path but for GET routes.
5
+"""
6
+from justapi import JustAPIApp
7
+
8
+app = JustAPIApp()
9
+
10
+QUERY_SCHEMA = {
11
+ "type": "object",
12
+ "properties": {
13
+ "name": {"type": "string"},
14
+ "age": {"type": "integer", "minimum": 0},
15
+ },
16
+ "required": ["name"],
17
+}
18
+
19
+
20
+@app.get("/search", native=True, query_schema=QUERY_SCHEMA)
21
+def search():
22
+ # Never runs on the fast path (request is validated + echoed in Rust).
23
+ return {"echo": True}
24
+
25
+
26
+@app.get("/search_gil")
27
+def search_gil(name: str = "", age: int = 0):
28
+ return {"name": name, "age": age}
29
+
30
+
31
+if __name__ == "__main__":
32
+ app.run("127.0.0.1:8263")
0
33
▼benchmarks/workloads_robyn.py
+21-0
1
+"""Robyn workload matching justapi's benchmark endpoints (hello + echo)."""
2
+
3
+from robyn import Robyn
4
+
5
+app = Robyn(__file__)
6
+
7
+
8
+@app.get("/hello")
9
+def hello(request):
10
+ return {"message": "hello, world"}
11
+
12
+
13
+@app.post("/echo")
14
+def echo(request):
15
+ return request.json()
16
+
17
+
18
+if __name__ == "__main__":
19
+ import sys
20
+ port = sys.argv[1] if len(sys.argv) > 1 else "8080"
21
+ app.start(host="127.0.0.1", port=int(port))
0
22
▼benchmarks/workloads_robyn_agent.py
+33-0
1
+"""Robyn agent-native workload: request validation runs in Python (pydantic)."""
2
+
3
+from robyn import Robyn
4
+from pydantic import BaseModel, Field
5
+
6
+app = Robyn(__file__)
7
+
8
+
9
+class User(BaseModel):
10
+ name: str
11
+ id: int
12
+
13
+
14
+class Meta(BaseModel):
15
+ version: str
16
+
17
+
18
+class Payload(BaseModel):
19
+ user: User
20
+ items: list[int]
21
+ meta: Meta
22
+
23
+
24
+@app.post("/validate")
25
+def validate(request):
26
+ data = Payload.model_validate(request.json())
27
+ return data.model_dump()
28
+
29
+
30
+if __name__ == "__main__":
31
+ import sys
32
+ port = sys.argv[1] if len(sys.argv) > 1 else "8080"
33
+ app.start(host="127.0.0.1", port=int(port))
0
34
▼crates/justapi-bench/src/gpu_bench.rs
+11-35
31
31
32
32
/// GPU benchmark arguments.
33
33
#[derive(Parser, Debug)]
34
-#[command(
35
- name = "justapi-gpu-bench",
36
- about = "GPU throughput benchmark for JustAPI inference"
37
-)]
34
+#[command(name = "justapi-gpu-bench", about = "GPU throughput benchmark for JustAPI inference")]
38
35
struct Args {
39
36
/// Path to model directory (GGUF file or safetensors + config.json).
40
37
#[arg(long, default_value = None)]
96
93
};
97
94
98
95
let ttft_p50 = ttfts.get(ttfts.len() / 2).copied().unwrap_or(0.0);
99
- let ttft_p99 = ttfts
100
- .get((ttfts.len() as f64 * 0.99) as usize)
101
- .copied()
102
- .unwrap_or(0.0);
96
+ let ttft_p99 = ttfts.get((ttfts.len() as f64 * 0.99) as usize).copied().unwrap_or(0.0);
103
97
let itl_p50 = itls.get(itls.len() / 2).copied().unwrap_or(0.0);
104
- let itl_p99 = itls
105
- .get((itls.len() as f64 * 0.99) as usize)
106
- .copied()
107
- .unwrap_or(0.0);
98
+ let itl_p99 = itls.get((itls.len() as f64 * 0.99) as usize).copied().unwrap_or(0.0);
108
99
109
100
println!("### {name}");
110
101
println!();
127
118
let device = match args.device.as_str() {
128
119
"cpu" => EngineDevice::Cpu,
129
120
s if s.starts_with("cuda") => {
130
- let ordinal = s
131
- .strip_prefix("cuda:")
132
- .and_then(|n| n.parse::<usize>().ok())
133
- .unwrap_or(0);
121
+ let ordinal =
122
+ s.strip_prefix("cuda:").and_then(|n| n.parse::<usize>().ok()).unwrap_or(0);
134
123
EngineDevice::Cuda(ordinal)
135
124
}
136
125
s => anyhow::bail!("unsupported device: {s}"),
143
132
#[cfg(feature = "real")]
144
133
{
145
134
if model_path.is_dir() || model_path.extension().map_or(false, |e| e == "gguf") {
146
- engine
147
- .load(&args.model_name, model_path)
148
- .context("failed to load model")?;
135
+ engine.load(&args.model_name, model_path).context("failed to load model")?;
149
136
println!("Loaded model from: {}", model_path.display());
150
137
} else {
151
138
anyhow::bail!(
182
169
183
170
// --- Scheduler setup ---
184
171
let pool = KvBlockPool::new(4096);
185
- let config = SchedulerConfig {
186
- max_num_seqs: args.num_requests.max(1),
187
- ..Default::default()
188
- };
172
+ let config = SchedulerConfig { max_num_seqs: args.num_requests.max(1), ..Default::default() };
189
173
let scheduler = Arc::new(Mutex::new(Scheduler::new(config, pool)));
190
174
let se = Arc::new(SchedulerEngine::new(engine.clone(), scheduler));
191
175
238
222
239
223
while let Some(tok) = rx.recv().await {
240
224
if req_first {
241
- timing
242
- .ttfts
243
- .push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
225
+ timing.ttfts.push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
244
226
req_first = false;
245
227
last_tok_time = Instant::now();
246
228
}
247
229
if tok.finish_reason.is_some() {
248
230
break;
249
231
}
250
- timing
251
- .itls
252
- .push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
232
+ timing.itls.push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
253
233
last_tok_time = Instant::now();
254
234
timing.total_tokens += 1;
255
235
}
283
263
284
264
while let Some(tok) = rx.recv().await {
285
265
if req_first {
286
- timing
287
- .ttfts
288
- .push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
266
+ timing.ttfts.push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
289
267
req_first = false;
290
268
last_tok_time = Instant::now();
291
269
}
292
270
if tok.finish_reason.is_some() {
293
271
break;
294
272
}
295
- timing
296
- .itls
297
- .push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
273
+ timing.itls.push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
298
274
last_tok_time = Instant::now();
299
275
timing.total_tokens += 1;
300
276
}
301
277
▼crates/justapi-bench/src/inference_bench.rs
+16-70
76
76
NewRequest {
77
77
id,
78
78
prompt: (0..prompt_len as u32).collect(),
79
- sampling_params: SamplingParams {
80
- max_tokens,
81
- ..Default::default()
82
- },
79
+ sampling_params: SamplingParams { max_tokens, ..Default::default() },
83
80
prefix_cached_tokens: 0,
84
81
cached_blocks: Vec::new(),
85
82
}
143
140
144
141
// ---- decode pool (runs in parallel on its own GPU) ----
145
142
let sched = pd.schedule_decode();
146
- let d_cost: f64 = if sched.decode.is_empty() {
147
- 0.0
148
- } else {
149
- p.decode_us_per_token as f64
150
- };
143
+ let d_cost: f64 = if sched.decode.is_empty() { 0.0 } else { p.decode_us_per_token as f64 };
151
144
decode_clock += d_cost;
152
145
if !sched.decode.is_empty() {
153
146
// Global virtual time = the later of the two pool timelines.
237
230
let mut itls = m.itls.clone();
238
231
itls.sort_by(|a, b| a.partial_cmp(b).unwrap());
239
232
240
- let tput = if m.wall_ms > 0.0 {
241
- m.total_tokens as f64 / (m.wall_ms / 1000.0)
242
- } else {
243
- 0.0
244
- };
233
+ let tput = if m.wall_ms > 0.0 { m.total_tokens as f64 / (m.wall_ms / 1000.0) } else { 0.0 };
245
234
246
235
println!("### {name}");
247
236
println!();
248
237
println!("| Metric | p50 | p99 |");
249
238
println!("|---|---|---|");
250
- println!(
251
- "| TTFT (ms) | {:.2} | {:.2} |",
252
- percentile(&ttfts, 0.5),
253
- percentile(&ttfts, 0.99)
254
- );
255
- println!(
256
- "| ITL (ms) | {:.2} | {:.2} |",
257
- percentile(&itls, 0.5),
258
- percentile(&itls, 0.99)
259
- );
239
+ println!("| TTFT (ms) | {:.2} | {:.2} |", percentile(&ttfts, 0.5), percentile(&ttfts, 0.99));
240
+ println!("| ITL (ms) | {:.2} | {:.2} |", percentile(&itls, 0.5), percentile(&itls, 0.99));
260
241
println!("| Throughput (tok/s) | {:.0} | |", tput);
261
242
println!("| Total tokens | {} | |", m.total_tokens);
262
243
println!("| Wall time (ms) | {:.2} | |", m.wall_ms);
295
276
fn bench_naive(n: usize, prompt_len: usize, max_tokens: usize) -> (u64, f64) {
296
277
let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap());
297
278
engine.register_mock(REAL_MODEL);
298
- let rt = tokio::runtime::Builder::new_current_thread()
299
- .build()
300
- .unwrap();
279
+ let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
301
280
302
281
let prompt: Vec<u32> = (0..prompt_len as u32).collect();
303
- let params = SamplingParams {
304
- max_tokens,
305
- ..Default::default()
306
- };
282
+ let params = SamplingParams { max_tokens, ..Default::default() };
307
283
308
284
let start = Instant::now();
309
285
let total: u64 = rt.block_on(async {
310
286
let mut total = 0u64;
311
287
for _ in 0..n {
312
- let mut rx = engine
313
- .generate(REAL_MODEL, &prompt, params.clone())
314
- .unwrap();
288
+ let mut rx = engine.generate(REAL_MODEL, &prompt, params.clone()).unwrap();
315
289
while let Some(tok) = rx.recv().await {
316
290
if tok.finish_reason.is_some() {
317
291
break;
331
305
let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap());
332
306
engine.register_mock(REAL_MODEL);
333
307
let pool = KvBlockPool::new(4096);
334
- let config = SchedulerConfig {
335
- max_num_seqs: n.max(1),
336
- ..Default::default()
337
- };
308
+ let config = SchedulerConfig { max_num_seqs: n.max(1), ..Default::default() };
338
309
let scheduler = Arc::new(Mutex::new(Scheduler::new(config, pool)));
339
310
let se = Arc::new(SchedulerEngine::new(engine.clone(), scheduler));
340
311
341
- let rt = tokio::runtime::Builder::new_current_thread()
342
- .build()
343
- .unwrap();
312
+ let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
344
313
345
314
let prompt: Vec<u32> = (0..prompt_len as u32).collect();
346
- let params = SamplingParams {
347
- max_tokens,
348
- ..Default::default()
349
- };
315
+ let params = SamplingParams { max_tokens, ..Default::default() };
350
316
351
317
let start = Instant::now();
352
318
let total: u64 = rt.block_on(async {
393
359
let coll = run_collocated(&params);
394
360
let disagg = run_disaggregated(&params);
395
361
396
- report(
397
- "Collocated (single pool, prefill+decode serialised)",
398
- &coll,
399
- &params,
400
- );
401
- report(
402
- "Disaggregated (independent prefill/decode pools)",
403
- &disagg,
404
- &params,
405
- );
362
+ report("Collocated (single pool, prefill+decode serialised)", &coll, &params);
363
+ report("Disaggregated (independent prefill/decode pools)", &disagg, &params);
406
364
407
365
let mut coll_itl = coll.itls.clone();
408
366
coll_itl.sort_by(|a, b| a.partial_cmp(b).unwrap());
411
369
412
370
let coll_p99 = percentile(&coll_itl, 0.99);
413
371
let disagg_p99 = percentile(&disagg_itl, 0.99);
414
- let itl_improvement = if disagg_p99 > 0.0 {
415
- coll_p99 / disagg_p99
416
- } else {
417
- 1.0
418
- };
419
- let tput_improvement = if coll.wall_ms > 0.0 {
420
- disagg.wall_ms / coll.wall_ms
421
- } else {
422
- 1.0
423
- };
372
+ let itl_improvement = if disagg_p99 > 0.0 { coll_p99 / disagg_p99 } else { 1.0 };
373
+ let tput_improvement = if coll.wall_ms > 0.0 { disagg.wall_ms / coll.wall_ms } else { 1.0 };
424
374
425
375
println!("---");
426
376
println!();
455
405
456
406
let naive_tput = (naive_tokens as f64) / (naive_ms / 1000.0);
457
407
let sched_tput = (sched_tokens as f64) / (sched_ms / 1000.0);
458
- let overhead = if naive_tput > 0.0 {
459
- sched_tput / naive_tput * 100.0
460
- } else {
461
- 0.0
462
- };
408
+ let overhead = if naive_tput > 0.0 { sched_tput / naive_tput * 100.0 } else { 0.0 };
463
409
464
410
println!("| Path | Total tokens | Wall (ms) | Throughput (tok/s) | Overhead vs naive |");
465
411
println!("|---|---|---|---|---|");
466
412
▼crates/justapi-bench/src/main.rs
+2-8
23
23
24
24
fn payload() -> NestedPayload {
25
25
NestedPayload {
26
- user: User {
27
- name: "test".into(),
28
- id: 42,
29
- },
26
+ user: User { name: "test".into(), id: 42 },
30
27
items: vec![1, 2, 3],
31
- meta: Metadata {
32
- version: "1.0".into(),
33
- timestamp: 1700000000,
34
- },
28
+ meta: Metadata { version: "1.0".into(), timestamp: 1700000000 },
35
29
}
36
30
}
37
31
38
32
▼crates/justapi-cli/src/gen_client.rs
+30-76
17
17
match s.to_ascii_lowercase().as_str() {
18
18
"py" | "python" => Ok(ClientLanguage::Python),
19
19
"ts" | "typescript" | "js" => Ok(ClientLanguage::Typescript),
20
- other => anyhow::bail!(
21
- "unsupported language '{}' (expected python or typescript)",
22
- other
23
- ),
20
+ other => {
21
+ anyhow::bail!("unsupported language '{}' (expected python or typescript)", other)
22
+ }
24
23
}
25
24
}
26
25
}
142
141
_ => None,
143
142
});
144
143
let arg = sanitize_ident(&p.name);
145
- let param = Param {
146
- original: p.name.clone(),
147
- arg,
148
- ty: t,
149
- };
144
+ let param = Param { original: p.name.clone(), arg, ty: t };
150
145
match p.location {
151
146
ParameterLocation::Path => path_params.push(param),
152
147
ParameterLocation::Query => query_params.push(param),
155
150
}
156
151
}
157
152
158
- let name = op
159
- .operation_id
160
- .clone()
161
- .map(|id| sanitize_ident(&id))
162
- .unwrap_or_else(|| {
163
- let base = raw_path
164
- .split('/')
165
- .filter(|s| !s.is_empty())
166
- .map(sanitize_ident)
167
- .collect::<Vec<_>>()
168
- .join("_");
169
- format!("{}_{}", method, base)
170
- });
153
+ let name = op.operation_id.clone().map(|id| sanitize_ident(&id)).unwrap_or_else(|| {
154
+ let base = raw_path
155
+ .split('/')
156
+ .filter(|s| !s.is_empty())
157
+ .map(sanitize_ident)
158
+ .collect::<Vec<_>>()
159
+ .join("_");
160
+ format!("{}_{}", method, base)
161
+ });
171
162
172
163
let has_body = op.request_body.is_some();
173
164
322
313
fmt_path = fmt_path.replace(&format!("{{{}}}", p.original), &format!("{{{}}}", p.arg));
323
314
fmt_args.push(format!("{}={}", p.arg, p.arg));
324
315
}
325
- format!(
326
- " path = {}.format({})",
327
- fmt_path.quoted(),
328
- fmt_args.join(", ")
329
- )
316
+ format!(" path = {}.format({})", fmt_path.quoted(), fmt_args.join(", "))
330
317
};
331
318
332
319
let mut body_lines: Vec<String> = Vec::new();
333
320
if !op.query_params.is_empty() {
334
- let keys: Vec<String> = op
335
- .query_params
336
- .iter()
337
- .map(|p| format!("{:?}: {}", p.original, p.arg))
338
- .collect();
321
+ let keys: Vec<String> =
322
+ op.query_params.iter().map(|p| format!("{:?}: {}", p.original, p.arg)).collect();
339
323
body_lines.push(format!(
340
324
" params = {{k: v for k, v in {{{}}}.items() if v is not None}}",
341
325
keys.join(", ")
360
344
})
361
345
.collect();
362
346
parts.push(" **(extra_headers or {})".to_string());
363
- body_lines.push(format!(
364
- " headers = {{\n{}\n }}",
365
- parts.join("\n")
366
- ));
347
+ body_lines.push(format!(" headers = {{\n{}\n }}", parts.join("\n")));
367
348
} else {
368
349
call_args.push("headers=extra_headers".to_string());
369
350
}
371
352
let call = format!(
372
353
" result = self._request({}, path{})",
373
354
op.method.quoted(),
374
- if call_args.is_empty() {
375
- String::new()
376
- } else {
377
- format!(", {}", call_args.join(", "))
378
- }
355
+ if call_args.is_empty() { String::new() } else { format!(", {}", call_args.join(", ")) }
379
356
);
380
357
381
358
let body = format!(
481
458
let repl: Vec<String> = op
482
459
.path_params
483
460
.iter()
484
- .map(|p| {
485
- format!(
486
- "path = path.replace(`{{{}}}`, String({}));",
487
- p.original, p.arg
488
- )
489
- })
461
+ .map(|p| format!("path = path.replace(`{{{}}}`, String({}));", p.original, p.arg))
490
462
.collect();
491
- format!(
492
- " let path = {};\n {}",
493
- op.raw_path.quoted(),
494
- repl.join("\n ")
495
- )
463
+ format!(" let path = {};\n {}", op.raw_path.quoted(), repl.join("\n "))
496
464
};
497
465
body_lines.push(path_stmt);
498
466
531
499
call_args.push("extraHeaders".to_string());
532
500
}
533
501
534
- let summary = op
535
- .summary
536
- .clone()
537
- .map(|s| format!(" /** {} */\n", s))
538
- .unwrap_or_default();
502
+ let summary = op.summary.clone().map(|s| format!(" /** {} */\n", s)).unwrap_or_default();
539
503
540
504
let call = format!(
541
505
" return this.request(\"{}\", path{}) as Promise<unknown>;",
545
509
} else {
546
510
format!(
547
511
", {{ {} }}",
548
- call_args
549
- .iter()
550
- .map(|a| format!("{a}: {}", a))
551
- .collect::<Vec<_>>()
552
- .join(", ")
512
+ call_args.iter().map(|a| format!("{a}: {}", a)).collect::<Vec<_>>().join(", ")
553
513
)
554
514
}
555
515
);
627
587
m
628
588
},
629
589
deprecated: None,
590
+ extensions: Default::default(),
630
591
},
631
592
)
632
593
.operation(
657
618
m
658
619
},
659
620
deprecated: None,
621
+ extensions: Default::default(),
660
622
},
661
623
)
662
624
.operation(
674
636
let mut c = std::collections::BTreeMap::new();
675
637
c.insert(
676
638
"application/json".to_string(),
677
- justapi_core::openapi::MediaType {
678
- schema: Some(body_schema),
679
- },
639
+ justapi_core::openapi::MediaType { schema: Some(body_schema) },
680
640
);
681
641
c
682
642
},
694
654
m
695
655
},
696
656
deprecated: None,
657
+ extensions: Default::default(),
697
658
},
698
659
)
699
660
.build()
737
698
738
699
#[test]
739
700
fn test_language_parsing() {
740
- assert_eq!(
741
- "python".parse::<ClientLanguage>().unwrap(),
742
- ClientLanguage::Python
743
- );
744
- assert_eq!(
745
- "ts".parse::<ClientLanguage>().unwrap(),
746
- ClientLanguage::Typescript
747
- );
701
+ assert_eq!("python".parse::<ClientLanguage>().unwrap(), ClientLanguage::Python);
702
+ assert_eq!("ts".parse::<ClientLanguage>().unwrap(), ClientLanguage::Typescript);
748
703
assert!("cobol".parse::<ClientLanguage>().is_err());
749
704
}
750
705
775
730
let mut c = std::collections::BTreeMap::new();
776
731
c.insert(
777
732
"application/x-www-form-urlencoded".to_string(),
778
- MediaType {
779
- schema: Some(Schema::string()),
780
- },
733
+ MediaType { schema: Some(Schema::string()) },
781
734
);
782
735
c
783
736
},
795
748
m
796
749
},
797
750
deprecated: None,
751
+ extensions: Default::default(),
798
752
},
799
753
)
800
754
.build();
801
755
▼crates/justapi-cli/src/main.rs
+24-72
11
11
mod watcher;
12
12
13
13
#[derive(Parser)]
14
-#[command(
15
- name = "justapi",
16
- about = "JustAPI Runtime — Python application server"
17
-)]
14
+#[command(name = "justapi", about = "JustAPI Runtime — Python application server")]
18
15
struct Cli {
19
16
#[command(subcommand)]
20
17
command: Commands,
380
377
use justapi_inference::{KvBlockPool, Scheduler, SchedulerConfig, SchedulerEngine};
381
378
let eng = engine.as_ref().unwrap().clone();
382
379
let pool = KvBlockPool::new(pool_blocks.max(64));
383
- let config = SchedulerConfig {
384
- max_num_seqs: max_seqs.max(1),
385
- ..Default::default()
386
- };
380
+ let config =
381
+ SchedulerConfig { max_num_seqs: max_seqs.max(1), ..Default::default() };
387
382
let scheduler =
388
383
std::sync::Arc::new(std::sync::Mutex::new(Scheduler::new(config, pool)));
389
384
let se = std::sync::Arc::new(SchedulerEngine::new(eng, scheduler));
462
457
463
458
#[cfg(feature = "tls")]
464
459
if let (Some(cert), Some(key)) = (tls_cert.clone(), tls_key.clone()) {
465
- let config = justapi_core::server::TlsConfig {
466
- cert_path: cert,
467
- key_path: key,
468
- };
460
+ let config =
461
+ justapi_core::server::TlsConfig { cert_path: cert, key_path: key };
469
462
let result = server.with_tls(config).run().await;
470
463
justapi_core::tracing_setup::shutdown_tracing();
471
464
return result;
515
508
516
509
#[cfg(feature = "tls")]
517
510
if let (Some(cert), Some(key)) = (tls_cert.clone(), tls_key.clone()) {
518
- let config = justapi_core::server::TlsConfig {
519
- cert_path: cert,
520
- key_path: key,
521
- };
511
+ let config = justapi_core::server::TlsConfig { cert_path: cert, key_path: key };
522
512
let result = server.with_tls(config).run().await;
523
513
justapi_core::tracing_setup::shutdown_tracing();
524
514
return result;
534
524
if !dir.exists() {
535
525
anyhow::bail!("Migrations directory does not exist: {:?}", dir);
536
526
}
537
- let config = justapi_core::db::DatabaseConfig {
538
- url,
539
- max_connections: 1,
540
- kind: None,
541
- };
527
+ let config =
528
+ justapi_core::db::DatabaseConfig { url, max_connections: 1, kind: None };
542
529
let mut mgr = justapi_core::db::PoolManager::new();
543
530
let pool = mgr.init("", config).await?;
544
531
555
542
if !dir.exists() {
556
543
anyhow::bail!("Migrations directory does not exist: {:?}", dir);
557
544
}
558
- let config = justapi_core::db::DatabaseConfig {
559
- url,
560
- max_connections: 1,
561
- kind: None,
562
- };
545
+ let config =
546
+ justapi_core::db::DatabaseConfig { url, max_connections: 1, kind: None };
563
547
let mut mgr = justapi_core::db::PoolManager::new();
564
548
let pool = mgr.init("", config).await?;
565
549
566
550
let mut migrator = justapi_core::db::Migrator::new();
567
551
migrator.discover(&dir).map_err(|e| anyhow::anyhow!(e))?;
568
- migrator
569
- .rollback_one(&pool)
570
- .await
571
- .map_err(|e| anyhow::anyhow!(e))?;
552
+ migrator.rollback_one(&pool).await.map_err(|e| anyhow::anyhow!(e))?;
572
553
tracing::info!("Rolled back most recent migration");
573
554
Ok(())
574
555
}
575
556
DbCommands::Init { url, dir } => {
576
- let config = justapi_core::db::DatabaseConfig {
577
- url,
578
- max_connections: 1,
579
- kind: None,
580
- };
557
+ let config =
558
+ justapi_core::db::DatabaseConfig { url, max_connections: 1, kind: None };
581
559
let mut mgr = justapi_core::db::PoolManager::new();
582
560
let pool = mgr.init("", config).await?;
583
561
585
563
if dir.exists() {
586
564
migrator.discover(&dir).map_err(|e| anyhow::anyhow!(e))?;
587
565
}
588
- migrator
589
- .ensure_tracking_table(&pool)
590
- .await
591
- .map_err(|e| anyhow::anyhow!(e))?;
566
+ migrator.ensure_tracking_table(&pool).await.map_err(|e| anyhow::anyhow!(e))?;
592
567
tracing::info!("Migrations tracking table initialized");
593
568
Ok(())
594
569
}
599
574
.map_err(|e| anyhow::anyhow!("Cannot read {}: {}", path.display(), e))?;
600
575
let doc: serde_json::Value = serde_json::from_str(&spec)
601
576
.map_err(|e| anyhow::anyhow!("Invalid OpenAPI spec: {}", e))?;
602
- let paths = doc
603
- .get("paths")
604
- .and_then(|p| p.as_object())
605
- .map(|p| p.len())
606
- .unwrap_or(0);
577
+ let paths =
578
+ doc.get("paths").and_then(|p| p.as_object()).map(|p| p.len()).unwrap_or(0);
607
579
println!("Loaded OpenAPI spec with {} path(s)", paths);
608
580
if let Some(paths_obj) = doc.get("paths").and_then(|p| p.as_object()) {
609
581
for (path, item) in paths_obj {
634
606
Diagnostic::new(DiagLevel::Hint, "Rust toolchain available").emit();
635
607
636
608
// Check Python availability
637
- let py_ok = std::process::Command::new("python3")
638
- .arg("--version")
639
- .output()
640
- .is_ok();
609
+ let py_ok = std::process::Command::new("python3").arg("--version").output().is_ok();
641
610
if py_ok {
642
611
Diagnostic::new(DiagLevel::Hint, "Python 3 detected").emit();
643
612
} else {
644
- error_catalog::E006_PYTHON_NOT_FOUND
645
- .into_diagnostic()
646
- .emit();
613
+ error_catalog::E006_PYTHON_NOT_FOUND.to_diagnostic().emit();
647
614
}
648
615
649
616
// Check config file
684
651
if env_path.exists() {
685
652
Diagnostic::new(DiagLevel::Hint, ".env file found").emit();
686
653
} else {
687
- Diagnostic::new(
688
- DiagLevel::Warning,
689
- "No .env file (recommended for configuration)",
690
- )
691
- .emit();
654
+ Diagnostic::new(DiagLevel::Warning, "No .env file (recommended for configuration)")
655
+ .emit();
692
656
}
693
657
694
658
// Check Dockerfile
696
660
if dockerfile.exists() {
697
661
Diagnostic::new(DiagLevel::Hint, "Dockerfile found").emit();
698
662
} else {
699
- Diagnostic::new(
700
- DiagLevel::Info,
701
- "No Dockerfile (use `justapi new` to create one)",
702
- )
703
- .emit();
663
+ Diagnostic::new(DiagLevel::Info, "No Dockerfile (use `justapi new` to create one)")
664
+ .emit();
704
665
}
705
666
706
667
println!();
719
680
println!("OpenAPI spec written to {}", output.display());
720
681
Ok(())
721
682
}
722
- GenCommands::Client {
723
- spec,
724
- output,
725
- language,
726
- } => {
683
+ GenCommands::Client { spec, output, language } => {
727
684
let lang: gen_client::ClientLanguage = language.parse()?;
728
685
let json = std::fs::read_to_string(&spec)
729
686
.map_err(|e| anyhow::anyhow!("Cannot read spec {}: {}", spec.display(), e))?;
850
807
851
808
Ok(())
852
809
}
853
- Commands::Profile {
854
- addr,
855
- duration,
856
- connections,
857
- output,
858
- } => {
810
+ Commands::Profile { addr, duration, connections, output } => {
859
811
println!("🔬 JustAPI Profiler");
860
812
println!(" Target: {addr}");
861
813
println!(" Duration: {duration}s");
862
814
▼crates/justapi-cli/src/profile.rs
+3-12
71
71
let start = Instant::now();
72
72
let mut stream = TcpStream::connect(addr).await.ok()?;
73
73
74
- let request = format!(
75
- "GET / HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
76
- addr
77
- );
74
+ let request = format!("GET / HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", addr);
78
75
stream.write_all(request.as_bytes()).await.ok()?;
79
76
80
77
let mut buf = vec![0u8; 4096];
195
192
0.0
196
193
};
197
194
198
- out.push_str(&format!(
199
- " Duration: {:.1}s\n",
200
- report.duration.as_secs_f64()
201
- ));
195
+ out.push_str(&format!(" Duration: {:.1}s\n", report.duration.as_secs_f64()));
202
196
out.push_str(&format!(" Total requests: {}\n", report.total_requests));
203
197
out.push_str(&format!(" Requests/sec: {rps:.1}\n\n"));
204
198
213
207
out.push_str(" Latency percentiles:\n");
214
208
out.push_str(&format!(" p50: {:.3}ms\n", p50.as_secs_f64() * 1000.0));
215
209
out.push_str(&format!(" p95: {:.3}ms\n", p95.as_secs_f64() * 1000.0));
216
- out.push_str(&format!(
217
- " p99: {:.3}ms\n\n",
218
- p99.as_secs_f64() * 1000.0
219
- ));
210
+ out.push_str(&format!(" p99: {:.3}ms\n\n", p99.as_secs_f64() * 1000.0));
220
211
221
212
out.push_str(" Latency histogram:\n");
222
213
out.push_str(&render_histogram(&sorted, 10, 30));
223
214
▼crates/justapi-cli/src/watcher.rs
+10-26
24
24
/// Returns `true` if the given path should be ignored because it resides
25
25
/// inside one of the [`EXCLUDED_DIRS`].
26
26
pub fn should_ignore_path(path: &Path) -> bool {
27
- path.components().any(|c| {
28
- c.as_os_str()
29
- .to_str()
30
- .map(|s| EXCLUDED_DIRS.contains(&s))
31
- .unwrap_or(false)
32
- })
27
+ path.components()
28
+ .any(|c| c.as_os_str().to_str().map(|s| EXCLUDED_DIRS.contains(&s)).unwrap_or(false))
33
29
}
34
30
35
31
/// Returns `true` if `path` has one of the given extensions.
56
52
extra_extensions: &[String],
57
53
) -> Result<mpsc::Receiver<()>, anyhow::Error> {
58
54
// Build the full extension list.
59
- let mut extensions: Vec<String> = DEFAULT_WATCH_EXTENSIONS
60
- .iter()
61
- .map(|s| (*s).to_owned())
62
- .collect();
55
+ let mut extensions: Vec<String> =
56
+ DEFAULT_WATCH_EXTENSIONS.iter().map(|s| (*s).to_owned()).collect();
63
57
for ext in extra_extensions {
64
58
let ext = ext.strip_prefix('.').unwrap_or(ext).to_owned();
65
59
if !extensions.contains(&ext) {
74
68
let (reload_tx, reload_rx) = mpsc::channel::<()>(1);
75
69
76
70
let exts = extensions.clone();
77
- let mut debouncer = new_debouncer(
78
- Duration::from_millis(300),
79
- move |result: DebounceEventResult| {
71
+ let mut debouncer =
72
+ new_debouncer(Duration::from_millis(300), move |result: DebounceEventResult| {
80
73
if let Ok(events) = result {
81
74
for event in &events {
82
75
if should_ignore_path(&event.path) {
94
87
}
95
88
}
96
89
}
97
- },
98
- )?;
90
+ })?;
99
91
100
- debouncer
101
- .watcher()
102
- .watch(watch_dir, RecursiveMode::Recursive)?;
92
+ debouncer.watcher().watch(watch_dir, RecursiveMode::Recursive)?;
103
93
104
94
tokio::spawn(async move {
105
95
// Keep the debouncer alive as long as the task runs.
165
155
166
156
#[test]
167
157
fn test_has_watched_extension_matches() {
168
- let exts: Vec<String> = vec!["py", "toml", "json"]
169
- .into_iter()
170
- .map(String::from)
171
- .collect();
158
+ let exts: Vec<String> = vec!["py", "toml", "json"].into_iter().map(String::from).collect();
172
159
assert!(has_watched_extension(&PathBuf::from("app/main.py"), &exts));
173
160
assert!(has_watched_extension(&PathBuf::from("config.toml"), &exts));
174
161
assert!(has_watched_extension(&PathBuf::from("data.json"), &exts));
176
163
177
164
#[test]
178
165
fn test_has_watched_extension_rejects_non_matching() {
179
- let exts: Vec<String> = vec!["py", "toml", "json"]
180
- .into_iter()
181
- .map(String::from)
182
- .collect();
166
+ let exts: Vec<String> = vec!["py", "toml", "json"].into_iter().map(String::from).collect();
183
167
assert!(!has_watched_extension(&PathBuf::from("style.css"), &exts));
184
168
assert!(!has_watched_extension(&PathBuf::from("binary"), &exts));
185
169
assert!(!has_watched_extension(&PathBuf::from("readme.md"), &exts));
186
170
▼crates/justapi-core/Cargo.toml
+1-0
74
74
futures = "0.3"
75
75
serde_json.workspace = true
76
76
tokio-tungstenite = { workspace = true, features = [] }
77
+rcgen = "0.13"
77
78
78
79
[build-dependencies]
79
80
tonic-build = "0.14.6"
80
81
▼crates/justapi-core/src/alerting.rs
+5-19
46
46
47
47
impl AlertingConfig {
48
48
pub fn new(webhook_url: &str, _channel: AlertChannel) -> Self {
49
- Self {
50
- webhook_url: Some(webhook_url.to_string()),
51
- ..Default::default()
52
- }
49
+ Self { webhook_url: Some(webhook_url.to_string()), ..Default::default() }
53
50
}
54
51
55
52
/// Send an alert. When a webhook_url is configured, dispatches to the
126
123
fn build_generic_payload(app: &str, severity: &AlertSeverity, message: &str) -> String {
127
124
let mut m = serde_json::Map::new();
128
125
m.insert("app".into(), serde_json::Value::String(app.into()));
129
- m.insert(
130
- "severity".into(),
131
- serde_json::Value::String(severity.to_string()),
132
- );
126
+ m.insert("severity".into(), serde_json::Value::String(severity.to_string()));
133
127
m.insert("message".into(), serde_json::Value::String(message.into()));
134
- m.insert(
135
- "timestamp".into(),
136
- serde_json::Value::Number(unix_timestamp().into()),
137
- );
128
+ m.insert("timestamp".into(), serde_json::Value::Number(unix_timestamp().into()));
138
129
serde_json::to_string(&m).unwrap_or_else(|_| "{}".to_string())
139
130
}
140
131
146
137
let uri: hyper::http::Uri = url.parse()?;
147
138
let scheme = uri.scheme_str().unwrap_or("http");
148
139
if scheme != "http" {
149
- return Err(anyhow::anyhow!(
150
- "webhook URL scheme '{}' not supported (use HTTP)",
151
- scheme
152
- ));
140
+ return Err(anyhow::anyhow!("webhook URL scheme '{}' not supported (use HTTP)", scheme));
153
141
}
154
- let host = uri
155
- .host()
156
- .ok_or_else(|| anyhow::anyhow!("webhook URL missing host"))?;
142
+ let host = uri.host().ok_or_else(|| anyhow::anyhow!("webhook URL missing host"))?;
157
143
let port = uri.port_u16().unwrap_or(80);
158
144
let path = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
159
145
160
146
▼crates/justapi-core/src/audit.rs
+1-4
14
14
15
15
impl AuditRule {
16
16
pub fn new() -> Self {
17
- Self {
18
- methods: Vec::new(),
19
- paths: Vec::new(),
20
- }
17
+ Self { methods: Vec::new(), paths: Vec::new() }
21
18
}
22
19
23
20
pub fn method(mut self, method: Method) -> Self {
24
21
▼crates/justapi-core/src/batching.rs
+1-4
22
22
/// Send a request to the batching queue and wait for the response.
23
23
pub async fn execute(&self, payload: Req) -> Result<Res, &'static str> {
24
24
let (tx, rx) = oneshot::channel();
25
- let req = BatchRequest {
26
- payload,
27
- responder: tx,
28
- };
25
+ let req = BatchRequest { payload, responder: tx };
29
26
30
27
if self.sender.send(req).await.is_err() {
31
28
return Err("Batcher is closed");
32
29
▼crates/justapi-core/src/coalesce.rs
+22-58
56
56
}
57
57
// `Infallible` can never be produced by `Full`, so this map is total.
58
58
builder
59
- .body(UnsyncBoxBody::new(Full::new(self.body.clone()).map_err(
60
- |e: std::convert::Infallible| -> anyhow::Error { match e {} },
61
- )))
59
+ .body(UnsyncBoxBody::new(
60
+ Full::new(self.body.clone())
61
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
62
+ ))
62
63
.expect("static response construction never fails")
63
64
}
64
65
}
105
106
match Pin::new(&mut this.inner).poll_frame(cx) {
106
107
Poll::Ready(Some(Ok(frame))) => {
107
108
if let Some(chunk) = frame.data_ref() {
108
- this.acc
109
- .lock()
110
- .expect("coalesce buffer poisoned")
111
- .extend_from_slice(chunk);
109
+ this.acc.lock().expect("coalesce buffer poisoned").extend_from_slice(chunk);
112
110
}
113
111
Poll::Ready(Some(Ok(frame)))
114
112
}
115
113
Poll::Ready(None) => {
116
114
if !this.finished {
117
115
this.finished = true;
118
- let full = this
119
- .acc
120
- .lock()
121
- .expect("coalesce buffer poisoned")
122
- .split()
123
- .freeze();
116
+ let full = this.acc.lock().expect("coalesce buffer poisoned").split().freeze();
124
117
this_tx_send(&this.tx, full, this.status, &this.headers);
125
118
}
126
119
Poll::Ready(None)
138
131
fn drop(&mut self) {
139
132
if !self.finished {
140
133
self.finished = true;
141
- let full = self
142
- .acc
143
- .lock()
144
- .expect("coalesce buffer poisoned")
145
- .split()
146
- .freeze();
134
+ let full = self.acc.lock().expect("coalesce buffer poisoned").split().freeze();
147
135
this_tx_send(&self.tx, full, self.status, &self.headers);
148
136
}
149
137
}
156
144
status: StatusCode,
157
145
headers: &HeaderMap,
158
146
) {
159
- let _ = tx.send(Outcome::Ok(CoalescedResponse {
160
- status,
161
- headers: headers.clone(),
162
- body,
163
- }));
147
+ let _ = tx.send(Outcome::Ok(CoalescedResponse { status, headers: headers.clone(), body }));
164
148
}
165
149
166
150
/// Middleware that collapses concurrent identical requests into a single
173
157
impl RequestCoalescer {
174
158
/// Create a coalescer with default settings (no extra headers in the key).
175
159
pub fn new() -> Self {
176
- Self {
177
- in_flight: AsyncMutex::new(HashMap::new()),
178
- include_headers: Vec::new(),
179
- }
160
+ Self { in_flight: AsyncMutex::new(HashMap::new()), include_headers: Vec::new() }
180
161
}
181
162
182
163
/// Include the values of the given request headers in the coalesce key.
187
168
self
188
169
}
189
170
190
- fn key_of(&self, req: &Request<hyper::body::Incoming>) -> CoalesceKey {
171
+ fn key_of<B: Send + 'static>(&self, req: &Request<B>) -> CoalesceKey {
191
172
let mut headers = Vec::with_capacity(self.include_headers.len());
192
173
for name in &self.include_headers {
193
174
if let Some(value) = req.headers().get(name) {
194
175
headers.push((name.clone(), value.clone()));
195
176
}
196
177
}
197
- CoalesceKey {
198
- method: req.method().clone(),
199
- uri: req.uri().clone(),
200
- headers,
201
- }
178
+ CoalesceKey { method: req.method().clone(), uri: req.uri().clone(), headers }
202
179
}
203
180
}
204
181
209
186
}
210
187
211
188
#[async_trait]
212
-impl Middleware<hyper::body::Incoming> for RequestCoalescer {
213
- async fn handle(
214
- &self,
215
- req: Request<hyper::body::Incoming>,
216
- next: Next<'_, hyper::body::Incoming>,
217
- ) -> Result<Response<ResponseBody>> {
189
+impl<B: Send + 'static> Middleware<B> for RequestCoalescer {
190
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
218
191
let key = self.key_of(&req);
219
192
220
193
// Atomically check for an in-flight leader or claim leadership.
239
212
}
240
213
241
214
impl RequestCoalescer {
242
- async fn wait_for_leader(
215
+ async fn wait_for_leader<B: Send + 'static>(
243
216
&self,
244
217
key: CoalesceKey,
245
218
tx: Arc<watch::Sender<Outcome>>,
246
- req: Request<hyper::body::Incoming>,
247
- next: Next<'_, hyper::body::Incoming>,
219
+ req: Request<B>,
220
+ next: Next<'_, B>,
248
221
) -> Result<Response<ResponseBody>> {
249
222
let mut rx = tx.subscribe();
250
223
loop {
262
235
self.run_leader(key, tx, req, next).await
263
236
}
264
237
265
- async fn run_leader(
238
+ async fn run_leader<B: Send + 'static>(
266
239
&self,
267
240
key: CoalesceKey,
268
241
tx: Arc<watch::Sender<Outcome>>,
269
- req: Request<hyper::body::Incoming>,
270
- next: Next<'_, hyper::body::Incoming>,
242
+ req: Request<B>,
243
+ next: Next<'_, B>,
271
244
) -> Result<Response<ResponseBody>> {
272
245
let result = next.run(req).await;
273
246
// Leadership for this key ends now; waiters already hold their own
351
324
#[tokio::test]
352
325
async fn single_request_runs_handler_once() {
353
326
let counter = Arc::new(AtomicU64::new(0));
354
- let client = client_with(
355
- counting_handler(counter.clone(), 0),
356
- RequestCoalescer::new(),
357
- );
327
+ let client = client_with(counting_handler(counter.clone(), 0), RequestCoalescer::new());
358
328
let resp = client.get("/a").await.unwrap();
359
329
assert_eq!(resp.status, 200);
360
330
assert_eq!(resp.body, br#"{"hello":"world"}"#);
364
334
#[tokio::test]
365
335
async fn concurrent_identical_gets_coalesce_to_one_handler_call() {
366
336
let counter = Arc::new(AtomicU64::new(0));
367
- let client = client_with(
368
- counting_handler(counter.clone(), 50),
369
- RequestCoalescer::new(),
370
- );
337
+ let client = client_with(counting_handler(counter.clone(), 50), RequestCoalescer::new());
371
338
372
339
let mut futs = Vec::new();
373
340
for _ in 0..10 {
391
358
#[tokio::test]
392
359
async fn distinct_paths_are_not_coalesced() {
393
360
let counter = Arc::new(AtomicU64::new(0));
394
- let client = client_with(
395
- counting_handler(counter.clone(), 20),
396
- RequestCoalescer::new(),
397
- );
361
+ let client = client_with(counting_handler(counter.clone(), 20), RequestCoalescer::new());
398
362
399
363
let (a, b) = futures::future::join(client.get("/a"), client.get("/b")).await;
400
364
let _ = (a, b);
401
365
▼crates/justapi-core/src/compress.rs
+15-41
6
6
use flate2::Compression;
7
7
use http_body_util::combinators::UnsyncBoxBody;
8
8
use http_body_util::BodyExt;
9
-use hyper::body::{Bytes, Incoming};
9
+use hyper::body::Bytes;
10
10
use hyper::{Request, Response};
11
11
12
12
use crate::ResponseBody;
137
137
}
138
138
139
139
#[async_trait]
140
-impl crate::middleware::Middleware<Incoming> for CompressionMiddleware {
140
+impl<B: Send + 'static> crate::middleware::Middleware<B> for CompressionMiddleware {
141
141
async fn handle(
142
142
&self,
143
- req: Request<Incoming>,
144
- next: crate::middleware::Next<'_, Incoming>,
143
+ req: Request<B>,
144
+ next: crate::middleware::Next<'_, B>,
145
145
) -> Result<Response<ResponseBody>> {
146
146
// Determine client's accepted encoding
147
- let accept_encoding = req
148
- .headers()
149
- .get("accept-encoding")
150
- .and_then(|v| v.to_str().ok())
151
- .unwrap_or("");
147
+ let accept_encoding =
148
+ req.headers().get("accept-encoding").and_then(|v| v.to_str().ok()).unwrap_or("");
152
149
153
150
let encoding = Encoding::from_accept_encoding(accept_encoding);
154
151
160
157
let resp = next.run(req).await?;
161
158
162
159
// Check if the response is worth compressing
163
- let content_type = resp
164
- .headers()
165
- .get("content-type")
166
- .and_then(|v| v.to_str().ok())
167
- .unwrap_or("");
160
+ let content_type =
161
+ resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("");
168
162
169
163
if !is_compressible_content_type(content_type) {
170
164
return Ok(resp);
203
197
}
204
198
205
199
let mut parts = parts;
206
- parts.headers.insert(
207
- "content-encoding",
208
- encoding.as_header_value().parse().unwrap(),
209
- );
210
- parts.headers.insert(
211
- "content-length",
212
- compressed.len().to_string().parse().unwrap(),
213
- );
200
+ parts.headers.insert("content-encoding", encoding.as_header_value().parse().unwrap());
201
+ parts.headers.insert("content-length", compressed.len().to_string().parse().unwrap());
214
202
// Add Vary header so caches know the response varies by Accept-Encoding
215
- parts
216
- .headers
217
- .insert("vary", "accept-encoding".parse().unwrap());
203
+ parts.headers.insert("vary", "accept-encoding".parse().unwrap());
218
204
219
205
let new_body = UnsyncBoxBody::new(
220
206
http_body_util::Full::new(Bytes::from(compressed))
251
237
252
238
#[test]
253
239
fn test_encoding_from_gzip() {
254
- assert_eq!(
255
- Encoding::from_accept_encoding("gzip, deflate"),
256
- Encoding::Gzip
257
- );
240
+ assert_eq!(Encoding::from_accept_encoding("gzip, deflate"), Encoding::Gzip);
258
241
}
259
242
260
243
#[test]
264
247
265
248
#[test]
266
249
fn test_encoding_from_identity() {
267
- assert_eq!(
268
- Encoding::from_accept_encoding("identity"),
269
- Encoding::Identity
270
- );
250
+ assert_eq!(Encoding::from_accept_encoding("identity"), Encoding::Identity);
271
251
}
272
252
273
253
#[test]
283
263
// and "deflate, gzip" has deflate first, so deflate would be matched first.
284
264
// Wait - the parser iterates parts and matches each. For "deflate, gzip":
285
265
// part 0 = "deflate" -> matches Deflate, returns immediately.
286
- assert_eq!(
287
- Encoding::from_accept_encoding("deflate, gzip"),
288
- Encoding::Deflate
289
- );
266
+ assert_eq!(Encoding::from_accept_encoding("deflate, gzip"), Encoding::Deflate);
290
267
}
291
268
292
269
#[test]
293
270
fn test_encoding_gzip_preferred_over_deflate_in_header() {
294
271
// When gzip appears before deflate, gzip wins
295
- assert_eq!(
296
- Encoding::from_accept_encoding("gzip, deflate"),
297
- Encoding::Gzip
298
- );
272
+ assert_eq!(Encoding::from_accept_encoding("gzip, deflate"), Encoding::Gzip);
299
273
}
300
274
301
275
#[test]
302
276
▼crates/justapi-core/src/db/migrations.rs
+9-33
53
53
.and_then(|s| s.to_str())
54
54
.ok_or_else(|| format!("Invalid filename: {:?}", path))?;
55
55
let (version, name) = Self::parse_filename(full_filename).ok_or_else(|| {
56
- format!(
57
- "Migration filename must be <version>_<name>.sql, got: {:?}",
58
- full_filename
59
- )
56
+ format!("Migration filename must be <version>_<name>.sql, got: {:?}", full_filename)
60
57
})?;
61
58
let content =
62
59
fs::read_to_string(path).map_err(|e| format!("Cannot read {:?}: {}", path, e))?;
63
60
let (up_sql, down_sql) = Self::parse_content(&content);
64
- Ok(Migration {
65
- version,
66
- name,
67
- up_sql,
68
- down_sql,
69
- })
61
+ Ok(Migration { version, name, up_sql, down_sql })
70
62
}
71
63
}
72
64
78
70
79
71
impl Migrator {
80
72
pub fn new() -> Self {
81
- Self {
82
- migrations: Vec::new(),
83
- }
73
+ Self { migrations: Vec::new() }
84
74
}
85
75
86
76
pub fn discover(&mut self, dir: &Path) -> Result<(), String> {
115
105
if applied.contains(&migration.version) {
116
106
continue;
117
107
}
118
- tracing::info!(
119
- "Running migration UP v{} ({})",
120
- migration.version,
121
- migration.name
122
- );
108
+ tracing::info!("Running migration UP v{} ({})", migration.version, migration.name);
123
109
pool.execute(&migration.up_sql)
124
110
.await
125
111
.map_err(|e| format!("Migration v{} error: {}", migration.version, e))?;
126
- self.record_migration(pool, migration.version, &migration.name)
127
- .await?;
112
+ self.record_migration(pool, migration.version, &migration.name).await?;
128
113
applied_list.push(migration.clone());
129
114
}
130
115
Ok(applied_list)
171
156
)"
172
157
}
173
158
};
174
- pool.execute(sql)
175
- .await
176
- .map_err(|e| format!("Create tracking table error: {}", e))?;
159
+ pool.execute(sql).await.map_err(|e| format!("Create tracking table error: {}", e))?;
177
160
Ok(())
178
161
}
179
162
211
194
)
212
195
}
213
196
};
214
- pool.execute(&sql)
215
- .await
216
- .map_err(|e| format!("Record migration error: {}", e))?;
197
+ pool.execute(&sql).await.map_err(|e| format!("Record migration error: {}", e))?;
217
198
Ok(())
218
199
}
219
200
220
201
async fn remove_migration(&self, pool: &AnyPool, version: u64) -> Result<(), String> {
221
- let sql = format!(
222
- "DELETE FROM _justapi_migrations WHERE version = {}",
223
- version
224
- );
225
- pool.execute(&sql)
226
- .await
227
- .map_err(|e| format!("Delete migration error: {}", e))?;
202
+ let sql = format!("DELETE FROM _justapi_migrations WHERE version = {}", version);
203
+ pool.execute(&sql).await.map_err(|e| format!("Delete migration error: {}", e))?;
228
204
Ok(())
229
205
}
230
206
}
231
207
▼crates/justapi-core/src/db/pool.rs
+4-16
54
54
55
55
impl Default for DatabaseConfig {
56
56
fn default() -> Self {
57
- Self {
58
- url: String::new(),
59
- max_connections: 10,
60
- kind: None,
61
- }
57
+ Self { url: String::new(), max_connections: 10, kind: None }
62
58
}
63
59
}
64
60
172
168
173
169
impl PoolManager {
174
170
pub fn new() -> Self {
175
- Self {
176
- pools: Arc::new(RwLock::new(HashMap::new())),
177
- }
171
+ Self { pools: Arc::new(RwLock::new(HashMap::new())) }
178
172
}
179
173
180
174
pub async fn init(
243
237
244
238
#[test]
245
239
fn test_db_kind_from_url() {
246
- assert_eq!(
247
- DbKind::from_url("postgres://localhost/db"),
248
- DbKind::Postgres
249
- );
250
- assert_eq!(
251
- DbKind::from_url("postgresql://localhost/db"),
252
- DbKind::Postgres
253
- );
240
+ assert_eq!(DbKind::from_url("postgres://localhost/db"), DbKind::Postgres);
241
+ assert_eq!(DbKind::from_url("postgresql://localhost/db"), DbKind::Postgres);
254
242
assert_eq!(DbKind::from_url("sqlite://./test.db"), DbKind::Sqlite);
255
243
assert_eq!(DbKind::from_url("mysql://localhost/db"), DbKind::MySql);
256
244
}
257
245
▼crates/justapi-core/src/db/query.rs
+12-48
96
96
97
97
impl Insert {
98
98
pub fn new(table: impl Into<String>) -> Self {
99
- Self {
100
- table: table.into(),
101
- columns: Vec::new(),
102
- values: Vec::new(),
103
- returning: None,
104
- }
99
+ Self { table: table.into(), columns: Vec::new(), values: Vec::new(), returning: None }
105
100
}
106
101
107
102
pub fn columns(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
110
105
}
111
106
112
107
pub fn values(mut self, values: impl IntoIterator<Item = impl Into<String>>) -> Self {
113
- self.values
114
- .push(values.into_iter().map(|v| v.into()).collect());
108
+ self.values.push(values.into_iter().map(|v| v.into()).collect());
115
109
self
116
110
}
117
111
131
125
format!("({})", params.join(", "))
132
126
})
133
127
.collect();
134
- let mut sql = format!(
135
- "INSERT INTO {} ({}) VALUES {}",
136
- self.table,
137
- cols,
138
- placeholders.join(", ")
139
- );
128
+ let mut sql =
129
+ format!("INSERT INTO {} ({}) VALUES {}", self.table, cols, placeholders.join(", "));
140
130
if let Some(ref ret) = self.returning {
141
131
sql.push_str(" RETURNING ");
142
132
sql.push_str(&ret.join(", "));
156
146
157
147
impl Update {
158
148
pub fn new(table: impl Into<String>) -> Self {
159
- Self {
160
- table: table.into(),
161
- sets: Vec::new(),
162
- conditions: Vec::new(),
163
- returning: None,
164
- }
149
+ Self { table: table.into(), sets: Vec::new(), conditions: Vec::new(), returning: None }
165
150
}
166
151
167
152
pub fn set(mut self, column: impl Into<String>, placeholder: impl Into<String>) -> Self {
168
- self.sets
169
- .push(format!("{} = {}", column.into(), placeholder.into()));
153
+ self.sets.push(format!("{} = {}", column.into(), placeholder.into()));
170
154
self
171
155
}
172
156
205
189
206
190
impl Delete {
207
191
pub fn new(table: impl Into<String>) -> Self {
208
- Self {
209
- table: table.into(),
210
- conditions: Vec::new(),
211
- returning: None,
212
- }
192
+ Self { table: table.into(), conditions: Vec::new(), returning: None }
213
193
}
214
194
215
195
pub fn r#where(mut self, condition: impl Into<String>) -> Self {
253
233
.r#where("id = ?")
254
234
.r#where("active = true")
255
235
.build();
256
- assert_eq!(
257
- q,
258
- "SELECT id, name, email FROM users WHERE id = ? AND active = true"
259
- );
236
+ assert_eq!(q, "SELECT id, name, email FROM users WHERE id = ? AND active = true");
260
237
}
261
238
262
239
#[test]
263
240
fn test_select_with_limit_offset() {
264
- let q = Select::new("users")
265
- .order_by("name ASC")
266
- .limit(10)
267
- .offset(20)
268
- .build();
269
- assert_eq!(
270
- q,
271
- "SELECT * FROM users ORDER BY name ASC LIMIT 10 OFFSET 20"
272
- );
241
+ let q = Select::new("users").order_by("name ASC").limit(10).offset(20).build();
242
+ assert_eq!(q, "SELECT * FROM users ORDER BY name ASC LIMIT 10 OFFSET 20");
273
243
}
274
244
275
245
#[test]
288
258
.values(["Alice", "[email protected]"])
289
259
.returning(["id"])
290
260
.build();
291
- assert_eq!(
292
- q,
293
- "INSERT INTO users (name, email) VALUES (?, ?) RETURNING id"
294
- );
261
+ assert_eq!(q, "INSERT INTO users (name, email) VALUES (?, ?) RETURNING id");
295
262
}
296
263
297
264
#[test]
298
265
fn test_update() {
299
- let q = Update::new("users")
300
- .set("name", "?")
301
- .r#where("id = ?")
302
- .build();
266
+ let q = Update::new("users").set("name", "?").r#where("id = ?").build();
303
267
assert_eq!(q, "UPDATE users SET name = ? WHERE id = ?");
304
268
}
305
269
306
270
▼crates/justapi-core/src/dx.rs
+1-7
99
99
impl Diagnostic {
100
100
/// Create a new diagnostic at the given level.
101
101
pub fn new(level: DiagLevel, message: impl Into<String>) -> Self {
102
- Self {
103
- level,
104
- code: None,
105
- message: message.into(),
106
- suggestions: Vec::new(),
107
- context: None,
108
- }
102
+ Self { level, code: None, message: message.into(), suggestions: Vec::new(), context: None }
109
103
}
110
104
111
105
/// Attach an error code (e.g. `"E001"`).
112
106
▼crates/justapi-core/src/error_catalog.rs
+4-4
24
24
///
25
25
/// The diagnostic is always at [`DiagLevel::Error`] and carries the
26
26
/// catalogue's code, template message, and default suggestions.
27
- pub fn into_diagnostic(&self) -> Diagnostic {
27
+ pub fn to_diagnostic(&self) -> Diagnostic {
28
28
let mut d = Diagnostic::new(DiagLevel::Error, self.template).code(self.code);
29
29
for s in self.suggestions {
30
30
d = d.suggestion(*s);
34
34
35
35
/// Build a diagnostic and attach context (e.g. a file path or value).
36
36
pub fn with_context(&self, ctx: impl Into<String>) -> Diagnostic {
37
- self.into_diagnostic().context(ctx)
37
+ self.to_diagnostic().context(ctx)
38
38
}
39
39
}
40
40
157
157
158
158
#[test]
159
159
fn catalog_entry_produces_diagnostic() {
160
- let d = E001_INVALID_ADDRESS.into_diagnostic();
160
+ let d = E001_INVALID_ADDRESS.to_diagnostic();
161
161
let text = d.render_plain();
162
162
assert!(text.contains("error[E001]"));
163
163
assert!(text.contains("= help:"));
174
174
#[test]
175
175
fn all_entries_render_without_panic() {
176
176
for entry in ALL_ENTRIES {
177
- let d = entry.into_diagnostic();
177
+ let d = entry.to_diagnostic();
178
178
let _plain = d.render_plain();
179
179
let _colored = d.render_colored();
180
180
}
181
181
▼crates/justapi-core/src/extract.rs
+7-22
43
43
B::Error: std::error::Error + Send + Sync + 'static,
44
44
{
45
45
let (_parts, body) = req.into_parts();
46
- let bytes = body
47
- .collect()
48
- .await
49
- .map_err(|e| anyhow::anyhow!(e))?
50
- .to_bytes();
46
+ let bytes = body.collect().await.map_err(|e| anyhow::anyhow!(e))?.to_bytes();
51
47
Self::from_bytes(&bytes)
52
48
}
53
49
}
194
190
type TestBody = http_body_util::Full<Bytes>;
195
191
196
192
fn test_req(method: Method, uri: &str) -> Request<TestBody> {
197
- Request::builder()
198
- .method(method)
199
- .uri(uri)
200
- .body(TestBody::new(Bytes::new()))
201
- .unwrap()
193
+ Request::builder().method(method).uri(uri).body(TestBody::new(Bytes::new())).unwrap()
202
194
}
203
195
204
196
fn form_req(body: &str) -> Request<TestBody> {
286
278
#[test]
287
279
fn cookies_single() {
288
280
let mut req = test_req(Method::GET, "/");
289
- req.headers_mut()
290
- .insert("cookie", "session_id=abc123".parse().unwrap());
281
+ req.headers_mut().insert("cookie", "session_id=abc123".parse().unwrap());
291
282
let jar = CookieJar::from_request(&req);
292
283
assert_eq!(jar.get("session_id"), Some("abc123"));
293
284
}
295
286
#[test]
296
287
fn cookies_multiple() {
297
288
let mut req = test_req(Method::GET, "/");
298
- req.headers_mut().insert(
299
- "cookie",
300
- "session=xyz; theme=dark; lang=en-US".parse().unwrap(),
301
- );
289
+ req.headers_mut().insert("cookie", "session=xyz; theme=dark; lang=en-US".parse().unwrap());
302
290
let jar = CookieJar::from_request(&req);
303
291
assert_eq!(jar.get("session"), Some("xyz"));
304
292
assert_eq!(jar.get("theme"), Some("dark"));
316
304
#[test]
317
305
fn cookies_url_decoded() {
318
306
let mut req = test_req(Method::GET, "/");
319
- req.headers_mut()
320
- .insert("cookie", "name=Hello%20World".parse().unwrap());
307
+ req.headers_mut().insert("cookie", "name=Hello%20World".parse().unwrap());
321
308
let jar = CookieJar::from_request(&req);
322
309
assert_eq!(jar.get("name"), Some("Hello World"));
323
310
}
325
312
#[test]
326
313
fn cookies_plus_decoded() {
327
314
let mut req = test_req(Method::GET, "/");
328
- req.headers_mut()
329
- .insert("cookie", "q=rust+lang".parse().unwrap());
315
+ req.headers_mut().insert("cookie", "q=rust+lang".parse().unwrap());
330
316
let jar = CookieJar::from_request(&req);
331
317
assert_eq!(jar.get("q"), Some("rust lang"));
332
318
}
336
322
#[test]
337
323
fn header_value_present() {
338
324
let mut req = test_req(Method::GET, "/");
339
- req.headers_mut()
340
- .insert("x-api-key", "secret".parse().unwrap());
325
+ req.headers_mut().insert("x-api-key", "secret".parse().unwrap());
341
326
assert_eq!(get_header(&req, "x-api-key"), Some("secret"));
342
327
}
343
328
344
329
▼crates/justapi-core/src/gateway.rs
+5-10
14
14
use crate::ResponseBody;
15
15
use async_trait::async_trait;
16
16
use http_body_util::BodyExt;
17
-use hyper::{body::Incoming, Request, Response};
17
+use hyper::{Request, Response};
18
18
19
19
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
20
20
pub struct GatewayRoute {
46
46
let path = path.as_ref().to_path_buf();
47
47
let (config, router) = Self::load_config(&path).unwrap_or_else(|e| {
48
48
warn!("Failed to load gateway config from {:?}: {}", path, e);
49
- (
50
- GatewayConfigData::default(),
51
- GatewayRouter {
52
- inner: Router::new(),
53
- },
54
- )
49
+ (GatewayConfigData::default(), GatewayRouter { inner: Router::new() })
55
50
});
56
51
57
52
Arc::new(Self {
148
143
}
149
144
150
145
#[async_trait]
151
-impl Middleware<Incoming> for GatewayMiddleware {
146
+impl<B: Send + 'static> Middleware<B> for GatewayMiddleware {
152
147
async fn handle(
153
148
&self,
154
- req: Request<Incoming>,
155
- next: Next<'_, Incoming>,
149
+ req: Request<B>,
150
+ next: Next<'_, B>,
156
151
) -> anyhow::Result<Response<ResponseBody>> {
157
152
let path = req.uri().path().to_string();
158
153
let method = req.method().clone();
159
154
▼crates/justapi-core/src/graphql.rs
+1-3
23
23
pub type AppSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>;
24
24
25
25
pub fn create_schema() -> AppSchema {
26
- Schema::build(QueryRoot, EmptyMutation, EmptySubscription)
27
- .enable_federation()
28
- .finish()
26
+ Schema::build(QueryRoot, EmptyMutation, EmptySubscription).enable_federation().finish()
29
27
}
30
28
31
29
pub async fn handle_graphql(
32
30
▼crates/justapi-core/src/grpc.rs
+1-3
73
73
74
74
impl DynamicGrpcService {
75
75
pub fn new(handler: GrpcHandler) -> Self {
76
- Self {
77
- handler: std::sync::Arc::new(handler),
78
- }
76
+ Self { handler: std::sync::Arc::new(handler) }
79
77
}
80
78
}
81
79
82
80
▼crates/justapi-core/src/health.rs
+6-11
80
80
let mut components = Vec::with_capacity(self.checks.len());
81
81
for check in &self.checks {
82
82
let status = (check.func)().await;
83
- components.push(ComponentStatus {
84
- name: check.name,
85
- status,
86
- });
83
+ components.push(ComponentStatus { name: check.name, status });
87
84
}
88
85
HealthReport { components }
89
86
}
107
104
.status(status_code)
108
105
.header("content-type", "application/json")
109
106
.header("content-length", body.len().to_string())
110
- .body(UnsyncBoxBody::new(Full::new(Bytes::from(body)).map_err(
111
- |e: std::convert::Infallible| -> anyhow::Error { match e {} },
112
- )))
107
+ .body(UnsyncBoxBody::new(
108
+ Full::new(Bytes::from(body))
109
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
110
+ ))
113
111
.unwrap()
114
112
}
115
113
}
270
268
#[tokio::test]
271
269
async fn test_all_healthy() {
272
270
let mut registry = HealthRegistry::new();
273
- registry.register(TestCheck {
274
- name: "test1",
275
- result: HealthStatus::Healthy,
276
- });
271
+ registry.register(TestCheck { name: "test1", result: HealthStatus::Healthy });
277
272
registry.register(AlwaysHealthy { name: "always" });
278
273
let report = registry.check_all().await;
279
274
assert_eq!(report.overall(), OverallHealth::Healthy);
280
275
▼crates/justapi-core/src/lib.rs
+1-3
82
82
Response::builder()
83
83
.status(status)
84
84
.header("content-type", content_type)
85
- .body(UnsyncBoxBody::new(http_body_util::StreamBody::new(
86
- frame_stream,
87
- )))
85
+ .body(UnsyncBoxBody::new(http_body_util::StreamBody::new(frame_stream)))
88
86
.unwrap()
89
87
}
90
88
pub mod dummy_extract;
91
89
▼crates/justapi-core/src/memory.rs
+3-8
76
76
77
77
impl SharedArena {
78
78
pub fn new() -> Self {
79
- Self {
80
- inner: Mutex::new(RequestArena::new()),
81
- }
79
+ Self { inner: Mutex::new(RequestArena::new()) }
82
80
}
83
81
84
82
pub fn reset(&self) {
108
106
109
107
impl BufferPool {
110
108
pub fn new() -> Self {
111
- Self {
112
- buckets: std::array::from_fn(|_| Mutex::new(Vec::new())),
113
- }
109
+ Self { buckets: std::array::from_fn(|_| Mutex::new(Vec::new())) }
114
110
}
115
111
116
112
fn bucket_index(min_size: usize) -> usize {
126
122
pub fn acquire(&self, min_size: usize) -> Vec<u8> {
127
123
let idx = Self::bucket_index(min_size);
128
124
let mut pool = self.buckets[idx].lock().unwrap();
129
- pool.pop()
130
- .unwrap_or_else(|| Vec::with_capacity(BUCKET_SIZES[idx]))
125
+ pool.pop().unwrap_or_else(|| Vec::with_capacity(BUCKET_SIZES[idx]))
131
126
}
132
127
133
128
/// Return a buffer to the pool for reuse.
134
129
▼crates/justapi-core/src/metrics.rs
+16-56
12
12
use crate::ResponseBody;
13
13
14
14
/// Prometheus-compatible latency bucket boundaries in milliseconds.
15
-const LATENCY_BUCKETS_MS: &[f64] = &[
16
- 1.0, 2.5, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0,
17
-];
15
+const LATENCY_BUCKETS_MS: &[f64] =
16
+ &[1.0, 2.5, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0];
18
17
19
18
/// A snapshot of all metric values at a point in time (for testing & Prometheus).
20
19
#[derive(Debug, Clone)]
64
63
65
64
impl LatencyHist {
66
65
fn new() -> Self {
67
- let buckets = LATENCY_BUCKETS_MS
68
- .iter()
69
- .map(|_| AtomicU64::new(0))
70
- .collect();
71
- Self {
72
- buckets,
73
- count: AtomicU64::new(0),
74
- sum_ms: AtomicU64::new(0),
75
- }
66
+ let buckets = LATENCY_BUCKETS_MS.iter().map(|_| AtomicU64::new(0)).collect();
67
+ Self { buckets, count: AtomicU64::new(0), sum_ms: AtomicU64::new(0) }
76
68
}
77
69
78
70
fn record(&self, latency_ms: f64) {
79
71
self.count.fetch_add(1, Ordering::Relaxed);
80
- self.sum_ms
81
- .fetch_add((latency_ms * 1000.0) as u64, Ordering::Relaxed);
72
+ self.sum_ms.fetch_add((latency_ms * 1000.0) as u64, Ordering::Relaxed);
82
73
for (i, threshold) in LATENCY_BUCKETS_MS.iter().enumerate() {
83
74
if latency_ms <= *threshold {
84
75
self.buckets[i].fetch_add(1, Ordering::Relaxed);
91
82
let count = self.count.load(Ordering::Relaxed);
92
83
let sum_us = self.sum_ms.load(Ordering::Relaxed);
93
84
let sum_ms = sum_us as f64 / 1000.0;
94
- let buckets: Vec<u64> = self
95
- .buckets
96
- .iter()
97
- .map(|b| b.load(Ordering::Relaxed))
98
- .collect();
85
+ let buckets: Vec<u64> = self.buckets.iter().map(|b| b.load(Ordering::Relaxed)).collect();
99
86
(count, sum_ms, buckets)
100
87
}
101
88
}
171
158
}
172
159
173
160
pub fn connection_opened(&self) {
174
- self.inner
175
- .active_connections
176
- .fetch_add(1, Ordering::Relaxed);
161
+ self.inner.active_connections.fetch_add(1, Ordering::Relaxed);
177
162
}
178
163
179
164
pub fn connection_closed(&self) {
180
- self.inner
181
- .active_connections
182
- .fetch_sub(1, Ordering::Relaxed);
165
+ self.inner.active_connections.fetch_sub(1, Ordering::Relaxed);
183
166
}
184
167
185
168
/// Return an atomic snapshot of all metric values.
260
243
"# HELP justapi_active_connections Current active connections.\n\
261
244
# TYPE justapi_active_connections gauge\n",
262
245
);
263
- out.push_str(&format!(
264
- "justapi_active_connections {}\n",
265
- s.active_connections
266
- ));
246
+ out.push_str(&format!("justapi_active_connections {}\n", s.active_connections));
267
247
268
248
// Status code breakdown
269
249
out.push_str(
270
250
"# HELP justapi_requests_by_status Requests by HTTP status code class.\n\
271
251
# TYPE justapi_requests_by_status counter\n",
272
252
);
273
- out.push_str(&format!(
274
- "justapi_requests_by_status{{code=\"2xx\"}} {}\n",
275
- s.status_2xx
276
- ));
277
- out.push_str(&format!(
278
- "justapi_requests_by_status{{code=\"3xx\"}} {}\n",
279
- s.status_3xx
280
- ));
281
- out.push_str(&format!(
282
- "justapi_requests_by_status{{code=\"4xx\"}} {}\n",
283
- s.status_4xx
284
- ));
285
- out.push_str(&format!(
286
- "justapi_requests_by_status{{code=\"5xx\"}} {}\n",
287
- s.status_5xx
288
- ));
253
+ out.push_str(&format!("justapi_requests_by_status{{code=\"2xx\"}} {}\n", s.status_2xx));
254
+ out.push_str(&format!("justapi_requests_by_status{{code=\"3xx\"}} {}\n", s.status_3xx));
255
+ out.push_str(&format!("justapi_requests_by_status{{code=\"4xx\"}} {}\n", s.status_4xx));
256
+ out.push_str(&format!("justapi_requests_by_status{{code=\"5xx\"}} {}\n", s.status_5xx));
289
257
290
258
// Latency histogram
291
259
out.push_str(
292
260
"# HELP justapi_request_duration_ms Request latency in milliseconds.\n\
293
261
# TYPE justapi_request_duration_ms histogram\n",
294
262
);
295
- out.push_str(&format!(
296
- "justapi_request_duration_ms_count {}\n",
297
- s.latency_count
298
- ));
299
- out.push_str(&format!(
300
- "justapi_request_duration_ms_sum {:.3}\n",
301
- s.latency_sum_ms
302
- ));
263
+ out.push_str(&format!("justapi_request_duration_ms_count {}\n", s.latency_count));
264
+ out.push_str(&format!("justapi_request_duration_ms_sum {:.3}\n", s.latency_sum_ms));
303
265
for (i, bucket_count) in s.latency_buckets.iter().enumerate() {
304
266
let le = LATENCY_BUCKETS_MS[i.min(LATENCY_BUCKETS_MS.len() - 1)];
305
267
out.push_str(&format!(
397
359
398
360
impl RequestTimer {
399
361
pub fn start() -> Self {
400
- Self {
401
- start: Instant::now(),
402
- }
362
+ Self { start: Instant::now() }
403
363
}
404
364
405
365
pub fn elapsed_ms(&self) -> f64 {
406
366
▼crates/justapi-core/src/middleware.rs
+59-366
73
73
74
74
impl<B> Clone for MiddlewareChain<B> {
75
75
fn clone(&self) -> Self {
76
- Self {
77
- middlewares: self.middlewares.clone(),
78
- handler: self.handler.clone(),
79
- }
76
+ Self { middlewares: self.middlewares.clone(), handler: self.handler.clone() }
80
77
}
81
78
}
82
79
83
80
impl<B> MiddlewareChain<B> {
84
81
pub fn new(handler: HandlerFn<B>) -> Self {
85
- Self {
86
- middlewares: Vec::new(),
87
- handler,
88
- }
82
+ Self { middlewares: Vec::new(), handler }
89
83
}
90
84
91
85
pub fn add(&mut self, mw: impl Middleware<B> + 'static) {
104
98
if self.middlewares.is_empty() {
105
99
return (self.handler)(req).await;
106
100
}
107
- let next = Next {
108
- middlewares: &self.middlewares,
109
- index: 0,
110
- handler: &self.handler,
111
- };
101
+ let next = Next { middlewares: &self.middlewares, index: 0, handler: &self.handler };
112
102
next.run(req).await
113
103
}
114
104
}
142
132
}
143
133
144
134
#[async_trait]
145
-impl Middleware<Incoming> for AccessLogger {
146
- async fn handle(
147
- &self,
148
- req: Request<Incoming>,
149
- next: Next<'_, Incoming>,
150
- ) -> Result<Response<ResponseBody>> {
135
+impl<B: Send + 'static> Middleware<B> for AccessLogger {
136
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
151
137
let method = req.method().clone();
152
138
let uri = req.uri().clone();
153
139
let start = std::time::Instant::now();
160
146
};
161
147
let duration = start.elapsed();
162
148
163
- let _ = self
164
- .tx
165
- .send(format!("[{method}] {uri} - {status} - {duration:?}"));
149
+ let _ = self.tx.send(format!("[{method}] {uri} - {status} - {duration:?}"));
166
150
167
151
res
168
152
}
248
232
}
249
233
250
234
#[async_trait]
251
-impl Middleware<Incoming> for Cors {
252
- async fn handle(
253
- &self,
254
- req: Request<Incoming>,
255
- next: Next<'_, Incoming>,
256
- ) -> Result<Response<ResponseBody>> {
235
+impl<B: Send + 'static> Middleware<B> for Cors {
236
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
257
237
let req_origin = req.headers().get("origin").and_then(|v| v.to_str().ok());
258
238
259
239
let allow_all = self.allow_origins.iter().any(|a| a == "*");
284
264
builder = builder.header("access-control-allow-credentials", "true");
285
265
}
286
266
if !self.expose_headers.is_empty() {
287
- builder = builder.header(
288
- "access-control-expose-headers",
289
- self.expose_headers.join(", "),
290
- );
267
+ builder =
268
+ builder.header("access-control-expose-headers", self.expose_headers.join(", "));
291
269
}
292
270
if add_vary {
293
271
builder = builder.header("vary", "Origin");
303
281
}
304
282
305
283
let mut resp = next.run(req).await?;
306
- resp.headers_mut().insert(
307
- "access-control-allow-origin",
308
- matched_origin.parse().unwrap(),
309
- );
284
+ resp.headers_mut().insert("access-control-allow-origin", matched_origin.parse().unwrap());
310
285
if self.allow_credentials {
311
- resp.headers_mut()
312
- .insert("access-control-allow-credentials", "true".parse().unwrap());
286
+ resp.headers_mut().insert("access-control-allow-credentials", "true".parse().unwrap());
313
287
}
314
288
if !self.expose_headers.is_empty() {
315
289
resp.headers_mut().insert(
368
342
}
369
343
370
344
#[async_trait]
371
-impl Middleware<Incoming> for SecurityHeaders {
372
- async fn handle(
373
- &self,
374
- req: Request<Incoming>,
375
- next: Next<'_, Incoming>,
376
- ) -> Result<Response<ResponseBody>> {
345
+impl<B: Send + 'static> Middleware<B> for SecurityHeaders {
346
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
377
347
let mut resp = next.run(req).await?;
378
348
let headers = resp.headers_mut();
379
349
458
428
pub fn from_ec_pem(pem: &str) -> anyhow::Result<Self> {
459
429
let decoding_key = jsonwebtoken::DecodingKey::from_ec_pem(pem.as_bytes())?;
460
430
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::ES256);
461
- validation.algorithms = vec![
462
- jsonwebtoken::Algorithm::ES256,
463
- jsonwebtoken::Algorithm::ES384,
464
- ];
431
+ validation.algorithms =
432
+ vec![jsonwebtoken::Algorithm::ES256, jsonwebtoken::Algorithm::ES384];
465
433
Ok(Self {
466
434
validation,
467
435
decoding_key,
489
457
pub fn from_ec_der(der: &[u8]) -> Self {
490
458
let decoding_key = jsonwebtoken::DecodingKey::from_ec_der(der);
491
459
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::ES256);
492
- validation.algorithms = vec![
493
- jsonwebtoken::Algorithm::ES256,
494
- jsonwebtoken::Algorithm::ES384,
495
- ];
460
+ validation.algorithms =
461
+ vec![jsonwebtoken::Algorithm::ES256, jsonwebtoken::Algorithm::ES384];
496
462
Self {
497
463
validation,
498
464
decoding_key,
523
489
524
490
/// Require JWT validation for requests whose path starts with the given prefix.
525
491
pub fn require_for(mut self, path_prefix: &str, requirement: JwtRequirement) -> Self {
526
- self.route_rules
527
- .push((path_prefix.to_string(), requirement));
492
+ self.route_rules.push((path_prefix.to_string(), requirement));
528
493
self
529
494
}
530
495
557
522
Some(role_list) => {
558
523
let user_roles: Vec<&str> =
559
524
role_list.iter().filter_map(|v| v.as_str()).collect();
560
- if required_roles
561
- .iter()
562
- .any(|r| user_roles.contains(&r.as_str()))
563
- {
525
+ if required_roles.iter().any(|r| user_roles.contains(&r.as_str())) {
564
526
Ok(())
565
527
} else {
566
528
Err("missing required role")
584
546
});
585
547
match scopes {
586
548
Some(user_scopes) => {
587
- if required_scopes
588
- .iter()
589
- .any(|s| user_scopes.contains(&s.as_str()))
590
- {
549
+ if required_scopes.iter().any(|s| user_scopes.contains(&s.as_str())) {
591
550
Ok(())
592
551
} else {
593
552
Err("missing required scope")
600
559
}
601
560
602
561
#[async_trait]
603
-impl Middleware<Incoming> for JwtAuth {
604
- async fn handle(
605
- &self,
606
- req: Request<Incoming>,
607
- next: Next<'_, Incoming>,
608
- ) -> Result<Response<ResponseBody>> {
562
+impl<B: Send + 'static> Middleware<B> for JwtAuth {
563
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
609
564
let path = req.uri().path().to_string();
610
565
let requirement = self.requirement_for_path(&path);
611
566
614
569
return next.run(req).await;
615
570
}
616
571
617
- let auth_header = req
618
- .headers()
619
- .get("authorization")
620
- .and_then(|v| v.to_str().ok());
572
+ let auth_header = req.headers().get("authorization").and_then(|v| v.to_str().ok());
621
573
let token = match auth_header {
622
574
Some(h) if h.starts_with("Bearer ") => &h[7..],
623
575
_ => {
656
608
let quota = governor::Quota::with_period(duration)
657
609
.unwrap()
658
610
.allow_burst(NonZeroU32::new(max_burst).unwrap());
659
- Self {
660
- limiter: governor::RateLimiter::direct(quota),
661
- }
611
+ Self { limiter: governor::RateLimiter::direct(quota) }
662
612
}
663
613
664
614
pub fn per_second(max_burst: u32) -> Self {
667
617
}
668
618
669
619
#[async_trait]
670
-impl Middleware<Incoming> for RateLimiter {
671
- async fn handle(
672
- &self,
673
- req: Request<Incoming>,
674
- next: Next<'_, Incoming>,
675
- ) -> Result<Response<ResponseBody>> {
620
+impl<B: Send + 'static> Middleware<B> for RateLimiter {
621
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
676
622
match self.limiter.check() {
677
623
Ok(()) => next.run(req).await,
678
624
Err(negative) => {
683
629
let mut resp = json_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded");
684
630
resp.headers_mut()
685
631
.insert("retry-after", retry_after_secs.to_string().parse().unwrap());
686
- resp.headers_mut().insert(
687
- "x-ratelimit-reset",
688
- retry_after_secs.to_string().parse().unwrap(),
689
- );
632
+ resp.headers_mut()
633
+ .insert("x-ratelimit-reset", retry_after_secs.to_string().parse().unwrap());
690
634
Ok(resp)
691
635
}
692
636
}
730
674
}
731
675
732
676
#[async_trait]
733
-impl Middleware<Incoming> for IpRateLimiter {
734
- async fn handle(
735
- &self,
736
- req: Request<Incoming>,
737
- next: Next<'_, Incoming>,
738
- ) -> Result<Response<ResponseBody>> {
677
+impl<B: Send + 'static> Middleware<B> for IpRateLimiter {
678
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
739
679
let ip = req
740
680
.extensions()
741
681
.get::<SocketAddr>()
752
692
let mut resp = json_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded");
753
693
resp.headers_mut()
754
694
.insert("retry-after", retry_after_secs.to_string().parse().unwrap());
755
- resp.headers_mut().insert(
756
- "x-ratelimit-reset",
757
- retry_after_secs.to_string().parse().unwrap(),
758
- );
695
+ resp.headers_mut()
696
+ .insert("x-ratelimit-reset", retry_after_secs.to_string().parse().unwrap());
759
697
Ok(resp)
760
698
}
761
699
}
843
781
}
844
782
845
783
fn extract_key<B>(&self, req: &Request<B>) -> Option<String> {
846
- if let Some(v) = req
847
- .headers()
848
- .get(&self.header_name)
849
- .and_then(|v| v.to_str().ok())
850
- {
784
+ if let Some(v) = req.headers().get(&self.header_name).and_then(|v| v.to_str().ok()) {
851
785
return Some(v.to_string());
852
786
}
853
787
if let Some(qp) = &self.query_param {
890
824
}
891
825
892
826
#[async_trait]
893
-impl Middleware<Incoming> for ApiKeyAuth {
894
- async fn handle(
895
- &self,
896
- req: Request<Incoming>,
897
- next: Next<'_, Incoming>,
898
- ) -> Result<Response<ResponseBody>> {
827
+impl<B: Send + 'static> Middleware<B> for ApiKeyAuth {
828
+ async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
899
829
self.handle_inner(req, next).await
900
830
}
901
831
}
1018
948
1019
949
Box::pin(async move {
1020
950
if req.method() != Method::POST {
1021
- return Ok(json_error(
1022
- StatusCode::METHOD_NOT_ALLOWED,
1023
- "only POST is allowed",
1024
- ));
951
+ return Ok(json_error(StatusCode::METHOD_NOT_ALLOWED, "only POST is allowed"));
1025
952
}
1026
953
1027
954
if req.uri().path() != path {
1028
955
return Ok(json_error(StatusCode::NOT_FOUND, "not found"));
1029
956
}
1030
957
1031
- let content_type = req
1032
- .headers()
1033
- .get("content-type")
1034
- .and_then(|v| v.to_str().ok())
1035
- .unwrap_or("");
958
+ let content_type =
959
+ req.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("");
1036
960
if !content_type.starts_with("application/x-www-form-urlencoded") {
1037
961
return Ok(json_error(
1038
962
StatusCode::UNSUPPORTED_MEDIA_TYPE,
1040
964
));
1041
965
}
1042
966
1043
- let body_bytes = req
1044
- .collect()
1045
- .await
1046
- .map_err(|e| anyhow::anyhow!(e))?
1047
- .to_bytes();
967
+ let body_bytes = req.collect().await.map_err(|e| anyhow::anyhow!(e))?.to_bytes();
1048
968
let body_str = String::from_utf8_lossy(&body_bytes).to_string();
1049
969
1050
970
let params: HashMap<String, String> = serde_urlencoded::from_str(&body_str)
1169
1089
1170
1090
type TestBody = http_body_util::Full<Bytes>;
1171
1091
1172
- #[async_trait]
1173
- impl Middleware<TestBody> for Cors {
1174
- async fn handle(
1175
- &self,
1176
- req: Request<TestBody>,
1177
- next: Next<'_, TestBody>,
1178
- ) -> Result<Response<ResponseBody>> {
1179
- let req_origin = req.headers().get("origin").and_then(|v| v.to_str().ok());
1180
-
1181
- let allow_all = self.allow_origins.iter().any(|a| a == "*");
1182
-
1183
- let matched_origin = if allow_all {
1184
- "*".to_string()
1185
- } else if let Some(origin) = req_origin {
1186
- if origin_matches(origin, &self.allow_origins) {
1187
- origin.to_string()
1188
- } else {
1189
- return next.run(req).await;
1190
- }
1191
- } else {
1192
- return next.run(req).await;
1193
- };
1194
-
1195
- let add_vary = !allow_all;
1196
-
1197
- if req.method() == Method::OPTIONS {
1198
- let mut builder = Response::builder()
1199
- .status(StatusCode::NO_CONTENT)
1200
- .header("access-control-allow-origin", matched_origin)
1201
- .header("access-control-allow-methods", &self.allow_methods)
1202
- .header("access-control-allow-headers", &self.allow_headers)
1203
- .header("access-control-max-age", &self.max_age);
1204
-
1205
- if self.allow_credentials {
1206
- builder = builder.header("access-control-allow-credentials", "true");
1207
- }
1208
- if !self.expose_headers.is_empty() {
1209
- builder = builder.header(
1210
- "access-control-expose-headers",
1211
- self.expose_headers.join(", "),
1212
- );
1213
- }
1214
- if add_vary {
1215
- builder = builder.header("vary", "Origin");
1216
- }
1217
-
1218
- let resp = builder
1219
- .body(UnsyncBoxBody::new(
1220
- http_body_util::Full::new(Bytes::new())
1221
- .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }),
1222
- ))
1223
- .unwrap();
1224
- return Ok(resp);
1225
- }
1226
-
1227
- let mut resp = next.run(req).await?;
1228
- resp.headers_mut().insert(
1229
- "access-control-allow-origin",
1230
- matched_origin.parse().unwrap(),
1231
- );
1232
- if self.allow_credentials {
1233
- resp.headers_mut()
1234
- .insert("access-control-allow-credentials", "true".parse().unwrap());
1235
- }
1236
- if !self.expose_headers.is_empty() {
1237
- resp.headers_mut().insert(
1238
- "access-control-expose-headers",
1239
- self.expose_headers.join(", ").parse().unwrap(),
1240
- );
1241
- }
1242
- if add_vary {
1243
- resp.headers_mut().insert("vary", "Origin".parse().unwrap());
1244
- }
1245
- Ok(resp)
1246
- }
1247
- }
1248
-
1249
- #[async_trait]
1250
- impl Middleware<TestBody> for SecurityHeaders {
1251
- async fn handle(
1252
- &self,
1253
- req: Request<TestBody>,
1254
- next: Next<'_, TestBody>,
1255
- ) -> Result<Response<ResponseBody>> {
1256
- let mut resp = next.run(req).await?;
1257
- let headers = resp.headers_mut();
1258
- headers.insert("x-content-type-options", "nosniff".parse().unwrap());
1259
- if self.include_xfo {
1260
- headers.insert("x-frame-options", "DENY".parse().unwrap());
1261
- }
1262
- let hsts_val = if self.include_hsts_preload {
1263
- format!("{}; preload", self.hsts.trim_end_matches("; preload"))
1264
- } else {
1265
- self.hsts.clone()
1266
- };
1267
- headers.insert("strict-transport-security", hsts_val.parse().unwrap());
1268
- if self.include_csp {
1269
- let csp = self.csp_directives.join("; ");
1270
- headers.insert("content-security-policy", csp.parse().unwrap());
1271
- }
1272
- headers.insert("x-xss-protection", "0".parse().unwrap());
1273
- Ok(resp)
1274
- }
1275
- }
1276
-
1277
- #[async_trait]
1278
- impl Middleware<TestBody> for JwtAuth {
1279
- async fn handle(
1280
- &self,
1281
- req: Request<TestBody>,
1282
- next: Next<'_, TestBody>,
1283
- ) -> Result<Response<ResponseBody>> {
1284
- let path = req.uri().path().to_string();
1285
- let requirement = self.requirement_for_path(&path);
1286
-
1287
- if matches!(requirement, JwtRequirement::None) {
1288
- return next.run(req).await;
1289
- }
1290
-
1291
- let auth_header = req
1292
- .headers()
1293
- .get("authorization")
1294
- .and_then(|v| v.to_str().ok());
1295
- let token = match auth_header {
1296
- Some(h) if h.starts_with("Bearer ") => &h[7..],
1297
- _ => {
1298
- return Ok(json_error(
1299
- StatusCode::UNAUTHORIZED,
1300
- "missing or invalid authorization header",
1301
- ))
1302
- }
1303
- };
1304
- match jsonwebtoken::decode::<serde_json::Value>(
1305
- token,
1306
- &self.decoding_key,
1307
- &self.validation,
1308
- ) {
1309
- Ok(token_data) => {
1310
- if let Err(msg) = check_claims(&token_data.claims, requirement) {
1311
- return Ok(json_error(StatusCode::FORBIDDEN, msg));
1312
- }
1313
- let mut req = req;
1314
- req.extensions_mut().insert(token_data.claims);
1315
- next.run(req).await
1316
- }
1317
- Err(_) => Ok(json_error(StatusCode::UNAUTHORIZED, "invalid token")),
1318
- }
1319
- }
1320
- }
1321
-
1322
- #[async_trait]
1323
- impl Middleware<TestBody> for RateLimiter {
1324
- async fn handle(
1325
- &self,
1326
- req: Request<TestBody>,
1327
- next: Next<'_, TestBody>,
1328
- ) -> Result<Response<ResponseBody>> {
1329
- match self.limiter.check() {
1330
- Ok(()) => next.run(req).await,
1331
- Err(negative) => {
1332
- let clock = governor::clock::DefaultClock::default();
1333
- let now = clock.now();
1334
- let wait = negative.wait_time_from(now);
1335
- let retry_after_secs = wait.as_secs().max(1);
1336
- let mut resp = json_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded");
1337
- resp.headers_mut()
1338
- .insert("retry-after", retry_after_secs.to_string().parse().unwrap());
1339
- Ok(resp)
1340
- }
1341
- }
1342
- }
1343
- }
1344
-
1345
1092
fn test_req(method: Method, uri: &str) -> Request<TestBody> {
1346
- Request::builder()
1347
- .method(method)
1348
- .uri(uri)
1349
- .body(TestBody::new(Bytes::new()))
1350
- .unwrap()
1093
+ Request::builder().method(method).uri(uri).body(TestBody::new(Bytes::new())).unwrap()
1351
1094
}
1352
1095
1353
1096
fn test_req_with_header(method: Method, uri: &str, key: &str, val: &str) -> Request<TestBody> {
1485
1228
chain.add(Cors::new().allow_origin("https://example.com"));
1486
1229
let req = test_req_with_header(Method::GET, "/hello", "origin", "https://example.com");
1487
1230
let resp = chain.run(req).await.unwrap();
1488
- assert_eq!(
1489
- resp.headers()["access-control-allow-origin"],
1490
- "https://example.com"
1491
- );
1231
+ assert_eq!(resp.headers()["access-control-allow-origin"], "https://example.com");
1492
1232
assert_eq!(resp.headers()["vary"], "Origin");
1493
1233
}
1494
1234
1525
1265
chain.add(Cors::new().expose_headers(&["x-custom-header", "x-trace-id"]));
1526
1266
let req = test_req_with_header(Method::GET, "/hello", "origin", "https://example.com");
1527
1267
let resp = chain.run(req).await.unwrap();
1528
- assert_eq!(
1529
- resp.headers()["access-control-expose-headers"],
1530
- "x-custom-header, x-trace-id"
1531
- );
1268
+ assert_eq!(resp.headers()["access-control-expose-headers"], "x-custom-header, x-trace-id");
1532
1269
}
1533
1270
1534
1271
#[tokio::test]
1544
1281
.allow_methods("POST, OPTIONS")
1545
1282
.allow_headers("Authorization, Content-Type"),
1546
1283
);
1547
- let req = test_req_with_header(
1548
- Method::OPTIONS,
1549
- "/hello",
1550
- "origin",
1551
- "https://app.example.com",
1552
- );
1284
+ let req =
1285
+ test_req_with_header(Method::OPTIONS, "/hello", "origin", "https://app.example.com");
1553
1286
let resp = chain.run(req).await.unwrap();
1554
1287
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1555
- assert_eq!(
1556
- resp.headers()["access-control-allow-origin"],
1557
- "https://app.example.com"
1558
- );
1288
+ assert_eq!(resp.headers()["access-control-allow-origin"], "https://app.example.com");
1559
1289
assert_eq!(resp.headers()["access-control-allow-credentials"], "true");
1560
1290
assert_eq!(resp.headers()["vary"], "Origin");
1561
1291
}
1659
1389
chain.add(
1660
1390
JwtAuth::from_secret(b"secret")
1661
1391
.default_requirement(JwtRequirement::None)
1662
- .require_for(
1663
- "/api",
1664
- JwtRequirement::Scopes(vec!["read:data".to_string()]),
1665
- ),
1392
+ .require_for("/api", JwtRequirement::Scopes(vec!["read:data".to_string()])),
1666
1393
);
1667
1394
let token = jsonwebtoken::encode(
1668
1395
&jsonwebtoken::Header::default(),
1689
1416
chain.add(
1690
1417
JwtAuth::from_secret(b"secret")
1691
1418
.default_requirement(JwtRequirement::None)
1692
- .require_for(
1693
- "/api",
1694
- JwtRequirement::Scopes(vec!["admin:all".to_string()]),
1695
- ),
1419
+ .require_for("/api", JwtRequirement::Scopes(vec!["admin:all".to_string()])),
1696
1420
);
1697
1421
let token = jsonwebtoken::encode(
1698
1422
&jsonwebtoken::Header::default(),
1712
1436
1713
1437
// --- ApiKeyAuth tests ------------------------------------------------
1714
1438
1715
- #[async_trait]
1716
- impl Middleware<TestBody> for ApiKeyAuth {
1717
- async fn handle(
1718
- &self,
1719
- req: Request<TestBody>,
1720
- next: Next<'_, TestBody>,
1721
- ) -> Result<Response<ResponseBody>> {
1722
- self.handle_inner(req, next).await
1723
- }
1724
- }
1725
-
1726
1439
fn api_chain(handler: HandlerFn<TestBody>, auth: ApiKeyAuth) -> MiddlewareChain<TestBody> {
1727
1440
let mut chain = MiddlewareChain::new(handler);
1728
1441
chain.add(auth);
1734
1447
let handler: HandlerFn<TestBody> = Arc::new(|_req: Request<TestBody>| {
1735
1448
Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) })
1736
1449
});
1737
- let auth = ApiKeyAuth::new("x-api-key").with_key(
1738
- "secret-123",
1739
- serde_json::json!({"sub": "user-1", "roles": ["admin"]}),
1740
- );
1450
+ let auth = ApiKeyAuth::new("x-api-key")
1451
+ .with_key("secret-123", serde_json::json!({"sub": "user-1", "roles": ["admin"]}));
1741
1452
let chain = api_chain(handler, auth);
1742
1453
let req = test_req_with_header(Method::GET, "/v1/models", "x-api-key", "secret-123");
1743
1454
let resp = chain.run(req).await.unwrap();
1909
1620
jsonwebtoken::DecodingKey::from_secret(b"test"),
1910
1621
);
1911
1622
let handler: HandlerFn<TestBody> = oauth2.token_handler();
1912
- let req = form_req(
1913
- Method::POST,
1914
- "/token",
1915
- "grant_type=password&username=alice&password=wrong",
1916
- );
1623
+ let req =
1624
+ form_req(Method::POST, "/token", "grant_type=password&username=alice&password=wrong");
1917
1625
let resp = handler(req).await.unwrap();
1918
1626
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1919
1627
}
1932
1640
jsonwebtoken::DecodingKey::from_secret(b"test"),
1933
1641
);
1934
1642
let handler: HandlerFn<TestBody> = oauth2.token_handler();
1935
- let req = form_req(
1936
- Method::POST,
1937
- "/token",
1938
- "grant_type=password&username=alice&password=secret",
1939
- );
1643
+ let req =
1644
+ form_req(Method::POST, "/token", "grant_type=password&username=alice&password=secret");
1940
1645
let resp = handler(req).await.unwrap();
1941
1646
assert_eq!(resp.status(), StatusCode::OK);
1942
1647
let body: serde_json::Value = serde_json::from_slice(
1943
- &http_body_util::BodyExt::collect(resp.into_body())
1944
- .await
1945
- .unwrap()
1946
- .to_bytes(),
1648
+ &http_body_util::BodyExt::collect(resp.into_body()).await.unwrap().to_bytes(),
1947
1649
)
1948
1650
.unwrap();
1949
1651
assert!(body.get("access_token").and_then(|v| v.as_str()).is_some());
1966
1668
);
1967
1669
// 1. Get a token from the token handler.
1968
1670
let handler: HandlerFn<TestBody> = oauth2.token_handler();
1969
- let req = form_req(
1970
- Method::POST,
1971
- "/token",
1972
- "grant_type=password&username=alice&password=secret",
1973
- );
1671
+ let req =
1672
+ form_req(Method::POST, "/token", "grant_type=password&username=alice&password=secret");
1974
1673
let resp = handler(req).await.unwrap();
1975
1674
let body: serde_json::Value = serde_json::from_slice(
1976
- &http_body_util::BodyExt::collect(resp.into_body())
1977
- .await
1978
- .unwrap()
1979
- .to_bytes(),
1675
+ &http_body_util::BodyExt::collect(resp.into_body()).await.unwrap().to_bytes(),
1980
1676
)
1981
1677
.unwrap();
1982
1678
let token = body["access_token"].as_str().unwrap().to_string();
1991
1687
"claims": claims,
1992
1688
"ok": true,
1993
1689
});
1994
- Ok(json_response(
1995
- StatusCode::OK,
1996
- &serde_json::to_string(&body).unwrap(),
1997
- ))
1690
+ Ok(json_response(StatusCode::OK, &serde_json::to_string(&body).unwrap()))
1998
1691
})
1999
1692
});
2000
1693
let mut chain = MiddlewareChain::new(handler2);
2001
1694
▼crates/justapi-core/src/multipart.rs
+66-18
13
13
pub field_name: String,
14
14
pub filename: Option<String>,
15
15
pub content_type: Option<String>,
16
+ /// The part headers (lower-cased keys), e.g. `content-type`, `content-disposition`.
17
+ pub headers: HashMap<String, String>,
18
+ /// Size of the uploaded file in bytes.
19
+ pub size: u64,
16
20
pub temp_path: PathBuf,
17
21
}
18
22
50
54
let stream = BodyDataStream::new(body);
51
55
let mut multipart = Multipart::new(stream, &boundary);
52
56
let mut form = MultipartForm::default();
53
- let mut size: usize = 0;
54
- let max_size: usize = 50 * 1024 * 1024; // 50 MB default
57
+ let mut total: usize = 0;
58
+ let max_size = max_upload_size();
55
59
56
60
while let Some(mut field) = multipart.next_field().await? {
57
61
let name = field.name().unwrap_or("").to_string();
58
- let filename = field.file_name().map(|s| s.to_string());
62
+ let raw_filename = field.file_name().map(|s| s.to_string());
59
63
let ct = field.content_type().map(|m| m.to_string());
60
64
61
- if filename.is_some() {
65
+ let headers: HashMap<String, String> = field
66
+ .headers()
67
+ .iter()
68
+ .filter_map(|(k, v)| {
69
+ v.to_str().ok().map(|s| (k.as_str().to_lowercase(), s.to_string()))
70
+ })
71
+ .collect();
72
+
73
+ if raw_filename.is_some() {
62
74
let mut temp_file = tempfile::NamedTempFile::new()?;
63
75
let temp_path = temp_file.path().to_path_buf();
64
76
77
+ let mut file_size: usize = 0;
65
78
use std::io::Write;
66
79
while let Some(chunk) = field.chunk().await? {
67
- size += chunk.len();
68
- if size > max_size {
69
- return Err(anyhow::anyhow!("file exceeds maximum size of 50 MB"));
80
+ total += chunk.len();
81
+ file_size += chunk.len();
82
+ if total > max_size {
83
+ return Err(anyhow::anyhow!(
84
+ "uploaded content exceeds maximum size of {} bytes",
85
+ max_size
86
+ ));
70
87
}
71
88
temp_file.write_all(&chunk)?;
72
89
}
73
- // Keep the temp file on disk by persisting it (or by just storing the path, but NamedTempFile deletes when dropped.
74
- // We should use `keep()` to persist it, and the Python wrapper will delete it when done).
75
90
temp_file.keep()?;
76
91
77
92
form.files.push(UploadFile {
78
93
field_name: name,
79
- filename,
94
+ filename: raw_filename.as_deref().map(sanitize_filename),
80
95
content_type: ct,
96
+ headers,
97
+ size: file_size as u64,
81
98
temp_path,
82
99
});
83
100
} else {
89
106
Ok(form)
90
107
}
91
108
109
+/// Maximum allowed size, in bytes, for the whole `multipart/form-data` body.
110
+///
111
+/// Defaults to 50 MiB. Override with the `JUSTAPI_MAX_UPLOAD_SIZE` environment
112
+/// variable (parsed as a decimal byte count).
113
+pub fn max_upload_size() -> usize {
114
+ std::env::var("JUSTAPI_MAX_UPLOAD_SIZE")
115
+ .ok()
116
+ .and_then(|v| v.parse::<usize>().ok())
117
+ .unwrap_or(50 * 1024 * 1024)
118
+}
119
+
120
+/// Strip any directory components from an uploaded file name so it can never
121
+/// be used for path traversal (e.g. `../../etc/passwd` -> `passwd`).
122
+///
123
+/// Back-slashes are normalised to `/` first, then every path component except
124
+/// the final basename is dropped. A name with no usable basename becomes the
125
+/// empty string.
126
+pub fn sanitize_filename(name: &str) -> String {
127
+ let normalized = name.replace('\\', "/");
128
+ normalized
129
+ .rsplit('/')
130
+ .find(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
131
+ .unwrap_or("")
132
+ .to_string()
133
+}
134
+
92
135
fn extract_boundary(content_type: &str) -> Result<String> {
93
136
for part in content_type.split(';') {
94
137
let part = part.trim();
130
173
assert!(extract_boundary(ct).is_err());
131
174
}
132
175
176
+ #[test]
177
+ fn sanitize_filename_strips_path_traversal() {
178
+ assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
179
+ assert_eq!(sanitize_filename("foo/bar/baz.txt"), "baz.txt");
180
+ assert_eq!(sanitize_filename("C:\\Windows\\win.ini"), "win.ini");
181
+ assert_eq!(sanitize_filename("/abs/path.txt"), "path.txt");
182
+ assert_eq!(sanitize_filename("normal.txt"), "normal.txt");
183
+ assert_eq!(sanitize_filename(""), "");
184
+ assert_eq!(sanitize_filename(".."), "");
185
+ }
186
+
133
187
fn build_multipart_body(
134
188
fields: &[(&str, Option<&str>, &str)], // (name, filename, data)
135
189
) -> (Vec<u8>, String) {
200
254
let body = Full::new(Bytes::from(body));
201
255
let form = parse_multipart(body, &ct).await.unwrap();
202
256
assert_eq!(form.files.len(), 2);
203
- assert_eq!(
204
- form.file("file1").unwrap().filename.as_deref(),
205
- Some("a.txt")
206
- );
207
- assert_eq!(
208
- form.file("file2").unwrap().filename.as_deref(),
209
- Some("b.txt")
210
- );
257
+ assert_eq!(form.file("file1").unwrap().filename.as_deref(), Some("a.txt"));
258
+ assert_eq!(form.file("file2").unwrap().filename.as_deref(), Some("b.txt"));
211
259
}
212
260
213
261
#[tokio::test]
214
262
▼crates/justapi-core/src/openai.rs
+20-88
50
50
};
51
51
if req.stream {
52
52
let stream = justapi_inference::openai::chat_completion_stream(engine, req);
53
- Ok(streaming_response(
54
- StatusCode::OK,
55
- "text/event-stream",
56
- stream,
57
- ))
53
+ Ok(streaming_response(StatusCode::OK, "text/event-stream", stream))
58
54
} else {
59
55
match justapi_inference::openai::chat_completion(engine, req).await {
60
56
Ok(resp) => {
108
104
};
109
105
if req.stream {
110
106
let stream = justapi_inference::openai::completion_stream(engine, req);
111
- Ok(streaming_response(
112
- StatusCode::OK,
113
- "text/event-stream",
114
- stream,
115
- ))
107
+ Ok(streaming_response(StatusCode::OK, "text/event-stream", stream))
116
108
} else {
117
109
match justapi_inference::openai::completion(engine, req).await {
118
110
Ok(resp) => {
233
225
if req.stream {
234
226
let stream =
235
227
justapi_inference::openai::scheduled_chat_completion_stream(engine, req);
236
- Ok(streaming_response(
237
- StatusCode::OK,
238
- "text/event-stream",
239
- stream,
240
- ))
228
+ Ok(streaming_response(StatusCode::OK, "text/event-stream", stream))
241
229
} else {
242
230
match justapi_inference::openai::scheduled_chat_completion(engine, req).await {
243
231
Ok(resp) => {
291
279
};
292
280
if req.stream {
293
281
let stream = justapi_inference::openai::scheduled_completion_stream(engine, req);
294
- Ok(streaming_response(
295
- StatusCode::OK,
296
- "text/event-stream",
297
- stream,
298
- ))
282
+ Ok(streaming_response(StatusCode::OK, "text/event-stream", stream))
299
283
} else {
300
284
match justapi_inference::openai::scheduled_completion(engine, req).await {
301
285
Ok(resp) => {
330
314
let v: serde_json::Value = serde_json::from_slice(body).ok()?;
331
315
let model = v.get("model")?.as_str()?.to_string();
332
316
let version = v.get("version").and_then(|x| x.as_str()).map(String::from);
333
- let routing_key = v
334
- .get("routing_key")
335
- .and_then(|x| x.as_str())
336
- .map(String::from);
337
- Some(RouteRequest {
338
- model,
339
- version,
340
- routing_key,
341
- })
317
+ let routing_key = v.get("routing_key").and_then(|x| x.as_str()).map(String::from);
318
+ Some(RouteRequest { model, version, routing_key })
342
319
}
343
320
344
321
/// Outcome of routing admission: either serve via the resolved decision, or
417
394
req.model = decision.resolved.model_name.clone();
418
395
if req.stream {
419
396
let stream = justapi_inference::openai::chat_completion_stream(engine, req);
420
- Ok(streaming_response(
421
- StatusCode::OK,
422
- "text/event-stream",
423
- stream,
424
- ))
397
+ Ok(streaming_response(StatusCode::OK, "text/event-stream", stream))
425
398
} else {
426
399
match justapi_inference::openai::chat_completion(engine, req).await {
427
400
Ok(resp) => {
484
457
req.model = decision.resolved.model_name.clone();
485
458
if req.stream {
486
459
let stream = justapi_inference::openai::completion_stream(engine, req);
487
- Ok(streaming_response(
488
- StatusCode::OK,
489
- "text/event-stream",
490
- stream,
491
- ))
460
+ Ok(streaming_response(StatusCode::OK, "text/event-stream", stream))
492
461
} else {
493
462
match justapi_inference::openai::completion(engine, req).await {
494
463
Ok(resp) => {
595
564
"stream": false
596
565
})
597
566
.to_string();
598
- let resp = client
599
- .post("/v1/chat/completions", body.into_bytes())
600
- .await
601
- .unwrap();
567
+ let resp = client.post("/v1/chat/completions", body.into_bytes()).await.unwrap();
602
568
assert_eq!(resp.status, 200);
603
569
let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
604
570
assert_eq!(v["object"], "chat.completion");
605
- assert!(!v["choices"][0]["message"]["content"]
606
- .as_str()
607
- .unwrap()
608
- .is_empty());
571
+ assert!(!v["choices"][0]["message"]["content"].as_str().unwrap().is_empty());
609
572
}
610
573
611
574
#[tokio::test]
619
582
"stream": true
620
583
})
621
584
.to_string();
622
- let resp = client
623
- .post("/v1/chat/completions", body.into_bytes())
624
- .await
625
- .unwrap();
585
+ let resp = client.post("/v1/chat/completions", body.into_bytes()).await.unwrap();
626
586
assert_eq!(resp.status, 200);
627
587
let text = String::from_utf8(resp.body).unwrap();
628
588
assert!(text.contains("data: "));
637
597
assert_eq!(resp.status, 200);
638
598
let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
639
599
assert_eq!(v["object"], "list");
640
- assert!(v["data"]
641
- .as_array()
642
- .unwrap()
643
- .iter()
644
- .any(|m| m["id"] == "mock"));
600
+ assert!(v["data"].as_array().unwrap().iter().any(|m| m["id"] == "mock"));
645
601
}
646
602
647
603
#[tokio::test]
653
609
"input": ["hello", "world"]
654
610
})
655
611
.to_string();
656
- let resp = client
657
- .post("/v1/embeddings", body.into_bytes())
658
- .await
659
- .unwrap();
612
+ let resp = client.post("/v1/embeddings", body.into_bytes()).await.unwrap();
660
613
assert_eq!(resp.status, 200);
661
614
let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
662
615
assert_eq!(v["object"], "list");
721
674
"stream": false
722
675
})
723
676
.to_string();
724
- let resp = client
725
- .post("/v1/chat/completions", body.into_bytes())
726
- .await
727
- .unwrap();
677
+ let resp = client.post("/v1/chat/completions", body.into_bytes()).await.unwrap();
728
678
assert_eq!(resp.status, 200);
729
679
let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
730
680
assert_eq!(v["object"], "chat.completion");
751
701
"stream": false
752
702
})
753
703
.to_string();
754
- let resp = client
755
- .post("/v1/chat/completions", body.into_bytes())
756
- .await
757
- .unwrap();
704
+ let resp = client.post("/v1/chat/completions", body.into_bytes()).await.unwrap();
758
705
assert_eq!(resp.status, 503);
759
706
let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
760
707
assert_eq!(v["error"]["type"], "server_error");
769
716
let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap());
770
717
engine.register_mock("mock");
771
718
let pool = KvBlockPool::new(1024);
772
- let config = SchedulerConfig {
773
- max_num_seqs: 8,
774
- ..Default::default()
775
- };
719
+ let config = SchedulerConfig { max_num_seqs: 8, ..Default::default() };
776
720
let scheduler = Arc::new(std::sync::Mutex::new(Scheduler::new(config, pool)));
777
721
let se = Arc::new(SchedulerEngine::new(engine.clone(), scheduler));
778
722
(engine, se)
790
734
"stream": false
791
735
})
792
736
.to_string();
793
- let resp = client
794
- .post("/v1/chat/completions", body.into_bytes())
795
- .await
796
- .unwrap();
737
+ let resp = client.post("/v1/chat/completions", body.into_bytes()).await.unwrap();
797
738
assert_eq!(resp.status, 200);
798
739
let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
799
740
assert_eq!(v["object"], "chat.completion");
800
- assert!(!v["choices"][0]["message"]["content"]
801
- .as_str()
802
- .unwrap()
803
- .is_empty());
741
+ assert!(!v["choices"][0]["message"]["content"].as_str().unwrap().is_empty());
804
742
}
805
743
806
744
#[tokio::test]
815
753
"stream": true
816
754
})
817
755
.to_string();
818
- let resp = client
819
- .post("/v1/chat/completions", body.into_bytes())
820
- .await
821
- .unwrap();
756
+ let resp = client.post("/v1/chat/completions", body.into_bytes()).await.unwrap();
822
757
assert_eq!(resp.status, 200);
823
758
let text = String::from_utf8(resp.body).unwrap();
824
759
assert!(text.contains("data: "));
837
772
"stream": false
838
773
})
839
774
.to_string();
840
- let resp = client
841
- .post("/v1/completions", body.into_bytes())
842
- .await
843
- .unwrap();
775
+ let resp = client.post("/v1/completions", body.into_bytes()).await.unwrap();
844
776
assert_eq!(resp.status, 200);
845
777
let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
846
778
assert_eq!(v["object"], "text_completion");
847
779
▼crates/justapi-core/src/openapi.rs
+114-110
48
48
pub head: Option<Operation>,
49
49
#[serde(default, skip_serializing_if = "Option::is_none")]
50
50
pub options: Option<Operation>,
51
+ #[serde(default, skip_serializing_if = "Option::is_none")]
52
+ pub trace: Option<Operation>,
51
53
/// The HTTP QUERY method (RFC 10008) — safe, idempotent, body-carrying.
52
54
#[serde(default, skip_serializing_if = "Option::is_none")]
53
55
pub query: Option<Operation>,
67
69
Method::PATCH => self.patch = Some(op),
68
70
Method::HEAD => self.head = Some(op),
69
71
Method::OPTIONS => self.options = Some(op),
72
+ Method::TRACE => self.trace = Some(op),
70
73
_ => {}
71
74
}
72
75
}
73
76
}
74
77
75
-#[derive(Debug, Clone, Serialize, Deserialize)]
78
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76
79
pub struct Operation {
77
80
#[serde(default, skip_serializing_if = "Option::is_none")]
78
81
pub summary: Option<String>,
92
95
pub responses: BTreeMap<String, Response>,
93
96
#[serde(default, skip_serializing_if = "Option::is_none")]
94
97
pub deprecated: Option<bool>,
98
+ /// Extra top-level fields merged into the Operation object (OpenAPI
99
+ /// `openapi_extra`). Serialized flat alongside the typed fields.
100
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
101
+ #[serde(flatten)]
102
+ pub extensions: BTreeMap<String, serde_json::Value>,
95
103
}
96
104
97
105
#[derive(Debug, Clone, Serialize, Deserialize)]
152
160
153
161
impl Schema {
154
162
pub fn ref_path(path: &str) -> Self {
155
- Self::Ref {
156
- ref_path: path.to_string(),
157
- }
163
+ Self::Ref { ref_path: path.to_string() }
158
164
}
159
165
160
166
pub fn object(obj: SchemaObject) -> Self {
236
242
237
243
impl SchemaObject {
238
244
pub fn string() -> Self {
239
- Self {
240
- schema_type: Some("string".to_string()),
241
- ..Default::default()
242
- }
245
+ Self { schema_type: Some("string".to_string()), ..Default::default() }
243
246
}
244
247
245
248
pub fn integer() -> Self {
246
- Self {
247
- schema_type: Some("integer".to_string()),
248
- ..Default::default()
249
- }
249
+ Self { schema_type: Some("integer".to_string()), ..Default::default() }
250
250
}
251
251
252
252
pub fn number() -> Self {
253
- Self {
254
- schema_type: Some("number".to_string()),
255
- ..Default::default()
256
- }
253
+ Self { schema_type: Some("number".to_string()), ..Default::default() }
257
254
}
258
255
259
256
pub fn boolean() -> Self {
260
- Self {
261
- schema_type: Some("boolean".to_string()),
262
- ..Default::default()
263
- }
257
+ Self { schema_type: Some("boolean".to_string()), ..Default::default() }
264
258
}
265
259
266
260
pub fn object() -> Self {
267
- Self {
268
- schema_type: Some("object".to_string()),
269
- ..Default::default()
270
- }
261
+ Self { schema_type: Some("object".to_string()), ..Default::default() }
271
262
}
272
263
273
264
pub fn array() -> Self {
274
- Self {
275
- schema_type: Some("array".to_string()),
276
- ..Default::default()
277
- }
265
+ Self { schema_type: Some("array".to_string()), ..Default::default() }
278
266
}
279
267
280
268
pub fn with_items(mut self, items: Schema) -> Self {
283
271
}
284
272
285
273
pub fn with_property(mut self, name: &str, prop: Schema) -> Self {
286
- self.properties
287
- .get_or_insert_with(BTreeMap::new)
288
- .insert(name.to_string(), prop);
274
+ self.properties.get_or_insert_with(BTreeMap::new).insert(name.to_string(), prop);
289
275
self
290
276
}
291
277
347
333
}
348
334
349
335
pub fn server(mut self, url: &str, description: Option<&str>) -> Self {
350
- self.servers.push(Server {
351
- url: url.to_string(),
352
- description: description.map(|s| s.to_string()),
353
- });
336
+ self.servers
337
+ .push(Server { url: url.to_string(), description: description.map(|s| s.to_string()) });
354
338
self
355
339
}
356
340
357
341
pub fn tag(mut self, name: &str, description: Option<&str>) -> Self {
358
- self.tags.push(Tag {
359
- name: name.to_string(),
360
- description: description.map(|s| s.to_string()),
361
- });
342
+ self.tags
343
+ .push(Tag { name: name.to_string(), description: description.map(|s| s.to_string()) });
362
344
self
363
345
}
364
346
377
359
patch: None,
378
360
head: None,
379
361
options: None,
362
+ trace: None,
380
363
query: None,
381
364
});
382
365
entry.insert(&method, op);
387
370
let components = if self.schemas.is_empty() {
388
371
None
389
372
} else {
390
- Some(Components {
391
- schemas: Some(self.schemas),
392
- })
373
+ Some(Components { schemas: Some(self.schemas) })
393
374
};
394
375
395
- let tags = if self.tags.is_empty() {
396
- None
397
- } else {
398
- Some(self.tags)
399
- };
376
+ let tags = if self.tags.is_empty() { None } else { Some(self.tags) };
400
377
401
- let servers = if self.servers.is_empty() {
402
- None
403
- } else {
404
- Some(self.servers)
405
- };
378
+ let servers = if self.servers.is_empty() { None } else { Some(self.servers) };
406
379
407
380
OpenApiDocument {
408
381
openapi: "3.1.0".to_string(),
432
405
/// When true, the generated operation is tagged `experimental`
433
406
/// (used for recently-standardized methods such as HTTP QUERY).
434
407
pub experimental: bool,
408
+ /// Default success status code for the operation (e.g. 201). When `None`
409
+ /// the generated OpenAPI uses `200`.
410
+ pub status_code: Option<u16>,
411
+ /// Additional responses to merge into the OpenAPI `responses` object
412
+ /// (keyed by status code). Raw JSON as provided by the user.
413
+ pub responses: Option<serde_json::Value>,
414
+ /// Custom operation ID. When `None`, one is auto-generated from the
415
+ /// method + path.
416
+ pub operation_id: Option<String>,
417
+ /// Arbitrary extra fields merged into the generated Operation object
418
+ /// (OpenAPI `openapi_extra`).
419
+ pub openapi_extra: Option<serde_json::Value>,
420
+ /// When false, the operation is excluded from the generated OpenAPI spec
421
+ /// (FastAPI `include_in_schema=False`).
422
+ pub include_in_schema: bool,
435
423
}
436
424
437
425
#[derive(Debug, Clone, Default)]
454
442
.tag("default", Some("Default endpoint group"));
455
443
456
444
for route in &self.routes {
445
+ let auto_operation_id = format!(
446
+ "{}_{}",
447
+ route.method.as_str().to_lowercase(),
448
+ route.path.replace(['/', '{', '}', ':'], "_").trim_matches('_')
449
+ );
457
450
let mut op = Operation {
458
451
summary: route.summary.clone(),
459
452
description: route.description.clone(),
460
- operation_id: Some(format!(
461
- "{}_{}",
462
- route.method.as_str().to_lowercase(),
463
- route
464
- .path
465
- .replace(['/', '{', '}', ':'], "_")
466
- .trim_matches('_')
467
- )),
453
+ operation_id: route.operation_id.clone().or(Some(auto_operation_id)),
468
454
tags: {
469
455
let mut tags = route.tags.clone();
470
456
if route.experimental && !tags.iter().any(|t| t == "experimental") {
476
462
request_body: None,
477
463
responses: BTreeMap::new(),
478
464
deprecated: if route.deprecated { Some(true) } else { None },
465
+ extensions: BTreeMap::new(),
479
466
};
480
467
468
+ // Skip routes excluded from the schema (FastAPI include_in_schema=False).
469
+ if !route.include_in_schema {
470
+ continue;
471
+ }
472
+
481
473
let mut path_params = Vec::new();
482
474
for segment in route.path.split('/') {
483
475
if segment.starts_with('{') && segment.ends_with('}') {
504
496
let body_schema = route.request_body_schema.as_ref().map(json_value_to_schema);
505
497
506
498
let mut content = BTreeMap::new();
507
- content.insert(
508
- "application/json".to_string(),
509
- MediaType {
510
- schema: body_schema,
511
- },
512
- );
513
- op.request_body = Some(RequestBody {
514
- description: None,
515
- content,
516
- required: true,
517
- });
499
+ content.insert("application/json".to_string(), MediaType { schema: body_schema });
500
+ op.request_body = Some(RequestBody { description: None, content, required: true });
518
501
}
519
502
520
503
let mut responses = BTreeMap::new();
504
+ let success_code = route.status_code.unwrap_or(200).to_string();
521
505
responses.insert(
522
- "200".to_string(),
506
+ success_code.clone(),
523
507
Response {
524
508
description: "Successful response".to_string(),
525
509
content: Some({
536
520
);
537
521
responses.insert(
538
522
"400".to_string(),
539
- Response {
540
- description: "Bad request".to_string(),
541
- content: None,
542
- },
523
+ Response { description: "Bad request".to_string(), content: None },
543
524
);
544
525
responses.insert(
545
526
"500".to_string(),
546
- Response {
547
- description: "Internal server error".to_string(),
548
- content: None,
549
- },
527
+ Response { description: "Internal server error".to_string(), content: None },
550
528
);
551
529
op.responses = responses;
552
530
531
+ // Merge user-provided `responses` into the generated Operation.
532
+ if let Some(resp) = &route.responses {
533
+ if let Some(map) = resp.as_object() {
534
+ for (code, val) in map {
535
+ if let Ok(parsed) = serde_json::from_value::<Response>(val.clone()) {
536
+ op.responses.insert(code.clone(), parsed);
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ // Merge `openapi_extra` into the Operation extensions (flattened
543
+ // top-level fields, mirroring FastAPI's `openapi_extra`).
544
+ if let Some(extra) = &route.openapi_extra {
545
+ if let Some(map) = extra.as_object() {
546
+ for (key, val) in map {
547
+ if key == "responses" || key == "operationId" {
548
+ continue;
549
+ }
550
+ op.extensions.insert(key.clone(), val.clone());
551
+ }
552
+ }
553
+ }
554
+
553
555
builder = builder.operation(route.method.clone(), &route.path, op);
554
556
}
555
557
707
709
let mut m = BTreeMap::new();
708
710
m.insert(
709
711
"200".to_string(),
710
- Response {
711
- description: "User found".to_string(),
712
- content: None,
713
- },
712
+ Response { description: "User found".to_string(), content: None },
714
713
);
715
714
m
716
715
},
717
716
deprecated: None,
717
+ extensions: Default::default(),
718
718
},
719
719
)
720
720
.build();
749
749
response_schema: Some(response_schema),
750
750
deprecated: false,
751
751
experimental: false,
752
+ status_code: None,
753
+ responses: None,
754
+ operation_id: None,
755
+ openapi_extra: None,
756
+ include_in_schema: true,
752
757
});
753
758
754
759
let doc = registry.generate("Test", "1.0.0");
775
780
response_schema: None,
776
781
deprecated: false,
777
782
experimental: false,
783
+ status_code: None,
784
+ responses: None,
785
+ operation_id: None,
786
+ openapi_extra: None,
787
+ include_in_schema: true,
778
788
});
779
789
780
790
let doc = registry.generate("Test", "1.0.0");
781
791
let json = serde_json::to_value(&doc).unwrap();
782
- let params = json
783
- .pointer("/paths/~1users~1{id}~1posts~1{postId}/get/parameters")
784
- .unwrap();
792
+ let params = json.pointer("/paths/~1users~1{id}~1posts~1{postId}/get/parameters").unwrap();
785
793
assert_eq!(params.as_array().unwrap().len(), 2);
786
794
assert_eq!(params[0]["name"], "id");
787
795
assert_eq!(params[1]["name"], "postId");
805
813
let mut c = std::collections::BTreeMap::new();
806
814
c.insert(
807
815
"application/x-www-form-urlencoded".to_string(),
808
- MediaType {
809
- schema: Some(Schema::string()),
810
- },
816
+ MediaType { schema: Some(Schema::string()) },
811
817
);
812
818
c
813
819
},
817
823
let mut m = std::collections::BTreeMap::new();
818
824
m.insert(
819
825
"200".to_string(),
820
- Response {
821
- description: "ok".to_string(),
822
- content: None,
823
- },
826
+ Response { description: "ok".to_string(), content: None },
824
827
);
825
828
m
826
829
},
827
830
deprecated: None,
831
+ extensions: Default::default(),
828
832
},
829
833
)
830
834
.build();
831
835
let json = serde_json::to_value(&doc).unwrap();
832
- let op = json
833
- .pointer("/paths/~1search/query")
834
- .expect("query op present");
836
+ let op = json.pointer("/paths/~1search/query").expect("query op present");
835
837
assert_eq!(op["summary"], "Search");
836
838
assert!(op.get("requestBody").is_some());
837
839
}
849
851
response_schema: None,
850
852
deprecated: false,
851
853
experimental: true,
854
+ status_code: None,
855
+ responses: None,
856
+ operation_id: None,
857
+ openapi_extra: None,
858
+ include_in_schema: true,
852
859
});
853
860
854
861
let doc = registry.generate("Test", "1.0.0");
855
862
let json = serde_json::to_value(&doc).unwrap();
856
- let op = json
857
- .pointer("/paths/~1search/query")
858
- .expect("query op present");
863
+ let op = json.pointer("/paths/~1search/query").expect("query op present");
859
864
let tags = op["tags"].as_array().expect("tags array");
860
865
assert!(tags.iter().any(|t| t == "experimental"));
861
866
// QUERY carries a request body per RFC 10008.
875
880
response_schema: None,
876
881
deprecated: false,
877
882
experimental: false,
883
+ status_code: None,
884
+ responses: None,
885
+ operation_id: None,
886
+ openapi_extra: None,
887
+ include_in_schema: true,
878
888
});
879
889
880
890
let doc = registry.generate("Test", "1.0.0");
881
891
let json = serde_json::to_value(&doc).unwrap();
882
- let op = json
883
- .pointer("/paths/~1search/query")
884
- .expect("query op present");
892
+ let op = json.pointer("/paths/~1search/query").expect("query op present");
885
893
let tags = op["tags"].as_array().expect("tags array");
886
894
assert!(tags.iter().all(|t| t != "experimental"));
887
895
}
907
915
let json = serde_json::to_value(&schema).unwrap();
908
916
if let Some(obj) = json.as_object() {
909
917
assert_eq!(
910
- obj.get("properties")
911
- .and_then(|p| p.get("name"))
912
- .and_then(|n| n.get("type")),
918
+ obj.get("properties").and_then(|p| p.get("name")).and_then(|n| n.get("type")),
913
919
Some(&serde_json::json!("string"))
914
920
);
915
921
}
933
939
let mut m = BTreeMap::new();
934
940
m.insert(
935
941
"200".to_string(),
936
- Response {
937
- description: "OK".to_string(),
938
- content: None,
939
- },
942
+ Response { description: "OK".to_string(), content: None },
940
943
);
941
944
m
942
945
},
943
946
deprecated: None,
947
+ extensions: Default::default(),
944
948
},
945
949
)
946
950
.build();
947
951
▼crates/justapi-core/src/panic.rs
+1-3
29
29
);
30
30
}));
31
31
32
- let result = std::panic::AssertUnwindSafe(handler(req))
33
- .catch_unwind()
34
- .await;
32
+ let result = std::panic::AssertUnwindSafe(handler(req)).catch_unwind().await;
35
33
36
34
std::panic::set_hook(prev);
37
35
38
36
▼crates/justapi-core/src/rate_limit.rs
+4-16
9
9
10
10
#[derive(Clone)]
11
11
pub enum RateLimiter {
12
- Redis {
13
- client: ConnectionManager,
14
- script: redis::Script,
15
- },
12
+ Redis { client: ConnectionManager, script: redis::Script },
16
13
// We can add a local governor fallback later if needed
17
14
// Local(Arc<governor::DefaultDirectRateLimiter>),
18
15
}
56
53
end
57
54
"#;
58
55
59
- Ok(RateLimiter::Redis {
60
- client: manager,
61
- script: redis::Script::new(lua_script),
62
- })
56
+ Ok(RateLimiter::Redis { client: manager, script: redis::Script::new(lua_script) })
63
57
}
64
58
65
59
/// Check if a request is allowed.
81
75
// Burst capacity in microseconds
82
76
let tau = t * capacity;
83
77
84
- let now = SystemTime::now()
85
- .duration_since(UNIX_EPOCH)
86
- .unwrap()
87
- .as_micros() as u64;
78
+ let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros() as u64;
88
79
89
80
let result: Vec<u64> = script
90
81
.key(key)
98
89
if result.len() >= 2 {
99
90
let allowed = result[0] == 1;
100
91
let retry_after_ms = result[1];
101
- Ok(RateLimitResult {
102
- allowed,
103
- retry_after_ms,
104
- })
92
+ Ok(RateLimitResult { allowed, retry_after_ms })
105
93
} else {
106
94
Err(anyhow::anyhow!("Invalid response from Redis GCRA script"))
107
95
}
108
96
▼crates/justapi-core/src/resilience.rs
+33-110
173
173
174
174
impl PerRouteCircuitBreakerMiddleware {
175
175
pub fn new(config: CircuitBreakerConfig) -> Self {
176
- Self {
177
- config,
178
- breakers: std::sync::RwLock::new(std::collections::HashMap::new()),
179
- }
176
+ Self { config, breakers: std::sync::RwLock::new(std::collections::HashMap::new()) }
180
177
}
181
178
182
179
fn get_breaker(&self, path: &str) -> CircuitBreaker {
227
224
fn circuit_open_response() -> Response<ResponseBody> {
228
225
let body = r#"{"error":"service unavailable — circuit breaker open"}"#;
229
226
let mut resp = json_text_response(StatusCode::SERVICE_UNAVAILABLE, body);
230
- resp.headers_mut()
231
- .insert("retry-after", "30".parse().unwrap());
227
+ resp.headers_mut().insert("retry-after", "30".parse().unwrap());
232
228
resp
233
229
}
234
230
258
254
}
259
255
260
256
fn timeout_response(timeout: Duration) -> Response<ResponseBody> {
261
- let body = format!(
262
- r#"{{"error":"request timed out after {}ms"}}"#,
263
- timeout.as_millis()
264
- );
257
+ let body = format!(r#"{{"error":"request timed out after {}ms"}}"#, timeout.as_millis());
265
258
json_text_response(StatusCode::GATEWAY_TIMEOUT, &body)
266
259
}
267
260
296
289
let capped = delay_ms.min(self.max_delay.as_millis() as u64);
297
290
if self.jitter {
298
291
let jitter_range = capped / 5;
299
- let jitter = if jitter_range > 0 {
300
- rand::random::<u64>() % jitter_range
301
- } else {
302
- 0
303
- };
292
+ let jitter = if jitter_range > 0 { rand::random::<u64>() % jitter_range } else { 0 };
304
293
Duration::from_millis(capped + jitter)
305
294
} else {
306
295
Duration::from_millis(capped)
326
315
327
316
impl Default for BulkheadConfig {
328
317
fn default() -> Self {
329
- Self {
330
- max_concurrent: 10,
331
- max_wait: Duration::from_secs(1),
332
- }
318
+ Self { max_concurrent: 10, max_wait: Duration::from_secs(1) }
333
319
}
334
320
}
335
321
341
327
342
328
impl Bulkhead {
343
329
pub fn new(config: BulkheadConfig) -> Self {
344
- Self {
345
- semaphore: Arc::new(Semaphore::new(config.max_concurrent as usize)),
346
- config,
347
- }
330
+ Self { semaphore: Arc::new(Semaphore::new(config.max_concurrent as usize)), config }
348
331
}
349
332
}
350
333
361
344
#[async_trait]
362
345
impl<B: Send + 'static> Middleware<B> for BulkheadMiddleware {
363
346
async fn handle(&self, req: Request<B>, next: Next<'_, B>) -> Result<Response<ResponseBody>> {
364
- let permit = tokio::time::timeout(
365
- self.bulkhead.config.max_wait,
366
- self.bulkhead.semaphore.acquire(),
367
- )
368
- .await;
347
+ let permit =
348
+ tokio::time::timeout(self.bulkhead.config.max_wait, self.bulkhead.semaphore.acquire())
349
+ .await;
369
350
370
351
match permit {
371
352
Ok(Ok(permit)) => {
402
383
403
384
impl FallbackPolicy {
404
385
pub fn new() -> Self {
405
- Self {
406
- fallbacks: Arc::new(HashMap::new()),
407
- default_fallback: None,
408
- }
386
+ Self { fallbacks: Arc::new(HashMap::new()), default_fallback: None }
409
387
}
410
388
411
389
pub fn add(mut self, path_prefix: &str, entry: FallbackEntry) -> Self {
599
577
fn five_hundred_handler() -> HandlerFn<TestBody> {
600
578
Arc::new(|_req: Request<TestBody>| {
601
579
Box::pin(async {
602
- Ok(json_response(
603
- StatusCode::INTERNAL_SERVER_ERROR,
604
- r#"{"error":"server error"}"#,
605
- ))
580
+ Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, r#"{"error":"server error"}"#))
606
581
})
607
582
})
608
583
}
609
584
610
585
fn test_req(method: hyper::Method, uri: &str) -> Request<TestBody> {
611
- Request::builder()
612
- .method(method)
613
- .uri(uri)
614
- .body(TestBody::new(Bytes::new()))
615
- .unwrap()
586
+ Request::builder().method(method).uri(uri).body(TestBody::new(Bytes::new())).unwrap()
616
587
}
617
588
618
589
// -----------------------------------------------------------------------
820
791
821
792
#[tokio::test]
822
793
async fn test_bulkhead_passes_normal_requests() {
823
- let config = BulkheadConfig {
824
- max_concurrent: 10,
825
- max_wait: Duration::from_secs(1),
826
- };
794
+ let config = BulkheadConfig { max_concurrent: 10, max_wait: Duration::from_secs(1) };
827
795
let bulkhead = Bulkhead::new(config);
828
796
let mw = BulkheadMiddleware::new(bulkhead);
829
797
let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
834
802
835
803
#[tokio::test]
836
804
async fn test_bulkhead_rejects_when_full() {
837
- let config = BulkheadConfig {
838
- max_concurrent: 1,
839
- max_wait: Duration::from_millis(50),
840
- };
805
+ let config = BulkheadConfig { max_concurrent: 1, max_wait: Duration::from_millis(50) };
841
806
let bulkhead = Bulkhead::new(config);
842
807
let mw = BulkheadMiddleware::new(bulkhead);
843
808
942
907
let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
943
908
chain.add(mw);
944
909
945
- let resp = chain
946
- .run(test_req(hyper::Method::GET, "/api/data"))
947
- .await
948
- .unwrap();
949
- assert!(
950
- String::from_utf8_lossy(&resp.into_body().collect().await.unwrap().to_bytes())
951
- .contains(r#""api"#)
952
- );
910
+ let resp = chain.run(test_req(hyper::Method::GET, "/api/data")).await.unwrap();
911
+ assert!(String::from_utf8_lossy(&resp.into_body().collect().await.unwrap().to_bytes())
912
+ .contains(r#""api"#));
953
913
954
- let resp = chain
955
- .run(test_req(hyper::Method::GET, "/users/123"))
956
- .await
957
- .unwrap();
958
- assert!(
959
- String::from_utf8_lossy(&resp.into_body().collect().await.unwrap().to_bytes())
960
- .contains(r#""users"#)
961
- );
914
+ let resp = chain.run(test_req(hyper::Method::GET, "/users/123")).await.unwrap();
915
+ assert!(String::from_utf8_lossy(&resp.into_body().collect().await.unwrap().to_bytes())
916
+ .contains(r#""users"#));
962
917
}
963
918
964
919
#[tokio::test]
967
922
let mw = FallbackMiddleware::new(policy);
968
923
let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
969
924
chain.add(mw);
970
- let resp = chain
971
- .run(test_req(hyper::Method::GET, "/no-fallback"))
972
- .await
973
- .unwrap();
925
+ let resp = chain.run(test_req(hyper::Method::GET, "/no-fallback")).await.unwrap();
974
926
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
975
927
}
976
928
984
936
let mw = FallbackMiddleware::new(policy);
985
937
let mut chain = crate::middleware::MiddlewareChain::new(error_handler());
986
938
chain.add(mw);
987
- let resp = chain
988
- .run(test_req(hyper::Method::GET, "/anything"))
989
- .await
990
- .unwrap();
939
+ let resp = chain.run(test_req(hyper::Method::GET, "/anything")).await.unwrap();
991
940
assert_eq!(resp.status(), StatusCode::OK);
992
941
}
993
942
997
946
998
947
#[tokio::test]
999
948
async fn test_chaos_disabled_passes_through() {
1000
- let config = ChaosConfig {
1001
- enabled: false,
1002
- error_p: 1.0,
1003
- ..Default::default()
1004
- };
949
+ let config = ChaosConfig { enabled: false, error_p: 1.0, ..Default::default() };
1005
950
let mw = ChaosMiddleware::new(config);
1006
951
let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1007
952
chain.add(mw);
1050
995
#[tokio::test]
1051
996
async fn test_timeout_and_bulkhead_together() {
1052
997
let timeout_mw = TimeoutMiddleware::new(Duration::from_secs(5));
1053
- let bulkhead = Bulkhead::new(BulkheadConfig {
1054
- max_concurrent: 5,
1055
- ..Default::default()
1056
- });
998
+ let bulkhead = Bulkhead::new(BulkheadConfig { max_concurrent: 5, ..Default::default() });
1057
999
let bulkhead_mw = BulkheadMiddleware::new(bulkhead);
1058
1000
1059
1001
let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1093
1035
1094
1036
let resp = chain.run(test_req(hyper::Method::GET, "/")).await.unwrap();
1095
1037
assert_eq!(resp.status(), StatusCode::OK);
1096
- assert!(
1097
- String::from_utf8_lossy(&resp.into_body().collect().await.unwrap().to_bytes())
1098
- .contains(r#""fallback"#)
1099
- );
1038
+ assert!(String::from_utf8_lossy(&resp.into_body().collect().await.unwrap().to_bytes())
1039
+ .contains(r#""fallback"#));
1100
1040
}
1101
1041
1102
1042
// -----------------------------------------------------------------------
1332
1272
1333
1273
#[tokio::test]
1334
1274
async fn test_bulkhead_zero_concurrent() {
1335
- let config = BulkheadConfig {
1336
- max_concurrent: 0,
1337
- max_wait: Duration::from_millis(10),
1338
- };
1275
+ let config = BulkheadConfig { max_concurrent: 0, max_wait: Duration::from_millis(10) };
1339
1276
let bulkhead = Bulkhead::new(config);
1340
1277
let mw = BulkheadMiddleware::new(bulkhead);
1341
1278
let mut chain = crate::middleware::MiddlewareChain::new(ok_handler());
1346
1283
1347
1284
#[tokio::test]
1348
1285
async fn test_bulkhead_release_on_error() {
1349
- let config = BulkheadConfig {
1350
- max_concurrent: 1,
1351
- max_wait: Duration::from_millis(50),
1352
- };
1286
+ let config = BulkheadConfig { max_concurrent: 1, max_wait: Duration::from_millis(50) };
1353
1287
let bulkhead = Bulkhead::new(config);
1354
1288
let mw = BulkheadMiddleware::new(bulkhead);
1355
1289
1386
1320
let mw = FallbackMiddleware::new(policy);
1387
1321
let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
1388
1322
chain.add(mw);
1389
- let resp = chain
1390
- .run(test_req(hyper::Method::GET, "/anything"))
1391
- .await
1392
- .unwrap();
1323
+ let resp = chain.run(test_req(hyper::Method::GET, "/anything")).await.unwrap();
1393
1324
assert_eq!(resp.status(), StatusCode::OK);
1394
1325
}
1395
1326
1406
1337
let mw = FallbackMiddleware::new(policy);
1407
1338
let mut chain = crate::middleware::MiddlewareChain::new(five_hundred_handler());
1408
1339
chain.add(mw);
1409
- let resp = chain
1410
- .run(test_req(hyper::Method::GET, "/api/data"))
1411
- .await
1412
- .unwrap();
1340
+ let resp = chain.run(test_req(hyper::Method::GET, "/api/data")).await.unwrap();
1413
1341
assert_eq!(resp.status(), StatusCode::OK);
1414
1342
assert_eq!(resp.headers()["content-type"], "application/xml");
1415
1343
let body = resp.into_body().collect().await.unwrap().to_bytes();
1459
1387
#[tokio::test]
1460
1388
async fn test_timeout_bulkhead_circuit_breaker_combined() {
1461
1389
let timeout_mw = TimeoutMiddleware::new(Duration::from_secs(5));
1462
- let bulkhead = Bulkhead::new(BulkheadConfig {
1463
- max_concurrent: 2,
1464
- max_wait: Duration::from_secs(1),
1465
- });
1390
+ let bulkhead =
1391
+ Bulkhead::new(BulkheadConfig { max_concurrent: 2, max_wait: Duration::from_secs(1) });
1466
1392
let bulkhead_mw = BulkheadMiddleware::new(bulkhead);
1467
1393
let breaker = CircuitBreaker::new(CircuitBreakerConfig {
1468
1394
failure_threshold: 5,
1512
1438
#[tokio::test]
1513
1439
async fn test_bulkhead_timeout_interaction() {
1514
1440
// A slow handler that would be blocked by bulkhead, but timeout should fire first
1515
- let config = BulkheadConfig {
1516
- max_concurrent: 1,
1517
- max_wait: Duration::from_millis(100),
1518
- };
1441
+ let config = BulkheadConfig { max_concurrent: 1, max_wait: Duration::from_millis(100) };
1519
1442
let bulkhead = Bulkhead::new(config);
1520
1443
let bulkhead_mw = BulkheadMiddleware::new(bulkhead);
1521
1444
let timeout_mw = TimeoutMiddleware::new(Duration::from_millis(200));
1522
1445
▼crates/justapi-core/src/router.rs
+13-46
26
26
27
27
impl<T: Clone> Router<T> {
28
28
pub fn new() -> Self {
29
- Self {
30
- routes: HashMap::new(),
31
- fallback: None,
32
- route_list: Vec::new(),
33
- }
29
+ Self { routes: HashMap::new(), fallback: None, route_list: Vec::new() }
34
30
}
35
31
36
32
pub fn insert(&mut self, method: Method, path: &str, handler: T) -> Result<(), InsertError> {
59
55
) -> Result<Match<'a, 'p, T>, RouterError> {
60
56
if let Some(router) = self.routes.get(method) {
61
57
if let Ok(matched) = router.at(path) {
62
- return Ok(Match {
63
- handler: matched.value,
64
- params: matched.params,
65
- });
58
+ return Ok(Match { handler: matched.value, params: matched.params });
66
59
}
67
60
}
68
61
97
90
#[test]
98
91
fn test_basic_route() {
99
92
let mut router = Router::new();
100
- router
101
- .insert(Method::GET, "/hello", "hello_handler")
102
- .unwrap();
93
+ router.insert(Method::GET, "/hello", "hello_handler").unwrap();
103
94
let m = router.at(&Method::GET, "/hello").unwrap();
104
95
assert_eq!(*m.handler, "hello_handler");
105
96
}
107
98
#[test]
108
99
fn test_route_not_found() {
109
100
let mut router = Router::new();
110
- router
111
- .insert(Method::GET, "/hello", "hello_handler")
112
- .unwrap();
101
+ router.insert(Method::GET, "/hello", "hello_handler").unwrap();
113
102
assert!(router.at(&Method::GET, "/unknown").is_err());
114
103
}
115
104
116
105
#[test]
117
106
fn test_wrong_method() {
118
107
let mut router = Router::new();
119
- router
120
- .insert(Method::GET, "/hello", "hello_handler")
121
- .unwrap();
108
+ router.insert(Method::GET, "/hello", "hello_handler").unwrap();
122
109
assert_eq!(
123
110
router.at(&Method::POST, "/hello").err().unwrap(),
124
111
RouterError::MethodNotAllowed
128
115
#[test]
129
116
fn test_path_param() {
130
117
let mut router = Router::new();
131
- router
132
- .insert(Method::GET, "/users/{id}", "user_handler")
133
- .unwrap();
118
+ router.insert(Method::GET, "/users/{id}", "user_handler").unwrap();
134
119
let m = router.at(&Method::GET, "/users/42").unwrap();
135
120
assert_eq!(*m.handler, "user_handler");
136
121
assert_eq!(m.params.get("id"), Some("42"));
139
124
#[test]
140
125
fn test_catch_all() {
141
126
let mut router = Router::new();
142
- router
143
- .insert(Method::GET, "/files/{*path}", "file_handler")
144
- .unwrap();
127
+ router.insert(Method::GET, "/files/{*path}", "file_handler").unwrap();
145
128
let m = router.at(&Method::GET, "/files/a/b/c").unwrap();
146
129
assert_eq!(*m.handler, "file_handler");
147
130
assert_eq!(m.params.get("path"), Some("a/b/c"));
150
133
#[test]
151
134
fn test_multiple_routes() {
152
135
let mut router = Router::new();
153
- router
154
- .insert(Method::GET, "/users/{id}", "user_handler")
155
- .unwrap();
136
+ router.insert(Method::GET, "/users/{id}", "user_handler").unwrap();
156
137
router.insert(Method::GET, "/users", "users_list").unwrap();
157
138
let m = router.at(&Method::GET, "/users/42").unwrap();
158
139
assert_eq!(*m.handler, "user_handler");
163
144
#[test]
164
145
fn test_post_route() {
165
146
let mut router = Router::new();
166
- router
167
- .insert(Method::POST, "/data", "data_handler")
168
- .unwrap();
147
+ router.insert(Method::POST, "/data", "data_handler").unwrap();
169
148
let m = router.at(&Method::POST, "/data").unwrap();
170
149
assert_eq!(*m.handler, "data_handler");
171
150
}
173
152
#[test]
174
153
fn test_query_method_route() {
175
154
let mut router = Router::new();
176
- router
177
- .insert(crate::query_method(), "/search", "search_handler")
178
- .unwrap();
155
+ router.insert(crate::query_method(), "/search", "search_handler").unwrap();
179
156
let m = router.at(&crate::query_method(), "/search").unwrap();
180
157
assert_eq!(*m.handler, "search_handler");
181
158
// QUERY is distinct from POST on the same path.
224
201
.unwrap();
225
202
}
226
203
// Catch-all
227
- router
228
- .insert(Method::GET, "/static/{*path}", "static_handler".to_string())
229
- .unwrap();
204
+ router.insert(Method::GET, "/static/{*path}", "static_handler".to_string()).unwrap();
230
205
231
206
// Warmup
232
207
for _ in 0..1000 {
254
229
255
230
let total_lookups = iterations * test_paths.len() as u64;
256
231
let avg_ns = total_ns / total_lookups;
257
- println!(
258
- "500-route table: {} lookups, avg {}ns",
259
- total_lookups, avg_ns,
260
- );
232
+ println!("500-route table: {} lookups, avg {}ns", total_lookups, avg_ns,);
261
233
262
234
// Benchmark gate: average lookup should be under 100ns (release) / 1us (debug)
263
235
let target = if cfg!(debug_assertions) { 1000 } else { 100 };
264
- assert!(
265
- avg_ns < target,
266
- "Route lookup too slow: {}ns (target < {}ns)",
267
- avg_ns,
268
- target
269
- );
236
+ assert!(avg_ns < target, "Route lookup too slow: {}ns (target < {}ns)", avg_ns, target);
270
237
}
271
238
}
272
239
▼crates/justapi-core/src/secrets.rs
+4-15
21
21
22
22
impl Secret {
23
23
pub fn env(var_name: &str) -> Self {
24
- Self {
25
- source: SecretSource::Env(var_name.to_string()),
26
- name: var_name.to_string(),
27
- }
24
+ Self { source: SecretSource::Env(var_name.to_string()), name: var_name.to_string() }
28
25
}
29
26
30
27
pub fn file(path: impl Into<std::path::PathBuf>) -> Self {
33
30
.file_name()
34
31
.map(|s| s.to_string_lossy().to_string())
35
32
.unwrap_or_else(|| path.to_string_lossy().to_string());
36
- Self {
37
- source: SecretSource::File(path),
38
- name,
39
- }
33
+ Self { source: SecretSource::File(path), name }
40
34
}
41
35
42
36
pub fn inline(value: &str, label: &str) -> Self {
43
- Self {
44
- source: SecretSource::Inline(value.to_string()),
45
- name: label.to_string(),
46
- }
37
+ Self { source: SecretSource::Inline(value.to_string()), name: label.to_string() }
47
38
}
48
39
49
40
/// Resolve the secret to its string value.
94
85
/// Resolve all secrets eagerly. Useful for startup validation.
95
86
pub fn resolve_all(&self) -> Vec<(String, Result<String, anyhow::Error>)> {
96
87
let map = self.secrets.read().unwrap();
97
- map.iter()
98
- .map(|(name, secret)| (name.clone(), secret.resolve()))
99
- .collect()
88
+ map.iter().map(|(name, secret)| (name.clone(), secret.resolve())).collect()
100
89
}
101
90
}
102
91
103
92
▼crates/justapi-core/src/serialize.rs
+8-35
66
66
impl<T: serde::Serialize> JsonResponse<T> {
67
67
/// Create a new `JsonResponse` with status 200 OK and no extra headers.
68
68
pub fn new(data: T) -> Self {
69
- Self {
70
- data,
71
- status: StatusCode::OK,
72
- headers: Vec::new(),
73
- }
69
+ Self { data, status: StatusCode::OK, headers: Vec::new() }
74
70
}
75
71
76
72
/// Set the HTTP status code.
125
121
126
122
#[test]
127
123
fn test_to_json_string() {
128
- let payload = TestPayload {
129
- message: "hello".into(),
130
- count: 42,
131
- };
124
+ let payload = TestPayload { message: "hello".into(), count: 42 };
132
125
let json = to_json_string(&payload).unwrap();
133
126
assert!(json.contains(r#""message":"hello""#));
134
127
assert!(json.contains(r#""count":42"#));
136
129
137
130
#[test]
138
131
fn test_to_json_vec() {
139
- let payload = TestPayload {
140
- message: "hello".into(),
141
- count: 42,
142
- };
132
+ let payload = TestPayload { message: "hello".into(), count: 42 };
143
133
let vec = to_json_vec(&payload).unwrap();
144
134
let json = String::from_utf8(vec).unwrap();
145
135
assert!(json.contains(r#""message":"hello""#));
157
147
158
148
#[tokio::test]
159
149
async fn json_response_default_status() {
160
- let user = UserResp {
161
- id: 1,
162
- name: "Alice".into(),
163
- email: None,
164
- };
150
+ let user = UserResp { id: 1, name: "Alice".into(), email: None };
165
151
let resp = JsonResponse::new(user).into_response().unwrap();
166
152
assert_eq!(resp.status(), StatusCode::OK);
167
153
let body = resp.into_body().collect().await.unwrap().to_bytes();
174
160
175
161
#[tokio::test]
176
162
async fn json_response_custom_status_and_headers() {
177
- let user = UserResp {
178
- id: 2,
179
- name: "Bob".into(),
180
- email: Some("[email protected]".into()),
181
- };
163
+ let user = UserResp { id: 2, name: "Bob".into(), email: Some("[email protected]".into()) };
182
164
let resp = JsonResponse::new(user)
183
165
.with_status(StatusCode::CREATED)
184
166
.with_header("x-request-id", "req-999")
196
178
197
179
#[tokio::test]
198
180
async fn json_response_from_convenience() {
199
- let user = UserResp {
200
- id: 3,
201
- name: "Carol".into(),
202
- email: None,
203
- };
181
+ let user = UserResp { id: 3, name: "Carol".into(), email: None };
204
182
let resp = json_response_from(user).into_response().unwrap();
205
183
assert_eq!(resp.status(), StatusCode::OK);
206
184
}
212
190
#[serde(rename = "userId")]
213
191
user_id: u64,
214
192
}
215
- let resp = JsonResponse::new(Renamed { user_id: 42 })
216
- .into_response()
217
- .unwrap();
193
+ let resp = JsonResponse::new(Renamed { user_id: 42 }).into_response().unwrap();
218
194
let body = resp.into_body().collect().await.unwrap().to_bytes();
219
195
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
220
196
assert_eq!(json["userId"], 42);
234
210
235
211
let mut meta = HashMap::new();
236
212
meta.insert("page".into(), serde_json::json!(1));
237
- let val = WithMeta {
238
- data: "content".into(),
239
- meta,
240
- };
213
+ let val = WithMeta { data: "content".into(), meta };
241
214
let resp = JsonResponse::new(val).into_response().unwrap();
242
215
let body = resp.into_body().collect().await.unwrap().to_bytes();
243
216
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
244
217
▼crates/justapi-core/src/server/mod.rs
+166-161
110
110
#[derive(Clone)]
111
111
#[allow(dead_code)]
112
112
pub(crate) enum Handler {
113
- Static {
114
- status: StatusCode,
115
- body: &'static str,
116
- },
113
+ Static { status: StatusCode, body: &'static str },
117
114
Echo,
118
115
ParamsEcho,
119
116
Sse,
359
356
.insert(
360
357
Method::GET,
361
358
"/hello",
362
- Handler::Static {
363
- status: StatusCode::OK,
364
- body: r#"{"message":"hello"}"#,
365
- },
359
+ Handler::Static { status: StatusCode::OK, body: r#"{"message":"hello"}"# },
366
360
)
367
361
.expect("valid");
368
- router
369
- .insert(Method::POST, "/echo", Handler::Echo)
370
- .expect("valid");
371
- router
372
- .insert(Method::GET, "/echo/{*rest}", Handler::ParamsEcho)
373
- .expect("valid");
362
+ router.insert(Method::POST, "/echo", Handler::Echo).expect("valid");
363
+ router.insert(Method::GET, "/echo/{*rest}", Handler::ParamsEcho).expect("valid");
374
364
router
375
365
.insert(
376
366
Method::GET,
377
367
"/users/{id}",
378
- Handler::Static {
379
- status: StatusCode::OK,
380
- body: r#"{"message":"user lookup"}"#,
381
- },
368
+ Handler::Static { status: StatusCode::OK, body: r#"{"message":"user lookup"}"# },
382
369
)
383
370
.expect("valid");
384
- router
385
- .insert(Method::GET, "/events", Handler::Sse)
386
- .expect("valid");
387
- router
388
- .insert(Method::GET, "/graphql", Handler::GraphQL)
389
- .expect("valid");
390
- router
391
- .insert(Method::POST, "/graphql", Handler::GraphQL)
392
- .expect("valid");
371
+ router.insert(Method::GET, "/events", Handler::Sse).expect("valid");
372
+ router.insert(Method::GET, "/graphql", Handler::GraphQL).expect("valid");
373
+ router.insert(Method::POST, "/graphql", Handler::GraphQL).expect("valid");
393
374
router.set_fallback(Handler::Static {
394
375
status: StatusCode::NOT_FOUND,
395
376
body: r#"{"error":"not found"}"#,
494
475
)
495
476
}
496
477
};
497
- self.metrics
498
- .register_extra_provider(Box::new(stats_provider));
478
+ self.metrics.register_extra_provider(Box::new(stats_provider));
499
479
500
480
if let Some(ref mut r) = self.router {
501
481
r.insert(
525
505
r.insert(
526
506
hyper::Method::GET,
527
507
"/v1/models",
528
- Handler::Custom(crate::openai::models_handler(
529
- scheduler_engine.engine().clone(),
530
- )),
508
+ Handler::Custom(crate::openai::models_handler(scheduler_engine.engine().clone())),
531
509
)
532
510
.expect("valid route: /v1/models");
533
511
}
597
575
598
576
/// Attach a Gateway configuration with hot reloading
599
577
pub fn add_gateway(mut self, state: std::sync::Arc<crate::gateway::GatewayState>) -> Self {
600
- self.chain
601
- .add(crate::gateway::GatewayMiddleware::new(state));
578
+ self.chain.add(crate::gateway::GatewayMiddleware::new(state));
602
579
self
603
580
}
604
581
659
636
r.insert(hyper::Method::POST, &path, Handler::Custom(handler))
660
637
.expect("valid oauth2 token path");
661
638
}
662
- let jwt = oauth2
663
- .jwt_auth()
664
- .require_for(&path, crate::middleware::JwtRequirement::None);
639
+ let jwt = oauth2.jwt_auth().require_for(&path, crate::middleware::JwtRequirement::None);
665
640
self.chain.add(jwt);
666
641
self
667
642
}
679
654
}
680
655
681
656
pub fn add_circuit_breaker(mut self, config: crate::resilience::CircuitBreakerConfig) -> Self {
682
- self.chain
683
- .add(crate::resilience::PerRouteCircuitBreakerMiddleware::new(
684
- config,
685
- ));
657
+ self.chain.add(crate::resilience::PerRouteCircuitBreakerMiddleware::new(config));
686
658
self
687
659
}
688
660
833
805
)
834
806
.await;
835
807
#[cfg(not(feature = "ws"))]
836
- let res = serve_http(
837
- listener,
838
- chain,
839
- static_dir,
840
- metrics,
841
- self.shutdown,
842
- wasm_middleware,
843
- )
844
- .await;
808
+ let res =
809
+ serve_http(listener, chain, static_dir, metrics, self.shutdown, wasm_middleware).await;
845
810
plugin_registry.on_shutdown_all().await?;
846
811
res
847
812
}
857
822
status: StatusCode::NOT_FOUND,
858
823
body: r#"{"error":"not found"}"#,
859
824
});
860
- router.insert(hyper::Method::GET, "/hello", Handler::Static { status: StatusCode::OK, body: r#"{"message":"hello"}"# }).unwrap();
825
+ router
826
+ .insert(
827
+ hyper::Method::GET,
828
+ "/hello",
829
+ Handler::Static { status: StatusCode::OK, body: r#"{"message":"hello"}"# },
830
+ )
831
+ .unwrap();
861
832
router.insert(hyper::Method::POST, "/echo", Handler::Echo).unwrap();
862
833
router.insert(hyper::Method::GET, "/health", Handler::Health).unwrap();
863
834
router.insert(hyper::Method::GET, "/ready", Handler::Ready).unwrap();
873
844
let chain = Arc::new(MiddlewareChain::new(handler));
874
845
875
846
#[cfg(feature = "ws")]
876
- let res = serve_http(
877
- listener,
878
- chain,
879
- None,
880
- metrics,
881
- None,
882
- None,
883
- Some(default_ws_echo()),
884
- )
885
- .await;
847
+ let res = serve_http(listener, chain, None, metrics, None, None, Some(default_ws_echo())).await;
886
848
#[cfg(not(feature = "ws"))]
887
849
let res = serve_http(listener, chain, None, metrics, None, None).await;
888
850
res
931
893
)),
932
894
Err(crate::router::RouterError::NotFound) => {
933
895
let fb = router.fallback().unwrap();
934
- let m = Match {
935
- handler: fb,
936
- params: matchit::Params::new(),
937
- };
896
+ let m = Match { handler: fb, params: matchit::Params::new() };
938
897
execute_handler(
939
898
m,
940
899
req,
955
914
// HTTP server loop
956
915
// ---------------------------------------------------------------------------
957
916
917
+/// Max time to wait for a client to send complete request headers (slowloris
918
+/// protection). Applies to HTTP/1 and HTTP/2.
919
+const HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
920
+
921
+/// Max wall-clock time a single request handler may run before we respond 504
922
+/// and abort it. Guards against stuck/slow handlers and resource exhaustion.
923
+/// Configurable via `JUSTAPI_REQUEST_TIMEOUT_SECS`.
924
+fn request_timeout() -> std::time::Duration {
925
+ std::env::var("JUSTAPI_REQUEST_TIMEOUT_SECS")
926
+ .ok()
927
+ .and_then(|v| v.parse::<u64>().ok())
928
+ .map(std::time::Duration::from_secs)
929
+ .unwrap_or_else(|| std::time::Duration::from_secs(60))
930
+}
931
+
932
+/// Hard cap on concurrently accepted connections. Beyond this, new connections
933
+/// block at accept time instead of letting the process exhaust memory/file
934
+/// descriptors (connection-flood protection). Configurable via
935
+/// `JUSTAPI_MAX_CONNECTIONS`.
936
+fn max_connections() -> usize {
937
+ std::env::var("JUSTAPI_MAX_CONNECTIONS")
938
+ .ok()
939
+ .and_then(|v| v.parse::<usize>().ok())
940
+ .filter(|n| *n > 0)
941
+ .unwrap_or(10_000)
942
+}
943
+
958
944
async fn serve_http(
959
945
listener: TcpListener,
960
946
chain: Arc<MiddlewareChain>,
965
951
#[cfg(feature = "ws")] ws_handler: Option<WsHandler>,
966
952
) -> Result<()> {
967
953
let mut connections = tokio::task::JoinSet::new();
954
+ let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_connections()));
968
955
969
956
loop {
970
957
let token = shutdown.as_ref().cloned();
993
980
994
981
#[cfg(feature = "ws")]
995
982
let ws_handler = ws_handler.clone();
996
-
983
+
997
984
let token_clone = shutdown.clone();
985
+ let conn_semaphore = conn_semaphore.clone();
998
986
connections.spawn(async move {
987
+ // Bound concurrent connections: a permit is held for the life of
988
+ // the connection so a flood of accepts can't exhaust FDs/memory
989
+ // (connection-flood / slowloris resource exhaustion).
990
+ let _permit = conn_semaphore
991
+ .acquire_owned()
992
+ .await
993
+ .expect("connection semaphore closed");
999
994
let io = TokioIo::new(stream);
1000
995
let arena = Arc::new(SharedArena::new());
1001
996
let svc = service_fn(move |mut req| {
1130
1125
}
1131
1126
1132
1127
// Run middleware chain (async, no entered span across await)
1133
- let resp = chain.run(req).await;
1128
+ let resp = tokio::time::timeout(request_timeout(), chain.run(req)).await;
1134
1129
1135
1130
match resp {
1136
- Ok(r) => {
1137
- let status = r.status();
1138
- span.record("http.status_code", status.as_u16());
1139
- metrics.record_status(status);
1140
- metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
1131
+ Ok(r) => match r {
1132
+ Ok(response) => {
1133
+ let status = response.status();
1134
+ span.record("http.status_code", status.as_u16());
1135
+ metrics.record_status(status);
1136
+ metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
1137
+
1138
+ // If the response is 404 and we have a static dir, try serving files
1139
+ if status == StatusCode::NOT_FOUND {
1140
+ if let Some(ref sd) = static_dir {
1141
+ if let Some(file_path) = sd.resolve(&path) {
1142
+ if tokio::fs::metadata(&file_path)
1143
+ .await
1144
+ .map(|m| m.is_file())
1145
+ .unwrap_or(false)
1146
+ {
1147
+ return sd.serve_file(&file_path).await;
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ Ok(response)
1153
+ }
1154
+ Err(_) => {
1155
+ span.record("http.status_code", 404u16);
1156
+ metrics.record_status(StatusCode::NOT_FOUND);
1157
+ metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
1141
1158
1142
- // If the response is 404 and we have a static dir, try serving files
1143
- if status == StatusCode::NOT_FOUND {
1159
+ // Middleware error — try static files
1144
1160
if let Some(ref sd) = static_dir {
1145
1161
if let Some(file_path) = sd.resolve(&path) {
1146
1162
if tokio::fs::metadata(&file_path)
1152
1168
}
1153
1169
}
1154
1170
}
1171
+ Ok(json_response(
1172
+ StatusCode::NOT_FOUND,
1173
+ r#"{"error":"not found"}"#
1174
+ ))
1155
1175
}
1156
- Ok(r)
1157
- }
1176
+ },
1158
1177
Err(_) => {
1159
- span.record("http.status_code", 404u16);
1160
- metrics.record_status(StatusCode::NOT_FOUND);
1178
+ // Handler exceeded the request timeout.
1179
+ span.record("http.status_code", 504u16);
1180
+ metrics.record_status(StatusCode::GATEWAY_TIMEOUT);
1161
1181
metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
1162
-
1163
- // Middleware error — try static files
1164
- if let Some(ref sd) = static_dir {
1165
- if let Some(file_path) = sd.resolve(&path) {
1166
- if tokio::fs::metadata(&file_path)
1167
- .await
1168
- .map(|m| m.is_file())
1169
- .unwrap_or(false)
1170
- {
1171
- return sd.serve_file(&file_path).await;
1172
- }
1173
- }
1174
- }
1182
+ tracing::warn!(
1183
+ "Request to {} {} timed out after {:?}",
1184
+ method,
1185
+ path,
1186
+ request_timeout()
1187
+ );
1175
1188
Ok(json_response(
1176
- StatusCode::NOT_FOUND,
1177
- r#"{"error":"not found"}"#,
1189
+ StatusCode::GATEWAY_TIMEOUT,
1190
+ r#"{"error":"request timeout"}"#
1178
1191
))
1179
1192
}
1180
1193
}
1181
1194
}
1182
1195
});
1183
- let builder = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
1196
+ let mut builder = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
1197
+ builder.http1().timer(hyper_util::rt::TokioTimer::new()).header_read_timeout(HEADER_READ_TIMEOUT);
1184
1198
let mut conn = std::pin::pin!(builder.serve_connection_with_upgrades(io, svc));
1185
1199
1186
1200
if let Some(token) = token_clone {
1207
1221
}
1208
1222
1209
1223
if shutdown.is_some() {
1210
- tracing::info!(
1211
- "Waiting for {} active connections to drain...",
1212
- connections.len()
1213
- );
1224
+ tracing::info!("Waiting for {} active connections to drain...", connections.len());
1214
1225
tokio::select! {
1215
1226
_ = async { while connections.join_next().await.is_some() {} } => {
1216
1227
tracing::debug!("all connections closed gracefully");
1269
1280
bytes
1270
1281
}
1271
1282
Err(_) => {
1272
- return Ok(json_response(
1273
- StatusCode::BAD_REQUEST,
1274
- r#"{"error":"bad request"}"#,
1275
- ))
1283
+ return Ok(json_response(StatusCode::BAD_REQUEST, r#"{"error":"bad request"}"#))
1276
1284
}
1277
1285
};
1278
1286
let mut buf = pool.acquire(body_bytes.len());
1282
1290
Ok(json_response(StatusCode::OK, &body_str))
1283
1291
}
1284
1292
Handler::ParamsEcho => {
1285
- let params_str: Vec<String> = m
1286
- .params
1287
- .iter()
1288
- .map(|(k, v)| format!(r#""{}":"{}""#, k, v))
1289
- .collect();
1293
+ let params_str: Vec<String> =
1294
+ m.params.iter().map(|(k, v)| format!(r#""{}":"{}""#, k, v)).collect();
1290
1295
let body = format!("{{{}}}", params_str.join(","));
1291
1296
Ok(json_response(StatusCode::OK, &body))
1292
1297
}
1306
1311
Handler::Live => Ok(metrics::live_response()),
1307
1312
Handler::Prometheus => Ok(metrics::metrics_response(metrics)),
1308
1313
Handler::OpenApiJson => {
1309
- let body: String = if let Some(spec) = openapi_spec {
1310
- spec.to_string()
1311
- } else {
1312
- BUILTIN_SPEC.clone()
1313
- };
1314
+ let body: String =
1315
+ if let Some(spec) = openapi_spec { spec.to_string() } else { BUILTIN_SPEC.clone() };
1314
1316
Ok(json_response(StatusCode::OK, &body))
1315
1317
}
1316
1318
Handler::SwaggerUi => {
1339
1341
}
1340
1342
Handler::GraphQL => {
1341
1343
if let Some(schema) = graphql_schema {
1342
- crate::graphql::handle_graphql(schema, req)
1343
- .await
1344
- .or_else(|e| {
1345
- Ok(Response::builder()
1346
- .status(StatusCode::INTERNAL_SERVER_ERROR)
1347
- .body(crate::UnsyncBoxBody::new(
1348
- http_body_util::Full::new(Bytes::from(format!(
1349
- "GraphQL Error: {}",
1350
- e
1351
- )))
1352
- .map_err(
1353
- |e: std::convert::Infallible| -> anyhow::Error { match e {} },
1354
- ),
1355
- ))
1356
- .unwrap())
1357
- })
1344
+ crate::graphql::handle_graphql(schema, req).await.or_else(|e| {
1345
+ Ok(Response::builder()
1346
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
1347
+ .body(crate::UnsyncBoxBody::new(
1348
+ http_body_util::Full::new(Bytes::from(format!("GraphQL Error: {}", e)))
1349
+ .map_err(|e: std::convert::Infallible| -> anyhow::Error {
1350
+ match e {}
1351
+ }),
1352
+ ))
1353
+ .unwrap())
1354
+ })
1358
1355
} else {
1359
1356
Ok(Response::builder()
1360
1357
.status(StatusCode::INTERNAL_SERVER_ERROR)
1436
1433
.ok_or_else(|| anyhow::anyhow!("no private key in {}", config.key_path))?
1437
1434
};
1438
1435
1439
- let mut server_config = rustls::ServerConfig::builder()
1440
- .with_no_client_auth()
1441
- .with_single_cert(certs, key)?;
1436
+ let mut server_config =
1437
+ rustls::ServerConfig::builder().with_no_client_auth().with_single_cert(certs, key)?;
1442
1438
1443
1439
server_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
1444
1440
1445
1441
let acceptor = tokio_rustls::TlsAcceptor::from(StdArc::new(server_config));
1446
1442
1447
1443
let local_addr = listener.local_addr()?;
1448
- tracing::info!(
1449
- "Listening on {} (TLS, HTTP/1.1 + HTTP/2 via ALPN)",
1450
- local_addr
1451
- );
1444
+ tracing::info!("Listening on {} (TLS, HTTP/1.1 + HTTP/2 via ALPN)", local_addr);
1452
1445
1453
1446
let mut connections = tokio::task::JoinSet::new();
1447
+ let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_connections()));
1454
1448
1455
1449
loop {
1456
1450
let token = shutdown.as_ref().cloned();
1478
1472
#[cfg(feature = "ws")]
1479
1473
let ws_handler = ws_handler.clone();
1480
1474
let token_clone = shutdown.clone();
1475
+ let conn_semaphore = conn_semaphore.clone();
1481
1476
connections.spawn(async move {
1477
+ // Bound concurrent connections (see serve_http for rationale).
1478
+ let _permit = conn_semaphore
1479
+ .acquire_owned()
1480
+ .await
1481
+ .expect("connection semaphore closed");
1482
1482
match acceptor.accept(stream).await {
1483
1483
Ok(tls_stream) => {
1484
1484
let io = TokioIo::new(tls_stream);
1602
1602
}
1603
1603
}
1604
1604
1605
- let resp = chain.run(req).await;
1605
+ let resp = tokio::time::timeout(request_timeout(), chain.run(req)).await;
1606
1606
1607
1607
match resp {
1608
- Ok(r) => {
1609
- let status = r.status();
1608
+ Ok(r) => match r {
1609
+ Ok(response) => {
1610
+ let status = response.status();
1610
1611
metrics.record_status(status);
1611
1612
metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
1612
1613
1623
1624
}
1624
1625
}
1625
1626
}
1626
- Ok(r)
1627
+ Ok(response)
1627
1628
}
1628
1629
Err(_) => {
1629
1630
metrics.record_status(StatusCode::NOT_FOUND);
1644
1645
StatusCode::NOT_FOUND,
1645
1646
r#"{"error":"not found"}"#,
1646
1647
))
1647
- }
1648
- }
1648
+ },
1649
+ },
1650
+ Err(_) => {
1651
+ // Handler exceeded the request timeout.
1652
+ metrics.record_status(StatusCode::GATEWAY_TIMEOUT);
1653
+ metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
1654
+ tracing::warn!(
1655
+ "Request to {} {} timed out after {:?}",
1656
+ method,
1657
+ path,
1658
+ request_timeout()
1659
+ );
1660
+ Ok(json_response(
1661
+ StatusCode::GATEWAY_TIMEOUT,
1662
+ r#"{"error":"request timeout"}"#
1663
+ ))
1649
1664
}
1650
- });
1665
+ }
1666
+ }
1667
+ });
1651
1668
1652
- let builder = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
1669
+ let mut builder = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
1670
+ builder.http1().timer(hyper_util::rt::TokioTimer::new()).header_read_timeout(HEADER_READ_TIMEOUT);
1653
1671
let mut conn = std::pin::pin!(builder.serve_connection_with_upgrades(io, svc));
1654
1672
1655
1673
if let Some(token) = token_clone {
1681
1699
}
1682
1700
1683
1701
if let Some(_) = shutdown {
1684
- tracing::info!(
1685
- "Waiting for {} active TLS connections to drain...",
1686
- connections.len()
1687
- );
1702
+ tracing::info!("Waiting for {} active TLS connections to drain...", connections.len());
1688
1703
tokio::select! {
1689
1704
_ = async { while connections.join_next().await.is_some() {} } => {
1690
1705
tracing::debug!("all connections closed after fallback drain");
1716
1731
.insert(
1717
1732
Method::GET,
1718
1733
"/hello",
1719
- Handler::Static {
1720
- status: StatusCode::OK,
1721
- body: r#"{"message":"hello"}"#,
1722
- },
1734
+ Handler::Static { status: StatusCode::OK, body: r#"{"message":"hello"}"# },
1723
1735
)
1724
1736
.unwrap();
1725
1737
let m = router.at(&Method::GET, "/hello").unwrap();
1744
1756
.insert(
1745
1757
Method::GET,
1746
1758
"/users/{id}",
1747
- Handler::Static {
1748
- status: StatusCode::OK,
1749
- body: r#"{"message":"user lookup"}"#,
1750
- },
1759
+ Handler::Static { status: StatusCode::OK, body: r#"{"message":"user lookup"}"# },
1751
1760
)
1752
1761
.unwrap();
1753
1762
let m = router.at(&Method::GET, "/users/42").unwrap();
1774
1783
#[tokio::test]
1775
1784
async fn test_health_route_exists() {
1776
1785
let mut router: Router<Handler> = Router::new();
1777
- router
1778
- .insert(Method::GET, "/health", Handler::Health)
1779
- .unwrap();
1786
+ router.insert(Method::GET, "/health", Handler::Health).unwrap();
1780
1787
let m = router.at(&Method::GET, "/health").unwrap();
1781
1788
assert!(matches!(m.handler, Handler::Health));
1782
1789
}
1784
1791
#[tokio::test]
1785
1792
async fn test_prometheus_route_exists() {
1786
1793
let mut router: Router<Handler> = Router::new();
1787
- router
1788
- .insert(Method::GET, "/metrics", Handler::Prometheus)
1789
- .unwrap();
1794
+ router.insert(Method::GET, "/metrics", Handler::Prometheus).unwrap();
1790
1795
let m = router.at(&Method::GET, "/metrics").unwrap();
1791
1796
assert!(matches!(m.handler, Handler::Prometheus));
1792
1797
}
1793
1798
▼crates/justapi-core/src/static_files.rs
+53-58
11
11
12
12
use crate::ResponseBody;
13
13
14
+/// A static-file mount: serves files from `dir` under `prefix`, with an
15
+/// optional SPA fallback (e.g. `index.html`) for unmatched paths.
16
+#[derive(Clone)]
17
+pub struct StaticMount {
18
+ pub prefix: String,
19
+ pub dir: StaticDir,
20
+ /// When set, unmatched paths fall back to this file (relative to `dir`).
21
+ pub fallback: Option<String>,
22
+}
23
+
24
+impl StaticMount {
25
+ /// Resolve a request path against this mount. Returns `None` if the path
26
+ /// is not under `prefix`.
27
+ pub fn resolve(&self, uri_path: &str) -> Option<PathBuf> {
28
+ let rel = if self.prefix == "/" {
29
+ uri_path.to_string()
30
+ } else {
31
+ if !uri_path.starts_with(&self.prefix) {
32
+ return None;
33
+ }
34
+ uri_path[self.prefix.len()..].to_string()
35
+ };
36
+ self.dir.resolve(&rel)
37
+ }
38
+}
39
+
14
40
/// Serve static files from a root directory.
15
41
#[derive(Clone)]
16
42
pub struct StaticDir {
19
45
20
46
impl StaticDir {
21
47
pub fn new(root: impl Into<PathBuf>) -> Self {
22
- Self {
23
- root: Arc::new(root.into()),
24
- }
48
+ Self { root: Arc::new(root.into()) }
49
+ }
50
+
51
+ /// Return the configured root directory as a `PathBuf`.
52
+ pub fn root(&self) -> PathBuf {
53
+ (*self.root).clone()
25
54
}
26
55
27
56
/// Resolve a URI path to a file path, preventing directory traversal.
78
107
);
79
108
let mut resp = Response::new(body);
80
109
*resp.status_mut() = StatusCode::OK;
81
- resp.headers_mut()
82
- .insert("content-type", content_type.parse().unwrap());
83
- resp.headers_mut()
84
- .insert("content-length", total_len.to_string().parse().unwrap());
110
+ resp.headers_mut().insert("content-type", content_type.parse().unwrap());
111
+ resp.headers_mut().insert("content-length", total_len.to_string().parse().unwrap());
85
112
resp.headers_mut().insert("etag", etag.parse().unwrap());
86
- resp.headers_mut()
87
- .insert("cache-control", "public, max-age=3600".parse().unwrap());
113
+ resp.headers_mut().insert("cache-control", "public, max-age=3600".parse().unwrap());
88
114
return Ok(resp);
89
115
}
90
116
96
122
97
123
let mut resp = Response::new(body);
98
124
*resp.status_mut() = StatusCode::OK;
99
- resp.headers_mut()
100
- .insert("content-type", content_type.parse().unwrap());
101
- resp.headers_mut()
102
- .insert("content-length", total_len.to_string().parse().unwrap());
125
+ resp.headers_mut().insert("content-type", content_type.parse().unwrap());
126
+ resp.headers_mut().insert("content-length", total_len.to_string().parse().unwrap());
103
127
resp.headers_mut().insert("etag", etag.parse().unwrap());
104
- resp.headers_mut()
105
- .insert("cache-control", "public, max-age=3600".parse().unwrap());
128
+ resp.headers_mut().insert("cache-control", "public, max-age=3600".parse().unwrap());
106
129
107
130
Ok(resp)
108
131
}
152
175
if let Some(range_header) = req.headers().get("range") {
153
176
if let Ok(range_str) = range_header.to_str() {
154
177
if let Some(range) = parse_range(range_str, total_len) {
155
- return self
156
- .serve_range(&path, &etag, content_type, total_len, range)
157
- .await;
178
+ return self.serve_range(&path, &etag, content_type, total_len, range).await;
158
179
}
159
180
}
160
181
}
169
190
);
170
191
let mut resp = Response::new(body);
171
192
*resp.status_mut() = StatusCode::OK;
172
- resp.headers_mut()
173
- .insert("content-type", content_type.parse().unwrap());
174
- resp.headers_mut()
175
- .insert("content-length", total_len.to_string().parse().unwrap());
193
+ resp.headers_mut().insert("content-type", content_type.parse().unwrap());
194
+ resp.headers_mut().insert("content-length", total_len.to_string().parse().unwrap());
176
195
resp.headers_mut().insert("etag", etag.parse().unwrap());
177
- resp.headers_mut()
178
- .insert("cache-control", "public, max-age=3600".parse().unwrap());
179
- resp.headers_mut()
180
- .insert("accept-ranges", "bytes".parse().unwrap());
196
+ resp.headers_mut().insert("cache-control", "public, max-age=3600".parse().unwrap());
197
+ resp.headers_mut().insert("accept-ranges", "bytes".parse().unwrap());
181
198
return Ok(resp);
182
199
}
183
200
189
206
190
207
let mut resp = Response::new(body);
191
208
*resp.status_mut() = StatusCode::OK;
192
- resp.headers_mut()
193
- .insert("content-type", content_type.parse().unwrap());
194
- resp.headers_mut()
195
- .insert("content-length", total_len.to_string().parse().unwrap());
209
+ resp.headers_mut().insert("content-type", content_type.parse().unwrap());
210
+ resp.headers_mut().insert("content-length", total_len.to_string().parse().unwrap());
196
211
resp.headers_mut().insert("etag", etag.parse().unwrap());
197
- resp.headers_mut()
198
- .insert("cache-control", "public, max-age=3600".parse().unwrap());
199
- resp.headers_mut()
200
- .insert("accept-ranges", "bytes".parse().unwrap());
212
+ resp.headers_mut().insert("cache-control", "public, max-age=3600".parse().unwrap());
213
+ resp.headers_mut().insert("accept-ranges", "bytes".parse().unwrap());
201
214
202
215
Ok(resp)
203
216
}
310
323
}
311
324
312
325
fn not_found() -> Result<Response<ResponseBody>> {
313
- Ok(crate::json_response(
314
- StatusCode::NOT_FOUND,
315
- r#"{"error":"not found"}"#,
316
- ))
326
+ Ok(crate::json_response(StatusCode::NOT_FOUND, r#"{"error":"not found"}"#))
317
327
}
318
328
319
329
fn guess_content_type(path: &Path) -> &'static str {
346
356
#[test]
347
357
fn test_resolve_basic() {
348
358
let dir = StaticDir::new("/tmp/static");
349
- assert_eq!(
350
- dir.resolve("/index.html"),
351
- Some(PathBuf::from("/tmp/static/index.html"))
352
- );
353
- assert_eq!(
354
- dir.resolve("css/style.css"),
355
- Some(PathBuf::from("/tmp/static/css/style.css"))
356
- );
359
+ assert_eq!(dir.resolve("/index.html"), Some(PathBuf::from("/tmp/static/index.html")));
360
+ assert_eq!(dir.resolve("css/style.css"), Some(PathBuf::from("/tmp/static/css/style.css")));
357
361
}
358
362
359
363
#[test]
372
376
373
377
#[test]
374
378
fn test_guess_content_type() {
375
- assert_eq!(
376
- guess_content_type(Path::new("index.html")),
377
- "text/html; charset=utf-8"
378
- );
379
- assert_eq!(
380
- guess_content_type(Path::new("style.css")),
381
- "text/css; charset=utf-8"
382
- );
379
+ assert_eq!(guess_content_type(Path::new("index.html")), "text/html; charset=utf-8");
380
+ assert_eq!(guess_content_type(Path::new("style.css")), "text/css; charset=utf-8");
383
381
assert_eq!(
384
382
guess_content_type(Path::new("app.js")),
385
383
"application/javascript; charset=utf-8"
386
384
);
387
385
assert_eq!(guess_content_type(Path::new("image.png")), "image/png");
388
386
assert_eq!(guess_content_type(Path::new("font.woff2")), "font/woff2");
389
- assert_eq!(
390
- guess_content_type(Path::new("unknown.xyz")),
391
- "application/octet-stream"
392
- );
387
+ assert_eq!(guess_content_type(Path::new("unknown.xyz")), "application/octet-stream");
393
388
}
394
389
395
390
// --- parse_range tests ---
396
391
▼crates/justapi-core/src/testing.rs
+65-26
48
48
self.request(Method::POST, path, body, &[]).await
49
49
}
50
50
51
+ /// POST with extra request headers (e.g. a `Content-Type` carrying the
52
+ /// `multipart/form-data` boundary for file-upload tests).
53
+ pub async fn post_with(
54
+ &self,
55
+ path: &str,
56
+ body: Vec<u8>,
57
+ headers: &[(&str, &str)],
58
+ ) -> Result<TestResponse, anyhow::Error> {
59
+ self.request(Method::POST, path, body, headers).await
60
+ }
61
+
51
62
pub async fn put(&self, path: &str, body: Vec<u8>) -> Result<TestResponse, anyhow::Error> {
52
63
self.request(Method::PUT, path, body, &[]).await
53
64
}
73
84
async move { handler(req).await }
74
85
});
75
86
let io = TokioIo::new(server);
76
- match hyper::server::conn::http1::Builder::new()
77
- .serve_connection(io, svc)
78
- .await
79
- {
87
+ match hyper::server::conn::http1::Builder::new().serve_connection(io, svc).await {
80
88
Ok(()) => {}
81
89
Err(e) => {
82
90
panic!("Test server connection error: {}", e);
87
95
// Build and send the raw HTTP request with Connection: close
88
96
let method_str = method.to_string();
89
97
let body_len = body.len();
90
- let mut request_bytes = format!(
91
- "{} {} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n",
92
- method_str, path,
93
- )
94
- .into_bytes();
98
+ let mut request_bytes =
99
+ format!("{} {} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n", method_str, path,)
100
+ .into_bytes();
95
101
for (name, value) in headers {
96
102
request_bytes.extend_from_slice(format!("{}: {}\r\n", name, value).as_bytes());
97
103
}
127
133
128
134
// Parse status line: "HTTP/1.1 200 OK"
129
135
let status_line = lines.next().unwrap_or("");
130
- let status: u16 = status_line
131
- .split_whitespace()
132
- .nth(1)
133
- .unwrap_or("500")
134
- .parse()
135
- .unwrap_or(500);
136
+ let status: u16 = status_line.split_whitespace().nth(1).unwrap_or("500").parse().unwrap_or(500);
136
137
137
138
// Parse headers
138
139
let headers: Vec<(String, String)> = lines
145
146
})
146
147
.collect();
147
148
148
- Ok(TestResponse {
149
- status,
150
- headers,
151
- body: body.to_vec(),
152
- })
149
+ let is_chunked = headers.iter().any(|(k, v)| {
150
+ k.eq_ignore_ascii_case("transfer-encoding") && v.eq_ignore_ascii_case("chunked")
151
+ });
152
+
153
+ let body = if is_chunked { decode_chunked(body)? } else { body.to_vec() };
154
+
155
+ Ok(TestResponse { status, headers, body })
156
+}
157
+
158
+/// Decode RFC 9112 chunked-transfer-encoding framing into the raw body bytes.
159
+fn decode_chunked(body: &[u8]) -> Result<Vec<u8>, anyhow::Error> {
160
+ let mut out = Vec::new();
161
+ let mut pos = 0;
162
+ while pos < body.len() {
163
+ // Find the end of the chunk-size line (terminated by \r\n or \n).
164
+ let line_end = body[pos..]
165
+ .iter()
166
+ .position(|&b| b == b'\n')
167
+ .map(|i| pos + i)
168
+ .ok_or_else(|| anyhow::anyhow!("Truncated chunked body"))?;
169
+ let size_line = &body[pos..line_end];
170
+ // Strip a trailing \r if present.
171
+ let size_line = if size_line.last() == Some(&b'\r') {
172
+ &size_line[..size_line.len() - 1]
173
+ } else {
174
+ size_line
175
+ };
176
+ let size_str = std::str::from_utf8(size_line)
177
+ .map_err(|e| anyhow::anyhow!("Invalid chunk size: {}", e))?;
178
+ // Allow optional chunk-ext after a ';'.
179
+ let size_hex = size_str.split(';').next().unwrap_or("").trim();
180
+ let size: usize = usize::from_str_radix(size_hex, 16)
181
+ .map_err(|e| anyhow::anyhow!("Invalid chunk size: {}", e))?;
182
+ if size == 0 {
183
+ break;
184
+ }
185
+ let data_start = line_end + 1;
186
+ let data_end = data_start + size;
187
+ if data_end > body.len() {
188
+ return Err(anyhow::anyhow!("Chunk claims more data than available"));
189
+ }
190
+ out.extend_from_slice(&body[data_start..data_end]);
191
+ // Skip the chunk-data trailing CRLF.
192
+ pos = data_end + if body[data_end..].starts_with(b"\r\n") { 2 } else { 1 };
193
+ }
194
+ Ok(out)
153
195
}
154
196
155
197
fn find_double_crlf(data: &[u8]) -> Option<usize> {
187
229
#[tokio::test]
188
230
async fn test_test_client_404() {
189
231
let handler: HandlerFn = std::sync::Arc::new(|_req| {
190
- Box::pin(async move {
191
- Ok(json_response(
192
- StatusCode::NOT_FOUND,
193
- r#"{"error":"not found"}"#,
194
- ))
195
- })
232
+ Box::pin(
233
+ async move { Ok(json_response(StatusCode::NOT_FOUND, r#"{"error":"not found"}"#)) },
234
+ )
196
235
});
197
236
let client = TestClient::new(handler);
198
237
let resp = client.get("/nonexistent").await.unwrap();
199
238
▼crates/justapi-core/src/tracing_setup.rs
+7-18
114
114
let provider = opentelemetry_otlp::new_pipeline()
115
115
.tracing()
116
116
.with_exporter(
117
- opentelemetry_otlp::new_exporter()
118
- .tonic()
119
- .with_endpoint(endpoint),
117
+ opentelemetry_otlp::new_exporter().tonic().with_endpoint(endpoint),
120
118
)
121
119
.install_batch(opentelemetry_sdk::runtime::Tokio)
122
120
.map_err(|e| anyhow::anyhow!("Failed to build OTLP tracing: {}", e))?;
132
130
if let Some(ref path) = config.file_path {
133
131
let parent = Path::new(path).parent().unwrap_or(Path::new("."));
134
132
std::fs::create_dir_all(parent)?;
135
- let file_name = Path::new(path)
136
- .file_name()
137
- .unwrap_or_else(|| std::ffi::OsStr::new("justapi.log"));
133
+ let file_name =
134
+ Path::new(path).file_name().unwrap_or_else(|| std::ffi::OsStr::new("justapi.log"));
138
135
139
136
let file_appender = match config.file_rotation {
140
137
FileRotation::Daily => tracing_appender::rolling::daily(parent, file_name),
145
142
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
146
143
set_guard(guard);
147
144
148
- let file_layer = tracing_subscriber::fmt::layer()
149
- .with_writer(non_blocking)
150
- .with_ansi(false)
151
- .boxed();
145
+ let file_layer =
146
+ tracing_subscriber::fmt::layer().with_writer(non_blocking).with_ansi(false).boxed();
152
147
153
148
layers.push(file_layer);
154
149
} else {
155
150
layers.push(fmt_layer);
156
151
}
157
152
158
- tracing_subscriber::registry()
159
- .with(env_filter)
160
- .with(layers)
161
- .init();
153
+ tracing_subscriber::registry().with(env_filter).with(layers).init();
162
154
163
155
Ok(())
164
156
}
181
173
182
174
/// Initialize JSON-formatted tracing with env-filter.
183
175
pub fn init_json_logging() -> Result<()> {
184
- init_logging(&LoggingConfig {
185
- format: LogFormat::Json,
186
- ..Default::default()
187
- })
176
+ init_logging(&LoggingConfig { format: LogFormat::Json, ..Default::default() })
188
177
}
189
178
190
179
/// Initialize JSON logging to a rolling file.
191
180
▼crates/justapi-core/src/validate.rs
+62-18
4
4
use http_body_util::Full;
5
5
use hyper::body::Bytes;
6
6
use hyper::{Response, StatusCode};
7
+use jsonschema::Validator;
7
8
use serde::de::DeserializeOwned;
8
9
9
10
use crate::ResponseBody;
20
21
21
22
impl ValidationError {
22
23
pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
23
- Self {
24
- errors: vec![FieldError {
25
- field: field.into(),
26
- message: message.into(),
27
- }],
28
- }
24
+ Self { errors: vec![FieldError { field: field.into(), message: message.into() }] }
29
25
}
30
26
31
27
pub fn multiple(errors: Vec<FieldError>) -> Self {
33
29
}
34
30
35
31
pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
36
- self.errors.push(FieldError {
37
- field: field.into(),
38
- message: message.into(),
39
- });
32
+ self.errors.push(FieldError { field: field.into(), message: message.into() });
40
33
}
41
34
42
35
pub fn is_empty(&self) -> bool {
204
197
}
205
198
}
206
199
200
+// ---------------------------------------------------------------------------
201
+// Precompiled schema validator (cacheable per route)
202
+// ---------------------------------------------------------------------------
203
+
204
+/// A JSON Schema compiled once and reused across requests.
205
+///
206
+/// `jsonschema::Validator` is `Send + Sync + Clone`, so a `CompiledValidator`
207
+/// is safe to store in an `Arc<Vec<Option<CompiledValidator>>>` shared across
208
+/// all handler threads — this avoids re-parsing *and* re-compiling the schema
209
+/// on every request (the dominant per-request cost of [`validate_json_schema`]).
210
+#[derive(Clone)]
211
+pub struct CompiledValidator(pub Validator);
212
+
213
+impl CompiledValidator {
214
+ /// Validate a JSON body against the precompiled schema.
215
+ pub fn validate(&self, body: &[u8]) -> Result<(), ValidationError> {
216
+ let body_value: serde_json::Value = serde_json::from_slice(body)
217
+ .map_err(|e| ValidationError::new("body", format!("Invalid JSON: {}", e)))?;
218
+ let mut verr = ValidationError { errors: Vec::new() };
219
+ for error in self.0.iter_errors(&body_value) {
220
+ let path = error.instance_path().to_string();
221
+ let field = if path.is_empty() || path == "/" || path == "#" {
222
+ "body".to_string()
223
+ } else {
224
+ path.trim_start_matches('/').to_string()
225
+ };
226
+ verr.add(field, error.to_string());
227
+ }
228
+ if verr.is_empty() {
229
+ Ok(())
230
+ } else {
231
+ Err(verr)
232
+ }
233
+ }
234
+}
235
+
236
+/// Compile a JSON Schema string once into a reusable [`CompiledValidator`].
237
+pub fn compile_schema(schema_json: &str) -> Result<CompiledValidator, ValidationError> {
238
+ let schema_value: serde_json::Value = serde_json::from_str(schema_json)
239
+ .map_err(|e| ValidationError::new("schema", format!("Invalid schema JSON: {}", e)))?;
240
+ let validator = jsonschema::options()
241
+ .build(&schema_value)
242
+ .map_err(|e| ValidationError::new("schema", format!("Schema compilation error: {}", e)))?;
243
+ Ok(CompiledValidator(validator))
244
+}
245
+
207
246
// ---------------------------------------------------------------------------
208
247
// Tests
209
248
// ---------------------------------------------------------------------------
266
305
#[test]
267
306
fn test_validation_error_display() {
268
307
let err = ValidationError::multiple(vec![
269
- FieldError {
270
- field: "name".into(),
271
- message: "required".into(),
272
- },
273
- FieldError {
274
- field: "age".into(),
275
- message: "must be positive".into(),
276
- },
308
+ FieldError { field: "name".into(), message: "required".into() },
309
+ FieldError { field: "age".into(), message: "must be positive".into() },
277
310
]);
278
311
let msg = err.to_string();
279
312
assert!(msg.contains("name: required"));
363
396
assert!(result.is_err());
364
397
assert!(result.unwrap_err().to_string().contains("Invalid JSON"));
365
398
}
399
+
400
+ #[test]
401
+ fn test_compiled_validator_reuse() {
402
+ let schema = r#"{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}"#;
403
+ let compiled = compile_schema(schema).unwrap();
404
+ // Reused across many requests without recompiling.
405
+ for _ in 0..1000 {
406
+ assert!(compiled.validate(br#"{"name": "Alice"}"#).is_ok());
407
+ assert!(compiled.validate(br#"{"wrong": 1}"#).is_err());
408
+ }
409
+ }
366
410
}
367
411
▼crates/justapi-core/src/wasm.rs
+4-10
26
26
27
27
let mut linker = Linker::new(&self.engine);
28
28
// Link host functions if needed (e.g., logging)
29
- linker.func_wrap(
30
- "env",
31
- "log",
32
- |_caller: Caller<'_, ()>, _ptr: i32, _len: i32| {
33
- // Placeholder for host logging from WASM
34
- },
35
- )?;
29
+ linker.func_wrap("env", "log", |_caller: Caller<'_, ()>, _ptr: i32, _len: i32| {
30
+ // Placeholder for host logging from WASM
31
+ })?;
36
32
37
33
let instance = linker.instantiate_async(&mut store, &self.module).await?;
38
34
41
37
// - `deallocate(ptr: i32, size: i32)`
42
38
// - `process_request(ptr: i32, len: i32) -> i64` (returns (ptr << 32) | len)
43
39
44
- let memory = instance
45
- .get_memory(&mut store, "memory")
46
- .context("Failed to find memory")?;
40
+ let memory = instance.get_memory(&mut store, "memory").context("Failed to find memory")?;
47
41
48
42
let allocate = instance
49
43
.get_typed_func::<i32, i32>(&mut store, "allocate")
50
44
▼crates/justapi-core/tests/integration.rs
+7-15
26
26
extra_headers: &HashMap<&str, &str>,
27
27
) -> (StatusCode, Vec<u8>, hyper::HeaderMap) {
28
28
let stream = TcpStream::connect(addr).await.unwrap();
29
- let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream))
30
- .await
31
- .unwrap();
29
+ let (mut sender, conn) =
30
+ hyper::client::conn::http1::handshake(TokioIo::new(stream)).await.unwrap();
32
31
tokio::spawn(conn);
33
32
34
33
let mut builder = hyper::Request::builder().method(method).uri(path);
39
38
builder = builder.header(*k, *v);
40
39
}
41
40
let req = builder
42
- .body(http_body_util::Full::new(hyper::body::Bytes::from(
43
- body.unwrap_or(b""),
44
- )))
41
+ .body(http_body_util::Full::new(hyper::body::Bytes::from(body.unwrap_or(b""))))
45
42
.unwrap();
46
43
let resp = sender.send_request(req).await.unwrap();
47
44
let status = resp.status();
52
49
53
50
/// Helper to start a server and return the address
54
51
async fn start_server() -> SocketAddr {
55
- let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
56
- .await
57
- .unwrap();
52
+ let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap();
58
53
let addr = listener.local_addr().unwrap();
59
54
tokio::spawn(async move {
60
55
serve(listener).await.unwrap();
182
177
use futures::{SinkExt, StreamExt};
183
178
use tokio_tungstenite::tungstenite::Message;
184
179
185
- let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
186
- .await
187
- .unwrap();
180
+ let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap();
188
181
let addr = listener.local_addr().unwrap();
189
182
190
183
tokio::spawn(async move {
194
187
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
195
188
196
189
let url = format!("ws://{}/ws", addr);
197
- let (mut ws, _) = tokio_tungstenite::connect_async(&url)
198
- .await
199
- .expect("WebSocket connection should succeed");
190
+ let (mut ws, _) =
191
+ tokio_tungstenite::connect_async(&url).await.expect("WebSocket connection should succeed");
200
192
201
193
ws.send(Message::Text("hello".into())).await.unwrap();
202
194
let msg = ws.next().await.unwrap().unwrap();
203
195
▼crates/justapi-inference/src/autoscaler.rs
+3-15
81
81
/// Create an autoscaler starting at `config.min_replicas`.
82
82
pub fn new(config: AutoscalerConfig) -> Self {
83
83
let current = config.min_replicas.max(1).min(config.max_replicas.max(1));
84
- Self {
85
- config,
86
- current,
87
- cooldown: 0,
88
- }
84
+ Self { config, current, cooldown: 0 }
89
85
}
90
86
91
87
/// Current desired replica count.
162
158
}
163
159
164
160
fn metrics(kv: f32, ttft: f32, q: usize) -> LlmMetrics {
165
- LlmMetrics {
166
- tokens_per_sec: 50.0,
167
- ttft_ms: ttft,
168
- queue_depth: q,
169
- kv_pressure_pct: kv,
170
- }
161
+ LlmMetrics { tokens_per_sec: 50.0, ttft_ms: ttft, queue_depth: q, kv_pressure_pct: kv }
171
162
}
172
163
173
164
#[test]
239
230
#[test]
240
231
fn cooldown_prevents_flapping() {
241
232
let mut a = Autoscaler::new(cfg());
242
- assert_eq!(
243
- a.decide(&metrics(95.0, 100.0, 0)),
244
- ScaleDecision::ScaleUp(2)
245
- );
233
+ assert_eq!(a.decide(&metrics(95.0, 100.0, 0)), ScaleDecision::ScaleUp(2));
246
234
// Immediately after scaling up we are in cooldown; metrics flipping to
247
235
// healthy must NOT trigger an instant scale-down.
248
236
assert_eq!(a.decide(&metrics(5.0, 50.0, 0)), ScaleDecision::Hold(2));
249
237
▼crates/justapi-inference/src/control_plane.rs
+11-36
24
24
/// Object storage (S3 / GCS / MinIO compatible).
25
25
S3 { bucket: String, key: String },
26
26
/// HuggingFace Hub repo (optional revision / branch).
27
- HuggingFace {
28
- repo: String,
29
- revision: Option<String>,
30
- },
27
+ HuggingFace { repo: String, revision: Option<String> },
31
28
/// A remote model registry served over HTTP(S).
32
29
RemoteRegistry { url: String },
33
30
}
139
136
140
137
/// Add (or replace) a version.
141
138
pub fn add_version(&mut self, version: ModelVersion) {
142
- if let Some(slot) = self
143
- .versions
144
- .iter_mut()
145
- .find(|v| v.version == version.version)
146
- {
139
+ if let Some(slot) = self.versions.iter_mut().find(|v| v.version == version.version) {
147
140
*slot = version;
148
141
} else {
149
142
self.versions.push(version);
165
158
166
159
/// Map a routing key to an adapter name for this model.
167
160
pub fn route_adapter(&mut self, routing_key: impl Into<String>, adapter: impl Into<String>) {
168
- self.adapter_routing
169
- .insert(routing_key.into(), adapter.into());
161
+ self.adapter_routing.insert(routing_key.into(), adapter.into());
170
162
}
171
163
172
164
/// Resolve a version-or-alias string to a concrete version.
204
196
impl ControlPlane {
205
197
/// Create an empty control plane.
206
198
pub fn new() -> Self {
207
- Self {
208
- inner: Arc::new(RwLock::new(HashMap::new())),
209
- }
199
+ Self { inner: Arc::new(RwLock::new(HashMap::new())) }
210
200
}
211
201
212
202
/// Register (or replace) a whole model record.
213
203
pub fn register(&self, record: ModelRecord) {
214
- self.inner
215
- .write()
216
- .unwrap()
217
- .insert(record.name.clone(), record);
204
+ self.inner.write().unwrap().insert(record.name.clone(), record);
218
205
}
219
206
220
207
/// Register a single version under an existing (or new) model.
221
208
pub fn register_version(&self, model: &str, version: ModelVersion) {
222
209
let mut inner = self.inner.write().unwrap();
223
- let record = inner
224
- .entry(model.to_string())
225
- .or_insert_with(|| ModelRecord::new(model));
210
+ let record = inner.entry(model.to_string()).or_insert_with(|| ModelRecord::new(model));
226
211
record.add_version(version);
227
212
}
228
213
279
264
.or_else(|| record.latest_version())?,
280
265
};
281
266
282
- let adapter = routing_key
283
- .and_then(|k| record.resolve_adapter(k))
284
- .map(String::from);
267
+ let adapter = routing_key.and_then(|k| record.resolve_adapter(k)).map(String::from);
285
268
286
269
Some(ResolvedModel {
287
270
model_name: record.name.clone(),
308
291
use super::*;
309
292
310
293
fn version(v: &str, at: u64) -> ModelVersion {
311
- ModelVersion::new(
312
- v,
313
- WeightLocation::Local(PathBuf::from(format!("/models/{v}"))),
314
- )
315
- .with_created_at(at)
294
+ ModelVersion::new(v, WeightLocation::Local(PathBuf::from(format!("/models/{v}"))))
295
+ .with_created_at(at)
316
296
}
317
297
318
298
#[test]
431
411
};
432
412
cp.register_version(
433
413
"m",
434
- version("v1", 100)
435
- .with_profile(profile.clone())
436
- .with_aliases(vec!["latest".into()]),
414
+ version("v1", 100).with_profile(profile.clone()).with_aliases(vec!["latest".into()]),
437
415
);
438
416
let r = cp.resolve("m", Some("latest"), None).unwrap();
439
417
assert_eq!(r.runtime_profile.device, EngineDevice::Cuda(0));
440
418
assert_eq!(r.runtime_profile.max_concurrency, 8);
441
419
assert_eq!(r.runtime_profile.quant_method, QuantMethod::Gguf);
442
- assert_eq!(
443
- r.weight_location,
444
- WeightLocation::Local(PathBuf::from("/models/v1"))
445
- );
420
+ assert_eq!(r.weight_location, WeightLocation::Local(PathBuf::from("/models/v1")));
446
421
assert_eq!(r.runtime_profile.expected_adapters, vec!["math-adapter"]);
447
422
}
448
423
449
424
▼crates/justapi-inference/src/engine.rs
+9-33
77
77
impl ModelRegistry {
78
78
/// Register a model under `name`, replacing any existing entry.
79
79
pub fn register(&mut self, name: &str, model: Arc<dyn Model>, source: ModelSource) {
80
- self.models
81
- .insert(name.to_string(), RegistryEntry { model, source });
80
+ self.models.insert(name.to_string(), RegistryEntry { model, source });
82
81
}
83
82
84
83
/// Fetch a shared handle to a registered model.
112
111
impl Engine {
113
112
/// Create an engine bound to `device`.
114
113
pub fn new(device: EngineDevice) -> Result<Self, ModelError> {
115
- let device = device
116
- .to_candle()
117
- .map_err(|e| ModelError::Generation(e.to_string()))?;
118
- Ok(Self {
119
- device,
120
- registry: Arc::new(Mutex::new(ModelRegistry::default())),
121
- })
114
+ let device = device.to_candle().map_err(|e| ModelError::Generation(e.to_string()))?;
115
+ Ok(Self { device, registry: Arc::new(Mutex::new(ModelRegistry::default())) })
122
116
}
123
117
124
118
/// The Candle device this engine runs models on.
128
122
129
123
/// Register an arbitrary model implementation.
130
124
pub fn register(&self, name: &str, model: Arc<dyn Model>) {
131
- self.registry
132
- .lock()
133
- .unwrap()
134
- .register(name, model, ModelSource::Mock);
125
+ self.registry.lock().unwrap().register(name, model, ModelSource::Mock);
135
126
}
136
127
137
128
/// Fetch a shared handle to a registered model by name.
142
133
/// Register a [`MockModel`] and return a handle to it.
143
134
pub fn register_mock(&self, name: &str) -> Arc<MockModel> {
144
135
let model = Arc::new(MockModel::new(crate::DEFAULT_MOCK_VOCAB));
145
- self.registry
146
- .lock()
147
- .unwrap()
148
- .register(name, model.clone(), ModelSource::Mock);
136
+ self.registry.lock().unwrap().register(name, model.clone(), ModelSource::Mock);
149
137
model
150
138
}
151
139
162
150
seed: u64,
163
151
) {
164
152
let spec = SpeculativeModel::new(target, SpeculativeConfig::new(gamma, draft, seed));
165
- self.registry
166
- .lock()
167
- .unwrap()
168
- .register(name, Arc::new(spec), ModelSource::Mock);
153
+ self.registry.lock().unwrap().register(name, Arc::new(spec), ModelSource::Mock);
169
154
}
170
155
171
156
/// Register a tree-based speculative-decode model (Medusa/EAGLE-style): a
184
169
seed: u64,
185
170
) {
186
171
let spec = TreeSpeculativeModel::new(target, draft, gamma, branch, seed);
187
- self.registry
188
- .lock()
189
- .unwrap()
190
- .register(name, Arc::new(spec), ModelSource::Mock);
172
+ self.registry.lock().unwrap().register(name, Arc::new(spec), ModelSource::Mock);
191
173
}
192
174
193
175
/// Load a real model from `model_dir`.
228
210
) -> Result<mpsc::UnboundedReceiver<GeneratedToken>, ModelError> {
229
211
let model = {
230
212
let reg = self.registry.lock().unwrap();
231
- reg.get(name)
232
- .ok_or_else(|| ModelError::NotFound(name.to_string()))?
213
+ reg.get(name).ok_or_else(|| ModelError::NotFound(name.to_string()))?
233
214
};
234
215
235
216
let (tx, rx) = mpsc::unbounded_channel();
247
228
248
229
/// Return the [`ModelSource`] a registered model was loaded from, if known.
249
230
pub fn model_source(&self, name: &str) -> Option<ModelSource> {
250
- self.registry
251
- .lock()
252
- .unwrap()
253
- .models
254
- .get(name)
255
- .map(|e| e.source.clone())
231
+ self.registry.lock().unwrap().models.get(name).map(|e| e.source.clone())
256
232
}
257
233
}
258
234
▼crates/justapi-inference/src/gateway.rs
+8-39
106
106
) {
107
107
// Readiness is folded into `healthy` so the router's candidate filter
108
108
// enforces it; a not-Ready pod is not a routing candidate.
109
- let healthy = if self.config.require_ready {
110
- ready
111
- } else {
112
- true
113
- };
114
- self.router
115
- .update_load(id, active_sequences, kv_pressure_pct, healthy);
109
+ let healthy = if self.config.require_ready { ready } else { true };
110
+ self.router.update_load(id, active_sequences, kv_pressure_pct, healthy);
116
111
}
117
112
118
113
/// K8s readiness-probe callback: mark a pod Ready/NotReady for traffic.
136
131
pub fn route(&self, request: &RouteRequest) -> Option<GatewayDecision> {
137
132
let decision: RouteDecision = self.router.route(&self.cp, request)?;
138
133
let endpoint = self.endpoint_for(&decision.replica, &decision.resolved);
139
- Some(GatewayDecision {
140
- replica: decision.replica,
141
- resolved: decision.resolved,
142
- endpoint,
143
- })
134
+ Some(GatewayDecision { replica: decision.replica, resolved: decision.resolved, endpoint })
144
135
}
145
136
146
137
/// Resolve a replica id + resolved model into a Kubernetes service endpoint.
201
192
let g = gateway();
202
193
g.register_replica(replica("llama-7b-v1-0", 0, 10.0, &[]));
203
194
let d = g
204
- .route(&RouteRequest {
205
- model: "llama-7b".into(),
206
- version: None,
207
- routing_key: None,
208
- })
195
+ .route(&RouteRequest { model: "llama-7b".into(), version: None, routing_key: None })
209
196
.unwrap();
210
197
assert_eq!(d.replica, "llama-7b-v1-0");
211
198
assert_eq!(d.endpoint, "llama-7b-v1-0.llm-prod.svc.cluster.local");
224
211
ModelVersion::new("v1", WeightLocation::Local(PathBuf::from("/m"))),
225
212
);
226
213
g.register_replica(replica("llama-7b-v1-0", 0, 10.0, &[]));
227
- let d = g
228
- .route(&RouteRequest {
229
- model: "llama-7b".into(),
230
- ..Default::default()
231
- })
232
- .unwrap();
214
+ let d = g.route(&RouteRequest { model: "llama-7b".into(), ..Default::default() }).unwrap();
233
215
assert!(d.endpoint.contains("team-a"));
234
216
assert!(d.endpoint.starts_with("justapi-llama-7b-v1-0"));
235
217
}
241
223
g.register_replica(replica("cool", 0, 10.0, &[]));
242
224
// Drain "hot" (not Ready) → traffic must go to "cool".
243
225
g.set_ready("hot", false);
244
- let d = g
245
- .route(&RouteRequest {
246
- model: "llama-7b".into(),
247
- ..Default::default()
248
- })
249
- .unwrap();
226
+ let d = g.route(&RouteRequest { model: "llama-7b".into(), ..Default::default() }).unwrap();
250
227
assert_eq!(d.replica, "cool");
251
228
252
229
// Drain both → no capacity.
253
230
g.set_ready("cool", false);
254
231
assert!(g
255
- .route(&RouteRequest {
256
- model: "llama-7b".into(),
257
- ..Default::default()
258
- })
232
+ .route(&RouteRequest { model: "llama-7b".into(), ..Default::default() })
259
233
.is_none());
260
234
}
261
235
265
239
let g = gateway();
266
240
g.register_replica(replica("hot", 1, 90.0, &[]));
267
241
g.register_replica(replica("cool", 1, 20.0, &[]));
268
- let d = g
269
- .route(&RouteRequest {
270
- model: "llama-7b".into(),
271
- ..Default::default()
272
- })
273
- .unwrap();
242
+ let d = g.route(&RouteRequest { model: "llama-7b".into(), ..Default::default() }).unwrap();
274
243
assert_eq!(d.replica, "cool");
275
244
}
276
245
277
246
▼crates/justapi-inference/src/kv_cache.rs
+3-18
74
74
75
75
impl BlockMeta {
76
76
const fn new() -> Self {
77
- Self {
78
- ref_count: 0,
79
- pinned: false,
80
- hash: 0,
81
- }
77
+ Self { ref_count: 0, pinned: false, hash: 0 }
82
78
}
83
79
}
84
80
105
101
let capacity = capacity.clamp(1, MAX_BLOCKS);
106
102
let metas = vec![BlockMeta::new(); capacity];
107
103
let free: Vec<BlockId> = (0..capacity as BlockId).rev().collect();
108
- Self {
109
- metas,
110
- free,
111
- clock_hand: 0,
112
- eviction_count: 0,
113
- allocated_count: 0,
114
- cached_count: 0,
115
- }
104
+ Self { metas, free, clock_hand: 0, eviction_count: 0, allocated_count: 0, cached_count: 0 }
116
105
}
117
106
118
107
/// Total number of blocks the pool manages.
327
316
impl Sequence {
328
317
/// Create a new empty sequence.
329
318
pub fn new(id: u64) -> Self {
330
- Self {
331
- id,
332
- blocks: Vec::new(),
333
- num_tokens: 0,
334
- }
319
+ Self { id, blocks: Vec::new(), num_tokens: 0 }
335
320
}
336
321
337
322
/// Opaque sequence identifier.
338
323
▼crates/justapi-inference/src/model.rs
+2-9
115
115
impl MockModel {
116
116
/// Create a mock model with the given vocabulary size (clamped to >= 1).
117
117
pub fn new(vocab_size: usize) -> Self {
118
- Self {
119
- vocab_size: vocab_size.max(1),
120
- }
118
+ Self { vocab_size: vocab_size.max(1) }
121
119
}
122
120
123
121
/// Trivial tokenization: each byte of `text` becomes a token id.
162
160
let text = decode_mock(next);
163
161
let stop = params.stop_tokens.contains(&next);
164
162
let finish_reason = if stop { Some(FinishReason::Stop) } else { None };
165
- let token = GeneratedToken {
166
- id: next,
167
- text,
168
- logprob: 0.0,
169
- finish_reason,
170
- };
163
+ let token = GeneratedToken { id: next, text, logprob: 0.0, finish_reason };
171
164
if !sink(token) {
172
165
return Ok(FinishReason::Cancelled);
173
166
}
174
167
▼crates/justapi-inference/src/openai.rs
+11-52
317
317
}
318
318
319
319
fn now_secs() -> u64 {
320
- SystemTime::now()
321
- .duration_since(UNIX_EPOCH)
322
- .map(|d| d.as_secs())
323
- .unwrap_or(0)
320
+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
324
321
}
325
322
326
323
fn short_id() -> String {
327
- let nanos = SystemTime::now()
328
- .duration_since(UNIX_EPOCH)
329
- .map(|d| d.as_nanos())
330
- .unwrap_or(0);
324
+ let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
331
325
format!("{:x}", nanos % 0xfffffffffffffff)
332
326
}
333
327
484
478
model,
485
479
choices: vec![ChatCompletionChoice {
486
480
index: 0,
487
- message: ChatMessageOut {
488
- role: "assistant".into(),
489
- content,
490
- finish_reason: None,
491
- },
481
+ message: ChatMessageOut { role: "assistant".into(), content, finish_reason: None },
492
482
finish_reason: finish.into(),
493
483
}],
494
484
usage: Usage {
597
587
object: "text_completion",
598
588
created: now_secs(),
599
589
model,
600
- choices: vec![CompletionChoice {
601
- text,
602
- index: 0,
603
- finish_reason: finish.into(),
604
- }],
590
+ choices: vec![CompletionChoice { text, index: 0, finish_reason: finish.into() }],
605
591
usage: Usage {
606
592
prompt_tokens,
607
593
completion_tokens,
623
609
total_tokens += tokens.len();
624
610
// Deterministic pseudo-embedding: normalize token ids.
625
611
let embedding: Vec<f32> = tokens.iter().map(|&t| (t as f32) / 1000.0 - 0.5).collect();
626
- EmbeddingObject {
627
- object: "embedding",
628
- index: i,
629
- embedding,
630
- }
612
+ EmbeddingObject { object: "embedding", index: i, embedding }
631
613
})
632
614
.collect();
633
615
635
617
object: "list",
636
618
data,
637
619
model: req.model,
638
- usage: Usage {
639
- prompt_tokens: total_tokens,
640
- completion_tokens: 0,
641
- total_tokens,
642
- },
620
+ usage: Usage { prompt_tokens: total_tokens, completion_tokens: 0, total_tokens },
643
621
})
644
622
}
645
623
682
660
model,
683
661
choices: vec![ChatCompletionChoice {
684
662
index: 0,
685
- message: ChatMessageOut {
686
- role: "assistant".into(),
687
- content,
688
- finish_reason: None,
689
- },
663
+ message: ChatMessageOut { role: "assistant".into(), content, finish_reason: None },
690
664
finish_reason: finish.into(),
691
665
}],
692
666
usage: Usage {
813
787
object: "text_completion",
814
788
created: now_secs(),
815
789
model,
816
- choices: vec![CompletionChoice {
817
- text,
818
- index: 0,
819
- finish_reason: finish.into(),
820
- }],
790
+ choices: vec![CompletionChoice { text, index: 0, finish_reason: finish.into() }],
821
791
usage: Usage {
822
792
prompt_tokens,
823
793
completion_tokens,
909
879
parent: None,
910
880
})
911
881
.collect();
912
- ModelList {
913
- object: "list",
914
- data,
915
- }
882
+ ModelList { object: "list", data }
916
883
}
917
884
918
885
// ---------------------------------------------------------------------------
1047
1014
#[test]
1048
1015
fn prompt_from_messages_flattened() {
1049
1016
let prompt = prompt_from_messages(&[
1050
- ChatMessage {
1051
- role: "system".into(),
1052
- content: "be nice".into(),
1053
- name: None,
1054
- },
1055
- ChatMessage {
1056
- role: "user".into(),
1057
- content: "hi".into(),
1058
- name: None,
1059
- },
1017
+ ChatMessage { role: "system".into(), content: "be nice".into(), name: None },
1018
+ ChatMessage { role: "user".into(), content: "hi".into(), name: None },
1060
1019
]);
1061
1020
assert!(prompt.contains("system: be nice"));
1062
1021
assert!(prompt.contains("user: hi"));
1063
1022
▼crates/justapi-inference/src/pd.rs
+5-15
143
143
let transferred = self.prefill.take_completed_prefill();
144
144
let count = transferred.len();
145
145
for t in &transferred {
146
- self.total_transferred_tokens = self
147
- .total_transferred_tokens
148
- .saturating_add(t.num_prefilled);
146
+ self.total_transferred_tokens =
147
+ self.total_transferred_tokens.saturating_add(t.num_prefilled);
149
148
}
150
149
for t in transferred {
151
150
self.decode.admit_transferred(t);
211
210
NewRequest {
212
211
id,
213
212
prompt: (0..8).collect(),
214
- sampling_params: SamplingParams {
215
- max_tokens: 16,
216
- ..Default::default()
217
- },
213
+ sampling_params: SamplingParams { max_tokens: 16, ..Default::default() },
218
214
prefix_cached_tokens: 0,
219
215
cached_blocks: Vec::new(),
220
216
}
291
287
pd.add_request(NewRequest {
292
288
id: 1,
293
289
prompt: (0..16).collect(),
294
- sampling_params: SamplingParams {
295
- max_tokens: 8,
296
- ..Default::default()
297
- },
290
+ sampling_params: SamplingParams { max_tokens: 8, ..Default::default() },
298
291
prefix_cached_tokens: 0,
299
292
cached_blocks: Vec::new(),
300
293
});
307
300
pd.add_request(NewRequest {
308
301
id: 2,
309
302
prompt: (0..8).collect(),
310
- sampling_params: SamplingParams {
311
- max_tokens: 8,
312
- ..Default::default()
313
- },
303
+ sampling_params: SamplingParams { max_tokens: 8, ..Default::default() },
314
304
prefix_cached_tokens: 0,
315
305
cached_blocks: Vec::new(),
316
306
});
317
307
▼crates/justapi-inference/src/radix_cache.rs
+6-24
50
50
impl RadixNode {
51
51
fn new(tokens: Vec<u32>, blocks: Vec<BlockId>, now: u64) -> Self {
52
52
let token_hash = hash_tokens(&tokens);
53
- Self {
54
- tokens,
55
- blocks,
56
- children: HashMap::new(),
57
- last_access: now,
58
- token_hash,
59
- }
53
+ Self { tokens, blocks, children: HashMap::new(), last_access: now, token_hash }
60
54
}
61
55
62
56
fn is_leaf(&self) -> bool {
125
119
/// any common prefix with existing sequences. `tokens.len()` must equal
126
120
/// `blocks.len() * BLOCK_SIZE`.
127
121
pub fn insert(&mut self, tokens: &[u32], blocks: &[BlockId]) {
128
- assert_eq!(
129
- tokens.len(),
130
- blocks.len() * BLOCK_SIZE,
131
- "block misalignment"
132
- );
122
+ assert_eq!(tokens.len(), blocks.len() * BLOCK_SIZE, "block misalignment");
133
123
let now = self.tick();
134
124
insert_node(&mut self.root, tokens, blocks, now);
135
125
}
155
145
None
156
146
} else if out.0 > 0 {
157
147
self.hits.set(self.hits.get() + 1);
158
- self.tokens_saved
159
- .set(self.tokens_saved.get() + out.0 as u64);
148
+ self.tokens_saved.set(self.tokens_saved.get() + out.0 as u64);
160
149
Some((out.0, out.1))
161
150
} else {
162
151
self.misses.set(self.misses.get() + 1);
306
295
}
307
296
} else {
308
297
// No matching child — create a leaf for the remaining path.
309
- node.children
310
- .insert(first, RadixNode::new(tokens.to_vec(), blocks.to_vec(), now));
298
+ node.children.insert(first, RadixNode::new(tokens.to_vec(), blocks.to_vec(), now));
311
299
}
312
300
}
313
301
465
453
#[test]
466
454
fn lookup_miss() {
467
455
let mut cache = RadixPrefixCache::new();
468
- cache.insert(
469
- &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
470
- &[1],
471
- );
456
+ cache.insert(&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], &[1]);
472
457
assert!(cache.lookup(&[99, 100]).is_none());
473
458
assert_eq!(cache.stats().misses, 1);
474
459
}
735
720
flat.insert(&toks, &block_ids);
736
721
}
737
722
738
- (
739
- radix.cached_tokens() / block_size,
740
- flat.total_stored_blocks(),
741
- )
723
+ (radix.cached_tokens() / block_size, flat.total_stored_blocks())
742
724
}
743
725
744
726
#[test]
745
727
▼crates/justapi-inference/src/real/lora.rs
+2-13
42
42
Self {
43
43
name: name.into(),
44
44
base_model_hash: None,
45
- config: LoraConfig {
46
- rank,
47
- alpha,
48
- dropout: 0.0,
49
- target_modules: Vec::new(),
50
- },
45
+ config: LoraConfig { rank, alpha, dropout: 0.0, target_modules: Vec::new() },
51
46
routing_key: None,
52
47
active: true,
53
48
}
145
140
146
141
/// List all adapter names (active + inactive).
147
142
pub fn all_adapters(&self) -> Vec<String> {
148
- self.inner
149
- .read()
150
- .unwrap()
151
- .adapters
152
- .keys()
153
- .cloned()
154
- .collect()
143
+ self.inner.read().unwrap().adapters.keys().cloned().collect()
155
144
}
156
145
157
146
/// Number of registered adapters.
158
147
▼crates/justapi-inference/src/real/model.rs
+29-59
28
28
29
29
/// Detect whether a model directory contains a valid `.gguf` file or safetensors.
30
30
pub fn detect_format(model_dir: &Path) -> Result<ModelFormat, ModelError> {
31
- let entries: Vec<_> = std::fs::read_dir(model_dir)
32
- .map_err(ModelError::Io)?
33
- .filter_map(|e| e.ok())
34
- .collect();
31
+ let entries: Vec<_> =
32
+ std::fs::read_dir(model_dir).map_err(ModelError::Io)?.filter_map(|e| e.ok()).collect();
35
33
36
34
let has_gguf = entries
37
35
.iter()
38
- .filter(|e| {
39
- e.path()
40
- .extension()
41
- .map(|ext| ext == "gguf")
42
- .unwrap_or(false)
43
- })
36
+ .filter(|e| e.path().extension().map(|ext| ext == "gguf").unwrap_or(false))
44
37
.any(|e| is_valid_gguf(&e.path()));
45
38
46
- let has_safetensors = entries.iter().any(|e| {
47
- e.path()
48
- .extension()
49
- .map(|ext| ext == "safetensors")
50
- .unwrap_or(false)
51
- });
39
+ let has_safetensors = entries
40
+ .iter()
41
+ .any(|e| e.path().extension().map(|ext| ext == "safetensors").unwrap_or(false));
52
42
53
43
match (has_gguf, has_safetensors) {
54
44
(true, _) => Ok(ModelFormat::Gguf),
124
114
let t = Tensor::from_slice(tokens, (1, tokens.len()), device)
125
115
.map_err(|e| ModelError::Generation(e.to_string()))?;
126
116
match self {
127
- ForwardState::Gguf(m) => m
128
- .forward(&t, index_pos)
129
- .map_err(|e| ModelError::Generation(e.to_string())),
117
+ ForwardState::Gguf(m) => {
118
+ m.forward(&t, index_pos).map_err(|e| ModelError::Generation(e.to_string()))
119
+ }
130
120
ForwardState::Safetensors { model, cache } => model
131
121
.forward(&t, index_pos, cache)
132
122
.map_err(|e| ModelError::Generation(e.to_string())),
233
223
.filter(|p| p.extension().map(|e| e == "safetensors").unwrap_or(false))
234
224
.cloned()
235
225
.collect();
226
+ // SAFETY: `from_mmaped_safetensors` memory-maps the safetensors
227
+ // files and reads tensor bytes directly from the mapped pages.
228
+ // This is sound as long as the files at `st_paths` are not
229
+ // concurrently modified or truncated for the lifetime of the
230
+ // returned `VarBuilder`; the framework treats loaded weights as
231
+ // immutable and does not write to them. The paths are validated
232
+ // above as existing `.safetensors` entries, so each mapping
233
+ // target is a regular, readable file rather than a directory or
234
+ // dangling path.
236
235
let vb =
237
236
unsafe { VarBuilder::from_mmaped_safetensors(&st_paths, DType::F32, &device) }
238
237
.map_err(|e| {
256
255
config.vocab_size,
257
256
);
258
257
259
- Ok(Self {
260
- format,
261
- config,
262
- device,
263
- tokenizer,
264
- state: Mutex::new(state),
265
- })
258
+ Ok(Self { format, config, device, tokenizer, state: Mutex::new(state) })
266
259
}
267
260
268
261
/// The detected format.
283
276
284
277
/// Load the Llama config: from `config.json` if present, else a sensible default.
285
278
fn load_config(model_dir: &Path, entries: &[PathBuf]) -> Result<LlamaConfig, ModelError> {
286
- let has_config = entries
287
- .iter()
288
- .any(|p| p.file_name().map(|n| n == "config.json").unwrap_or(false));
279
+ let has_config =
280
+ entries.iter().any(|p| p.file_name().map(|n| n == "config.json").unwrap_or(false));
289
281
if has_config {
290
282
let config_str =
291
283
std::fs::read_to_string(model_dir.join("config.json")).map_err(ModelError::Io)?;
325
317
},
326
318
)
327
319
} else {
328
- let temperature = if params.temperature < 1e-7 {
329
- None
330
- } else {
331
- Some(params.temperature as f64)
332
- };
333
- let top_p = if params.top_p >= 1.0 {
334
- None
335
- } else {
336
- Some(params.top_p as f64)
337
- };
320
+ let temperature =
321
+ if params.temperature < 1e-7 { None } else { Some(params.temperature as f64) };
322
+ let top_p = if params.top_p >= 1.0 { None } else { Some(params.top_p as f64) };
338
323
LogitsProcessor::new(seed, temperature, top_p)
339
324
}
340
325
}
349
334
state.reset_cache(&self.config, &self.device)?;
350
335
let logits = state.forward(context, 0, &self.device)?;
351
336
// `to_vec1` returns the last position's logits (shape [vocab]).
352
- let vec = logits
353
- .to_vec1::<f32>()
354
- .map_err(|e| ModelError::Generation(e.to_string()))?;
337
+ let vec = logits.to_vec1::<f32>().map_err(|e| ModelError::Generation(e.to_string()))?;
355
338
Ok(vec)
356
339
}
357
340
377
360
// + number of tokens already decoded) — it is NOT a redundant copy of the
378
361
// `step` loop counter, so the explicit counter is intentional.
379
362
let logits = state.forward(prompt, 0, &self.device)?;
380
- let mut token = lp
381
- .sample(&logits)
382
- .map_err(|e| ModelError::Generation(e.to_string()))?;
363
+ let mut token = lp.sample(&logits).map_err(|e| ModelError::Generation(e.to_string()))?;
383
364
let mut index_pos = prompt.len();
384
365
385
366
for step in 0..params.max_tokens {
388
369
.decode(&[token])
389
370
.map_err(|e| ModelError::Generation(e.to_string()))?;
390
371
let is_stop = params.stop_tokens.contains(&token);
391
- let finish = if is_stop {
392
- Some(FinishReason::Stop)
393
- } else {
394
- None
395
- };
396
- let produced = GeneratedToken {
397
- id: token,
398
- text,
399
- logprob: 0.0,
400
- finish_reason: finish,
401
- };
372
+ let finish = if is_stop { Some(FinishReason::Stop) } else { None };
373
+ let produced = GeneratedToken { id: token, text, logprob: 0.0, finish_reason: finish };
402
374
if !sink(produced) {
403
375
return Ok(FinishReason::Cancelled);
404
376
}
411
383
}
412
384
// Decode the next token, extending the KV cache.
413
385
let next_logits = state.forward(&[token], index_pos, &self.device)?;
414
- token = lp
415
- .sample(&next_logits)
416
- .map_err(|e| ModelError::Generation(e.to_string()))?;
386
+ token = lp.sample(&next_logits).map_err(|e| ModelError::Generation(e.to_string()))?;
417
387
index_pos += 1;
418
388
}
419
389
Ok(FinishReason::Length)
420
390
▼crates/justapi-inference/src/real/quant.rs
+8-36
73
73
impl QuantConfig {
74
74
/// Build a uniform quant config (same method for all layers).
75
75
pub fn uniform(method: QuantMethod, bits: f32) -> Self {
76
- Self {
77
- method,
78
- layers: HashMap::new(),
79
- bits,
80
- }
76
+ Self { method, layers: HashMap::new(), bits }
81
77
}
82
78
83
79
/// Effective method for a specific layer (falls back to global).
207
203
let mut cfg = QuantConfig::uniform(QuantMethod::Gptq, 4.0);
208
204
cfg.layers.insert(
209
205
"model.layers.0".to_string(),
210
- LayerQuantConfig {
211
- method: QuantMethod::None,
212
- group_size: 128,
213
- symmetric: true,
214
- },
206
+ LayerQuantConfig { method: QuantMethod::None, group_size: 128, symmetric: true },
215
207
);
216
208
assert_eq!(cfg.method_for_layer("model.layers.0"), QuantMethod::None);
217
209
assert_eq!(cfg.method_for_layer("model.layers.1"), QuantMethod::Gptq);
222
214
let mut cfg = QuantConfig::uniform(QuantMethod::Awq, 4.0);
223
215
cfg.layers.insert(
224
216
"model.layers".to_string(),
225
- LayerQuantConfig {
226
- method: QuantMethod::Gptq,
227
- group_size: 0,
228
- symmetric: false,
229
- },
217
+ LayerQuantConfig { method: QuantMethod::Gptq, group_size: 0, symmetric: false },
230
218
);
231
219
cfg.layers.insert(
232
220
"model.layers.0.self_attn".to_string(),
233
- LayerQuantConfig {
234
- method: QuantMethod::None,
235
- group_size: 128,
236
- symmetric: true,
237
- },
238
- );
239
- assert_eq!(
240
- cfg.method_for_layer("model.layers.0.self_attn.q_proj"),
241
- QuantMethod::None
242
- );
243
- assert_eq!(
244
- cfg.method_for_layer("model.layers.1.ffn"),
245
- QuantMethod::Gptq
221
+ LayerQuantConfig { method: QuantMethod::None, group_size: 128, symmetric: true },
246
222
);
223
+ assert_eq!(cfg.method_for_layer("model.layers.0.self_attn.q_proj"), QuantMethod::None);
224
+ assert_eq!(cfg.method_for_layer("model.layers.1.ffn"), QuantMethod::Gptq);
247
225
}
248
226
249
227
#[test]
262
240
263
241
#[test]
264
242
fn gguf_to_quant_method() {
265
- assert_eq!(
266
- gguf::to_quant_method(gguf::GgufType::F16),
267
- QuantMethod::None
268
- );
269
- assert_eq!(
270
- gguf::to_quant_method(gguf::GgufType::Q4_K_M),
271
- QuantMethod::Gguf
272
- );
243
+ assert_eq!(gguf::to_quant_method(gguf::GgufType::F16), QuantMethod::None);
244
+ assert_eq!(gguf::to_quant_method(gguf::GgufType::Q4_K_M), QuantMethod::Gguf);
273
245
}
274
246
}
275
247
▼crates/justapi-inference/src/real/tokenizer.rs
+5-12
20
20
metadata
21
21
.get(key)
22
22
.and_then(|v| match v {
23
- Value::Array(arr) => Some(
24
- arr.iter()
25
- .filter_map(|x| x.to_string().ok())
26
- .cloned()
27
- .collect(),
28
- ),
23
+ Value::Array(arr) => {
24
+ Some(arr.iter().filter_map(|x| x.to_string().ok()).cloned().collect())
25
+ }
29
26
_ => None,
30
27
})
31
28
.unwrap_or_default()
83
80
"trim_offsets": false,
84
81
});
85
82
86
- let _bos = metadata
87
- .get("tokenizer.ggml.bos_token_id")
88
- .and_then(|v| v.to_u32().ok());
89
- let _eos = metadata
90
- .get("tokenizer.ggml.eos_token_id")
91
- .and_then(|v| v.to_u32().ok());
83
+ let _bos = metadata.get("tokenizer.ggml.bos_token_id").and_then(|v| v.to_u32().ok());
84
+ let _eos = metadata.get("tokenizer.ggml.eos_token_id").and_then(|v| v.to_u32().ok());
92
85
93
86
// BOS/EOS injection is handled by the model's generate path, not the
94
87
// HuggingFace tokenizer post-processor, so we omit it here to keep the
95
88
▼crates/justapi-inference/src/router.rs
+13-51
89
89
impl Router {
90
90
/// Create a router with the given strategy.
91
91
pub fn new(strategy: RoutingStrategy) -> Self {
92
- Self {
93
- strategy,
94
- replicas: Mutex::new(Vec::new()),
95
- rr: AtomicUsize::new(0),
96
- }
92
+ Self { strategy, replicas: Mutex::new(Vec::new()), rr: AtomicUsize::new(0) }
97
93
}
98
94
99
95
/// Register or replace a replica by id.
167
163
/// Route a request: resolve it via the control plane, then select the best
168
164
/// replica. Returns `None` if the model is unknown or no replica can serve it.
169
165
pub fn route(&self, cp: &ControlPlane, request: &RouteRequest) -> Option<RouteDecision> {
170
- let resolved = cp.resolve(
171
- &request.model,
172
- request.version.as_deref(),
173
- request.routing_key.as_deref(),
174
- )?;
166
+ let resolved =
167
+ cp.resolve(&request.model, request.version.as_deref(), request.routing_key.as_deref())?;
175
168
let mut candidates = self.candidates(&resolved);
176
169
if candidates.is_empty() {
177
170
return None;
178
171
}
179
172
let chosen = match self.strategy {
180
- RoutingStrategy::LeastLoaded => candidates
181
- .into_iter()
182
- .min_by_key(|r| r.active_sequences)
183
- .unwrap(),
173
+ RoutingStrategy::LeastLoaded => {
174
+ candidates.into_iter().min_by_key(|r| r.active_sequences).unwrap()
175
+ }
184
176
RoutingStrategy::LowestKvPressure => candidates
185
177
.into_iter()
186
178
.min_by(|a, b| {
195
187
candidates.swap_remove(idx)
196
188
}
197
189
};
198
- Some(RouteDecision {
199
- replica: chosen.id,
200
- resolved,
201
- })
190
+ Some(RouteDecision { replica: chosen.id, resolved })
202
191
}
203
192
}
204
193
250
239
let d = router
251
240
.route(
252
241
&cp,
253
- &RouteRequest {
254
- model: "llama-7b".into(),
255
- version: None,
256
- routing_key: None,
257
- },
242
+ &RouteRequest { model: "llama-7b".into(), version: None, routing_key: None },
258
243
)
259
244
.unwrap();
260
245
assert_eq!(d.replica, "r1");
270
255
let d = router
271
256
.route(
272
257
&cp,
273
- &RouteRequest {
274
- model: "llama-7b".into(),
275
- version: None,
276
- routing_key: None,
277
- },
258
+ &RouteRequest { model: "llama-7b".into(), version: None, routing_key: None },
278
259
)
279
260
.unwrap();
280
261
assert_eq!(d.replica, "idle"); // fewer active sequences wins
289
270
let d = router
290
271
.route(
291
272
&cp,
292
- &RouteRequest {
293
- model: "llama-7b".into(),
294
- version: None,
295
- routing_key: None,
296
- },
273
+ &RouteRequest { model: "llama-7b".into(), version: None, routing_key: None },
297
274
)
298
275
.unwrap();
299
276
assert_eq!(d.replica, "cool");
343
320
assert!(router
344
321
.route(
345
322
&cp,
346
- &RouteRequest {
347
- model: "llama-7b".into(),
348
- version: None,
349
- routing_key: None,
350
- }
323
+ &RouteRequest { model: "llama-7b".into(), version: None, routing_key: None }
351
324
)
352
325
.is_none());
353
326
}
357
330
let cp = cp_with_model();
358
331
let router = Router::new(RoutingStrategy::LeastLoaded);
359
332
assert!(router
360
- .route(
361
- &cp,
362
- &RouteRequest {
363
- model: "nope".into(),
364
- version: None,
365
- routing_key: None,
366
- }
367
- )
333
+ .route(&cp, &RouteRequest { model: "nope".into(), version: None, routing_key: None })
368
334
.is_none());
369
335
}
370
336
405
371
assert!(router
406
372
.route(
407
373
&cp,
408
- &RouteRequest {
409
- model: "llama-7b".into(),
410
- version: None,
411
- routing_key: None,
412
- }
374
+ &RouteRequest { model: "llama-7b".into(), version: None, routing_key: None }
413
375
)
414
376
.is_none());
415
377
}
416
378
▼crates/justapi-inference/src/scheduler.rs
+21-80
272
272
273
273
/// Snapshot of scheduler state.
274
274
pub fn stats(&self) -> SchedulerStats {
275
- let num_prefilling = self
276
- .running
277
- .iter()
278
- .filter(|s| s.state == SeqState::Prefilling)
279
- .count();
275
+ let num_prefilling =
276
+ self.running.iter().filter(|s| s.state == SeqState::Prefilling).count();
280
277
SchedulerStats {
281
278
num_waiting: self.wait_queue.len(),
282
279
num_running: self.running.len(),
314
311
/// How many tokens a running sequence has generated so far, if it is
315
312
/// currently running.
316
313
pub fn running_seq_generated(&self, seq_id: u64) -> Option<usize> {
317
- self.running
318
- .iter()
319
- .find(|s| s.seq.id() == seq_id)
320
- .map(|s| s.num_generated)
314
+ self.running.iter().find(|s| s.seq.id() == seq_id).map(|s| s.num_generated)
321
315
}
322
316
323
317
// -- scheduling ---------------------------------------------------------
401
395
for _ in 0..uncached_blocks {
402
396
if seq.grow(&mut self.pool).is_none() {
403
397
let freed = self.prefix.evict_filter(uncached_blocks, |bs| {
404
- bs.iter()
405
- .all(|b| self.prefix_refs.get(b).copied().unwrap_or(0) == 0)
398
+ bs.iter().all(|b| self.prefix_refs.get(b).copied().unwrap_or(0) == 0)
406
399
});
407
400
for b in freed {
408
401
self.pool.unpin(b);
449
442
450
443
// Decode capacity: at most one token per running sequence that is
451
444
// in decode mode.
452
- let decode_count = self
453
- .running
454
- .iter()
455
- .filter(|s| s.state == SeqState::Decoding)
456
- .count();
445
+ let decode_count = self.running.iter().filter(|s| s.state == SeqState::Decoding).count();
457
446
let decode_tokens = decode_count.min(token_budget);
458
447
token_budget = token_budget.saturating_sub(decode_tokens);
459
448
488
477
s.state = SeqState::Decoding;
489
478
}
490
479
491
- prefill.push(PrefillStep {
492
- seq_id: s.seq.id(),
493
- tokens,
494
- computed_tokens,
495
- });
480
+ prefill.push(PrefillStep { seq_id: s.seq.id(), tokens, computed_tokens });
496
481
}
497
482
498
483
// Fill decode list.
504
489
505
490
self.last_back_pressure = back_pressure;
506
491
507
- Schedule {
508
- prefill,
509
- decode,
510
- back_pressure,
511
- }
492
+ Schedule { prefill, decode, back_pressure }
512
493
}
513
494
514
495
// -- step completion ----------------------------------------------------
527
508
return;
528
509
}
529
510
let cache_tokens = cache_blocks * BLOCK_SIZE;
530
- self.prefix
531
- .insert(&prompt[..cache_tokens], &blocks[..cache_blocks]);
511
+ self.prefix.insert(&prompt[..cache_tokens], &blocks[..cache_blocks]);
532
512
for &b in &blocks[..cache_blocks] {
533
513
let entry = self.prefix_refs.entry(b).or_insert(0);
534
514
if *entry == 0 {
673
653
NewRequest {
674
654
id,
675
655
prompt: (0..8).collect(),
676
- sampling_params: SamplingParams {
677
- max_tokens: 16,
678
- ..Default::default()
679
- },
656
+ sampling_params: SamplingParams { max_tokens: 16, ..Default::default() },
680
657
prefix_cached_tokens: 0,
681
658
cached_blocks: Vec::new(),
682
659
}
720
697
sched.add_request(NewRequest {
721
698
id: 1,
722
699
prompt: (0..10).collect(), // 10 tokens
723
- sampling_params: SamplingParams {
724
- max_tokens: 8,
725
- ..Default::default()
726
- },
700
+ sampling_params: SamplingParams { max_tokens: 8, ..Default::default() },
727
701
prefix_cached_tokens: 0,
728
702
cached_blocks: Vec::new(),
729
703
});
776
750
sched.add_request(NewRequest {
777
751
id: 20,
778
752
prompt: (0..16).collect(),
779
- sampling_params: SamplingParams {
780
- max_tokens: 8,
781
- ..Default::default()
782
- },
753
+ sampling_params: SamplingParams { max_tokens: 8, ..Default::default() },
783
754
prefix_cached_tokens: 0,
784
755
cached_blocks: Vec::new(),
785
756
}); // internal seq_id = 2
842
813
sched.add_request(NewRequest {
843
814
id: 1,
844
815
prompt: (0..4).collect(),
845
- sampling_params: SamplingParams {
846
- max_tokens: 3,
847
- ..Default::default()
848
- },
816
+ sampling_params: SamplingParams { max_tokens: 3, ..Default::default() },
849
817
prefix_cached_tokens: 0,
850
818
cached_blocks: Vec::new(),
851
819
});
907
875
sched.add_request(NewRequest {
908
876
id: 1,
909
877
prompt: (0..48).collect(),
910
- sampling_params: SamplingParams {
911
- max_tokens: 8,
912
- ..Default::default()
913
- },
878
+ sampling_params: SamplingParams { max_tokens: 8, ..Default::default() },
914
879
prefix_cached_tokens: 32, // 2 blocks × 16 tokens
915
880
cached_blocks: vec![b0, b1],
916
881
});
987
952
sched.add_request(NewRequest {
988
953
id: 1,
989
954
prompt: (0..48).collect(),
990
- sampling_params: SamplingParams {
991
- max_tokens: 1,
992
- ..Default::default()
993
- },
955
+ sampling_params: SamplingParams { max_tokens: 1, ..Default::default() },
994
956
prefix_cached_tokens: 0,
995
957
cached_blocks: Vec::new(),
996
958
});
1008
970
sched.add_request(NewRequest {
1009
971
id: 2,
1010
972
prompt: (0..48).collect(),
1011
- sampling_params: SamplingParams {
1012
- max_tokens: 1,
1013
- ..Default::default()
1014
- },
973
+ sampling_params: SamplingParams { max_tokens: 1, ..Default::default() },
1015
974
prefix_cached_tokens: 0,
1016
975
cached_blocks: Vec::new(),
1017
976
});
1018
977
let s = sched.schedule();
1019
978
// Nothing to prefill; the cached prefix covers the whole prompt.
1020
- assert!(
1021
- s.prefill.is_empty(),
1022
- "shared prefix should be reused, not prefilled"
1023
- );
979
+ assert!(s.prefill.is_empty(), "shared prefix should be reused, not prefilled");
1024
980
assert_eq!(s.decode.len(), 1);
1025
981
// The hit should have been recorded and 48 tokens saved.
1026
982
let stats = sched.prefix_cache().stats();
1037
993
sched.add_request(NewRequest {
1038
994
id: 1,
1039
995
prompt: (0..48).collect(), // 3 blocks
1040
- sampling_params: SamplingParams {
1041
- max_tokens: 1,
1042
- ..Default::default()
1043
- },
996
+ sampling_params: SamplingParams { max_tokens: 1, ..Default::default() },
1044
997
prefix_cached_tokens: 0,
1045
998
cached_blocks: Vec::new(),
1046
999
});
1052
1005
sched.add_request(NewRequest {
1053
1006
id: 2,
1054
1007
prompt: (0..64).collect(),
1055
- sampling_params: SamplingParams {
1056
- max_tokens: 1,
1057
- ..Default::default()
1058
- },
1008
+ sampling_params: SamplingParams { max_tokens: 1, ..Default::default() },
1059
1009
prefix_cached_tokens: 0,
1060
1010
cached_blocks: Vec::new(),
1061
1011
});
1081
1031
sched.add_request(NewRequest {
1082
1032
id: i,
1083
1033
prompt,
1084
- sampling_params: SamplingParams {
1085
- max_tokens: 1,
1086
- ..Default::default()
1087
- },
1034
+ sampling_params: SamplingParams { max_tokens: 1, ..Default::default() },
1088
1035
prefix_cached_tokens: 0,
1089
1036
cached_blocks: Vec::new(),
1090
1037
});
1106
1053
sched.add_request(NewRequest {
1107
1054
id: 1,
1108
1055
prompt: (0..48).collect(),
1109
- sampling_params: SamplingParams {
1110
- max_tokens: 1,
1111
- ..Default::default()
1112
- },
1056
+ sampling_params: SamplingParams { max_tokens: 1, ..Default::default() },
1113
1057
prefix_cached_tokens: 0,
1114
1058
cached_blocks: Vec::new(),
1115
1059
});
1127
1071
sched.add_request(NewRequest {
1128
1072
id: 2,
1129
1073
prompt: (0..48).collect(),
1130
- sampling_params: SamplingParams {
1131
- max_tokens: 1,
1132
- ..Default::default()
1133
- },
1074
+ sampling_params: SamplingParams { max_tokens: 1, ..Default::default() },
1134
1075
prefix_cached_tokens: 0,
1135
1076
cached_blocks: Vec::new(),
1136
1077
});
1137
1078
▼crates/justapi-inference/src/scheduler_engine.rs
+22-94
124
124
125
125
self.senders.lock().unwrap().insert(seq_id, tx);
126
126
self.seq_model.lock().unwrap().insert(seq_id, model.clone());
127
- self.seq_max_tokens
128
- .lock()
129
- .unwrap()
130
- .insert(seq_id, params.max_tokens);
131
- self.seq_params
132
- .lock()
133
- .unwrap()
134
- .insert(seq_id, params.clone());
127
+ self.seq_max_tokens.lock().unwrap().insert(seq_id, params.max_tokens);
128
+ self.seq_params.lock().unwrap().insert(seq_id, params.clone());
135
129
136
130
Ok(rx)
137
131
}
184
178
id: token,
185
179
text,
186
180
logprob: 0.0,
187
- finish_reason: if done {
188
- Some(FinishReason::Length)
189
- } else {
190
- None
191
- },
181
+ finish_reason: if done { Some(FinishReason::Length) } else { None },
192
182
},
193
183
);
194
184
}
211
201
id: token,
212
202
text,
213
203
logprob: 0.0,
214
- finish_reason: if done {
215
- Some(FinishReason::Length)
216
- } else {
217
- None
218
- },
204
+ finish_reason: if done { Some(FinishReason::Length) } else { None },
219
205
},
220
206
);
221
207
}
274
260
seq_params: &Arc<Mutex<HashMap<u64, SamplingParams>>>,
275
261
seq_id: u64,
276
262
) -> SamplingParams {
277
- seq_params
278
- .lock()
279
- .unwrap()
280
- .get(&seq_id)
281
- .cloned()
282
- .unwrap_or_default()
263
+ seq_params.lock().unwrap().get(&seq_id).cloned().unwrap_or_default()
283
264
}
284
265
285
266
/// Sample a token id from logits according to `params`.
301
282
let mut idx: Vec<usize> = (0..scaled.len()).collect();
302
283
let k = params.top_k;
303
284
idx.select_nth_unstable_by(k - 1, |&a, &b| {
304
- scaled[b]
305
- .partial_cmp(&scaled[a])
306
- .unwrap_or(std::cmp::Ordering::Equal)
285
+ scaled[b].partial_cmp(&scaled[a]).unwrap_or(std::cmp::Ordering::Equal)
307
286
});
308
287
for &i in &idx[k..] {
309
288
scaled[i] = f32::NEG_INFINITY;
325
304
// reaches cumulative mass `top_p`, renormalise.
326
305
if params.top_p < 1.0 {
327
306
let mut order: Vec<usize> = (0..probs.len()).collect();
328
- order.sort_by(|&a, &b| {
329
- probs[b]
330
- .partial_cmp(&probs[a])
331
- .unwrap_or(std::cmp::Ordering::Equal)
332
- });
307
+ order
308
+ .sort_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap_or(std::cmp::Ordering::Equal));
333
309
let mut cum = 0.0_f32;
334
310
for (rank, &i) in order.iter().enumerate() {
335
311
cum += probs[i];
431
407
scheduler: &Arc<Mutex<Scheduler>>,
432
408
seq_max_tokens: &Arc<Mutex<HashMap<u64, usize>>>,
433
409
) -> bool {
434
- let max = seq_max_tokens
435
- .lock()
436
- .unwrap()
437
- .get(&seq_id)
438
- .copied()
439
- .unwrap_or(usize::MAX);
410
+ let max = seq_max_tokens.lock().unwrap().get(&seq_id).copied().unwrap_or(usize::MAX);
440
411
let generated = scheduler.lock().unwrap().running_seq_generated(seq_id);
441
412
match generated {
442
413
Some(n) => n >= max,
491
462
async fn scheduler_engine_generates_tokens() {
492
463
let (_, _, se) = test_setup();
493
464
let prompt = MockModel::tokenize("hello");
494
- let params = SamplingParams {
495
- max_tokens: 10,
496
- temperature: 0.0,
497
- ..Default::default()
498
- };
465
+ let params = SamplingParams { max_tokens: 10, temperature: 0.0, ..Default::default() };
499
466
500
467
let mut rx = se.generate("mock", &prompt, params).unwrap();
501
468
let tokens = drain_receiver(&mut rx).await;
506
473
async fn scheduler_engine_streams_multiple_tokens() {
507
474
let (_, _, se) = test_setup();
508
475
let prompt = MockModel::tokenize("hello world");
509
- let params = SamplingParams {
510
- max_tokens: 5,
511
- temperature: 0.0,
512
- ..Default::default()
513
- };
476
+ let params = SamplingParams { max_tokens: 5, temperature: 0.0, ..Default::default() };
514
477
515
478
let mut rx = se.generate("mock", &prompt, params).unwrap();
516
479
let tokens = drain_receiver(&mut rx).await;
517
480
assert!(!tokens.is_empty(), "should produce tokens");
518
- assert!(
519
- tokens.iter().all(|t| !t.text.is_empty()),
520
- "every token should have text"
521
- );
481
+ assert!(tokens.iter().all(|t| !t.text.is_empty()), "every token should have text");
522
482
}
523
483
524
484
#[tokio::test]
527
487
// Need >= 16 tokens for at least one full block (BLOCK_SIZE=16).
528
488
let prompt_text = "abcdefghijklmnopqrstuvwxyz0123456789"; // 36 tokens
529
489
let prompt = MockModel::tokenize(prompt_text);
530
- assert!(
531
- prompt.len() >= 16,
532
- "prompt must be >= 16 tokens for block caching"
533
- );
490
+ assert!(prompt.len() >= 16, "prompt must be >= 16 tokens for block caching");
534
491
535
- let params = SamplingParams {
536
- max_tokens: 3,
537
- temperature: 0.0,
538
- ..Default::default()
539
- };
492
+ let params = SamplingParams { max_tokens: 3, temperature: 0.0, ..Default::default() };
540
493
541
494
// First request caches its prompt.
542
495
let mut rx1 = se.generate("mock", &prompt, params.clone()).unwrap();
566
519
#[tokio::test]
567
520
async fn scheduler_engine_batches_concurrent_requests() {
568
521
let (_, _, se) = test_setup();
569
- let params = SamplingParams {
570
- max_tokens: 4,
571
- temperature: 0.0,
572
- ..Default::default()
573
- };
522
+ let params = SamplingParams { max_tokens: 4, temperature: 0.0, ..Default::default() };
574
523
575
524
// Launch two concurrent requests; both should complete.
576
- let mut rx_a = se
577
- .generate("mock", &MockModel::tokenize("hello"), params.clone())
578
- .unwrap();
579
- let mut rx_b = se
580
- .generate("mock", &MockModel::tokenize("world"), params.clone())
581
- .unwrap();
525
+ let mut rx_a = se.generate("mock", &MockModel::tokenize("hello"), params.clone()).unwrap();
526
+ let mut rx_b = se.generate("mock", &MockModel::tokenize("world"), params.clone()).unwrap();
582
527
583
528
let a = drain_receiver(&mut rx_a).await;
584
529
let b = drain_receiver(&mut rx_b).await;
590
535
#[tokio::test]
591
536
async fn scheduler_engine_handles_many_concurrent_requests() {
592
537
let (_, _, se) = test_setup();
593
- let params = SamplingParams {
594
- max_tokens: 2,
595
- temperature: 0.0,
596
- ..Default::default()
597
- };
538
+ let params = SamplingParams { max_tokens: 2, temperature: 0.0, ..Default::default() };
598
539
599
540
// Fire 8 concurrent requests (more than max_num_seqs=4 batch limit).
600
541
let mut receivers = Vec::new();
637
578
fn greedy_sampling_is_argmax() {
638
579
// temperature 0 ⇒ deterministic argmax regardless of distribution.
639
580
let logits = vec![-1.0, 0.5, 3.0, -2.0];
640
- let params = SamplingParams {
641
- temperature: 0.0,
642
- ..Default::default()
643
- };
581
+ let params = SamplingParams { temperature: 0.0, ..Default::default() };
644
582
assert_eq!(sample_token_with_params(&logits, &params), 2);
645
583
}
646
584
648
586
fn sampling_honors_temperature() {
649
587
// A sharp peak with very low temperature should always pick the peak.
650
588
let logits = vec![-100.0, -100.0, 50.0, -100.0];
651
- let params = SamplingParams {
652
- temperature: 0.01,
653
- ..Default::default()
654
- };
589
+ let params = SamplingParams { temperature: 0.01, ..Default::default() };
655
590
for _ in 0..50 {
656
591
assert_eq!(sample_token_with_params(&logits, &params), 2);
657
592
}
661
596
fn sampling_top_k_truncates() {
662
597
// top_k=1 ⇒ always the argmax even with high temperature noise.
663
598
let logits = vec![1.0, 2.0, 10.0, 0.5];
664
- let params = SamplingParams {
665
- temperature: 2.0,
666
- top_k: 1,
667
- ..Default::default()
668
- };
599
+ let params = SamplingParams { temperature: 2.0, top_k: 1, ..Default::default() };
669
600
for _ in 0..50 {
670
601
assert_eq!(sample_token_with_params(&logits, &params), 2);
671
602
}
676
607
// A flat distribution at high temperature should produce variety, not
677
608
// a single token (stochasticity is exercised).
678
609
let logits = vec![0.0, 0.0, 0.0, 0.0];
679
- let params = SamplingParams {
680
- temperature: 1.0,
681
- ..Default::default()
682
- };
610
+ let params = SamplingParams { temperature: 1.0, ..Default::default() };
683
611
let mut seen = std::collections::HashSet::new();
684
612
for _ in 0..200 {
685
613
seen.insert(sample_token_with_params(&logits, &params));
686
614
▼crates/justapi-inference/src/spec_decode.rs
+13-54
45
45
impl AcceptanceStats {
46
46
/// Create stats for a tree-based speculation run with the given branch factor.
47
47
pub fn with_tree_branch(branch: usize) -> Self {
48
- Self {
49
- tree_branch: branch,
50
- ..Default::default()
51
- }
48
+ Self { tree_branch: branch, ..Default::default() }
52
49
}
53
50
54
51
/// Total tokens emitted (`accepted_draft + bonus`).
112
109
if params.top_k > 0 && params.top_k < vocab {
113
110
let mut idx: Vec<usize> = (0..vocab).collect();
114
111
idx.sort_by(|&a, &b| {
115
- scaled[b]
116
- .partial_cmp(&scaled[a])
117
- .unwrap_or(std::cmp::Ordering::Equal)
112
+ scaled[b].partial_cmp(&scaled[a]).unwrap_or(std::cmp::Ordering::Equal)
118
113
});
119
114
let kth = scaled[idx[params.top_k.min(vocab - 1)]];
120
115
for s in scaled.iter_mut() {
132
127
// Top-p (nucleus) truncation.
133
128
if params.top_p < 1.0 && sum > 0.0 {
134
129
let mut order: Vec<usize> = (0..vocab).collect();
135
- order.sort_by(|&a, &b| {
136
- probs[b]
137
- .partial_cmp(&probs[a])
138
- .unwrap_or(std::cmp::Ordering::Equal)
139
- });
130
+ order
131
+ .sort_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap_or(std::cmp::Ordering::Equal));
140
132
let mut cum = 0.0;
141
133
let mut cutoff = 1.0;
142
134
for &i in &order {
413
405
// gamma 0 must reproduce the target's normal decode exactly.
414
406
let target = MockModel::new(32);
415
407
let draft = MockModel::new(32);
416
- let params = SamplingParams {
417
- max_tokens: 12,
418
- temperature: 0.0,
419
- ..Default::default()
420
- };
408
+ let params = SamplingParams { max_tokens: 12, temperature: 0.0, ..Default::default() };
421
409
let prompt = vec![0u32];
422
410
let baseline = collect(&target, &prompt, &params);
423
411
let (spec, _) = collect_spec(&target, &draft, &prompt, &params, 0);
431
419
// correctness-preserving, only faster.
432
420
let target = MockModel::new(32);
433
421
let draft = MockModel::new(32);
434
- let params = SamplingParams {
435
- max_tokens: 20,
436
- temperature: 0.0,
437
- ..Default::default()
438
- };
422
+ let params = SamplingParams { max_tokens: 20, temperature: 0.0, ..Default::default() };
439
423
let prompt = vec![5u32];
440
424
let baseline = collect(&target, &prompt, &params);
441
425
let (spec, _) = collect_spec(&target, &draft, &prompt, &params, 4);
448
432
let draft = MockModel::new(32);
449
433
let max = 20usize;
450
434
let gamma = 4usize;
451
- let params = SamplingParams {
452
- max_tokens: max,
453
- temperature: 0.0,
454
- ..Default::default()
455
- };
435
+ let params = SamplingParams { max_tokens: max, temperature: 0.0, ..Default::default() };
456
436
let (spec, stats) = collect_spec(&target, &draft, &[0u32], &params, gamma);
457
437
assert_eq!(spec.len(), max);
458
438
// With a perfect draft, every step emits gamma+1 tokens, so we need
470
450
fn imperfect_draft_lowers_acceptance_rate() {
471
451
let target = MockModel::new(64);
472
452
// Draft predicts (prev + 7) while target predicts (prev + 1): rarely equal.
473
- let draft = OffsetModel {
474
- vocab: 64,
475
- offset: 7,
476
- };
477
- let params = SamplingParams {
478
- max_tokens: 40,
479
- temperature: 0.0,
480
- ..Default::default()
481
- };
453
+ let draft = OffsetModel { vocab: 64, offset: 7 };
454
+ let params = SamplingParams { max_tokens: 40, temperature: 0.0, ..Default::default() };
482
455
let (_spec, stats) = collect_spec(&target, &draft, &[0u32], &params, 5);
483
456
// With temperature 0 and a mismatched draft, acceptance is essentially 0.
484
- assert!(
485
- stats.acceptance_rate() < 0.2,
486
- "got {}",
487
- stats.acceptance_rate()
488
- );
457
+ assert!(stats.acceptance_rate() < 0.2, "got {}", stats.acceptance_rate());
489
458
}
490
459
491
460
#[test]
492
461
fn spec_honors_max_tokens_with_imperfect_draft() {
493
462
let target = MockModel::new(64);
494
- let draft = OffsetModel {
495
- vocab: 64,
496
- offset: 3,
497
- };
463
+ let draft = OffsetModel { vocab: 64, offset: 3 };
498
464
let max = 15usize;
499
- let params = SamplingParams {
500
- max_tokens: max,
501
- temperature: 0.0,
502
- ..Default::default()
503
- };
465
+ let params = SamplingParams { max_tokens: max, temperature: 0.0, ..Default::default() };
504
466
let (spec, _) = collect_spec(&target, &draft, &[0u32], &params, 3);
505
467
assert_eq!(spec.len(), max);
506
468
}
525
487
#[test]
526
488
fn sampling_token_argmax_is_greedy() {
527
489
let logits = vec![-1.0, 3.0, -2.0, 0.5];
528
- let params = SamplingParams {
529
- temperature: 0.0,
530
- ..Default::default()
531
- };
490
+ let params = SamplingParams { temperature: 0.0, ..Default::default() };
532
491
let mut rng = StdRng::seed_from_u64(1);
533
492
assert_eq!(sample_token(&logits, &params, &mut rng), 1);
534
493
}
535
494
▼crates/justapi-inference/src/spec_decode_tree.rs
+33-151
35
35
36
36
impl TreeNode {
37
37
pub fn new(token: u32, score: f32) -> Self {
38
- Self {
39
- token,
40
- score,
41
- children: vec![],
42
- }
38
+ Self { token, score, children: vec![] }
43
39
}
44
40
}
45
41
71
67
if k == 0 || logits.is_empty() {
72
68
return vec![];
73
69
}
74
- let mut scored: Vec<(u32, f32)> = logits
75
- .iter()
76
- .enumerate()
77
- .map(|(i, &s)| (i as u32, s))
78
- .collect();
70
+ let mut scored: Vec<(u32, f32)> =
71
+ logits.iter().enumerate().map(|(i, &s)| (i as u32, s)).collect();
79
72
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
80
73
scored.truncate(k);
81
74
scored
92
85
branch: usize,
93
86
) -> Result<DraftTree, ModelError> {
94
87
if gamma == 0 || branch == 0 {
95
- return Ok(DraftTree {
96
- roots: vec![],
97
- gamma: 0,
98
- branch: 0,
99
- });
88
+ return Ok(DraftTree { roots: vec![], gamma: 0, branch: 0 });
100
89
}
101
90
102
91
fn expand(
115
104
let mut child_ctx = ctx.to_vec();
116
105
child_ctx.push(token);
117
106
let children = expand(draft, &child_ctx, depth_remaining - 1, branch)?;
118
- nodes.push(TreeNode {
119
- token,
120
- score,
121
- children,
122
- });
107
+ nodes.push(TreeNode { token, score, children });
123
108
}
124
109
Ok(nodes)
125
110
}
126
111
127
112
let roots = expand(draft, context, gamma, branch)?;
128
- Ok(DraftTree {
129
- roots,
130
- gamma,
131
- branch,
132
- })
113
+ Ok(DraftTree { roots, gamma, branch })
133
114
}
134
115
135
116
/// Verify a draft tree by walking the longest matching path.
156
137
let mut ctx = context.to_vec();
157
138
158
139
// Clone roots into frontier so we can own children when descending.
159
- let mut frontier: Vec<(u32, Vec<TreeNode>)> = tree
160
- .roots
161
- .iter()
162
- .map(|n| (n.token, n.children.clone()))
163
- .collect();
140
+ let mut frontier: Vec<(u32, Vec<TreeNode>)> =
141
+ tree.roots.iter().map(|n| (n.token, n.children.clone())).collect();
164
142
165
143
let mut n_accepted = 0usize;
166
144
173
151
accepted.push(target_tok);
174
152
n_accepted += 1;
175
153
ctx.push(target_tok);
176
- frontier = children
177
- .into_iter()
178
- .map(|n| (n.token, n.children))
179
- .collect();
154
+ frontier = children.into_iter().map(|n| (n.token, n.children)).collect();
180
155
if frontier.is_empty() {
181
156
let logits = target.forward_logits(&ctx)?;
182
157
let bonus = sample_token(&logits, params, rng);
317
292
branch: usize,
318
293
seed: u64,
319
294
) -> Self {
320
- Self {
321
- target,
322
- draft,
323
- gamma,
324
- branch,
325
- seed,
326
- }
295
+ Self { target, draft, gamma, branch, seed }
327
296
}
328
297
}
329
298
371
340
372
341
impl RangeModel {
373
342
fn new(vocab: usize, offset: u32, spread: u32) -> Self {
374
- Self {
375
- vocab,
376
- offset,
377
- spread,
378
- }
343
+ Self { vocab, offset, spread }
379
344
}
380
345
}
381
346
480
445
481
446
#[test]
482
447
fn tree_total_nodes_formula() {
483
- let t = DraftTree {
484
- roots: vec![],
485
- gamma: 4,
486
- branch: 3,
487
- };
448
+ let t = DraftTree { roots: vec![], gamma: 4, branch: 3 };
488
449
// 3 + 9 + 27 + 81 = 120
489
450
assert_eq!(t.total_nodes(), 120);
490
451
}
491
452
492
453
#[test]
493
454
fn tree_total_nodes_zero() {
494
- let t = DraftTree {
495
- roots: vec![],
496
- gamma: 0,
497
- branch: 3,
498
- };
455
+ let t = DraftTree { roots: vec![], gamma: 0, branch: 3 };
499
456
assert_eq!(t.total_nodes(), 0);
500
- let t = DraftTree {
501
- roots: vec![],
502
- gamma: 4,
503
- branch: 0,
504
- };
457
+ let t = DraftTree { roots: vec![], gamma: 4, branch: 0 };
505
458
assert_eq!(t.total_nodes(), 0);
506
459
}
507
460
514
467
// top-3 include the correct prediction at every depth.
515
468
let target = MockModel::new(32);
516
469
let draft = RangeModel::new(32, 0, 3);
517
- let params = SamplingParams {
518
- max_tokens: 20,
519
- temperature: 0.0,
520
- ..Default::default()
521
- };
470
+ let params = SamplingParams { max_tokens: 20, temperature: 0.0, ..Default::default() };
522
471
let prompt = vec![5u32];
523
472
let baseline = collect(&target, &prompt, &params);
524
473
let (spec, stats) = collect_tree(&target, &draft, &prompt, &params, 4, 3);
525
- assert_eq!(
526
- baseline, spec,
527
- "tree speculation must not change the output"
528
- );
474
+ assert_eq!(baseline, spec, "tree speculation must not change the output");
529
475
// Acceptance should be high — perfect draft.
530
- assert!(
531
- stats.acceptance_rate() > 0.8,
532
- "got {}",
533
- stats.acceptance_rate()
534
- );
476
+ assert!(stats.acceptance_rate() > 0.8, "got {}", stats.acceptance_rate());
535
477
assert_eq!(stats.tree_branch, 3);
536
478
}
537
479
541
483
fn tree_gamma_zero_matches_plain() {
542
484
let target = MockModel::new(32);
543
485
let draft = MockModel::new(32);
544
- let params = SamplingParams {
545
- max_tokens: 12,
546
- temperature: 0.0,
547
- ..Default::default()
548
- };
486
+ let params = SamplingParams { max_tokens: 12, temperature: 0.0, ..Default::default() };
549
487
let prompt = vec![0u32];
550
488
let baseline = collect(&target, &prompt, &params);
551
489
let (spec, _) = collect_tree(&target, &draft, &prompt, &params, 0, 3);
558
496
fn tree_branch_one_acts_like_single_path() {
559
497
let target = MockModel::new(32);
560
498
let draft = MockModel::new(32);
561
- let params = SamplingParams {
562
- max_tokens: 20,
563
- temperature: 0.0,
564
- ..Default::default()
565
- };
499
+ let params = SamplingParams { max_tokens: 20, temperature: 0.0, ..Default::default() };
566
500
let prompt = vec![3u32];
567
501
let baseline = collect(&target, &prompt, &params);
568
502
// With branch=1, tree has only one candidate per depth = essentially
579
513
// Draft offset=10, centered on (prev+11)%V — never includes (prev+1).
580
514
let target = MockModel::new(64);
581
515
let draft = RangeModel::new(64, 10, 0);
582
- let params = SamplingParams {
583
- max_tokens: 30,
584
- temperature: 0.0,
585
- ..Default::default()
586
- };
516
+ let params = SamplingParams { max_tokens: 30, temperature: 0.0, ..Default::default() };
587
517
let prompt = vec![0u32];
588
518
let baseline = collect(&target, &prompt, &params);
589
519
let (spec, stats) = collect_tree(&target, &draft, &prompt, &params, 4, 3);
590
520
assert_eq!(baseline, spec, "must preserve output even with bad draft");
591
521
// Acceptance rate should be near 0.
592
- assert!(
593
- stats.acceptance_rate() < 0.1,
594
- "got {}",
595
- stats.acceptance_rate()
596
- );
522
+ assert!(stats.acceptance_rate() < 0.1, "got {}", stats.acceptance_rate());
597
523
// Tokens emitted == max_tokens (graceful, no loss)
598
524
assert_eq!(spec.len(), 30);
599
525
}
607
533
// Target predicts (prev+1). So acceptance should be high.
608
534
let target = MockModel::new(64);
609
535
let draft = RangeModel::new(64, 0, 2);
610
- let params = SamplingParams {
611
- max_tokens: 40,
612
- temperature: 0.0,
613
- ..Default::default()
614
- };
536
+ let params = SamplingParams { max_tokens: 40, temperature: 0.0, ..Default::default() };
615
537
let prompt = vec![0u32];
616
538
617
539
let (_spec1, stats1) = collect_tree(&target, &draft, &prompt, &params, 4, 1);
633
555
fn tree_honors_max_tokens() {
634
556
let target = MockModel::new(64);
635
557
let draft = RangeModel::new(64, 0, 2);
636
- let params = SamplingParams {
637
- max_tokens: 15,
638
- temperature: 0.0,
639
- ..Default::default()
640
- };
558
+ let params = SamplingParams { max_tokens: 15, temperature: 0.0, ..Default::default() };
641
559
let (spec, _) = collect_tree(&target, &draft, &[0u32], &params, 4, 3);
642
560
assert_eq!(spec.len(), 15);
643
561
}
672
590
gamma: 1,
673
591
branch: 2,
674
592
};
675
- let params = SamplingParams {
676
- temperature: 0.0,
677
- ..Default::default()
678
- };
593
+ let params = SamplingParams { temperature: 0.0, ..Default::default() };
679
594
let mut rng = StdRng::seed_from_u64(0);
680
595
let (accepted, bonus, n) = verify_tree(&target, &tree, &[0u32], &params, &mut rng).unwrap();
681
596
assert_eq!(accepted, vec![1]);
692
607
gamma: 1,
693
608
branch: 2,
694
609
};
695
- let params = SamplingParams {
696
- temperature: 0.0,
697
- ..Default::default()
698
- };
610
+ let params = SamplingParams { temperature: 0.0, ..Default::default() };
699
611
let mut rng = StdRng::seed_from_u64(0);
700
612
let (accepted, bonus, n) = verify_tree(&target, &tree, &[0u32], &params, &mut rng).unwrap();
701
613
assert_eq!(accepted.len(), 0);
722
634
let mut node_b = TreeNode::new(7, 3.0);
723
635
node_b.children = vec![TreeNode::new(8, 3.0)];
724
636
725
- let tree = DraftTree {
726
- roots: vec![node_a, node_b],
727
- gamma: 3,
728
- branch: 2,
729
- };
637
+ let tree = DraftTree { roots: vec![node_a, node_b], gamma: 3, branch: 2 };
730
638
let target = MockModel::new(16);
731
- let params = SamplingParams {
732
- temperature: 0.0,
733
- ..Default::default()
734
- };
639
+ let params = SamplingParams { temperature: 0.0, ..Default::default() };
735
640
let mut rng = StdRng::seed_from_u64(0);
736
641
let (accepted, bonus, n) = verify_tree(&target, &tree, &[0u32], &params, &mut rng).unwrap();
737
642
// Accepted: [1, 2, 3] — the matching path.
750
655
TreeNode::new(10, 4.0),
751
656
];
752
657
753
- let tree = DraftTree {
754
- roots: vec![node_a],
755
- gamma: 2,
756
- branch: 2,
757
- };
658
+ let tree = DraftTree { roots: vec![node_a], gamma: 2, branch: 2 };
758
659
let target = MockModel::new(16);
759
- let params = SamplingParams {
760
- temperature: 0.0,
761
- ..Default::default()
762
- };
660
+ let params = SamplingParams { temperature: 0.0, ..Default::default() };
763
661
let mut rng = StdRng::seed_from_u64(0);
764
662
let (accepted, bonus, n) = verify_tree(&target, &tree, &[0u32], &params, &mut rng).unwrap();
765
663
assert_eq!(accepted, vec![1]); // only first token accepted
774
672
fn tree_stats_record_branch() {
775
673
let target = MockModel::new(32);
776
674
let draft = MockModel::new(32);
777
- let params = SamplingParams {
778
- max_tokens: 10,
779
- temperature: 0.0,
780
- ..Default::default()
781
- };
675
+ let params = SamplingParams { max_tokens: 10, temperature: 0.0, ..Default::default() };
782
676
let (_, stats) = collect_tree(&target, &draft, &[0u32], &params, 3, 3);
783
677
assert_eq!(stats.tree_branch, 3);
784
678
assert!(stats.steps > 0);
792
686
// (both produce the same tokens as plain decode).
793
687
let target = MockModel::new(32);
794
688
let draft = MockModel::new(32);
795
- let params = SamplingParams {
796
- max_tokens: 20,
797
- temperature: 0.0,
798
- ..Default::default()
799
- };
689
+ let params = SamplingParams { max_tokens: 20, temperature: 0.0, ..Default::default() };
800
690
let prompt = vec![0u32];
801
691
let baseline = collect(&target, &prompt, &params);
802
692
813
703
fn tree_handles_empty_prompt() {
814
704
let target = MockModel::new(32);
815
705
let draft = MockModel::new(32);
816
- let params = SamplingParams {
817
- max_tokens: 5,
818
- temperature: 0.0,
819
- ..Default::default()
820
- };
706
+ let params = SamplingParams { max_tokens: 5, temperature: 0.0, ..Default::default() };
821
707
let (spec, _) = collect_tree(&target, &draft, &[], &params, 2, 2);
822
708
assert_eq!(spec.len(), 5);
823
709
}
834
720
let draft = Arc::new(MockModel::new(32));
835
721
let spec = TreeSpeculativeModel::new(target.clone(), draft, 4, 3, 0);
836
722
837
- let params = SamplingParams {
838
- max_tokens: 20,
839
- temperature: 0.0,
840
- ..Default::default()
841
- };
723
+ let params = SamplingParams { max_tokens: 20, temperature: 0.0, ..Default::default() };
842
724
843
725
let plain = collect(&*target, &[3u32], &params);
844
726
let wrapped = collect(&spec, &[3u32], &params);
845
727
▼crates/justapi-inference/src/supervisor.rs
+4-18
37
37
#[derive(Debug, Clone)]
38
38
pub enum SupervisorAction {
39
39
/// Start a new replica for `model` @ `version`.
40
- StartReplica {
41
- id: String,
42
- model: String,
43
- version: String,
44
- },
40
+ StartReplica { id: String, model: String, version: String },
45
41
/// Terminate the replica with `id`.
46
42
StopReplica { id: String },
47
43
}
129
125
130
126
// Scale up: register not-yet-healthy replicas and emit start intents.
131
127
while self.live.len() < desired && self.live.len() < self.config.max_replicas {
132
- let id = format!(
133
- "{}-{}-{}",
134
- self.config.model, self.config.version, self.next_index
135
- );
128
+ let id = format!("{}-{}-{}", self.config.model, self.config.version, self.next_index);
136
129
self.next_index += 1;
137
130
self.live.push(LiveReplica {
138
131
id: id.clone(),
238
231
}
239
232
240
233
fn metrics(kv: f32, ttft: f32, q: usize) -> LlmMetrics {
241
- LlmMetrics {
242
- tokens_per_sec: 50.0,
243
- ttft_ms: ttft,
244
- queue_depth: q,
245
- kv_pressure_pct: kv,
246
- }
234
+ LlmMetrics { tokens_per_sec: 50.0, ttft_ms: ttft, queue_depth: q, kv_pressure_pct: kv }
247
235
}
248
236
249
237
#[test]
297
285
// Either we already scaled up via the cooldown window or this emits starts.
298
286
assert!(s.desired() >= 2);
299
287
if !actions.is_empty() {
300
- assert!(actions
301
- .iter()
302
- .all(|a| matches!(a, SupervisorAction::StartReplica { .. })));
288
+ assert!(actions.iter().all(|a| matches!(a, SupervisorAction::StartReplica { .. })));
303
289
}
304
290
assert!(s.live_count() >= 2);
305
291
}
306
292
▼crates/justapi-inference/tests/engine.rs
+6-27
17
17
#[test]
18
18
fn mock_generate_respects_max_tokens() {
19
19
let model = MockModel::new(26);
20
- let params = SamplingParams {
21
- max_tokens: 10,
22
- ..Default::default()
23
- };
20
+ let params = SamplingParams { max_tokens: 10, ..Default::default() };
24
21
let count = std::cell::Cell::new(0usize);
25
22
let reason = model
26
23
.generate(&[0], &params, &|t| {
37
34
fn mock_generate_stops_on_stop_token() {
38
35
let model = MockModel::new(26);
39
36
// Starting from prompt [0], next is 1 ('b'); stop when we produce token 3.
40
- let params = SamplingParams {
41
- max_tokens: 100,
42
- stop_tokens: vec![3],
43
- ..Default::default()
44
- };
37
+ let params = SamplingParams { max_tokens: 100, stop_tokens: vec![3], ..Default::default() };
45
38
let produced: Vec<u32> = Vec::new();
46
39
let produced = std::sync::Mutex::new(produced);
47
40
let reason = model
73
66
async fn engine_generate_streams_tokens() {
74
67
let engine = Engine::new(EngineDevice::Cpu).unwrap();
75
68
engine.register_mock("demo");
76
- let params = SamplingParams {
77
- max_tokens: 8,
78
- ..Default::default()
79
- };
69
+ let params = SamplingParams { max_tokens: 8, ..Default::default() };
80
70
let mut rx = engine.generate("demo", &[0], params).unwrap();
81
71
82
72
let mut count = 0;
127
117
let draft = std::sync::Arc::new(MockModel::new(32));
128
118
engine.register_speculative("spec", target, draft, 4, 7);
129
119
130
- let params = SamplingParams {
131
- max_tokens: 20,
132
- temperature: 0.0,
133
- ..Default::default()
134
- };
120
+ let params = SamplingParams { max_tokens: 20, temperature: 0.0, ..Default::default() };
135
121
136
122
// Collect the speculatively-decoded stream.
137
123
let mut spec_rx = engine.generate("spec", &[0], params.clone()).unwrap();
148
134
while let Some(tok) = plain_rx.blocking_recv() {
149
135
plain_ids.push(tok.id);
150
136
}
151
- assert_eq!(
152
- spec_ids, plain_ids,
153
- "speculation must not change the output"
154
- );
137
+ assert_eq!(spec_ids, plain_ids, "speculation must not change the output");
155
138
}
156
139
157
140
#[test]
159
142
// Direct verify of AcceptanceStats with a perfect draft.
160
143
let target = MockModel::new(32);
161
144
let draft = MockModel::new(32);
162
- let params = SamplingParams {
163
- max_tokens: 30,
164
- temperature: 0.0,
165
- ..Default::default()
166
- };
145
+ let params = SamplingParams { max_tokens: 30, temperature: 0.0, ..Default::default() };
167
146
let out = std::cell::RefCell::new(Vec::new());
168
147
let (_finish, stats) =
169
148
justapi_inference::speculative_generate(&target, &draft, &[0], &params, 4, 11, &|t| {
170
149
▼crates/justapi-py/Cargo.toml
+5-0
12
12
justapi-core = { workspace = true, features = ["db", "ws"] }
13
13
tokio.workspace = true
14
14
http-body-util.workspace = true
15
+http-body = "1"
15
16
hyper.workspace = true
16
17
http = "1"
17
18
futures = "0.3"
18
19
serde.workspace = true
19
20
serde_json.workspace = true
21
+serde_urlencoded.workspace = true
20
22
tracing.workspace = true
21
23
anyhow.workspace = true
22
24
tokio-util.workspace = true
24
26
tokio-stream.workspace = true
25
27
tokio-tungstenite = "0.24"
26
28
pyo3-async-runtimes = { version = "0.29.0", features = ["tokio", "tokio-runtime"] }
29
+jsonschema = { version = "0.46", default-features = false }
30
+uuid = { version = "1", features = ["v4"] }
31
+
27
32
▼crates/justapi-py/python/justapi/__init__.py
+23-7
27
27
28
28
from . import testing as testing
29
29
from . import tracing as tracing
30
+from . import status as status
30
31
31
-from ._justapi import serve, TokenStreamResponse, WebSocket, Dag, DagNode, RateLimiter, RateLimitResult, Request, UploadFile # type: ignore[import-untyped]
32
+from ._justapi import serve, TokenStreamResponse, ValidatedStreamResponse, Dag, DagNode, RateLimiter, RateLimitResult, Request, HTTPConnection, Headers, QueryParams, URL, State, RequestStream, UploadFile # type: ignore[import-untyped]
32
33
from ._justapi import Database # type: ignore[import-untyped]
33
-from .app import JustAPIApp, Depends, adaptive_batch, APIRouter, Controller, controller, route_get, route_post, route_put, route_patch, route_delete, route_query, route_sse, route_websocket, JustAPITestClient, RequestValidationError
34
+from .app import JustAPIApp, JustAPI, Depends, adaptive_batch, APIRouter, Controller, controller, route_get, route_post, route_put, route_patch, route_delete, route_query, route_sse, route_websocket, JustAPITestClient, RequestValidationError, Session
35
+from .system import build_help, build_openapi, register_system_routes
36
+from .exceptions import HTTPException, WebSocketException
37
+from .websockets import WebSocket, WebSocketState, WebSocketDisconnect
34
38
from .templating import Jinja2Templates
35
39
from .background import BackgroundTasks
36
-from .responses import Response, HTMLResponse, PlainTextResponse, JSONResponse, RedirectResponse, StreamingResponse
40
+from .responses import (
41
+ Response,
42
+ HTMLResponse,
43
+ PlainTextResponse,
44
+ JSONResponse,
45
+ RedirectResponse,
46
+ StreamingResponse,
47
+ FileResponse,
48
+)
37
49
from .params import Param, Path, Query, Header, Cookie, Body, File, Form
38
50
39
51
# ---------------------------------------------------------------------------
155
167
156
168
157
169
__all__ = [
158
- "serve", "JustAPIApp", "Depends", "Database", "Schema", "pydantic_schema",
170
+ "serve", "JustAPIApp", "JustAPI", "Depends", "Database", "Schema", "pydantic_schema",
159
171
"JustAPITestClient", "testing", "tracing", "Jinja2Templates", "BackgroundTasks",
160
- "TokenStreamResponse", "WebSocket", "Dag", "DagNode", "RateLimiter", "RateLimitResult",
172
+ "TokenStreamResponse", "ValidatedStreamResponse", "WebSocket", "Dag", "DagNode", "RateLimiter", "RateLimitResult",
161
173
"adaptive_batch", "APIRouter", "Controller", "controller",
162
174
"route_get", "route_post", "route_put", "route_patch", "route_delete", "route_query",
163
- "route_sse", "route_websocket", "Request", "Response", "HTMLResponse",
175
+ "route_sse", "route_websocket", "Request", "HTTPConnection", "Headers",
176
+ "QueryParams", "URL", "State", "Session", "RequestStream", "Response", "HTMLResponse",
164
177
"PlainTextResponse", "JSONResponse", "RedirectResponse", "StreamingResponse",
178
+ "FileResponse",
165
179
"UploadFile", "RequestValidationError", "Param", "Path", "Query", "Header",
166
- "Cookie", "Body", "File", "Form"
180
+ "Cookie", "Body", "File", "Form", "status", "HTTPException", "WebSocketException",
181
+ "WebSocketState", "WebSocketDisconnect",
182
+ "build_help", "build_openapi", "register_system_routes"
167
183
]
168
184
▼crates/justapi-py/python/justapi/__init__.pyi
+2-2
1
-from . import testing as testing, tracing as tracing
1
+from . import testing as testing, tracing as tracing, status as status
2
2
from ._justapi import Dag as Dag, DagNode as DagNode, Database as Database, RateLimitResult as RateLimitResult, RateLimiter as RateLimiter, Request as Request, TokenStreamResponse as TokenStreamResponse, WebSocket as WebSocket, serve as serve
3
3
from .app import APIRouter as APIRouter, Controller as Controller, Depends as Depends, JustAPIApp as JustAPIApp, JustAPITestClient as JustAPITestClient, adaptive_batch as adaptive_batch, controller as controller, route_delete as route_delete, route_get as route_get, route_post as route_post, route_put as route_put, route_query as route_query, route_sse as route_sse, route_websocket as route_websocket
4
4
from .background import BackgroundTasks as BackgroundTasks
5
5
from .responses import HTMLResponse as HTMLResponse, JSONResponse as JSONResponse, PlainTextResponse as PlainTextResponse, RedirectResponse as RedirectResponse, Response as Response, StreamingResponse as StreamingResponse
6
6
from .templating import Jinja2Templates as Jinja2Templates
7
7
8
-__all__ = ['serve', 'JustAPIApp', 'Depends', 'Database', 'Schema', 'pydantic_schema', 'JustAPITestClient', 'testing', 'tracing', 'Jinja2Templates', 'BackgroundTasks', 'TokenStreamResponse', 'WebSocket', 'Dag', 'DagNode', 'RateLimiter', 'RateLimitResult', 'adaptive_batch', 'APIRouter', 'Controller', 'controller', 'route_get', 'route_post', 'route_put', 'route_delete', 'route_query', 'route_sse', 'route_websocket', 'Request', 'Response', 'HTMLResponse', 'PlainTextResponse', 'JSONResponse', 'RedirectResponse', 'StreamingResponse']
8
+__all__ = ['serve', 'JustAPIApp', 'Depends', 'Database', 'Schema', 'pydantic_schema', 'JustAPITestClient', 'testing', 'tracing', 'Jinja2Templates', 'BackgroundTasks', 'TokenStreamResponse', 'WebSocket', 'Dag', 'DagNode', 'RateLimiter', 'RateLimitResult', 'adaptive_batch', 'APIRouter', 'Controller', 'controller', 'route_get', 'route_post', 'route_put', 'route_delete', 'route_query', 'route_sse', 'route_websocket', 'Request', 'Response', 'HTMLResponse', 'PlainTextResponse', 'JSONResponse', 'RedirectResponse', 'StreamingResponse', 'status']
9
9
10
10
class Schema:
11
11
def __init_subclass__(cls, **kwargs) -> None: ...
12
12
▼crates/justapi-py/python/justapi/_justapi.pyi
+297-1
1
-def serve(*args, **kwargs): ...
1
+"""Type stubs for the compiled justapi._justapi extension (auto-generated from runtime introspection)."""
2
+from _typeshed import Incomplete
3
+from typing import Any, Optional, Iterator, Mapping, List, Dict
4
+
5
+def serve(addr: str) -> None: ...
6
+
7
+class BackgroundTasks:
8
+ def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
9
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
10
+ def add_task(self, func: Any, *args: Any, **kwargs: Any) -> Any: ...
11
+ def run(self, ) -> Any: ...
12
+ def shutdown(self, wait: Any) -> Any: ...
13
+ def stats(self, ) -> Any: ...
14
+ def task_count(self, ) -> Any: ...
15
+
16
+class Dag:
17
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
18
+ def execute(self, inputs: Any) -> Any: ...
19
+
20
+class DagNode:
21
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
22
+
23
+class Database:
24
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
25
+ @property
26
+ def max_connections(self) -> Any: ...
27
+ @property
28
+ def url(self) -> Any: ...
29
+
30
+class HTTPConnection:
31
+ def __contains__(self, key: Any) -> Any: ...
32
+ def __getitem__(self, key: Any) -> Any: ...
33
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
34
+ def __setitem__(self, key: Any, value: Any) -> Any: ...
35
+ @property
36
+ def app(self) -> Any: ...
37
+ @property
38
+ def auth(self) -> Any: ...
39
+ @property
40
+ def base_url(self) -> Any: ...
41
+ @property
42
+ def client(self) -> Any: ...
43
+ @property
44
+ def cookies(self) -> Any: ...
45
+ def get(self, key: Any, default: Any = ...) -> Any: ...
46
+ @property
47
+ def headers(self) -> Any: ...
48
+ @property
49
+ def http_version(self) -> Any: ...
50
+ @property
51
+ def method(self) -> Any: ...
52
+ @property
53
+ def path(self) -> Any: ...
54
+ @property
55
+ def path_params(self) -> Any: ...
56
+ @property
57
+ def query_params(self) -> Any: ...
58
+ @property
59
+ def query_string(self) -> Any: ...
60
+ def receive(self, ) -> Any: ...
61
+ @property
62
+ def scheme(self) -> Any: ...
63
+ @property
64
+ def scope(self) -> Any: ...
65
+ @property
66
+ def session(self) -> Any: ...
67
+ @property
68
+ def state(self) -> Any: ...
69
+ @property
70
+ def url(self) -> Any: ...
71
+ def url_for(self, name: Any, **kwargs: Any) -> Any: ...
72
+ @property
73
+ def user(self) -> Any: ...
74
+
75
+class Headers:
76
+ def __contains__(self, key: Any) -> Any: ...
77
+ def __getitem__(self, key: Any) -> Any: ...
78
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
79
+ def get(self, key: Any, default: Any = ...) -> Any: ...
80
+ def items(self, ) -> Any: ...
81
+ def keys(self, ) -> Any: ...
82
+ def mutable(self, ) -> Any: ...
83
+ @property
84
+ def raw(self) -> Any: ...
85
+ def values(self, ) -> Any: ...
86
+
87
+class JustAPITestClient:
88
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
89
+ def delete(self, path: Any) -> Any: ...
90
+ def get(self, path: Any) -> Any: ...
91
+ def post(self, path: Any, body: Any) -> Any: ...
92
+ def post_with(self, path: Any, body: Any, headers: Any) -> Any: ...
93
+ def put(self, path: Any, body: Any) -> Any: ...
94
+
95
+class QueryParams:
96
+ def __contains__(self, key: Any) -> Any: ...
97
+ def __getitem__(self, key: Any) -> Any: ...
98
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
99
+ def get(self, key: Any, default: Any = ...) -> Any: ...
100
+ def getlist(self, key: Any) -> Any: ...
101
+ def items(self, ) -> Any: ...
102
+ def keys(self, ) -> Any: ...
103
+ def multi_items(self, ) -> Any: ...
104
+ def mutable(self, ) -> Any: ...
105
+ def values(self, ) -> Any: ...
106
+
107
+class RateLimitResult:
108
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
109
+ @property
110
+ def allowed(self) -> Any: ...
111
+ @property
112
+ def retry_after_ms(self) -> Any: ...
113
+
114
+class RateLimiter:
115
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
116
+ def check_limit(self, key: Any, capacity: Any, replenish_rate: Any, tokens: Any) -> Any: ...
117
+ def new_redis(self, redis_url: Any) -> Any: ...
118
+
119
+class Request:
120
+ def __contains__(self, key: Any) -> Any: ...
121
+ def __getitem__(self, key: Any) -> Any: ...
122
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
123
+ def __setitem__(self, key: Any, value: Any) -> Any: ...
124
+ @property
125
+ def app(self) -> Any: ...
126
+ @property
127
+ def auth(self) -> Any: ...
128
+ @property
129
+ def base_url(self) -> Any: ...
130
+ def body(self, ) -> Any: ...
131
+ @property
132
+ def client(self) -> Any: ...
133
+ def close(self, ) -> Any: ...
134
+ @property
135
+ def cookies(self) -> Any: ...
136
+ def form(self, ) -> Any: ...
137
+ def get(self, key: Any, default: Any = ...) -> Any: ...
138
+ @property
139
+ def headers(self) -> Any: ...
140
+ @property
141
+ def http_version(self) -> Any: ...
142
+ def is_disconnected(self, ) -> Any: ...
143
+ def json(self, ) -> Any: ...
144
+ @property
145
+ def method(self) -> Any: ...
146
+ @property
147
+ def path(self) -> Any: ...
148
+ @property
149
+ def path_params(self) -> Any: ...
150
+ @property
151
+ def query_params(self) -> Any: ...
152
+ @property
153
+ def query_string(self) -> Any: ...
154
+ def receive(self, ) -> Any: ...
155
+ @property
156
+ def scheme(self) -> Any: ...
157
+ @property
158
+ def scope(self) -> Any: ...
159
+ def send_push_promise(self, path: Any) -> Any: ...
160
+ @property
161
+ def session(self) -> Any: ...
162
+ @property
163
+ def state(self) -> Any: ...
164
+ def stream(self, ) -> Any: ...
165
+ @property
166
+ def url(self) -> Any: ...
167
+ def url_for(self, name: Any, **kwargs: Any) -> Any: ...
168
+ @property
169
+ def user(self) -> Any: ...
170
+
171
+class RequestStream:
172
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
173
+
174
+class State:
175
+ def __contains__(self, key: Any) -> Any: ...
176
+ def __getitem__(self, key: Any) -> Any: ...
177
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
178
+ def __setitem__(self, key: Any, value: Any) -> Any: ...
179
+
180
+class TokenStreamResponse:
181
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
182
+
183
+class URL:
184
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
185
+ @property
186
+ def fragment(self) -> Any: ...
187
+ @property
188
+ def host(self) -> Any: ...
189
+ @property
190
+ def hostname(self) -> Any: ...
191
+ @property
192
+ def path(self) -> Any: ...
193
+ @property
194
+ def port(self) -> Any: ...
195
+ @property
196
+ def query(self) -> Any: ...
197
+ @property
198
+ def scheme(self) -> Any: ...
199
+
200
+class UploadFile:
201
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
202
+ def close(self, ) -> Any: ...
203
+ @property
204
+ def content_type(self) -> Any: ...
205
+ @property
206
+ def file(self) -> Any: ...
207
+ @property
208
+ def filename(self) -> Any: ...
209
+ @property
210
+ def headers(self) -> Any: ...
211
+ def read(self, size: Any = ...) -> Any: ...
212
+ def seek(self, offset: Any) -> Any: ...
213
+ @property
214
+ def size(self) -> Any: ...
215
+ def write(self, data: Any) -> Any: ...
216
+
217
+class WebSocket:
218
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
219
+ def accept(self, subprotocol: Any = ..., headers: Any = ...) -> Any: ...
220
+ @property
221
+ def app(self) -> Any: ...
222
+ @property
223
+ def application_state(self) -> Any: ...
224
+ @property
225
+ def base_url(self) -> Any: ...
226
+ @property
227
+ def client(self) -> Any: ...
228
+ @property
229
+ def client_state(self) -> Any: ...
230
+ def close(self, code: Any = ..., reason: Any = ...) -> Any: ...
231
+ @property
232
+ def cookies(self) -> Any: ...
233
+ @property
234
+ def headers(self) -> Any: ...
235
+ @property
236
+ def path_params(self) -> Any: ...
237
+ @property
238
+ def query_params(self) -> Any: ...
239
+ def receive(self, ) -> Any: ...
240
+ def receive_bytes(self, ) -> Any: ...
241
+ def receive_json(self, mode: Any = ...) -> Any: ...
242
+ def receive_text(self, ) -> Any: ...
243
+ def send(self, message: Any) -> Any: ...
244
+ def send_bytes(self, message: Any) -> Any: ...
245
+ def send_json(self, data: Any, mode: Any = ...) -> Any: ...
246
+ def send_text(self, message: Any) -> Any: ...
247
+ @property
248
+ def state(self) -> Any: ...
249
+ @property
250
+ def subprotocol(self) -> Any: ...
251
+ @property
252
+ def url(self) -> Any: ...
253
+ def url_for(self, name: Any, **kwargs: Any) -> Any: ...
254
+
255
+class WebSocketState:
256
+ @property
257
+ def CONNECTED(self) -> Any: ...
258
+ @property
259
+ def CONNECTING(self) -> Any: ...
260
+ @property
261
+ def DISCONNECTED(self) -> Any: ...
262
+ @property
263
+ def RESPONSE(self) -> Any: ...
264
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
265
+
266
+class _JustAPIApp:
267
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
268
+ def add_grpc_service(self, path: Any, handler: Any) -> Any: ...
269
+ def delete(self, path: Any, handler: Any, tags: Any = ..., summary: Any = ..., description: Any = ..., deprecated: Any = ..., status_code: Any = ..., responses: Any = ..., operation_id: Any = ..., openapi_extra: Any = ..., include_in_schema: Any = ..., name: Any = ...) -> Any: ...
270
+ def enable_circuit_breaker(self, failure_threshold: Any, reset_timeout_ms: Any) -> Any: ...
271
+ def enable_gateway(self, path: Any) -> Any: ...
272
+ def enable_request_coalescing(self, headers: Any) -> Any: ...
273
+ def frontend(self, path: Any, directory: Any, fallback: Any = ..., check_dir: Any = ...) -> Any: ...
274
+ def get(self, path: Any, handler: Any, tags: Any = ..., summary: Any = ..., description: Any = ..., deprecated: Any = ..., status_code: Any = ..., responses: Any = ..., operation_id: Any = ..., openapi_extra: Any = ..., include_in_schema: Any = ..., name: Any = ...) -> Any: ...
275
+ def get_database(self, ) -> Any: ...
276
+ def head(self, path: Any, handler: Any, tags: Any = ..., summary: Any = ..., description: Any = ..., deprecated: Any = ..., status_code: Any = ..., responses: Any = ..., operation_id: Any = ..., openapi_extra: Any = ..., include_in_schema: Any = ..., name: Any = ...) -> Any: ...
277
+ def load_wasm_middleware(self, path: Any) -> Any: ...
278
+ def options(self, path: Any, handler: Any, tags: Any = ..., summary: Any = ..., description: Any = ..., deprecated: Any = ..., status_code: Any = ..., responses: Any = ..., operation_id: Any = ..., openapi_extra: Any = ..., include_in_schema: Any = ..., name: Any = ...) -> Any: ...
279
+ def patch(self, path: Any, handler: Any, body_schema: Any = ..., schema: Any = ..., batch_size: Any = ..., batch_window_ms: Any = ..., tags: Any = ..., summary: Any = ..., description: Any = ..., deprecated: Any = ..., status_code: Any = ..., responses: Any = ..., operation_id: Any = ..., openapi_extra: Any = ..., include_in_schema: Any = ..., name: Any = ...) -> Any: ...
280
+ def post(self, path: Any, handler: Any, body_schema: Any = ..., schema: Any = ..., batch_size: Any = ..., batch_window_ms: Any = ..., tags: Any = ..., summary: Any = ..., description: Any = ..., deprecated: Any = ..., status_code: Any = ..., responses: Any = ..., operation_id: Any = ..., openapi_extra: Any = ..., include_in_schema: Any = ..., name: Any = ...) -> Any: ...
281
+ def put(self, path: Any, handler: Any, body_schema: Any = ..., schema: Any = ..., batch_size: Any = ..., batch_window_ms: Any = ..., tags: Any = ..., summary: Any = ..., description: Any = ..., deprecated: Any = ..., status_code: Any = ..., responses: Any = ..., operation_id: Any = ..., openapi_extra: Any = ..., include_in_schema: Any = ..., name: Any = ...) -> Any: ...
282
+ def query(self, path: Any, handler: Any, body_schema: Any = ..., schema: Any = ..., experimental: Any = ..., tags: Any = ..., summary: Any = ..., description: Any = ..., deprecated: Any = ..., status_code: Any = ..., responses: Any = ..., operation_id: Any = ..., openapi_extra: Any = ..., include_in_schema: Any = ..., name: Any = ...) -> Any: ...
283
+ def run(self, addr: Any) -> Any: ...
284
+ def set_database(self, db: Any) -> Any: ...
285
+ def set_grpc_addr(self, addr: Any) -> Any: ...
286
+ def store_meta(self, method: Any, path: Any, body_schema: Any, response_schema_json: Any, tags: Any, summary: Any, description: Any, deprecated: Any, status_code: Any, responses: Any, operation_id: Any, openapi_extra: Any, experimental: Any, include_in_schema: Any) -> Any: ...
287
+ def trace(self, path: Any, handler: Any, tags: Any = ..., summary: Any = ..., description: Any = ..., deprecated: Any = ..., status_code: Any = ..., responses: Any = ..., operation_id: Any = ..., openapi_extra: Any = ..., include_in_schema: Any = ..., name: Any = ...) -> Any: ...
288
+ def url_for(self, name: Any, **kwargs: Any) -> Any: ...
289
+ def use(self, plugin: Any) -> Any: ...
290
+ def websocket(self, path: Any, handler: Any) -> Any: ...
291
+
292
+class _StreamSender:
293
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
294
+ def close(self, ) -> Any: ...
295
+ def send(self, data: Any) -> Any: ...
296
+ def send_error(self, msg: Any) -> Any: ...
297
+
2
298
▼crates/justapi-py/python/justapi/_native_helper.py
+75-7
6
6
import threading
7
7
import traceback
8
8
9
+try:
10
+ import orjson
11
+ _dumps = lambda obj: orjson.dumps(obj, default=str)
12
+except Exception: # pragma: no cover - orjson is optional
13
+ _dumps = lambda obj: json.dumps(obj, default=str).encode("utf-8")
14
+
15
+from justapi._justapi import validate_value
16
+
9
17
_trace_id_var = contextvars.ContextVar("trace_id", default=None)
10
18
_span_id_var = contextvars.ContextVar("span_id", default=None)
11
19
41
49
return _loop
42
50
43
51
def wrap_result(result):
52
+ # Streaming responses are returned unwrapped so the Rust side can pump the
53
+ # generator into an SSE / TokenStream / validated-stream response.
54
+ if type(result).__name__ in ("TokenStreamResponse", "StreamingResponse", "ValidatedStreamResponse"):
55
+ return result
56
+
57
+ # Schedule response-attached background tasks after the response is built.
58
+ bg = getattr(result, "background", None)
59
+ if bg is not None:
60
+ bg.run()
61
+
44
62
if hasattr(result, "to_dict"):
45
63
return result.to_dict()
46
64
if isinstance(result, dict) and ("body" in result or "status" in result):
48
66
result["body"] = result["body"].encode("utf-8")
49
67
return result
50
68
51
- body = json.dumps(result, default=str).encode("utf-8")
69
+ body = _dumps(result)
70
+ if isinstance(body, str):
71
+ body = body.encode("utf-8")
52
72
return {
53
73
"status": 200,
54
74
"headers": [(b"content-type", b"application/json")],
56
76
}
57
77
58
78
def call_handler(handler, request):
59
- """Call a native Python handler with a request dict.
79
+ """Call a native Python handler with a request object.
60
80
61
- The handler may be sync or async. Returns a response dict
62
- with "status", "headers", and "body" keys.
81
+ Returns the handler's raw return value (dict, list, str, ``Response``,
82
+ streaming response, …). Response serialization is performed on the Rust
83
+ side (``serialize_response``), which mirrors Robyn's fast path and avoids a
84
+ Python ``wrap_result`` round-trip on every request.
63
85
"""
64
86
try:
65
87
result = handler(request)
68
90
return asyncio.run_coroutine_threadsafe(result, loop)
69
91
70
92
# Streaming responses are returned unwrapped so the Rust side can
71
- # pump the generator into an SSE / TokenStream response.
72
- if type(result).__name__ in ("TokenStreamResponse", "StreamingResponse"):
93
+ # pump the generator into an SSE / TokenStream / validated-stream response.
94
+ if type(result).__name__ in ("TokenStreamResponse", "StreamingResponse", "ValidatedStreamResponse"):
73
95
return result
74
96
75
- return wrap_result(result)
97
+ return result
76
98
except Exception as e:
77
99
print(f"ERROR in call_handler: {repr(e)}")
78
100
traceback.print_exc()
137
159
loop = _get_loop()
138
160
asyncio.run_coroutine_threadsafe(pump(), loop)
139
161
162
+
163
+def _pump_validated_stream(generator, schema_json, sender, mode="ndjson"):
164
+ """Pump a generator of JSON-serialisable objects, validating each against
165
+ `schema_json` (in Rust) before forwarding. Invalid items abort the stream.
166
+
167
+ `mode` is "ndjson" (one object per line) or "array" (a single JSON array).
168
+ Sync and async generators are both supported.
169
+ """
170
+ async def pump():
171
+ # Support both sync and async generators.
172
+ if hasattr(generator, "__aiter__"):
173
+ agen = generator
174
+ else:
175
+ async def awrap():
176
+ for item in generator:
177
+ yield item
178
+ agen = awrap()
179
+ try:
180
+ if mode == "array":
181
+ sender.send(b"[")
182
+ first = True
183
+ async for item in agen:
184
+ item_json = json.dumps(item, default=str)
185
+ errors = validate_value(schema_json, item_json)
186
+ if errors:
187
+ sender.send_error("validation failed: " + "; ".join(errors))
188
+ return
189
+ if mode == "array":
190
+ if not first:
191
+ sender.send(b",")
192
+ sender.send(item_json.encode("utf-8"))
193
+ first = False
194
+ else:
195
+ sender.send((item_json + "\n").encode("utf-8"))
196
+ if mode == "array":
197
+ sender.send(b"]")
198
+ except Exception as e:
199
+ import traceback as _tb
200
+ _tb.print_exc()
201
+ sender.send_error(str(e))
202
+ finally:
203
+ sender.close()
204
+
205
+ loop = _get_loop()
206
+ asyncio.run_coroutine_threadsafe(pump(), loop)
207
+
140
208
def call_plugin_hook(plugin, hook_name):
141
209
"""Call a plugin lifecycle hook (sync or async)."""
142
210
if not hasattr(plugin, hook_name):
143
211
▼crates/justapi-py/python/justapi/app.py
+730-101
1
1
import inspect
2
2
import functools
3
+import json
4
+import re
3
5
import typing
4
-from ._justapi import _JustAPIApp, TokenStreamResponse
6
+from urllib.parse import quote
7
+from ._justapi import _JustAPIApp, TokenStreamResponse, ValidatedStreamResponse
8
+from .exceptions import WebSocketException
9
+from .websockets import WebSocket
10
+from .system import register_system_routes, build_help, build_openapi
11
+from .responses import PlainTextResponse
12
+
13
+_PY_TYPE_TO_JSON = {
14
+ str: "string",
15
+ int: "integer",
16
+ float: "number",
17
+ bool: "boolean",
18
+ list: "array",
19
+ dict: "object",
20
+ type(None): "null",
21
+}
22
+
23
+
24
+# Built-in health/observability handlers. Registered on every JustAPIApp so the
25
+# Python app exposes Kubernetes probe + metrics endpoints without relying on the
26
+# Rust server's default-route registry (which is bypassed for Python apps, where
27
+# routing happens in the Python handler). The Rust server emits richer Prometheus
28
+# metrics via `with_health_registry` when used standalone.
29
+def _builtin_health():
30
+ return {"status": "ok", "components": {}}
31
+
32
+
33
+def _builtin_live():
34
+ return {"status": "alive"}
35
+
36
+
37
+def _builtin_ready():
38
+ return {"status": "ready"}
39
+
40
+
41
+def _builtin_metrics():
42
+ body = (
43
+ "# HELP justapi_up 1 if the server process is up.\n"
44
+ "# TYPE justapi_up gauge\n"
45
+ "justapi_up 1\n"
46
+ )
47
+ return PlainTextResponse(body)
48
+
49
+
50
+def _model_json_schema(ann):
51
+ """Best-effort JSON schema from a Pydantic / dataclass / model annotation."""
52
+ try:
53
+ if hasattr(ann, "model_json_schema"):
54
+ return ann.model_json_schema()
55
+ except Exception:
56
+ pass
57
+ try:
58
+ if hasattr(ann, "__pydantic_model__"):
59
+ return ann.__pydantic_model__.schema()
60
+ except Exception:
61
+ pass
62
+ try:
63
+ import dataclasses
64
+
65
+ if dataclasses.is_dataclass(ann):
66
+ props, required = {}, []
67
+ for f in dataclasses.fields(ann):
68
+ props[f.name] = {"type": _PY_TYPE_TO_JSON.get(f.type, "string")}
69
+ if f.default is dataclasses.MISSING:
70
+ required.append(f.name)
71
+ return {"type": "object", "properties": props, "required": required}
72
+ except Exception:
73
+ pass
74
+ return None
75
+
76
+
77
+def _infer_tool_schema(func):
78
+ """Infer a JSON Schema from a callable's signature / type hints."""
79
+ sig = inspect.signature(func)
80
+ hints = typing.get_type_hints(func)
81
+ props, required = {}, []
82
+ for pname, p in sig.parameters.items():
83
+ if pname == "self":
84
+ continue
85
+ ann = hints.get(pname, p.annotation)
86
+ if ann is inspect.Parameter.empty:
87
+ ann = str
88
+ schema = _model_json_schema(ann)
89
+ if schema is None:
90
+ schema = {"type": _PY_TYPE_TO_JSON.get(ann, "string")}
91
+ props[pname] = schema
92
+ if p.default is inspect.Parameter.empty:
93
+ required.append(pname)
94
+ return {
95
+ "type": "object",
96
+ "properties": props,
97
+ "required": required,
98
+ }
99
+
5
100
6
101
class RequestValidationError(Exception):
7
102
def __init__(self, errors):
8
103
self.errors = errors
104
+ self.detail = errors
9
105
super().__init__(str(errors))
10
106
11
107
class Depends:
13
109
self.dependency = dependency
14
110
self.use_cache = use_cache
15
111
112
+
113
+class Session:
114
+ """Agent session state, backed by the Rust session store on the app.
115
+
116
+ Obtain one by declaring a ``session: Session`` parameter on a route handler
117
+ (the id is read from the ``justapi_session`` cookie or ``?session=`` query
118
+ param) or by calling ``app.create_session()`` directly.
119
+ """
120
+
121
+ def __init__(self, app: "JustAPIApp", session_id: str):
122
+ self._app = app
123
+ self.id = session_id
124
+
125
+ def get(self):
126
+ return self._app.get_session(self.id)
127
+
128
+ def set(self, data: dict):
129
+ return self._app.set_session(self.id, data)
130
+
131
+ def update(self, **fields):
132
+ return self._app.update_session(self.id, **fields)
133
+
134
+ def delete(self):
135
+ return self._app.delete_session(self.id)
136
+
137
+
16
138
def adaptive_batch(max_size: int = 32, window_ms: int = 10):
17
139
"""
18
140
Decorator to enable adaptive batching for this route.
30
152
self.dependencies = dependencies or []
31
153
self.exception_handlers = {}
32
154
self.middlewares = []
155
+ self._named_routes = {}
156
+ self.routes = []
157
+ self.title = "JustAPIApp"
158
+ self.version = "1.0.0"
159
+
160
+ # Built-in probe/metrics endpoints for the Python app.
161
+ self.get("/health", _builtin_health, include_in_schema=False, name="builtin_health")
162
+ self.get("/live", _builtin_live, include_in_schema=False, name="builtin_live")
163
+ self.get("/ready", _builtin_ready, include_in_schema=False, name="builtin_ready")
164
+ self.get("/metrics", _builtin_metrics, include_in_schema=False, name="builtin_metrics")
165
+
166
+ def _record(self, method, path, handler, *, body_schema=None, schema=None,
167
+ experimental=False, **kw):
168
+ """Record a route's metadata on the Python side for introspection.
169
+
170
+ The Rust runtime owns actual dispatch; this list powers
171
+ ``build_help`` / ``build_openapi`` and the ``/_system`` endpoints.
172
+ """
173
+ name = kw.get("name")
174
+ if name is not None:
175
+ self._named_routes[name] = path
176
+ self.routes.append({
177
+ "method": method,
178
+ "path": path,
179
+ "handler": handler,
180
+ "body_schema": body_schema,
181
+ "schema": schema,
182
+ "experimental": experimental,
183
+ "dependencies": [],
184
+ "middlewares": [],
185
+ "tags": kw.get("tags") or [],
186
+ "summary": kw.get("summary"),
187
+ "description": kw.get("description"),
188
+ "deprecated": kw.get("deprecated"),
189
+ "status_code": kw.get("status_code"),
190
+ "responses": kw.get("responses"),
191
+ "operation_id": kw.get("operation_id"),
192
+ "openapi_extra": kw.get("openapi_extra"),
193
+ "include_in_schema": kw.get("include_in_schema", True),
194
+ "name": name,
195
+ })
33
196
34
197
def middleware(self, middleware_type: str = "http"):
35
198
def decorator(func):
96
259
request["_dep_cache"][param.default.dependency] = res
97
260
kwargs[name] = res
98
261
99
- elif name == "request" or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "Request"):
262
+ elif name in ("request", "req", "r", "_request") or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "Request"):
100
263
kwargs[name] = request
264
+ elif (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "Session") or name == "session":
265
+ sid = (request.cookies).get(getattr(self, "_session_cookie", "justapi_session"))
266
+ if sid is None:
267
+ sid = request.query_params.get("session")
268
+ if sid is None:
269
+ if getattr(self, "_sessions_enabled", False):
270
+ sid = self.create_session()
271
+ request["_session_id"] = sid
272
+ elif getattr(self, "_sessions_enabled", False) and self.get_session(sid) is None:
273
+ self.create_session(id=sid)
274
+ request["_session_id"] = sid
275
+ kwargs[name] = Session(self, sid)
101
276
else:
102
277
from .params import Param, Path, Query, Header, Cookie, Body, File, Form
103
-
278
+
104
279
alias = name
105
280
param_type = None
106
281
default = inspect.Parameter.empty
107
-
282
+
108
283
if isinstance(param.default, Param):
109
284
alias = param.default.alias or name
110
285
param_type = type(param.default)
115
290
val = None
116
291
found = False
117
292
118
- if param_type is Path or (param_type is None and alias in request.get("path_params", {})):
119
- val = request.get("path_params", {}).get(alias)
293
+ if param_type is Path or (param_type is None and alias in request.path_params):
294
+ val = request.path_params.get(alias)
120
295
if val is not None: found = True
121
-
122
- if not found and (param_type is Query or (param_type is None and alias in request.get("query_params", {}))):
123
- val = request.get("query_params", {}).get(alias)
296
+
297
+ if not found and (param_type is Query or (param_type is None and alias in request.query_params)):
298
+ val = request.query_params.get(alias)
124
299
if val is not None: found = True
125
-
300
+
126
301
if not found and param_type is Header:
127
- headers = request.get("headers", [])
128
- if isinstance(headers, list):
129
- for hk, hv in headers:
130
- if hk.decode("utf-8").lower() == alias.lower():
131
- val = hv.decode("utf-8")
132
- found = True
133
- break
134
-
302
+ val = request.headers.get(alias)
303
+ if val is not None:
304
+ found = True
305
+
135
306
if not found and param_type is Cookie:
136
- val = request.get("cookies", {}).get(alias)
307
+ val = request.cookies.get(alias)
137
308
if val is not None: found = True
138
309
139
310
if not found and (param_type is File or param_type is Form or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "UploadFile")):
206
377
request["_dep_cache"][param.default.dependency] = res
207
378
kwargs[name] = res
208
379
209
- elif name == "request" or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "Request"):
380
+ elif name in ("request", "req", "r", "_request") or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "Request"):
210
381
kwargs[name] = request
382
+ elif (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "Session") or name == "session":
383
+ sid = (request.cookies).get(getattr(self, "_session_cookie", "justapi_session"))
384
+ if sid is None:
385
+ sid = request.query_params.get("session")
386
+ if sid is None:
387
+ if getattr(self, "_sessions_enabled", False):
388
+ sid = self.create_session()
389
+ request["_session_id"] = sid
390
+ elif getattr(self, "_sessions_enabled", False) and self.get_session(sid) is None:
391
+ self.create_session(id=sid)
392
+ request["_session_id"] = sid
393
+ kwargs[name] = Session(self, sid)
211
394
else:
212
395
from .params import Param, Path, Query, Header, Cookie, Body, File, Form
213
-
396
+
214
397
alias = name
215
398
param_type = None
216
399
default = inspect.Parameter.empty
217
-
400
+
218
401
if isinstance(param.default, Param):
219
402
alias = param.default.alias or name
220
403
param_type = type(param.default)
225
408
val = None
226
409
found = False
227
410
228
- if param_type is Path or (param_type is None and alias in request.get("path_params", {})):
229
- val = request.get("path_params", {}).get(alias)
411
+ if param_type is Path or (param_type is None and alias in request.path_params):
412
+ val = request.path_params.get(alias)
230
413
if val is not None: found = True
231
-
232
- if not found and (param_type is Query or (param_type is None and alias in request.get("query_params", {}))):
233
- val = request.get("query_params", {}).get(alias)
414
+
415
+ if not found and (param_type is Query or (param_type is None and alias in request.query_params)):
416
+ val = request.query_params.get(alias)
234
417
if val is not None: found = True
235
-
418
+
236
419
if not found and param_type is Header:
237
- headers = request.get("headers", [])
238
- if isinstance(headers, list):
239
- for hk, hv in headers:
240
- if hk.decode("utf-8").lower() == alias.lower():
241
- val = hv.decode("utf-8")
242
- found = True
243
- break
244
-
420
+ val = request.headers.get(alias)
421
+ if val is not None:
422
+ found = True
423
+
245
424
if not found and param_type is Cookie:
246
- val = request.get("cookies", {}).get(alias)
425
+ val = request.cookies.get(alias)
247
426
if val is not None: found = True
248
427
249
428
if not found and (param_type is File or param_type is Form or (hasattr(param.annotation, "__name__") and param.annotation.__name__ == "UploadFile")):
293
472
chain = make_wrapper(mw, chain)
294
473
return chain
295
474
296
- def _wrap_handler(self, handler, route_dependencies=None, route_middlewares=None):
475
+ def _wrap_handler(self, handler, route_dependencies=None, route_middlewares=None, native=False):
476
+ # The native Rust fast path validates + echoes the request and returns
477
+ # without ever calling the Python handler, so it would silently SKIP any
478
+ # route- or app-level `dependencies` (e.g. auth) and `middlewares`. That
479
+ # is a security hole, so refuse the combination outright. (App-level
480
+ # middleware is also bypassed by the native fast path by design — it is a
481
+ # validate-and-echo shortcut — so do not rely on auth running there.)
482
+ if native and (
483
+ self.dependencies
484
+ or route_dependencies
485
+ or self.middlewares
486
+ or route_middlewares
487
+ ):
488
+ raise ValueError(
489
+ "native=True cannot be combined with dependencies or route "
490
+ "middlewares: the native fast path validates and echoes the "
491
+ "request without invoking the handler, so those would be "
492
+ "silently bypassed. Use a non-native route, or move the logic "
493
+ "into the native validation schema."
494
+ )
495
+
297
496
sig = inspect.signature(handler)
298
497
route_deps = route_dependencies or []
299
498
all_deps = self.dependencies + route_deps
300
-
499
+
301
500
is_async = inspect.iscoroutinefunction(handler)
302
501
for name, param in sig.parameters.items():
303
502
if isinstance(param.default, Depends):
312
511
p.annotation.__name__ == "BackgroundTasks" for p in sig.parameters.values() if hasattr(p.annotation, "__name__")
313
512
)
314
513
514
+ # A handler needs the Python `Request` object (and thus one must be
515
+ # built per request) if it takes any parameters, has any dependencies
516
+ # to resolve, or is wrapped by any (route- or app-level) middleware —
517
+ # middleware runs against the request and would break on an empty dict.
518
+ # 0-parameter, dependency-free, middleware-free handlers can skip it.
519
+ needs_request = (
520
+ bool(sig.parameters)
521
+ or bool(all_deps)
522
+ or bool(self.middlewares)
523
+ or bool(route_middlewares)
524
+ )
525
+
315
526
if is_async:
316
527
async def async_wrapper(request):
317
528
try:
330
541
result = handler(**kwargs)
331
542
332
543
if bg_tasks: bg_tasks()
333
- from ._native_helper import wrap_result
334
- return wrap_result(result)
544
+ return result
335
545
except Exception as e:
336
546
return self._handle_exception(request, e)
337
- return self._apply_middlewares(async_wrapper, route_middlewares)
547
+ wrapper = self._apply_middlewares(async_wrapper, route_middlewares)
338
548
else:
339
549
def sync_wrapper(request):
340
550
try:
348
558
kwargs = self._resolve_kwargs_sync(sig, request)
349
559
result = handler(**kwargs)
350
560
if bg_tasks: bg_tasks()
351
- from ._native_helper import wrap_result
352
- return wrap_result(result)
561
+ return result
353
562
except Exception as e:
354
563
return self._handle_exception(request, e)
355
564
361
570
loop = asyncio.get_running_loop()
362
571
ctx = contextvars.copy_context()
363
572
return await loop.run_in_executor(None, ctx.run, sync_wrapper, request)
364
- return self._apply_middlewares(async_base_handler, route_middlewares)
365
-
366
- return sync_wrapper
573
+ wrapper = self._apply_middlewares(async_base_handler, route_middlewares)
574
+ else:
575
+ wrapper = sync_wrapper
576
+
577
+ wrapper._needs_request = needs_request
578
+ return wrapper
367
579
368
580
def _resolve_batch_config(self, func):
369
581
batch_size = getattr(func, "__batch_size__", None)
370
582
batch_window_ms = getattr(func, "__batch_window_ms__", None)
371
583
return batch_size, batch_window_ms
372
584
373
- def _wrap_batch_handler(self, handler):
585
+ def _wrap_batch_handler(self, handler, native=False):
586
+ if native and (self.dependencies or self.middlewares):
587
+ raise ValueError(
588
+ "native=True cannot be combined with dependencies or route "
589
+ "middlewares: the native fast path validates and echoes the "
590
+ "request without invoking the handler, so those would be "
591
+ "silently bypassed."
592
+ )
374
593
is_async = inspect.iscoroutinefunction(handler)
375
594
if is_async:
376
595
async def async_batch_wrapper(requests):
381
600
return handler(requests)
382
601
return sync_batch_wrapper
383
602
384
- def get(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
603
+ def _meta(self, responses, openapi_extra):
604
+ """Serialize the OpenAPI metadata dicts to JSON strings for the Rust layer."""
605
+ import json
606
+ responses_json = json.dumps(responses) if responses is not None else None
607
+ extra_json = json.dumps(openapi_extra) if openapi_extra is not None else None
608
+ return responses_json, extra_json
609
+
610
+ def _route_kw(self, tags=None, summary=None, description=None, deprecated=None,
611
+ status_code=None, responses=None, operation_id=None, openapi_extra=None,
612
+ include_in_schema=True, name=None):
613
+ responses_json, extra_json = self._meta(responses, openapi_extra)
614
+ return dict(
615
+ tags=tags,
616
+ summary=summary,
617
+ description=description,
618
+ deprecated=deprecated or False,
619
+ status_code=status_code,
620
+ responses=responses_json,
621
+ operation_id=operation_id,
622
+ openapi_extra=extra_json,
623
+ include_in_schema=include_in_schema,
624
+ name=name,
625
+ )
626
+
627
+ def get(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True, query_schema=None, native: bool = False):
628
+ if name is not None: self._named_routes[name] = path
629
+ kw = self._route_kw(tags, summary, description, deprecated, status_code, responses, operation_id, openapi_extra, include_in_schema, name=name)
385
630
if handler is None:
386
631
def decorator(func):
387
- self._app.get(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares))
632
+ self._app.get(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
633
+ self._record("GET", path, func, query_schema=query_schema, native=native, **kw)
388
634
return func
389
635
return decorator
390
- self._app.get(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares))
636
+ self._app.get(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
637
+ self._record("GET", path, handler, query_schema=query_schema, native=native, **kw)
391
638
return handler
392
639
393
- def post(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
640
+ def post(self, path: str, handler=None, body_schema=None, schema=None, query_schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True, native: bool = False):
641
+ if name is not None: self._named_routes[name] = path
642
+ kw = self._route_kw(tags, summary, description, deprecated, status_code, responses, operation_id, openapi_extra, include_in_schema, name=name)
643
+ kw["native"] = native
394
644
if handler is None:
395
645
def decorator(func):
396
646
batch_size, batch_window_ms = self._resolve_batch_config(func)
397
- wrapped = self._wrap_batch_handler(func) if batch_size else self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares)
398
- self._app.post(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms)
647
+ wrapped = self._wrap_batch_handler(func, native=native) if batch_size else self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares, native=native)
648
+ self._app.post(path, wrapped, body_schema, schema, query_schema=query_schema, batch_size=batch_size, batch_window_ms=batch_window_ms, **kw)
649
+ self._record("POST", path, func, body_schema=body_schema, schema=schema, query_schema=query_schema, **kw)
399
650
return func
400
651
return decorator
401
652
batch_size, batch_window_ms = self._resolve_batch_config(handler)
402
- wrapped = self._wrap_batch_handler(handler) if batch_size else self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares)
403
- self._app.post(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms)
653
+ wrapped = self._wrap_batch_handler(handler, native=native) if batch_size else self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares, native=native)
654
+ self._app.post(path, wrapped, body_schema, schema, query_schema=query_schema, batch_size=batch_size, batch_window_ms=batch_window_ms, **kw)
655
+ self._record("POST", path, handler, body_schema=body_schema, schema=schema, query_schema=query_schema, **kw)
404
656
return handler
405
657
406
- def put(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
658
+ def put(self, path: str, handler=None, body_schema=None, schema=None, query_schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True, native: bool = False):
659
+ if name is not None: self._named_routes[name] = path
660
+ kw = self._route_kw(tags, summary, description, deprecated, status_code, responses, operation_id, openapi_extra, include_in_schema, name=name)
661
+ kw["native"] = native
407
662
if handler is None:
408
663
def decorator(func):
409
664
batch_size, batch_window_ms = self._resolve_batch_config(func)
410
- wrapped = self._wrap_batch_handler(func) if batch_size else self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares)
411
- self._app.put(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms)
665
+ wrapped = self._wrap_batch_handler(func, native=native) if batch_size else self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares, native=native)
666
+ self._app.put(path, wrapped, body_schema, schema, query_schema=query_schema, batch_size=batch_size, batch_window_ms=batch_window_ms, **kw)
667
+ self._record("PUT", path, func, body_schema=body_schema, schema=schema, query_schema=query_schema, **kw)
412
668
return func
413
669
return decorator
414
670
batch_size, batch_window_ms = self._resolve_batch_config(handler)
415
- wrapped = self._wrap_batch_handler(handler) if batch_size else self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares)
416
- self._app.put(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms)
671
+ wrapped = self._wrap_batch_handler(handler, native=native) if batch_size else self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares, native=native)
672
+ self._app.put(path, wrapped, body_schema, schema, query_schema=query_schema, batch_size=batch_size, batch_window_ms=batch_window_ms, **kw)
673
+ self._record("PUT", path, handler, body_schema=body_schema, schema=schema, query_schema=query_schema, **kw)
417
674
return handler
418
675
419
- def delete(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
676
+ def delete(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True, query_schema=None, native: bool = False):
677
+ if name is not None: self._named_routes[name] = path
678
+ kw = self._route_kw(tags, summary, description, deprecated, status_code, responses, operation_id, openapi_extra, include_in_schema, name=name)
420
679
if handler is None:
421
680
def decorator(func):
422
- self._app.delete(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares))
681
+ self._app.delete(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
682
+ self._record("DELETE", path, func, query_schema=query_schema, native=native, **kw)
423
683
return func
424
684
return decorator
425
- self._app.delete(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares))
685
+ self._app.delete(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
686
+ self._record("DELETE", path, handler, query_schema=query_schema, native=native, **kw)
426
687
return handler
427
688
428
- def patch(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
689
+ def patch(self, path: str, handler=None, body_schema=None, schema=None, query_schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True, native: bool = False):
690
+ if name is not None: self._named_routes[name] = path
691
+ kw = self._route_kw(tags, summary, description, deprecated, status_code, responses, operation_id, openapi_extra, include_in_schema, name=name)
692
+ kw["native"] = native
429
693
if handler is None:
430
694
def decorator(func):
431
695
batch_size, batch_window_ms = self._resolve_batch_config(func)
432
- wrapped = self._wrap_batch_handler(func) if batch_size else self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares)
433
- self._app.patch(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms)
696
+ wrapped = self._wrap_batch_handler(func, native=native) if batch_size else self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares, native=native)
697
+ self._app.patch(path, wrapped, body_schema, schema, query_schema=query_schema, batch_size=batch_size, batch_window_ms=batch_window_ms, **kw)
698
+ self._record("PATCH", path, func, body_schema=body_schema, schema=schema, query_schema=query_schema, **kw)
434
699
return func
435
700
return decorator
436
701
batch_size, batch_window_ms = self._resolve_batch_config(handler)
437
- wrapped = self._wrap_batch_handler(handler) if batch_size else self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares)
438
- self._app.patch(path, wrapped, body_schema, schema, batch_size=batch_size, batch_window_ms=batch_window_ms)
702
+ wrapped = self._wrap_batch_handler(handler, native=native) if batch_size else self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares, native=native)
703
+ self._app.patch(path, wrapped, body_schema, schema, query_schema=query_schema, batch_size=batch_size, batch_window_ms=batch_window_ms, **kw)
704
+ self._record("PATCH", path, handler, body_schema=body_schema, schema=schema, query_schema=query_schema, **kw)
439
705
return handler
440
706
441
- def query(self, path: str, handler=None, body_schema=None, schema=None, experimental: bool = True, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
707
+ def query(self, path: str, handler=None, body_schema=None, schema=None, query_schema=None, experimental: bool = True, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True, native: bool = False):
442
708
"""Register a route for the HTTP QUERY method (RFC 10008).
443
709
444
710
QUERY is safe and idempotent like GET, but carries a request body
445
711
like POST — ideal for queries too large for the URI. By default the
446
712
generated OpenAPI operation is tagged ``experimental``.
447
713
"""
714
+ kw = self._route_kw(tags, summary, description, deprecated, status_code, responses, operation_id, openapi_extra, include_in_schema, name=name)
715
+ kw["native"] = native
716
+ if handler is None:
717
+ def decorator(func):
718
+ self._app.query(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares, native=native), body_schema, schema, query_schema=query_schema, experimental=experimental, **kw)
719
+ self._record("QUERY", path, func, body_schema=body_schema, schema=schema, query_schema=query_schema, experimental=experimental, **kw)
720
+ return func
721
+ return decorator
722
+ self._app.query(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares, native=native), body_schema, schema, query_schema=query_schema, experimental=experimental, **kw)
723
+ self._record("QUERY", path, handler, body_schema=body_schema, schema=schema, query_schema=query_schema, experimental=experimental, **kw)
724
+ return handler
725
+
726
+ def head(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True, query_schema=None, native: bool = False):
727
+ if name is not None: self._named_routes[name] = path
728
+ kw = self._route_kw(tags, summary, description, deprecated, status_code, responses, operation_id, openapi_extra, include_in_schema, name=name)
729
+ if handler is None:
730
+ def decorator(func):
731
+ self._app.head(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
732
+ self._record("HEAD", path, func, query_schema=query_schema, native=native, **kw)
733
+ return func
734
+ return decorator
735
+ self._app.head(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
736
+ self._record("HEAD", path, handler, query_schema=query_schema, native=native, **kw)
737
+ return handler
738
+
739
+ def options(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True, query_schema=None, native: bool = False):
740
+ if name is not None: self._named_routes[name] = path
741
+ kw = self._route_kw(tags, summary, description, deprecated, status_code, responses, operation_id, openapi_extra, include_in_schema, name=name)
742
+ if handler is None:
743
+ def decorator(func):
744
+ self._app.options(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
745
+ self._record("OPTIONS", path, func, query_schema=query_schema, native=native, **kw)
746
+ return func
747
+ return decorator
748
+ self._app.options(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
749
+ self._record("OPTIONS", path, handler, query_schema=query_schema, native=native, **kw)
750
+ return handler
751
+
752
+ def trace(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True, query_schema=None, native: bool = False):
753
+ kw = self._route_kw(tags, summary, description, deprecated, status_code, responses, operation_id, openapi_extra, include_in_schema, name=name)
448
754
if handler is None:
449
755
def decorator(func):
450
- self._app.query(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares), body_schema, schema, experimental)
756
+ self._app.trace(path, self._wrap_handler(func, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
757
+ self._record("TRACE", path, func, query_schema=query_schema, native=native, **kw)
451
758
return func
452
759
return decorator
453
- self._app.query(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares), body_schema, schema, experimental)
760
+ self._app.trace(path, self._wrap_handler(handler, route_dependencies=dependencies, route_middlewares=middlewares), query_schema=query_schema, native=native, **kw)
761
+ self._record("TRACE", path, handler, query_schema=query_schema, native=native, **kw)
454
762
return handler
455
763
764
+ def frontend(self, path: str, directory: str, html: bool = True, check_dir: bool = True):
765
+ """Serve a static frontend (SPA) from ``directory`` under ``path``.
766
+
767
+ When ``html`` is true unknown routes fall back to ``index.html``
768
+ (SPA client-side routing), mirroring FastAPI's ``StaticFiles(html=True)``.
769
+ """
770
+ fallback = "index.html" if html else None
771
+ self._app.frontend(path, directory, fallback, check_dir)
772
+ return directory
773
+
456
774
def add_plugin(self, plugin):
457
775
self._app.use(plugin)
458
776
487
805
distinct representations of the same resource are not collapsed together.
488
806
"""
489
807
self._app.enable_request_coalescing(headers)
490
-
808
+
491
809
def set_grpc_addr(self, addr: str):
492
810
self._app.set_grpc_addr(addr)
493
811
524
842
elif route["method"] == "WS":
525
843
self._app.websocket(path, method)
526
844
527
- def include_router(self, router, prefix: str = ""):
845
+ def route(self, path: str, methods: typing.List[str] = None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
846
+ if methods is None:
847
+ methods = ["GET"]
848
+ def decorator(func):
849
+ for method in methods:
850
+ m = method.upper()
851
+ if m == "GET":
852
+ self.get(path, func, dependencies=dependencies, middlewares=middlewares)
853
+ elif m == "POST":
854
+ self.post(path, func, dependencies=dependencies, middlewares=middlewares)
855
+ elif m == "PUT":
856
+ self.put(path, func, dependencies=dependencies, middlewares=middlewares)
857
+ elif m == "DELETE":
858
+ self.delete(path, func, dependencies=dependencies, middlewares=middlewares)
859
+ elif m == "PATCH":
860
+ self.patch(path, func, dependencies=dependencies, middlewares=middlewares)
861
+ elif m == "QUERY":
862
+ self.query(path, func, dependencies=dependencies, middlewares=middlewares)
863
+ return func
864
+ return decorator
865
+
866
+ def include_router(self, router, prefix: str = "", tags: typing.List[str] = None):
528
867
for route in router.routes:
529
868
path = prefix + route["path"]
530
869
# Combine dependencies: App -> Router -> Route
531
870
combined_deps = router.dependencies + route["dependencies"]
532
871
combined_mws = router.middlewares + route.get("middlewares", [])
872
+ # Include-level tags, then router-level tags, then the route's
873
+ # own tags (FastAPI merge order).
874
+ route_tags = (tags or []) + (router.tags or []) + (route.get("tags") or [])
875
+
876
+ kw = dict(
877
+ dependencies=combined_deps,
878
+ middlewares=combined_mws,
879
+ tags=route_tags or None,
880
+ summary=route.get("summary"),
881
+ description=route.get("description"),
882
+ deprecated=route.get("deprecated", False),
883
+ status_code=route.get("status_code"),
884
+ responses=route.get("responses"),
885
+ operation_id=route.get("operation_id"),
886
+ openapi_extra=route.get("openapi_extra"),
887
+ include_in_schema=route.get("include_in_schema", True),
888
+ name=route.get("name"),
889
+ )
533
890
534
891
if route["method"] == "GET":
535
- self.get(path, route["handler"], dependencies=combined_deps, middlewares=combined_mws)
892
+ self.get(path, route["handler"], **kw)
536
893
elif route["method"] == "POST":
537
- self.post(path, route["handler"], body_schema=route["body_schema"], schema=route["schema"], dependencies=combined_deps, middlewares=combined_mws)
894
+ self.post(path, route["handler"], body_schema=route.get("body_schema"), schema=route.get("schema"), **kw)
538
895
elif route["method"] == "PUT":
539
- self.put(path, route["handler"], body_schema=route["body_schema"], schema=route["schema"], dependencies=combined_deps, middlewares=combined_mws)
896
+ self.put(path, route["handler"], body_schema=route.get("body_schema"), schema=route.get("schema"), **kw)
540
897
elif route["method"] == "PATCH":
541
- self.patch(path, route["handler"], body_schema=route["body_schema"], schema=route["schema"], dependencies=combined_deps, middlewares=combined_mws)
898
+ self.patch(path, route["handler"], body_schema=route.get("body_schema"), schema=route.get("schema"), **kw)
542
899
elif route["method"] == "DELETE":
543
- self.delete(path, route["handler"], dependencies=combined_deps, middlewares=combined_mws)
900
+ self.delete(path, route["handler"], **kw)
544
901
elif route["method"] == "QUERY":
545
- self.query(path, route["handler"], body_schema=route.get("body_schema"), schema=route.get("schema"), experimental=route.get("experimental", True), dependencies=combined_deps, middlewares=combined_mws)
902
+ self.query(path, route["handler"], body_schema=route.get("body_schema"), schema=route.get("schema"), experimental=route.get("experimental", True), **kw)
903
+ elif route["method"] == "HEAD":
904
+ self.head(path, route["handler"], **kw)
905
+ elif route["method"] == "OPTIONS":
906
+ self.options(path, route["handler"], **kw)
907
+ elif route["method"] == "TRACE":
908
+ self.trace(path, route["handler"], **kw)
909
+ elif route["method"] == "FRONTEND":
910
+ self.frontend(path, route["directory"], html=route.get("html", True), check_dir=route.get("check_dir", True))
911
+ elif route["method"] == "TRACE":
912
+ self.trace(path, route["handler"], **kw)
546
913
elif route["method"] == "SSE":
547
914
self._app.get(path, self._wrap_sse_handler(route["handler"]))
548
915
elif route["method"] == "WS":
549
916
self._app.websocket(path, route["handler"])
550
917
918
+ def url_for(self, name, **path_params):
919
+ """Build a URL path for a named route, substituting path parameters.
920
+
921
+ Mirrors FastAPI/Starlette ``request.url_for(name, **params)``.
922
+ """
923
+ try:
924
+ path = self._named_routes[name]
925
+ except KeyError:
926
+ raise KeyError(f"No route named {name!r} is registered")
927
+ def repl(m):
928
+ p = m.group(1)
929
+ if p not in path_params:
930
+ raise KeyError(f"Missing path parameter {p!r} for route {name!r}")
931
+ return quote(str(path_params[p]), safe="")
932
+ return re.sub(r"{([^}]+)}", repl, path)
933
+
934
+ # --- Native MCP tool surface (Rust registry; Python is thin glue) ---
935
+
936
+ def tool(self, func=None, *, name=None, description=None, input_schema=None, annotations=None):
937
+ """Register a Python callable as a native MCP tool.
938
+
939
+ The tool is stored in the Rust registry and exposed over
940
+ ``/_system/tools`` and the bundled MCP server. The input JSON schema is
941
+ inferred from the function signature / type hints unless ``input_schema``
942
+ is given. Schema inference is Python glue; the registry and serving live
943
+ in Rust.
944
+
945
+ Usable both bare (``@app.tool``) and with arguments
946
+ (``@app.tool(description="...")``).
947
+
948
+ Example::
949
+
950
+ @app.tool(description="Add two numbers")
951
+ def add(a: int, b: int) -> int:
952
+ return a + b
953
+ """
954
+ def decorator(f):
955
+ tool_name = name or f.__name__
956
+ tool_desc = description or (inspect.getdoc(f) or f"Tool {tool_name}")
957
+ schema_json = input_schema
958
+ if schema_json is None:
959
+ schema_json = _infer_tool_schema(f)
960
+ if isinstance(schema_json, (dict, list)):
961
+ schema_json = json.dumps(schema_json)
962
+ self._app.register_tool(tool_name, tool_desc, schema_json, f)
963
+ return f
964
+ if func is not None and callable(func):
965
+ return decorator(func)
966
+ return decorator
967
+
968
+ def list_tools(self):
969
+ """Return registered tools in MCP ``tools/list`` shape (list of dicts)."""
970
+ return self._app.list_tools()
971
+
972
+ def call_tool(self, name, arguments=None):
973
+ """Invoke a registered tool; returns the handler result (or a coroutine
974
+ if the handler is async)."""
975
+ return self._app.call_tool(name, json.dumps(arguments or {}))
976
+
977
+ # --- Agent session state (Rust-backed store) ---
978
+
979
+ def create_session(self, data: dict = None, id: str = None):
980
+ """Create a new session, optionally seeded with a JSON dict and/or a
981
+ caller-supplied id (used to materialize a known session id such as one
982
+ passed via ``?session=``)."""
983
+ raw = json.dumps(data) if data is not None else None
984
+ return self._app.create_session(id, raw)
985
+
986
+ def get_session(self, id: str):
987
+ """Return a session's data as a dict, or None if unknown."""
988
+ raw = self._app.get_session(id)
989
+ return json.loads(raw) if raw else None
990
+
991
+ def set_session(self, id: str, data: dict):
992
+ return self._app.set_session(id, json.dumps(data))
993
+
994
+ def update_session(self, id: str, **fields):
995
+ return self._app.update_session(id, json.dumps(fields))
996
+
997
+ def delete_session(self, id: str):
998
+ return self._app.delete_session(id)
999
+
1000
+ def enable_sessions(self, cookie_name: str = "justapi_session"):
1001
+ """Enable automatic session id resolution for injected ``Session`` params."""
1002
+ self._sessions_enabled = True
1003
+ self._session_cookie = cookie_name
1004
+ return self
1005
+
1006
+ # --- Streaming validated structured output ---
1007
+
1008
+ def stream_json(self, path: str, schema=None, mode: str = "ndjson", handler=None, dependencies=None):
1009
+ """Register a route that streams JSON objects, validating each against
1010
+ ``schema`` (a JSON Schema dict/str) before it is sent to the client.
1011
+
1012
+ The handler must return a (sync or async) generator yielding
1013
+ JSON-serialisable Python objects. ``mode`` is ``"ndjson"`` (one object
1014
+ per line) or ``"array"`` (a single JSON array). Validation runs in Rust.
1015
+
1016
+ The handler's parameters participate in normal dependency injection, so
1017
+ ``session: Session`` and query-string params work exactly as on a regular
1018
+ route.
1019
+ """
1020
+ schema_json = schema if isinstance(schema, str) else json.dumps(schema or {})
1021
+
1022
+ def decorator(func):
1023
+ @functools.wraps(func)
1024
+ def adapted(*args, **kwargs):
1025
+ gen = func(*args, **kwargs)
1026
+ return ValidatedStreamResponse(gen, schema_json, mode)
1027
+
1028
+ self._app.get(path, self._wrap_handler(adapted, route_dependencies=dependencies))
1029
+ self._record("GET", path, func)
1030
+ return func
1031
+
1032
+ if handler is None:
1033
+ return decorator
1034
+ return decorator(handler)
1035
+
1036
+ def enable_system_routes(self):
1037
+ """Mount ``/_system/help``, ``/_system/help/{name}`` and ``/_system/openapi``.
1038
+
1039
+ These expose the app's full route metadata (signatures, parameters,
1040
+ schemas, docstrings, examples) over HTTP for editors and AI agents.
1041
+ """
1042
+ register_system_routes(self)
1043
+ return self
1044
+
551
1045
def run(self, addr: str):
552
1046
self._app.run(addr)
553
1047
605
1099
return self._app
606
1100
607
1101
class APIRouter:
608
- def __init__(self, prefix: str = "", dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
1102
+ def __init__(self, prefix: str = "", tags: typing.List[str] = None, responses: dict = None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, callbacks: typing.List[typing.Callable] = None, deprecated: bool = False, name: str = None, include_in_schema: bool = True):
609
1103
self.prefix = prefix
1104
+ self.tags = tags or []
1105
+ self.responses = responses
610
1106
self.dependencies = dependencies or []
611
1107
self.middlewares = middlewares or []
1108
+ self.callbacks = callbacks or []
1109
+ self.deprecated = deprecated
1110
+ self.include_in_schema = include_in_schema
612
1111
self.routes = []
1112
+ self._named_routes = {}
1113
+
1114
+ def _store(self, method, path, handler, body_schema=None, schema=None, experimental=False,
1115
+ dependencies=None, middlewares=None, tags=None, summary=None, description=None,
1116
+ deprecated=None, status_code=None, responses=None, operation_id=None,
1117
+ openapi_extra=None, include_in_schema=None, name=None):
1118
+ full_path = self.prefix + path
1119
+ # Route-level tags override the router default; include-level merging
1120
+ # happens in JustAPIApp.include_router.
1121
+ if name is not None:
1122
+ self._named_routes[name] = full_path
1123
+ route_tags = tags if tags is not None else (self.tags or [])
1124
+ route_responses = responses if responses is not None else self.responses
1125
+ route_deprecated = deprecated if deprecated else self.deprecated
1126
+ route_include = include_in_schema if include_in_schema is not None else self.include_in_schema
1127
+ self.routes.append({
1128
+ "method": method,
1129
+ "path": full_path,
1130
+ "handler": handler,
1131
+ "body_schema": body_schema,
1132
+ "schema": schema,
1133
+ "experimental": experimental,
1134
+ "dependencies": dependencies or [],
1135
+ "middlewares": middlewares or [],
1136
+ "tags": route_tags or [],
1137
+ "summary": summary,
1138
+ "description": description,
1139
+ "deprecated": route_deprecated,
1140
+ "status_code": status_code,
1141
+ "responses": route_responses,
1142
+ "operation_id": operation_id,
1143
+ "openapi_extra": openapi_extra,
1144
+ "include_in_schema": route_include,
1145
+ "name": name,
1146
+ })
1147
+
1148
+ def get(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True):
1149
+ if handler is None:
1150
+ def decorator(func):
1151
+ self._store("GET", path, func, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
1152
+ return func
1153
+ return decorator
1154
+ self._store("GET", path, handler, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
1155
+ return handler
1156
+
1157
+ def post(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True):
1158
+ if handler is None:
1159
+ def decorator(func):
1160
+ self._store("POST", path, func, body_schema=body_schema, schema=schema, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
1161
+ return func
1162
+ return decorator
1163
+ self._store("POST", path, handler, body_schema=body_schema, schema=schema, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
1164
+ return handler
613
1165
614
- def get(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
1166
+ def put(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True):
615
1167
if handler is None:
616
1168
def decorator(func):
617
- self.routes.append({"method": "GET", "path": self.prefix + path, "handler": func, "dependencies": dependencies or [], "middlewares": middlewares or []})
1169
+ self._store("PUT", path, func, body_schema=body_schema, schema=schema, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
618
1170
return func
619
1171
return decorator
620
- self.routes.append({"method": "GET", "path": self.prefix + path, "handler": handler, "dependencies": dependencies or [], "middlewares": middlewares or []})
1172
+ self._store("PUT", path, handler, body_schema=body_schema, schema=schema, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
621
1173
return handler
622
1174
623
- def post(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
1175
+ def patch(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True):
624
1176
if handler is None:
625
1177
def decorator(func):
626
- self.routes.append({"method": "POST", "path": self.prefix + path, "handler": func, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []})
1178
+ self._store("PATCH", path, func, body_schema=body_schema, schema=schema, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
627
1179
return func
628
1180
return decorator
629
- self.routes.append({"method": "POST", "path": self.prefix + path, "handler": handler, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []})
1181
+ self._store("PATCH", path, handler, body_schema=body_schema, schema=schema, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
630
1182
return handler
631
1183
632
- def put(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
1184
+ def delete(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True):
633
1185
if handler is None:
634
1186
def decorator(func):
635
- self.routes.append({"method": "PUT", "path": self.prefix + path, "handler": func, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []})
1187
+ self._store("DELETE", path, func, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
636
1188
return func
637
1189
return decorator
638
- self.routes.append({"method": "PUT", "path": self.prefix + path, "handler": handler, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []})
1190
+ self._store("DELETE", path, handler, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
639
1191
return handler
640
1192
641
- def patch(self, path: str, handler=None, body_schema=None, schema=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
1193
+ def query(self, path: str, handler=None, body_schema=None, schema=None, experimental: bool = True, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True):
642
1194
if handler is None:
643
1195
def decorator(func):
644
- self.routes.append({"method": "PATCH", "path": self.prefix + path, "handler": func, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []})
1196
+ self._store("QUERY", path, func, body_schema=body_schema, schema=schema, experimental=experimental, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
645
1197
return func
646
1198
return decorator
647
- self.routes.append({"method": "PATCH", "path": self.prefix + path, "handler": handler, "body_schema": body_schema, "schema": schema, "dependencies": dependencies or [], "middlewares": middlewares or []})
1199
+ self._store("QUERY", path, handler, body_schema=body_schema, schema=schema, experimental=experimental, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
648
1200
return handler
649
1201
650
- def delete(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
1202
+ def head(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True):
651
1203
if handler is None:
652
1204
def decorator(func):
653
- self.routes.append({"method": "DELETE", "path": self.prefix + path, "handler": func, "dependencies": dependencies or [], "middlewares": middlewares or []})
1205
+ self._store("HEAD", path, func, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
654
1206
return func
655
1207
return decorator
656
- self.routes.append({"method": "DELETE", "path": self.prefix + path, "handler": handler, "dependencies": dependencies or [], "middlewares": middlewares or []})
1208
+ self._store("HEAD", path, handler, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
657
1209
return handler
658
1210
659
- def query(self, path: str, handler=None, body_schema=None, schema=None, experimental: bool = True, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
1211
+ def options(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True):
660
1212
if handler is None:
661
1213
def decorator(func):
662
- self.routes.append({"method": "QUERY", "path": self.prefix + path, "handler": func, "body_schema": body_schema, "schema": schema, "experimental": experimental, "dependencies": dependencies or [], "middlewares": middlewares or []})
1214
+ self._store("OPTIONS", path, func, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
663
1215
return func
664
1216
return decorator
665
- self.routes.append({"method": "QUERY", "path": self.prefix + path, "handler": handler, "body_schema": body_schema, "schema": schema, "experimental": experimental, "dependencies": dependencies or [], "middlewares": middlewares or []})
1217
+ self._store("OPTIONS", path, handler, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
666
1218
return handler
667
1219
1220
+ def trace(self, path: str, handler=None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None, tags: typing.List[str] = None, summary: str = None, description: str = None, deprecated: bool = False, status_code: int = None, responses: dict = None, operation_id: str = None, openapi_extra: dict = None, name: str = None, include_in_schema: bool = True):
1221
+ if handler is None:
1222
+ def decorator(func):
1223
+ self._store("TRACE", path, func, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
1224
+ return func
1225
+ return decorator
1226
+ self._store("TRACE", path, handler, dependencies=dependencies, middlewares=middlewares, tags=tags, summary=summary, description=description, deprecated=deprecated, status_code=status_code, responses=responses, operation_id=operation_id, openapi_extra=openapi_extra, include_in_schema=include_in_schema, name=name)
1227
+ return handler
1228
+
1229
+ def frontend(self, path: str, directory: str, html: bool = True, check_dir: bool = True):
1230
+ """Register a static frontend (SPA) mount under ``path``."""
1231
+ self.routes.append({"method": "FRONTEND", "path": path, "directory": directory, "html": html, "check_dir": check_dir})
1232
+
668
1233
def sse(self, path: str, handler=None, dependencies: typing.List[Depends] = None):
669
1234
if handler is None:
670
1235
def decorator(func):
683
1248
self.routes.append({"method": "WS", "path": self.prefix + path, "handler": handler, "dependencies": dependencies or []})
684
1249
return handler
685
1250
1251
+ def route(self, path: str, methods: typing.List[str] = None, dependencies: typing.List[Depends] = None, middlewares: typing.List[typing.Callable] = None):
1252
+ if methods is None:
1253
+ methods = ["GET"]
1254
+ def decorator(func):
1255
+ for method in methods:
1256
+ m = method.upper()
1257
+ if m == "GET":
1258
+ self.get(path, func, dependencies=dependencies, middlewares=middlewares)
1259
+ elif m == "POST":
1260
+ self.post(path, func, dependencies=dependencies, middlewares=middlewares)
1261
+ elif m == "PUT":
1262
+ self.put(path, func, dependencies=dependencies, middlewares=middlewares)
1263
+ elif m == "DELETE":
1264
+ self.delete(path, func, dependencies=dependencies, middlewares=middlewares)
1265
+ elif m == "PATCH":
1266
+ self.patch(path, func, dependencies=dependencies, middlewares=middlewares)
1267
+ elif m == "QUERY":
1268
+ self.query(path, func, dependencies=dependencies, middlewares=middlewares)
1269
+ elif m == "HEAD":
1270
+ self.head(path, func, dependencies=dependencies, middlewares=middlewares)
1271
+ elif m == "OPTIONS":
1272
+ self.options(path, func, dependencies=dependencies, middlewares=middlewares)
1273
+ elif m == "TRACE":
1274
+ self.trace(path, func, dependencies=dependencies, middlewares=middlewares)
1275
+ return func
1276
+ return decorator
1277
+
1278
+ def include_router(self, router, prefix: str = "", tags: typing.List[str] = None):
1279
+ self._named_routes.update(router._named_routes)
1280
+ for route in router.routes:
1281
+ new_route = route.copy()
1282
+ # route["path"] already has router.prefix; prepend our prefix + include prefix.
1283
+ new_route["path"] = self.prefix + prefix + route["path"]
1284
+ new_route["dependencies"] = router.dependencies + route["dependencies"]
1285
+ new_route["middlewares"] = router.middlewares + route.get("middlewares", [])
1286
+ # Include-level tags are prepended to the route's existing tags.
1287
+ new_route["tags"] = (tags or []) + route.get("tags", [])
1288
+ self.routes.append(new_route)
1289
+
1290
+ def url_for(self, name, **path_params):
1291
+ """Build a URL path for a named route, substituting path parameters."""
1292
+ try:
1293
+ path = self._named_routes[name]
1294
+ except KeyError:
1295
+ raise KeyError(f"No route named {name!r} is registered")
1296
+ def repl(m):
1297
+ p = m.group(1)
1298
+ if p not in path_params:
1299
+ raise KeyError(f"Missing path parameter {p!r} for route {name!r}")
1300
+ return quote(str(path_params[p]), safe="")
1301
+ return re.sub(r"{([^}]+)}", repl, path)
1302
+
686
1303
class Controller:
687
1304
"""Base class for controller classes."""
688
1305
pass
738
1355
739
1356
def route_websocket(path: str, dependencies: typing.List[Depends] = None):
740
1357
def decorator(func):
741
- func.__route_info__ = {"method": "WS", "path": path, "dependencies": dependencies or []}
742
- return func
1358
+ @functools.wraps(func)
1359
+ async def wrapper(websocket):
1360
+ ws = WebSocket(websocket)
1361
+ try:
1362
+ return await func(ws)
1363
+ except WebSocketException as exc:
1364
+ # FastAPI/Starlette semantics: a raised WebSocketException
1365
+ # closes the socket with the given close code and reason.
1366
+ await ws.close(exc.code, exc.reason)
1367
+ return
1368
+ wrapper.__route_info__ = {"method": "WS", "path": path, "dependencies": dependencies or []}
1369
+ return wrapper
743
1370
return decorator
744
1371
745
1372
class _JustAPIGrpcContext:
801
1428
def __new__(cls, app, database=None):
802
1429
inner = getattr(app, "_inner", app)
803
1430
return _JustAPITestClient(inner, database=database)
1431
+
1432
+JustAPI = JustAPIApp
804
1433