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,6 +101,53 @@ jobs: | ||
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,3 +20,6 @@ jobs: | ||
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,11 +12,16 @@ jobs: | ||
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,6 +24,8 @@ __pycache__/ | ||
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,7 +10,33 @@ Do not begin a phase in `PLAN.md` until the previous phase's exit criteria | ||
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,7 +60,7 @@ 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,8 +75,10 @@ any non-trivial change — they catch different classes of mistakes: | ||
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,7 +88,7 @@ any non-trivial change — they catch different classes of mistakes: | ||
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,7 +98,7 @@ any non-trivial change — they catch different classes of mistakes: | ||
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,7 +107,7 @@ conflict and your recommendation into `DECISIONS.md` and proceed with the | ||
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,7 +148,7 @@ justapi-py → justapi-core | ||
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,6 +31,143 @@ | ||
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,3 +1006,219 @@ overhead across a batch) is gated on `--features real` + CUDA. | ||
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,6 +2717,7 @@ dependencies = [ | ||
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,19 +2771,23 @@ dependencies = [ | ||
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,6 +4022,19 @@ dependencies = [ | ||
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,6 +5635,7 @@ version = "1.23.4" | ||
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,6 +6526,15 @@ version = "0.8.16" | ||
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,6 +14,16 @@ version = "2.0.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,3 +1479,270 @@ it with a `unwrap_or_default` fallback. | ||
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,10 +6,14 @@ | ||
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,7 +74,7 @@ cloud-native readiness. | ||
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,6 +600,14 @@ Don't duplicate that reasoning here — just reference the entry. | ||
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,7 +743,7 @@ Based on a massive deep-dive into the modern Python ecosystem (Litestar, Robyn), | ||
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,6 +998,62 @@ k8s guides, OpenAPI. | ||
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,6 +76,45 @@ Kernel (epoll/io_uring) | ||
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,6 +513,7 @@ Step-by-step deployment guides are available for: | ||
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
| @@ -0,0 +1,70 @@ | ||
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
| @@ -0,0 +1,26 @@ | ||
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
| @@ -0,0 +1,14 @@ | ||
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
| @@ -0,0 +1,36 @@ | ||
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
| @@ -0,0 +1,40 @@ | ||
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
| @@ -0,0 +1,26 @@ | ||
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
| @@ -0,0 +1,18 @@ | ||
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
| @@ -0,0 +1,12 @@ | ||
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
| @@ -0,0 +1,9 @@ | ||
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
| @@ -0,0 +1,23 @@ | ||
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
| @@ -0,0 +1,24 @@ | ||
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,24 +1,27 @@ | ||
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,7 +27,37 @@ def echo(request): | ||
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
| @@ -0,0 +1,32 @@ | ||
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
| @@ -0,0 +1,27 @@ | ||
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
| @@ -0,0 +1,32 @@ | ||
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
| @@ -0,0 +1,21 @@ | ||
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
| @@ -0,0 +1,33 @@ | ||
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,10 +31,7 @@ use justapi_inference::{ | ||
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,15 +93,9 @@ fn report(name: &str, timing: &Timing) { | ||
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,10 +118,8 @@ fn main() -> Result<()> { | ||
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,9 +132,7 @@ fn main() -> Result<()> { | ||
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,10 +169,7 @@ fn main() -> Result<()> { | ||
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,18 +222,14 @@ async fn bench_naive( | ||
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,18 +263,14 @@ async fn bench_scheduled( | ||
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,10 +76,7 @@ fn make_request(id: u64, prompt_len: usize, max_tokens: usize) -> NewRequest { | ||
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,11 +140,7 @@ fn run_disaggregated(p: &BenchParams) -> Metrics { | ||
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,26 +230,14 @@ fn report(name: &str, m: &Metrics, p: &BenchParams) { | ||
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,23 +276,16 @@ const REAL_MODEL: &str = "mock"; | ||
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,22 +305,14 @@ fn bench_scheduled(n: usize, prompt_len: usize, max_tokens: usize) -> (u64, f64) | ||
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,16 +359,8 @@ fn main() { | ||
393 | 359 | let coll = run_collocated(¶ms); |
394 | 360 | let disagg = run_disaggregated(¶ms); |
395 | 361 | |
396 | - report( | |
397 | - "Collocated (single pool, prefill+decode serialised)", | |
398 | - &coll, | |
399 | - ¶ms, | |
400 | - ); | |
401 | - report( | |
402 | - "Disaggregated (independent prefill/decode pools)", | |
403 | - &disagg, | |
404 | - ¶ms, | |
405 | - ); | |
362 | + report("Collocated (single pool, prefill+decode serialised)", &coll, ¶ms); | |
363 | + report("Disaggregated (independent prefill/decode pools)", &disagg, ¶ms); | |
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,16 +369,8 @@ fn main() { | ||
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,11 +405,7 @@ fn main() { | ||
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,15 +23,9 @@ struct Metadata { | ||
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,10 +17,9 @@ impl std::str::FromStr for ClientLanguage { | ||
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,11 +141,7 @@ fn collect_ops(doc: &OpenApiDocument) -> Vec<Op> { | ||
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,19 +150,15 @@ fn collect_ops(doc: &OpenApiDocument) -> Vec<Op> { | ||
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,20 +313,13 @@ fn python_method(op: &Op) -> String { | ||
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,10 +344,7 @@ fn python_method(op: &Op) -> String { | ||
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,11 +352,7 @@ fn python_method(op: &Op) -> String { | ||
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,18 +458,9 @@ fn ts_method(op: &Op) -> String { | ||
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,11 +499,7 @@ fn ts_method(op: &Op) -> String { | ||
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,11 +509,7 @@ fn ts_method(op: &Op) -> String { | ||
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,6 +587,7 @@ mod tests { | ||
627 | 587 | m |
628 | 588 | }, |
629 | 589 | deprecated: None, |
590 | + extensions: Default::default(), | |
630 | 591 | }, |
631 | 592 | ) |
632 | 593 | .operation( |
| @@ -657,6 +618,7 @@ mod tests { | ||
657 | 618 | m |
658 | 619 | }, |
659 | 620 | deprecated: None, |
621 | + extensions: Default::default(), | |
660 | 622 | }, |
661 | 623 | ) |
662 | 624 | .operation( |
| @@ -674,9 +636,7 @@ mod tests { | ||
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,6 +654,7 @@ mod tests { | ||
694 | 654 | m |
695 | 655 | }, |
696 | 656 | deprecated: None, |
657 | + extensions: Default::default(), | |
697 | 658 | }, |
698 | 659 | ) |
699 | 660 | .build() |
| @@ -737,14 +698,8 @@ mod tests { | ||
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,9 +730,7 @@ mod tests { | ||
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,6 +748,7 @@ mod tests { | ||
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,10 +11,7 @@ mod profile; | ||
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,10 +377,8 @@ async fn run() -> anyhow::Result<()> { | ||
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,10 +457,8 @@ async fn run() -> anyhow::Result<()> { | ||
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,10 +508,7 @@ async fn run() -> anyhow::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,11 +524,8 @@ async fn run() -> anyhow::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,29 +542,20 @@ async fn run() -> anyhow::Result<()> { | ||
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,10 +563,7 @@ async fn run() -> anyhow::Result<()> { | ||
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,11 +574,8 @@ async fn run() -> anyhow::Result<()> { | ||
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,16 +606,11 @@ async fn run() -> anyhow::Result<()> { | ||
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,11 +651,8 @@ async fn run() -> anyhow::Result<()> { | ||
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,11 +660,8 @@ async fn run() -> anyhow::Result<()> { | ||
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,11 +680,7 @@ async fn run() -> anyhow::Result<()> { | ||
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,12 +807,7 @@ justapi serve --reload | ||
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,10 +71,7 @@ async fn send_request(addr: &str) -> Option<RequestResult> { | ||
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,10 +192,7 @@ pub fn format_report(report: &ProfileReport) -> String { | ||
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,10 +207,7 @@ pub fn format_report(report: &ProfileReport) -> String { | ||
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,12 +24,8 @@ const EXCLUDED_DIRS: &[&str] = &[ | ||
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,10 +52,8 @@ pub fn spawn_file_watcher( | ||
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,9 +68,8 @@ pub fn spawn_file_watcher( | ||
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,12 +87,9 @@ pub fn spawn_file_watcher( | ||
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,10 +155,7 @@ mod tests { | ||
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,10 +163,7 @@ mod tests { | ||
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,6 +74,7 @@ proptest = "1" | ||
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,10 +46,7 @@ impl Default for AlertingConfig { | ||
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,15 +123,9 @@ fn build_pagerduty_payload(app: &str, severity: &AlertSeverity, message: &str) - | ||
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,14 +137,9 @@ async fn post_webhook(url: &str, body: &str) -> anyhow::Result<()> { | ||
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,10 +14,7 @@ pub struct AuditRule { | ||
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,10 +22,7 @@ where | ||
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,9 +56,10 @@ impl CoalescedResponse { | ||
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,22 +106,14 @@ impl Body for FanoutBody { | ||
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,12 +131,7 @@ impl Drop for FanoutBody { | ||
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,11 +144,7 @@ fn this_tx_send( | ||
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,10 +157,7 @@ pub struct RequestCoalescer { | ||
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,18 +168,14 @@ impl RequestCoalescer { | ||
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,12 +186,8 @@ impl Default for RequestCoalescer { | ||
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,12 +212,12 @@ impl Middleware<hyper::body::Incoming> for RequestCoalescer { | ||
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,12 +235,12 @@ impl RequestCoalescer { | ||
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,10 +324,7 @@ mod tests { | ||
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,10 +334,7 @@ mod tests { | ||
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,10 +358,7 @@ mod tests { | ||
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,7 +6,7 @@ use flate2::write::{DeflateEncoder, GzEncoder}; | ||
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,18 +137,15 @@ impl Default for CompressionMiddleware { | ||
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,11 +157,8 @@ impl crate::middleware::Middleware<Incoming> for CompressionMiddleware { | ||
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,18 +197,10 @@ impl crate::middleware::Middleware<Incoming> for CompressionMiddleware { | ||
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,10 +237,7 @@ mod tests { | ||
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,10 +247,7 @@ mod tests { | ||
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,19 +263,13 @@ mod tests { | ||
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,20 +53,12 @@ impl Migration { | ||
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,9 +70,7 @@ pub struct Migrator { | ||
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,16 +105,11 @@ impl Migrator { | ||
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,9 +156,7 @@ impl Migrator { | ||
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,20 +194,13 @@ impl Migrator { | ||
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,11 +54,7 @@ pub struct DatabaseConfig { | ||
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,9 +168,7 @@ pub struct PoolManager { | ||
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,14 +237,8 @@ mod tests { | ||
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,12 +96,7 @@ pub struct Insert { | ||
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,8 +105,7 @@ impl Insert { | ||
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,12 +125,8 @@ impl Insert { | ||
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,17 +146,11 @@ pub struct Update { | ||
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,11 +189,7 @@ pub struct Delete { | ||
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,23 +233,13 @@ mod tests { | ||
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,18 +258,12 @@ mod tests { | ||
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,13 +99,7 @@ pub struct Diagnostic { | ||
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,7 +24,7 @@ impl CatalogEntry { | ||
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,7 +34,7 @@ impl CatalogEntry { | ||
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,7 +157,7 @@ mod tests { | ||
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,7 +174,7 @@ mod tests { | ||
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,11 +43,7 @@ impl<T: DeserializeOwned> Form<T> { | ||
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,11 +190,7 @@ mod tests { | ||
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,8 +278,7 @@ mod tests { | ||
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,10 +286,7 @@ mod tests { | ||
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,8 +304,7 @@ mod tests { | ||
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,8 +312,7 @@ mod tests { | ||
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,8 +322,7 @@ mod tests { | ||
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,7 +14,7 @@ use crate::middleware::{Middleware, Next}; | ||
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,12 +46,7 @@ impl GatewayState { | ||
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,11 +143,11 @@ impl GatewayMiddleware { | ||
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,9 +23,7 @@ impl QueryRoot { | ||
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,9 +73,7 @@ pub struct DynamicGrpcService { | ||
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,10 +80,7 @@ impl HealthRegistry { | ||
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,9 +104,10 @@ impl HealthRegistry { | ||
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,10 +268,7 @@ mod tests { | ||
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,9 +82,7 @@ pub fn streaming_response( | ||
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,9 +76,7 @@ pub struct SharedArena { | ||
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,9 +106,7 @@ const BUCKET_SIZES: [usize; 4] = [1024, 4096, 16384, 65536]; | ||
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,8 +122,7 @@ impl BufferPool { | ||
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,9 +12,8 @@ use hyper::{Response, StatusCode}; | ||
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,21 +63,13 @@ struct LatencyHist { | ||
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,11 +82,7 @@ impl LatencyHist { | ||
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,15 +158,11 @@ impl Metrics { | ||
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,46 +243,25 @@ impl Metrics { | ||
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,9 +359,7 @@ pub struct RequestTimer { | ||
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,19 +73,13 @@ pub struct MiddlewareChain<B = Incoming> { | ||
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,11 +98,7 @@ impl<B> MiddlewareChain<B> { | ||
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,12 +132,8 @@ impl Default for AccessLogger { | ||
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,9 +146,7 @@ impl Middleware<Incoming> for AccessLogger { | ||
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,12 +232,8 @@ fn origin_matches(origin: &str, allowed: &[String]) -> bool { | ||
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,10 +264,8 @@ impl Middleware<Incoming> for Cors { | ||
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,13 +281,9 @@ impl Middleware<Incoming> for Cors { | ||
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,12 +342,8 @@ impl SecurityHeaders { | ||
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,10 +428,8 @@ impl JwtAuth { | ||
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,10 +457,8 @@ impl JwtAuth { | ||
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,8 +489,7 @@ impl JwtAuth { | ||
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,10 +522,7 @@ fn check_claims( | ||
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,10 +546,7 @@ fn check_claims( | ||
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,12 +559,8 @@ fn check_claims( | ||
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,10 +569,7 @@ impl Middleware<Incoming> for JwtAuth { | ||
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,9 +608,7 @@ impl RateLimiter { | ||
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,12 +617,8 @@ impl RateLimiter { | ||
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,10 +629,8 @@ impl Middleware<Incoming> for RateLimiter { | ||
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,12 +674,8 @@ impl IpRateLimiter { | ||
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,10 +692,8 @@ impl Middleware<Incoming> for IpRateLimiter { | ||
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,11 +781,7 @@ impl ApiKeyAuth { | ||
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,12 +824,8 @@ impl ApiKeyAuth { | ||
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,21 +948,15 @@ impl OAuth2Password { | ||
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,11 +964,7 @@ impl OAuth2Password { | ||
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,185 +1089,8 @@ mod tests { | ||
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,10 +1228,7 @@ mod tests { | ||
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,10 +1265,7 @@ mod tests { | ||
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,18 +1281,11 @@ mod tests { | ||
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,10 +1389,7 @@ mod tests { | ||
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,10 +1416,7 @@ mod tests { | ||
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,17 +1436,6 @@ mod tests { | ||
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,10 +1447,8 @@ mod tests { | ||
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,11 +1620,8 @@ mod tests { | ||
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,18 +1640,12 @@ mod tests { | ||
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,17 +1668,11 @@ mod tests { | ||
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,10 +1687,7 @@ mod tests { | ||
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,6 +13,10 @@ pub struct UploadFile { | ||
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,34 +54,47 @@ where | ||
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,6 +106,32 @@ where | ||
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,6 +173,17 @@ mod tests { | ||
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,14 +254,8 @@ mod tests { | ||
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,11 +50,7 @@ pub fn chat_completions_handler(engine: Arc<Engine>) -> HandlerFn { | ||
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,11 +104,7 @@ pub fn completions_handler(engine: Arc<Engine>) -> HandlerFn { | ||
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,11 +225,7 @@ pub fn scheduled_chat_completions_handler(engine: Arc<SchedulerEngine>) -> Handl | ||
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,11 +279,7 @@ pub fn scheduled_completions_handler(engine: Arc<SchedulerEngine>) -> HandlerFn | ||
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,15 +314,8 @@ fn routing_context(body: &[u8]) -> Option<RouteRequest> { | ||
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,11 +394,7 @@ pub fn routed_chat_completions_handler( | ||
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,11 +457,7 @@ pub fn routed_completions_handler( | ||
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,17 +564,11 @@ mod tests { | ||
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,10 +582,7 @@ mod tests { | ||
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,11 +597,7 @@ mod tests { | ||
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,10 +609,7 @@ mod tests { | ||
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,10 +674,7 @@ mod tests { | ||
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,10 +701,7 @@ mod tests { | ||
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,10 +716,7 @@ mod tests { | ||
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,17 +734,11 @@ mod tests { | ||
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,10 +753,7 @@ mod tests { | ||
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,10 +772,7 @@ mod tests { | ||
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,6 +48,8 @@ pub struct PathItem { | ||
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,12 +69,13 @@ impl PathItem { | ||
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,6 +95,11 @@ pub struct Operation { | ||
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,9 +160,7 @@ pub enum Schema { | ||
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,45 +242,27 @@ pub struct SchemaObject { | ||
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,9 +271,7 @@ impl SchemaObject { | ||
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,18 +333,14 @@ impl OpenApiBuilder { | ||
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,6 +359,7 @@ impl OpenApiBuilder { | ||
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,22 +370,12 @@ impl OpenApiBuilder { | ||
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,6 +405,21 @@ pub struct RouteMeta { | ||
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,17 +442,15 @@ impl OpenApiRegistry { | ||
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,8 +462,14 @@ impl OpenApiRegistry { | ||
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,22 +496,14 @@ impl OpenApiRegistry { | ||
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,20 +520,38 @@ impl OpenApiRegistry { | ||
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,14 +709,12 @@ mod tests { | ||
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,6 +749,11 @@ mod tests { | ||
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,13 +780,16 @@ mod tests { | ||
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,9 +813,7 @@ mod tests { | ||
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,21 +823,17 @@ mod tests { | ||
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,13 +851,16 @@ mod tests { | ||
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,13 +880,16 @@ mod tests { | ||
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,9 +915,7 @@ mod tests { | ||
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,14 +939,12 @@ mod tests { | ||
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,9 +29,7 @@ pub fn with_panic_recovery(handler: HandlerFn) -> HandlerFn { | ||
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,10 +9,7 @@ pub struct RateLimitResult { | ||
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,10 +53,7 @@ impl RateLimiter { | ||
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,10 +75,7 @@ impl RateLimiter { | ||
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,10 +89,7 @@ impl RateLimiter { | ||
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,10 +173,7 @@ pub struct PerRouteCircuitBreakerMiddleware { | ||
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,8 +224,7 @@ impl<B: Send + Sync + 'static> Middleware<B> for PerRouteCircuitBreakerMiddlewar | ||
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,10 +254,7 @@ impl<B: Send + 'static> Middleware<B> for TimeoutMiddleware { | ||
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,11 +289,7 @@ impl RetryPolicy { | ||
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,10 +315,7 @@ pub struct BulkheadConfig { | ||
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,10 +327,7 @@ pub struct Bulkhead { | ||
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,11 +344,9 @@ impl BulkheadMiddleware { | ||
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,10 +383,7 @@ pub struct FallbackEntry { | ||
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,20 +577,13 @@ mod tests { | ||
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,10 +791,7 @@ mod tests { | ||
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,10 +802,7 @@ mod tests { | ||
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,23 +907,13 @@ mod tests { | ||
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,10 +922,7 @@ mod tests { | ||
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,10 +936,7 @@ mod tests { | ||
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,11 +946,7 @@ mod tests { | ||
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,10 +995,7 @@ mod tests { | ||
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,10 +1035,8 @@ mod tests { | ||
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,10 +1272,7 @@ mod tests { | ||
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,10 +1283,7 @@ mod tests { | ||
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,10 +1320,7 @@ mod tests { | ||
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,10 +1337,7 @@ mod tests { | ||
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,10 +1387,8 @@ mod tests { | ||
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,10 +1438,7 @@ mod tests { | ||
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,11 +26,7 @@ pub struct Router<T> { | ||
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,10 +55,7 @@ impl<T: Clone> Router<T> { | ||
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,9 +90,7 @@ mod tests { | ||
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,18 +98,14 @@ mod tests { | ||
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,9 +115,7 @@ mod tests { | ||
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,9 +124,7 @@ mod tests { | ||
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,9 +133,7 @@ mod tests { | ||
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,9 +144,7 @@ mod tests { | ||
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,9 +152,7 @@ mod tests { | ||
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,9 +201,7 @@ mod tests { | ||
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,18 +229,10 @@ mod tests { | ||
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,10 +21,7 @@ pub struct Secret { | ||
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,17 +30,11 @@ impl Secret { | ||
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,9 +85,7 @@ impl SecretsRegistry { | ||
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,11 +66,7 @@ pub struct JsonResponse<T: serde::Serialize> { | ||
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,10 +121,7 @@ mod tests { | ||
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,10 +129,7 @@ mod tests { | ||
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,11 +147,7 @@ mod tests { | ||
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,11 +160,7 @@ mod tests { | ||
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,11 +178,7 @@ mod tests { | ||
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,9 +190,7 @@ mod tests { | ||
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,10 +210,7 @@ mod tests { | ||
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,10 +110,7 @@ fn default_ws_echo() -> WsHandler { | ||
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,37 +356,21 @@ impl Server { | ||
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,8 +475,7 @@ impl Server { | ||
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,9 +505,7 @@ impl Server { | ||
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,8 +575,7 @@ impl Server { | ||
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,9 +636,7 @@ impl Server { | ||
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,10 +654,7 @@ impl Server { | ||
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,15 +805,8 @@ impl Server { | ||
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,7 +822,13 @@ pub async fn serve(listener: TcpListener) -> Result<()> { | ||
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,16 +844,7 @@ pub async fn serve(listener: TcpListener) -> Result<()> { | ||
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,10 +893,7 @@ fn make_handler( | ||
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,6 +914,33 @@ fn make_handler( | ||
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,6 +951,7 @@ async fn serve_http( | ||
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,9 +980,17 @@ async fn serve_http( | ||
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,17 +1125,38 @@ async fn serve_http( | ||
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,35 +1168,33 @@ async fn serve_http( | ||
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,10 +1221,7 @@ async fn serve_http( | ||
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,10 +1280,7 @@ async fn execute_handler( | ||
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,11 +1290,8 @@ async fn execute_handler( | ||
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,11 +1311,8 @@ async fn execute_handler( | ||
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,22 +1341,17 @@ async fn execute_handler( | ||
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,21 +1433,18 @@ async fn serve_with_tls( | ||
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,7 +1472,13 @@ async fn serve_with_tls( | ||
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,11 +1602,12 @@ async fn serve_with_tls( | ||
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,7 +1624,7 @@ async fn serve_with_tls( | ||
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,12 +1645,29 @@ async fn serve_with_tls( | ||
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,10 +1699,7 @@ async fn serve_with_tls( | ||
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,10 +1731,7 @@ mod tests { | ||
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,10 +1756,7 @@ mod tests { | ||
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,9 +1783,7 @@ mod tests { | ||
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,9 +1791,7 @@ mod tests { | ||
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,6 +11,32 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt}; | ||
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,9 +45,12 @@ 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,13 +107,10 @@ impl StaticDir { | ||
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,13 +122,10 @@ impl StaticDir { | ||
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,9 +175,7 @@ impl StaticDir { | ||
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,15 +190,11 @@ impl StaticDir { | ||
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,15 +206,11 @@ impl StaticDir { | ||
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,10 +323,7 @@ fn parse_range(range_str: &str, total_len: u64) -> Option<ByteRange> { | ||
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,14 +356,8 @@ mod tests { | ||
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,24 +376,15 @@ mod tests { | ||
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,6 +48,17 @@ impl TestClient { | ||
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,10 +84,7 @@ impl TestClient { | ||
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,11 +95,9 @@ impl TestClient { | ||
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,12 +133,7 @@ fn parse_response(raw: &[u8]) -> Result<TestResponse, anyhow::Error> { | ||
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,11 +146,52 @@ fn parse_response(raw: &[u8]) -> Result<TestResponse, anyhow::Error> { | ||
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,12 +229,9 @@ mod tests { | ||
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,9 +114,7 @@ pub fn init_logging(config: &LoggingConfig) -> Result<()> { | ||
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,9 +130,8 @@ pub fn init_logging(config: &LoggingConfig) -> Result<()> { | ||
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,20 +142,15 @@ pub fn init_logging(config: &LoggingConfig) -> Result<()> { | ||
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,10 +173,7 @@ pub fn init_tracing() -> Result<()> { | ||
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,6 +4,7 @@ use http_body_util::BodyExt; | ||
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,12 +21,7 @@ pub struct ValidationError { | ||
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,10 +29,7 @@ impl ValidationError { | ||
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,6 +197,52 @@ pub fn validate_json_schema(body: &[u8], schema_json: &str) -> Result<(), Valida | ||
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,14 +305,8 @@ mod tests { | ||
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,4 +396,15 @@ mod tests { | ||
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,13 +26,9 @@ impl WasmEngine { | ||
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,9 +37,7 @@ impl WasmEngine { | ||
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,9 +26,8 @@ async fn send_request_with_headers( | ||
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,9 +38,7 @@ async fn send_request_with_headers( | ||
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,9 +49,7 @@ async fn send_request_with_headers( | ||
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,9 +177,7 @@ async fn test_loopback_websocket() { | ||
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,9 +187,8 @@ async fn test_loopback_websocket() { | ||
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,11 +81,7 @@ impl Autoscaler { | ||
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,12 +158,7 @@ mod tests { | ||
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,10 +230,7 @@ mod tests { | ||
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,10 +24,7 @@ pub enum WeightLocation { | ||
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,11 +136,7 @@ impl ModelRecord { | ||
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,8 +158,7 @@ impl ModelRecord { | ||
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,25 +196,18 @@ pub struct ControlPlane { | ||
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,9 +264,7 @@ impl ControlPlane { | ||
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,11 +291,8 @@ mod tests { | ||
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,18 +411,13 @@ mod tests { | ||
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,8 +77,7 @@ pub struct ModelRegistry { | ||
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,13 +111,8 @@ pub struct Engine { | ||
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,10 +122,7 @@ impl Engine { | ||
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,10 +133,7 @@ impl Engine { | ||
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,10 +150,7 @@ impl Engine { | ||
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,10 +169,7 @@ impl Engine { | ||
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,8 +210,7 @@ impl Engine { | ||
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,11 +228,6 @@ impl Engine { | ||
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,13 +106,8 @@ impl InferenceGateway { | ||
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,11 +131,7 @@ impl InferenceGateway { | ||
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,11 +192,7 @@ mod tests { | ||
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,12 +211,7 @@ mod tests { | ||
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,21 +223,13 @@ mod tests { | ||
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,12 +239,7 @@ mod tests { | ||
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,11 +74,7 @@ struct BlockMeta { | ||
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,14 +101,7 @@ impl KvBlockPool { | ||
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,11 +316,7 @@ pub struct Sequence { | ||
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,9 +115,7 @@ pub struct MockModel { | ||
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,12 +160,7 @@ impl Model for MockModel { | ||
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,17 +317,11 @@ pub fn prompt_from_messages(messages: &[ChatMessage]) -> String { | ||
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,11 +478,7 @@ pub async fn chat_completion( | ||
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,11 +587,7 @@ pub async fn completion( | ||
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,11 +609,7 @@ pub fn embeddings(engine: &Engine, req: EmbeddingRequest) -> Result<EmbeddingRes | ||
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,11 +617,7 @@ pub fn embeddings(engine: &Engine, req: EmbeddingRequest) -> Result<EmbeddingRes | ||
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,11 +660,7 @@ pub async fn scheduled_chat_completion( | ||
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,11 +787,7 @@ pub async fn scheduled_completion( | ||
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,10 +879,7 @@ pub fn model_list(engine: &Engine) -> ModelList { | ||
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,16 +1014,8 @@ mod tests { | ||
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,9 +143,8 @@ impl PdScheduler { | ||
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,10 +210,7 @@ mod tests { | ||
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,10 +287,7 @@ mod tests { | ||
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,10 +300,7 @@ mod tests { | ||
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,13 +50,7 @@ fn hash_tokens(tokens: &[u32]) -> u64 { | ||
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,11 +119,7 @@ impl RadixPrefixCache { | ||
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,8 +145,7 @@ impl RadixPrefixCache { | ||
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,8 +295,7 @@ fn insert_node(node: &mut RadixNode, tokens: &[u32], blocks: &[BlockId], now: u6 | ||
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,10 +453,7 @@ mod tests { | ||
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,10 +720,7 @@ mod tests { | ||
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,12 +42,7 @@ impl LoraAdapter { | ||
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,13 +140,7 @@ impl LoraRegistry { | ||
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,27 +28,17 @@ fn is_valid_gguf(path: &Path) -> bool { | ||
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,9 +114,9 @@ impl ForwardState { | ||
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,6 +223,15 @@ impl RealModel { | ||
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,13 +255,7 @@ impl RealModel { | ||
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,9 +276,8 @@ impl RealModel { | ||
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,16 +317,9 @@ fn build_processor(seed: u64, params: &SamplingParams) -> LogitsProcessor { | ||
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,9 +334,7 @@ impl Model for RealModel { | ||
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,9 +360,7 @@ impl Model for RealModel { | ||
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,17 +369,8 @@ impl Model for RealModel { | ||
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,9 +383,7 @@ impl Model for RealModel { | ||
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,11 +73,7 @@ pub struct QuantConfig { | ||
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,11 +203,7 @@ mod tests { | ||
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,28 +214,14 @@ mod tests { | ||
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,13 +240,7 @@ mod tests { | ||
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,12 +20,9 @@ fn extract_string_array(metadata: &HashMap<String, Value>, key: &str) -> Vec<Str | ||
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,12 +80,8 @@ impl Tokenizer { | ||
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,11 +89,7 @@ pub struct Router { | ||
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,20 +163,16 @@ impl Router { | ||
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,10 +187,7 @@ impl Router { | ||
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,11 +239,7 @@ mod tests { | ||
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,11 +255,7 @@ mod tests { | ||
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,11 +270,7 @@ mod tests { | ||
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,11 +320,7 @@ mod tests { | ||
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,14 +330,7 @@ mod tests { | ||
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,11 +371,7 @@ mod tests { | ||
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,11 +272,8 @@ impl Scheduler { | ||
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,10 +311,7 @@ impl Scheduler { | ||
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,8 +395,7 @@ impl Scheduler { | ||
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,11 +442,7 @@ impl Scheduler { | ||
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,11 +477,7 @@ impl Scheduler { | ||
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,11 +489,7 @@ impl Scheduler { | ||
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,8 +508,7 @@ impl Scheduler { | ||
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,10 +653,7 @@ mod tests { | ||
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,10 +697,7 @@ mod tests { | ||
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,10 +750,7 @@ mod tests { | ||
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,10 +813,7 @@ mod tests { | ||
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,10 +875,7 @@ mod tests { | ||
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,10 +952,7 @@ mod tests { | ||
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,19 +970,13 @@ mod tests { | ||
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,10 +993,7 @@ mod tests { | ||
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,10 +1005,7 @@ mod tests { | ||
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,10 +1031,7 @@ mod tests { | ||
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,10 +1053,7 @@ mod tests { | ||
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,10 +1071,7 @@ mod tests { | ||
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,14 +124,8 @@ impl SchedulerEngine { | ||
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,11 +178,7 @@ impl SchedulerEngine { | ||
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,11 +201,7 @@ impl SchedulerEngine { | ||
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,12 +260,7 @@ fn get_seq_params( | ||
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,9 +282,7 @@ fn sample_token_with_params(logits: &[f32], params: &SamplingParams) -> u32 { | ||
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,11 +304,8 @@ fn sample_token_with_params(logits: &[f32], params: &SamplingParams) -> u32 { | ||
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,12 +407,7 @@ fn is_seq_done( | ||
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,11 +462,7 @@ mod tests { | ||
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,19 +473,12 @@ mod tests { | ||
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,16 +487,9 @@ mod tests { | ||
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,19 +519,11 @@ mod tests { | ||
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,11 +535,7 @@ mod tests { | ||
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,10 +578,7 @@ mod tests { | ||
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, ¶ms), 2); |
645 | 583 | } |
646 | 584 | |
| @@ -648,10 +586,7 @@ mod tests { | ||
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, ¶ms), 2); |
657 | 592 | } |
| @@ -661,11 +596,7 @@ mod tests { | ||
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, ¶ms), 2); |
671 | 602 | } |
| @@ -676,10 +607,7 @@ mod tests { | ||
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, ¶ms)); |
686 | 614 | |
▼crates/justapi-inference/src/spec_decode.rs
+13-54
| @@ -45,10 +45,7 @@ pub struct AcceptanceStats { | ||
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,9 +109,7 @@ pub fn sample_token(logits: &[f32], params: &SamplingParams, rng: &mut StdRng) - | ||
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,11 +127,8 @@ pub fn sample_token(logits: &[f32], params: &SamplingParams, rng: &mut StdRng) - | ||
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,11 +405,7 @@ mod tests { | ||
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, ¶ms); |
423 | 411 | let (spec, _) = collect_spec(&target, &draft, &prompt, ¶ms, 0); |
| @@ -431,11 +419,7 @@ mod tests { | ||
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, ¶ms); |
441 | 425 | let (spec, _) = collect_spec(&target, &draft, &prompt, ¶ms, 4); |
| @@ -448,11 +432,7 @@ mod tests { | ||
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], ¶ms, gamma); |
457 | 437 | assert_eq!(spec.len(), max); |
458 | 438 | // With a perfect draft, every step emits gamma+1 tokens, so we need |
| @@ -470,37 +450,19 @@ mod tests { | ||
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], ¶ms, 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], ¶ms, 3); |
505 | 467 | assert_eq!(spec.len(), max); |
506 | 468 | } |
| @@ -525,10 +487,7 @@ mod tests { | ||
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, ¶ms, &mut rng), 1); |
534 | 493 | } |
535 | 494 | |
▼crates/justapi-inference/src/spec_decode_tree.rs
+33-151
| @@ -35,11 +35,7 @@ pub struct TreeNode { | ||
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,11 +67,8 @@ pub fn top_k_tokens(logits: &[f32], k: usize) -> Vec<(u32, f32)> { | ||
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,11 +85,7 @@ pub fn build_draft_tree( | ||
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,21 +104,13 @@ pub fn build_draft_tree( | ||
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,11 +137,8 @@ pub fn verify_tree( | ||
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,10 +151,7 @@ pub fn verify_tree( | ||
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,13 +292,7 @@ impl TreeSpeculativeModel { | ||
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,11 +340,7 @@ mod tests { | ||
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,28 +445,16 @@ mod tests { | ||
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,24 +467,13 @@ mod tests { | ||
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, ¶ms); |
524 | 473 | let (spec, stats) = collect_tree(&target, &draft, &prompt, ¶ms, 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,11 +483,7 @@ mod tests { | ||
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, ¶ms); |
551 | 489 | let (spec, _) = collect_tree(&target, &draft, &prompt, ¶ms, 0, 3); |
| @@ -558,11 +496,7 @@ mod tests { | ||
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, ¶ms); |
568 | 502 | // With branch=1, tree has only one candidate per depth = essentially |
| @@ -579,21 +513,13 @@ mod tests { | ||
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, ¶ms); |
589 | 519 | let (spec, stats) = collect_tree(&target, &draft, &prompt, ¶ms, 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,11 +533,7 @@ mod tests { | ||
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, ¶ms, 4, 1); |
| @@ -633,11 +555,7 @@ mod tests { | ||
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], ¶ms, 4, 3); |
642 | 560 | assert_eq!(spec.len(), 15); |
643 | 561 | } |
| @@ -672,10 +590,7 @@ mod tests { | ||
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], ¶ms, &mut rng).unwrap(); |
681 | 596 | assert_eq!(accepted, vec![1]); |
| @@ -692,10 +607,7 @@ mod tests { | ||
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], ¶ms, &mut rng).unwrap(); |
701 | 613 | assert_eq!(accepted.len(), 0); |
| @@ -722,16 +634,9 @@ mod tests { | ||
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], ¶ms, &mut rng).unwrap(); |
737 | 642 | // Accepted: [1, 2, 3] — the matching path. |
| @@ -750,16 +655,9 @@ mod tests { | ||
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], ¶ms, &mut rng).unwrap(); |
765 | 663 | assert_eq!(accepted, vec![1]); // only first token accepted |
| @@ -774,11 +672,7 @@ mod tests { | ||
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], ¶ms, 3, 3); |
783 | 677 | assert_eq!(stats.tree_branch, 3); |
784 | 678 | assert!(stats.steps > 0); |
| @@ -792,11 +686,7 @@ mod tests { | ||
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, ¶ms); |
802 | 692 | |
| @@ -813,11 +703,7 @@ mod tests { | ||
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, &[], ¶ms, 2, 2); |
822 | 708 | assert_eq!(spec.len(), 5); |
823 | 709 | } |
| @@ -834,11 +720,7 @@ mod tests { | ||
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], ¶ms); |
844 | 726 | let wrapped = collect(&spec, &[3u32], ¶ms); |
845 | 727 | |
▼crates/justapi-inference/src/supervisor.rs
+4-18
| @@ -37,11 +37,7 @@ pub struct LiveReplica { | ||
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,10 +125,7 @@ impl Supervisor { | ||
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,12 +231,7 @@ mod tests { | ||
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,9 +285,7 @@ mod tests { | ||
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,10 +17,7 @@ fn mock_tokenize_detokenize_roundtrip() { | ||
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], ¶ms, &|t| { |
| @@ -37,11 +34,7 @@ fn mock_generate_respects_max_tokens() { | ||
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,10 +66,7 @@ fn engine_registers_and_lists_mock() { | ||
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,11 +117,7 @@ fn engine_speculative_decoding_end_to_end() { | ||
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,10 +134,7 @@ fn engine_speculative_decoding_end_to_end() { | ||
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,11 +142,7 @@ fn acceptance_rate_perfect_draft_is_one() { | ||
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], ¶ms, 4, 11, &|t| { |
170 | 149 | |
▼crates/justapi-py/Cargo.toml
+5-0
| @@ -12,11 +12,13 @@ pyo3.workspace = true | ||
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,3 +26,6 @@ tonic = "0.14.6" | ||
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,13 +27,25 @@ Usage: | ||
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,13 +167,17 @@ def pydantic_schema(model_class) -> str: | ||
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,11 +1,11 @@ | ||
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 +1,297 @@ | ||
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 +6,14 @@ import concurrent.futures | ||
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,6 +49,16 @@ def _get_loop(): | ||
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,7 +66,9 @@ def wrap_result(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,10 +76,12 @@ def wrap_result(result): | ||
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,11 +90,11 @@ def call_handler(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,6 +159,52 @@ def _pump_stream(generator, sender): | ||
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,11 +1,107 @@ | ||
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,6 +109,32 @@ 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,6 +152,47 @@ class JustAPIApp: | ||
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,15 +259,27 @@ class JustAPIApp: | ||
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,25 +290,21 @@ class JustAPIApp: | ||
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,15 +377,27 @@ class JustAPIApp: | ||
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,25 +408,21 @@ class JustAPIApp: | ||
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,11 +472,31 @@ class JustAPIApp: | ||
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,6 +511,18 @@ class JustAPIApp: | ||
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,11 +541,10 @@ class JustAPIApp: | ||
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,8 +558,7 @@ class JustAPIApp: | ||
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,16 +570,26 @@ class JustAPIApp: | ||
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,78 +600,177 @@ class JustAPIApp: | ||
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,7 +805,7 @@ class JustAPIApp: | ||
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,30 +842,206 @@ class JustAPIApp: | ||
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,66 +1099,137 @@ class JustAPIApp: | ||
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,6 +1248,58 @@ class APIRouter: | ||
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,8 +1355,18 @@ def route_sse(path: str, dependencies: typing.List[Depends] = None): | ||
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,3 +1428,5 @@ class JustAPITestClient: | ||
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 | |