p1: add built-in GraphQL execution and hardened schema validation
swadhinbiswas committed 3 months ago
7438445
1 parent 7ffe1c0
commit 7438445
6 files changed
+359 -28
Showing 6 changed files+359-28
▼Cargo.toml
+4-0
| @@ -47,6 +47,10 @@ serde_json = "1" | ||
47 | 47 | serde_urlencoded = "0.7" |
48 | 48 | simd-json = { version = "0.14", features = ["serde_impl"] } |
49 | 49 | quick-xml = { version = "0.36", features = ["serialize"] } |
50 | +# Validation: Rust-native JSON Schema. `default-features = false` drops the | |
51 | +# `$ref` resolver's network/file backends (resolve-http/resolve-file/tls) | |
52 | +# we don't need — drafts + the `format` keyword are compiled in by | |
53 | +# default in 0.46 (auto-detected per schema) and enforced at runtime. | |
50 | 54 | jsonschema = { version = "0.46", default-features = false } |
51 | 55 | |
52 | 56 | # Observability |
53 | 57 | |
▼crates/justapi-core/src/db/pool.rs
+196-19
| @@ -91,14 +91,45 @@ impl IsolationLevel { | ||
91 | 91 | /// so existing `sqlite://...` configs (used by the Python `app.set_database`) |
92 | 92 | /// keep working. |
93 | 93 | fn normalize_db_url(url: &str) -> String { |
94 | - if url.starts_with("sqlite://") { | |
95 | - // `sqlx::Any` wants `sqlite:<path>` (single colon). Replace the `//`: | |
96 | - // `sqlite://:memory:` => `sqlite::memory:`, `sqlite://./x` => `sqlite:./x`, | |
97 | - // `sqlite:///abs/x` => `sqlite:/abs/x`. Already-single-colon URLs | |
98 | - // (`sqlite:./x`) don't match and pass through unchanged. | |
99 | - url.replacen("sqlite://", "sqlite:", 1) | |
94 | + let Some(rest) = url.strip_prefix("sqlite://") else { | |
95 | + return url.to_string(); | |
96 | + }; | |
97 | + if let Some(abs) = rest.strip_prefix("//") { | |
98 | + // `sqlite:////abs/path.db` -> `sqlite:/abs/path.db` (absolute) | |
99 | + format!("sqlite:/{abs}") | |
100 | + } else if rest.starts_with('/') { | |
101 | + // 3-slash form `sqlite:///path`. The remainder `rest` begins with `/`. | |
102 | + // We cannot reliably tell "relative `shop.db`" from "absolute `/abs`" | |
103 | + // from the slash count alone (both yield a `/`-prefixed remainder), so we | |
104 | + // use the filesystem notion of absolute: if `rest` is an absolute path, | |
105 | + // keep it absolute; otherwise it is a relative path — but `sqlite:rel` | |
106 | + // (bare relative) is not reliably resolved by sqlx's Any driver, so we | |
107 | + // join against cwd and emit an absolute `sqlite:/abs/rel.db` path. | |
108 | + if std::path::Path::new(rest).is_absolute() { | |
109 | + format!("sqlite:{rest}") | |
110 | + } else { | |
111 | + let rel = rest.strip_prefix('/').unwrap_or(rest); | |
112 | + let joined = std::env::current_dir() | |
113 | + .ok() | |
114 | + .map(|c| c.join(rel)) | |
115 | + .unwrap_or_else(|| std::path::PathBuf::from(rel)); | |
116 | + format!("sqlite:{}", joined.display()) | |
117 | + } | |
100 | 118 | } else { |
101 | - url.to_string() | |
119 | + // `sqlite://:memory:`, `sqlite://./explicit-relative.db`, or a bare | |
120 | + // relative name (`sqlite://foo.db`). For relative paths (not absolute, | |
121 | + // not `:memory:`, not already-relative `./x`) join against cwd so sqlx's | |
122 | + // Any driver can open them. | |
123 | + if rest == ":memory:" || rest.starts_with("./") || std::path::Path::new(rest).is_absolute() | |
124 | + { | |
125 | + format!("sqlite:{rest}") | |
126 | + } else { | |
127 | + let joined = std::env::current_dir() | |
128 | + .ok() | |
129 | + .map(|c| c.join(rest)) | |
130 | + .unwrap_or_else(|| std::path::PathBuf::from(rest)); | |
131 | + format!("sqlite:{}", joined.display()) | |
132 | + } | |
102 | 133 | } |
103 | 134 | } |
104 | 135 | |
| @@ -119,6 +150,14 @@ pub struct DatabaseConfig { | ||
119 | 150 | /// Maximum time to wait for a connection from the pool (acquire timeout). |
120 | 151 | /// `None` uses sqlx's default (no timeout). |
121 | 152 | pub acquire_timeout: Option<Duration>, |
153 | + /// Fast-fail window for **per-request** connection acquires. When the pool | |
154 | + /// is saturated (all connections busy), a request that cannot obtain a | |
155 | + /// connection within this window fails immediately with `503 Service | |
156 | + /// Unavailable` (backpressure) instead of hanging until `acquire_timeout` | |
157 | + /// elapses and then erroring. Defaults to 3s. If unset, falls back to | |
158 | + /// `acquire_timeout`, then sqlx's default. This is the primary saturation | |
159 | + /// knob for request-facing pools (see ADR-075). | |
160 | + pub request_acquire_timeout: Option<Duration>, | |
122 | 161 | /// Maximum idle time before a connection is closed and recycled. |
123 | 162 | pub idle_timeout: Option<Duration>, |
124 | 163 | /// Maximum lifetime of a connection before it is forcibly closed. |
| @@ -140,6 +179,7 @@ impl Default for DatabaseConfig { | ||
140 | 179 | init_sql: None, |
141 | 180 | pragmas: None, |
142 | 181 | acquire_timeout: Some(Duration::from_secs(30)), |
182 | + request_acquire_timeout: Some(Duration::from_secs(3)), | |
143 | 183 | idle_timeout: None, |
144 | 184 | max_lifetime: Some(Duration::from_secs(1800)), |
145 | 185 | health_check_interval: None, |
| @@ -205,6 +245,31 @@ impl Param { | ||
205 | 245 | } |
206 | 246 | } |
207 | 247 | |
248 | +/// Result of acquiring a connection for a **request**, distinguishing a | |
249 | +/// saturated pool (fast-fail to `503 Service Unavailable`) from a genuine | |
250 | +/// backend failure (map to `500`). | |
251 | +#[derive(Debug)] | |
252 | +pub enum DbAcquireError { | |
253 | + /// No connection became free within the request acquire window — the pool | |
254 | + /// is saturated and the request should be rejected with backpressure | |
255 | + /// (`503`) rather than hanging. | |
256 | + TimedOut, | |
257 | + /// The pool is closed and no longer accepts connections. | |
258 | + PoolClosed, | |
259 | + /// Any other SQLx error (auth failure, broken connection, bad SQL, ...). | |
260 | + Other(sqlx::Error), | |
261 | +} | |
262 | + | |
263 | +impl From<sqlx::Error> for DbAcquireError { | |
264 | + fn from(e: sqlx::Error) -> Self { | |
265 | + match e { | |
266 | + sqlx::Error::PoolTimedOut => DbAcquireError::TimedOut, | |
267 | + sqlx::Error::PoolClosed => DbAcquireError::PoolClosed, | |
268 | + other => DbAcquireError::Other(other), | |
269 | + } | |
270 | + } | |
271 | +} | |
272 | + | |
208 | 273 | /// A ready-to-use database pool (any engine). |
209 | 274 | /// |
210 | 275 | /// Wraps a type-erased `sqlx::AnyPool` plus the engine [`DbKind`] (needed to pick |
| @@ -217,6 +282,7 @@ pub struct AnyPool { | ||
217 | 282 | inner: sqlx::AnyPool, |
218 | 283 | kind: DbKind, |
219 | 284 | default_isolation: Option<IsolationLevel>, |
285 | + request_acquire_timeout: Option<Duration>, | |
220 | 286 | } |
221 | 287 | |
222 | 288 | impl AnyPool { |
| @@ -239,25 +305,54 @@ impl AnyPool { | ||
239 | 305 | let kind = config.kind.unwrap_or_else(|| DbKind::from_url(&config.url)); |
240 | 306 | sqlx::any::install_default_drivers(); |
241 | 307 | let connect_url = normalize_db_url(&config.url); |
242 | - let mut opts = AnyPoolOptions::new().max_connections(config.max_connections); | |
243 | - if let Some(t) = config.acquire_timeout { | |
244 | - opts = opts.acquire_timeout(t); | |
308 | + // sqlx's SQLite (Any) driver will not auto-create the database file for | |
309 | + // this URL form — opening a non-existent path fails with | |
310 | + // `SQLITE_CANTOPEN` (code 14). For file-backed SQLite, ensure the parent | |
311 | + // directory exists and the file exists before opening (`:memory:` and | |
312 | + // other engines are left untouched). | |
313 | + if kind == DbKind::Sqlite && !connect_url.contains(":memory:") { | |
314 | + if let Some(path) = connect_url.strip_prefix("sqlite:") { | |
315 | + let db_path = std::path::Path::new(path); | |
316 | + if let Some(parent) = db_path.parent() { | |
317 | + if !parent.as_os_str().is_empty() && !parent.exists() { | |
318 | + let _ = std::fs::create_dir_all(parent); | |
319 | + } | |
320 | + } | |
321 | + if !db_path.exists() { | |
322 | + if let Ok(f) = std::fs::File::create(db_path) { | |
323 | + let _ = f.sync_all(); | |
324 | + } | |
325 | + } | |
326 | + } | |
245 | 327 | } |
328 | + let mut opts = AnyPoolOptions::new().max_connections(config.max_connections); | |
329 | + // Saturation backpressure: the per-request acquire window drives how long | |
330 | + // a request waits for a free connection before failing fast with a 503. | |
331 | + let acquire = config | |
332 | + .request_acquire_timeout | |
333 | + .or(config.acquire_timeout) | |
334 | + .unwrap_or_else(|| Duration::from_secs(3)); | |
335 | + opts = opts.acquire_timeout(acquire); | |
246 | 336 | if let Some(t) = config.idle_timeout { |
247 | 337 | opts = opts.idle_timeout(t); |
248 | 338 | } |
249 | 339 | if let Some(t) = config.max_lifetime { |
250 | 340 | opts = opts.max_lifetime(t); |
251 | 341 | } |
252 | - if let (DbKind::Sqlite, Some(pragmas)) = (kind, config.pragmas) { | |
253 | - // Always set a `busy_timeout` first so that lock contention (e.g. while | |
254 | - // `journal_mode=WAL` takes an exclusive lock during pool warmup) | |
255 | - // waits and retries instead of hanging the connection-open forever. | |
256 | - // Without this, `PRAGMA journal_mode=WAL` can deadlock when several | |
257 | - // pool connections are opened concurrently (see DECISIONS.md ADR-068, | |
258 | - // fixes D2). | |
342 | + if kind == DbKind::Sqlite { | |
343 | + // SQLite gets zero concurrency protection from stock defaults: a | |
344 | + // rollback journal (one writer blocks every reader/writer) and | |
345 | + // `busy_timeout=0` (an immediate `SQLITE_BUSY` on any lock | |
346 | + // contention, no wait). Under a multi-connection pool that produces | |
347 | + // constant `SQLITE_BUSY` errors and ~1000x throughput collapse | |
348 | + // (see PRODUCTION_PLAN.md P0.2 / stress-test D3). So busy_timeout + | |
349 | + // WAL + synchronous=NORMAL are applied *unconditionally* for SQLite; | |
350 | + // caller-supplied `pragmas` are appended after and may override | |
351 | + // (e.g. `wal=True` from the Python bridge, or a custom journal mode). | |
259 | 352 | let mut all = vec!["PRAGMA busy_timeout=5000".to_string()]; |
260 | - all.extend(pragmas.iter().cloned()); | |
353 | + all.extend(config.pragmas.clone().unwrap_or_else(|| { | |
354 | + vec!["journal_mode=WAL".to_string(), "synchronous=NORMAL".to_string()] | |
355 | + })); | |
261 | 356 | opts = opts.after_connect(move |conn, _meta| { |
262 | 357 | let pragmas = all.clone(); |
263 | 358 | Box::pin(async move { |
| @@ -273,7 +368,12 @@ impl AnyPool { | ||
273 | 368 | }); |
274 | 369 | } |
275 | 370 | let inner = opts.connect(&connect_url).await?; |
276 | - Ok(Self { inner, kind, default_isolation: config.default_isolation }) | |
371 | + Ok(Self { | |
372 | + inner, | |
373 | + kind, | |
374 | + default_isolation: config.default_isolation, | |
375 | + request_acquire_timeout: config.request_acquire_timeout, | |
376 | + }) | |
277 | 377 | } |
278 | 378 | |
279 | 379 | pub fn kind(&self) -> DbKind { |
| @@ -305,6 +405,12 @@ impl AnyPool { | ||
305 | 405 | self.default_isolation |
306 | 406 | } |
307 | 407 | |
408 | + /// The per-request acquire window (fast-fail threshold under saturation). | |
409 | + /// `None` means sqlx's default is in effect. | |
410 | + pub fn request_acquire_timeout(&self) -> Option<Duration> { | |
411 | + self.request_acquire_timeout | |
412 | + } | |
413 | + | |
308 | 414 | /// Access the underlying type-erased pool. |
309 | 415 | pub fn as_any(&self) -> &sqlx::AnyPool { |
310 | 416 | &self.inner |
| @@ -335,6 +441,18 @@ impl AnyPool { | ||
335 | 441 | self.begin_with(self.default_isolation).await |
336 | 442 | } |
337 | 443 | |
444 | + /// Begin a transaction for a **request**, failing fast when the pool is | |
445 | + /// saturated. Returns [`DbAcquireError::TimedOut`]/`PoolClosed` so callers | |
446 | + /// can respond `503` with backpressure instead of hanging until the pool's | |
447 | + /// acquire timeout elapses. The actual wait window is the pool's configured | |
448 | + /// `acquire_timeout` (driven by `request_acquire_timeout`). | |
449 | + pub async fn begin_request( | |
450 | + &self, | |
451 | + ) -> Result<sqlx::Transaction<'static, sqlx::Any>, DbAcquireError> { | |
452 | + let tx = self.inner.begin().await?; | |
453 | + Ok(tx) | |
454 | + } | |
455 | + | |
338 | 456 | /// Begin a transaction with an explicit isolation level. If the engine does |
339 | 457 | /// not support the requested level (e.g. non-SERIALIZABLE on SQLite), it |
340 | 458 | /// falls back to the engine default rather than erroring. |
| @@ -680,6 +798,7 @@ fn config( | ||
680 | 798 | init_sql: None, |
681 | 799 | pragmas, |
682 | 800 | acquire_timeout: Some(std::time::Duration::from_secs(30)), |
801 | + request_acquire_timeout: Some(std::time::Duration::from_secs(3)), | |
683 | 802 | idle_timeout: None, |
684 | 803 | max_lifetime: Some(std::time::Duration::from_secs(1800)), |
685 | 804 | health_check_interval: None, |
| @@ -771,6 +890,64 @@ mod tests { | ||
771 | 890 | assert_eq!(DbKind::from_url("mariadb://localhost/db"), DbKind::MySql); |
772 | 891 | } |
773 | 892 | |
893 | + #[test] | |
894 | + fn test_normalize_db_url_sqlite_paths() { | |
895 | + // Relative path with no leading slash (2-slash form) is joined against | |
896 | + // cwd and emitted absolute. | |
897 | + let rel = normalize_db_url("sqlite://foo.db"); | |
898 | + assert!(rel.starts_with("sqlite:/"), "relative path must be absolute-joined, got {rel}"); | |
899 | + assert!(rel.ends_with("/foo.db"), "got {rel}"); | |
900 | + // An absolute path passed as `sqlite:///abs/...` (3 slashes, as the | |
901 | + // Python bridge does with `sqlite://{abs_path}`) must stay absolute and | |
902 | + // NOT get cwd-prefixed. | |
903 | + let abs_as_three = normalize_db_url("sqlite:///home/u/app.db"); | |
904 | + assert_eq!(abs_as_three, "sqlite:/home/u/app.db"); | |
905 | + // Absolute paths (4 slashes) are preserved absolute. | |
906 | + assert_eq!(normalize_db_url("sqlite:////tmp/foo.db"), "sqlite:/tmp/foo.db"); | |
907 | + // In-memory and explicit-relative forms are preserved. | |
908 | + assert_eq!(normalize_db_url("sqlite://:memory:"), "sqlite::memory:"); | |
909 | + assert_eq!(normalize_db_url("sqlite://./x.db"), "sqlite:./x.db"); | |
910 | + // Non-SQLite URLs are untouched. | |
911 | + assert_eq!(normalize_db_url("postgres://localhost/db"), "postgres://localhost/db"); | |
912 | + } | |
913 | + | |
914 | + #[tokio::test] | |
915 | + async fn test_sqlite_connect_relative_and_absolute_file() { | |
916 | + // Previously both of these failed with SQLITE_CANTOPEN (code 14) because | |
917 | + // normalize_db_url redirected them to the filesystem root. See | |
918 | + // PRODUCTION_PLAN.md P0.1 (BUG-2a). | |
919 | + let dir = std::env::temp_dir(); | |
920 | + let rel_name = format!("ja_norm_rel_{}.db", std::process::id()); | |
921 | + let abs_name = format!("ja_norm_abs_{}.db", std::process::id()); | |
922 | + let rel_path = dir.join(&rel_name); | |
923 | + let abs_path = dir.join(&abs_name); | |
924 | + let _ = std::fs::remove_file(&rel_path); | |
925 | + let _ = std::fs::remove_file(&abs_path); | |
926 | + | |
927 | + let rel_url = format!("sqlite://{}", rel_name); | |
928 | + // Absolute convention is `sqlite:////abs/path` (4 slashes); `abs_path` | |
929 | + // already begins with `/`, so strip it to avoid a 5th slash. | |
930 | + let abs_url = | |
931 | + format!("sqlite:////{}", abs_path.display().to_string().trim_start_matches('/')); | |
932 | + eprintln!("REL normalized = {}", normalize_db_url(&rel_url)); | |
933 | + eprintln!("ABS normalized = {}", normalize_db_url(&abs_url)); | |
934 | + | |
935 | + let rel_pool = AnyPool::connect(config(&rel_url, 2, DbKind::Sqlite, None)) | |
936 | + .await | |
937 | + .expect("relative sqlite file should connect"); | |
938 | + rel_pool.execute("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY)").await.unwrap(); | |
939 | + assert_eq!(rel_pool.query_single_i64("SELECT 1").await.unwrap(), 1); | |
940 | + | |
941 | + let abs_pool = AnyPool::connect(config(&abs_url, 2, DbKind::Sqlite, None)) | |
942 | + .await | |
943 | + .expect("absolute sqlite file should connect"); | |
944 | + abs_pool.execute("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY)").await.unwrap(); | |
945 | + assert_eq!(abs_pool.query_single_i64("SELECT 1").await.unwrap(), 1); | |
946 | + | |
947 | + let _ = std::fs::remove_file(&rel_path); | |
948 | + let _ = std::fs::remove_file(&abs_path); | |
949 | + } | |
950 | + | |
774 | 951 | #[test] |
775 | 952 | fn test_config_default() { |
776 | 953 | let cfg = DatabaseConfig::default(); |
777 | 954 | |
▼crates/justapi-core/src/graphql.rs
+13-0
| @@ -73,3 +73,16 @@ pub async fn handle_graphql( | ||
73 | 73 | .header("content-type", "application/json") |
74 | 74 | .body(body)?) |
75 | 75 | } |
76 | + | |
77 | +pub fn graphiql_html() -> String { | |
78 | + async_graphql::http::GraphiQLSource::build().endpoint("/graphql").finish() | |
79 | +} | |
80 | + | |
81 | +pub async fn execute_graphql_bytes( | |
82 | + schema: &AppSchema, | |
83 | + body_bytes: &[u8], | |
84 | +) -> anyhow::Result<String> { | |
85 | + let gql_req: async_graphql::Request = serde_json::from_slice(body_bytes)?; | |
86 | + let res = schema.execute(gql_req).await; | |
87 | + Ok(serde_json::to_string(&res)?) | |
88 | +} | |
76 | 89 | |
▼crates/justapi-core/src/lib.rs
+28-0
| @@ -86,6 +86,34 @@ pub fn validation_response(detail: &str) -> Response<ResponseBody> { | ||
86 | 86 | error_response(hyper::StatusCode::UNPROCESSABLE_ENTITY, detail) |
87 | 87 | } |
88 | 88 | |
89 | +/// 503 Service Unavailable with a `Retry-After` hint, using the canonical | |
90 | +/// `{"detail": ...}` envelope. Used for backpressure signals — e.g. a saturated | |
91 | +/// DB connection pool that cannot serve a request within its acquire window. | |
92 | +pub fn service_unavailable_response(detail: &str) -> Response<ResponseBody> { | |
93 | + Response::builder() | |
94 | + .status(hyper::StatusCode::SERVICE_UNAVAILABLE) | |
95 | + .header("content-type", "application/json") | |
96 | + .header("retry-after", "1") | |
97 | + .body(UnsyncBoxBody::new( | |
98 | + Full::new(Bytes::from(serde_json::json!({ "detail": detail }).to_string())) | |
99 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
100 | + )) | |
101 | + .unwrap() | |
102 | +} | |
103 | + | |
104 | +/// Map a SQLx error from a request-path DB operation to the right status. | |
105 | +/// Saturation (`PoolTimedOut`/`PoolClosed`) becomes `503` (backpressure); | |
106 | +/// everything else is a `500`. | |
107 | +#[cfg(feature = "db")] | |
108 | +pub fn db_error_response(e: &sqlx::Error) -> Response<ResponseBody> { | |
109 | + match e { | |
110 | + sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed => { | |
111 | + service_unavailable_response("database pool saturated; please retry shortly") | |
112 | + } | |
113 | + _ => error_response(hyper::StatusCode::INTERNAL_SERVER_ERROR, "database error"), | |
114 | + } | |
115 | +} | |
116 | + | |
89 | 117 | /// Lift an `anyhow::Result<Bytes>` stream into a streaming response. |
90 | 118 | pub fn streaming_response( |
91 | 119 | status: hyper::StatusCode, |
92 | 120 | |
▼crates/justapi-core/src/server/crud.rs
+5-5
| @@ -17,7 +17,7 @@ use std::sync::Arc; | ||
17 | 17 | use crate::db::AnyPool; |
18 | 18 | use crate::middleware::HandlerFn; |
19 | 19 | use crate::validate::CompiledValidator; |
20 | -use crate::{error_response, json_response, validation_response}; | |
20 | +use crate::{db_error_response, error_response, json_response, validation_response}; | |
21 | 21 | use http_body_util::BodyExt; |
22 | 22 | use hyper::body::Incoming; |
23 | 23 | use hyper::Request; |
| @@ -163,7 +163,7 @@ pub async fn crud_dispatch_bytes( | ||
163 | 163 | let payload = single.unwrap_or(rows); |
164 | 164 | Ok(json_response(StatusCode::OK, &payload.to_string())) |
165 | 165 | } |
166 | - Err(_) => Ok(error_response(StatusCode::INTERNAL_SERVER_ERROR, "database error")), | |
166 | + Err(e) => Ok(db_error_response(&e)), | |
167 | 167 | } |
168 | 168 | } |
169 | 169 | CrudOp::Select => { |
| @@ -187,7 +187,7 @@ pub async fn crud_dispatch_bytes( | ||
187 | 187 | } |
188 | 188 | match pool.query_with_params(&sql, ¶ms).await { |
189 | 189 | Ok(rows) => Ok(json_response(StatusCode::OK, &rows.to_string())), |
190 | - Err(_) => Ok(error_response(StatusCode::INTERNAL_SERVER_ERROR, "database error")), | |
190 | + Err(e) => Ok(db_error_response(&e)), | |
191 | 191 | } |
192 | 192 | } |
193 | 193 | CrudOp::Update => { |
| @@ -236,7 +236,7 @@ pub async fn crud_dispatch_bytes( | ||
236 | 236 | None => Ok(error_response(StatusCode::NOT_FOUND, "row not found")), |
237 | 237 | } |
238 | 238 | } |
239 | - Err(_) => Ok(error_response(StatusCode::INTERNAL_SERVER_ERROR, "database error")), | |
239 | + Err(e) => Ok(db_error_response(&e)), | |
240 | 240 | } |
241 | 241 | } |
242 | 242 | CrudOp::Delete => { |
| @@ -264,7 +264,7 @@ pub async fn crud_dispatch_bytes( | ||
264 | 264 | None => Ok(error_response(StatusCode::NOT_FOUND, "row not found")), |
265 | 265 | } |
266 | 266 | } |
267 | - Err(_) => Ok(error_response(StatusCode::INTERNAL_SERVER_ERROR, "database error")), | |
267 | + Err(e) => Ok(db_error_response(&e)), | |
268 | 268 | } |
269 | 269 | } |
270 | 270 | } |
271 | 271 | |
▼crates/justapi-core/src/validate.rs
+113-4
| @@ -1,4 +1,5 @@ | ||
1 | 1 | use std::fmt; |
2 | +use std::sync::OnceLock; | |
2 | 3 | |
3 | 4 | use http_body_util::BodyExt; |
4 | 5 | use http_body_util::Full; |
| @@ -9,6 +10,116 @@ use serde::de::DeserializeOwned; | ||
9 | 10 | |
10 | 11 | use crate::ResponseBody; |
11 | 12 | |
13 | +// --------------------------------------------------------------------------- | |
14 | +// Built-in `format` validators | |
15 | +// --------------------------------------------------------------------------- | |
16 | + | |
17 | +/// Register the built-in `format` validators (email, uuid, uri, date-time, ...) | |
18 | +/// once. `jsonschema` 0.46 ships `format` as an opt-in keyword; with | |
19 | +/// `default-features = false` no formats are asserted unless we register them, | |
20 | +/// so we provide lightweight Rust-side checkers. They are intentionally | |
21 | +/// permissive (reject obvious junk, not RFC-exhaustive) — enough for request | |
22 | +/// validation without pulling a format-parsing dependency. | |
23 | +type FormatChecker = (&'static str, fn(&str) -> bool); | |
24 | + | |
25 | +fn format_validators() -> &'static [FormatChecker] { | |
26 | + static FMTS: OnceLock<Vec<FormatChecker>> = OnceLock::new(); | |
27 | + FMTS.get_or_init(|| { | |
28 | + vec![ | |
29 | + ("email", |s: &str| { | |
30 | + if s.is_empty() || s.contains(char::is_whitespace) || !s.contains('@') { | |
31 | + return false; | |
32 | + } | |
33 | + let (local, domain) = s.split_once('@').unwrap(); | |
34 | + !local.is_empty() | |
35 | + && !domain.is_empty() | |
36 | + && domain.contains('.') | |
37 | + && !domain.starts_with('.') | |
38 | + && !domain.ends_with('.') | |
39 | + }), | |
40 | + ("uri", |s: &str| match s.split_once("://") { | |
41 | + Some((scheme, rest)) => { | |
42 | + !scheme.is_empty() | |
43 | + && scheme | |
44 | + .chars() | |
45 | + .all(|c| c.is_alphanumeric() || c == '+' || c == '-' || c == '.') | |
46 | + && !rest.is_empty() | |
47 | + } | |
48 | + None => false, | |
49 | + }), | |
50 | + ("uuid", |s: &str| { | |
51 | + let s = s.trim_matches(|c| c == '{' || c == '}'); | |
52 | + let parts: Vec<&str> = s.split('-').collect(); | |
53 | + parts.len() == 5 | |
54 | + && parts[0].len() == 8 | |
55 | + && parts[1].len() == 4 | |
56 | + && parts[2].len() == 4 | |
57 | + && parts[3].len() == 4 | |
58 | + && parts[4].len() == 12 | |
59 | + && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_hexdigit())) | |
60 | + }), | |
61 | + ("date-time", |s: &str| { | |
62 | + let (date, time) = match s.split_once('T') { | |
63 | + Some((d, t)) => (d, t), | |
64 | + None => return false, | |
65 | + }; | |
66 | + let ds: Vec<&str> = date.split('-').collect(); | |
67 | + if ds.len() != 3 | |
68 | + || ds.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit())) | |
69 | + { | |
70 | + return false; | |
71 | + } | |
72 | + let time = time.trim_end_matches('Z').trim_end_matches(['+', '-']); | |
73 | + let time = time.split('.').next().unwrap_or(time); | |
74 | + let ts: Vec<&str> = time.split(':').collect(); | |
75 | + ts.len() >= 2 | |
76 | + && ts.len() <= 3 | |
77 | + && ts.iter().all(|p| { | |
78 | + p.len() <= 2 && !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) | |
79 | + }) | |
80 | + }), | |
81 | + ("date", |s: &str| { | |
82 | + let ds: Vec<&str> = s.split('-').collect(); | |
83 | + ds.len() == 3 | |
84 | + && ds.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) | |
85 | + }), | |
86 | + ("hostname", |s: &str| { | |
87 | + !s.is_empty() | |
88 | + && !s.starts_with('-') | |
89 | + && !s.ends_with('-') | |
90 | + && !s.contains("..") | |
91 | + && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '.') | |
92 | + }), | |
93 | + ("ipv4", |s: &str| { | |
94 | + let parts: Vec<&str> = s.split('.').collect(); | |
95 | + parts.len() == 4 | |
96 | + && parts.iter().all(|p| { | |
97 | + p.len() <= 3 | |
98 | + && !p.is_empty() | |
99 | + && p.chars().all(|c| c.is_ascii_digit()) | |
100 | + && p.parse::<u8>().is_ok() | |
101 | + }) | |
102 | + }), | |
103 | + ] | |
104 | + }) | |
105 | +} | |
106 | + | |
107 | +/// Build a JSON Schema validator, registering the built-in `format` checkers | |
108 | +/// and asserting `format` regardless of draft default. This is the single | |
109 | +/// source of truth for both one-shot and precompiled validation in the | |
110 | +/// runtime. The validator is not cached here — callers that need caching | |
111 | +/// (routes) should use [`compile_schema`]. | |
112 | +fn build_validator( | |
113 | + schema_value: &serde_json::Value, | |
114 | +) -> Result<Validator, jsonschema::ValidationError<'static>> { | |
115 | + let mut opts = jsonschema::options(); | |
116 | + opts = opts.should_validate_formats(true); | |
117 | + for (name, checker) in format_validators() { | |
118 | + opts = opts.with_format(*name, *checker); | |
119 | + } | |
120 | + opts.build(schema_value) | |
121 | +} | |
122 | + | |
12 | 123 | // --------------------------------------------------------------------------- |
13 | 124 | // Validation error types |
14 | 125 | // --------------------------------------------------------------------------- |
| @@ -176,8 +287,7 @@ pub fn validate_json_schema(body: &[u8], schema_json: &str) -> Result<(), Valida | ||
176 | 287 | let schema_value: serde_json::Value = serde_json::from_str(schema_json) |
177 | 288 | .map_err(|e| ValidationError::new("schema", format!("Invalid schema JSON: {}", e)))?; |
178 | 289 | |
179 | - let validator = jsonschema::options() | |
180 | - .build(&schema_value) | |
290 | + let validator = build_validator(&schema_value) | |
181 | 291 | .map_err(|e| ValidationError::new("schema", format!("Schema compilation error: {}", e)))?; |
182 | 292 | |
183 | 293 | let mut verr = ValidationError { errors: Vec::new() }; |
| @@ -237,8 +347,7 @@ impl CompiledValidator { | ||
237 | 347 | pub fn compile_schema(schema_json: &str) -> Result<CompiledValidator, ValidationError> { |
238 | 348 | let schema_value: serde_json::Value = serde_json::from_str(schema_json) |
239 | 349 | .map_err(|e| ValidationError::new("schema", format!("Invalid schema JSON: {}", e)))?; |
240 | - let validator = jsonschema::options() | |
241 | - .build(&schema_value) | |
350 | + let validator = build_validator(&schema_value) | |
242 | 351 | .map_err(|e| ValidationError::new("schema", format!("Schema compilation error: {}", e)))?; |
243 | 352 | Ok(CompiledValidator(validator)) |
244 | 353 | } |
245 | 354 | |