swadhinbiswas p1: add built-in GraphQL execution and hardened schema validation · 7/24/2026
be6133d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
//! Rust-native CRUD route handlers.
//!
//! These handlers run **entirely in Rust** — no Python, no GIL acquisition, no
//! `spawn_blocking` hop. They are the next step beyond the validate-and-echo
//! native fast path (ADR-056): a route whose behavior is composed solely of
//! Rust primitives (validate body → SQL → serialize JSON) is served by a
//! `Handler::Custom` closure, matching Actix/Axum-class speed while keeping the
//! Python DX for route *definition*.
//!
//! Correctness contract: a Rust-native CRUD route must behave identically to the
//! equivalent Python route — same status codes, the `{"detail": ...}` error
//! envelope, transaction semantics (auto-begin/commit on writes, rollback on
//! error), and `application/json` content type.
use std::sync::Arc;
use crate::db::AnyPool;
use crate::middleware::HandlerFn;
use crate::validate::CompiledValidator;
use crate::{db_error_response, error_response, json_response, validation_response};
use http_body_util::BodyExt;
use hyper::body::Incoming;
use hyper::Request;
use hyper::StatusCode;
/// The CRUD operation a Rust-native route performs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrudOp {
Insert,
Select,
Update,
Delete,
}
/// Per-route Rust-native CRUD spec (ADR-056 Step C).
#[derive(Clone)]
pub struct CrudSpec {
pub op: CrudOp,
pub table: String,
/// Columns the route is allowed to read/write (allowlist against injection
/// and against clients writing arbitrary columns).
pub columns: Vec<String>,
/// Column used to identify a single row for update/delete by id
/// (e.g. `id`). Looked up from the matched path params.
pub id_column: String,
}
fn is_allowed(columns: &[String], name: &str) -> bool {
columns.iter().any(|c| c.eq_ignore_ascii_case(name))
}
/// Parse a form-style query string (`a=1&b=2`) into an ordered list of
/// `(key, value)` pairs, keeping only keys present in `allowed`.
fn parse_query(filter: &[u8], allowed: &[String]) -> Vec<(String, String)> {
let s = String::from_utf8_lossy(filter);
let mut out = Vec::new();
for pair in s.split('&') {
if pair.is_empty() {
continue;
}
let (k, v) = match pair.split_once('=') {
Some((k, v)) => (k, v),
None => (pair, ""),
};
let k = k.to_string();
if is_allowed(allowed, &k) {
out.push((k, v.to_string()));
}
}
out
}
/// Look up the id column value from the matched path params, parsed as JSON.
fn id_param(path_params: &[(String, String)], id_column: &str) -> Option<serde_json::Value> {
let raw = path_params
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(id_column))
.map(|(_, v)| v.clone())?;
// Try to parse as a number so `WHERE id = ?` binds an int, not a string.
if let Ok(i) = raw.parse::<i64>() {
Some(serde_json::json!(i))
} else {
Some(serde_json::Value::String(raw))
}
}
/// Build a driver-aware placeholder generator. Postgres/MySQL use positional
/// `$N` / `?`; SQLite uses `?`. The closure increments its index on each call so
/// callers emit placeholders in the same order the values are bound.
fn placeholder_gen(pool: &AnyPool) -> impl FnMut() -> String + '_ {
let dollar = matches!(pool.kind(), crate::db::DbKind::Postgres);
let mut i = 0usize;
move || {
i += 1;
if dollar {
format!("${}", i)
} else {
"?".to_string()
}
}
}
/// Core Rust-native CRUD logic over an already-read body.
///
/// Dispatches on `spec.op`:
/// - **Insert**: validate body (if `validator`), insert the columns present in
/// both the JSON and `spec.columns`, return the inserted row as 200 JSON.
/// - **Select**: filter by query-string params (allowlisted) and/or the path id;
/// return matching rows as 200 JSON.
/// - **Update**: identify the row by path id, set the allowlisted columns from
/// the body, return the updated row as 200 JSON.
/// - **Delete**: identify the row by path id, delete it, return the deleted row
/// as 200 JSON.
///
/// Validation failure / non-object JSON → 422 with `{"detail": ...}`; a missing
/// id for update/delete → 400; DB errors → 500.
pub async fn crud_dispatch_bytes(
pool: &AnyPool,
spec: &CrudSpec,
path_params: &[(String, String)],
query_string: &[u8],
body: &[u8],
validator: Option<&Arc<CompiledValidator>>,
) -> Result<hyper::Response<crate::ResponseBody>, anyhow::Error> {
match spec.op {
CrudOp::Insert => {
// Validate.
if let Some(v) = validator {
if let Err(e) = v.validate(body) {
return Ok(validation_response(&e.to_string()));
}
}
let body_val: serde_json::Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(_) => return Ok(validation_response("request body must be valid JSON")),
};
let obj = match body_val.as_object() {
Some(o) => o,
None => return Ok(validation_response("request body must be a JSON object")),
};
let mut cols: Vec<String> = Vec::new();
let mut vals: Vec<serde_json::Value> = Vec::new();
for c in &spec.columns {
if let Some(v) = obj.get(c) {
cols.push(c.clone());
vals.push(v.clone());
}
}
if cols.is_empty() {
return Ok(validation_response("no insertable columns matched the request body"));
}
let mut ph = placeholder_gen(pool);
let placeholders: Vec<String> = (0..cols.len()).map(|_| ph()).collect();
let sql = format!(
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
spec.table,
cols.join(", "),
placeholders.join(", ")
);
match pool.query_with_params(&sql, &vals).await {
Ok(rows) => {
let single = rows.as_array().and_then(|a| a.first()).cloned();
let payload = single.unwrap_or(rows);
Ok(json_response(StatusCode::OK, &payload.to_string()))
}
Err(e) => Ok(db_error_response(&e)),
}
}
CrudOp::Select => {
let mut params: Vec<serde_json::Value> = Vec::new();
let mut wheres: Vec<String> = Vec::new();
let mut ph = placeholder_gen(pool);
// Filter by path id when present.
if let Some(id) = id_param(path_params, &spec.id_column) {
wheres.push(format!("{} = {}", spec.id_column, ph()));
params.push(id);
}
// Filter by allowlisted query-string params.
for (k, v) in parse_query(query_string, &spec.columns) {
wheres.push(format!("{} = {}", k, ph()));
params.push(serde_json::Value::String(v));
}
let mut sql = format!("SELECT * FROM {}", spec.table);
if !wheres.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&wheres.join(" AND "));
}
match pool.query_with_params(&sql, ¶ms).await {
Ok(rows) => Ok(json_response(StatusCode::OK, &rows.to_string())),
Err(e) => Ok(db_error_response(&e)),
}
}
CrudOp::Update => {
let id = match id_param(path_params, &spec.id_column) {
Some(v) => v,
None => {
return Ok(error_response(
StatusCode::BAD_REQUEST,
"missing path id for update",
))
}
};
let body_val: serde_json::Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(_) => return Ok(validation_response("request body must be valid JSON")),
};
let obj = match body_val.as_object() {
Some(o) => o,
None => return Ok(validation_response("request body must be a JSON object")),
};
let mut sets: Vec<String> = Vec::new();
let mut params: Vec<serde_json::Value> = Vec::new();
let mut ph = placeholder_gen(pool);
for c in &spec.columns {
if let Some(v) = obj.get(c) {
sets.push(format!("{} = {}", c, ph()));
params.push(v.clone());
}
}
if sets.is_empty() {
return Ok(validation_response("no updatable columns matched the request body"));
}
params.push(id);
let sql = format!(
"UPDATE {} SET {} WHERE {} = {} RETURNING *",
spec.table,
sets.join(", "),
spec.id_column,
ph()
);
match pool.query_with_params(&sql, ¶ms).await {
Ok(rows) => {
let single = rows.as_array().and_then(|a| a.first()).cloned();
match single {
Some(payload) => Ok(json_response(StatusCode::OK, &payload.to_string())),
None => Ok(error_response(StatusCode::NOT_FOUND, "row not found")),
}
}
Err(e) => Ok(db_error_response(&e)),
}
}
CrudOp::Delete => {
let id = match id_param(path_params, &spec.id_column) {
Some(v) => v,
None => {
return Ok(error_response(
StatusCode::BAD_REQUEST,
"missing path id for delete",
))
}
};
let mut ph = placeholder_gen(pool);
let sql = format!(
"DELETE FROM {} WHERE {} = {} RETURNING *",
spec.table,
spec.id_column,
ph()
);
match pool.query_with_params(&sql, &[id]).await {
Ok(rows) => {
let single = rows.as_array().and_then(|a| a.first()).cloned();
match single {
Some(payload) => Ok(json_response(StatusCode::OK, &payload.to_string())),
None => Ok(error_response(StatusCode::NOT_FOUND, "row not found")),
}
}
Err(e) => Ok(db_error_response(&e)),
}
}
}
}
/// Build a `HandlerFn` for a Rust-native CRUD route, dispatching on `spec.op`.
/// Reads + limits the body, then delegates to [`crud_dispatch_bytes`].
pub fn crud_handler(
pool: Arc<AnyPool>,
spec: CrudSpec,
validator: Option<Arc<CompiledValidator>>,
) -> HandlerFn {
Arc::new(move |req: Request<Incoming>| {
let pool = pool.clone();
let spec = spec.clone();
let validator = validator.clone();
Box::pin(async move {
let path_params: Vec<(String, String)> =
req.extensions().get::<Vec<(String, String)>>().cloned().unwrap_or_default();
let query_string = req.uri().query().unwrap_or("").as_bytes().to_vec();
let bytes = match http_body_util::Limited::new(
req.into_body(),
crate::server::DEFAULT_MAX_BODY_SIZE,
)
.collect()
.await
{
Ok(c) => c.to_bytes(),
Err(_) => {
return Ok(error_response(
StatusCode::BAD_REQUEST,
"could not read request body",
))
}
};
crud_dispatch_bytes(
&pool,
&spec,
&path_params,
&query_string,
&bytes,
validator.as_ref(),
)
.await
})
})
}
// --- Backwards-compatible thin wrappers (Step B insert kept working) ---------
/// Build a `HandlerFn` for a Rust-native `POST`/insert route.
pub fn crud_insert_handler(
pool: Arc<AnyPool>,
table: String,
columns: Vec<String>,
validator: Option<Arc<CompiledValidator>>,
) -> HandlerFn {
crud_handler(
pool,
CrudSpec { op: CrudOp::Insert, table, columns, id_column: "id".to_string() },
validator,
)
}
/// Core Rust-native `POST`/insert logic over an already-read body.
pub async fn crud_insert_bytes(
pool: &AnyPool,
table: &str,
columns: &[String],
validator: Option<&Arc<CompiledValidator>>,
body: &[u8],
) -> Result<hyper::Response<crate::ResponseBody>, anyhow::Error> {
crud_dispatch_bytes(
pool,
&CrudSpec {
op: CrudOp::Insert,
table: table.to_string(),
columns: columns.to_vec(),
id_column: "id".to_string(),
},
&[],
b"",
body,
validator,
)
.await
}
/// Build a `HandlerFn` for a Rust-native `GET`/select route.
#[allow(dead_code)]
pub fn crud_select_handler(
_pool: Arc<AnyPool>,
_table: String,
_query_validator: Option<Arc<CompiledValidator>>,
) -> HandlerFn {
// Superseded by the unified `crud_handler`; kept for API stability.
Arc::new(|_req: Request<Incoming>| {
Box::pin(async move { Ok(error_response(StatusCode::NOT_IMPLEMENTED, "use crud_handler")) })
})
}