fix: WebSocket scope query/headers/client, OpenAPI operationId/extra, and SPA frontend mounts (ADR-054)
swadhinbiswas committed 3 months ago
be86772
1 parent 6e3634a
commit be86772
8 files changed
+348 -99
Showing 8 changed files+348-99
▼DECISIONS.md
+41-0
| @@ -1864,3 +1864,44 @@ Rust HTTP server. The actionable mitigation is the **hot-path unwrap audit**: | ||
1864 | 1864 | |
1865 | 1865 | **Evidence:** `cargo build --workspace` and all gates green; the core 404 path |
1866 | 1866 | returns 200/404 normally and no longer panics on unmatched routes. |
1867 | + | |
1868 | +## ADR-054 — 2026-07-16 — Fix WebSocket scope, OpenAPI parity, and SPA frontend mounts | |
1869 | + | |
1870 | +**Context:** Two pre-existing pytest failures (`test_websocket.py:: | |
1871 | +test_websocket_scope_and_json`, `test_openapi_parity.py::test_openapi_parity`) | |
1872 | +were investigated and found to be genuine production-grade framework bugs, not | |
1873 | +test issues. A third gap (SPA `frontend()` serving) was discovered while fixing | |
1874 | +the OpenAPI parity test. | |
1875 | + | |
1876 | +**Decision / changes:** | |
1877 | +1. **WebSocket scope completeness.** The Rust WS upgrade path previously passed | |
1878 | + only the request `path` to handlers; the query string, headers, and peer | |
1879 | + address were discarded, so a Starlette-style `scope` exposed an empty | |
1880 | + `query` and `None` client. The `WsHandler` signature changed from | |
1881 | + `Fn(String, WsRead, WsWrite)` to `Fn(WsConnInfo, WsRead, WsWrite)`, where | |
1882 | + `WsConnInfo { path, query_string, headers, client }` is built from the | |
1883 | + upgrade `req` *before* `hyper::upgrade::on(req)` consumes it. The Python | |
1884 | + `WebSocket` scope now reflects the real query/headers/client. The Rust fast | |
1885 | + path (`native=True`) is unaffected. | |
1886 | +2. **OpenAPI parity.** `build_openapi` (Python) emitted the wrong key | |
1887 | + (`operation_id` instead of OpenAPI's `operationId`), silently dropped | |
1888 | + `openapi_extra` extensions, and omitted the default success response when a | |
1889 | + `responses` map was supplied. Fixed to emit `operationId`, deep-merge | |
1890 | + `openapi_extra` (handles the JSON-string form stored by route registration), | |
1891 | + and always include the success code (`200` or `status_code`). | |
1892 | +3. **SPA frontend mounts.** `app.frontend(path, dir, fallback=...)` stored a | |
1893 | + `StaticMount { prefix, dir, fallback }` but `Server::run()` discarded the | |
1894 | + `prefix` and `fallback` (only mounted `dir` at root via `with_static_dir`), | |
1895 | + so `/static/` returned 404. Core `Server` gained `with_static_mounts` and a | |
1896 | + `try_serve_static` helper that honors per-mount prefix + SPA fallback | |
1897 | + (unmatched paths under the prefix serve the fallback file). Added | |
1898 | + `Server::run_on(listener)` so callers can supply a pre-bound listener (avoids | |
1899 | + an ephemeral-port bind race in tests). Added Rust unit tests for | |
1900 | + `StaticMount::resolve` and an integration test for prefix + SPA fallback. | |
1901 | + | |
1902 | +**Evidence:** Both previously-failing pytest tests now pass; full pytest suite | |
1903 | +119 passed / 1 skipped; `cargo test --workspace` green; `cargo clippy | |
1904 | +--workspace --tests -D warnings` clean; `cargo fmt --check` clean. New Rust | |
1905 | +tests: `static_files::tests::test_mount_resolve_prefix`, | |
1906 | +`test_mount_traversal_blocked_through_prefix`, and | |
1907 | +`integration::test_static_mount_prefix_and_spa_fallback`. | |
1867 | 1908 | |
▼PLAN.md
+34-14
| @@ -1099,17 +1099,37 @@ Status of the P0 sprint driven by the MED#9 audit verdict (see BENCHMARKS.md | ||
1099 | 1099 | - [x] **Dead code / CI** (prior session): HTTP/3 removed (ADR-049); fuzz CI |
1100 | 1100 | fixed to run all 7 real targets; orphan root `.rs` removed; `rustfmt.toml` |
1101 | 1101 | stable-only so `cargo fmt --check` passes. |
1102 | -- [ ] **Error-contract unification** (P1): three inconsistent shapes | |
1103 | - (`{"error"}` vs `{"detail"}` vs RFC-7807) should collapse to one. | |
1104 | -- [ ] **Panic isolation** (P1/P2): `panic = "abort"` means one bad request can | |
1105 | - kill the process; a GIL-path `catch_unwind` around handlers + an unwrap | |
1106 | - audit on the hot path (server/ + native/) is still TODO. | |
1107 | - | |
1108 | -- [x] **Error contract** (ADR-052): single `{"detail": ...}` envelope across | |
1109 | - core + Python (Rust validation RFC-7807 + `{"error":...}` collapsed; | |
1110 | - `application/json` everywhere). `error_response`/`validation_response` | |
1111 | - helpers added in `justapi_core::lib.rs`. | |
1112 | -- [x] **Panic model** (ADR-053): kept `panic = "abort"` + supervisor restart | |
1113 | - (catch_unwind at the GIL FFI boundary is UB + ~3x regression per | |
1114 | - `gil_pool.rs`); fixed the per-request `router.fallback().unwrap()` abort | |
1115 | - and audited remaining hot-path unwraps. | |
1102 | + - [x] **Error-contract unification** (P1): single `{"detail": ...}` envelope | |
1103 | + across core + Python (ADR-052); Rust validation RFC-7807 + `{"error":...}` | |
1104 | + collapsed; `application/json` everywhere. `error_response`/ | |
1105 | + `validation_response` helpers added in `justapi_core::lib.rs`. | |
1106 | + - [x] **Panic model** (ADR-053): kept `panic = "abort"` + supervisor restart | |
1107 | + (catch_unwind at the GIL FFI boundary is UB + ~3x regression per | |
1108 | + `gil_pool.rs`); fixed the per-request `router.fallback().unwrap()` abort | |
1109 | + and audited remaining hot-path unwraps. | |
1110 | + | |
1111 | +### Framework bug fixes (post-audit, 2026-07-16) | |
1112 | + | |
1113 | +Two pre-existing test failures (`test_websocket.py::test_websocket_scope_and_json` | |
1114 | +and `test_openapi_parity.py::test_openapi_parity`) were production-grade bugs, | |
1115 | +not test issues: | |
1116 | + | |
1117 | +- [x] **WebSocket scope query/headers/client** (ADR-054): the Rust WS upgrade | |
1118 | + path only passed the request `path` to handlers — query string, headers, | |
1119 | + and peer address were dropped, so a Starlette-style `scope` had an empty | |
1120 | + `query`. `WsHandler` now receives a `WsConnInfo { path, query_string, | |
1121 | + headers, client }` built from the upgrade request before | |
1122 | + `hyper::upgrade::on` consumes it. Python `WebSocket` scope now reflects the | |
1123 | + real query/headers/client. | |
1124 | +- [x] **OpenAPI parity** (ADR-054): `build_openapi` emitted `operation_id` | |
1125 | + (wrong key; OpenAPI requires `operationId`), dropped `openapi_extra` | |
1126 | + extensions, and omitted the default success response when `responses` was | |
1127 | + supplied. Fixed to emit `operationId`, merge `openapi_extra`, and always | |
1128 | + include the success code (`200`/`status_code`). | |
1129 | +- [x] **Frontend SPA mounts** (ADR-054): `app.frontend()` stored per-mount | |
1130 | + `StaticMount { prefix, dir, fallback }` but `run()` discarded the prefix | |
1131 | + and fallback (only mounted the dir root), so `/static/` returned 404. Core | |
1132 | + `Server` now honors `with_static_mounts` (prefix + SPA fallback) via | |
1133 | + `try_serve_static`. Added `run_on(listener)` to avoid an ephemeral-port | |
1134 | + bind race in tests; added Rust integration + unit tests. | |
1135 | + | |
1116 | 1136 | |
▼crates/justapi-core/src/middleware.rs
+5-1
| @@ -1291,7 +1291,11 @@ mod tests { | ||
1291 | 1291 | Box::pin(async { Ok(json_response(StatusCode::OK, "ok")) }) |
1292 | 1292 | }); |
1293 | 1293 | let mut chain = MiddlewareChain::new(handler); |
1294 | - chain.add(Cors::new().allow_origin("https://example.com").expose_headers(&["x-custom-header", "x-trace-id"])); | |
1294 | + chain.add( | |
1295 | + Cors::new() | |
1296 | + .allow_origin("https://example.com") | |
1297 | + .expose_headers(&["x-custom-header", "x-trace-id"]), | |
1298 | + ); | |
1295 | 1299 | let req = test_req_with_header(Method::GET, "/hello", "origin", "https://example.com"); |
1296 | 1300 | let resp = chain.run(req).await.unwrap(); |
1297 | 1301 | assert_eq!(resp.headers()["access-control-expose-headers"], "x-custom-header, x-trace-id"); |
1298 | 1302 | |
▼crates/justapi-core/src/server/mod.rs
+147-60
| @@ -22,7 +22,7 @@ use crate::middleware::{ | ||
22 | 22 | use crate::openapi; |
23 | 23 | use crate::plugin::PluginRegistry; |
24 | 24 | use crate::router::{Match, Router}; |
25 | -use crate::static_files::StaticDir; | |
25 | +use crate::static_files::{StaticDir, StaticMount}; | |
26 | 26 | use crate::{json_response, streaming_response, ResponseBody}; |
27 | 27 | #[cfg(feature = "ws")] |
28 | 28 | use futures::StreamExt; |
| @@ -56,10 +56,28 @@ pub type WsWrite = Box< | ||
56 | 56 | /// A handler invoked when a WebSocket upgrade is accepted on a registered path. |
57 | 57 | /// Receives the request path plus the split read/write halves of the accepted |
58 | 58 | /// WebSocket stream. The handler owns the connection for its lifetime. |
59 | +#[cfg(feature = "ws")] | |
60 | +/// Connection metadata handed to a WebSocket handler on upgrade. Mirrors the | |
61 | +/// subset of the HTTP request a WebSocket scope needs (`path`, decoded query | |
62 | +/// string, raw headers) so handler frameworks can build a Starlette-style | |
63 | +/// `scope` without re-reading the (already upgraded) socket. | |
64 | +#[cfg(feature = "ws")] | |
65 | +#[derive(Debug, Clone)] | |
66 | +pub struct WsConnInfo { | |
67 | + /// Request path the upgrade arrived on. | |
68 | + pub path: String, | |
69 | + /// Raw query string (percent-encoded, without the leading `?`). | |
70 | + pub query_string: Vec<u8>, | |
71 | + /// Raw request headers observed on the upgrade request. | |
72 | + pub headers: Vec<(Vec<u8>, Vec<u8>)>, | |
73 | + /// Remote peer address as `(host, port)`, if known. | |
74 | + pub client: Option<(String, u16)>, | |
75 | +} | |
76 | + | |
59 | 77 | #[cfg(feature = "ws")] |
60 | 78 | pub type WsHandler = std::sync::Arc< |
61 | 79 | dyn Fn( |
62 | - String, | |
80 | + WsConnInfo, | |
63 | 81 | WsRead, |
64 | 82 | WsWrite, |
65 | 83 | ) |
| @@ -77,7 +95,7 @@ use tokio_tungstenite::tungstenite::Message; | ||
77 | 95 | /// replaces this with an application-provided handler. |
78 | 96 | #[cfg(feature = "ws")] |
79 | 97 | fn default_ws_echo() -> WsHandler { |
80 | - std::sync::Arc::new(|_path, mut read, mut write| { | |
98 | + std::sync::Arc::new(|_info, mut read, mut write| { | |
81 | 99 | Box::pin(async move { |
82 | 100 | use futures::{SinkExt, StreamExt}; |
83 | 101 | while let Some(msg) = read.next().await { |
| @@ -275,6 +293,7 @@ pub struct Server { | ||
275 | 293 | addr: SocketAddr, |
276 | 294 | chain: MiddlewareChain, |
277 | 295 | static_dir: Option<StaticDir>, |
296 | + static_mounts: Vec<StaticMount>, | |
278 | 297 | metrics: Metrics, |
279 | 298 | health_registry: Arc<HealthRegistry>, |
280 | 299 | #[cfg(feature = "tls")] |
| @@ -318,6 +337,7 @@ impl Server { | ||
318 | 337 | addr, |
319 | 338 | chain, |
320 | 339 | static_dir: None, |
340 | + static_mounts: Vec::new(), | |
321 | 341 | metrics, |
322 | 342 | health_registry, |
323 | 343 | shutdown: None, |
| @@ -613,6 +633,15 @@ impl Server { | ||
613 | 633 | self |
614 | 634 | } |
615 | 635 | |
636 | + /// Register one or more SPA/frontend mounts. Each mount serves files from | |
637 | + /// its directory under its `prefix`, with an optional SPA `fallback` file | |
638 | + /// (e.g. `index.html`) for unmatched paths. Prefix + fallback are honored | |
639 | + /// per-mount, unlike `with_static_dir` which mounts a single dir at root. | |
640 | + pub fn with_static_mounts(mut self, mounts: Vec<StaticMount>) -> Self { | |
641 | + self.static_mounts = mounts; | |
642 | + self | |
643 | + } | |
644 | + | |
616 | 645 | /// Register a plugin with the server. |
617 | 646 | /// This calls the `build` hook immediately to allow the plugin to modify the Server configuration. |
618 | 647 | pub fn register_plugin(mut self, plugin: Box<dyn crate::plugin::Plugin>) -> Result<Self> { |
| @@ -742,6 +771,24 @@ impl Server { | ||
742 | 771 | } |
743 | 772 | |
744 | 773 | let listener = tokio::net::TcpListener::bind(self.addr).await?; |
774 | + self.run_on(listener).await | |
775 | + } | |
776 | + | |
777 | + /// Run the server on an already-bound listener (e.g. chosen by the caller | |
778 | + /// to avoid a bind race when the address uses an ephemeral port). | |
779 | + pub async fn run_on(mut self, listener: tokio::net::TcpListener) -> Result<()> { | |
780 | + if let Some(router) = self.router.take() { | |
781 | + let pool = Arc::new(BufferPool::new()); | |
782 | + let handler = make_handler( | |
783 | + Arc::new(router), | |
784 | + pool, | |
785 | + self.metrics.clone(), | |
786 | + self.health_registry.clone(), | |
787 | + self.openapi_spec.clone(), | |
788 | + ); | |
789 | + self.chain.set_handler(handler); | |
790 | + } | |
791 | + | |
745 | 792 | let local_addr = listener.local_addr()?; |
746 | 793 | |
747 | 794 | if let (Some(grpc_addr), Some(grpc_handler)) = (self.grpc_addr, self.grpc_handler.take()) { |
| @@ -833,6 +880,7 @@ impl Server { | ||
833 | 880 | listener, |
834 | 881 | chain, |
835 | 882 | static_dir, |
883 | + self.static_mounts.clone(), | |
836 | 884 | metrics, |
837 | 885 | self.shutdown, |
838 | 886 | wasm_middleware, |
| @@ -840,8 +888,16 @@ impl Server { | ||
840 | 888 | ) |
841 | 889 | .await; |
842 | 890 | #[cfg(not(feature = "ws"))] |
843 | - let res = | |
844 | - serve_http(listener, chain, static_dir, metrics, self.shutdown, wasm_middleware).await; | |
891 | + let res = serve_http( | |
892 | + listener, | |
893 | + chain, | |
894 | + static_dir, | |
895 | + self.static_mounts.clone(), | |
896 | + metrics, | |
897 | + self.shutdown, | |
898 | + wasm_middleware, | |
899 | + ) | |
900 | + .await; | |
845 | 901 | plugin_registry.on_shutdown_all().await?; |
846 | 902 | res |
847 | 903 | } |
| @@ -879,9 +935,11 @@ pub async fn serve(listener: TcpListener) -> Result<()> { | ||
879 | 935 | let chain = Arc::new(MiddlewareChain::new(handler)); |
880 | 936 | |
881 | 937 | #[cfg(feature = "ws")] |
882 | - let res = serve_http(listener, chain, None, metrics, None, None, Some(default_ws_echo())).await; | |
938 | + let res = | |
939 | + serve_http(listener, chain, None, Vec::new(), metrics, None, None, Some(default_ws_echo())) | |
940 | + .await; | |
883 | 941 | #[cfg(not(feature = "ws"))] |
884 | - let res = serve_http(listener, chain, None, metrics, None, None).await; | |
942 | + let res = serve_http(listener, chain, None, Vec::new(), metrics, None, None).await; | |
885 | 943 | res |
886 | 944 | } |
887 | 945 | |
| @@ -978,10 +1036,50 @@ fn max_connections() -> usize { | ||
978 | 1036 | .unwrap_or(10_000) |
979 | 1037 | } |
980 | 1038 | |
1039 | +/// Attempt to serve `path` from any registered frontend mount (prefix + | |
1040 | +/// optional SPA fallback), falling back to the legacy single `static_dir`. | |
1041 | +/// Returns `None` if no mount/dir matches a real file. | |
1042 | +async fn try_serve_static( | |
1043 | + path: &str, | |
1044 | + static_dir: &Option<StaticDir>, | |
1045 | + static_mounts: &[StaticMount], | |
1046 | +) -> Option<Response<ResponseBody>> { | |
1047 | + // Prefer per-mount prefix + fallback routing. | |
1048 | + for mount in static_mounts { | |
1049 | + if let Some(file_path) = mount.resolve(path) { | |
1050 | + if tokio::fs::metadata(&file_path).await.map(|m| m.is_file()).unwrap_or(false) { | |
1051 | + return mount.dir.serve_file(&file_path).await.ok(); | |
1052 | + } | |
1053 | + } | |
1054 | + // SPA fallback: unmatched paths under the prefix serve the fallback file. | |
1055 | + if let Some(ref fallback) = mount.fallback { | |
1056 | + let under_prefix = mount.prefix == "/" | |
1057 | + || path == mount.prefix | |
1058 | + || path.starts_with(&format!("{}/", mount.prefix.trim_end_matches('/'))); | |
1059 | + if under_prefix { | |
1060 | + let fb = mount.dir.root().join(fallback.trim_start_matches('/')); | |
1061 | + if tokio::fs::metadata(&fb).await.map(|m| m.is_file()).unwrap_or(false) { | |
1062 | + return mount.dir.serve_file(&fb).await.ok(); | |
1063 | + } | |
1064 | + } | |
1065 | + } | |
1066 | + } | |
1067 | + // Legacy single static dir at root. | |
1068 | + if let Some(ref sd) = static_dir { | |
1069 | + if let Some(file_path) = sd.resolve(path) { | |
1070 | + if tokio::fs::metadata(&file_path).await.map(|m| m.is_file()).unwrap_or(false) { | |
1071 | + return sd.serve_file(&file_path).await.ok(); | |
1072 | + } | |
1073 | + } | |
1074 | + } | |
1075 | + None | |
1076 | +} | |
1077 | + | |
981 | 1078 | async fn serve_http( |
982 | 1079 | listener: TcpListener, |
983 | 1080 | chain: Arc<MiddlewareChain>, |
984 | 1081 | static_dir: Option<StaticDir>, |
1082 | + static_mounts: Vec<StaticMount>, | |
985 | 1083 | metrics: Metrics, |
986 | 1084 | shutdown: Option<CancellationToken>, |
987 | 1085 | wasm_middleware: Option<Arc<crate::wasm::WasmEngine>>, |
| @@ -1009,6 +1107,7 @@ async fn serve_http( | ||
1009 | 1107 | // --- Normal HTTP via hyper ----------------------------------------- |
1010 | 1108 | let chain = chain.clone(); |
1011 | 1109 | let static_dir = static_dir.clone(); |
1110 | + let static_mounts = static_mounts.clone(); | |
1012 | 1111 | let conn_metrics = metrics.clone(); |
1013 | 1112 | conn_metrics.connection_opened(); |
1014 | 1113 | let spawn_metrics = conn_metrics.clone(); |
| @@ -1030,12 +1129,13 @@ async fn serve_http( | ||
1030 | 1129 | .expect("connection semaphore closed"); |
1031 | 1130 | let io = TokioIo::new(stream); |
1032 | 1131 | let arena = Arc::new(SharedArena::new()); |
1033 | - let svc = service_fn(move |mut req| { | |
1034 | - arena.reset(); | |
1035 | - let chain = chain.clone(); | |
1036 | - let arena = arena.clone(); | |
1037 | - let static_dir = static_dir.clone(); | |
1038 | - let metrics = spawn_metrics.clone(); | |
1132 | + let svc = service_fn(move |mut req| { | |
1133 | + arena.reset(); | |
1134 | + let chain = chain.clone(); | |
1135 | + let arena = arena.clone(); | |
1136 | + let static_dir = static_dir.clone(); | |
1137 | + let static_mounts = static_mounts.clone(); | |
1138 | + let metrics = spawn_metrics.clone(); | |
1039 | 1139 | let wasm_middleware = wasm_middleware.clone(); |
1040 | 1140 | #[cfg(feature = "ws")] |
1041 | 1141 | let ws_handler = ws_handler.clone(); |
| @@ -1078,7 +1178,16 @@ async fn serve_http( | ||
1078 | 1178 | res.headers_mut().insert("sec-websocket-accept", hyper::header::HeaderValue::from_str(&accept_key).unwrap()); |
1079 | 1179 | |
1080 | 1180 | let handler = handler.clone(); |
1081 | - let path_clone = path.clone(); | |
1181 | + let ws_info = WsConnInfo { | |
1182 | + path: path.clone(), | |
1183 | + query_string: req.uri().query().unwrap_or("").as_bytes().to_vec(), | |
1184 | + headers: req | |
1185 | + .headers() | |
1186 | + .iter() | |
1187 | + .map(|(k, v)| (k.as_str().as_bytes().to_vec(), v.as_bytes().to_vec())) | |
1188 | + .collect(), | |
1189 | + client: Some((peer_addr.ip().to_string(), peer_addr.port())), | |
1190 | + }; | |
1082 | 1191 | |
1083 | 1192 | tokio::task::spawn(async move { |
1084 | 1193 | match hyper::upgrade::on(&mut req).await { |
| @@ -1090,7 +1199,7 @@ async fn serve_http( | ||
1090 | 1199 | None |
1091 | 1200 | ).await; |
1092 | 1201 | let (write, read) = ws_stream.split(); |
1093 | - dispatch_ws(Box::new(read), Box::new(write), path_clone, &handler).await; | |
1202 | + dispatch_ws(Box::new(read), Box::new(write), ws_info, &handler).await; | |
1094 | 1203 | } |
1095 | 1204 | Err(e) => tracing::error!("WebSocket upgrade error: {}", e), |
1096 | 1205 | } |
| @@ -1172,18 +1281,10 @@ async fn serve_http( | ||
1172 | 1281 | metrics.record_status(status); |
1173 | 1282 | metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0); |
1174 | 1283 | |
1175 | - // If the response is 404 and we have a static dir, try serving files | |
1284 | + // If the response is 404, try serving static files. | |
1176 | 1285 | if status == StatusCode::NOT_FOUND { |
1177 | - if let Some(ref sd) = static_dir { | |
1178 | - if let Some(file_path) = sd.resolve(&path) { | |
1179 | - if tokio::fs::metadata(&file_path) | |
1180 | - .await | |
1181 | - .map(|m| m.is_file()) | |
1182 | - .unwrap_or(false) | |
1183 | - { | |
1184 | - return sd.serve_file(&file_path).await; | |
1185 | - } | |
1186 | - } | |
1286 | + if let Some(resp) = try_serve_static(&path, &static_dir, &static_mounts).await { | |
1287 | + return Ok::<_, anyhow::Error>(resp); | |
1187 | 1288 | } |
1188 | 1289 | } |
1189 | 1290 | Ok(response) |
| @@ -1194,16 +1295,8 @@ async fn serve_http( | ||
1194 | 1295 | metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0); |
1195 | 1296 | |
1196 | 1297 | // Middleware error — try static files |
1197 | - if let Some(ref sd) = static_dir { | |
1198 | - if let Some(file_path) = sd.resolve(&path) { | |
1199 | - if tokio::fs::metadata(&file_path) | |
1200 | - .await | |
1201 | - .map(|m| m.is_file()) | |
1202 | - .unwrap_or(false) | |
1203 | - { | |
1204 | - return sd.serve_file(&file_path).await; | |
1205 | - } | |
1206 | - } | |
1298 | + if let Some(resp) = try_serve_static(&path, &static_dir, &static_mounts).await { | |
1299 | + return Ok::<_, anyhow::Error>(resp); | |
1207 | 1300 | } |
1208 | 1301 | Ok(json_response( |
1209 | 1302 | StatusCode::NOT_FOUND, |
| @@ -1429,8 +1522,8 @@ fn sse_response() -> Response<ResponseBody> { | ||
1429 | 1522 | |
1430 | 1523 | /// Dispatch an accepted WebSocket connection to a registered handler. |
1431 | 1524 | #[cfg(feature = "ws")] |
1432 | -async fn dispatch_ws(read: WsRead, write: WsWrite, path: String, handler: &WsHandler) { | |
1433 | - if let Err(e) = handler(path, read, write).await { | |
1525 | +async fn dispatch_ws(read: WsRead, write: WsWrite, info: WsConnInfo, handler: &WsHandler) { | |
1526 | + if let Err(e) = handler(info, read, write).await { | |
1434 | 1527 | tracing::warn!("WebSocket handler error: {}", e); |
1435 | 1528 | } |
1436 | 1529 | } |
| @@ -1452,6 +1545,7 @@ async fn serve_with_tls( | ||
1452 | 1545 | chain: Arc<MiddlewareChain>, |
1453 | 1546 | config: TlsConfig, |
1454 | 1547 | static_dir: Option<StaticDir>, |
1548 | + static_mounts: Vec<StaticMount>, | |
1455 | 1549 | metrics: Metrics, |
1456 | 1550 | shutdown: Option<CancellationToken>, |
1457 | 1551 | wasm_middleware: Option<Arc<crate::wasm::WasmEngine>>, |
| @@ -1563,7 +1657,16 @@ async fn serve_with_tls( | ||
1563 | 1657 | res.headers_mut().insert("sec-websocket-accept", hyper::header::HeaderValue::from_str(&accept_key).unwrap()); |
1564 | 1658 | |
1565 | 1659 | let handler = handler.clone(); |
1566 | - let path_clone = path.clone(); | |
1660 | + let ws_info = WsConnInfo { | |
1661 | + path: path.clone(), | |
1662 | + query_string: req.uri().query().unwrap_or("").as_bytes().to_vec(), | |
1663 | + headers: req | |
1664 | + .headers() | |
1665 | + .iter() | |
1666 | + .map(|(k, v)| (k.as_str().as_bytes().to_vec(), v.as_bytes().to_vec())) | |
1667 | + .collect(), | |
1668 | + client: Some((peer_addr.ip().to_string(), peer_addr.port())), | |
1669 | + }; | |
1567 | 1670 | |
1568 | 1671 | tokio::task::spawn(async move { |
1569 | 1672 | match hyper::upgrade::on(&mut req).await { |
| @@ -1575,7 +1678,7 @@ async fn serve_with_tls( | ||
1575 | 1678 | None |
1576 | 1679 | ).await; |
1577 | 1680 | let (write, read) = ws_stream.split(); |
1578 | - dispatch_ws(Box::new(read), Box::new(write), path_clone, &handler).await; | |
1681 | + dispatch_ws(Box::new(read), Box::new(write), ws_info, &handler).await; | |
1579 | 1682 | } |
1580 | 1683 | Err(e) => tracing::error!("WebSocket upgrade error: {}", e), |
1581 | 1684 | } |
| @@ -1652,16 +1755,8 @@ async fn serve_with_tls( | ||
1652 | 1755 | metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0); |
1653 | 1756 | |
1654 | 1757 | if status == StatusCode::NOT_FOUND { |
1655 | - if let Some(ref sd) = static_dir { | |
1656 | - if let Some(file_path) = sd.resolve(&path) { | |
1657 | - if tokio::fs::metadata(&file_path) | |
1658 | - .await | |
1659 | - .map(|m| m.is_file()) | |
1660 | - .unwrap_or(false) | |
1661 | - { | |
1662 | - return sd.serve_file(&file_path).await; | |
1663 | - } | |
1664 | - } | |
1758 | + if let Some(resp) = try_serve_static(&path, &static_dir, &static_mounts).await { | |
1759 | + return Ok::<_, anyhow::Error>(resp); | |
1665 | 1760 | } |
1666 | 1761 | } |
1667 | 1762 | Ok(response) |
| @@ -1670,16 +1765,8 @@ async fn serve_with_tls( | ||
1670 | 1765 | metrics.record_status(StatusCode::NOT_FOUND); |
1671 | 1766 | metrics.record_latency(start.elapsed().as_secs_f64() * 1000.0); |
1672 | 1767 | |
1673 | - if let Some(ref sd) = static_dir { | |
1674 | - if let Some(file_path) = sd.resolve(&path) { | |
1675 | - if tokio::fs::metadata(&file_path) | |
1676 | - .await | |
1677 | - .map(|m| m.is_file()) | |
1678 | - .unwrap_or(false) | |
1679 | - { | |
1680 | - return sd.serve_file(&file_path).await; | |
1681 | - } | |
1682 | - } | |
1768 | + if let Some(resp) = try_serve_static(&path, &static_dir, &static_mounts).await { | |
1769 | + return Ok::<_, anyhow::Error>(resp); | |
1683 | 1770 | } |
1684 | 1771 | Ok(json_response( |
1685 | 1772 | StatusCode::NOT_FOUND, |
1686 | 1773 | |
▼crates/justapi-core/src/static_files.rs
+34-0
| @@ -374,6 +374,40 @@ mod tests { | ||
374 | 374 | assert_eq!(dir.resolve(""), None); |
375 | 375 | } |
376 | 376 | |
377 | + #[test] | |
378 | + fn test_mount_resolve_prefix() { | |
379 | + let mount = StaticMount { | |
380 | + prefix: "/static".to_string(), | |
381 | + dir: StaticDir::new("/srv/app"), | |
382 | + fallback: Some("index.html".to_string()), | |
383 | + }; | |
384 | + // Path under prefix resolves relative to the mount root. | |
385 | + assert_eq!(mount.resolve("/static/index.html"), Some(PathBuf::from("/srv/app/index.html"))); | |
386 | + assert_eq!( | |
387 | + mount.resolve("/static/css/style.css"), | |
388 | + Some(PathBuf::from("/srv/app/css/style.css")) | |
389 | + ); | |
390 | + // Path not under prefix does not resolve. | |
391 | + assert_eq!(mount.resolve("/api/users"), None); | |
392 | + // Root prefix mount serves everything. | |
393 | + let root = StaticMount { | |
394 | + prefix: "/".to_string(), | |
395 | + dir: StaticDir::new("/srv/root"), | |
396 | + fallback: None, | |
397 | + }; | |
398 | + assert_eq!(root.resolve("/favicon.ico"), Some(PathBuf::from("/srv/root/favicon.ico"))); | |
399 | + } | |
400 | + | |
401 | + #[test] | |
402 | + fn test_mount_traversal_blocked_through_prefix() { | |
403 | + let mount = StaticMount { | |
404 | + prefix: "/static".to_string(), | |
405 | + dir: StaticDir::new("/srv/app"), | |
406 | + fallback: None, | |
407 | + }; | |
408 | + assert_eq!(mount.resolve("/static/../../etc/passwd"), None); | |
409 | + } | |
410 | + | |
377 | 411 | #[test] |
378 | 412 | fn test_guess_content_type() { |
379 | 413 | assert_eq!(guess_content_type(Path::new("index.html")), "text/html; charset=utf-8"); |
380 | 414 | |
▼crates/justapi-core/tests/integration.rs
+51-0
| @@ -5,6 +5,8 @@ use http_body_util::BodyExt; | ||
5 | 5 | use hyper::{Method, StatusCode}; |
6 | 6 | use hyper_util::rt::TokioIo; |
7 | 7 | use justapi_core::serve; |
8 | +use justapi_core::server::Server; | |
9 | +use justapi_core::static_files::{StaticDir, StaticMount}; | |
8 | 10 | use tokio::net::{TcpListener, TcpStream}; |
9 | 11 | |
10 | 12 | async fn send_request( |
| @@ -194,3 +196,52 @@ async fn test_loopback_websocket() { | ||
194 | 196 | let msg = ws.next().await.unwrap().unwrap(); |
195 | 197 | assert_eq!(msg, Message::Text("hello".into())); |
196 | 198 | } |
199 | + | |
200 | +/// Helper to start a server with one static/frontend mount (prefix + SPA | |
201 | +/// fallback) and the default router (404 for unknown paths, so the static | |
202 | +/// fallback engages). | |
203 | +async fn start_server_with_static(root: &std::path::Path, prefix: &str) -> SocketAddr { | |
204 | + let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).await.unwrap(); | |
205 | + let addr = listener.local_addr().unwrap(); | |
206 | + let mounts = vec![StaticMount { | |
207 | + prefix: prefix.to_string(), | |
208 | + dir: StaticDir::new(root), | |
209 | + fallback: Some("index.html".to_string()), | |
210 | + }]; | |
211 | + tokio::spawn(async move { | |
212 | + Server::new(addr) | |
213 | + .with_default_routes() | |
214 | + .with_static_mounts(mounts) | |
215 | + .run_on(listener) | |
216 | + .await | |
217 | + .unwrap(); | |
218 | + }); | |
219 | + tokio::time::sleep(std::time::Duration::from_millis(200)).await; | |
220 | + addr | |
221 | +} | |
222 | + | |
223 | +#[tokio::test] | |
224 | +async fn test_static_mount_prefix_and_spa_fallback() { | |
225 | + let tmp = std::env::temp_dir().join(format!("justapi_static_test_{}", std::process::id())); | |
226 | + let _ = std::fs::create_dir_all(tmp.join("css")); | |
227 | + std::fs::write(tmp.join("index.html"), "<html>SPA</html>").unwrap(); | |
228 | + std::fs::write(tmp.join("css/style.css"), "body{}").unwrap(); | |
229 | + | |
230 | + let addr = start_server_with_static(&tmp, "/static").await; | |
231 | + | |
232 | + // Exact file under the mount prefix is served. | |
233 | + let (status, body) = send_request(addr, Method::GET, "/static/css/style.css", None).await; | |
234 | + assert_eq!(status, StatusCode::OK); | |
235 | + assert_eq!(&body[..], b"body{}"); | |
236 | + | |
237 | + // SPA fallback: unknown path under the prefix serves index.html. | |
238 | + let (status, body) = send_request(addr, Method::GET, "/static/deep/route", None).await; | |
239 | + assert_eq!(status, StatusCode::OK); | |
240 | + assert_eq!(&body[..], b"<html>SPA</html>"); | |
241 | + | |
242 | + // Path not under the mount prefix is NOT served by the mount (404). | |
243 | + let (status, _) = send_request(addr, Method::GET, "/elsewhere/file", None).await; | |
244 | + assert_eq!(status, StatusCode::NOT_FOUND); | |
245 | + | |
246 | + let _ = std::fs::remove_dir_all(&tmp); | |
247 | +} | |
197 | 248 | |
▼crates/justapi-py/python/justapi/system.py
+20-4
| @@ -121,6 +121,7 @@ def _build_descriptor(route): | ||
121 | 121 | "operation_id": route.get("operation_id"), |
122 | 122 | "responses": route.get("responses"), |
123 | 123 | "experimental": bool(route.get("experimental")), |
124 | + "openapi_extra": route.get("openapi_extra"), | |
124 | 125 | } |
125 | 126 | params = [] |
126 | 127 | return_ann = None |
| @@ -240,9 +241,16 @@ def build_openapi(app): | ||
240 | 241 | if d.get("tags"): |
241 | 242 | op["tags"] = d["tags"] |
242 | 243 | if d.get("operation_id"): |
243 | - op["operation_id"] = d["operation_id"] | |
244 | + op["operationId"] = d["operation_id"] | |
244 | 245 | if d.get("deprecated"): |
245 | 246 | op["deprecated"] = True |
247 | + if d.get("openapi_extra"): | |
248 | + try: | |
249 | + extra = json.loads(d["openapi_extra"]) if isinstance(d["openapi_extra"], str) else d["openapi_extra"] | |
250 | + if isinstance(extra, dict): | |
251 | + op.update(extra) | |
252 | + except (ValueError, TypeError): | |
253 | + pass | |
246 | 254 | parameters = [] |
247 | 255 | request_body = None |
248 | 256 | for p in d["parameters"]: |
| @@ -281,9 +289,17 @@ def build_openapi(app): | ||
281 | 289 | op["parameters"] = parameters |
282 | 290 | if request_body: |
283 | 291 | op["requestBody"] = request_body |
284 | - op["responses"] = d.get("responses") or { | |
285 | - str(d.get("status_code") or 200): {"description": "Successful Response"} | |
286 | - } | |
292 | + success_code = str(d.get("status_code") or 200) | |
293 | + raw_responses = d.get("responses") | |
294 | + if isinstance(raw_responses, str): | |
295 | + try: | |
296 | + raw_responses = json.loads(raw_responses) | |
297 | + except (ValueError, TypeError): | |
298 | + raw_responses = None | |
299 | + responses = dict(raw_responses or {}) | |
300 | + if success_code not in responses: | |
301 | + responses[success_code] = {"description": "Successful Response"} | |
302 | + op["responses"] = responses | |
287 | 303 | paths.setdefault(path, {})[method] = op |
288 | 304 | return { |
289 | 305 | "openapi": "3.1.0", |
290 | 306 | |
▼crates/justapi-py/src/native/app.rs
+16-20
| @@ -1572,12 +1572,8 @@ impl JustAPIApp { | ||
1572 | 1572 | server = server.add_gateway(gateway_state); |
1573 | 1573 | } |
1574 | 1574 | |
1575 | - for mount in &frontend_mounts { | |
1576 | - // NOTE: core's static API currently supports a single | |
1577 | - // directory mount (`with_static_dir`); per-mount prefix and | |
1578 | - // fallback routing from `frontend_mounts` is not yet wired | |
1579 | - // through. This keeps the last mount as the served dir. | |
1580 | - server = server.with_static_dir(mount.dir.root()); | |
1575 | + if !frontend_mounts.is_empty() { | |
1576 | + server = server.with_static_mounts(frontend_mounts.clone()); | |
1581 | 1577 | } |
1582 | 1578 | |
1583 | 1579 | if let (Some(gaddr), true) = (grpc_addr, !grpc_handlers.is_empty()) { |
| @@ -1619,7 +1615,7 @@ impl JustAPIApp { | ||
1619 | 1615 | // accepted tokio-tungstenite stream to a Python handler. |
1620 | 1616 | if !ws_routes.is_empty() { |
1621 | 1617 | let ws_routes_arc = Arc::new(ws_routes); |
1622 | - let ws_handler: justapi_core::server::WsHandler = Arc::new(move |path, mut read, mut write| { | |
1618 | + let ws_handler: justapi_core::server::WsHandler = Arc::new(move |info, mut read, mut write| { | |
1623 | 1619 | let ws_routes = ws_routes_arc.clone(); |
1624 | 1620 | let app_py_ws = app_py_ws.clone(); |
1625 | 1621 | Box::pin(async move { |
| @@ -1686,19 +1682,19 @@ impl JustAPIApp { | ||
1686 | 1682 | |
1687 | 1683 | // Invoke the Python handler on the daemon event loop. |
1688 | 1684 | Python::attach(|py| { |
1689 | - if let Some(h) = ws_routes.get(&path) { | |
1690 | - let conn = Conn { | |
1691 | - method: "GET".to_string(), | |
1692 | - path: path.clone(), | |
1693 | - path_params_raw: vec![], | |
1694 | - query_string_raw: vec![], | |
1695 | - headers_raw: vec![], | |
1696 | - scheme: "ws".to_string(), | |
1697 | - client: None, | |
1698 | - app: app_py_ws.as_ref().as_ref().map(|a| a.clone_ref(py)), | |
1699 | - http_version: "1.1".to_string(), | |
1700 | - state: PyDict::new(py).unbind(), | |
1701 | - }; | |
1685 | + if let Some(h) = ws_routes.get(&info.path) { | |
1686 | + let conn = Conn { | |
1687 | + method: "GET".to_string(), | |
1688 | + path: info.path.clone(), | |
1689 | + path_params_raw: vec![], | |
1690 | + query_string_raw: info.query_string.clone(), | |
1691 | + headers_raw: info.headers.clone(), | |
1692 | + scheme: "ws".to_string(), | |
1693 | + client: info.client.clone(), | |
1694 | + app: app_py_ws.as_ref().as_ref().map(|a| a.clone_ref(py)), | |
1695 | + http_version: "1.1".to_string(), | |
1696 | + state: PyDict::new(py).unbind(), | |
1697 | + }; | |
1702 | 1698 | let rust_ws = match Py::new( |
1703 | 1699 | py, |
1704 | 1700 | WebSocket::new(incoming, out_tx, conn), |
1705 | 1701 | |