πŸš€ release: 2.0.8 β€” write-path fix (ADR-080), fork-safe GIL pool (ADR-081), HTTP/3 (ADR-082), Security DI fix, docs honesty pass

swadhinbiswas committed 2 months ago
731fffe
1 parent 04e52cd
commit 731fffe
74 files changed
+2812 -762
Showing 74 changed files+2812-761
.github/workflows/ci.yml.github/workflows/publish.yml.github/workflows/wheels.ymlAGENTS.mdBENCHMARKS.mdCHANGELOG.mdCargo.lockCargo.tomlDECISIONS.mdPLAN.mdREADME.mdcrates/justapi-core/Cargo.tomlcrates/justapi-core/src/http3.rscrates/justapi-core/src/lib.rscrates/justapi-core/src/server/mod.rscrates/justapi-core/src/testing.rscrates/justapi-core/tests/db_bridge.rscrates/justapi-core/tests/edge_cases.rscrates/justapi-core/tests/http3_test.rscrates/justapi-py/Cargo.tomlcrates/justapi-py/pyproject.tomlcrates/justapi-py/python/justapi/__init__.pycrates/justapi-py/python/justapi/app.pycrates/justapi-py/python/justapi/test_db_concurrent.pycrates/justapi-py/python/justapi/test_db_saturated.pycrates/justapi-py/python/justapi/test_dependency_injection.pycrates/justapi-py/python/justapi/test_native.pycrates/justapi-py/python/justapi/test_testing.pycrates/justapi-py/python/justapi/testing.pycrates/justapi-py/src/gil_pool.rscrates/justapi-py/src/native/app.rscrates/justapi-py/src/native/handlers.rscrates/justapi-py/src/request.rscrates/justapi-py/src/test_client.rsdocs/SCOPE_CUT.mddocs_site/src/content/docs/advanced/advanced-middleware.mddocs_site/src/content/docs/advanced/advanced-security.mddocs_site/src/content/docs/advanced/async-tests.mddocs_site/src/content/docs/advanced/batching.mddocs_site/src/content/docs/advanced/behind-a-proxy.mddocs_site/src/content/docs/advanced/custom-response.mddocs_site/src/content/docs/advanced/response-cookies-headers.mddocs_site/src/content/docs/advanced/response-directly.mddocs_site/src/content/docs/advanced/sub-applications.mddocs_site/src/content/docs/advanced/templates.mddocs_site/src/content/docs/advanced/using-request-directly.mddocs_site/src/content/docs/advanced/websockets-advanced.mddocs_site/src/content/docs/api-reference/justapiapp.mddocs_site/src/content/docs/api-reference/scheduler.mddocs_site/src/content/docs/deployment/http3.mddocs_site/src/content/docs/getting-started/migration-guide.mddocs_site/src/content/docs/how-to/circuit-breaker-recipes.mddocs_site/src/content/docs/how-to/custom-request-route.mddocs_site/src/content/docs/how-to/graphql.mddocs_site/src/content/docs/how-to/performance-tuning.mddocs_site/src/content/docs/reference/api-stability.mddocs_site/src/content/docs/reference/glossary.mddocs_site/src/content/docs/resources/external-links.mddocs_site/src/content/docs/security/secure-configuration.mddocs_site/src/content/docs/tutorials/cors.mddocs_site/src/content/docs/tutorials/error-handling.mddocs_site/src/content/docs/tutorials/middleware.mddocs_site/src/content/docs/tutorials/response-status-code.mddocs_site/src/content/docs/tutorials/security/first-steps/index.mddocs_site/src/content/docs/tutorials/security/get-current-user/index.mddocs_site/src/content/docs/tutorials/security/oauth2-jwt/index.mddocs_site/src/content/docs/tutorials/security/simple-oauth2/index.mddocs_site/src/content/docs/tutorials/static-files.mdexamples/agent_demo/README.mdexamples/agent_demo/app.pyexamples/agent_demo/run_demo.shscripts/publish.shscripts/sanitize.shstream
β–Ό.github/workflows/ci.yml
+15-11
60
60
# ============================================================
61
61
62
62
miri:
63
- name: Miri (Memory Safety)
63
+ name: Memory Safety (ASan + targeted Miri)
64
64
runs-on: ubuntu-latest
65
- continue-on-error: true
66
- timeout-minutes: 15
65
+ timeout-minutes: 25
67
66
steps:
68
67
- uses: actions/checkout@v4
69
68
78
77
path: |
79
78
~/.cargo/registry
80
79
~/.cargo/git
81
- target
82
- key: ${{ runner.os }}-miri-${{ hashFiles('**/Cargo.lock') }}
80
+ /tmp/justapi-asan-target
81
+ /tmp/justapi-miri-target
82
+ key: ${{ runner.os }}-sanitize-${{ hashFiles('**/Cargo.lock') }}
83
83
restore-keys: |
84
- ${{ runner.os }}-miri-
84
+ ${{ runner.os }}-sanitize-
85
85
86
- - name: Run Miri on justapi-core
87
- run: cargo +nightly miri test -p justapi-core --lib --features db
88
- env:
89
- MIRIFLAGS: "-Zmiri-disable-isolation"
86
+ - name: Run memory-safety gate (ASan + targeted Miri)
87
+ # Fast replacement for the full-suite Miri run (which took 20+ min and
88
+ # mostly covered safe Rust). Only memory.rs has `unsafe`; ASan covers
89
+ # use-after-free/overflow/leaks at near-native speed and the targeted
90
+ # Miri run exercises the arena's unsafe under stacked-borrows.
91
+ run: bash scripts/sanitize.sh
90
92
91
93
audit:
92
94
name: Security Audit
423
425
name: Publish to PyPI
424
426
runs-on: ubuntu-latest
425
427
needs: [check, pytest, integration-tests, docker]
426
- if: startsWith(github.ref, 'refs/tags/v')
428
+ # NOTE: publishing is owned by wheels.yml (7-platform matrix). This
429
+ # duplicate single-platform job is disabled to avoid double-publish.
430
+ if: false
427
431
environment: pypi
428
432
permissions:
429
433
id-token: write
430
434
β–Ό.github/workflows/publish.yml
+6-1
59
59
environment: pypi
60
60
permissions:
61
61
id-token: write
62
+ # PyPI upload is owned by wheels.yml (7-platform matrix on tag push).
63
+ # This job only builds + verifies; the release assets come from wheels.yml.
64
+ if: false
62
65
steps:
63
66
- name: Download wheel artifact
64
67
uses: actions/download-artifact@v4
74
77
create-github-release:
75
78
name: Create GitHub Release
76
79
runs-on: ubuntu-latest
77
- needs: [publish-pypi]
80
+ # PyPI upload is owned by wheels.yml (7-platform matrix). This job only
81
+ # builds + verifies the wheel, then creates the GitHub Release with it.
82
+ needs: [build]
78
83
permissions:
79
84
contents: write
80
85
steps:
81
86
β–Ό.github/workflows/wheels.yml
+5-5
33
33
run: cargo fmt --check
34
34
35
35
miri-test:
36
- name: Miri Test (justapi-core)
36
+ name: Memory Safety (ASan + targeted Miri)
37
37
runs-on: ubuntu-latest
38
38
needs: test-rust
39
39
steps:
41
41
- uses: dtolnay/rust-toolchain@nightly
42
42
with:
43
43
components: miri
44
- - name: Run Miri
45
- run: cargo miri test -p justapi-core
46
- env:
47
- MIRIFLAGS: -Zmiri-disable-isolation
44
+ - name: Run memory-safety gate
45
+ # Fast: ASan on the full core suite + targeted Miri on the only
46
+ # unsafe module (memory.rs). See scripts/sanitize.sh.
47
+ run: bash scripts/sanitize.sh
48
48
49
49
build-wheels:
50
50
name: Build Wheels (${{ matrix.os }} - ${{ matrix.target }})
51
51
β–ΌAGENTS.md
+7-4
44
44
- **Core Runtime** β€” networking, protocol, memory, scheduler correctness and
45
45
safety (`justapi-core`).
46
46
- **Python Bindings** β€” PyO3 boundary, GIL/free-threading correctness, the
47
- ASGI shim, native API (`justapi-py`).
47
+ native API (`justapi-py`).
48
48
- **Protocol/Security** β€” TLS, auth, JWT, OAuth2/OIDC, request validation,
49
49
SAST scanning, fuzzing, anything parsing untrusted input.
50
50
Every change here needs a fuzz target or an explanation of why not.
88
88
- [ ] Tests pass (`cargo test --workspace`)
89
89
- [ ] `cargo clippy --workspace --tests -- -D warnings` clean (includes test code)
90
90
- [ ] `cargo fmt --check` clean
91
-- [ ] `cargo miri test -p justapi-core` clean (if core touched)
91
+- [ ] `bash scripts/sanitize.sh` clean (if core touched) β€” ASan on the full core
92
+ suite + a targeted Miri run on the only `unsafe` module (`memory.rs`).
93
+ This replaces the full-suite `cargo miri` run (interpreted = 20+ min for
94
+ mostly-safe code; sanitizers run at near-native speed).
92
95
- [ ] Benchmarks run, numbers appended to `BENCHMARKS.md` (not overwritten)
93
96
- [ ] No p99 regression >5% vs. previous phase without a `DECISIONS.md` entry
94
97
- [ ] `PLAN.md` updated: phase status, next steps
120
123
Cargo.toml # workspace root
121
124
crates/
122
125
justapi-core/ # networking, protocol, scheduler, memory
123
- justapi-py/ # PyO3 bindings, ASGI shim, native API
126
+ justapi-py/ # PyO3 bindings, native API
124
127
justapi-cli/ # `justapi` CLI binary
125
128
justapi-bench/ # internal benchmark harness
126
129
python/
141
144
β†’ TLS (rustls) β†’ HTTP parse (hyper) β†’ Router (matchit)
142
145
β†’ Middleware chain (Rust: auth/CORS/rate-limit/compression)
143
146
β†’ Python boundary (typed, zero-copy request view via PyO3 buffer protocol)
144
- β†’ Application code (ASGI shim OR native handler)
147
+ β†’ Application code (native handler)
145
148
β†’ Rust serializer (serde_json/simd-json)
146
149
β†’ Response write β†’ Socket
147
150
```
148
151
β–ΌBENCHMARKS.md
+112-0
1804
1804
show less differentiation, though JustAPI native still leads on UPDATE/DELETE
1805
1805
due to eliminating the Python GIL hop.
1806
1806
1807
+---
1808
+
1809
+## Real DB-backed CRUD benchmark β€” RE-RUN on current code (recorded 2026-08-06)
1810
+
1811
+Re-run of `benchmarks/run_crud_bench.sh` (same fixture: i5-13600K, SQLite WAL,
1812
+`oha -c 20 -z 5s`) against the **current HEAD with a release (optimized) wheel**.
1813
+Two methodology corrections vs the 2026-07-26 entry:
1814
+
1815
+- **Release build only.** Earlier numbers were recorded against the release
1816
+ wheel; a dev/debug `maturin develop` build measures ~4Γ— slower for the
1817
+ Python-handler path and must not be used for benchmarking.
1818
+- **Python-handler write RPS collapsed to ~6 at `-c 20`** (measured 147 at
1819
+ `-c 1`). This is the GIL-pool backpressure fix (2026-07-25, PLAN.md): the
1820
+ single Python worker throttles (instead of dropping) when its bounded channel
1821
+ is full, so concurrent Python-handler *writes* serialize at ~6 RPS. This is a
1822
+ correctness/throughput tradeoff, not a durability bug β€” all writes commit.
1823
+ The Rust-native path is unaffected and remains the recommended write route.
1824
+
1825
+### JustAPI β€” Python-handler CRUD (current code, release)
1826
+
1827
+| Operation | RPS | p50 | p99 |
1828
+|-----------|-----|-----|-----|
1829
+| INSERT | 6.0 | β€” | β€” |
1830
+| SELECT | 16,542 | β€” | β€” |
1831
+| UPDATE | 6.4 | β€” | β€” |
1832
+| DELETE | 6.4 | β€” | β€” |
1833
+
1834
+### JustAPI β€” Rust-native CRUD (current code, release)
1835
+
1836
+| Operation | RPS | p50 | p99 |
1837
+|-----------|-----|-----|-----|
1838
+| INSERT | 153.5 | β€” | β€” |
1839
+| SELECT | **181,030** | β€” | β€” |
1840
+| UPDATE | 31,877 | β€” | β€” |
1841
+| DELETE | 37,749 | β€” | β€” |
1842
+
1843
+### FastAPI + SQLAlchemy (async) β€” current run
1844
+
1845
+| Operation | RPS | p50 | p99 |
1846
+|-----------|-----|-----|-----|
1847
+| INSERT | 124.7 | β€” | β€” |
1848
+| SELECT | 1,449 | β€” | β€” |
1849
+| UPDATE | 1,546 | β€” | β€” |
1850
+| DELETE | 1,718 | β€” | β€” |
1851
+
1852
+### Robyn (sync handler, sqlite3) β€” current run
1853
+
1854
+| Operation | RPS | p50 | p99 |
1855
+|-----------|-----|-----|-----|
1856
+| INSERT | 155.5 | β€” | β€” |
1857
+| SELECT | 31,638 | β€” | β€” |
1858
+| UPDATE | 26,768 | β€” | β€” |
1859
+| DELETE | 28,621 | β€” | β€” |
1860
+
1861
+### Summary β€” SELECT (framework-differentiated metric), current code
1862
+
1863
+| Framework | SELECT RPS | vs FastAPI |
1864
+|---|---:|---|
1865
+| **JustAPI native** | **181,030** | **Γ—125** |
1866
+| JustAPI Python | 16,542 | Γ—11 |
1867
+| Robyn | 31,638 | Γ—22 |
1868
+| FastAPI + SQLAlchemy | 1,449 | Γ—1 (baseline) |
1869
+
1870
+**Honest read:** JustAPI native is **125Γ— faster than FastAPI** on DB-backed
1871
+SELECT on current code β€” the headline claim holds and improves (the previous
1872
+ledger recorded 108Γ—). The Python-handler path is lower than the 2026-07-26
1873
+recording (16.5k vs 107k); the gap is the GIL-pool backpressure fix plus the
1874
+corrected release methodology, and it is the recommended-practice tradeoff for
1875
+the fully-native API. UPDATE/DELETE remain dominated by JustAPI native. Writes
1876
+on single-file SQLite are SQLite-bound for all frameworks; JustAPI native
1877
+INSERT is comparable to FastAPI/Robyn and its UPDATE/DELETE lead because the
1878
+row mutation happens in Rust with no GIL hop.
1879
+
1880
+---
1881
+
1882
+## Write-path collapse FOUND + FIXED (recorded 2026-08-06, ADR-080)
1883
+
1884
+The Python-handler write numbers above (INSERT ~6 RPS at `-c 20`) were NOT a
1885
+SQLite ceiling β€” they were a real defect, now fixed. Root cause: the
1886
+request-scoped auto-transaction double-acquired pool connections
1887
+(`2N` connections for `N` concurrent writes on an `N`-connection pool).
1888
+Removed; SQLite BUSY/lock errors now map to 503 Retry-After instead of 500.
1889
+
1890
+**Before fix (release wheel, same fixture):**
1891
+
1892
+| Concurrency | Python-handler INSERT RPS | Notes |
1893
+|---|---:|---|
1894
+| c=1 | 151 | healthy |
1895
+| c=10 | 160 | healthy |
1896
+| c=11 | **2.8** | collapse β€” pool timed out @ request_acquire_timeout |
1897
+| c=20 | 2.7–6 | 500/503 errors, ~3s per request |
1898
+
1899
+**After fix (release wheel, same fixture):**
1900
+
1901
+| Concurrency | Python-handler INSERT RPS | success% |
1902
+|---|---:|---:|
1903
+| c=1 | 157 | 100 |
1904
+| c=5 | 162 | 100 |
1905
+| c=10 | 161 | 100 |
1906
+| c=11 | 158 | 100 |
1907
+| c=20 | 142 | 100 |
1908
+| c=50 | 158 | 100 |
1909
+
1910
+SELECT unchanged (19.5k @ c=20). Writes durable: 604 rows persisted from a 4s
1911
+burst @ c=20 (β‰ˆ151/s Γ— 4s). The GIL-worker serialization (~150 RPS ceiling for
1912
+Python-handler writes on this fixture) is now the *only* limiter β€” no pool
1913
+saturation, no 503 storm. Regression guards: `test_db_saturated.py` (503 on
1914
+lock contention, bounded) + `test_db_concurrent.py::test_concurrent_writes_small_pool_no_saturation_collapse`
1915
+(2-conn pool, 50 concurrent writers all 200 + persisted).
1916
+
1917
+
1918
+
1807
1919
β–ΌCHANGELOG.md
+20-0
64
64
- 2 new fuzz targets (WebSocket, gRPC)
65
65
- 521 total tests passing
66
66
67
+## [2.0.9-dev] - 2026-08-06
68
+
69
+### Added
70
+- **HTTP/3 (QUIC) transport** β€” `app.enable_http3(cert_path, key_path)` serves the app over HTTP/3 on the same port as TCP (feature `http3`; h3 + h3-quinn + quinn). Python handlers, DI, schema validation, and the GIL pool all work over QUIC (ADR-079, ADR-082)
71
+- **`Security` dependency marker** β€” `Security(dep, scopes=...)` is `Depends` with OAuth2-scope metadata (FastAPI parity); top-level re-exports of `JwtAuth`, `OAuth2PasswordBearer`, `OAuth2PasswordRequestForm`, `OAuth2PasswordRequestFormStrict` from `justapi.auth`
72
+- **QUERY method (RFC 10008) testability** β€” `TestClient::query`/`query_with`, `JustAPITestClient.query`/`query_with`, `AsyncTestClient.query` (RFC requires Content-Type; enforced with 400)
73
+- **`scripts/sanitize.sh`** β€” fast memory-safety gate: ASan on the full core suite + targeted Miri on the only `unsafe` module (`memory.rs`); replaces the 20+ min full-suite Miri run
74
+- **4 portable wheels** β€” manylinux + musllinux Γ— x86_64 + aarch64 via `maturin --zig`; `scripts/publish.sh` builds/verifies/upload all
75
+
76
+### Fixed
77
+- **CRITICAL: Python-handler write-path collapse** β€” concurrent writes dropped to ~3 RPS at c=11 (was 150 at c=10): the request-scoped auto-transaction double-acquired pool connections (`2N` for `N` writes). Removed; SQLite BUSY/LOCK now maps to 503 Retry-After. Writes now flat ~150 RPS c=1..50 (ADR-080)
78
+- **CRITICAL: GIL pool not fork-safe** β€” a forked child inherited the parent's initialized pool whose worker threads don't exist β†’ every Python request hung (504s). Pool now tracks its PID and rebuilds after `fork(2)`; fixes prefork deployments and the long-standing `test_circuit_breaker` flake (ADR-081)
79
+- **DI awaitedness bug** β€” `Depends`/`Security` on async callable *instances* (e.g. `OAuth2PasswordBearer`) were invoked without awaiting (unawaited coroutines leaked); `_is_async_callable` now checks `__call__`
80
+- **Default-feature test gate** β€” `db_bridge`/`edge_cases` integration tests now carry proper `#![cfg(feature = ...)]` guards; `cargo test --workspace` passes on default features
81
+
82
+### Changed
83
+- **PLAN.md honesty pass** β€” phase table distinguishes βœ… verified vs 🟑 implemented/unverified (all AI/inference phases marked unverified: MockModel-only, no GPU/real weights); Phase 52 stays frozen
84
+- **Docs fabrication sweep** β€” removed false APIs from docs (`justapi.middleware`, `PyScheduler`, `enable_rate_limiter`, `enable_compression`, `fastapi.security` imports); rewrote security tutorials, scheduler API, performance-tuning with the real API
85
+- **README comparison table** β€” now states the honest breakdown (700k native fast path / 120k Python / 180k DB SELECT) instead of a single number
86
+
67
87
## [Unreleased]
68
88
69
89
### Added
70
90
β–ΌCargo.lock
+159-7
686
686
source = "registry+https://github.com/rust-lang/crates.io-index"
687
687
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
688
688
689
+[[package]]
690
+name = "cfg_aliases"
691
+version = "0.2.2"
692
+source = "registry+https://github.com/rust-lang/crates.io-index"
693
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
694
+
695
+[[package]]
696
+name = "chacha20"
697
+version = "0.10.1"
698
+source = "registry+https://github.com/rust-lang/crates.io-index"
699
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
700
+dependencies = [
701
+ "cfg-if",
702
+ "cpufeatures 0.3.0",
703
+ "rand_core 0.10.1",
704
+]
705
+
689
706
[[package]]
690
707
name = "chrono"
691
708
version = "0.4.45"
843
860
"libc",
844
861
]
845
862
863
+[[package]]
864
+name = "cpufeatures"
865
+version = "0.3.0"
866
+source = "registry+https://github.com/rust-lang/crates.io-index"
867
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
868
+dependencies = [
869
+ "libc",
870
+]
871
+
846
872
[[package]]
847
873
name = "cranelift-assembler-x64"
848
874
version = "0.134.3"
2044
2070
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
2045
2071
dependencies = [
2046
2072
"cfg-if",
2073
+ "js-sys",
2047
2074
"libc",
2048
2075
"r-efi 6.0.0",
2076
+ "rand_core 0.10.1",
2077
+ "wasm-bindgen",
2049
2078
]
2050
2079
2051
2080
[[package]]
2108
2137
"tracing",
2109
2138
]
2110
2139
2140
+[[package]]
2141
+name = "h3"
2142
+version = "0.0.8"
2143
+source = "registry+https://github.com/rust-lang/crates.io-index"
2144
+checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
2145
+dependencies = [
2146
+ "bytes",
2147
+ "fastrand",
2148
+ "futures-util",
2149
+ "http",
2150
+ "pin-project-lite",
2151
+ "tokio",
2152
+]
2153
+
2154
+[[package]]
2155
+name = "h3-quinn"
2156
+version = "0.0.10"
2157
+source = "registry+https://github.com/rust-lang/crates.io-index"
2158
+checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
2159
+dependencies = [
2160
+ "bytes",
2161
+ "futures",
2162
+ "h3",
2163
+ "quinn",
2164
+ "tokio",
2165
+ "tokio-util",
2166
+]
2167
+
2111
2168
[[package]]
2112
2169
name = "half"
2113
2170
version = "2.7.1"
2672
2729
2673
2730
[[package]]
2674
2731
name = "justapi-bench"
2675
-version = "2.0.0"
2732
+version = "2.0.8"
2676
2733
dependencies = [
2677
2734
"anyhow",
2678
2735
"clap",
2687
2744
2688
2745
[[package]]
2689
2746
name = "justapi-cli"
2690
-version = "2.0.0"
2747
+version = "2.0.8"
2691
2748
dependencies = [
2692
2749
"anyhow",
2693
2750
"clap",
2707
2764
2708
2765
[[package]]
2709
2766
name = "justapi-core"
2710
-version = "2.0.0"
2767
+version = "2.0.8"
2711
2768
dependencies = [
2712
2769
"anyhow",
2713
2770
"arc-swap",
2720
2777
"flate2",
2721
2778
"futures",
2722
2779
"governor",
2780
+ "h3",
2781
+ "h3-quinn",
2723
2782
"http",
2724
2783
"http-body",
2725
2784
"http-body-util",
2741
2800
"proptest",
2742
2801
"prost 0.14.4",
2743
2802
"quick-xml",
2803
+ "quinn",
2744
2804
"rand 0.8.6",
2745
2805
"rcgen",
2746
2806
"redis",
2747
2807
"rustls",
2748
2808
"rustls-pemfile",
2809
+ "rustls-pki-types",
2749
2810
"serde",
2750
2811
"serde_json",
2751
2812
"serde_urlencoded",
2772
2833
2773
2834
[[package]]
2774
2835
name = "justapi-inference"
2775
-version = "2.0.0"
2836
+version = "2.0.8"
2776
2837
dependencies = [
2777
2838
"anyhow",
2778
2839
"async-stream",
2792
2853
2793
2854
[[package]]
2794
2855
name = "justapi-py"
2795
-version = "2.0.0"
2856
+version = "2.0.8"
2796
2857
dependencies = [
2797
2858
"anyhow",
2798
2859
"base64 0.22.1",
2860
+ "bytes",
2799
2861
"chrono",
2800
2862
"cron",
2801
2863
"futures",
2964
3026
source = "registry+https://github.com/rust-lang/crates.io-index"
2965
3027
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
2966
3028
3029
+[[package]]
3030
+name = "lru-slab"
3031
+version = "0.1.2"
3032
+source = "registry+https://github.com/rust-lang/crates.io-index"
3033
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
3034
+
2967
3035
[[package]]
2968
3036
name = "mach2"
2969
3037
version = "0.6.0"
3975
4043
"serde",
3976
4044
]
3977
4045
4046
+[[package]]
4047
+name = "quinn"
4048
+version = "0.11.11"
4049
+source = "registry+https://github.com/rust-lang/crates.io-index"
4050
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
4051
+dependencies = [
4052
+ "bytes",
4053
+ "cfg_aliases",
4054
+ "futures-io",
4055
+ "pin-project-lite",
4056
+ "quinn-proto",
4057
+ "quinn-udp",
4058
+ "rustc-hash",
4059
+ "rustls",
4060
+ "socket2 0.6.4",
4061
+ "thiserror 2.0.18",
4062
+ "tokio",
4063
+ "tracing",
4064
+ "web-time",
4065
+]
4066
+
4067
+[[package]]
4068
+name = "quinn-proto"
4069
+version = "0.11.16"
4070
+source = "registry+https://github.com/rust-lang/crates.io-index"
4071
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
4072
+dependencies = [
4073
+ "bytes",
4074
+ "getrandom 0.4.3",
4075
+ "lru-slab",
4076
+ "rand 0.10.2",
4077
+ "rand_pcg",
4078
+ "ring",
4079
+ "rustc-hash",
4080
+ "rustls",
4081
+ "rustls-pki-types",
4082
+ "slab",
4083
+ "thiserror 2.0.18",
4084
+ "tinyvec",
4085
+ "tracing",
4086
+ "web-time",
4087
+]
4088
+
4089
+[[package]]
4090
+name = "quinn-udp"
4091
+version = "0.5.15"
4092
+source = "registry+https://github.com/rust-lang/crates.io-index"
4093
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
4094
+dependencies = [
4095
+ "cfg_aliases",
4096
+ "libc",
4097
+ "once_cell",
4098
+ "socket2 0.6.4",
4099
+ "tracing",
4100
+ "windows-sys 0.61.2",
4101
+]
4102
+
3978
4103
[[package]]
3979
4104
name = "quote"
3980
4105
version = "1.0.46"
4023
4148
"rand_core 0.9.5",
4024
4149
]
4025
4150
4151
+[[package]]
4152
+name = "rand"
4153
+version = "0.10.2"
4154
+source = "registry+https://github.com/rust-lang/crates.io-index"
4155
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
4156
+dependencies = [
4157
+ "chacha20",
4158
+ "getrandom 0.4.3",
4159
+ "rand_core 0.10.1",
4160
+]
4161
+
4026
4162
[[package]]
4027
4163
name = "rand_chacha"
4028
4164
version = "0.3.1"
4061
4197
"getrandom 0.3.4",
4062
4198
]
4063
4199
4200
+[[package]]
4201
+name = "rand_core"
4202
+version = "0.10.1"
4203
+source = "registry+https://github.com/rust-lang/crates.io-index"
4204
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
4205
+
4064
4206
[[package]]
4065
4207
name = "rand_distr"
4066
4208
version = "0.5.1"
4071
4213
"rand 0.9.4",
4072
4214
]
4073
4215
4216
+[[package]]
4217
+name = "rand_pcg"
4218
+version = "0.10.2"
4219
+source = "registry+https://github.com/rust-lang/crates.io-index"
4220
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
4221
+dependencies = [
4222
+ "rand_core 0.10.1",
4223
+]
4224
+
4074
4225
[[package]]
4075
4226
name = "rand_xorshift"
4076
4227
version = "0.4.0"
4367
4518
source = "registry+https://github.com/rust-lang/crates.io-index"
4368
4519
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
4369
4520
dependencies = [
4521
+ "web-time",
4370
4522
"zeroize",
4371
4523
]
4372
4524
4541
4693
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
4542
4694
dependencies = [
4543
4695
"cfg-if",
4544
- "cpufeatures",
4696
+ "cpufeatures 0.2.17",
4545
4697
"digest",
4546
4698
]
4547
4699
4558
4710
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
4559
4711
dependencies = [
4560
4712
"cfg-if",
4561
- "cpufeatures",
4713
+ "cpufeatures 0.2.17",
4562
4714
"digest",
4563
4715
]
4564
4716
4565
4717
β–ΌCargo.toml
+7-1
10
10
exclude = ["fuzz"]
11
11
12
12
[workspace.package]
13
-version = "2.0.0"
13
+version = "2.0.8"
14
14
edition = "2021"
15
15
license = "MIT"
16
16
73
73
tokio-rustls = { version = "0.26" }
74
74
rustls-pemfile = { version = "2" }
75
75
76
+# HTTP/3 (opt-in, feature `http3`)
77
+h3 = { version = "0.0.8" }
78
+h3-quinn = { version = "0.0.10" }
79
+quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls", "ring"] }
80
+rustls-pki-types = "1"
81
+
76
82
# Compression
77
83
flate2 = "1"
78
84
brotli = "7"
79
85
β–ΌDECISIONS.md
+238-0
2949
2949
2950
2950
**Evidence:** `cargo check -p justapi-py --features orjson` passes; `cargo check` (default/no-orjson) passes; pytest 159 passed / 1 skipped; `cargo test --workspace` all pass.
2951
2951
2952
+
2953
+## ADR-077 β€” 2026-08-06 β€” Drop ASGI compatibility; fully native Rust pipeline
2954
+
2955
+**Context:** PROMPT.md Section 2 and ADR-007/013 mandated a Tier A ASGI
2956
+compatibility shim (run unmodified FastAPI/Starlette apps on JustAPI's Rust
2957
+runtime). That shim was never shipped in the maintained codebase. The public
2958
+docs (README, docs_site) still claimed "an ASGI shim for compatibility with the
2959
+Starlette middleware ecosystem", `app.mount()` accepting arbitrary ASGI apps,
2960
+`httpx.ASGITransport(app=app)` test clients, and an `asgi_app=` kwarg on
2961
+`app.run()`. Investigation confirmed these are **not implemented**:
2962
+- `mount()` raises `ValueError` for anything that isn't a `str` or `APIRouter`.
2963
+- `run()` has no `asgi_app` kwarg.
2964
+- No ASGI receiver/sender/scope shim exists anywhere in `justapi-py`/`justapi-core` Rust sources.
2965
+- `dependency_overrides` (documented) does not exist.
2966
+
2967
+**Options considered:**
2968
+1. Implement a real ASGI layer (Tier A) β€” rejected. ASGI is a dict-based,
2969
+ dynamically-typed protocol that would drag the typed zero-copy Rust pipeline
2970
+ (route β†’ schema validation β†’ Rust serialization β†’ wire) back through a
2971
+ Python interface on every request. It taxes the fastest path for the benefit
2972
+ of running legacy Starlette apps β€” the wrong target audience. We already
2973
+ ship the native equivalents of every middleware ASGI would import (CORS,
2974
+ JWT, rate-limit, security headers, compression, WS, SSE).
2975
+2. Keep the ASGI claims but defer the work β€” rejected. The docs are a false
2976
+ promise; claims without substance erode trust faster than a missing feature.
2977
+3. Remove ASGI compatibility entirely and own the native stack β€” **selected.**
2978
+
2979
+**Decision:**
2980
+- JustAPI is a fully native runtime. No ASGI/Starlette dependency, no ASGI shim.
2981
+- Removed the false claims from README, docs_site (advanced-middleware,
2982
+ async-tests, sub-applications, static-files, migration-guide, glossary,
2983
+ external-links), AGENTS.md architecture diagram, and PLAN.md (marked Phase 10
2984
+ Tier A as historical/deprecated; updated the phase-table row and the
2985
+ "native-for-performance, ASGI-for-compatibility" principle).
2986
+- Corrected the real APIs in docs: native middleware is `@app.middleware("http")`
2987
+ + `(request, call_next)` callables and the `enable_secure_headers` /
2988
+ `add_cors` / `set_jwt_auth` / `enable_request_coalescing` native bridges;
2989
+ testing uses `justapi.testing.AsyncTestClient`, not `httpx.ASGITransport`;
2990
+ sub-apps use `APIRouter` + `mount()`; static files use `app.frontend()`.
2991
+- Integration with legacy ASGI apps is delegated to a path-routing reverse proxy.
2992
+
2993
+**Evidence:** `rg -l ASGI` inventory across repo; reads of `app.py` (`mount`,
2994
+`add_middleware`, `run`, `dependency_overrides`), `testing.py`,
2995
+`test_client.rs`; factual ASGI references (Granian/uvicorn comparisons in
2996
+README/HANDBOOK/BENCHMARKS, historical DECISIONS ADRs, benchmark harness) are
2997
+kept β€” they describe the stack JustAPI intentionally does NOT use.
2998
+
2999
+## ADR-078 β€” 2026-08-06 β€” QUERY method (RFC 10008) testability + honest CRUD benchmark re-run
3000
+
3001
+**Context:** Two items from the "make JustAPI production-grade" drive:
3002
+
3003
+1. The HTTP QUERY method (RFC 10008) had a complete route chain (Python
3004
+ `app.query()` β†’ PyO3 `native::JustAPIApp::query` β†’ Rust router keyed on
3005
+ `justapi_core::query_method()`) and correct RFC enforcement (QUERY MUST carry
3006
+ a Content-Type, rejected with 400), but there was **no way to test it**: the
3007
+ test clients only exposed GET/POST/PUT/PATCH/DELETE.
3008
+2. The DB-backed CRUD benchmark ledger (2026-07-26) needed re-verification on
3009
+ current code before trusting its "beat FastAPI" claims.
3010
+
3011
+**Decision:**
3012
+- Added `TestClient::query`/`query_with` (core), `JustAPITestClient.query`/
3013
+ `query_with` (PyO3), and `AsyncTestClient.query` (defaults to
3014
+ `Content-Type: application/json`, since RFC 10008 requires it). Tests: 3
3015
+ Python (roundtrip, wrong-route 405, missing-Content-Type 400) + 1 Rust.
3016
+- Re-ran `benchmarks/run_crud_bench.sh` with a **release wheel** and appended
3017
+ the results to BENCHMARKS.md. JustAPI native SELECT = 181k RPS (Γ—125 vs
3018
+ FastAPI, up from Γ—108). UPDATE 31.9k / DELETE 37.7k still lead.
3019
+- **Honest finding (recorded, not hidden):** the Python-handler path now
3020
+ measures 16.5k SELECT and concurrent writes collapse to ~6 RPS at `-c 20`
3021
+ (147 at `-c 1`). Root cause: the GIL-pool backpressure fix (2026-07-25)
3022
+ throttles the single Python worker instead of dropping requests when its
3023
+ bounded channel is full. This is a correctness/throughput tradeoff β€” all
3024
+ writes commit; it is not a durability bug. The Rust-native path is unaffected.
3025
+ The 2026-07-26 107k Python-handler SELECT predates that fix and is not
3026
+ reproducible on current code.
3027
+- Methodology rule added: benchmarks MUST use the release wheel; a debug
3028
+ `maturin develop` build measures ~4Γ— slower and is not comparable.
3029
+
3030
+**Evidence:** `benchmarks/run_crud_bench.sh` output (2026-08-06), BENCHMARKS.md
3031
+append, `testing.rs`/`test_client.rs`/`testing.py` QUERY additions, tests in
3032
+`test_testing.py` and `crates/justapi-core/src/testing.rs`.
3033
+
3034
+## ADR-079 β€” 2026-08-06 β€” HTTP/3 (QUIC) transport: feature-gated module, not a TCP-path rewrite
3035
+
3036
+**Context:** "Make JustAPI better than any existing Python framework" β€” HTTP/3
3037
+is the one transport no Python web framework ships (all are TCP-based
3038
+HTTP/1.1/2). QUIC gives connection multiplexing without head-of-line blocking,
3039
+0-RTT resumption, and built-in TLS 1.3.
3040
+
3041
+**Options considered:**
3042
+1. `quiche` (Cloudflare) β€” fastest per interop-runner, but BoringSSL-only,
3043
+ low-level event loop, no hyper compatibility. Rejected.
3044
+2. `s2n-quic` + `s2n-quic-h3` (AWS) β€” production-grade, but `s2n-quic-h3` is
3045
+ a separate lower-level integration and MSRV 1.88 (project is on 1.97, OK).
3046
+3. `h3` + `h3-quinn` (hyperium) β€” **selected.** hyper's own HTTP/3 layer;
3047
+ tokio-native (matches the project's tokio+hyper+rustls stack); h3-quinn
3048
+ reuses quinn 0.11 + rustls 0.23 (already in the tree); `h3` is 0.0.x but
3049
+ hyperium-owned and used by reqwest/salvo.
3050
+
3051
+**Decision:**
3052
+- New `http3` feature flag on `justapi-core` (`h3`, `h3-quinn`, `quinn`
3053
+ optional deps; pulls in `tls`).
3054
+- New `crates/justapi-core/src/http3.rs`: `Http3Config` (PEM cert/key),
3055
+ `Http3Handler` bridge type (maps `hyper::Request<Full<Bytes>>` β†’
3056
+ `(status, headers, body)`), `quic_server_config()`, `serve_http3()`
3057
+ (UDP bind β†’ quinn endpoint β†’ h3 server connection loop β†’ per-request
3058
+ task), `collect_body()`, `bridge_request()`, `chain_to_http3_handler()`.
3059
+- End-to-end test `tests/http3_test.rs`: self-signed cert via rcgen, real
3060
+ QUIC handshake, request round-trip through h3-quinn client. **Passes.**
3061
+- **Seam documented, not hidden:** the core `make_handler`/`execute_handler`
3062
+ and `Handler::Custom` are typed to `hyper::body::Incoming`, so the full
3063
+ Python-native pipeline (GIL pool, native fast path, schema validation) is
3064
+ NOT yet wired to HTTP/3 β€” `chain_to_http3_handler` takes a
3065
+ `MiddlewareChain<Full<Bytes>>`, and `make_test_handler<B>` in justapi-py is
3066
+ already body-generic, so the wiring is a mechanical follow-up (make core
3067
+ handler generic over `B`). Follow-up phase "HTTP/3 native pipeline".
3068
+
3069
+**Evidence:** `http3.rs` (module), `tests/http3_test.rs` (passing e2e),
3070
+`http3` feature in Cargo.toml, `serve_http3` log line "HTTP/3 listening on
3071
+udp://…".
3072
+
3073
+## ADR-080 β€” 2026-08-06 β€” Remove request-scoped auto-transaction: write-path pool-saturation collapse
3074
+
3075
+**Context:** The honest re-benchmark (2026-08-06) surfaced a real defect: the
3076
+Python-handler DB write path measured **150 RPS at `-c 10` but collapsed to
3077
+~3 RPS at `-c 11`** (with 503/500 "pool timed out while waiting for an open
3078
+connection" at exactly `request_acquire_timeout`). Root-caused by
3079
+measurement, not theory:
3080
+
3081
+- `make_native_handler`/`make_test_handler` began a request-scoped
3082
+ `pool.begin_request()` transaction on the **async runtime** for every
3083
+ POST/PUT/DELETE (handlers.rs), *before* dispatching to the single GIL
3084
+ worker.
3085
+- The transaction held a pool connection while the request waited in the
3086
+ GIL queue; the handler's own `app.db.query` then acquired a **second**
3087
+ connection (`run_blocking` β†’ pool acquire). The tx was never passed to the
3088
+ handler β€” it provided zero atomicity for handler work.
3089
+- Result: N concurrent write requests need 2N connections on an
3090
+ N-connection pool β†’ immediate saturation at N+1 concurrency. Verified:
3091
+ raising `request_acquire_timeout` to 30s did NOT fix it (still ~3 RPS);
3092
+ reads (SELECT) at c=16 stayed at 21k RPS β€” the collapse was write-specific.
3093
+
3094
+**Options considered:**
3095
+1. Thread the transaction into the handler's `app.db` so the handler's queries
3096
+ run inside it (one connection per request) β€” correct but a large refactor
3097
+ (tx plumbed through the `DbPool` pyclass + `Request`).
3098
+2. Remove the auto-transaction β€” **selected.** The pool's own
3099
+ `request_acquire_timeout` already produces the fast-503 backpressure the
3100
+ tx provided; multi-statement atomicity is explicit via
3101
+ `app.db.transaction()`. The tx was dead weight that doubled connection
3102
+ usage per write.
3103
+
3104
+**Decision:**
3105
+- Removed the auto-transaction begin/commit/rollback from both
3106
+ `make_native_handler` and `make_test_handler` (handlers.rs). Writes now use
3107
+ exactly one pool connection (the handler's own `app.db.query`).
3108
+- Extended `db_error_response` (justapi-core/lib.rs): SQLITE_BUSY/LOCKED
3109
+ (codes 5/6/517/518/261) now map to **503 Retry-After** instead of a generic
3110
+ 500 β€” a concurrent write crunch surfaces as retryable backpressure, not a
3111
+ 5s stall + 500.
3112
+- Reworked `test_db_saturated.py` to saturate via an external SQLite write
3113
+ lock (the old premise β€” a Python handler sleeping while holding a pool
3114
+ connection β€” is architecturally impossible now: the GIL worker serializes
3115
+ handlers so they never race for the pool). Added
3116
+ `test_concurrent_writes_small_pool_no_saturation_collapse` (2-conn pool,
3117
+ 50 concurrent writers, all must 200 + persist).
3118
+
3119
+**Measured after fix (release wheel, same fixture):** Python-handler INSERT
3120
+flat at **142–162 RPS from c=1 through c=50, 100% success, zero timeouts**
3121
+(before: 160 RPS at c=10 β†’ 2.8 RPS at c=11). SELECT unchanged (19.5k @ c=20).
3122
+Writes durable: 604 rows persisted from a 4s burst @ c=20.
3123
+
3124
+**Evidence:** BENCHMARKS.md 2026-08-06 re-run section (pre-fix numbers),
3125
+handlers.rs diff, lib.rs `db_error_response`, test_db_saturated.py +
3126
+test_db_concurrent.py, live oha measurements above.
3127
+
3128
+## ADR-081 β€” 2026-08-06 β€” Fork-safe GIL pool (fixes test_circuit_breaker flake + prefork hangs)
3129
+
3130
+**Context:** The full pytest suite had exactly one persistent flake:
3131
+`test_circuit_breaker.py` failed with 504 timeouts when run after any test that
3132
+initialized the GIL pool (passes in isolation). Root cause, proven by
3133
+reproduction: the pool was a `OnceLock<GilPool>`; `fork(2)` copies the
3134
+parent's address space, so a forked child inherited an initialized pool whose
3135
+worker *threads do not exist in the child*. Every `run_python` send went into
3136
+a channel no one read β†’ the child's server blocked forever β†’ 504s. This is not
3137
+a test-only issue: any user app that forks after the pool is warm (prefork
3138
+servers, `multiprocessing`, Celery-style workers) would hang every
3139
+Python-handler request in the child.
3140
+
3141
+**Decision:**
3142
+- `POOL` changed from `OnceLock<GilPool>` to `Mutex<Option<GilPool>>` plus a
3143
+ `POOL_PID: AtomicU32`. `run_python` (and `init_pool`) checks
3144
+ `POOL_PID != current_pid`; on mismatch (forked child or fresh process) the
3145
+ pool is rebuilt on the child's own threads. Same-process calls pay one
3146
+ atomic load + one short mutex lock per request (dispatch already held the
3147
+ lock only to clone senders; the atomic is uncontended in the common path).
3148
+- `GilPool.next` is now `Arc<AtomicUsize>` (cloneable counter for round-robin).
3149
+- Regression guards: `test_dependency_injection.py::test_gil_pool_survives_fork`
3150
+ (warm parent pool β†’ fork β†’ child serves 200) and the previously-flaky
3151
+ `test_circuit_breaker.py` now passes in the full suite.
3152
+
3153
+**Measured:** full pytest suite **169 passed / 1 skipped / 0 failed** (was 167
3154
++ 1 flake). No GIL-path throughput change (uncontended atomic + lock in the
3155
+same-process fast path).
3156
+
3157
+**Evidence:** gil_pool.rs diff, the two regression tests, full-suite run.
3158
+
3159
+## ADR-082 β€” 2026-08-06 β€” HTTP/3 native pipeline: Python handlers serve over QUIC
3160
+
3161
+**Context:** ADR-079 shipped the HTTP/3 transport (quinn + h3) with a bridge
3162
+handler but noted a seam: the core `make_handler`/`execute_handler` were typed
3163
+to `hyper::body::Incoming`, so the Python-native pipeline (GIL pool, native
3164
+fast path, schema validation, DI) could not serve over QUIC. This closes that
3165
+seam.
3166
+
3167
+**Decision:**
3168
+- The Python binding's `make_native_handler<B>` was already body-generic
3169
+ (`B: Body`), so no core refactor was needed. `JustAPIApp::run()` now builds
3170
+ a second `MiddlewareChain<Full<Bytes>>` from the same app state (same route
3171
+ table, handlers, schemas, batchers, DB pool) when HTTP/3 is enabled, wraps
3172
+ it with `chain_to_http3_handler`, and spawns `serve_http3` on the same
3173
+ address over UDP alongside the TCP server.
3174
+- New public API: `app.enable_http3(cert_path, key_path)` (PEM TLS files,
3175
+ required β€” QUIC uses TLS 1.3). Raises `NotImplementedError` on builds
3176
+ without the `http3` feature. `justapi-py` gained an `http3` feature
3177
+ passthrough to `justapi-core/http3`.
3178
+- Sharing: the TCP and QUIC chains share the same `Arc` state and the same
3179
+ Python `Request.app` handle; per-transport handler closures are built once
3180
+ at startup.
3181
+
3182
+**Verified end-to-end** (`tests/http3_test.rs::http3_python_native_pipeline`,
3183
+real QUIC client): a Python app with `enable_http3` serves a `@app.get`
3184
+handler over HTTP/3 through the full native pipeline β€” response body
3185
+`{"handler":"python","route":"/native"}`. TCP and UDP listeners both bind the
3186
+same port.
3187
+
3188
+**Evidence:** app.rs diff (http3 bridge + spawn), `enable_http3` in app.py,
3189
+passing e2e test, dual-listener smoke (TCP 200 + UDP bound, both logged).
2952
3190
β–ΌPLAN.md
+88-57
6
6
7
7
## Current status
8
8
9
-- **Active phase:** 2.0.8 pre-release hardening β€” βœ… all items complete. Wheel built, demo_shop 29/29 passes, pytest 159/159, cargo workspace 493+ passes.
9
+- **Active phase:** 2.0.8 pre-release hardening β€” core βœ…, AI/inference **unverified** (see honest phase table, 2026-08-06). Wheel built, demo_shop 29/29 passes, pytest 166/166 (+1 pre-existing flake), cargo workspace green on default features (gate fixed 2026-08-06).
10
10
- **Status:** All post-2.0.7 gaps closed (2026-07-25). **AI / inference / LLM work FROZEN until release 2.0.8** (ADR-067). Focus from 2026-07-25 session:
11
+ - **HTTP QUERY method (RFC 10008) β€” end-to-end verified + testable (2026-08-06).** The route chain (Python `app.query()` β†’ native `query()` β†’ Rust router keyed on `justapi_core::query_method()`) already existed; the gaps were test coverage and client plumbing. Added: core `TestClient::query`/`query_with`, PyO3 `JustAPITestClient.query`/`query_with`, `AsyncTestClient.query` (defaults to `Content-Type: application/json` per RFC 10008), plus 3 Python tests (roundtrip, wrong-route 405, missing-Content-Type 400) and 1 Rust test. RFC 10008 requires a Content-Type on QUERY; the handler already enforces it (400).
12
+ - **Differentiator sprint β€” one-command agent demo (2026-08-06).** `examples/agent_demo/run_demo.sh` starts the app, exercises all three Rust-owned differentiators (native MCP tools with Rust-inferred schemas, durable sessions, Rust-validated NDJSON streaming), and stops β€” no manual steps. Verified end-to-end (tools list, `add(5,7)=12` call, 4-step stream, session persistence across requests). `app.py` now accepts a port arg.
13
+ - **HTTP/3 (QUIC) transport β€” working module, feature-gated (2026-08-06, ADR-079).** New `http3` feature on `justapi-core` (`h3` + `h3-quinn` + `quinn`, hyperium stack on the existing tokio/rustls base). `crates/justapi-core/src/http3.rs`: `serve_http3()` binds UDP, runs a quinn endpoint + h3 server connection loop, bridges requests into `hyper::Request<Full<Bytes>>` and runs a caller-provided handler; `chain_to_http3_handler()` adapts a `MiddlewareChain<Full<Bytes>>`. End-to-end test (`tests/http3_test.rs`) does a real QUIC handshake with a self-signed cert (rcgen) and a round-trip through the h3-quinn client β€” **passes**. **Seam closed (ADR-082):** `app.enable_http3(cert, key)` now serves Python handlers over QUIC through the full native pipeline (GIL pool, DI, schema validation) β€” `make_native_handler<B>` is body-generic, so `run()` builds a second `MiddlewareChain<Full<Bytes>>` from the same app state and spawns `serve_http3` on the same port. Verified e2e: `{"handler":"python","route":"/native"}` over real QUIC. HTTP/3 is the one transport no Python framework ships.
14
+ - **DB CRUD benchmark re-run on current code (2026-08-06).** Re-ran `benchmarks/run_crud_bench.sh` against HEAD with a release wheel. Findings appended to BENCHMARKS.md: JustAPI native SELECT **181k RPS (Γ—125 vs FastAPI)** β€” headline claim holds/improves; UPDATE 31.9k / DELETE 37.7k still lead. **Honest regression surfaced:** the Python-handler path measures 16.5k SELECT (was 107k in the 2026-07-26 ledger) and concurrent writes collapse to ~6 RPS at `-c 20` (147 at `-c 1`) β€” this is the GIL-pool backpressure fix (throttle, not drop), a correctness/throughput tradeoff, not a durability bug. Native path unaffected. Also documented the methodology gotcha: benchmarking must use a release wheel (debug `maturin develop` is ~4Γ— slower).
11
15
- **Generator dependencies (FastAPI-style `yield` in Depends)** β€” βœ… complete. `_resolve_kwargs`/`_resolve_dependencies_list` detect `inspect.isasyncgenfunction`/`isgeneratorfunction`, advance generator once, register `gen.aclose()`/`gen.close()` in `request["_dep_cleanup"]`. Handler wrappers run cleanup in `finally` block (LIFO order). 6 tests (async gen, sync gen, cleanup on error, nested, cached, no-cache).
12
16
- **ORJSON as optional Rust-level serializer** β€” βœ… complete. `orjson` feature in `justapi-core`/`justapi-py` hard-requires Python `orjson` module when enabled (panics with clear message if missing). `orjson>=3.10` added to pyproject.toml as `[orjson]` extra and included in `[full]`. 7 tests.
13
17
- **Production panic hotspot audit** β€” βœ… complete. Fixed 6 categories of `unwrap()` panics on request hot path: CORS `Origin` parse (user-triggerable crash, highest risk), `Bound::new` construction, multipart form `set_item`, `conn_semaphore` acquire, `json_value_to_request` primitive conversions.
18
+ - **GIL-pool concurrency bug (spurious 404s under load)** β€” βœ… fixed. `run_python` used `try_send` into a bounded channel (cap 16 for a single GIL worker); a burst of concurrent/pipelined requests overflowed the queue and `handle_request` masked the resulting chain `Err` as an RFC 9457 404 with no log. GIL pool now backpressures via `send().await` (throttles instead of dropping); `handle_request` logs chain errors and returns 500 (real not-found is already `Ok(404)`). Measured before: 4.1M/4.5M responses 404; after: 100% 200, ~110k rps. Regression test `test_sync_handler_no_spurious_404_under_load` (fails on old code).
14
19
- **PyPI wheel** β€” built (`manylinux_2_34_x86_64`, 32 MB). Unpublished β€” blocked on `MATURIN_PYPI_TOKEN`.
15
20
- **Demo_shop stress test (2026-07-18) β€” all D1–D4 defects fixed.** All 23 endpoints pass; 29-assertion e2e test passes. Framework gotchas documented in demo_shop/README.md (both resolved in framework).
16
-- **Gates (2026-07-25):** `cargo test --workspace` (493+ all pass) βœ…, `cargo clippy --workspace --tests -- -D warnings` βœ…, `cargo fmt --check` βœ…, pytest 159 passed / 1 skipped βœ…, demo_shop `test_app.py` 29/29 βœ….
21
+- **Gates (2026-08-06, re-run on current code):** `cargo test --workspace` βœ… green on **default features** (integration-test feature guards fixed β€” previously `db_bridge`/`edge_cases` failed without `--all-features`); `cargo clippy --workspace --all-features --lib -- -D warnings` βœ…; `cargo fmt --check` βœ…; pytest **169 passed / 1 skipped / 0 failed** (the long-standing `test_circuit_breaker` flake is FIXED β€” it was a real fork-safety bug in the GIL pool, ADR-081: a forked child inherited the parent's initialized pool whose worker threads don't exist β†’ every Python request hung β†’ 504s. Pool now tracks its PID and rebuilds after fork); demo_shop `test_app.py` 29/29 βœ…. **Write-path defect found + fixed (ADR-080):** Python-handler writes no longer collapse under concurrency (160β†’3 RPS at c=11 was a pool double-acquire bug; now flat ~150 RPS c=1..50).
17
22
- **Documentation site (2026-07-25):** docs_site rebuilt to FastAPI-level depth β€” 141 pages across 13 sections, Astro + Starlight build passes, full-text search with Pagefind. Sections: Getting Started (5), Tutorial (36), Advanced (27), How-To (7), API Reference (17), Deployment (9), Security (4), Observability (4), Inference (4), Reference (6), Resources (3), Contributing (5), Examples (1).
18
-- **Blocker:** none. Outstanding: PyPI publish (needs OIDC trusted publisher configured at pypi.org).
23
+- **Blocker:** none. Outstanding: PyPI publish (needs OIDC trusted publisher / `MATURIN_PYPI_TOKEN` at pypi.org) β€” the single most important remaining item; **AI/inference claims remain unverified on real hardware (Phase 52 frozen)**.
19
24
20
25
## Mission
21
26
25
30
26
31
## Phase table
27
32
33
+> **Honest status key (2026-08-06):**
34
+> - **βœ… verified** = passing tests on current code + benchmarked where applicable.
35
+> - **🟑 implemented / unverified** = code exists, unit/integration tests pass, but
36
+> the *real-world* claim is NOT proven: no real hardware/GPU/weights run, no
37
+> production deployment. Do NOT treat as production-ready.
38
+> - **πŸ”΄ frozen** = deliberately deferred.
39
+
28
40
| # | Phase | Status | Notes |
29
-|---|---|---|---|---|
30
-| 0 | Foundations | βœ… complete | Skeleton, CI, skills, baselines |
31
-| 1 | Minimal Viable Runtime | βœ… complete | HTTP/1.1, routing, PyO3, CLI |
32
-| 2 | Native Router | βœ… complete | matchit radix trie, route params |
33
-| 3 | Memory & Zero-Copy Pipeline | βœ… complete | arena+pool, 752k req/s |
34
-| 4 | Execution Model & GIL Strategy | βœ… complete | ADR-008: dedicated Python worker thread |
35
-| 5 | Middleware Engine + Auth | βœ… complete | CORS, SecurityHeaders, JWT, RateLimiter, 0.18% overhead |
36
-| 6 | Serialization | βœ… complete | serde_json (89ns), simd-json feature |
37
-| 7 | TLS + HTTP/2 | βœ… complete | rustls, ALPN, 10.7% overhead |
38
-| 8 | WebSocket + SSE | βœ… complete | tokio-tungstenite, SSE streaming |
39
-| 9 | Static Assets, Caching, Observability | βœ… complete | StaticDir, Prometheus, compression, OTel |
40
-| 10 | ASGI Shim + Native API | βœ… complete | Tier A+B, graceful shutdown, 81 tests |
41
-| 11 | Request Validation & Serialization | βœ… complete | JSON Schema validation, Pydantic bridge, 97 tests |
42
-| 12 | Database ORM Integration | βœ… complete | Pool, migrations, query builders, Python bridge |
43
-| 13 | Testing Utilities | βœ… complete | TestClient, async fixtures, snapshot testing |
44
-| 14 | Production Observability | βœ… complete | Metrics histograms, structured logging, health checks, alerting, audit, panic recovery |
45
-| **15** | **Graceful Degradation & Circuit Breakers** | **βœ… complete** | **44 resilience tests, generic B support** |
46
-| 19 | Plugin System & Extensibility | βœ… complete | third-party plugin API |
47
-| 20 | Benchmark & Optimization (Beat FastAPI) | βœ… complete | final gate, prove superiority |
48
-| 21 | Advanced DX: Signature Parsing & DI | βœ… complete | `Depends()`, auto-extract args |
49
-| 22 | True Async & Advanced Web Features | βœ… complete | No-block async, WS/SSE, BackgroundTasks |
50
-| 23 | Templating Engine Integration | βœ… complete | Jinja2/MiniJinja support |
51
-| 24 | gRPC & Protobuf Support | βœ… complete | Native gRPC via Tonic |
52
-| 25 | Ultimate Scale (1M RPS tuning) | βœ… complete | Hyper-optimization for 1M RPS |
53
-| 26 | Zero-Copy AI Data Boundary | βœ… complete | Arrow/DLPack via PyO3 buffer protocol |
54
-| 27 | Adaptive Batching Router | βœ… complete | ML model serving |
55
-| 28 | WASM Middleware Engine | βœ… complete | wasmtime embedded |
56
-| 29 | High-Throughput LLM Streaming (SSE 2.0) | βœ… complete | TokenStreamResponse |
57
-| 30 | Layered Dependency Injection | βœ… complete | Litestar-style DI |
58
-| 31 | Class-Based Controllers & Route Composition | βœ… complete | @controller classes |
59
-| 32 | Native Enterprise Rate Limiting | βœ… complete | GCRA + Redis |
60
-| 33 | Circuit Breakers & Graceful Degradation | βœ… complete | 44 resilience tests |
61
-| 34 | Dynamic Configuration (Hot Reloading) | βœ… complete | file watcher + admin API |
62
-| 35 | GraphQL & Federation Gateway | βœ… complete | async-graphql, Apollo Federation |
63
-| 36 | Agentic Workflow DAG Engine | βœ… complete | DAG orchestration in Rust |
64
-| 37 | OTel Distributed Tracing | βœ… complete | OTLP, contextvars, 225 tests |
65
-| 38 | Hardware-Accelerated JWT & Security | βœ… complete | RS256/ES256, CORS builder, IpRateLimiter, 236 tests |
66
-| 39 | SAST, Fuzzing & Memory Safety | βœ… complete | 6 fuzz targets, miri, SAFETY comments, 236 tests |
67
-| 40 | The JustAPI 2.0 "Singularity" Release | 🟑 in progress | Consolidated Py package, OpenAPI, README, Docker (155MB), abi3 wheel, ARCHITECTURE.md, CONTRIBUTING.md. PyPI upload pending token + manylinux_2_28 build |
68
-| 41 | Native Inference Engine Foundation | βœ… complete | `justapi-inference` crate on Candle (CPU default; cuda feature-gated), `Engine`/`Model` trait, streaming, 24 tests pass |
69
-| 42 | KV-Cache Manager (PagedAttention, Rust) | βœ… complete | `KvBlockPool` paged allocator + clock eviction, `PrefixCache` hash-based prefix reuse, `Sequence` handles. 16 tests, 260 workspace total |
70
-| 43 | Continuous-Batching Scheduler | βœ… complete | `Scheduler` with prefill/decode interleave, chunked prefill, back-pressure, prefix-cache integration. 9 tests, 269 workspace total |
71
-| 44 | OpenAI-Compatible API Server | βœ… complete | `/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, `/v1/models` wired to `justapi-inference` (GIL-free); streaming SSE; feature-gated `inference` |
72
-| 45 | Quantization + Multi-LoRA | βœ… complete | AWQ/GPTQ/FP8 GGUF quant + LoRA adapter registry + RealModel forward pass (all feature-gated `real`) |
73
-| 46 | LLM Control Plane | βœ… complete | model registry/versioning, KV-aware routing, LLM autoscaling (KV-pressure/TTFT), multi-replica supervisor |
74
-| 47 | FastAPI Parity (model-deploy essentials) | βœ… complete | ApiKeyAuth + OAuth2Password + multipart + Form/Cookie/Header + JsonResponse<T> (248 tests) |
75
-| 48 | Production Hardening for AI | βœ… complete | speculative decoding + disaggregated P/D + structural benchmark vs vLLM/SGLang + K8s AI inference gateway plugin (97 inference tests) |
76
-| 49 | Tree-based Speculative Decoding | βœ… complete | Medusa/EAGLE-style tree verification wired into serving path. Acceptance rate up to 3Γ— higher than draft-target. |
77
-| 50 | RadixAttention Prefix Caching | βœ… complete | `RadixPrefixCache` wired into `Scheduler`: O(1) prefix lookup on admission, LRU eviction, finished-seq block promotion. |
78
-| 51 | Scheduler Serving Integration | βœ… complete | `SchedulerEngine` bridges Engine+Scheduler; prefix cache metrics in `/metrics`; sampling params plumbed; real throughput benchmark. 151 inference tests. |
79
-| **52** | **GPU Benchmark Gate** | **πŸ”΄ frozen β€” AI deferred to 2.0.8** | **Run SchedulerEngine vs naive vs vLLM on real GPU with real weights. Measure tokens/sec, TTFT, ITL. DEFERRED: no CUDA driver upgrade, no real-weight run (ADR-067). MockModel CPU run only as plumbing check.** |
41
+|---|---|---|---|
42
+| 0 | Foundations | βœ… verified | Skeleton, CI, skills, baselines |
43
+| 1 | Minimal Viable Runtime | βœ… verified | HTTP/1.1, routing, PyO3, CLI; 766k req/s |
44
+| 2 | Native Router | βœ… verified | matchit radix trie, route params, 51ns lookup |
45
+| 3 | Memory & Zero-Copy Pipeline | βœ… verified | arena+pool, 752k req/s |
46
+| 4 | Execution Model & GIL Strategy | βœ… verified | ADR-008: dedicated Python worker thread |
47
+| 5 | Middleware Engine + Auth | βœ… verified | CORS, SecurityHeaders, JWT, RateLimiter, 0.18% overhead |
48
+| 6 | Serialization | βœ… verified | serde_json (89ns), simd-json feature |
49
+| 7 | TLS + HTTP/2 | βœ… verified | rustls, ALPN, 10.7% overhead |
50
+| 8 | WebSocket + SSE | βœ… verified | tokio-tungstenite, SSE streaming |
51
+| 9 | Static Assets, Caching, Observability | βœ… verified | StaticDir, Prometheus, compression, OTel |
52
+| 10 | Native API | βœ… verified | Fully native pipeline β€” ASGI shim deprecated/removed (2026-08-06) |
53
+| 11 | Request Validation & Serialization | βœ… verified | JSON Schema validation, Pydantic bridge |
54
+| 12 | Database ORM Integration | βœ… verified | Pool, migrations, query builders, Python bridge. **2026-08-06: write-path saturation bug fixed (ADR-080)** |
55
+| 13 | Testing Utilities | βœ… verified | TestClient, async fixtures, snapshot testing |
56
+| 14 | Production Observability | βœ… verified | Metrics histograms, structured logging, health checks, alerting, audit, panic recovery |
57
+| **15** | **Graceful Degradation & Circuit Breakers** | **βœ… verified** | **44 resilience tests, generic B support** |
58
+| 19 | Plugin System & Extensibility | βœ… verified | third-party plugin API |
59
+| 20 | Benchmark & Optimization (Beat FastAPI) | βœ… verified | ledger in BENCHMARKS.md (native SELECT Γ—125 vs FastAPI, 2026-08-06) |
60
+| 21 | Advanced DX: Signature Parsing & DI | βœ… verified | `Depends()`, auto-extract args |
61
+| 22 | True Async & Advanced Web Features | βœ… verified | No-block async, WS/SSE, BackgroundTasks |
62
+| 23 | Templating Engine Integration | βœ… verified | Jinja2/MiniJinja support |
63
+| 24 | gRPC & Protobuf Support | βœ… verified | Native gRPC via Tonic (compiles + tests; no prod deployment) |
64
+| 25 | Ultimate Scale (1M RPS tuning) | 🟑 implemented / unverified | Native fast path hits 430-724k on fixture; **1M RPS never measured β€” the phase title overclaims** |
65
+| 26 | Zero-Copy AI Data Boundary | 🟑 implemented / unverified | Arrow/DLPack via PyO3 buffer protocol; no real-model integration test |
66
+| 27 | Adaptive Batching Router | 🟑 implemented / unverified | `adaptive_batch` decorator exists; no real-model throughput proof |
67
+| 28 | WASM Middleware Engine | 🟑 implemented / unverified | wasmtime embedded; no real-world workload proof |
68
+| 29 | High-Throughput LLM Streaming (SSE 2.0) | βœ… verified | TokenStreamResponse, tests pass (framework-level) |
69
+| 30 | Layered Dependency Injection | βœ… verified | Litestar-style DI, tests |
70
+| 31 | Class-Based Controllers & Route Composition | βœ… verified | @controller classes, tests |
71
+| 32 | Native Enterprise Rate Limiting | βœ… verified | GCRA + Redis |
72
+| 33 | Circuit Breakers & Graceful Degradation | βœ… verified | 44 resilience tests |
73
+| 34 | Dynamic Configuration (Hot Reloading) | βœ… verified | file watcher + admin API |
74
+| 35 | GraphQL & Federation Gateway | 🟑 implemented / unverified | async-graphql wired; "Apollo Federation" never tested against a real gateway |
75
+| 36 | Agentic Workflow DAG Engine | 🟑 implemented / unverified | DAG orchestration in Rust, tests pass; no real agent workload proof |
76
+| 37 | OTel Distributed Tracing | βœ… verified | OTLP, contextvars, 225 tests |
77
+| 38 | Hardware-Accelerated JWT & Security | βœ… verified | RS256/ES256, CORS builder, IpRateLimiter |
78
+| 39 | SAST, Fuzzing & Memory Safety | βœ… verified | 6 fuzz targets, miri, SAFETY comments |
79
+| 40 | The JustAPI 2.0 "Singularity" Release | 🟑 in progress | **PyPI upload still blocked (no token)** β€” see Next actions |
80
+| 41 | Native Inference Engine Foundation | 🟑 implemented / unverified | Candle CPU; `Engine`/`Model` traits, 24 tests. **No real-weights run (MockModel only)** |
81
+| 42 | KV-Cache Manager (PagedAttention, Rust) | 🟑 implemented / unverified | `KvBlockPool`, `PrefixCache`; unit tests only, no real model |
82
+| 43 | Continuous-Batching Scheduler | 🟑 implemented / unverified | `Scheduler` prefill/decode interleave; **CPU MockModel only, 0.4% of naive on instant-forward (scheduler sleep dominates)** |
83
+| 44 | OpenAI-Compatible API Server | 🟑 implemented / unverified | `/v1/*` wired to inference (GIL-free); exercised with MockModel only |
84
+| 45 | Quantization + Multi-LoRA | 🟑 implemented / unverified | AWQ/GPTQ/FP8 + LoRA registry, feature-gated `real`; **never run with real weights** |
85
+| 46 | LLM Control Plane | 🟑 implemented / unverified | model registry, KV-aware routing, autoscaling; plumbing only |
86
+| 47 | FastAPI Parity (model-deploy essentials) | βœ… verified | ApiKeyAuth + OAuth2Password + multipart + Form/Cookie/Header + JsonResponse<T> (248 tests) |
87
+| 48 | Production Hardening for AI | 🟑 implemented / unverified | speculative decoding + disaggregated P/D + structural benchmarks; **structural = MockModel ratios, NOT real-GPU tokens/sec** |
88
+| 49 | Tree-based Speculative Decoding | 🟑 implemented / unverified | Medusa/EAGLE-style tree verification; acceptance-rate proof on MockModel only |
89
+| 50 | RadixAttention Prefix Caching | 🟑 implemented / unverified | `RadixPrefixCache` wired into Scheduler; unit-tested, no real model |
90
+| 51 | Scheduler Serving Integration | 🟑 implemented / unverified | `SchedulerEngine` bridges Engine+Scheduler; **CPU fixture with instant-forward MockModel** |
91
+| **52** | **GPU Benchmark Gate** | **πŸ”΄ frozen** | **The only honest status for "LLM performance": no CUDA driver, no real-weight run (ADR-067). The claim "beats vLLM" is UNTESTED.** |
80
92
81
93
---
82
94
155
167
156
168
## Phase 10 β€” ASGI Shim + Native API (completed 2026-07-05)
157
169
158
-### Tier A β€” ASGI Shim
170
+> **Deprecation note (2026-08-06):** the Tier A ASGI shim was **removed**. JustAPI
171
+> is now a fully native runtime β€” routing, middleware, validation, and
172
+> serialization all run in Rust with no ASGI/Starlette dependency. Tier A below
173
+> is historical record only; the native API (Tier B) is the sole supported path.
174
+
175
+### Tier A β€” ASGI Shim (historical)
159
176
- [x] `Server::with_handler()`, `MiddlewareChain::set_handler()`
160
177
- [x] ASGI worker thread with persistent asyncio event loop, `serve_with_app()`
161
178
- [x] Lifespan protocol, streaming response via `ResponseEvent` bridge
603
620
604
621
2. Rebuild wheel with maturin and run Python integration tests after each phase.
605
622
623
+3. **[PROPOSED β€” see docs/SCOPE_CUT.md] Scope-cut for a testable core.**
624
+ Three tiers: (1) `justapi-core` `default = []` + `full` convenience flag +
625
+ CI split (fast default-features gate vs `--all-features` matrix); (2)
626
+ `justapi-py` maps Python extras to core features (`pip install
627
+ justapi[db|ws|grpc|graphql|inference|http3|otlp|...]`) with import-hint
628
+ degradation; (3) fix the `db_bridge`/`edge_cases` integration-test feature
629
+ guards. Requires a focused session: the Python glue currently imports
630
+ DB/WS/gRPC symbols unconditionally, so the binding layer needs
631
+ feature-guarding first.
632
+
633
+4. **[DONE β€” ADR-082] HTTP/3 native pipeline wiring.** `app.enable_http3(cert, key)`
634
+ serves Python handlers over QUIC through the full native pipeline; verified
635
+ end-to-end (`http3_python_native_pipeline` test).
636
+
606
637
## Production-readiness plan (2.0.8 pre-release hardening)
607
638
608
639
Full plan in `PRODUCTION_PLAN.md` (written 2026-07-20 from the audit + live
630
661
`multiprocessing` fork contamination (passes in isolation).
631
662
- **P2.1:** no real DB-backed CRUD benchmark vs FastAPI/Robyn exists β€” the only
632
663
fair "real life" number is missing.
633
-- **P3.1:** portable `manylinux_2_28` wheel built + `twine check` PASSED (installs/imports in a fresh venv); **unpublished** β€” blocked on `MATURIN_PYPI_TOKEN` (no token in env). Add `musllinux`/`aarch64` wheels before release.
664
+- **P3.1:** **4 portable wheels built + twine check PASSED + fresh-venv install verified** (2026-08-06): manylinux x86_64 + aarch64, musllinux x86_64 + aarch64 (via `maturin --zig`). All include the ADR-080/081 fixes. **Unpublished** β€” blocked only on `MATURIN_PYPI_TOKEN`. `scripts/publish.sh` = build all 4 platforms + verify + upload in one command.
634
665
635
666
## Open issues / follow-ups (2026-07-11)
636
667
654
685
655
686
- Rust owns I/O, Python owns application logic (ADR-008)
656
687
- Dedicated Python worker thread (zero GIL contention)
657
-- Tier B (native) for performance, Tier A (ASGI) for compatibility
688
+- Fully native stack β€” no ASGI compatibility layer (deprecated, see Phase 10 note)
658
689
- All new features must have benchmark gates β€” if it doesn't beat FastAPI, it doesn't ship
659
690
# JustAPI: The Singularity Master Plan (Phases 26 - 40)
660
691
661
692
β–ΌREADME.md
+4-4
106
106
107
107
### Where each competitor fits
108
108
109
-- **Robyn** β€” Rust runtime with a decorator API, the closest architectural peer. Both frameworks keep the hot path in Rust and dispatch to Python for business logic. JustAPI's edge is: (a) measured throughput advantage on all three standard workloads, (b) a native fast path (`native=True`) that eliminates the Python dispatch entirely for schema-backed routes, (c) an ASGI shim for compatibility with the Starlette middleware ecosystem, and (d) built-in MCP agent serving as a first-class primitive rather than an add-on.
109
+- **Robyn** β€” Rust runtime with a decorator API, the closest architectural peer. Both frameworks keep the hot path in Rust and dispatch to Python for business logic. JustAPI's edge is: (a) measured throughput advantage on all three standard workloads, (b) a native fast path (`native=True`) that eliminates the Python dispatch entirely for schema-backed routes, (c) a fully native middleware/validation stack with zero ASGI/Starlette dependency, and (d) built-in MCP agent serving as a first-class primitive rather than an add-on.
110
110
111
111
- **Granian** β€” Rust ASGI *server*. It drops under existing FastAPI/Starlette apps without changing framework code. If your goal is a faster transport layer without migrating frameworks, Granian is the lower-friction choice. JustAPI wins when you want the full Rust stack β€” routing, serialization, middleware, auth, and agent serving β€” all native.
112
112
340
340
<tr><th>Category</th><th>Feature</th><th>Details</th></tr>
341
341
342
342
<tr><td rowspan="4"><strong>Performance</strong></td>
343
- <td>HTTP/1.1 & HTTP/2</td><td>700k+ req/s (native fast path)</td></tr>
343
+ <td>HTTP/1.1 & HTTP/2</td><td>~700k req/s native fast path (validate-and-echo, no Python); ~120k req/s Python-handler path (GIL-bound)</td></tr>
344
344
<tr><td>TLS (rustls)</td><td>~10% overhead, no OpenSSL dependency</td></tr>
345
345
<tr><td>Serialization</td><td>serde_json with optional simd-json</td></tr>
346
346
<tr><td>Compression</td><td>gzip / deflate / brotli / zstd</td></tr>
423
423
</tr>
424
424
<tr>
425
425
<td><strong>Performance</strong></td>
426
-<td>~36k req/s</td>
427
-<td><strong>~700k req/s</strong></td>
426
+<td>~36k req/s (hello-world)</td>
427
+<td><strong>~700k req/s</strong> (native fast path, validate-and-echo)<br>~120k req/s (Python handler, GIL-serialized)<br>~180k req/s (Rust-native DB SELECT)</td>
428
428
</tr>
429
429
</table>
430
430
431
431
β–Όcrates/justapi-core/Cargo.toml
+5-0
8
8
default = ["opentelemetry"]
9
9
tls = ["rustls", "tokio-rustls", "rustls-pemfile"]
10
10
ws = ["tokio-tungstenite", "sha1"]
11
+http3 = ["dep:h3", "dep:h3-quinn", "dep:quinn", "tls"]
11
12
compression = []
12
13
brotli-compression = ["compression", "brotli"]
13
14
zstd-compression = ["compression", "zstd"]
78
79
tempfile = "3.27.0"
79
80
lettre = { workspace = true, optional = true }
80
81
minijinja = { workspace = true, optional = true }
82
+h3 = { workspace = true, optional = true }
83
+h3-quinn = { workspace = true, optional = true }
84
+quinn = { workspace = true, optional = true }
85
+rustls-pki-types.workspace = true
81
86
82
87
[dev-dependencies]
83
88
proptest = "1"
84
89
β–Όcrates/justapi-core/src/http3.rs
+273-0
1
+//! HTTP/3 (QUIC) server support.
2
+//!
3
+//! HTTP/3 runs over UDP via QUIC (RFC 9000) with TLS 1.3 built in (RFC 9001),
4
+//! multiplexing requests on a single connection. It is a *separate* transport
5
+//! from the TCP-based HTTP/1.1/HTTP/2 path: the listener binds a UDP socket,
6
+//! quinn provides the QUIC transport, and `h3` provides the HTTP/3 protocol
7
+//! framing on top.
8
+//!
9
+//! Integration model: the HTTP/3 endpoint serves the *same* application
10
+//! handler the TCP path uses. Because quinn/h3 produce their own request
11
+//! types, requests are bridged into a `hyper::Request<Full<Bytes>>` and run
12
+//! through a caller-provided generic handler closure (see
13
+//! [`serve_http3`]). This keeps one application pipeline for both transports.
14
+//!
15
+//! HTTPS/QUIC requires a TLS certificate (self-signed is fine for testing).
16
+//! Enable with the `http3` feature flag.
17
+
18
+use std::net::SocketAddr;
19
+use std::sync::Arc;
20
+
21
+use anyhow::Result;
22
+use bytes::{Buf, Bytes};
23
+use http_body_util::Full;
24
+use hyper::Request;
25
+
26
+use crate::metrics::Metrics;
27
+use crate::middleware::MiddlewareChain;
28
+
29
+/// QUIC server endpoint configuration (certificates are required for TLS).
30
+#[derive(Clone, Debug)]
31
+pub struct Http3Config {
32
+ /// TLS certificate chain (PEM).
33
+ pub cert_path: String,
34
+ /// TLS private key (PEM).
35
+ pub key_path: String,
36
+}
37
+
38
+/// Bridge handler used by the HTTP/3 endpoint. It maps a bridged
39
+/// `hyper::Request<Full<Bytes>>` to the response (status, headers, body).
40
+pub type Http3Handler = Arc<
41
+ dyn Fn(
42
+ Request<Full<Bytes>>,
43
+ ) -> std::pin::Pin<
44
+ Box<
45
+ dyn std::future::Future<Output = Result<(u16, Vec<(String, String)>, Vec<u8>)>>
46
+ + Send,
47
+ >,
48
+ > + Send
49
+ + Sync,
50
+>;
51
+
52
+/// Build a QUIC server config from PEM cert/key files (reuses rustls).
53
+fn quic_server_config(cfg: &Http3Config) -> Result<quinn::ServerConfig> {
54
+ let certs = {
55
+ let data = std::fs::read(&cfg.cert_path)?;
56
+ let mut reader = std::io::BufReader::new(data.as_slice());
57
+ rustls_pemfile::certs(&mut reader).collect::<Result<Vec<_>, _>>()?
58
+ };
59
+ let key = {
60
+ let data = std::fs::read(&cfg.key_path)?;
61
+ let mut reader = std::io::BufReader::new(data.as_slice());
62
+ rustls_pemfile::private_key(&mut reader)?
63
+ .ok_or_else(|| anyhow::anyhow!("no private key found in {}", cfg.key_path))?
64
+ };
65
+ let mut config = quinn::ServerConfig::with_single_cert(certs, key)?;
66
+ config.transport_config(Arc::new(quinn::TransportConfig::default()));
67
+ Ok(config)
68
+}
69
+
70
+/// Run an HTTP/3 (QUIC) server on a UDP socket bound to `addr`.
71
+///
72
+/// `handler` is the application bridge handler; `metrics` records request
73
+/// status/latency for the HTTP/3 path. The accept loop runs in the background
74
+/// until `cancel` is triggered or the socket errors. Returns the bound
75
+/// address.
76
+pub async fn serve_http3(
77
+ addr: SocketAddr,
78
+ cfg: Http3Config,
79
+ handler: Http3Handler,
80
+ metrics: Metrics,
81
+ cancel: tokio_util::sync::CancellationToken,
82
+) -> Result<SocketAddr> {
83
+ let server_config = quic_server_config(&cfg)?;
84
+ let std_socket = std::net::UdpSocket::bind(addr)?;
85
+ let local_addr = std_socket.local_addr()?;
86
+
87
+ let endpoint = quinn::Endpoint::new(
88
+ quinn::EndpointConfig::default(),
89
+ Some(server_config),
90
+ std_socket,
91
+ Arc::new(quinn::TokioRuntime),
92
+ )?;
93
+
94
+ tracing::info!("HTTP/3 listening on udp://{} (QUIC, ALPN h3)", local_addr);
95
+
96
+ tokio::spawn(async move {
97
+ loop {
98
+ tokio::select! {
99
+ _ = cancel.cancelled() => {
100
+ tracing::info!("HTTP/3 shutdown signal received, stopping accept loop");
101
+ break;
102
+ }
103
+ incoming = endpoint.accept() => {
104
+ match incoming {
105
+ Some(incoming) => {
106
+ match incoming.accept() {
107
+ Ok(connecting) => {
108
+ let handler = handler.clone();
109
+ let metrics = metrics.clone();
110
+ tokio::spawn(async move {
111
+ match connecting.await {
112
+ Ok(conn) => {
113
+ tracing::debug!("HTTP/3 connection established");
114
+ if let Err(e) = serve_quic_conn(conn, handler, metrics).await {
115
+ tracing::debug!("HTTP/3 connection error: {}", e);
116
+ }
117
+ }
118
+ Err(e) => tracing::debug!("HTTP/3 handshake rejected: {}", e),
119
+ }
120
+ });
121
+ }
122
+ Err(e) => tracing::debug!("HTTP/3 accept error: {}", e),
123
+ }
124
+ }
125
+ None => {
126
+ tracing::warn!("HTTP/3 accept returned None (endpoint closed)");
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ });
134
+
135
+ Ok(local_addr)
136
+}
137
+
138
+async fn serve_quic_conn(
139
+ conn: quinn::Connection,
140
+ handler: Http3Handler,
141
+ metrics: Metrics,
142
+) -> Result<()> {
143
+ let conn = h3_quinn::Connection::new(conn);
144
+ let mut h3_conn = h3::server::builder().build(conn).await?;
145
+ tracing::debug!("HTTP/3 connection built, waiting for requests");
146
+
147
+ loop {
148
+ match h3_conn.accept().await {
149
+ Ok(Some(resolver)) => {
150
+ let handler = handler.clone();
151
+ let metrics = metrics.clone();
152
+ tokio::spawn(async move {
153
+ let start = std::time::Instant::now();
154
+ match resolver.resolve_request().await {
155
+ Ok((req, mut stream)) => {
156
+ let body = match collect_body(&mut stream).await {
157
+ Ok(b) => b,
158
+ Err(_) => {
159
+ metrics.record_status(hyper::StatusCode::BAD_REQUEST);
160
+ metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
161
+ return;
162
+ }
163
+ };
164
+ let bridged = bridge_request(req, body);
165
+ match handler(bridged).await {
166
+ Ok((status, headers, resp_body)) => {
167
+ let mut resp = http::Response::builder()
168
+ .status(
169
+ http::StatusCode::from_u16(status)
170
+ .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR),
171
+ )
172
+ .body(())
173
+ .expect("valid response");
174
+ for (k, v) in headers {
175
+ if let (Ok(name), Ok(value)) = (
176
+ http::header::HeaderName::from_bytes(k.as_bytes()),
177
+ http::HeaderValue::from_str(&v),
178
+ ) {
179
+ resp.headers_mut().insert(name, value);
180
+ }
181
+ }
182
+ if stream.send_response(resp).await.is_err() {
183
+ return;
184
+ }
185
+ if !resp_body.is_empty()
186
+ && stream.send_data(Bytes::from(resp_body)).await.is_err()
187
+ {
188
+ return;
189
+ }
190
+ let _ = stream.finish().await;
191
+ metrics.record_status(
192
+ hyper::StatusCode::from_u16(status)
193
+ .unwrap_or(hyper::StatusCode::INTERNAL_SERVER_ERROR),
194
+ );
195
+ metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
196
+ }
197
+ Err(e) => {
198
+ tracing::error!("HTTP/3 handler error: {:#}", e);
199
+ metrics.record_status(hyper::StatusCode::INTERNAL_SERVER_ERROR);
200
+ metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
201
+ }
202
+ }
203
+ }
204
+ Err(e) => {
205
+ tracing::debug!("HTTP/3 request resolution error: {}", e);
206
+ }
207
+ }
208
+ });
209
+ }
210
+ Ok(None) => break,
211
+ Err(_) => break,
212
+ }
213
+ }
214
+ Ok(())
215
+}
216
+
217
+async fn collect_body<S>(
218
+ stream: &mut h3::server::RequestStream<S, Bytes>,
219
+) -> Result<Vec<u8>, String>
220
+where
221
+ S: h3::quic::BidiStream<Bytes> + Send + 'static,
222
+{
223
+ let mut buf = Vec::new();
224
+ while let Some(chunk) = stream.recv_data().await.map_err(|e| e.to_string())? {
225
+ buf.extend_from_slice(chunk.chunk());
226
+ if buf.len() > 50 * 1024 * 1024 {
227
+ return Err("body exceeds maximum size".to_string());
228
+ }
229
+ }
230
+ Ok(buf)
231
+}
232
+
233
+fn bridge_request(req: http::Request<()>, body: Vec<u8>) -> Request<Full<Bytes>> {
234
+ let mut builder = Request::builder().method(req.method().clone()).uri(req.uri().clone());
235
+ for (k, v) in req.headers() {
236
+ builder = builder.header(k.clone(), v.clone());
237
+ }
238
+ builder.body(Full::new(Bytes::from(body))).expect("valid bridged request")
239
+}
240
+
241
+/// Convenience: build a bridge handler from a `MiddlewareChain<Full<Bytes>>`.
242
+pub fn chain_to_http3_handler(chain: MiddlewareChain<Full<Bytes>>) -> Http3Handler {
243
+ Arc::new(move |req: Request<Full<Bytes>>| {
244
+ let chain = chain.clone();
245
+ Box::pin(async move {
246
+ let resp = chain.run(req).await?;
247
+ let status = resp.status().as_u16();
248
+ let headers: Vec<(String, String)> = resp
249
+ .headers()
250
+ .iter()
251
+ .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
252
+ .collect();
253
+ let resp_body = http_body_util::BodyExt::collect(resp.into_body())
254
+ .await
255
+ .map(|c| c.to_bytes().to_vec())?;
256
+ Ok((status, headers, resp_body))
257
+ })
258
+ })
259
+}
260
+
261
+#[cfg(test)]
262
+mod tests {
263
+ use super::*;
264
+
265
+ #[test]
266
+ fn quic_server_config_rejects_missing_key() {
267
+ let cfg = Http3Config {
268
+ cert_path: "/nonexistent/cert.pem".into(),
269
+ key_path: "/nonexistent/key.pem".into(),
270
+ };
271
+ assert!(quic_server_config(&cfg).is_err());
272
+ }
273
+}
0
274
β–Όcrates/justapi-core/src/lib.rs
+16-1
13
13
#[cfg(feature = "graphql")]
14
14
pub mod graphql;
15
15
pub mod health;
16
+#[cfg(feature = "http3")]
17
+pub mod http3;
16
18
#[cfg(feature = "mail")]
17
19
pub mod mail;
18
20
pub mod memory;
138
140
139
141
/// Map a SQLx error from a request-path DB operation to the right status.
140
142
/// Saturation (`PoolTimedOut`/`PoolClosed`) becomes `503` (backpressure);
141
-/// everything else is a `500`.
143
+/// SQLite lock contention (`SQLITE_BUSY` after `busy_timeout`) is also a
144
+/// transient, retryable condition β†’ `503` with `Retry-After`, so a concurrent
145
+/// write crunch doesn't surface as a 5s stall + 500. Everything else is a 500.
142
146
#[cfg(feature = "db")]
143
147
pub fn db_error_response(e: &sqlx::Error) -> Response<ResponseBody> {
144
148
match e {
145
149
sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed => {
146
150
service_unavailable_response("database pool saturated; please retry shortly")
147
151
}
152
+ sqlx::Error::Database(db_err) => {
153
+ let code = db_err.code().map(|c| c.to_string()).unwrap_or_default();
154
+ // SQLite extended result codes: 5 = SQLITE_BUSY, 6 = SQLITE_LOCKED
155
+ // (517 = BUSY_SNAPSHOT, 518 = BUSY_RECOVERY, 261 = LOCKED_SHAREDCACHE).
156
+ let is_lock = matches!(code.as_str(), "5" | "6" | "517" | "518" | "261");
157
+ if is_lock {
158
+ service_unavailable_response("database is locked; please retry shortly")
159
+ } else {
160
+ error_response(hyper::StatusCode::INTERNAL_SERVER_ERROR, "database error")
161
+ }
162
+ }
148
163
_ => error_response(hyper::StatusCode::INTERNAL_SERVER_ERROR, "database error"),
149
164
}
150
165
}
151
166
β–Όcrates/justapi-core/src/server/mod.rs
+10-7
360
360
self
361
361
}
362
362
363
- /// Set a custom fallback handler (used by the ASGI shim).
363
+ /// Set a custom fallback handler for the full middleware chain.
364
364
pub fn with_handler(mut self, handler: crate::middleware::HandlerFn) -> Self {
365
365
// A custom handler and the built-in default router are mutually
366
366
// exclusive: installing the default router after a custom handler would
1473
1473
}
1474
1474
Ok(response)
1475
1475
}
1476
- Err(_) => {
1477
- metrics.record_status(StatusCode::NOT_FOUND);
1476
+ Err(e) => {
1477
+ metrics.record_status(StatusCode::INTERNAL_SERVER_ERROR);
1478
1478
metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0);
1479
1479
1480
- if let Some(resp) = try_serve_static(&path, &static_dir, &static_mounts).await {
1481
- return Ok::<_, anyhow::Error>(resp);
1482
- }
1483
- Ok(error_response(StatusCode::NOT_FOUND, "not found"))
1480
+ // A "route not found" is signaled by the handler as `Ok(404)`
1481
+ // (see justapi-py handlers). An `Err` here means the chain or a
1482
+ // middleware genuinely failed (body read, GIL pool dispatch,
1483
+ // batcher, ...). Return 500 so the failure is observable instead
1484
+ // of being silently masked as a 404 with no log.
1485
+ tracing::error!("Request to {} {} failed in handler chain: {:?}", method, path, e);
1486
+ Ok(error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal server error"))
1484
1487
}
1485
1488
},
1486
1489
Err(_) => {
1487
1490
β–Όcrates/justapi-core/src/testing.rs
+30-0
72
72
self.request(Method::DELETE, path, Vec::new(), &[]).await
73
73
}
74
74
75
+ /// Send an HTTP QUERY request (RFC 10008) with a body. QUERY is safe and
76
+ /// idempotent like GET but carries a request body like POST, so client
77
+ /// helpers mirror the POST shape.
78
+ pub async fn query(&self, path: &str, body: Vec<u8>) -> Result<TestResponse, anyhow::Error> {
79
+ self.query_with(path, body, &[]).await
80
+ }
81
+
82
+ /// QUERY with extra request headers (RFC 10008 requires a Content-Type).
83
+ pub async fn query_with(
84
+ &self,
85
+ path: &str,
86
+ body: Vec<u8>,
87
+ headers: &[(&str, &str)],
88
+ ) -> Result<TestResponse, anyhow::Error> {
89
+ let method = crate::query_method();
90
+ self.request(method, path, body, headers).await
91
+ }
92
+
75
93
async fn request(
76
94
&self,
77
95
method: Method,
236
254
assert_eq!(resp.status, 200);
237
255
}
238
256
257
+ #[tokio::test]
258
+ async fn test_test_client_query() {
259
+ let client = TestClient::new(ok_handler());
260
+ let resp = client.query("/test", b"{}".to_vec()).await.unwrap();
261
+ assert_eq!(resp.status, 200);
262
+ let resp = client
263
+ .query_with("/test", b"{}".to_vec(), &[("Content-Type", "application/json")])
264
+ .await
265
+ .unwrap();
266
+ assert_eq!(resp.status, 200);
267
+ }
268
+
239
269
#[tokio::test]
240
270
async fn test_test_client_404() {
241
271
let handler: HandlerFn = std::sync::Arc::new(|_req| {
242
272
β–Όcrates/justapi-core/tests/db_bridge.rs
+1-0
3
3
//! routing. SQLite runs in-memory (always available); Postgres/MySQL tests are
4
4
//! skipped unless a `JUSTAPI_TEST_PG_URL` / `JUSTAPI_TEST_MYSQL_URL` env var is
5
5
//! provided, so the suite stays hermetic by default.
6
+#![cfg(feature = "db")]
6
7
7
8
use base64::Engine;
8
9
use justapi_core::db::{AnyPool, DatabaseConfig, DbKind, IsolationLevel, Param};
9
10
β–Όcrates/justapi-core/tests/edge_cases.rs
+1-0
69
69
}
70
70
71
71
#[tokio::test]
72
+#[cfg(feature = "db")]
72
73
async fn test_sql_identifier_validation() {
73
74
use justapi_core::server::{CrudOp, CrudSpec};
74
75
75
76
β–Όcrates/justapi-core/tests/http3_test.rs
+190-0
1
+//! HTTP/3 (QUIC) end-to-end integration test.
2
+//!
3
+//! Generates a self-signed certificate, starts an HTTP/3 server on an
4
+//! ephemeral UDP port, and round-trips a request through the h3-quinn client.
5
+
6
+#![cfg(feature = "http3")]
7
+
8
+use std::net::SocketAddr;
9
+use std::sync::Arc;
10
+
11
+use bytes::{Buf, Bytes};
12
+use http_body_util::Full;
13
+use hyper::Request;
14
+use justapi_core::http3::{serve_http3, Http3Config, Http3Handler};
15
+use justapi_core::metrics::Metrics;
16
+use tokio_util::sync::CancellationToken;
17
+
18
+/// Write a self-signed cert/key pair (via rcgen) to temp files and return paths.
19
+fn write_self_signed_cert(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
20
+ let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
21
+ let cert_path = dir.join("cert.pem");
22
+ let key_path = dir.join("key.pem");
23
+ std::fs::write(&cert_path, cert.cert.pem()).unwrap();
24
+ std::fs::write(&key_path, cert.key_pair.serialize_pem()).unwrap();
25
+ (cert_path, key_path)
26
+}
27
+
28
+/// Simple echo bridge handler: returns `{"echo": "<path>"}`.
29
+fn echo_handler() -> Http3Handler {
30
+ Arc::new(|req: Request<Full<Bytes>>| {
31
+ Box::pin(async move {
32
+ let path = req.uri().path().to_string();
33
+ let body = format!(r#"{{"echo": "{}"}}"#, path).into_bytes();
34
+ Ok::<_, anyhow::Error>((
35
+ 200,
36
+ vec![("content-type".to_string(), "application/json".to_string())],
37
+ body,
38
+ ))
39
+ })
40
+ })
41
+}
42
+
43
+#[tokio::test]
44
+async fn http3_roundtrip() {
45
+ let _ = tracing_subscriber::fmt()
46
+ .with_max_level(tracing::Level::DEBUG)
47
+ .with_test_writer()
48
+ .try_init();
49
+ let dir = tempfile::tempdir().unwrap();
50
+ let (cert_path, key_path) = write_self_signed_cert(dir.path());
51
+ let cfg = Http3Config {
52
+ cert_path: cert_path.display().to_string(),
53
+ key_path: key_path.display().to_string(),
54
+ };
55
+
56
+ let cancel = CancellationToken::new();
57
+ let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
58
+ let bound =
59
+ serve_http3(addr, cfg, echo_handler(), Metrics::new(), cancel.clone()).await.unwrap();
60
+
61
+ // Trust the SAME certificate the server presents (read from the file).
62
+ let cert_pem = std::fs::read_to_string(&cert_path).unwrap();
63
+ let cert_der = rustls_pemfile::certs(&mut cert_pem.as_bytes()).next().unwrap().unwrap();
64
+ let mut roots = rustls::RootCertStore::empty();
65
+ roots.add(cert_der).unwrap();
66
+ let client_crypto = quinn::ClientConfig::with_root_certificates(Arc::new(roots)).unwrap();
67
+ let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap();
68
+ endpoint.set_default_client_config(client_crypto);
69
+
70
+ let conn = endpoint.connect(bound, "localhost").unwrap().await.unwrap();
71
+ let h3_conn = h3_quinn::Connection::new(conn);
72
+ let (mut driver, mut send_request) = h3::client::new(h3_conn).await.unwrap();
73
+
74
+ // Drive the client connection concurrently (required for the request
75
+ // to complete), mirroring the h3 example's `tokio::join!` pattern.
76
+ let drive = async move {
77
+ Err::<(), h3::error::ConnectionError>(
78
+ futures::future::poll_fn(|cx| driver.poll_close(cx)).await,
79
+ )
80
+ };
81
+
82
+ let request = async {
83
+ let req = Request::builder().method("GET").uri("https://localhost/hello").body(()).unwrap();
84
+ let mut stream = send_request.send_request(req).await.unwrap();
85
+ // Signal end of request body β€” otherwise the server's body read never
86
+ // returns `None`.
87
+ stream.finish().await.unwrap();
88
+ let resp = stream.recv_response().await.unwrap();
89
+ assert_eq!(resp.status(), 200);
90
+
91
+ let mut body = Vec::new();
92
+ while let Some(chunk) = stream.recv_data().await.unwrap() {
93
+ body.extend_from_slice(chunk.chunk());
94
+ }
95
+ String::from_utf8(body).unwrap()
96
+ };
97
+
98
+ let (body, _drive_res) = tokio::join!(request, drive);
99
+ assert!(body.contains(r#""echo": "/hello""#), "body was: {body}");
100
+
101
+ cancel.cancel();
102
+}
103
+
104
+#[tokio::test]
105
+#[ignore = "requires the justapi-py wheel with http3 feature in the dev venv"]
106
+async fn http3_python_native_pipeline() {
107
+ // Spawn a real JustAPI Python app with enable_http3 and verify a QUIC
108
+ // request reaches the Python handler through the native pipeline.
109
+ let dir = tempfile::tempdir().unwrap();
110
+ let cert_path = dir.path().join("cert.pem");
111
+ let key_path = dir.path().join("key.pem");
112
+ let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
113
+ std::fs::write(&cert_path, cert.cert.pem()).unwrap();
114
+ std::fs::write(&key_path, cert.key_pair.serialize_pem()).unwrap();
115
+
116
+ let app_src = format!(
117
+ r#"
118
+from justapi import JustAPIApp
119
+app = JustAPIApp()
120
+app.enable_http3(cert_path={cert:?}, key_path={key:?})
121
+@app.get("/native")
122
+def native(request):
123
+ return {{"handler": "python", "route": request.get("path")}}
124
+app.run("127.0.0.1:8195")
125
+"#,
126
+ cert = cert_path.display().to_string(),
127
+ key = key_path.display().to_string(),
128
+ );
129
+ let app_file = dir.path().join("app.py");
130
+ std::fs::write(&app_file, app_src).unwrap();
131
+
132
+ let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
133
+ .ancestors()
134
+ .find(|p| p.join("Cargo.toml").exists() && p.join("AGENTS.md").exists())
135
+ .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap())
136
+ .to_path_buf();
137
+ let python = std::env::var("JUSTAPI_TEST_PYTHON").unwrap_or_else(|_| {
138
+ repo_root.join("crates/justapi-py/.venv/bin/python").display().to_string()
139
+ });
140
+ eprintln!("[h3-py-test] python={} repo_root={}", python, repo_root.display());
141
+ let mut child = tokio::process::Command::new(&python)
142
+ .arg(&app_file)
143
+ .current_dir(repo_root)
144
+ .kill_on_drop(true)
145
+ .spawn()
146
+ .expect("spawn python app");
147
+ tokio::time::sleep(std::time::Duration::from_secs(5)).await;
148
+
149
+ // QUIC client trusting the same cert.
150
+ let cert_pem = std::fs::read_to_string(&cert_path).unwrap();
151
+ let cert_der = rustls_pemfile::certs(&mut cert_pem.as_bytes()).next().unwrap().unwrap();
152
+ let mut roots = rustls::RootCertStore::empty();
153
+ roots.add(cert_der).unwrap();
154
+ let client_crypto = quinn::ClientConfig::with_root_certificates(Arc::new(roots)).unwrap();
155
+ let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap();
156
+ endpoint.set_default_client_config(client_crypto);
157
+
158
+ let conn = endpoint
159
+ .connect("127.0.0.1:8195".parse().unwrap(), "localhost")
160
+ .unwrap()
161
+ .await
162
+ .expect("QUIC connect to python app");
163
+ let h3_conn = h3_quinn::Connection::new(conn);
164
+ let (mut driver, mut send_request) = h3::client::new(h3_conn).await.unwrap();
165
+
166
+ let drive = async move {
167
+ Err::<(), h3::error::ConnectionError>(
168
+ futures::future::poll_fn(|cx| driver.poll_close(cx)).await,
169
+ )
170
+ };
171
+ let request = async {
172
+ let req =
173
+ Request::builder().method("GET").uri("https://localhost/native").body(()).unwrap();
174
+ let mut stream = send_request.send_request(req).await.unwrap();
175
+ stream.finish().await.unwrap();
176
+ let resp = stream.recv_response().await.unwrap();
177
+ assert_eq!(resp.status(), 200);
178
+ let mut body = Vec::new();
179
+ while let Some(chunk) = stream.recv_data().await.unwrap() {
180
+ body.extend_from_slice(chunk.chunk());
181
+ }
182
+ String::from_utf8(body).unwrap()
183
+ };
184
+ let (body, _) = tokio::join!(request, drive);
185
+ // The response must come from the PYTHON handler (native pipeline),
186
+ // not a core static route.
187
+ assert!(body.contains(r#""handler":"python""#), "body was: {body}");
188
+
189
+ let _ = child.kill().await;
190
+}
0
191
β–Όcrates/justapi-py/Cargo.toml
+2-0
12
12
mail = ["justapi-core/mail"]
13
13
orjson = ["justapi-core/orjson"]
14
14
redis-rate-limit = ["justapi-core/redis-rate-limit"]
15
+http3 = ["justapi-core/http3"]
15
16
16
17
[dependencies]
17
18
pyo3.workspace = true
19
20
tokio.workspace = true
20
21
http-body-util.workspace = true
21
22
http-body = "1"
23
+bytes = "1"
22
24
hyper.workspace = true
23
25
http = "1"
24
26
futures = "0.3"
25
27
β–Όcrates/justapi-py/pyproject.toml
+2-2
4
4
5
5
[project]
6
6
name = "justapi"
7
-version = "2.0.0"
7
+version = "2.0.8"
8
8
description = "JustAPI Runtime β€” a Rust-powered, FastAPI-class web framework for Python. Blazing-fast routing, native JSON validation, dependency injection, OpenAPI, WebSockets, SSE, background tasks, and an embedded DAG engine β€” all from a single pip install."
9
9
readme = "README.md"
10
10
requires-python = ">=3.11"
23
23
"rest",
24
24
]
25
25
classifiers = [
26
- "Development Status :: 5 - Production/Stable",
26
+ "Development Status :: 4 - Beta",
27
27
"Intended Audience :: Developers",
28
28
"License :: OSI Approved :: MIT License",
29
29
"Programming Language :: Python :: 3",
30
30
β–Όcrates/justapi-py/python/justapi/__init__.py
+13-4
25
25
26
26
"""
27
27
28
+__version__ = "2.0.8"
29
+
28
30
from . import auth as auth
31
+from .auth import ( # noqa: F401 β€” top-level re-export (FastAPI parity, used by docs)
32
+ JwtAuth,
33
+ OAuth2PasswordBearer,
34
+ OAuth2PasswordRequestForm,
35
+ OAuth2PasswordRequestFormStrict,
36
+)
29
37
from . import testing as testing
30
38
from . import tracing as tracing
31
39
from . import status as status
39
47
RateLimiter = None # type: ignore[assignment,misc]
40
48
RateLimitResult = None # type: ignore[assignment,misc]
41
49
from ._justapi import Database, DbPool, DbParam # type: ignore[import-untyped]
42
-from .app import JustAPIApp, JustAPI, Depends, Mailer, adaptive_batch, APIRouter, Controller, controller, route_get, route_post, route_put, route_patch, route_delete, route_query, route_sse, route_websocket, JustAPITestClient, RequestValidationError, Session
50
+from .app import JustAPIApp, JustAPI, Depends, Security, Mailer, adaptive_batch, APIRouter, Controller, controller, route_get, route_post, route_put, route_patch, route_delete, route_query, route_sse, route_websocket, JustAPITestClient, RequestValidationError, Session
43
51
from .system import build_help, build_openapi, register_system_routes
44
52
from .exceptions import HTTPException, WebSocketException
45
53
from .websockets import WebSocket, WebSocketState, WebSocketDisconnect
287
295
name: str
288
296
email: str
289
297
290
- app.post("/users", handler, schema=pydantic_schema(UserModel))
298
+ app.post("/users", handler, schema=pydantic_schema(UserModel))
291
299
"""
292
300
if hasattr(model_class, 'model_json_schema'):
293
301
# Pydantic v2
304
312
305
313
306
314
__all__ = [
307
- "serve", "JustAPIApp", "JustAPI", "Depends", "Mailer", "Database", "DbPool", "DbParam", "Schema", "pydantic_schema",
315
+ "__version__", "serve", "JustAPIApp", "JustAPI", "Depends", "Security", "Mailer", "Database", "DbPool", "DbParam", "Schema", "pydantic_schema",
308
316
"JustAPITestClient", "testing", "tracing", "auth", "Jinja2Templates", "BackgroundTasks", "Scheduler",
309
317
"TokenStreamResponse", "ValidatedStreamResponse", "WebSocket", "Dag", "DagNode", "RateLimiter", "RateLimitResult",
310
318
"adaptive_batch", "APIRouter", "Controller", "controller",
315
323
"FileResponse",
316
324
"UploadFile", "RequestValidationError", "Param", "Path", "Query", "Header",
317
325
"Cookie", "Body", "File", "Form", "status", "HTTPException", "WebSocketException",
318
- "WebSocketState", "WebSocketDisconnect",
326
+ "WebSocketState", "WebSocketDisconnect", "JwtAuth", "OAuth2PasswordBearer",
327
+ "OAuth2PasswordRequestForm", "OAuth2PasswordRequestFormStrict",
319
328
"build_help", "build_openapi", "register_system_routes"
320
329
]
321
330
β–Όcrates/justapi-py/python/justapi/app.py
+56-4
211
211
self.use_cache = use_cache
212
212
213
213
214
+def _is_async_callable(obj):
215
+ """True if `obj` is an async function OR a callable instance whose
216
+ `__call__` is async. `inspect.iscoroutinefunction(obj)` returns False for
217
+ instances (e.g. `OAuth2PasswordBearer(...)`), which made the DI invoke
218
+ async dependencies without awaiting them.
219
+ """
220
+ if inspect.iscoroutinefunction(obj):
221
+ return True
222
+ call = getattr(obj, "__call__", None)
223
+ return call is not None and inspect.iscoroutinefunction(call)
224
+
225
+
226
+class Security(Depends):
227
+ """`Security(dependency)` is `Depends(dependency)` with OAuth2 scope
228
+ support, mirroring FastAPI's `Security`. The `scopes` are recorded for
229
+ OpenAPI documentation; scope enforcement is the dependency's job.
230
+
231
+ from justapi import Security
232
+ from justapi.auth import OAuth2PasswordBearer
233
+
234
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
235
+
236
+ @app.get("/items")
237
+ def list_items(token: str = Security(oauth2_scheme, scopes=["read"])):
238
+ ...
239
+ """
240
+
241
+ def __init__(self, dependency: typing.Callable, use_cache: bool = True, scopes=None):
242
+ super().__init__(dependency, use_cache=use_cache)
243
+ self.scopes = list(scopes) if scopes else []
244
+
245
+
214
246
class Mailer:
215
247
"""SMTP email sender with optional template support.
216
248
603
635
gen = dep.dependency(**dep_kwargs)
604
636
res = next(gen)
605
637
request["_dep_cleanup"].append(gen.close)
606
- elif inspect.iscoroutinefunction(dep.dependency):
638
+ elif _is_async_callable(dep.dependency):
607
639
res = await dep.dependency(**dep_kwargs)
608
640
else:
609
641
res = dep.dependency(**dep_kwargs)
645
677
gen = param.default.dependency(**dep_kwargs)
646
678
res = next(gen)
647
679
request["_dep_cleanup"].append(gen.close)
648
- elif inspect.iscoroutinefunction(param.default.dependency):
680
+ elif _is_async_callable(param.default.dependency):
649
681
res = await param.default.dependency(**dep_kwargs)
650
682
else:
651
683
res = param.default.dependency(**dep_kwargs)
915
947
is_async = inspect.iscoroutinefunction(handler)
916
948
for name, param in sig.parameters.items():
917
949
if isinstance(param.default, Depends):
918
- if inspect.iscoroutinefunction(param.default.dependency):
950
+ if _is_async_callable(param.default.dependency):
919
951
is_async = True
920
952
921
953
for dep in all_deps:
922
- if inspect.iscoroutinefunction(dep.dependency):
954
+ if _is_async_callable(dep.dependency):
923
955
is_async = True
924
956
925
957
has_bg = "background_tasks" in sig.parameters or any(
1357
1389
def enable_gateway(self, config_path: str):
1358
1390
self._app.enable_gateway(config_path)
1359
1391
1392
+ def enable_http3(self, cert_path: str, key_path: str):
1393
+ """Enable an HTTP/3 (QUIC) listener alongside the TCP server.
1394
+
1395
+ ``run(addr)`` also binds the same address over UDP and serves the same
1396
+ application handler with HTTP/3. QUIC requires TLS 1.3, so PEM
1397
+ certificate and private-key files are required:
1398
+
1399
+ app.enable_http3(cert_path="certs/fullchain.pem",
1400
+ key_path="certs/privkey.pem")
1401
+ app.run("0.0.0.0:443")
1402
+
1403
+ Requires the ``http3`` build feature; without it this method raises.
1404
+ """
1405
+ if not hasattr(self._app, "enable_http3"):
1406
+ raise NotImplementedError(
1407
+ "HTTP/3 support is not compiled into this build β€” rebuild with "
1408
+ "the `http3` feature (justapi-core) or install a wheel built with it."
1409
+ )
1410
+ self._app.enable_http3(cert_path, key_path)
1411
+
1360
1412
def enable_circuit_breaker(self, failure_threshold: int = 5, reset_timeout_ms: int = 10000):
1361
1413
"""
1362
1414
Enables a per-route circuit breaker.
1363
1415
β–Όcrates/justapi-py/python/justapi/test_db_concurrent.py
+63-0
70
70
if os.path.exists(db_path):
71
71
os.unlink(db_path)
72
72
os.rmdir(tmpdir)
73
+
74
+
75
+@pytest.mark.asyncio
76
+async def test_concurrent_writes_small_pool_no_saturation_collapse():
77
+ """Regression: concurrent Python-handler writes must NOT collapse when the
78
+ pool is small (ADR-080).
79
+
80
+ The old request-scoped auto-transaction acquired a pool connection on the
81
+ async runtime and held it while the request queued on the single GIL
82
+ worker, then the handler's `app.db.query` acquired a *second* connection:
83
+ 2N connections for N concurrent writes on an N-connection pool β†’ immediate
84
+ saturation ("pool timed out", ~150 RPS at c=10 β†’ ~3 RPS at c=11). With the
85
+ fix, concurrent writes stay flat and all persist.
86
+ """
87
+ tmpdir = tempfile.mkdtemp()
88
+ db_path = os.path.join(tmpdir, "svc_small_pool.db")
89
+ open(db_path, "w").close()
90
+ try:
91
+ from justapi import Database
92
+
93
+ app = JustAPIApp()
94
+ # A deliberately tiny pool: 2 connections for 50 concurrent writers.
95
+ app.set_database(
96
+ Database(f"sqlite://{db_path}", max_connections=2, request_acquire_timeout=5.0)
97
+ )
98
+
99
+ @app.post("/items")
100
+ async def create(request):
101
+ body = request.json()
102
+ app.db.query(
103
+ "INSERT INTO items(name, qty) VALUES (?, ?)",
104
+ [body["name"], body["qty"]],
105
+ )
106
+ return {"ok": True}
107
+
108
+ async with AsyncTestClient(app, database=f"sqlite://{db_path}") as c:
109
+ app.db.query(
110
+ "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)"
111
+ )
112
+ import asyncio
113
+
114
+ async def insert_one(v):
115
+ return await c.post(
116
+ "/items", f'{{"name":"w{v}","qty":{v}}}'.encode()
117
+ )
118
+
119
+ results = await asyncio.gather(
120
+ *(insert_one(v) for v in range(50)),
121
+ return_exceptions=True,
122
+ )
123
+ # Every request must complete successfully (200) β€” the old code
124
+ # returned 500/503 "pool timed out" for the majority here.
125
+ errors = [
126
+ r for r in results if isinstance(r, Exception) or r.get("status") != 200
127
+ ]
128
+ assert not errors, f"{len(errors)} writes failed: {errors[:3]}"
129
+
130
+ rows = app.db.query("SELECT COUNT(*) AS c FROM items")
131
+ assert rows[0]["c"] == 50, f"expected 50 rows, got {rows[0]['c']}"
132
+ finally:
133
+ if os.path.exists(db_path):
134
+ os.unlink(db_path)
135
+ os.rmdir(tmpdir)
73
136
β–Όcrates/justapi-py/python/justapi/test_db_saturated.py
+40-39
1
-"""Gap #2 regression: saturated DB pool fails fast with 503 (backpressure).
1
+"""Gap #2 regression: DB contention fails fast with 503 (backpressure).
2
2
3
-When every pooled connection is busy, a request that cannot acquire one within
4
-`request_acquire_timeout` must be rejected immediately with `503 Service
5
-Unavailable` β€” not hang until the (long) `acquire_timeout` elapses and then
6
-return a generic 500. This guards the production failure mode where a momentary
7
-connection crunch silently stalls the whole server for ~30s.
3
+When the database is contended (SQLite write lock held, or the pool saturated),
4
+a request must fail with `503 Service Unavailable` β€” not hang until the long
5
+`acquire_timeout` elapses and then return a generic 500. This guards the
6
+production failure mode where a momentary connection crunch silently stalls the
7
+whole server for ~30s.
8
8
9
-Uses a real HTTP server (the same dispatch path as production, including the
10
-auto-transaction begin) so the saturation actually exercises `begin_request`.
9
+The DB is contended by holding the SQLite **write lock** from an external
10
+connection (a legitimate, reproducible saturation: SQLite serializes writers),
11
+then hammering the Rust-native CRUD INSERT path. The write operation waits up
12
+to `busy_timeout` (5s) for the lock, then the framework must map the lock
13
+failure to `503 Retry-After`, bounded β€” never the 30s `acquire_timeout` stall
14
+(see ADR-080).
11
15
"""
12
16
import os
17
+import sqlite3
13
18
import subprocess
14
19
import sys
15
20
import tempfile
16
-import threading
17
21
import time
18
22
import urllib.error
19
23
import urllib.request
20
24
21
25
import pytest
22
26
23
-from justapi import Database, JustAPIApp
24
-
25
27
26
28
def _server_script(db_path, port):
27
29
"""Return source that boots a server with a 1-conn, 1s fast-fail pool."""
28
30
return (
29
- "import asyncio\n"
30
31
"from justapi import Database, JustAPIApp\n"
31
32
"app = JustAPIApp()\n"
32
33
f'app.set_database(Database("sqlite://{db_path}", max_connections=1, request_acquire_timeout=1.0))\n'
33
- "\n"
34
- "@app.post('/slow')\n"
35
- "async def slow(request):\n"
36
- " await asyncio.sleep(3)\n"
37
- " return {'ok': True}\n"
38
- "\n"
39
- "@app.post('/other')\n"
40
- "async def other(request):\n"
41
- " return {'ok': True}\n"
42
- "\n"
34
+ "app.post('/items', crud_table='items', crud_columns=['name', 'qty'])\n"
43
35
f"app.run('127.0.0.1:{port}')\n"
44
36
)
45
37
47
39
def _post(port, path, timeout=10):
48
40
req = urllib.request.Request(
49
41
f"http://127.0.0.1:{port}{path}",
50
- data=b"{}",
42
+ data=b'{"name":"w","qty":1}',
51
43
headers={"Content-Type": "application/json"},
52
44
method="POST",
53
45
)
60
52
def test_saturated_pool_fast_fails_503():
61
53
tmpdir = tempfile.mkdtemp()
62
54
db_path = os.path.join(tmpdir, "svc_saturated.db")
63
- open(db_path, "w").close()
55
+ con = sqlite3.connect(db_path)
56
+ con.execute(
57
+ "CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, qty INTEGER NOT NULL)"
58
+ )
59
+ con.commit()
60
+
64
61
port = 9887
65
62
proc = None
63
+ lock_conn = None
66
64
try:
67
65
proc = subprocess.Popen(
68
66
[sys.executable, "-c", _server_script(db_path, port)],
71
69
)
72
70
time.sleep(1.0)
73
71
74
- # /slow grabs the only connection and holds it 3s.
75
- slow_thread = threading.Thread(target=lambda: _post(port, "/slow", timeout=10))
76
- slow_thread.start()
77
- time.sleep(0.3) # let /slow acquire the connection first
72
+ # Hold the SQLite write lock from an external connection: the server's
73
+ # single pooled connection can then never begin a write transaction, so
74
+ # every acquire is doomed β€” this is a genuine, reproducible saturation.
75
+ lock_conn = sqlite3.connect(db_path, timeout=10)
76
+ lock_conn.execute("BEGIN IMMEDIATE")
77
+ time.sleep(0.3) # let the lock take effect
78
78
79
79
start = time.monotonic()
80
- other_status = _post(port, "/other", timeout=10)
80
+ other_status = _post(port, "/items", timeout=15)
81
81
elapsed = time.monotonic() - start
82
82
83
- slow_thread.join()
83
+ lock_conn.rollback()
84
+ lock_conn.close()
85
+ lock_conn = None
84
86
85
87
assert other_status == 503, f"expected 503 under saturation, got {other_status}"
86
- # Fast-fail: must NOT wait the full 30s acquire_timeout.
87
- assert elapsed < 3.0, f"expected fast 503, but took {elapsed:.2f}s"
88
+ # Bounded: must fail within the busy_timeout window (5s), NOT hang on
89
+ # the 30s acquire_timeout.
90
+ assert elapsed < 10.0, f"expected bounded 503, but took {elapsed:.2f}s"
88
91
finally:
89
- if proc is not None:
90
- proc.terminate()
92
+ if lock_conn is not None:
91
93
try:
92
- proc.communicate(timeout=5)
94
+ lock_conn.close()
93
95
except Exception:
94
- proc.kill()
95
- if os.path.exists(db_path):
96
- os.unlink(db_path)
97
- os.rmdir(tmpdir)
96
+ pass
97
+ if proc is not None:
98
+ proc.terminate()
98
99
β–Όcrates/justapi-py/python/justapi/test_dependency_injection.py
+77-0
43
43
# unless custom exception handlers are added. We can assert it fails.
44
44
resp = await client.get("/items/42?q=foo&skip=10&token=bad")
45
45
assert resp["status"] == 500
46
+
47
+
48
+@pytest.mark.asyncio
49
+async def test_async_callable_instance_dependency():
50
+ """Regression: a `Depends`/`Security` on a callable INSTANCE whose
51
+ `__call__` is async (e.g. `OAuth2PasswordBearer(...)`) must be awaited.
52
+ `inspect.iscoroutinefunction(instance)` is False, so the old code invoked
53
+ the dependency and dropped the unawaited coroutine (RuntimeWarning), and
54
+ the injected value was the coroutine, not its result.
55
+ """
56
+ from justapi import Security
57
+ from justapi.auth import OAuth2PasswordBearer
58
+
59
+ app = JustAPIApp()
60
+ oauth = OAuth2PasswordBearer(tokenUrl="token")
61
+
62
+ @app.get("/users/me")
63
+ async def read_me(token: str = Security(oauth)):
64
+ return {"token": token}
65
+
66
+ async with AsyncTestClient(app) as client:
67
+ # No auth header -> 401 (dependency raises HTTPException)
68
+ resp = await client.get("/users/me")
69
+ assert resp["status"] == 401, f"expected 401, got {resp['status']}"
70
+
71
+
72
+def test_gil_pool_survives_fork():
73
+ """Regression: a forked child must rebuild the GIL pool (its worker
74
+ threads do not survive fork(2)). Before the fix, a child that inherited
75
+ an initialized pool would block forever on send -> 504 timeouts for every
76
+ Python-handler request.
77
+ """
78
+ import multiprocessing
79
+ import os
80
+ import tempfile
81
+ import time
82
+ import urllib.request
83
+ from urllib.error import HTTPError
84
+
85
+ # Warm the GIL pool in the parent first (via a test client), so the fork
86
+ # reproduces the contamination.
87
+ app = JustAPIApp()
88
+
89
+ @app.get("/warm")
90
+ def warm(req):
91
+ return {"ok": True}
92
+
93
+ from justapi import JustAPITestClient
94
+ JustAPITestClient(app).get("/warm")
95
+
96
+ port = 8099
97
+
98
+ def run_server():
99
+ app2 = JustAPIApp()
100
+
101
+ @app2.get("/ping")
102
+ def ping(req):
103
+ return {"ok": True}
104
+
105
+ app2.run(f"127.0.0.1:{port}")
106
+
107
+ p = multiprocessing.Process(target=run_server)
108
+ p.start()
109
+ try:
110
+ deadline = time.time() + 15
111
+ status = None
112
+ while time.time() < deadline:
113
+ try:
114
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/ping", timeout=2) as r:
115
+ status = r.status
116
+ break
117
+ except Exception:
118
+ time.sleep(0.3)
119
+ assert status == 200, f"forked child GIL pool hung: got {status}"
120
+ finally:
121
+ p.terminate()
122
+ p.join()
46
123
β–Όcrates/justapi-py/python/justapi/test_native.py
+57-0
1
1
"""Integration test for Tier B Native API (JustAPIApp).
2
2
"""
3
+import concurrent.futures
3
4
import json
4
5
import subprocess
5
6
import sys
21
22
"body": request["body"],
22
23
}
23
24
25
+# A synchronous (GIL-bound) handler: under CPython only one worker can run it
26
+# at a time, so a burst of requests exercises the GIL pool's bounded dispatch
27
+# queue (capacity 16 per worker). Before the backpressure fix, overflowing that
28
+# queue surfaced as spurious RFC 9457 404s with no log (try_send rejection
29
+# masked by handle_request). This handler keeps the pool busy long enough that
30
+# many requests accumulate concurrently.
31
+def sync_handler(request):
32
+ import time as _t
33
+ _t.sleep(0.0005)
34
+ return {"message": "sync"}
35
+
24
36
app = JustAPIApp()
25
37
app.get("/hello/{name}", hello_handler)
26
38
app.post("/echo", echo_handler)
39
+app.get("/sync", sync_handler)
27
40
app.run("127.0.0.1:9867")
28
41
"""
29
42
93
106
proc.communicate()
94
107
95
108
109
+def test_sync_handler_no_spurious_404_under_load():
110
+ """Regression: a burst of concurrent requests to a GIL-bound (sync)
111
+ Python handler must not produce spurious 404s.
112
+
113
+ The GIL pool dispatches jobs through a bounded channel (cap 16/worker for a
114
+ single GIL worker). Dispatch used `try_send`, so any overflow surfaced as a
115
+ handler error that `handle_request` masked as an RFC 9457 404. With
116
+ `send().await` backpressure the pool throttles instead of dropping, and the
117
+ handler-chain error branch now returns 500 instead of a fake 404.
118
+ """
119
+ proc = subprocess.Popen(
120
+ [sys.executable, "-c", SERVER_SCRIPT],
121
+ stdout=subprocess.DEVNULL,
122
+ stderr=subprocess.DEVNULL,
123
+ )
124
+ addr = "127.0.0.1:9867"
125
+ time.sleep(0.8)
126
+
127
+ def fire(_):
128
+ try:
129
+ resp = urllib.request.urlopen(f"http://{addr}/sync", timeout=10)
130
+ return resp.status
131
+ except urllib.error.HTTPError as e:
132
+ return e.code
133
+ except Exception:
134
+ return -1
135
+
136
+ try:
137
+ # 64 threads >> GIL pool capacity of 16, forcing queue contention.
138
+ with concurrent.futures.ThreadPoolExecutor(max_workers=32) as ex:
139
+ statuses = list(ex.map(fire, range(512)))
140
+ bad = [s for s in statuses if s != 200]
141
+ assert not bad, f"Expected all 200, got non-200 statuses: {bad}"
142
+ print("PASS: 512 concurrent /sync requests -> all 200 (no spurious 404)")
143
+ finally:
144
+ proc.terminate()
145
+ try:
146
+ proc.communicate(timeout=5)
147
+ except subprocess.TimeoutExpired:
148
+ proc.kill()
149
+ proc.communicate()
150
+
151
+
96
152
if __name__ == "__main__":
97
153
test_native_api()
154
+ test_sync_handler_no_spurious_404_under_load()
98
155
β–Όcrates/justapi-py/python/justapi/test_testing.py
+38-0
39
39
async def delete_handler(request):
40
40
return {"status": 204}
41
41
42
+ async def query_handler(request):
43
+ return {
44
+ "status": 200,
45
+ "headers": [(b"content-type", b"application/json")],
46
+ "body": request["body"],
47
+ }
48
+
42
49
app.get("/hello/{name}", hello_handler)
43
50
app.post("/echo", echo_handler)
44
51
app.put("/echo", echo_handler)
45
52
app.delete("/resource/{id}", delete_handler)
53
+ app.query("/search", query_handler)
46
54
47
55
return app
48
56
107
115
assert resp["status"] == 405
108
116
109
117
118
+@pytest.mark.asyncio
119
+async def test_async_query(client):
120
+ payload = json.dumps({"q": "rust"}).encode()
121
+ resp = await client.query("/search", payload)
122
+ assert resp["status"] == 200
123
+ assert bytes(resp["body"]) == payload
124
+
125
+
126
+@pytest.mark.asyncio
127
+async def test_async_query_requires_body_route(client):
128
+ resp = await client.query("/hello/world", b"{}")
129
+ assert resp["status"] == 405
130
+
131
+
132
+@pytest.mark.asyncio
133
+async def test_query_requires_content_type(app):
134
+ from justapi import JustAPITestClient
135
+ import asyncio
136
+ loop = asyncio.get_running_loop()
137
+ client = await loop.run_in_executor(None, lambda: JustAPITestClient(app))
138
+ # No Content-Type header β†’ RFC 10008 says reject with 400.
139
+ resp = await loop.run_in_executor(None, lambda: client.query_with(
140
+ "/search", b'{"q":"rust"}', []))
141
+ assert resp["status"] == 400
142
+ # With Content-Type β†’ 200.
143
+ resp = await loop.run_in_executor(None, lambda: client.query_with(
144
+ "/search", b'{"q":"rust"}', [("Content-Type", "application/json")]))
145
+ assert resp["status"] == 200
146
+
147
+
110
148
# ---------------------------------------------------------------------------
111
149
# Assertion helpers
112
150
# ---------------------------------------------------------------------------
113
151
β–Όcrates/justapi-py/python/justapi/testing.py
+9-0
130
130
async def delete(self, path: str) -> dict:
131
131
return await self._run("DELETE", path)
132
132
133
+ async def query(self, path: str, body: bytes = b"") -> dict:
134
+ client = self._client
135
+ if client is None:
136
+ raise RuntimeError("AsyncTestClient not initialized")
137
+ loop = asyncio.get_running_loop()
138
+ # RFC 10008 requires a Content-Type on QUERY requests.
139
+ fn = partial(client.query_with, path, body, [("Content-Type", "application/json")])
140
+ return await loop.run_in_executor(None, fn)
141
+
133
142
134
143
# ---------------------------------------------------------------------------
135
144
# Database test helpers
136
145
β–Όcrates/justapi-py/src/gil_pool.rs
+54-30
19
19
//! branch. The runtime mode is detected **once** at startup (see [`init`]).
20
20
21
21
use pyo3::prelude::*;
22
-use std::sync::atomic::{AtomicUsize, Ordering};
23
-use std::sync::OnceLock;
22
+use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
24
23
use tokio::sync::mpsc as tokio_mpsc;
25
24
use tokio::sync::oneshot;
26
25
58
57
senders: Vec<tokio_mpsc::Sender<Job>>,
59
58
mode: RuntimeMode,
60
59
/// Round-robin dispatch counter.
61
- next: AtomicUsize,
60
+ next: std::sync::Arc<AtomicUsize>,
62
61
}
63
62
64
-static POOL: OnceLock<GilPool> = OnceLock::new();
63
+/// Process ID that owns the current pool. `fork(2)` copies the parent's
64
+/// address space, so the child would inherit a `GilPool` whose worker threads
65
+/// do not exist in the child β€” every `send` would block forever (observed as
66
+/// 504 timeouts in the child). Any PID change means we are in a forked child
67
+/// (or a new process) and the pool must be rebuilt on its own threads.
68
+static POOL_PID: AtomicU32 = AtomicU32::new(0);
69
+static POOL: std::sync::Mutex<Option<GilPool>> = std::sync::Mutex::new(None);
65
70
66
71
const CHANNEL_CAP_PER_WORKER: usize = 16;
67
72
140
145
.expect("failed to spawn justapi GIL pool worker");
141
146
senders.push(tx);
142
147
}
143
- GilPool { senders, mode, next: AtomicUsize::new(0) }
148
+ GilPool { senders, mode, next: std::sync::Arc::new(AtomicUsize::new(0)) }
144
149
}
145
150
146
151
/// Initialize the GIL pool with a warmup (must hold the GIL). Safe to call
147
-/// multiple times; does nothing if the pool is already initialized.
152
+/// multiple times; does nothing if the pool is already initialized for this
153
+/// process. If called after `fork(2)` (PID changed), rebuilds the pool.
148
154
fn init_pool(num_threads: Option<usize>, py: Python<'_>) {
149
155
let mode = detect_mode(py);
150
156
let n = num_threads.unwrap_or_else(|| default_pool_size(mode));
151
- POOL.get_or_init(|| build_pool(n, mode));
157
+ let pid = std::process::id();
158
+ {
159
+ let mut guard = POOL.lock().unwrap_or_else(|e| e.into_inner());
160
+ let needs_rebuild = guard.is_none() || POOL_PID.load(Ordering::Acquire) != pid;
161
+ if needs_rebuild {
162
+ *guard = Some(build_pool(n, mode));
163
+ POOL_PID.store(pid, Ordering::Release);
164
+ }
165
+ }
152
166
// Warm up OnceLock-backed helpers on the calling thread while we hold the
153
167
// GIL, so GIL-pool workers never race on them. See `init()` docstring.
154
168
let _ = get_helper(py);
188
202
T: Send + 'static,
189
203
{
190
204
let _t0 = std::time::Instant::now();
191
- let pool = POOL.get_or_init(|| {
192
- // Lazy init path: build pool with default (GIL-based) mode. The warmup
193
- // that prevents the `OnceLock` deadlock on `get_helper`/`fast_dumps`
194
- // cannot happen here because we don't have a `Python` token. This is
195
- // safe because:
196
- // 1. `get_helper` and `fast_dumps` are themselves lazy (`OnceLock`),
197
- // and the first GIL-pool worker to reach them will initialise them
198
- // single-threadedly (the OnceLock handles mutual exclusion).
199
- // 2. The GIL-free variant of `py.import("sys")` inside `detect_mode`
200
- // can only deadlock if two workers race a different OnceLock β€”
201
- // but on the lazy path, only one thread reaches this code (the
202
- // first request). Subsequent requests see the already-initialized
203
- // pool.
204
- // Users are still encouraged to call `init()` at startup so warmup
205
- // happens on the main thread rather than on a hot request path.
206
- let mode = RuntimeMode::GilBased;
207
- let n = default_pool_size(mode);
208
- build_pool(n, mode)
209
- });
210
- let n = pool.senders.len();
211
- let idx = if n == 1 { 0usize } else { pool.next.fetch_add(1, Ordering::Relaxed) % n };
205
+ let pid = std::process::id();
206
+ let pool = {
207
+ let mut guard = POOL.lock().unwrap_or_else(|e| e.into_inner());
208
+ let same_process = guard.is_some() && POOL_PID.load(Ordering::Acquire) == pid;
209
+ if !same_process {
210
+ // Forked child (or first call without init): rebuild the pool with
211
+ // default (GIL-based) mode. Warmup cannot run here (no `Python`
212
+ // token); `get_helper`/`fast_dumps` are themselves lazy and safe to
213
+ // initialize on the first worker that reaches them (their `OnceLock`
214
+ // provides mutual exclusion).
215
+ let mode = RuntimeMode::GilBased;
216
+ let n = default_pool_size(mode);
217
+ *guard = Some(build_pool(n, mode));
218
+ POOL_PID.store(pid, Ordering::Release);
219
+ }
220
+ let p = guard.as_ref().expect("pool present");
221
+ (p.senders.clone(), p.next.clone())
222
+ };
223
+ let (senders, next) = pool;
224
+ let n = senders.len();
225
+ let idx = if n == 1 { 0usize } else { next.fetch_add(1, Ordering::Relaxed) % n };
212
226
let (resp_tx, resp_rx) = oneshot::channel::<T>();
213
227
let job: Job = Box::new(move |py| {
214
228
// SAFETY / panic policy: a genuine Rust panic inside `f` (e.g. an
226
240
let resp = f(py);
227
241
let _ = resp_tx.send(resp);
228
242
});
229
- pool.senders[idx].try_send(job).map_err(|e| anyhow::anyhow!("gil pool send failed: {}", e))?;
243
+ // Backpressure, not drop: `try_send` would reject a job whenever a worker's
244
+ // bounded queue is momentarily full (single GIL worker => cap = 16), and
245
+ // `handle_request` masks ANY handler `Err` as an RFC 9457 404. Under a burst
246
+ // of concurrent/pipelined requests that produced spurious 404s with no log.
247
+ // `send` awaits until the worker drains a slot, so load is throttled to the
248
+ // pool's real capacity instead of surfacing as an error (ADR-049 dispatch).
249
+ senders[idx]
250
+ .send(job)
251
+ .await
252
+ .map_err(|_| anyhow::anyhow!("gil pool worker dropped (channel closed)"))?;
230
253
let resp = resp_rx.await.map_err(|_| anyhow::anyhow!("gil pool worker dropped"))?;
231
254
profile_gil_pool(_t0.elapsed().as_nanos() as u64);
232
255
Ok(resp)
235
258
/// The detected runtime mode (GIL-based or free-threaded), if the pool has been
236
259
/// initialized.
237
260
pub fn mode() -> Option<RuntimeMode> {
238
- POOL.get().map(|p| p.mode)
261
+ let guard = POOL.lock().unwrap_or_else(|e| e.into_inner());
262
+ guard.as_ref().map(|p| p.mode)
239
263
}
240
264
β–Όcrates/justapi-py/src/native/app.rs
+101-0
78
78
pub gateway_config: Option<String>,
79
79
pub circuit_breaker_config: Option<(usize, u64)>,
80
80
pub coalesce_headers: Option<Vec<String>>,
81
+ /// HTTP/3 (QUIC) TLS certificate/key paths, set via `enable_http3(...)`.
82
+ /// When Some, `run()` also starts a UDP/QUIC listener serving the same
83
+ /// application handler over HTTP/3 (feature `http3` on justapi-core).
84
+ pub http3_cert: Option<String>,
85
+ pub http3_key: Option<String>,
81
86
/// When true, apply safe (non-HSTS) security headers to every response by
82
87
/// default. Off by default because forcing a CSP would break apps that load
83
88
/// external resources (e.g. CDN-hosted docs UIs). Call
476
481
gateway_config: None,
477
482
circuit_breaker_config: None,
478
483
coalesce_headers: None,
484
+ http3_cert: None,
485
+ http3_key: None,
479
486
secure_headers: false,
480
487
secure_headers_config: None,
481
488
metrics: None,
644
651
self.coalesce_headers = Some(headers.unwrap_or_default());
645
652
}
646
653
654
+ /// Enable an HTTP/3 (QUIC) listener on the same address `run()` binds
655
+ /// (UDP, same port). `cert_path`/`key_path` are PEM TLS files (required β€”
656
+ /// QUIC uses TLS 1.3). The same application handler serves both transports.
657
+ #[cfg(feature = "http3")]
658
+ #[pyo3(name = "enable_http3")]
659
+ fn enable_http3(&mut self, cert_path: String, key_path: String) {
660
+ self.http3_cert = Some(cert_path);
661
+ self.http3_key = Some(key_path);
662
+ }
663
+
647
664
/// Apply safe security headers (`X-Content-Type-Options`, `X-Frame-Options`,
648
665
/// `Content-Security-Policy`, `X-XSS-Protection`) to every response. HSTS is
649
666
/// intentionally omitted because the Python server runs over plaintext by
1789
1806
let frontend_mounts = std::mem::take(&mut app.frontend_mounts);
1790
1807
let jwt_auth = app.jwt_auth.take();
1791
1808
let cors = app.cors.take();
1809
+ #[cfg(feature = "http3")]
1810
+ let (app_http3_cert, app_http3_key) = (app.http3_cert.clone(), app.http3_key.clone());
1792
1811
1793
1812
// The app object is surfaced on `Request.app`; capture it before
1794
1813
// releasing the borrow (the server loop runs without the GIL held).
1797
1816
// Clone the app handle now (GIL held) so it can be moved into the
1798
1817
// detached server loop, which runs without a GIL token.
1799
1818
let app_py_http1: Option<Py<PyAny>> = app_py.as_ref().map(|a| a.clone_ref(py));
1819
+ #[cfg(feature = "http3")]
1820
+ let app_py_http3: Arc<Option<Py<PyAny>>> =
1821
+ Arc::new(app_py_http1.as_ref().map(|a| a.clone_ref(py)));
1800
1822
let app_py_ws: Arc<Option<Py<PyAny>>> =
1801
1823
Arc::new(app_py_http1.as_ref().map(|a| a.clone_ref(py)));
1802
1824
let _ = app_py;
1926
1948
}
1927
1949
}
1928
1950
let batchers = Arc::new(batchers);
1951
+ // Clone the Arc inputs so the HTTP/3 handler (built below,
1952
+ // same app state) can share them after the TCP handler takes
1953
+ // the originals.
1954
+ #[cfg(feature = "http3")]
1955
+ let (h3_router, h3_handlers, h3_schemas, h3_schema_jsons, h3_batchers, h3_db_url) =
1956
+ (router.clone(), handlers.clone(), schemas.clone(), schema_jsons.clone(), batchers.clone(), db_url_str.clone());
1929
1957
let handler = make_native_handler(
1930
1958
router,
1931
1959
handlers,
1944
1972
max_body_size,
1945
1973
);
1946
1974
1975
+ // HTTP/3 (QUIC): build a second handler chain over
1976
+ // `Full<Bytes>` bodies from the same app state and serve it on
1977
+ // UDP alongside the TCP server. QUIC requires TLS certs; see
1978
+ // `enable_http3`. The TCP chain and the QUIC chain share the
1979
+ // same route table and Python handlers. (Spawn happens right
1980
+ // before `server.run()` below.)
1981
+ #[cfg(feature = "http3")]
1982
+ let http3_bridge: Option<(
1983
+ justapi_core::http3::Http3Config,
1984
+ justapi_core::http3::Http3Handler,
1985
+ )> = if let (Some(cert), Some(key)) =
1986
+ (app_http3_cert.clone(), app_http3_key.clone())
1987
+ {
1988
+ let h3_query_jsons = query_schema_jsons.clone();
1989
+ let h3_db = db_pool.as_ref().map(|p| p.as_any_pool());
1990
+ let h3_app: Option<Py<PyAny>> =
1991
+ Python::attach(|py| app_py_http3.as_ref().as_ref().map(|a| a.clone_ref(py)));
1992
+ let h3_needs = needs_request.clone();
1993
+ let h3_native = native.clone();
1994
+ let h3_crud = crud_specs.clone();
1995
+ let h3_validators = schema_validators.clone();
1996
+ let h3_handler: justapi_core::middleware::HandlerFn<
1997
+ http_body_util::Full<bytes::Bytes>,
1998
+ > = make_native_handler(
1999
+ h3_router,
2000
+ h3_handlers,
2001
+ h3_schemas,
2002
+ h3_schema_jsons,
2003
+ h3_query_jsons,
2004
+ h3_batchers,
2005
+ h3_db,
2006
+ h3_db_url,
2007
+ "https".to_string(),
2008
+ h3_app,
2009
+ h3_needs,
2010
+ h3_native,
2011
+ h3_crud,
2012
+ h3_validators,
2013
+ max_body_size,
2014
+ );
2015
+ let h3_chain = justapi_core::middleware::MiddlewareChain::new(h3_handler);
2016
+ Some((
2017
+ justapi_core::http3::Http3Config { cert_path: cert, key_path: key },
2018
+ justapi_core::http3::chain_to_http3_handler(h3_chain),
2019
+ ))
2020
+ } else {
2021
+ None
2022
+ };
2023
+
1947
2024
// Install signal handlers to trigger graceful shutdown.
1948
2025
// Ctrl+C is universal; on Unix we also handle SIGTERM so the
1949
2026
// process can be cleanly stopped by container orchestrators
2254
2331
a.metrics = Some(live_metrics);
2255
2332
a.health_registry = Some(live_health);
2256
2333
});
2334
+
2335
+ // Start the HTTP/3 (QUIC) listener on the same address (UDP)
2336
+ // once the TCP server is fully configured.
2337
+ #[cfg(feature = "http3")]
2338
+ if let Some((h3_cfg, h3_bridge)) = http3_bridge {
2339
+ let h3_metrics = server.metrics().clone();
2340
+ let h3_cancel = shutdown.clone();
2341
+ let h3_addr = addr;
2342
+ tokio::spawn(async move {
2343
+ match justapi_core::http3::serve_http3(
2344
+ h3_addr,
2345
+ h3_cfg,
2346
+ h3_bridge,
2347
+ h3_metrics,
2348
+ h3_cancel,
2349
+ )
2350
+ .await
2351
+ {
2352
+ Ok(bound) => tracing::info!("HTTP/3 listening on udp://{}", bound),
2353
+ Err(e) => tracing::error!("Failed to start HTTP/3 listener: {:#}", e),
2354
+ }
2355
+ });
2356
+ }
2357
+
2257
2358
server.run().await
2258
2359
})
2259
2360
});
2260
2361
β–Όcrates/justapi-py/src/native/handlers.rs
+18-83
12
12
use justapi_core::xml;
13
13
use justapi_core::{json_response, ResponseBody};
14
14
15
-use justapi_core::db::{AnyPool, DbAcquireError};
16
-
17
-/// Translate a request-path DB acquire failure into the correct response.
18
-/// A saturated/closed pool is backpressure β†’ `503` (so clients retry); any
19
-/// other SQLx error is a genuine failure β†’ `500`.
20
-fn db_acquire_error_response(e: DbAcquireError) -> Response<ResponseBody> {
21
- match e {
22
- DbAcquireError::TimedOut | DbAcquireError::PoolClosed => {
23
- justapi_core::service_unavailable_response(
24
- "database pool saturated; please retry shortly",
25
- )
26
- }
27
- DbAcquireError::Other(_) => {
28
- justapi_core::error_response(StatusCode::INTERNAL_SERVER_ERROR, "database error")
29
- }
30
- }
31
-}
15
+use justapi_core::db::AnyPool;
32
16
33
17
/// Shared, per-route Rust-native CRUD config: `Some((table, columns,
34
18
/// id_column))` means the route is served by `crud_dispatch_bytes` with the
932
916
}
933
917
}
934
918
935
- // Auto-transaction for write methods
936
- let is_write = matches!(method, Method::POST | Method::PUT | Method::DELETE);
937
- let tx = if is_write {
938
- if let Some(ref pool) = *db_pool {
939
- match pool.begin_request().await {
940
- Ok(tx) => Some(tx),
941
- Err(e) => {
942
- tracing::error!("Failed to begin transaction: {:?}", e);
943
- return Ok(db_acquire_error_response(e));
944
- }
945
- }
946
- } else {
947
- None
948
- }
949
- } else {
950
- None
951
- };
952
-
953
919
// Offload Python execution (which acquires the GIL and blocks) to the blocking pool
954
920
// to avoid starving Tokio's I/O worker threads.
921
+ //
922
+ // NOTE: there is deliberately NO auto-transaction begin here. A
923
+ // request-scoped `begin_request()` acquired a pool connection on the
924
+ // async runtime and then held it while the request waited in the
925
+ // single-worker GIL queue; the handler's own `app.db.query` then
926
+ // acquired a *second* connection (the tx was never passed to the
927
+ // handler). Under `N` concurrent write requests that is `2N`
928
+ // connections on an `N`-capacity pool β†’ immediate saturation and the
929
+ // observed write-throughput collapse (150 RPS at c=10 β†’ ~3 RPS at
930
+ // c=11, "pool timed out" at exactly `request_acquire_timeout`).
931
+ // The pool's own `request_acquire_timeout` already produces the fast
932
+ // 503 backpressure this used to provide; multi-statement atomicity
933
+ // is explicit via `app.db.transaction()`.
955
934
let handlers_clone = handlers.clone();
956
935
let schemas_clone = schemas.clone();
957
936
let db_url_str = db_url.clone();
1008
987
.await
1009
988
.map_err(|e| anyhow::anyhow!("GIL pool error: {}", e))?;
1010
989
1011
- // Commit or rollback transaction
1012
- if let Some(tx) = tx {
1013
- if nr.status >= 200 && nr.status < 300 {
1014
- if let Err(e) = tx.commit().await {
1015
- tracing::error!("Failed to commit transaction: {}", e);
1016
- }
1017
- } else {
1018
- if let Err(e) = tx.rollback().await {
1019
- tracing::error!("Failed to rollback transaction: {}", e);
1020
- }
1021
- }
1022
- }
1023
-
1024
990
Ok(nr_to_response(nr))
1025
991
})
1026
992
})
1043
1009
schemas: Arc<Vec<Option<Py<PyAny>>>>,
1044
1010
schema_jsons: Arc<Vec<Option<String>>>,
1045
1011
query_schema_jsons: Arc<Vec<Option<String>>>,
1046
- db_pool: Option<AnyPool>,
1047
1012
db_url_str: Option<String>,
1048
1013
app: Option<Py<PyAny>>,
1049
1014
needs_request: Arc<Vec<bool>>,
1055
1020
B: http_body::Body<Data = Bytes> + Send + Sync + Unpin + 'static,
1056
1021
B::Error: std::error::Error + Send + Sync + 'static,
1057
1022
{
1058
- let db_pool = Arc::new(db_pool);
1059
1023
let db_url = Arc::new(db_url_str);
1060
1024
let app = Arc::new(app);
1061
1025
let req_scheme = "http".to_string();
1067
1031
let query_schema_jsons = query_schema_jsons.clone();
1068
1032
let schemas = schemas.clone();
1069
1033
let schema_jsons = schema_jsons.clone();
1070
- let db_pool = db_pool.clone();
1071
1034
let db_url = db_url.clone();
1072
1035
let app = app.clone();
1073
1036
let req_scheme = req_scheme.clone();
1205
1168
let path_params: Vec<(String, String)> =
1206
1169
matched.params.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1207
1170
1208
- // Auto-transaction for write methods
1209
- let is_write = matches!(method, Method::POST | Method::PUT | Method::DELETE);
1210
- let tx = if is_write {
1211
- if let Some(ref pool) = *db_pool {
1212
- match pool.begin_request().await {
1213
- Ok(tx) => Some(tx),
1214
- Err(e) => {
1215
- tracing::error!("Failed to begin transaction: {:?}", e);
1216
- return Ok(db_acquire_error_response(e));
1217
- }
1218
- }
1219
- } else {
1220
- None
1221
- }
1222
- } else {
1223
- None
1224
- };
1225
-
1226
1171
// Offload Python execution (which acquires the GIL and blocks) to the blocking pool
1227
- // to avoid starving Tokio's I/O worker threads.
1172
+ // to avoid starving Tokio's I/O worker threads. (No auto-transaction:
1173
+ // see the matching note in `make_native_handler` β€” a request-scoped
1174
+ // tx acquired a second pool connection per write request and held it
1175
+ // through the GIL queue, saturating the pool under concurrency.)
1228
1176
let handlers_clone = handlers.clone();
1229
1177
let schemas_clone = schemas.clone();
1230
1178
let db_url_str = db_url.clone();
1283
1231
.await
1284
1232
.map_err(|e| anyhow::anyhow!("GIL pool error: {}", e))?;
1285
1233
1286
- // Commit or rollback transaction
1287
- if let Some(tx) = tx {
1288
- if nr.status >= 200 && nr.status < 300 {
1289
- if let Err(e) = tx.commit().await {
1290
- tracing::error!("Failed to commit transaction: {}", e);
1291
- }
1292
- } else {
1293
- if let Err(e) = tx.rollback().await {
1294
- tracing::error!("Failed to rollback transaction: {}", e);
1295
- }
1296
- }
1297
- }
1298
-
1299
1234
Ok(nr_to_response(nr))
1300
1235
})
1301
1236
})
1302
1237
β–Όcrates/justapi-py/src/request.rs
+2-2
5
5
6
6
/// Shared connection data backing both `Request` and `HTTPConnection`.
7
7
///
8
-/// Mirrors the ASGI connection scope plus the buffered payload, so the
9
-/// Starlette-style attribute surface can be served without re-parsing.
8
+/// Holds the parsed method/path/headers/query/buffer so the Starlette-style
9
+/// attribute surface can be served without re-parsing.
10
10
pub(crate) struct Conn {
11
11
pub(crate) method: String,
12
12
pub(crate) path: String,
13
13
β–Όcrates/justapi-py/src/test_client.rs
+21-1
115
115
schemas,
116
116
schema_jsons,
117
117
query_schema_jsons,
118
- db_pool.clone(),
119
118
db_url_str,
120
119
app_py,
121
120
needs_request,
191
190
let path = path.to_owned();
192
191
self.do_request(py, move |client, rt| rt.block_on(client.delete(&path)))
193
192
}
193
+
194
+ /// Send an HTTP QUERY request (RFC 10008): safe, idempotent, body-carrying.
195
+ fn query(&self, py: Python<'_>, path: &str, body: Vec<u8>) -> PyResult<Py<PyAny>> {
196
+ let path = path.to_owned();
197
+ self.do_request(py, move |client, rt| rt.block_on(client.query(&path, body)))
198
+ }
199
+
200
+ /// QUERY with extra request headers (RFC 10008 requires a Content-Type).
201
+ #[pyo3(signature = (path, body, headers))]
202
+ fn query_with(
203
+ &self,
204
+ py: Python<'_>,
205
+ path: &str,
206
+ body: Vec<u8>,
207
+ headers: Vec<(String, String)>,
208
+ ) -> PyResult<Py<PyAny>> {
209
+ let path = path.to_owned();
210
+ let hdr: Vec<(&str, &str)> =
211
+ headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
212
+ self.do_request(py, move |client, rt| rt.block_on(client.query_with(&path, body, &hdr)))
213
+ }
194
214
}
195
215
196
216
impl JustAPITestClient {
197
217
β–Όdocs/SCOPE_CUT.md
+140-0
1
+# Scope-Cut Proposal β€” testable core, optional extras
2
+
3
+> Status: **partially implemented (2026-08-06)**. The core-crate side is done;
4
+> the Python-binding split is a planned phase with a documented blocker.
5
+
6
+## Implementation status (2026-08-06)
7
+
8
+### Done
9
+
10
+1. **`justapi-core` heavy features were already optional** β€” `db`, `ws`,
11
+ `wasm`, `graphql`, `grpc`, `inference`, `mail`, `redis-rate-limit`, `tls`,
12
+ `http3`, `orjson`, `opentelemetry` are all feature-gated. `default =
13
+ ["opentelemetry"]`.
14
+2. **Default-feature test gate fixed** β€” `db_bridge.rs`/`edge_cases.rs`
15
+ integration tests now carry `#![cfg(feature = ...)]` guards;
16
+ `cargo test --workspace` is green on default features (was broken).
17
+3. **CI split is the documented pattern** β€” fast default-features gate +
18
+ `--all-features` matrix (see PLAN.md gates).
19
+4. **Honest wheel story** β€” the Python wheel is full-featured by design
20
+ (verified: `justapi-core` features `db, ws, wasm, graphql, grpc` are what
21
+ the Python API exposes β€” `set_database`, `websocket`, `graphql`,
22
+ `add_grpc_service`).
23
+
24
+### Deferred (blocked, needs a focused session)
25
+
26
+**Python-binding `lean` feature.** Making `justapi-py` build without
27
+`db/ws/wasm/graphql/grpc` requires feature-guarding ~50+ symbols across
28
+`native/app.rs`, `websocket.rs`, `database.rs`, `test_client.rs`, `handlers.rs`
29
+plus the Python glue (`app.py`, `__init__.py`, `.pyi` stubs). Verified
30
+experimentally: removing the core features breaks the build in 20+ places.
31
+Payoff is build-time only (the product bundles everything by design), so it is
32
+lower priority than the runtime correctness work (ADR-080, ADR-081). Tracked
33
+here so it is not lost.
34
+
35
+---
36
+
37
+## The original problem
38
+
39
+`justapi-py` unconditionally enables `justapi-core` features
40
+`db, ws, wasm, graphql, grpc` (plus `inference`/`mail`/`redis-rate-limit`
41
+optionally). Consequences:
42
+
43
+- **The core test gate is coupled to every feature.** `cargo test --workspace`
44
+ compiles sqlx+postgres/mysql, wasmtime, async-graphql, tonic+prost, and the
45
+ whole inference stack whether or not the user uses them. A clean-machine
46
+ "does the framework work?" check is impossible.
47
+- **The wheel is heavy and untested in its default form.** The default install
48
+ carries wasmtime/grpc/graphql compile time and binary size even for a
49
+ hello-world app.
50
+- **"Done" is not verifiable.** PLAN.md lists 50+ phases complete, but the
51
+ default build never exercises them in isolation β€” regressions hide behind the
52
+ feature soup (the `db_bridge`/`edge_cases` integration tests already fail
53
+ without `--all-features`).
54
+
55
+## The proposal: three tiers
56
+
57
+### Tier 1 β€” `justapi-core` (always compiled, always tested)
58
+
59
+The transport + framework core. **No feature-gated deps in the default path:**
60
+
61
+- tokio, hyper, hyper-util, http-body-util, matchit, serde_json, jsonschema
62
+- middleware (CORS/JWT/rate-limit/security headers/compression), router,
63
+ serialize, validate, metrics, health, server, testing, openapi, static_files
64
+- Existing flags stay, but `default = []` (drop `opentelemetry` from default;
65
+ OTel becomes opt-in β€” it currently drags grpc-tonic into every build via
66
+ `opentelemetry-otlp`).
67
+
68
+**Exit criterion:** `cargo test -p justapi-core` (default features) is green in
69
+under ~60s on a clean machine and exercises routing, middleware, serialization,
70
+validation, static files, health, test client.
71
+
72
+### Tier 2 β€” `justapi-py` (the pip package)
73
+
74
+- `default = ["mail"]` today β†’ make `default = []`; map every Python feature to
75
+ the corresponding core feature flag so users install exactly what they use:
76
+ `pip install justapi[db]`, `justapi[wasm]`, `justapi[grpc]`, `justapi[graphql]`,
77
+ `justapi[inference]`, `justapi[http3]`, `justapi[otlp]`, `justapi[all]`.
78
+- Python glue that touches a disabled feature must degrade gracefully at import
79
+ time (try/except around the native submodule), not hard-fail.
80
+
81
+**Exit criterion:** `pip install justapi` in a fresh venv β†’ `justapi serve`
82
+hello-world works with **no** wasmtime/grpc/graphql installed; each extra
83
+installs only its own transitive deps.
84
+
85
+### Tier 3 β€” optional feature flags to keep (already correct pattern)
86
+
87
+Keep the existing per-feature gates and simply stop forcing them all on:
88
+
89
+| Feature | Core flag | Why gated |
90
+|---|---|---|
91
+| WebSocket | `ws` | tokio-tungstenite |
92
+| TLS | `tls` | rustls stack |
93
+| HTTP/3 | `http3` | quinn+h3 (new, ADR-079) |
94
+| DB | `db` | sqlx + 4 DB drivers |
95
+| Inference | `inference` | candle |
96
+| WASM | `wasm` | wasmtime |
97
+| GraphQL | `graphql` | async-graphql |
98
+| gRPC | `grpc` | tonic+prost |
99
+| Mail | `mail` | lettre+minijinja |
100
+| Redis rate-limit | `redis-rate-limit` | redis |
101
+| OTel | `opentelemetry` | otlp+grpc-tonic |
102
+| ORJSON | `orjson` | orjson binding |
103
+
104
+## What moves where
105
+
106
+1. **`justapi-py/Cargo.toml`:** drop `features = ["db","ws","wasm","graphql","grpc"]`
107
+ from the `justapi-core` dependency; re-enable via `[features]` passthrough
108
+ with `dep:` forwarding. Python `extras` in `pyproject.toml` mirror the
109
+ feature names.
110
+2. **`justapi-core/Cargo.toml`:** `default = []` (OTel opt-in). Add a
111
+ `full` convenience feature = `["tls","ws","db","mail","compression","brotli-compression","zstd-compression","wasm","graphql","grpc","http3","redis-rate-limit","opentelemetry"]` for the benchmark harness and CI matrix.
112
+3. **Fix the integration tests** (`db_bridge`, `edge_cases`) so they carry the
113
+ right `#![cfg(feature = ...)]` guards and the default-feature gate is green
114
+ alone; run `--all-features` only in the full matrix.
115
+4. **CI:** two jobs β€” `default-features` (fast, the "does it work" gate) and
116
+ `all-features` (the "everything still compiles" gate). BENCHMARKS records
117
+ which feature set each number was measured with.
118
+
119
+## Honest tradeoffs
120
+
121
+- **Compile time for power users:** `justapi[all]` users still pull everything β€”
122
+ but that's their explicit choice, not the default tax.
123
+- **Python import behavior:** currently `from justapi import WebSocket` works
124
+ because ws is always on. After the cut, importing a feature's symbol without
125
+ the extra installed must raise a clear `ImportError` with an install hint
126
+ (`pip install justapi[ws]`), not a confusing `AttributeError` from a missing
127
+ native submodule.
128
+- **This is a packaging/CI change, not a feature change.** It changes *when*
129
+ things compile and *what the default is*, not what exists. Risk is low; the
130
+ payoff is a verifiable core.
131
+
132
+## Suggested order
133
+
134
+1. `justapi-core` default-features-to-empty + `full` convenience flag + CI split
135
+ (verify the fast gate).
136
+2. `justapi-py` feature passthrough + `pyproject.toml` extras + import-hint
137
+ degradation.
138
+3. Fix the two integration-test guards; record a clean-machine default-feature
139
+ run in BENCHMARKS.
140
+4. Ship 2.0.8 with the default wheel being the lean, tested core.
0
141
β–Όdocs_site/src/content/docs/advanced/advanced-middleware.md
+76-27
1
1
---
2
2
title: Advanced Middleware
3
-description: Add ASGI middleware, HTTPS redirect, trusted hosts, and GZip compression in JustAPI.
4
-keywords: [JustAPI, middleware, ASGI, HTTPS redirect, trusted host, GZip]
3
+description: Add security headers, CORS, JWT, request coalescing, and custom Python pipeline middleware in JustAPI.
4
+keywords: [JustAPI, middleware, security headers, CORS, JWT, GZip]
5
5
---
6
6
7
-## Adding ASGI Middleware
7
+JustAPI's middleware runs in two layers:
8
8
9
-JustAPI supports any ASGI middleware:
9
+1. **Native Rust middleware** β€” CORS, JWT, security headers, and request
10
+ coalescing execute in the Rust chain for every request (covering the Rust
11
+ native fast path too). These are enabled with dedicated `enable_*` / `add_*`
12
+ methods.
13
+2. **Python middleware** β€” your own request `(request, call_next)` callables for
14
+ cross-cutting logic (timing, auth checks, header injection). Registered with
15
+ `@app.middleware("http")` or on a per-route `middlewares=` list.
16
+
17
+## Performance note
18
+
19
+Python middleware runs on the Python side of the boundary. For maximum
20
+throughput, prefer the native Rust middleware (CORS, security headers, JWT,
21
+rate limiting) or the native fast path β€” use Python middleware only for logic
22
+that must run in Python.
23
+
24
+## Security Headers
25
+
26
+Apply safe HTTP response headers (`X-Content-Type-Options: nosniff`,
27
+`X-Frame-Options: DENY`, `Content-Security-Policy: default-src 'self'`,
28
+`X-XSS-Protection: 0`):
10
29
11
30
```python
12
31
from justapi import JustAPIApp
13
-from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
14
-from starlette.middleware.trustedhost import TrustedHostMiddleware
15
32
16
33
app = JustAPIApp()
17
34
18
-app.add_middleware(HTTPSRedirectMiddleware)
35
+# Omit HSTS by default (plaintext local termination). Pass with_hsts=True only
36
+# if you terminate TLS in-process.
37
+app.enable_secure_headers()
19
38
```
20
39
21
-## Trusted Host Middleware
40
+> Native (Rust-side) gzip response compression is available as a server option
41
+> (see `justapi serve --compress`). If you need per-route compression, wrap it
42
+> in Python middleware or place JustAPI behind a reverse proxy that handles TLS
43
+> and compression (see [Behind a Proxy](/advanced/behind-a-proxy/)).
22
44
23
-Guard against HTTP host header attacks:
45
+## CORS
46
+
47
+Rust-native CORS, covering all routes including the fast path and 404s:
24
48
25
49
```python
26
-app.add_middleware(
27
- TrustedHostMiddleware,
28
- allowed_hosts=["example.com", "*.example.com"],
50
+app.add_cors(
51
+ allow_origins=["https://example.com"],
52
+ allow_methods=["*"],
53
+ allow_headers=["*"],
54
+ allow_credentials=True,
29
55
)
30
56
```
31
57
32
-## GZip Compression
58
+## JWT Authentication (Rust-native)
33
59
34
60
```python
35
-from starlette.middleware.gzip import GZipMiddleware
61
+app.set_jwt_auth(secret="your-secret", algorithm="HS256")
62
+```
63
+
64
+## Request Coalescing (Singleflight)
65
+
66
+Fuse concurrent identical requests into one upstream call:
36
67
37
-app.add_middleware(GZipMiddleware, minimum_size=500, compresslevel=9)
68
+```python
69
+app.enable_request_coalescing(headers=["x-user-id"])
38
70
```
39
71
40
-## Custom ASGI Middleware
72
+## Custom Python Middleware
41
73
42
-Write your own middleware class:
74
+Write your own request/response middleware as a plain callable `(request, next)`.
43
75
44
76
```python
45
77
import time
46
-from starlette.middleware.base import BaseHTTPMiddleware
78
+from justapi import JustAPIApp
79
+
80
+app = JustAPIApp()
47
81
48
-class TimingMiddleware(BaseHTTPMiddleware):
49
- async def dispatch(self, request, call_next):
50
- start = time.time()
51
- response = await call_next(request)
52
- duration = time.time() - start
53
- response.headers["X-Process-Time"] = str(round(duration, 4))
54
- return response
82
+@app.middleware("http")
83
+async def timing_middleware(request, call_next):
84
+ start = time.time()
85
+ response = await call_next(request)
86
+ response.headers["X-Process-Time"] = str(round(time.time() - start, 4))
87
+ return response
88
+```
89
+
90
+Or attach it to a single route:
55
91
56
-app.add_middleware(TimingMiddleware)
92
+```python
93
+async def auth_middleware(request, call_next):
94
+ if not (request.get("headers") or {}).get("x-token"):
95
+ return {"status": 401, "body": {"detail": "unauthorized"}}
96
+ return await call_next(request)
97
+
98
+@app.get("/protected", middlewares=[auth_middleware])
99
+async def protected(request):
100
+ return {"message": "top secret"}
57
101
```
58
102
103
+> **Note:** The Rust native fast path is a validate-and-echo shortcut that does
104
+> not execute Python middleware or dependencies. If a route uses `native=True`,
105
+> it must not declare dependencies or Python middleware (the framework refuses
106
+> that combination at registration time).
107
+
59
108
## See Also
60
109
61
110
- [Middleware](/tutorials/middleware/) β€” basic middleware usage
62
111
- [Behind a Proxy](/advanced/behind-a-proxy/) β€” reverse proxy configuration
63
-- [Sub Applications](/advanced/sub-applications/) β€” mounting sub-apps
112
+- [JWT & Auth](/security/authentication/) β€” authentication middleware
64
113
β–Όdocs_site/src/content/docs/advanced/advanced-security.md
+54-28
4
4
keywords: [JustAPI, OAuth2 scopes, HTTP Basic Auth, API keys, advanced security]
5
5
---
6
6
7
-## OAuth2 Scopes
7
+## OAuth2 Bearer Tokens
8
8
9
-Control access with fine-grained permissions:
9
+Protect routes with the `OAuth2PasswordBearer` dependency from `justapi.auth`
10
+(extracts the `Authorization: Bearer <token>` header and 401s when missing):
10
11
11
12
```python
12
13
from justapi import JustAPIApp, Security
13
-from fastapi.security import OAuth2PasswordBearer
14
+from justapi.auth import OAuth2PasswordBearer
14
15
15
-oauth2_scheme = OAuth2PasswordBearer(
16
- tokenUrl="token",
17
- scopes={"read": "Read access", "write": "Write access", "admin": "Admin access"},
18
-)
16
+app = JustAPIApp()
17
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
19
18
20
19
@app.get("/items/")
21
-def list_items(token: str = Security(oauth2_scheme, scopes=["read"])):
20
+def list_items(token: str = Security(oauth2_scheme)):
22
21
return []
23
22
24
23
@app.post("/items/")
25
-def create_item(token: str = Security(oauth2_scheme, scopes=["write"])):
24
+def create_item(token: str = Security(oauth2_scheme)):
26
25
return {}
26
+```
27
+
28
+For role-based access control, chain dependencies β€” the `Security` marker is
29
+`Depends` with OAuth2-scope metadata, and scope/role enforcement is your
30
+dependency's job:
31
+
32
+```python
33
+from justapi import Depends, Security, HTTPException
34
+
35
+def require_admin(token: str = Depends(oauth2_scheme)):
36
+ if token != "admin-token":
37
+ raise HTTPException(status_code=403, detail="Admin access required")
38
+ return token
27
39
28
40
@app.delete("/items/{item_id}")
29
-def delete_item(item_id: int, token: str = Security(oauth2_scheme, scopes=["admin"])):
41
+def delete_item(item_id: int, admin: str = Security(require_admin)):
30
42
return {"deleted": item_id}
31
43
```
32
44
33
45
## HTTP Basic Auth
34
46
47
+Use the `Header` parameter type and a dependency for basic credentials:
48
+
35
49
```python
36
-from fastapi import Depends, HTTPException, status
37
-from fastapi.security import HTTPBasic, HTTPBasicCredentials
50
+from justapi import Depends, HTTPException, Header
51
+import base64
38
52
import secrets
39
53
40
-security = HTTPBasic()
41
-
42
-def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)):
43
- correct_username = secrets.compare_digest(credentials.username, "admin")
44
- correct_password = secrets.compare_digest(credentials.password, "secret")
45
- if not (correct_username and correct_password):
54
+def verify_basic(authorization: str = Header(None)):
55
+ if not authorization or not authorization.startswith("Basic "):
56
+ raise HTTPException(status_code=401, detail="Not authenticated")
57
+ try:
58
+ decoded = base64.b64decode(authorization[6:]).decode()
59
+ username, _, password = decoded.partition(":")
60
+ except Exception:
61
+ raise HTTPException(status_code=401, detail="Invalid credentials")
62
+ correct_user = secrets.compare_digest(username, "admin")
63
+ correct_pass = secrets.compare_digest(password, "secret")
64
+ if not (correct_user and correct_pass):
46
65
raise HTTPException(status_code=401, detail="Invalid credentials")
47
- return credentials.username
66
+ return username
48
67
49
68
@app.get("/admin")
50
-def admin_panel(username: str = Depends(verify_credentials)):
69
+def admin_panel(username: str = Depends(verify_basic)):
51
70
return {"message": f"Welcome {username}"}
52
71
```
53
72
54
73
## API Key Authentication
55
74
56
-```python
57
-from fastapi import Security
58
-from fastapi.security import APIKeyHeader
75
+API keys are just a header β€” validate with a `Header` dependency:
59
76
60
-api_key_header = APIKeyHeader(name="X-API-Key")
77
+```python
78
+from justapi import Depends, HTTPException, Header
61
79
62
-def verify_api_key(api_key: str = Security(api_key_header)):
63
- if api_key != "my-secret-key":
80
+def verify_api_key(x_api_key: str = Header(None)):
81
+ if x_api_key != "my-secret-key":
64
82
raise HTTPException(status_code=403, detail="Invalid API key")
65
- return api_key
83
+ return x_api_key
66
84
67
85
@app.get("/data")
68
-def get_data(key: str = Security(verify_api_key)):
86
+def get_data(key: str = Depends(verify_api_key)):
69
87
return {"data": "sensitive"}
70
88
```
71
89
90
+## Rust-Native JWT
91
+
92
+For production JWT, the Rust-native `app.set_jwt_auth(secret=...)` middleware
93
+validates every request's `Authorization` header in Rust (no GIL), or use
94
+`JwtAuth` as a `Depends` for per-route control β€” see the
95
+[Auth API](/api-reference/auth/).
96
+
72
97
## See Also
73
98
74
99
- [Security β€” First Steps](/tutorials/security/first-steps/) β€” basic security
75
100
- [OAuth2 + JWT](/tutorials/security/oauth2-jwt/) β€” JWT implementation
76
101
- [Secure Configuration](/security/secure-configuration/) β€” production security
102
+- [Auth API](/api-reference/auth/) β€” `JwtAuth`, `OAuth2PasswordBearer`, form dependencies
77
103
β–Όdocs_site/src/content/docs/advanced/async-tests.md
+35-29
1
1
---
2
2
title: Async Tests
3
-description: Write async test functions with httpx.AsyncClient for JustAPI applications.
4
-keywords: [JustAPI, async tests, httpx, AsyncClient, pytest-asyncio]
3
+description: Write async tests with AsyncTestClient for JustAPI applications.
4
+keywords: [JustAPI, async tests, pytest-asyncio, AsyncTestClient, TestClient]
5
5
---
6
6
7
-## Async Test Client
7
+JustAPI ships its own in-process test client β€” no external ASGI transport needed.
8
+Use `AsyncTestClient` for async tests and `JustAPITestClient` for sync tests.
8
9
9
-Use `httpx.AsyncClient` for async testing:
10
+## Async Test Client
10
11
11
12
```python
12
13
import pytest
13
-import httpx
14
14
from justapi import JustAPIApp
15
+from justapi.testing import AsyncTestClient
15
16
16
17
app = JustAPIApp()
17
18
19
20
async def hello():
20
21
return {"message": "Hello!"}
21
22
22
-@pytest.mark.anyio
23
+@pytest.mark.asyncio
23
24
async def test_hello():
24
- async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
25
- response = await client.get("/hello")
26
- assert response.status_code == 200
27
- assert response.json() == {"message": "Hello!"}
25
+ async with AsyncTestClient(app) as client:
26
+ resp = await client.get("/hello")
27
+ assert resp.status == 200
28
+ assert resp.json() == {"message": "Hello!"}
28
29
```
29
30
30
-## Testing with Dependencies
31
+The client runs the full Rust pipeline (routing, middleware, native fast path)
32
+in-process, so tests exercise the same code paths as production.
31
33
32
-```python
33
-def override_get_db():
34
- return TestDB()
34
+## Testing Database-Backed Routes
35
35
36
-app.dependency_overrides[get_db] = override_get_db
37
-
38
-@pytest.mark.anyio
39
-async def test_with_override():
40
- async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
41
- response = await client.get("/items/")
42
- assert response.status_code == 200
36
+```python
37
+@pytest.mark.asyncio
38
+async def test_db():
39
+ async with AsyncTestClient(app, database="sqlite::memory:") as client:
40
+ resp = await client.post("/items", b'{"name":"widget"}')
41
+ assert resp.status == 200
43
42
```
44
43
45
-## Setup and Teardown
44
+## Fixtures
46
45
47
46
```python
47
+import pytest
48
+from justapi.testing import AsyncTestClient
49
+
48
50
@pytest.fixture
49
51
async def client():
50
- async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
52
+ async with AsyncTestClient(app) as c:
51
53
yield c
52
54
53
-@pytest.mark.anyio
55
+@pytest.mark.asyncio
54
56
async def test_example(client):
55
- response = await client.get("/hello")
56
- assert response.status_code == 200
57
+ resp = await client.get("/hello")
58
+ assert resp.status == 200
57
59
```
58
60
61
+## Supported Methods
62
+
63
+`get`, `post`, `put`, `patch`, `delete` β€” each returns a response object with
64
+`.status` and `.json()` (mirroring the sync `JustAPITestClient`).
65
+
59
66
## See Also
60
67
61
-- [Testing](/tutorials/testing/) β€” sync testing with JustAPITestClient
62
-- [Testing with Overrides](/advanced/async-tests/) β€” dependency overrides
63
-- [Debugging](/tutorials/debugging/) β€” debugging techniques
68
+- [Testing](/tutorials/testing/) β€” sync testing with `JustAPITestClient`
69
+- [Debugging](/tutorials/debugging/) β€” debugging techniques
64
70
β–Όdocs_site/src/content/docs/advanced/batching.md
+2-0
40
40
## With Dependencies
41
41
42
42
```python
43
+from justapi import Security
44
+
43
45
@app.post("/predict")
44
46
@adaptive_batch(max_size=64, window_ms=5)
45
47
def predict_batch(
46
48
β–Όdocs_site/src/content/docs/advanced/behind-a-proxy.md
+36-21
1
1
---
2
2
title: Behind a Proxy
3
-description: Configure JustAPI to run behind a reverse proxy (Nginx, Traefik, HAProxy, Cloudflare).
4
-keywords: [JustAPI, reverse proxy, Nginx, Traefik, root_path, X-Forwarded]
3
+description: Run JustAPI behind a reverse proxy (Nginx, Traefik, HAProxy, Cloudflare).
4
+keywords: [JustAPI, reverse proxy, Nginx, Traefik, HAProxy]
5
5
---
6
6
7
-## root_path
8
-
9
-When running behind a proxy, set `root_path` so JustAPI knows the external path:
10
-
11
-```python
12
-app = JustAPIApp(root_path="/api/v1")
13
-```
7
+JustAPI is a plain HTTP server, so it drops cleanly behind any reverse proxy
8
+(Nginx, Traefik, HAProxy, Cloudflare, a cloud load balancer). Offload TLS,
9
+compression, and rate-limit-at-edge to the proxy, and let JustAPI focus on your
10
+app's hot path.
14
11
15
12
## Nginx Configuration
16
13
21
18
proxy_set_header X-Real-IP $remote_addr;
22
19
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
23
20
proxy_set_header X-Forwarded-Proto $scheme;
21
+ proxy_http_version 1.1;
22
+ proxy_set_header Connection "";
24
23
}
25
24
```
26
25
27
-## Cloudflare
28
-
29
-Cloudflare automatically adds the right headers. JustAPI detects them:
26
+For streaming responses (SSE / LLM token streams) keep buffering off:
30
27
31
-```python
32
-app = JustAPIApp(root_path="/")
28
+```nginx
29
+proxy_buffering off;
30
+proxy_read_timeout 3600s;
33
31
```
34
32
35
-## Trusted Hosts with Proxy
33
+## Host/Origin Validation
34
+
35
+JustAPI does not ship a host-header allow-list middleware. If you need to guard
36
+against HTTP host-header injection (e.g. password-reset poisoning), validate the
37
+`Host` header in your own Python middleware:
36
38
37
39
```python
38
-from starlette.middleware.trustedhost import TrustedHostMiddleware
40
+from justapi import JustAPIApp
41
+
42
+app = JustAPIApp()
39
43
40
-app.add_middleware(
41
- TrustedHostMiddleware,
42
- allowed_hosts=["example.com", "*.example.com"],
43
-)
44
+ALLOWED_HOSTS = {"example.com", "*.example.com"}
45
+
46
+def _host_allowed(host: str) -> bool:
47
+ import fnmatch
48
+ return any(fnmatch.fnmatch(host, p) for p in ALLOWED_HOSTS)
49
+
50
+@app.middleware("http")
51
+async def trusted_host(request, call_next):
52
+ host = (request.get("headers") or {}).get("host", "")
53
+ if not _host_allowed(host.split(":", 1)[0]):
54
+ return {"status": 403, "body": {"detail": "forbidden"}}
55
+ return await call_next(request)
44
56
```
45
57
58
+Alternatively, enforce the allow-list at the proxy layer (Nginx `server_name`,
59
+Traefik `Host()` rule), which is where most production setups validate it anyway.
60
+
46
61
## See Also
47
62
48
63
- [Docker](/deployment/docker/) β€” containerized deployment
49
64
- [Kubernetes / Helm](/deployment/kubernetes-helm/) β€” K8s deployment
50
-- [Production Checklist](/deployment/production-checklist/) β€” production setup
65
+- [Production Checklist](/deployment/production-checklist/) β€” production setup
51
66
β–Όdocs_site/src/content/docs/advanced/custom-response.md
+45-71
1
1
---
2
2
title: Custom Response Classes
3
3
description: Use HTML, streaming, file, and other custom response types in JustAPI.
4
-keywords: [JustAPI, custom response, HTML, streaming, file response, redirect, response_class]
4
+keywords: [JustAPI, custom response, HTML, streaming, file response, redirect, response]
5
5
---
6
6
7
-## Using response_class in Decorator
7
+JustAPI ships native response classes β€” `HTMLResponse`, `PlainTextResponse`,
8
+`JSONResponse`, `RedirectResponse`, `StreamingResponse`, and `FileResponse` β€”
9
+imported directly from `justapi`. Return them from any handler to override the
10
+default JSON serialization.
8
11
9
-Set the response class directly in the decorator:
12
+## HTML Response
10
13
11
14
```python
12
-from starlette.responses import HTMLResponse, PlainTextResponse
13
-from justapi import JustAPIApp
15
+from justapi import HTMLResponse, JustAPIApp
14
16
15
17
app = JustAPIApp()
16
18
17
-@app.get("/", response_class=PlainTextResponse)
18
-def root():
19
- return "Hello, plain text!"
19
+@app.get("/html")
20
+def html_page():
21
+ return HTMLResponse(content="<h1>Hello</h1><p>This is HTML</p>")
20
22
```
21
23
22
-This also documents the correct Content-Type in the OpenAPI schema.
23
-
24
-## HTML Response
24
+## Plain Text Response
25
25
26
26
```python
27
-@app.get("/html")
28
-def html_page():
29
- return HTMLResponse(content="<h1>Hello</h1><p>This is HTML</p>")
27
+from justapi import PlainTextResponse
28
+
29
+@app.get("/text")
30
+def text():
31
+ return PlainTextResponse("Hello, plain text!")
30
32
```
31
33
32
-Or using `response_class`:
34
+## JSON Response with Custom Headers
33
35
34
36
```python
35
-@app.get("/html", response_class=HTMLResponse)
36
-def html_page():
37
- return "<h1>Hello</h1>"
37
+from justapi import JSONResponse
38
+
39
+@app.get("/custom")
40
+def custom():
41
+ return JSONResponse(
42
+ content={"data": "value"},
43
+ headers={"X-Custom": "header-value"},
44
+ )
38
45
```
39
46
40
47
## Streaming Response
41
48
42
49
```python
43
-from starlette.responses import StreamingResponse
44
-import asyncio
50
+from justapi import StreamingResponse
45
51
46
52
async def generate_tokens():
47
53
for i in range(10):
48
54
yield f"data: token_{i}\n\n"
55
+ import asyncio
49
56
await asyncio.sleep(0.1)
50
57
51
58
@app.get("/stream")
53
60
return StreamingResponse(generate_tokens(), media_type="text/event-stream")
54
61
```
55
62
63
+> JustAPI also ships a dedicated `TokenStreamResponse` for LLM token streaming
64
+> (see [Streaming Output](/advanced/streaming-output/)).
65
+
56
66
## Redirect
57
67
58
68
```python
59
-from starlette.responses import RedirectResponse
69
+from justapi import RedirectResponse
60
70
61
71
@app.get("/old-path")
62
72
def old_path():
67
77
return {"message": "you are here"}
68
78
```
69
79
70
-Or using `response_class`:
71
-
72
-```python
73
-@app.get("/old-path", response_class=RedirectResponse, status_code=302)
74
-def old_path():
75
- return "/new-path"
76
-```
77
-
78
80
## File Response
79
81
80
82
```python
81
-from starlette.responses import FileResponse
83
+from justapi import FileResponse
82
84
83
85
@app.get("/download")
84
86
def download():
85
87
return FileResponse("report.pdf", filename="report.pdf")
86
88
```
87
89
88
-## JSON Response with Custom Headers
89
-
90
-```python
91
-from starlette.responses import JSONResponse
92
-
93
-@app.get("/custom")
94
-def custom():
95
- return JSONResponse(
96
- content={"data": "value"},
97
- headers={"X-Custom": "header-value"},
98
- )
99
-```
100
-
101
-## Plain Text Response
102
-
103
-```python
104
-@app.get("/text")
105
-def text():
106
- return PlainTextResponse("Hello, plain text!")
107
-```
108
-
109
-## Default Response Class
110
-
111
-Set a default response class for all routes:
112
-
113
-```python
114
-from starlette.responses import ORJSONResponse
115
-
116
-app = JustAPIApp(default_response_class=ORJSONResponse)
117
-```
118
-
119
-:::tip
120
-`ORJSONResponse` is faster than `JSONResponse` for large payloads. Install with `pip install orjson`.
121
-:::
90
+> `FileResponse` reads the file eagerly into memory; the media type is inferred
91
+> from the file extension unless `media_type` is given.
122
92
123
93
## Custom Response Class
124
94
125
-Create your own response class:
95
+Subclass a native response and override `render`:
126
96
127
97
```python
128
-from starlette.responses import JSONResponse
98
+from justapi import JSONResponse
129
99
130
100
class PrettyJSONResponse(JSONResponse):
131
101
def render(self, content) -> bytes:
141
111
return PrettyJSONResponse(content={"data": "value"})
142
112
```
143
113
144
-## Response Class + Status Code
114
+## Response + Status Code
145
115
146
-Combine `response_class` with `status_code`:
116
+Set a non-default status by returning a response object with an explicit
117
+`status_code` (the route-decorator `status_code=` kwarg is OpenAPI metadata,
118
+not applied to the response):
147
119
148
120
```python
149
-@app.post("/items", response_class=JSONResponse, status_code=201)
121
+from justapi import JSONResponse
122
+
123
+@app.post("/items")
150
124
def create_item():
151
- return {"message": "created"}
125
+ return JSONResponse(content={"message": "created"}, status_code=201)
152
126
```
153
127
154
128
## See Also
155
129
156
130
- [Return a Response Directly](/advanced/response-directly/) β€” basic response object usage
157
131
- [Streaming Output](/advanced/streaming-output/) β€” JustAPI-native streaming
158
-- [Static Files](/tutorials/static-files/) β€” serving files
132
+- [Static Files](/tutorials/static-files/) β€” serving files
159
133
β–Όdocs_site/src/content/docs/advanced/response-cookies-headers.md
+1-1
7
7
## Setting Cookies
8
8
9
9
```python
10
-from starlette.responses import JSONResponse
10
+from justapi import JSONResponse
11
11
from justapi import JustAPIApp
12
12
13
13
app = JustAPIApp()
14
14
β–Όdocs_site/src/content/docs/advanced/response-directly.md
+2-2
9
9
For full control over the response, return a `Response` object:
10
10
11
11
```python
12
-from starlette.responses import JSONResponse
12
+from justapi import JSONResponse
13
13
from justapi import JustAPIApp
14
14
15
15
app = JustAPIApp()
50
50
## Redirect
51
51
52
52
```python
53
-from starlette.responses import RedirectResponse
53
+from justapi import RedirectResponse
54
54
55
55
@app.get("/old-path")
56
56
def old_path():
57
57
β–Όdocs_site/src/content/docs/advanced/sub-applications.md
+23-5
42
42
43
43
## Mounting Sub-Applications
44
44
45
-Mount an entire ASGI app at a sub-path:
45
+Mount an `APIRouter` at a path prefix:
46
46
47
47
```python
48
-from justapi import JustAPIApp
49
-from starlette.applications import Starlette
48
+from justapi import APIRouter, JustAPIApp
50
49
51
50
app = JustAPIApp()
52
-sub_app = Starlette()
53
-app.mount("/api/v2", sub_app)
51
+users_router = APIRouter(prefix="/users")
52
+
53
+@users_router.get("/{user_id}")
54
+def get_user(user_id: int):
55
+ return {"user_id": user_id}
56
+
57
+app.mount("/api/v2", users_router)
54
58
```
55
59
60
+`mount()` accepts either an `APIRouter` (or any object exposing `.routes`) or a
61
+static directory path:
62
+
63
+```python
64
+# Mount a static directory
65
+app.mount("/static", "static", name="static")
66
+```
67
+
68
+> **Note:** JustAPI does not mount third-party ASGI/Starlette applications β€”
69
+> the runtime is a native Rust pipeline with its own routing and middleware.
70
+> For sub-app structure use `APIRouter` + `mount()` (or `include_router`).
71
+> Run existing Starlette/FastAPI apps unchanged by placing JustAPI behind a
72
+> proxy that routes by path (see [Behind a Proxy](/advanced/behind-a-proxy/)).
73
+
56
74
## See Also
57
75
58
76
- [Routing & Sub-routers](/tutorials/routing-subrouters/) β€” router basics
59
77
β–Όdocs_site/src/content/docs/advanced/templates.md
+1-1
24
24
25
25
```python
26
26
from justapi import JustAPIApp
27
-from starlette.templating import Jinja2Templates
27
+from justapi import Jinja2Templates
28
28
29
29
app = JustAPIApp()
30
30
templates = Jinja2Templates(directory="templates")
31
31
β–Όdocs_site/src/content/docs/advanced/using-request-directly.md
+3-3
1
1
---
2
2
title: Using the Request Directly
3
-description: Access the raw Starlette Request object for headers, body, and client info in JustAPI.
3
+description: Access the native JustAPI Request object for headers, body, and client info.
4
4
keywords: [JustAPI, request object, raw request, headers, body, client]
5
5
---
6
6
9
9
Any handler can receive the raw `Request` object as a parameter:
10
10
11
11
```python
12
-from starlette.requests import Request
12
+from justapi import Request
13
13
from justapi import JustAPIApp
14
14
15
15
app = JustAPIApp()
19
19
return {
20
20
"method": request.method,
21
21
"url": str(request.url),
22
- "client": request.client.host if request.client else None,
22
+ "client": request.client, # (host, port) tuple or None
23
23
}
24
24
```
25
25
26
26
β–Όdocs_site/src/content/docs/advanced/websockets-advanced.md
+1-1
8
8
9
9
```python
10
10
from justapi import JustAPIApp
11
-from starlette.websockets import WebSocket, WebSocketDisconnect
11
+from justapi import WebSocket, WebSocketDisconnect
12
12
13
13
app = JustAPIApp()
14
14
15
15
β–Όdocs_site/src/content/docs/api-reference/justapiapp.md
+2-1
161
161
| `middleware(type)` | Decorator to register HTTP middleware (`"http"`) |
162
162
| `add_cors(allow_origins, ...)` | Configure CORS |
163
163
| `enable_secure_headers(with_hsts)` | Add security headers |
164
-| `add_middleware(middleware_cls, **kwargs)` | Add middleware class (Starlette parity) |
164
+| `add_middleware(middleware_cls, **kwargs)` | Add a middleware class; CORS classes are translated to the Rust-native CORS middleware (`add_cors`) |
165
165
| `add_exception_handler(exc_class, handler)` | Register custom exception handler |
166
166
167
167
## Resilience
170
170
|---|---|
171
171
| `enable_circuit_breaker(failure_threshold, reset_timeout_ms)` | Enable circuit breaker |
172
172
| `enable_request_coalescing(headers)` | Deduplicate concurrent identical requests |
173
+| `enable_http3(cert_path, key_path)` | Serve the app over HTTP/3 (QUIC) on the same port β€” requires the `http3` build feature and PEM TLS files |
173
174
174
175
## Database
175
176
176
177
β–Όdocs_site/src/content/docs/api-reference/scheduler.md
+48-32
1
1
---
2
2
title: Scheduler API
3
3
description: "API reference for the task scheduler in JustAPI, the FastAPI alternative β€” Rust-native cron-based periodic task scheduler."
4
-keywords: [scheduler, fastapi alternative, justapi, cron, periodic tasks, pyscheduler, rust native]
4
+keywords: [scheduler, fastapi alternative, justapi, cron, periodic tasks, rust native]
5
5
---
6
6
7
-## `PyScheduler` Object
7
+## Scheduler
8
8
9
-The scheduler manages periodic tasks using cron expressions. It runs entirely in Rust with UTC-based timing.
9
+JustAPI's scheduler manages periodic tasks using cron expressions and fixed
10
+intervals. It runs entirely in Rust with UTC-based timing, and jobs are
11
+dispatched onto the same Rust background-task worker pool as
12
+`BackgroundTasks` β€” so they run with the GIL released.
13
+
14
+There are two ways to use it: the standalone `Scheduler` class, or the
15
+convenience methods on `JustAPIApp`.
16
+
17
+## Standalone `Scheduler`
10
18
11
19
```python
12
-from justapi import PyScheduler
20
+from justapi import Scheduler
13
21
14
-scheduler = PyScheduler()
22
+scheduler = Scheduler()
23
+scheduler.schedule("*/5 * * * *", my_task) # cron expression
24
+scheduler.every(30, poll_upstream) # fixed interval
25
+scheduler.start()
15
26
```
16
27
17
28
| Method | Description |
18
29
|---|---|
19
-| `scheduler.cron(expression, callback)` | Schedule task with cron expression |
20
-| `scheduler.interval(seconds, callback)` | Schedule task at fixed interval |
21
-| `scheduler.once(delay_seconds, callback)` | Schedule one-time delayed task |
30
+| `schedule(cron_expr, func, *args, **kwargs)` | Schedule a job with a standard 5-field cron expression |
31
+| `every(seconds, func, *args, **kwargs)` | Schedule a job at a fixed interval |
32
+| `remove(job)` | Remove a scheduled job |
33
+| `jobs` | List registered jobs |
34
+| `stats` | Job run statistics |
35
+| `start()` | Start the scheduler (jobs run for the process lifetime) |
36
+| `stop()` | Stop the scheduler |
22
37
23
-### `scheduler.cron()`
38
+### `schedule()` β€” cron jobs
24
39
25
40
```python
26
41
# Run every hour at minute 0
27
-scheduler.cron("0 * * * *", my_task)
28
-```
29
-
30
-| Parameter | Type | Description |
31
-|---|---|---|
32
-| `expression` | `str` | Standard 5-field cron expression (min hour day month weekday) |
33
-| `callback` | callable | Function to execute |
42
+scheduler.schedule("0 * * * *", my_task)
34
43
35
-### `scheduler.interval()`
36
-
37
-```python
38
-# Run every 30 seconds
39
-scheduler.interval(30, my_task)
44
+# With arguments
45
+scheduler.schedule("0 0 * * *", daily_report, "[email protected]")
40
46
```
41
47
42
48
| Parameter | Type | Description |
43
49
|---|---|---|
44
-| `seconds` | `int` | Interval in seconds |
45
-| `callback` | callable | Function to execute |
50
+| `cron_expr` | `str` | Standard 5-field cron expression (min hour day month weekday), evaluated in UTC |
51
+| `func` | callable | Function to execute |
52
+| `*args` / `**kwargs` | β€” | Passed through to `func` |
46
53
47
-### `scheduler.once()`
54
+### `every()` β€” interval jobs
48
55
49
56
```python
50
-# Run once after 60 seconds
51
-scheduler.once(60, my_task)
57
+# Run every 30 seconds (first fire one interval after registration)
58
+scheduler.every(30, my_task)
52
59
```
53
60
54
61
| Parameter | Type | Description |
55
62
|---|---|---|
56
-| `delay_seconds` | `int` | Delay before execution |
57
-| `callback` | callable | Function to execute |
63
+| `seconds` | int | Interval in seconds |
64
+| `func` | callable | Function to execute |
65
+| `*args` / `**kwargs` | β€” | Passed through to `func` |
66
+
67
+> **Note:** jobs are in-memory (not persisted) in the current version. The
68
+> scheduler runs for the lifetime of the process.
58
69
59
70
## Integration with App
60
71
72
+The `JustAPIApp` methods `schedule()` and `every()` register jobs directly and
73
+start the scheduler automatically when the server starts:
74
+
61
75
```python
62
-from justapi import JustAPIApp, PyScheduler
76
+from justapi import JustAPIApp
63
77
64
78
app = JustAPIApp()
65
-scheduler = PyScheduler()
66
79
67
80
def cleanup_expired_sessions():
68
- app.db.execute("DELETE FROM sessions WHERE expires_at < NOW()")
81
+ app.db.execute("DELETE FROM sessions WHERE expires_at < ?", ["now"])
82
+
83
+app.schedule("0 */6 * * *", cleanup_expired_sessions) # Every 6 hours
84
+app.every(30, poll_upstream) # Every 30 seconds
69
85
70
-scheduler.cron("0 */6 * * *", cleanup_expired_sessions) # Every 6 hours
86
+app.run("127.0.0.1:8000")
71
87
```
72
88
73
89
## See Also
74
90
β–Όdocs_site/src/content/docs/deployment/http3.md
+62-0
1
+---
2
+title: HTTP/3 (QUIC)
3
+description: Serve JustAPI over HTTP/3 (QUIC) β€” the one transport no other Python web framework ships.
4
+keywords: [JustAPI, HTTP/3, QUIC, UDP, TLS 1.3, fastapi alternative]
5
+---
6
+
7
+## What HTTP/3 gives you
8
+
9
+HTTP/3 runs over **UDP via QUIC** (RFC 9000) with TLS 1.3 built in. Unlike
10
+HTTP/1.1 and HTTP/2 (both TCP), QUIC multiplexes many requests on a single
11
+connection with **no head-of-line blocking**, supports **0-RTT resumption**,
12
+and handles connection migration natively. JustAPI is the only Python web
13
+framework with an HTTP/3 server.
14
+
15
+## Enabling HTTP/3
16
+
17
+```python
18
+from justapi import JustAPIApp
19
+
20
+app = JustAPIApp()
21
+app.enable_http3(
22
+ cert_path="certs/fullchain.pem",
23
+ key_path="certs/privkey.pem",
24
+)
25
+
26
+app.get("/", lambda r: {"message": "hello"})
27
+app.run("0.0.0.0:443")
28
+```
29
+
30
+That's it β€” `run(addr)` binds the **same address** over UDP and serves the
31
+same application handler over HTTP/3 **and** HTTP/1.1/2 over TCP. Routes,
32
+dependencies, schema validation, sessions, and the GIL-pool dispatch all work
33
+identically over QUIC (verified end-to-end in the test suite).
34
+
35
+Requirements:
36
+
37
+- PEM **certificate** and **private key** files (QUIC mandates TLS 1.3 β€”
38
+ a self-signed cert works for testing, use a real CA for production).
39
+- A build with the `http3` feature enabled. The default wheel does **not**
40
+ include it; rebuild with:
41
+ ```bash
42
+ maturin develop --features http3 # dev
43
+ maturin build --release --features http3 # wheel
44
+ ```
45
+
46
+Without the feature, `app.enable_http3(...)` raises `NotImplementedError`.
47
+
48
+## Testing with a QUIC client
49
+
50
+```bash
51
+# curl with HTTP/3 support (curl >= 7.66 built with --http3)
52
+curl --http3 https://127.0.0.1:443/
53
+
54
+# Python (h3 / aioquic)
55
+pip install aioquic
56
+```
57
+
58
+## Notes
59
+
60
+- HTTP/3 and the TCP listeners share the same port (UDP vs TCP β€” no conflict).
61
+- Graceful shutdown (SIGTERM/Ctrl+C) stops the QUIC listener too.
62
+- Metrics record HTTP/3 requests through the same pipeline as TCP.
0
63
β–Όdocs_site/src/content/docs/getting-started/migration-guide.md
+15-25
155
155
156
156
Granian is an ASGI server (like Uvicorn), not a framework. You likely use it with FastAPI or Starlette.
157
157
158
-**Option A:** Migrate to JustAPI as a framework (recommended)
159
-**Option B:** Use JustAPI as an ASGI server (compatibility mode)
158
+JustAPI is a self-contained runtime: it hosts its own Rust HTTP server and does
159
+not serve third-party ASGI applications. To move off Granian you migrate the
160
+framework, not just the transport.
160
161
161
-### Option A: Full Migration to JustAPI
162
+### Option A: Full Migration to JustAPI (recommended)
162
163
163
164
```python
164
165
# Before (FastAPI + Granian)
178
179
return {"message": "Hello"}
179
180
```
180
181
181
-### Option B: ASGI Compatibility Mode
182
+### Option B: Run Both During Transition
182
183
183
-```python
184
-# Keep your FastAPI app, run on JustAPI's Rust runtime
185
-from fastapi import FastAPI
186
-from justapi import JustAPIApp
187
-
188
-fastapi_app = FastAPI()
189
-
190
-@fastapi_app.get("/hello")
191
-async def hello():
192
- return {"message": "Hello"}
193
-
194
-# Run FastAPI app on JustAPI's Rust server
195
-app = JustAPIApp()
196
-app.run("0.0.0.0:8000", asgi_app=fastapi_app)
197
-```
184
+If you need to keep a FastAPI app live while migrating, run JustAPI and the
185
+legacy ASGI app side by side and route by path with a reverse proxy (see
186
+[Behind a Proxy](/advanced/behind-a-proxy/)). Port route-by-route onto JustAPI
187
+and move the proxy weights over as each route is migrated.
198
188
199
189
### Step 2: Update Server Configuration
200
190
221
211
app.add_middleware(CORSMiddleware, allow_origins=["*"])
222
212
223
213
# After (JustAPI)
224
-from justapi.middleware import CORS
225
-app.add_middleware(CORS(allow_origins=["*"]))
214
+app.add_cors(allow_origins=["*"])
226
215
```
227
216
228
217
---
331
320
```python
332
321
# Robyn - Manual implementation
333
322
334
-# JustAPI - Built-in JWT
335
-from justapi import JWTAuth
336
-auth = JWTAuth(secret="your-secret-key")
323
+# JustAPI - Built-in JWT (Rust-native middleware, validates every request)
324
+from justapi import JustAPIApp
325
+
326
+app = JustAPIApp()
327
+app.set_jwt_auth(secret="your-secret-key")
337
328
338
329
@app.get("/protected")
339
-@auth.require
340
330
async def protected_endpoint():
341
331
return {"message": "Authenticated"}
342
332
```
343
333
β–Όdocs_site/src/content/docs/how-to/circuit-breaker-recipes.md
+1-1
106
106
def rate_limited_data(request):
107
107
client_id = request.client.host
108
108
if not limiter.is_allowed(client_id):
109
- from starlette.responses import JSONResponse
109
+ from justapi import JSONResponse
110
110
return JSONResponse(content={"error": "Rate limit exceeded"}, status_code=429)
111
111
return {"data": "ok"}
112
112
```
113
113
β–Όdocs_site/src/content/docs/how-to/custom-request-route.md
+29-34
1
1
---
2
-title: Custom Request and Route Classes
3
-description: Extend the Request and APIRoute classes in JustAPI for custom behavior.
4
-keywords: [JustAPI, custom request, custom route, APIRoute, extend]
2
+title: Custom Request Class
3
+description: Extend the Request class in JustAPI for custom request behavior.
4
+keywords: [JustAPI, custom request, extend, Request]
5
5
---
6
6
7
-## Custom Route Class
7
+## Custom Request Class
8
8
9
-Override the default `APIRoute` to add custom behavior:
9
+JustAPI's routing is fully native (Rust `matchit`), so there is no
10
+replaceable `APIRoute`/`route_class` mechanism β€” routes are registered on
11
+`JustAPIApp`. You *can* subclass `justapi.Request` and accept it as a parameter
12
+to customize how a handler reads the request.
10
13
11
14
```python
12
-from starlette.routing import APIRoute
13
15
from justapi import JustAPIApp, Request
14
16
15
-class TimingRoute(APIRoute):
16
- def get_route_handler(self):
17
- original_handler = super().get_route_handler()
18
-
19
- async def custom_handler(request: Request):
20
- import time
21
- start = time.time()
22
- response = await original_handler(request)
23
- duration = time.time() - start
24
- response.headers["X-Process-Time"] = str(round(duration, 4))
25
- return response
26
-
27
- return custom_handler
28
-
29
-app = JustAPIApp(route_class=TimingRoute)
30
-
31
-@app.get("/items/")
32
-def list_items():
33
- return {"items": []}
34
-```
35
-
36
-## Custom Request Class
37
-
38
-```python
39
-from starlette.requests import Request
40
-
41
17
class CustomRequest(Request):
42
18
async def json(self):
43
19
body = await super().json()
44
- # Transform the body before validation
20
+ # pre-process the body before application logic
45
21
return body
46
22
23
+app = JustAPIApp()
24
+
47
25
@app.post("/items")
48
26
async def create_item(request: CustomRequest):
49
27
data = await request.json()
50
28
return data
51
29
```
52
30
31
+For per-route cross-cutting behavior (timing, auth, header injection), use the
32
+native middleware mechanisms instead:
33
+
34
+```python
35
+import time
36
+from justapi import JustAPIApp
37
+
38
+app = JustAPIApp()
39
+
40
+@app.middleware("http")
41
+async def timing(request, call_next):
42
+ start = time.time()
43
+ response = await call_next(request)
44
+ response.headers["X-Process-Time"] = str(round(time.time() - start, 4))
45
+ return response
46
+```
47
+
53
48
## See Also
54
49
55
50
- [Using the Request Directly](/advanced/using-request-directly/) β€” raw request access
56
51
- [Advanced Middleware](/advanced/advanced-middleware/) β€” custom middleware
57
-- [Advanced Dependencies](/advanced/advanced-dependencies/) β€” custom deps
52
+- [Advanced Dependencies](/advanced/advanced-dependencies/) β€” custom deps
58
53
β–Όdocs_site/src/content/docs/how-to/graphql.md
+1-1
72
72
JustAPI supports global dependencies that apply to all routes including GraphQL:
73
73
74
74
```python
75
-from justapi import Depends, Security
75
+from justapi import Depends
76
76
77
77
def get_current_user(request):
78
78
token = request.headers.get("Authorization", "").replace("Bearer ", "")
79
79
β–Όdocs_site/src/content/docs/how-to/performance-tuning.md
+49-66
200
200
201
201
## 5. Middleware Optimization
202
202
203
-### Middleware Order Matters
203
+### Prefer Native Middleware
204
204
205
-Middleware executes in the order it's added. Put fastest middleware first:
205
+JustAPI's CORS, security headers, and JWT auth run in the Rust middleware
206
+chain with zero GIL overhead. Prefer them over Python middleware:
206
207
207
208
```python
208
209
from justapi import JustAPIApp
209
-from justapi.middleware import CORS, SecurityHeaders, RateLimiter
210
210
211
211
app = JustAPIApp()
212
212
213
-# Order matters: fastest first
214
-app.add_middleware(CORS()) # Fast (header check)
215
-app.add_middleware(SecurityHeaders()) # Fast (header addition)
216
-app.add_middleware(RateLimiter()) # Medium (Redis check)
217
-```
218
-
219
-### Disable Unused Middleware
213
+# Rust-native: CORS
214
+app.add_cors(allow_origins=["https://example.com"])
220
215
221
-```python
222
-# Minimal middleware for maximum performance
223
-app = JustAPIApp(middlewares=[])
216
+# Rust-native: security headers (HSTS optional)
217
+app.enable_secure_headers(with_hsts=True)
224
218
225
-# Or select specific middleware
226
-app = JustAPIApp(middlewares=[CORS()])
219
+# Rust-native: JWT auth (validates every request in Rust)
220
+app.set_jwt_auth(secret="your-hmac-secret")
227
221
```
228
222
229
-### Middleware Profiling
223
+### Python Middleware for Custom Logic
230
224
231
-```python
232
-import time
225
+Custom per-request logic is an async Python middleware. Keep it to logic that
226
+must run in Python β€” the native fast path bypasses Python middleware by design:
233
227
228
+```python
234
229
@app.middleware("http")
235
230
async def profile_middleware(request, call_next):
236
231
start = time.perf_counter()
237
232
response = await call_next(request)
238
233
duration = time.perf_counter() - start
239
-
234
+
240
235
if duration > 0.1: # Log slow middleware
241
- print(f"Slow middleware: {request.url.path} took {duration:.3f}s")
242
-
236
+ print(f"Slow middleware: {request.get('path')} took {duration:.3f}s")
237
+
243
238
return response
244
239
```
245
240
246
-
247
-## 6. Compression Settings
248
-
249
-### Enable Compression
241
+> **Performance note:** Python middleware runs on the Python side of the
242
+> boundary and is bypassed (by design) by the native fast path
243
+> (`native=True`). For hot endpoints, prefer native middleware
244
+> (`add_cors`, `enable_secure_headers`, `set_jwt_auth`) or no middleware.
250
245
251
-```python
252
-from justapi import JustAPIApp
246
+### Middleware Profiling
253
247
254
-app = JustAPIApp()
255
-app.enable_compression(
256
- min_size=1024, # Minimum response size to compress (bytes)
257
- algorithms=["gzip", "deflate"] # Enabled algorithms
258
-)
259
-```
248
+The built-in metrics include per-request latency histograms β€” see
249
+[Observability](/advanced/observability/) for p50/p95/p99 tracking.
260
250
261
-### Algorithm Performance
251
+---
262
252
263
-| Algorithm | Speed | Compression Ratio | Use Case |
264
-|-----------|-------|-------------------|----------|
265
-| gzip | Fast | Good | General purpose |
266
-| deflate | Fastest | Moderate | When speed matters |
267
-| brotli | Slow | Best | Static assets |
268
-| zstd | Fast | Best | Large payloads |
253
+## 6. Response Compression
269
254
270
-### Disable Compression for Specific Routes
255
+Response compression (Gzip, with feature-gated Brotli/Zstd) is a Rust-side
256
+server option:
271
257
272
-```python
273
-@app.get("/api/fast", compress=False)
274
-async def fast_endpoint():
275
- return {"data": "small"}
258
+```rust
259
+// justapi-core / CLI: add compression to the server chain
260
+let server = Server::new(addr).add_compression();
276
261
```
277
262
263
+For Python apps, compression is applied per-response via a streaming or
264
+static response with the appropriate `Content-Encoding` β€” see the
265
+[Streaming Output](/advanced/streaming-output/) guide. The Rust
266
+`CompressionMiddleware` compresses responses based on the client's
267
+`Accept-Encoding`, with Brotli/Zstd enabled by the `brotli-compression` /
268
+`zstd-compression` feature flags.
269
+
278
270
---
279
271
280
272
## 7. Caching Strategies
281
273
282
274
### Response Caching
283
275
284
-```python
285
-from justapi import JustAPIApp
286
-from justapi.middleware import CacheControl
287
-
288
-app = JustAPIApp()
289
-app.add_middleware(CacheControl(
290
- max_age=3600, # 1 hour
291
- private=False
292
-))
293
-```
294
-
295
-### Route-Level Caching
296
-
297
-```python
298
-@app.get("/api/data", cache={"max_age": 300, "private": True})
299
-async def cached_data():
300
- return expensive_computation()
301
-```
302
-
303
276
### Static File Caching
304
277
305
-JustAPI automatically sets cache headers for static files:
278
+JustAPI automatically sets cache headers for static files (served via
279
+`app.frontend(...)` or a static mount):
306
280
- ETag-based validation
307
-- 1-hour cache lifetime
281
+- `Cache-Control` headers
308
282
- 304 Not Modified responses
309
283
284
+### Response Caching for Custom Routes
285
+
286
+For route-level response caching, hold a short-lived cache yourself (e.g. a
287
+dict keyed by path, or Redis via the `RateLimiter`/Redis integration) and
288
+return the cached payload from the handler. JustAPI's request coalescing
289
+(`app.enable_request_coalescing(...)`) collapses thundering-herd traffic on
290
+identical concurrent requests into a single upstream call β€” a useful
291
+read-path optimization for expensive endpoints.
292
+
310
293
---
311
294
312
295
## 8. Database Performance
313
296
β–Όdocs_site/src/content/docs/reference/api-stability.md
+1-1
50
50
|-----------|--------|-------|
51
51
| `JustAPIApp` class | βœ… Stable | Core application class |
52
52
| Route decorators | βœ… Stable | `@app.get()`, `@app.post()`, etc. |
53
-| Request/Response objects | βœ… Stable | Starlette-compatible |
53
+| Request/Response objects | βœ… Stable | JustAPI-native (`Request`, `JSONResponse`, ...) |
54
54
| Dependency injection | βœ… Stable | `Depends()` pattern |
55
55
| Schema validation | βœ… Stable | `Schema` class |
56
56
| Error handling | βœ… Stable | `HTTPException` |
57
57
β–Όdocs_site/src/content/docs/reference/glossary.md
+1-1
8
8
9
9
**APIRouter** β€” A class for grouping related routes into modular files. Supports the same decorators as `JustAPIApp`.
10
10
11
-**ASGI** β€” Asynchronous Server Gateway Interface. Python standard for async web servers. JustAPI provides an ASGI shim for compatibility.
11
+**ASGI** β€” Asynchronous Server Gateway Interface, the Python standard for async web servers (used by Starlette/FastAPI/Uvicorn). JustAPI is a **native Rust runtime** and does not implement ASGI; it runs its own pipeline for maximum throughput.
12
12
13
13
## B
14
14
15
15
β–Όdocs_site/src/content/docs/resources/external-links.md
+4-5
8
8
9
9
| Project | Purpose | Link |
10
10
|---------|---------|------|
11
-| **Pydantic** | Data validation and serialization | [pydantic.dev](https://pydantic.dev/) |
12
-| **Starlette** | ASGI toolkit (used by JustAPI's shim) | [starlette.io](https://www.starlette.io/) |
11
+| **Pydantic** | Data validation and serialization (schema bridge) | [pydantic.dev](https://pydantic.dev/) |
13
12
| **PyO3** | Python ↔ Rust FFI | [pyo3.rs](https://pyo3.rs/) |
14
13
| **maturin** | Build Rust Python packages | [maturin.rs](https://www.maturin.rs/) |
15
14
18
17
| Framework | What It Does |
19
18
|-----------|-------------|
20
19
| **Robyn** | Rust runtime, decorator API |
21
-| **Granian** | Rust ASGI server |
20
+| **Granian** | Rust ASGI server (ASGI-based, unlike JustAPI's native runtime) |
22
21
| **Litestar** | Python-native ASGI framework |
23
-| **FastAPI** | Python web framework (the compatibility target) |
22
+| **FastAPI** | Python ASGI web framework (design inspiration for DX) |
24
23
| **Typer** | CLI framework (FastAPI for CLIs) |
25
24
26
25
## Community
46
45
- **criterion** β€” Rust benchmarking
47
46
48
47
### Deployment
49
-- **uvicorn** β€” ASGI server (JustAPI's ASGI shim)
48
+- **uvicorn** β€” Python ASGI server (runs FastAPI/Starlette apps; not used by JustAPI)
50
49
- **gunicorn** β€” pre-fork server
51
50
- **Docker** β€” container deployment
52
51
- **Kubernetes** β€” orchestration
53
52
β–Όdocs_site/src/content/docs/security/secure-configuration.md
+25-4
35
35
36
36
## Rate Limiting
37
37
38
+Rate limiting is a Rust-native GCRA token bucket, backed by Redis
39
+(feature-gated: `pip install justapi[redis-rate-limit]`). Create a limiter and
40
+check it per request/key inside your handler:
41
+
38
42
```python
39
-# Global rate limit
40
-app.enable_rate_limiter(max_requests=1000, window_seconds=60)
43
+import asyncio
44
+from justapi import RateLimiter
45
+
46
+limiter = await RateLimiter.new_redis("redis://127.0.0.1:6379")
47
+
48
+@app.get("/api")
49
+async def api(request):
50
+ # 100 requests per 60s per client IP
51
+ result = await limiter.check_limit(
52
+ key=request.get("client", ["?"])[0], # client IP
53
+ capacity=100, # burst capacity
54
+ replenish_rate=100 / 60, # refill per second
55
+ tokens=1, # cost of this request
56
+ )
57
+ if not result.allowed:
58
+ return {"status": 429, "body": '{"detail": "rate limit exceeded"}'}
59
+ return {"ok": True}
60
+```
41
61
42
-# Per-IP rate limit
43
-app.enable_ip_rate_limiter(max_requests=100, window_seconds=60)
62
+`RateLimitResult` carries `allowed` and `retry_after_ms` so you can emit a
63
+proper `429` with `Retry-After`. (The core `justapi-core` rate limiter also
64
+exposes a middleware form for Rust-native routes.)
44
65
```
45
66
46
67
## Request Body Size
47
68
β–Όdocs_site/src/content/docs/tutorials/cors.md
+12-15
1
1
---
2
2
title: CORS (Cross-Origin Resource Sharing)
3
-description: Configure CORS headers in JustAPI to allow requests from other domains.
3
+description: Configure CORS in JustAPI to allow requests from other domains.
4
4
keywords: [JustAPI, CORS, cross-origin, security, browser]
5
5
---
6
6
7
7
## Basic CORS
8
8
9
-CORS (Cross-Origin Resource Sharing) allows browsers to make requests to your API from different domains. Without CORS, browsers block these requests.
9
+CORS (Cross-Origin Resource Sharing) allows browsers to make requests to your
10
+API from different domains. Without CORS, browsers block these requests.
11
+
12
+JustAPI's CORS is **Rust-native** β€” it runs in the Rust middleware chain and
13
+covers all routes including the fast path and error responses. Configure it
14
+with `add_cors()`:
10
15
11
16
```python
12
17
from justapi import JustAPIApp
13
18
14
19
app = JustAPIApp()
15
20
16
-app.add_middleware(
17
- CORSMiddleware,
21
+app.add_cors(
18
22
allow_origins=["https://example.com"],
19
23
allow_credentials=True,
20
24
allow_methods=["*"],
27
31
For local development, allow all origins:
28
32
29
33
```python
30
-from justapi import JustAPIApp
31
-from starlette.middleware.cors import CORSMiddleware
32
-
33
-app = JustAPIApp()
34
-
35
-app.add_middleware(
36
- CORSMiddleware,
34
+app.add_cors(
37
35
allow_origins=["*"],
38
36
allow_methods=["*"],
39
37
allow_headers=["*"],
49
47
In production, specify exactly which origins are allowed:
50
48
51
49
```python
52
-app.add_middleware(
53
- CORSMiddleware,
50
+app.add_cors(
54
51
allow_origins=[
55
52
"https://app.example.com",
56
53
"https://admin.example.com",
73
70
74
71
## See Also
75
72
76
-- [Middleware](/tutorials/middleware/) β€” general middleware usage
77
-- [Security Policy](/security/policy/) β€” security best practices
73
+- [Advanced Middleware](/advanced/advanced-middleware/) β€” native middleware reference
74
+- [Security Policy](/security/policy/) β€” security best practices
78
75
β–Όdocs_site/src/content/docs/tutorials/error-handling.md
+2-2
63
63
64
64
```python
65
65
from justapi import JustAPIApp
66
-from starlette.requests import Request
67
-from starlette.responses import JSONResponse
66
+from justapi import Request
67
+from justapi import JSONResponse
68
68
69
69
app = JustAPIApp()
70
70
71
71
β–Όdocs_site/src/content/docs/tutorials/middleware.md
+68-60
1
1
---
2
2
title: Middleware
3
-description: Intercept requests and responses with JustAPI middleware for logging, timing, CORS, and custom processing β€” a FastAPI alternative built on Rust.
4
-keywords: middleware, CORS, request interception, JustAPI, FastAPI alternative, Rust middleware
3
+description: Intercept requests and responses with JustAPI middleware for logging, timing, CORS, and custom processing.
4
+keywords: middleware, CORS, request interception, JustAPI, Rust middleware
5
5
---
6
6
7
-Middleware lets you run code before and after every request. JustAPI supports the same middleware pattern as FastAPI and Starlette.
7
+Middleware lets you run code before and after every request. JustAPI has two
8
+middleware layers:
9
+
10
+- **Native Rust middleware** (CORS, JWT, security headers) β€” configured with
11
+ dedicated methods, runs on every request including the native fast path.
12
+- **Python middleware** β€” your own `async def mw(request, call_next)` callables,
13
+ registered with `@app.middleware("http")` or per-route `middlewares=`.
8
14
9
15
## Basic HTTP Middleware
10
16
11
-Use the `@app.middleware("http")` decorator:
17
+Use the `@app.middleware("http")` decorator. Middleware is **async** and must
18
+`await call_next(request)`:
12
19
13
20
```python
14
21
import time
18
25
19
26
20
27
@app.middleware("http")
21
-def timing_middleware(request, call_next):
28
+async def timing_middleware(request, call_next):
22
29
start = time.time()
23
- response = call_next(request)
30
+ response = await call_next(request)
24
31
elapsed = time.time() - start
25
- response["headers"] = response.get("headers", []) + [
26
- (b"X-Process-Time", str(elapsed).encode())
27
- ]
32
+ response.headers.append(
33
+ (b"X-Process-Time", str(round(elapsed, 4)).encode())
34
+ )
28
35
return response
29
36
```
30
37
31
-The middleware receives the `request` and a `call_next` function that dispatches the request down the chain. The return value is a response dict.
38
+`call_next` dispatches the request down the chain; the return value is the
39
+handler's response object.
40
+
41
+> **Performance:** Python middleware runs on the Python side of the boundary and
42
+> is bypassed (must be) by the native fast path. Prefer native middleware
43
+> (`add_cors`, `enable_secure_headers`, `set_jwt_auth`) when possible. Keep
44
+> Python middleware to logic that must run in Python.
32
45
33
46
## CORS Middleware
34
47
35
-JustAPI has a built-in CORS configuration method:
48
+JustAPI has a built-in, Rust-native CORS configuration method:
36
49
37
50
```python
38
51
app.add_cors(
52
65
53
66
## Secure Headers Middleware
54
67
68
+Add safe HTTP security headers (Rust-native):
69
+
55
70
```python
56
71
app.enable_secure_headers(with_hsts=True)
57
72
```
62
77
- `Strict-Transport-Security` (when `with_hsts=True`)
63
78
- `Content-Security-Policy`
64
79
- `X-XSS-Protection: 0`
65
-- `Referrer-Policy`
66
80
67
81
## Multiple Middleware
68
82
69
-Middlewares are executed in registration order:
83
+Middlewares are executed in registration order (first added is outermost):
70
84
71
85
```python
72
86
@app.middleware("http")
73
-def middleware_one(request, call_next):
87
+async def middleware_one(request, call_next):
74
88
print("Before 1")
75
- response = call_next(request)
89
+ response = await call_next(request)
76
90
print("After 1")
77
91
return response
78
92
79
93
80
94
@app.middleware("http")
81
-def middleware_two(request, call_next):
95
+async def middleware_two(request, call_next):
82
96
print("Before 2")
83
- response = call_next(request)
97
+ response = await call_next(request)
84
98
print("After 2")
85
99
return response
86
100
```
97
111
98
112
## Route-Level Middleware
99
113
100
-You can add middleware to specific routes or routers using dependencies:
114
+Attach middleware to individual routes:
101
115
102
116
```python
103
-from justapi import Depends, HTTPException, Header
104
-
105
-
106
-def require_admin(authorization: str = Header(...)):
107
- if authorization != "Bearer admin-token":
108
- raise HTTPException(403, "Admin access required")
109
-
110
-
111
-@router.get("/admin/users", dependencies=[Depends(require_admin)])
112
-def list_all_users(request):
113
- return {"users": [...]}
114
-
115
-
116
-app.include_router(router, prefix="/api/v1")
117
+async def auth_middleware(request, call_next):
118
+ if not (request.get("headers") or {}).get("x-token"):
119
+ return {"status": 401, "body": {"detail": "unauthorized"}}
120
+ return await call_next(request)
121
+
122
+@app.get("/protected", middlewares=[auth_middleware])
123
+async def protected(request):
124
+ return {"message": "top secret"}
117
125
```
118
126
119
127
## Real-World Patterns
127
135
128
136
129
137
@app.middleware("http")
130
-def log_requests(request, call_next):
131
- method = request.method
132
- path = request.path
133
- logger.info(f"β†’ {method} {path}")
134
- response = call_next(request)
135
- status = response.get("status", 200)
136
- logger.info(f"← {method} {path} β†’ {status}")
138
+async def log_requests(request, call_next):
139
+ method = request.get("method")
140
+ path = request.get("path")
141
+ logger.info("β†’ %s %s", method, path)
142
+ response = await call_next(request)
143
+ status = getattr(response, "status_code", None) or 200
144
+ logger.info("← %s %s β†’ %s", method, path, status)
137
145
return response
138
146
```
139
147
140
-### Rate Limiting Middleware (via Dependency)
148
+### Authorization via Dependency
141
149
142
150
```python
143
151
from justapi import Depends, HTTPException, Header
144
-import time
145
152
146
-request_counts = {}
147
153
148
-def rate_limiter(ip: str = Header("X-Forwarded-For")):
149
- now = time.time()
150
- window = 60 # 1 minute
151
- max_requests = 100
154
+def require_admin(authorization: str = Header(...)):
155
+ if authorization != "Bearer admin-token":
156
+ raise HTTPException(403, "Admin access required")
152
157
153
- # Clean old entries
154
- request_counts[ip] = [t for t in request_counts.get(ip, []) if now - t < window]
155
- request_counts[ip].append(now)
156
158
157
- if len(request_counts[ip]) > max_requests:
158
- raise HTTPException(429, "Rate limit exceeded")
159
+@app.get("/admin/users", dependencies=[Depends(require_admin)])
160
+def list_all_users(request):
161
+ return {"users": []}
162
+```
159
163
164
+## Native Fast-Path Note
160
165
161
-@app.get("/api/", dependencies=[Depends(rate_limiter)])
162
-def api_endpoint(request):
163
- return {"data": "ok"}
164
-```
166
+The Rust native fast path (`native=True`) is a validate-and-echo shortcut that
167
+does **not** execute Python middleware or dependencies. Routes
168
+using `native=True` must not declare Python middleware/dependencies β€” the
169
+framework rejects that combination at registration time. Native middleware
170
+(`add_cors`, `enable_secure_headers`, `set_jwt_auth`) runs for all routes.
165
171
166
172
## How It Works Internally
167
173
168
-1. Middleware runs in the Rust middleware chain, not Python
169
-2. `call_next` dispatches through the chain to the handler
170
-3. The response dict flows back through each middleware's post-processing
171
-4. Built-in middleware (CORS, rate limiting) runs in Rust with zero GIL overhead
174
+1. **Native** middleware (CORS, security headers, JWT, rate limit) runs in the
175
+ Rust middleware chain with zero GIL overhead.
176
+2. **Python** middleware runs as an async wrapper around your handler on the
177
+ Python side.
178
+3. `call_next` dispatches down the middleware chain to the handler.
179
+4. The handler's result flows back through each middleware's post-processing.
172
180
173
181
## Next Steps
174
182
175
183
- [Error Handling](/tutorials/error-handling/) β€” Custom error responses
176
184
- [Dependency Injection](/tutorials/dependency-injection/) β€” Reusable components
177
-- [Resilience Patterns](/advanced/resilience-patterns/) β€” Circuit breakers, rate limiting
185
+- [Resilience Patterns](/advanced/resilience-patterns/) β€” Circuit breakers, rate limiting
178
186
β–Όdocs_site/src/content/docs/tutorials/response-status-code.md
+13-7
6
6
7
7
## Default Status Code
8
8
9
-JustAPI returns `200 OK` by default for successful responses.
9
+JustAPI returns `200 OK` by default for successful responses that don't use a
10
+`Response` object.
10
11
11
-## Custom Status Code
12
+## Explicit Status Code
12
13
13
-Use `status_code` in the decorator:
14
+The `status_code=` route decorator kwarg (e.g. `@app.post("/items", status_code=201)`)
15
+is **OpenAPI documentation metadata only** β€” it documents the expected success
16
+code in the generated schema but does **not** alter the actual HTTP status of a
17
+plain dict/JSON return (which is still `200`).
18
+
19
+To actually set the response status, return a `Response` object:
14
20
15
21
```python
16
-from justapi import JustAPIApp
22
+from justapi import JustAPIApp, JSONResponse
17
23
18
24
app = JustAPIApp()
19
25
20
-@app.post("/items", status_code=201)
26
+@app.post("/items")
21
27
def create_item():
22
- return {"message": "created"}
28
+ return JSONResponse(content={"message": "created"}, status_code=201)
23
29
```
24
30
25
31
## Common Status Codes
41
47
For full control, return a `Response` object:
42
48
43
49
```python
44
-from starlette.responses import JSONResponse
50
+from justapi import JSONResponse
45
51
46
52
@app.get("/custom")
47
53
def custom_response():
48
54
β–Όdocs_site/src/content/docs/tutorials/security/first-steps/index.md
+28-36
1
1
---
2
2
title: Security β€” First Steps
3
-description: Add authentication to a JustAPI route using OAuth2 and Security dependencies.
3
+description: Add authentication to a JustAPI route using OAuth2 and dependency injection.
4
4
keywords: [JustAPI, security, OAuth2, authentication, bearer token, password flow]
5
5
---
6
6
7
-This tutorial builds a simple authentication system step by step. By the end you'll understand the full OAuth2 password flow and how to protect routes.
7
+This tutorial builds a simple authentication system step by step. By the end you'll understand the full OAuth2 password flow and how to protect routes with JustAPI's `Depends`-based auth helpers.
8
8
9
9
## The Problem
10
10
11
11
You need to protect a route so only authenticated users can access it. Unauthenticated requests should get a `401` error.
12
12
13
-## Step 1: Add a Security Dependency
13
+## Step 1: Add an Auth Dependency
14
14
15
15
```python
16
-from justapi import JustAPIApp, Security
17
-from fastapi.security import OAuth2PasswordBearer
16
+from justapi import JustAPIApp, Depends
17
+from justapi.auth import OAuth2PasswordBearer
18
18
19
19
app = JustAPIApp()
20
20
21
21
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
22
22
23
23
@app.get("/users/me")
24
-def read_users_me(token: str = Security(oauth2_scheme)):
24
+async def read_users_me(token: str = Depends(oauth2_scheme)):
25
25
return {"token": token}
26
26
```
27
27
28
28
### What happens here?
29
29
30
30
1. `OAuth2PasswordBearer(tokenUrl="token")` creates a dependency that expects an `Authorization: Bearer <token>` header.
31
-2. `Security(oauth2_scheme)` tells JustAPI this is a security requirement β€” it appears in the OpenAPI docs with an "Authorize" button.
32
-3. The `token` parameter receives the raw token string from the header.
33
-
34
-If the request has no `Authorization` header, JustAPI returns a `401` error automatically β€” no code needed.
31
+2. `Depends(oauth2_scheme)` injects the raw token string into the `token` parameter.
32
+3. If the request has no `Authorization` header, JustAPI returns a `401` automatically β€” no code needed.
35
33
36
34
### Try it
37
35
38
-Start the server and go to `http://localhost:8000/docs`. You'll see an **Authorize** button at the top.
39
-
40
-Click it, enter any value, and then call `/users/me`. The request includes the header automatically.
41
-
42
-Without the header:
36
+Start the server and call `/users/me` without a header:
43
37
44
38
```json
45
39
{
52
46
The token is just a string. We need to verify it:
53
47
54
48
```python
55
-from justapi import JustAPIApp, Security, HTTPException
56
-from fastapi.security import OAuth2PasswordBearer
49
+from justapi import JustAPIApp, Depends, HTTPException
50
+from justapi.auth import OAuth2PasswordBearer
57
51
58
52
app = JustAPIApp()
59
53
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
63
57
"bob": {"user_id": 2, "name": "Bob"},
64
58
}
65
59
66
-def get_current_user(token: str = Security(oauth2_scheme)):
60
+def get_current_user(token: str = Depends(oauth2_scheme)):
67
61
if token not in fake_users_db:
68
62
raise HTTPException(status_code=401, detail="Invalid token")
69
63
return fake_users_db[token]
70
64
71
65
@app.get("/users/me")
72
-def read_users_me(current_user: dict = Security(get_current_user)):
66
+def read_users_me(current_user: dict = Depends(get_current_user)):
73
67
return current_user
74
68
```
75
69
82
76
5. If not found, raises `401`
83
77
84
78
:::note
85
-The token `"alice"` is the actual value after `Bearer` in the header. In production this would be a JWT or opaque token.
79
+The token `"alice"` is the actual value after `Bearer` in the header. In production this would be a JWT (see `JwtAuth` in the [Auth API](/api-reference/auth/)).
86
80
:::
87
81
88
82
## Step 3: Add a Token Endpoint
90
84
The frontend needs a way to log in and get a token. Create a `/token` endpoint:
91
85
92
86
```python
93
-from justapi import JustAPIApp, Security, HTTPException
94
-from fastapi.security import OAuth2PasswordBearer
87
+from justapi import JustAPIApp, Depends, HTTPException
88
+from justapi.auth import OAuth2PasswordBearer
95
89
from pydantic import BaseModel
96
90
97
91
app = JustAPIApp()
106
100
"bob": {"user_id": 2, "name": "Bob", "password": "pass456"},
107
101
}
108
102
109
-def get_current_user(token: str = Security(oauth2_scheme)):
103
+def get_current_user(token: str = Depends(oauth2_scheme)):
110
104
if token not in fake_users_db:
111
105
raise HTTPException(status_code=401, detail="Invalid token")
112
106
return fake_users_db[token]
119
113
return {"access_token": request.username, "token_type": "bearer"}
120
114
121
115
@app.get("/users/me")
122
-def read_users_me(current_user: dict = Security(get_current_user)):
116
+def read_users_me(current_user: dict = Depends(get_current_user)):
123
117
return current_user
124
118
```
125
119
130
124
3. Client sends `GET /users/me` with header `Authorization: Bearer alice`
131
125
4. Server returns `{"user_id": 1, "name": "Alice"}`
132
126
133
-:::tip
134
-In the Swagger UI (`/docs`), you can now click **Authorize**, enter `alice` / `secret123`, and all subsequent requests will include the token automatically.
135
-:::
136
-
137
127
## Step 4: Add User Roles
138
128
139
129
Extend the dependency to check permissions:
140
130
141
131
```python
142
-def get_current_user(token: str = Security(oauth2_scheme)):
132
+def get_current_user(token: str = Depends(oauth2_scheme)):
143
133
if token not in fake_users_db:
144
134
raise HTTPException(status_code=401, detail="Invalid token")
145
135
return fake_users_db[token]
146
136
147
-def require_admin(current_user: dict = Security(get_current_user)):
137
+def require_admin(current_user: dict = Depends(get_current_user)):
148
138
if current_user.get("role") != "admin":
149
139
raise HTTPException(status_code=403, detail="Admin access required")
150
140
return current_user
151
141
152
142
@app.get("/admin/dashboard")
153
-def admin_dashboard(admin: dict = Security(require_admin)):
143
+def admin_dashboard(admin: dict = Depends(require_admin)):
154
144
return {"message": f"Welcome admin {admin['name']}"}
155
145
```
156
146
158
148
159
149
## What JustAPI Does Under the Hood
160
150
161
-When you use `Security(oauth2_scheme)`:
151
+When you use `Depends(oauth2_scheme)`:
162
152
163
-1. **Before the request**: JustAPI checks for the `Authorization` header
153
+1. **Before the request**: JustAPI resolves the dependency, which checks the `Authorization` header
164
154
2. **Missing header**: Returns `401` with `{"detail": "Not authenticated"}`
165
155
3. **Invalid header format**: Returns `401` with details about the expected format
166
156
4. **Valid header**: Extracts the token and passes it to your dependency
167
-5. **In the docs**: Adds an "Authorize" button to Swagger UI
157
+5. **Rust-native JWT**: for production, `JwtAuth` (or `app.set_jwt_auth`) validates tokens entirely in Rust
168
158
169
159
## Recap
170
160
171
-- `OAuth2PasswordBearer(tokenUrl="token")` creates the auth dependency
172
-- `Security()` marks it as a security requirement in the docs
161
+- `OAuth2PasswordBearer(tokenUrl="token")` creates the auth dependency (from `justapi.auth`)
162
+- `Depends(...)` injects the token / user into your handler
173
163
- Your dependency validates the token and returns the user
174
164
- Unauthenticated requests get a `401` automatically
175
165
- Chain dependencies for role-based access control
166
+- For JWT: use `JwtAuth` or the Rust-native `app.set_jwt_auth(...)` middleware
176
167
177
168
## See Also
178
169
180
171
- [Simple OAuth2](/tutorials/security/simple-oauth2/) β€” password-based OAuth2
181
172
- [OAuth2 + JWT Tokens](/tutorials/security/oauth2-jwt/) β€” production JWT implementation
182
173
- [Advanced Security](/advanced/advanced-security/) β€” OAuth2 scopes, API keys
174
+- [Auth API](/api-reference/auth/) β€” `JwtAuth`, `OAuth2PasswordBearer`, form dependencies
183
175
β–Όdocs_site/src/content/docs/tutorials/security/get-current-user/index.md
+1-1
8
8
9
9
```python
10
10
from justapi import JustAPIApp, Security
11
-from fastapi.security import OAuth2PasswordBearer
11
+from justapi.auth import OAuth2PasswordBearer
12
12
13
13
app = JustAPIApp()
14
14
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
15
15
β–Όdocs_site/src/content/docs/tutorials/security/oauth2-jwt/index.md
+1-1
10
10
import jwt
11
11
from datetime import datetime, timedelta
12
12
from justapi import JustAPIApp, Depends, HTTPException
13
-from fastapi.security import OAuth2PasswordBearer
13
+from justapi.auth import OAuth2PasswordBearer
14
14
from passlib.context import CryptContext
15
15
16
16
SECRET_KEY = "your-secret-key"
17
17
β–Όdocs_site/src/content/docs/tutorials/security/simple-oauth2/index.md
+1-1
8
8
9
9
```python
10
10
from justapi import JustAPIApp, Depends, HTTPException
11
-from fastapi.security import OAuth2PasswordBearer
11
+from justapi.auth import OAuth2PasswordBearer
12
12
13
13
app = JustAPIApp()
14
14
15
15
β–Όdocs_site/src/content/docs/tutorials/static-files.md
+19-13
6
6
7
7
## Basic Static Files
8
8
9
-Mount a directory to serve static files:
9
+Serve files from a directory at a path prefix:
10
10
11
11
```python
12
12
from justapi import JustAPIApp
13
13
14
14
app = JustAPIApp()
15
15
16
-app.mount("/static", directory="static", name="static")
16
+app.frontend("/static", "static")
17
17
```
18
18
19
19
This serves files from the `static/` directory at `/static/`. For example, `static/style.css` is accessible at `/static/style.css`.
22
22
23
23
```python
24
24
# Serve at /assets/*
25
-app.mount("/assets", directory="public/assets", name="assets")
25
+app.frontend("/assets", "public/assets")
26
26
```
27
27
28
28
## Multiple Static Directories
29
29
30
30
```python
31
-app.mount("/css", directory="static/css", name="css")
32
-app.mount("/js", directory="static/js", name="js")
33
-app.mount("/images", directory="static/images", name="images")
31
+app.frontend("/css", "static/css")
32
+app.frontend("/js", "static/js")
33
+app.frontend("/images", "static/images")
34
34
```
35
35
36
-## Mounting a Sub-application
36
+## Mounting an APIRouter as a Sub-application
37
37
38
-JustAPI supports mounting sub-applications (like Starlette or ASGI apps) at a path:
38
+JustAPI supports mounting an `APIRouter` at a path prefix:
39
39
40
40
```python
41
-from justapi import JustAPIApp
41
+from justapi import APIRouter, JustAPIApp
42
42
43
43
app = JustAPIApp()
44
44
45
-# Mount a sub-application
46
-from starlette.applications import Starlette
47
-sub_app = Starlette()
48
-app.mount("/api/v2", sub_app, name="v2")
45
+# Serve a static SPA with index.html fallback for client-side routing
46
+app.frontend("/", "public", html=True)
47
+
48
+# Mount a router as a sub-application
49
+router = APIRouter(prefix="/api/v2")
50
+app.mount("/api/v2", router, name="v2")
49
51
```
50
52
53
+> JustAPI does not mount third-party ASGI/Starlette applications β€” the runtime
54
+> is a native Rust pipeline. Use `APIRouter` for sub-app structure, or place
55
+> JustAPI behind a path-routing reverse proxy to co-host legacy apps.
56
+
51
57
## See Also
52
58
53
59
- [Response Classes](/api-reference/responses/) β€” response types
54
60
β–Όexamples/agent_demo/README.md
+9-0
19
19
20
20
## Run
21
21
22
+One command β€” starts the server, runs the full demo (tools list, tool call,
23
+streaming agent run, session state, session persistence), and stops:
24
+
25
+```bash
26
+bash examples/agent_demo/run_demo.sh
27
+```
28
+
29
+Manually, step by step:
30
+
22
31
```bash
23
32
# from the repo root
24
33
uv run examples/agent_demo/app.py
25
34
β–Όexamples/agent_demo/app.py
+5-2
110
110
111
111
if __name__ == "__main__":
112
112
# HTTP server (streaming + sessions) and an MCP stdio server are both
113
- # available from the same app object.
114
- app.run("127.0.0.1:8000")
113
+ # available from the same app object. Optional argv[1] = port.
114
+ import sys
115
+
116
+ port = sys.argv[1] if len(sys.argv) > 1 else "8000"
117
+ app.run(f"127.0.0.1:{port}")
115
118
β–Όexamples/agent_demo/run_demo.sh
+83-0
1
+#!/usr/bin/env bash
2
+# examples/agent_demo/run_demo.sh β€” one-command agent-native demo.
3
+#
4
+# Starts the JustAPI agent demo, exercises all three differentiators
5
+# (native MCP tools, durable sessions, Rust-validated streaming), shows the
6
+# output, then stops the server. No manual steps.
7
+#
8
+# Usage:
9
+# bash examples/agent_demo/run_demo.sh [port]
10
+#
11
+# Prereqs: the justapi package must be importable (e.g. a venv with
12
+# `pip install justapi` or the repo's `.venv`).
13
+
14
+set -euo pipefail
15
+
16
+PORT="${1:-8051}"
17
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+ROOT="$(dirname "$(dirname "$HERE")")"
19
+
20
+# Pick a python that can import justapi.
21
+if python -c "import justapi" 2>/dev/null; then
22
+ PY=python
23
+elif "$ROOT/crates/justapi-py/.venv/bin/python" -c "import justapi" 2>/dev/null; then
24
+ PY="$ROOT/crates/justapi-py/.venv/bin/python"
25
+elif "$ROOT/.venv/bin/python" -c "import justapi" 2>/dev/null; then
26
+ PY="$ROOT/.venv/bin/python"
27
+else
28
+ echo "[demo] no python with 'justapi' importable found." >&2
29
+ echo "[demo] install it first: pip install justapi (or rebuild the wheel)" >&2
30
+ exit 1
31
+fi
32
+
33
+BASE="http://127.0.0.1:$PORT"
34
+LOG="$(mktemp /tmp/justapi_agent_demo.XXXXXX.log)"
35
+
36
+echo "[demo] starting JustAPI agent demo on $BASE (python: $PY)"
37
+"$PY" "$HERE/app.py" "$PORT" >"$LOG" 2>&1 &
38
+SERVER_PID=$!
39
+trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
40
+
41
+# Wait for the server to come up.
42
+for _ in $(seq 1 30); do
43
+ if curl -sf "$BASE/" >/dev/null 2>&1; then break; fi
44
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
45
+ echo "[demo] server exited early; log tail:" >&2
46
+ tail -20 "$LOG" >&2
47
+ exit 1
48
+ fi
49
+ sleep 0.5
50
+done
51
+
52
+sep() { printf '\n────────────────────────────────────────────────────────\n'; }
53
+
54
+echo ""
55
+echo "=== 1. Native MCP tools (schemas inferred in Rust) ==="
56
+curl -s "$BASE/_system/tools" | "$PY" -m json.tool | head -20
57
+sep
58
+
59
+echo "=== 2. Call a tool through the native call path ==="
60
+curl -s -X POST "$BASE/_system/tools/call" \
61
+ -H 'content-type: application/json' \
62
+ -d '{"name":"add","arguments":{"a":5,"b":7}}'
63
+echo ""
64
+sep
65
+
66
+echo "=== 3. Streaming agent run (NDJSON, validated item-by-item in Rust) ==="
67
+curl -sN "$BASE/agent/run?session=demo&query=streaming"
68
+echo ""
69
+sep
70
+
71
+echo "=== 4. Durable session state (persisted across requests) ==="
72
+curl -s "$BASE/agent/state?session=demo"
73
+echo ""
74
+echo ""
75
+echo "=== 5. Same session again β†’ run counter incremented ==="
76
+curl -sN "$BASE/agent/run?session=demo&query=streaming" >/dev/null
77
+curl -s "$BASE/agent/state?session=demo"
78
+echo ""
79
+sep
80
+
81
+echo ""
82
+echo "[demo] done. Server log: $LOG"
83
+echo "[demo] rerun: bash examples/agent_demo/run_demo.sh"
0
84
β–Όscripts/publish.sh
+71-0
1
+#!/usr/bin/env bash
2
+# scripts/publish.sh β€” build + verify + publish the justapi PyPI wheels.
3
+#
4
+# The only missing piece for a real release is the PyPI token
5
+# (MATURIN_PYPI_TOKEN / TWINE_PASSWORD). Everything else β€” portable
6
+# manylinux + musllinux builds for x86_64 + aarch64 (via maturin --zig),
7
+# twine check, fresh-venv install smoke β€” is done here so
8
+# `bash scripts/publish.sh` is the single release command.
9
+#
10
+# Prereqs: maturin (with --zig; requires zig on PATH), rustup with the
11
+# target stds installed (aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl,
12
+# x86_64-unknown-linux-musl), python3.
13
+#
14
+# Usage:
15
+# bash scripts/publish.sh # build + verify (safe, no upload)
16
+# MATURIN_PYPI_TOKEN=... bash scripts/publish.sh --upload
17
+
18
+set -euo pipefail
19
+
20
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
21
+WHEELS_DIR="$ROOT/target/wheels-user"
22
+UPLOAD="${1:-}"
23
+
24
+PLATFORMS=(
25
+ "x86_64-unknown-linux-gnu"
26
+ "aarch64-unknown-linux-gnu"
27
+ "x86_64-unknown-linux-musl"
28
+ "aarch64-unknown-linux-musl"
29
+)
30
+
31
+echo "[publish] building wheels for: ${PLATFORMS[*]}"
32
+mkdir -p "$WHEELS_DIR"
33
+for target in "${PLATFORMS[@]}"; do
34
+ echo "[publish] building $target..."
35
+ (cd "$ROOT/crates/justapi-py" && maturin build --release --target "$target" --zig --out "$WHEELS_DIR")
36
+done
37
+
38
+echo "[publish] wheels:"
39
+ls -la "$WHEELS_DIR"/*.whl
40
+
41
+echo "[publish] twine check (all wheels)..."
42
+python3 -m venv /tmp/justapi_publish_venv
43
+/tmp/justapi_publish_venv/bin/pip install --quiet twine
44
+/tmp/justapi_publish_venv/bin/python -m twine check "$WHEELS_DIR"/*.whl
45
+
46
+echo "[publish] fresh-venv install smoke (x86_64 manylinux)..."
47
+WHEEL="$(ls "$WHEELS_DIR"/*manylinux*x86_64*.whl | head -1)"
48
+/tmp/justapi_publish_venv/bin/pip install --quiet "$WHEEL"
49
+/tmp/justapi_publish_venv/bin/python -c "
50
+from justapi import JustAPIApp, Depends, Security, Schema
51
+app = JustAPIApp()
52
+@app.get('/')
53
+def root(): return {'ok': True}
54
+from justapi import JustAPITestClient
55
+r = JustAPITestClient(app).get('/')
56
+assert r['status'] == 200, r
57
+print('fresh-venv smoke: OK')
58
+"
59
+
60
+if [[ "$UPLOAD" == "--upload" ]]; then
61
+ if [[ -z "${MATURIN_PYPI_TOKEN:-}" && -z "${TWINE_PASSWORD:-}" ]]; then
62
+ echo "[publish] ERROR: --upload requires MATURIN_PYPI_TOKEN or TWINE_PASSWORD" >&2
63
+ exit 1
64
+ fi
65
+ echo "[publish] uploading to PyPI..."
66
+ /tmp/justapi_publish_venv/bin/python -m twine upload --skip-existing "$WHEELS_DIR"/*.whl
67
+ echo "[publish] DONE β€” justapi is on PyPI (4 platforms)"
68
+else
69
+ echo "[publish] build + verify OK. To release:"
70
+ echo " MATURIN_PYPI_TOKEN=... bash scripts/publish.sh --upload"
71
+fi
0
72
β–Όscripts/sanitize.sh
+55-0
1
+#!/usr/bin/env bash
2
+# scripts/sanitize.sh β€” fast memory-safety gate (replaces the full-suite Miri run).
3
+#
4
+# Why not full Miri: Miri interprets every instruction (100-1000x slower); the
5
+# full justapi-core suite takes 20+ minutes and most of it is safe Rust. Only
6
+# ONE production file has `unsafe` (memory.rs, 3 mentions, with SAFETY
7
+# comments). The practical gate is:
8
+#
9
+# 1. AddressSanitizer on the full core suite (near-native speed, catches
10
+# use-after-free / buffer overflow / leaks).
11
+# 2. A targeted Miri run on just memory.rs's unsafe (via its #[cfg(miri)]
12
+# test) β€” covers the only UB-sensitive code path.
13
+#
14
+# Prereqs: rustup nightly with miri, zig not needed. ASan needs
15
+# `-Zbuild-std` (builds std instrumented) because of proc-macro crates
16
+# (time) that break under blanket RUSTFLAGS sanitizer.
17
+#
18
+# Usage:
19
+# bash scripts/sanitize.sh # ASan + targeted miri
20
+# bash scripts/sanitize.sh --asan # ASan only
21
+# bash scripts/sanitize.sh --miri # targeted miri only
22
+
23
+set -euo pipefail
24
+
25
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
26
+cd "$ROOT"
27
+
28
+MODE="${1:-all}"
29
+ASAN_TARGET_DIR="${ASAN_TARGET_DIR:-/tmp/justapi-asan-target}"
30
+MIRI_TARGET_DIR="${MIRI_TARGET_DIR:-/tmp/justapi-miri-target}"
31
+
32
+if [[ "$MODE" == "all" || "$MODE" == "--asan" ]]; then
33
+ echo "[sanitize] AddressSanitizer on justapi-core (lib, features=db)..."
34
+ PATH="$HOME/.cargo/bin:$PATH" \
35
+ CARGO_TARGET_DIR="$ASAN_TARGET_DIR" \
36
+ RUSTFLAGS="-Zsanitizer=address" \
37
+ rustup run nightly cargo test -p justapi-core --lib --features db \
38
+ -Zbuild-std --target x86_64-unknown-linux-gnu 2>&1 | \
39
+ grep -E "test result:|error\[|UndefinedBehavior" | tail -5
40
+ echo "[sanitize] ASan: PASS"
41
+fi
42
+
43
+if [[ "$MODE" == "all" || "$MODE" == "--miri" ]]; then
44
+ echo "[sanitize] Miri on the only unsafe module (memory.rs, targeted)..."
45
+ # The #[cfg(miri)] test in memory.rs exercises the arena's
46
+ # from_utf8_unchecked under stacked-borrows provenance rules.
47
+ PATH="$HOME/.cargo/bin:$PATH" \
48
+ CARGO_TARGET_DIR="$MIRI_TARGET_DIR" \
49
+ MIRIFLAGS="-Zmiri-disable-isolation" \
50
+ rustup run nightly cargo miri test -p justapi-core --lib memory 2>&1 | \
51
+ grep -E "test result:|error|Undefined Behavior" | tail -5
52
+ echo "[sanitize] Miri (targeted): PASS"
53
+fi
54
+
55
+echo "[sanitize] done β€” memory-safety gate green"
0
56
β–Όstream
+47-0
1
+Summary:
2
+ Success rate: 100.00%
3
+ Total: 3002.6978 ms
4
+ Slowest: 19.5828 ms
5
+ Fastest: 0.0114 ms
6
+ Average: 0.1067 ms
7
+ Requests/sec: 369276.9253
8
+
9
+ Total data: 94.90 MiB
10
+ Size/request: 89 B
11
+ Size/sec: 31.60 MiB
12
+
13
+Response time histogram:
14
+ 0.011 ms [1] |
15
+ 1.969 ms [1108753] |β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– β– 
16
+ 3.926 ms [22] |
17
+ 5.883 ms [0] |
18
+ 7.840 ms [0] |
19
+ 9.797 ms [0] |
20
+ 11.754 ms [0] |
21
+ 13.711 ms [0] |
22
+ 15.669 ms [0] |
23
+ 17.626 ms [0] |
24
+ 19.583 ms [15] |
25
+
26
+Response time distribution:
27
+ 10.00% in 0.0281 ms
28
+ 25.00% in 0.0364 ms
29
+ 50.00% in 0.0515 ms
30
+ 75.00% in 0.0929 ms
31
+ 90.00% in 0.3063 ms
32
+ 95.00% in 0.4416 ms
33
+ 99.00% in 0.5670 ms
34
+ 99.90% in 0.8631 ms
35
+ 99.99% in 1.3814 ms
36
+
37
+
38
+Details (average, fastest, slowest):
39
+ DNS+dialup: 7.1526 ms, 0.0427 ms, 19.0758 ms
40
+ DNS-lookup: 0.0071 ms, 0.0008 ms, 0.0214 ms
41
+
42
+Status code distribution:
43
+ [404] 942404 responses
44
+ [200] 166387 responses
45
+
46
+Error distribution:
47
+ [36] aborted due to deadline
0
48