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
▼DECISIONS.md
+41-0
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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