p1: add built-in GraphQL execution and hardened schema validation

swadhinbiswas committed 3 months ago
be6133d
1 parent 299a084
commit be6133d
6 files changed
+359 -28
▼Cargo.toml
+4-0
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
187
}
188
188
match pool.query_with_params(&sql, &params).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
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
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
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
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
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
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