Skip to main content

backbone_core/
http.rs

1//! HTTP layer and REST endpoint implementations
2//!
3//! This module provides generic HTTP/REST components for the Backbone CRUD system:
4//! - `CrudService` trait: Core async trait for entity CRUD operations
5//! - `BackboneCrudHandler`: Generic Axum router builder for all 11 endpoints
6//! - Response types: `ApiResponse`, `PaginatedResponse`, `BulkResponse`
7
8use crate::extractors::JsonOrForm;
9pub use crate::permission::{
10    guarded, guarded_all, permission_refusal, unauthenticated_refusal, CrudPermissions,
11};
12use serde::{de::DeserializeOwned, Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15
16// ============================================================
17// Response Types
18// ============================================================
19
20/// Standard API response wrapper
21#[derive(Debug, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23pub struct ApiResponse<T> {
24    pub success: bool,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub data: Option<T>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub message: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub error: Option<String>,
31    /// One entry per broken rule, beside the `error` sentence, when the
32    /// service refused the write for named reasons. Absent otherwise, so a
33    /// body without violations is unchanged.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub violations: Option<Vec<crate::violation::Violation>>,
36}
37
38impl<T> ApiResponse<T> {
39    /// A refused write: the sentence for clients that read `error`, and each
40    /// broken rule for clients that mark fields.
41    pub fn rejected(error: impl Into<String>, violations: Vec<crate::violation::Violation>) -> Self {
42        Self {
43            success: false,
44            data: None,
45            message: None,
46            error: Some(error.into()),
47            violations: (!violations.is_empty()).then_some(violations),
48        }
49    }
50
51    /// Create a success response with data and an optional message
52    pub fn success(data: T, message: Option<String>) -> Self {
53        Self {
54            success: true,
55            data: Some(data),
56            message,
57            error: None,
58            violations: None,
59        }
60    }
61
62    /// Create a success response without a message (convenience method)
63    pub fn ok(data: T) -> Self {
64        Self {
65            success: true,
66            data: Some(data),
67            message: None,
68            error: None,
69            violations: None,
70        }
71    }
72
73    pub fn success_with_message(data: T, message: impl Into<String>) -> Self {
74        Self {
75            success: true,
76            data: Some(data),
77            message: Some(message.into()),
78            error: None,
79            violations: None,
80        }
81    }
82
83    pub fn error(error: impl Into<String>) -> Self {
84        Self {
85            success: false,
86            data: None,
87            message: None,
88            error: Some(error.into()),
89            violations: None,
90        }
91    }
92
93    /// A missing record: [`NOT_FOUND_MESSAGE`] and a `not_found` violation, the same body for
94    /// every resource. `entity` and `id` go to the debug log only — a type name can carry the
95    /// crate's module path, and the body has no use for either.
96    pub fn not_found(entity: &str, id: &str) -> Self {
97        tracing::debug!(target: "backbone::http", entity, id, "record not found");
98        Self::rejected(NOT_FOUND_MESSAGE, vec![not_found_violation()])
99    }
100}
101
102/// The sentence a 404 answers with.
103pub const NOT_FOUND_MESSAGE: &str = "Not found";
104
105fn not_found_violation() -> crate::violation::Violation {
106    crate::violation::Violation::new("", "not_found", NOT_FOUND_MESSAGE)
107}
108
109/// The code a client refusal that names no rule answers with, by status: every 4xx body
110/// carries a machine-readable code, whatever layer refused.
111fn status_code_violation(status: axum::http::StatusCode, message: &str) -> Option<crate::violation::Violation> {
112    use axum::http::StatusCode;
113    let code = match status {
114        StatusCode::NOT_FOUND => return Some(not_found_violation()),
115        StatusCode::CONFLICT => "conflict",
116        StatusCode::BAD_REQUEST => "invalid_request",
117        StatusCode::UNPROCESSABLE_ENTITY => "invalid_value",
118        _ => return None,
119    };
120    Some(crate::violation::Violation::new("", code, message))
121}
122
123/// The sentence a 500 answers with. The real error is logged, never sent: it can carry SQL,
124/// table names and driver text a client has no use for and an attacker does.
125pub const INTERNAL_ERROR_MESSAGE: &str = "An internal error occurred. It has been logged.";
126
127/// The response to a failed call at `status`: 422 listing the broken rules when there are
128/// any, the error's sentence for any other client fault, and a generic sentence for a server
129/// fault, whose detail goes to the log instead.
130fn error_at<T>(
131    status: axum::http::StatusCode,
132    violations: Option<Vec<crate::violation::Violation>>,
133    err: &impl std::fmt::Display,
134) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
135    if status.is_server_error() {
136        tracing::error!(target: "backbone::http", status = status.as_u16(), error = %err, "request failed");
137        return (status, axum::Json(ApiResponse::error(INTERNAL_ERROR_MESSAGE)));
138    }
139    match violations {
140        Some(v) if !v.is_empty() => (status, axum::Json(ApiResponse::rejected(err.to_string(), v))),
141        _ if status == axum::http::StatusCode::NOT_FOUND => {
142            tracing::debug!(target: "backbone::http", error = %err, "not found");
143            (status, axum::Json(ApiResponse::rejected(NOT_FOUND_MESSAGE, vec![not_found_violation()])))
144        }
145        _ => {
146            let message = err.to_string();
147            match status_code_violation(status, &message) {
148                Some(v) => (status, axum::Json(ApiResponse::rejected(message, vec![v]))),
149                None => (status, axum::Json(ApiResponse::error(message))),
150            }
151        }
152    }
153}
154
155/// Bind the request's correlation id for the writes it makes, so the events they stage (and
156/// the logs and audit rows of the request) share one id.
157///
158/// A composing service that already attributes the request (its guard binds a
159/// [`RequestAuditContext`](backbone_orm::RequestAuditContext) carrying the correlation id, which
160/// it echoes on the response) is left alone: that id is the one the hooks read, so events, audit
161/// rows and the response header agree. Otherwise the id is the request's `x-correlation-id`
162/// header when it carries a usable one (printable ASCII, at most 128 characters), else a fresh
163/// uuid. The generic write routes and the generated transition routes carry this layer; a
164/// hand-written route that writes through the service can add it with
165/// `axum::middleware::from_fn`.
166pub async fn correlation_middleware(
167    req: axum::extract::Request,
168    next: axum::middleware::Next,
169) -> axum::response::Response {
170    let attributed = backbone_orm::audit_context::current_request_audit()
171        .is_some_and(|audit| !audit.correlation_id.is_empty());
172    if attributed {
173        return next.run(req).await;
174    }
175    let id = req
176        .headers()
177        .get("x-correlation-id")
178        .and_then(|v| v.to_str().ok())
179        .map(str::trim)
180        .filter(|v| !v.is_empty() && v.len() <= 128 && v.bytes().all(|b| b.is_ascii_graphic()))
181        .map(str::to_string)
182        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
183    crate::write_hooks::with_correlation_id(id, next.run(req)).await
184}
185
186/// The status a [`ServiceError`](crate::service::ServiceError) answers with, by variant.
187///
188/// A refusal for named reasons — a protected field, a broken rule, a constraint the database
189/// refused — is the caller's to fix: 422, except a refused query parameter (`invalid_query`),
190/// which is 400 like any malformed request. A plain validation error is 400, a missing record
191/// 404, a duplicate or conflict 409, and only what is left is a 500.
192pub fn service_error_status(err: &crate::service::ServiceError) -> axum::http::StatusCode {
193    use crate::persistence::traits::RepositoryError;
194    use crate::service::ServiceError;
195    use axum::http::StatusCode;
196    match err {
197        // A refused query parameter is a malformed request, not a broken rule: 400.
198        ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v))
199            if is_query_refusal(v) =>
200        {
201            StatusCode::BAD_REQUEST
202        }
203        ServiceError::Violations(_) | ServiceError::Repository(RepositoryError::Violations(_)) => {
204            StatusCode::UNPROCESSABLE_ENTITY
205        }
206        ServiceError::Validation(_)
207        | ServiceError::Repository(RepositoryError::ValidationError(_)) => StatusCode::BAD_REQUEST,
208        ServiceError::NotFound | ServiceError::Repository(RepositoryError::NotFound) => {
209            StatusCode::NOT_FOUND
210        }
211        ServiceError::AlreadyExists(_)
212        | ServiceError::Repository(RepositoryError::AlreadyExists(_))
213        | ServiceError::Repository(RepositoryError::Conflict(_)) => StatusCode::CONFLICT,
214        _ => StatusCode::INTERNAL_SERVER_ERROR,
215    }
216}
217
218/// The response for a failed [`ServiceError`](crate::service::ServiceError), for handlers outside
219/// the generic CRUD handler (a generated state-machine transition, a custom route) that call the
220/// service directly — the same answer the generic handler gives (see [`service_error_status`]).
221/// A 500 carries [`INTERNAL_ERROR_MESSAGE`]; the error itself is logged.
222pub fn service_error_response<T>(
223    err: &crate::service::ServiceError,
224) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
225    use crate::persistence::traits::RepositoryError;
226    use crate::service::ServiceError;
227    let violations = match err {
228        ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => {
229            Some(v.clone())
230        }
231        _ => None,
232    };
233    error_at(service_error_status(err), violations, err)
234}
235
236/// The response to a failed write when the error's kind is unknown: 422 listing the broken
237/// rules when there are any, `fallback` otherwise (a server fault answers generically).
238#[cfg(test)]
239fn write_error<T>(
240    violations: Option<Vec<crate::violation::Violation>>,
241    err: &impl std::fmt::Display,
242    fallback: axum::http::StatusCode,
243) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
244    let status = if violations.is_some() { axum::http::StatusCode::UNPROCESSABLE_ENTITY } else { fallback };
245    error_at(status, violations, err)
246}
247
248/// A path id that is not a UUID, refused before it reaches a query: 400 with an `invalid_id`
249/// violation on `path`. Every generic repository keys rows by UUID, so such an id can name no
250/// record — and passed through, it failed the `::uuid` cast as a 500 with the driver's text.
251fn invalid_id<T>(id: &str, path: &str) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
252    if uuid::Uuid::parse_str(id).is_ok() {
253        return None;
254    }
255    let violation = crate::violation::Violation::new(
256        path,
257        "invalid_id",
258        format!("`{id}` is not a valid record id"),
259    );
260    Some((
261        axum::http::StatusCode::BAD_REQUEST,
262        axum::Json(ApiResponse::rejected(format!("invalid id: `{id}` is not a valid record id"), vec![violation])),
263    ))
264}
265
266/// [`invalid_id`] over a batch: every id that is not a UUID, each on `<prefix>/<index>` (or
267/// `<index>/<suffix>` when `suffix` is given).
268fn invalid_ids<'a, T>(
269    ids: impl IntoIterator<Item = &'a str>,
270    path_of: impl Fn(usize) -> String,
271) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
272    let violations: Vec<crate::violation::Violation> = ids
273        .into_iter()
274        .enumerate()
275        .filter(|(_, id)| uuid::Uuid::parse_str(id).is_err())
276        .map(|(i, id)| {
277            crate::violation::Violation::new(path_of(i), "invalid_id", format!("`{id}` is not a valid record id"))
278        })
279        .collect();
280    if violations.is_empty() {
281        return None;
282    }
283    Some((
284        axum::http::StatusCode::BAD_REQUEST,
285        axum::Json(ApiResponse::rejected("invalid id in the batch", violations)),
286    ))
287}
288
289/// Generic list query parameters
290#[derive(Debug, Deserialize, Default, Clone)]
291#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
292pub struct ListQueryParams {
293    #[serde(default = "default_page")]
294    pub page: u32,
295    #[serde(default = "default_limit")]
296    pub limit: u32,
297    #[serde(default)]
298    pub sort_by: Option<String>,
299    #[serde(default)]
300    pub sort_order: Option<String>,
301    #[serde(default)]
302    pub search: Option<String>,
303    #[serde(default)]
304    pub status: Option<String>,
305    #[serde(flatten)]
306    pub filters: HashMap<String, String>,
307}
308
309fn default_page() -> u32 { 1 }
310fn default_limit() -> u32 { 20 }
311
312impl ListQueryParams {
313    /// The page request every list route serves: page 0 reads as page 1, and the page size
314    /// is held to `1..=`[`MAX_PER_PAGE`], as the repositories hold it, so the `meta` a
315    /// response reports is the page actually read.
316    pub fn normalized(mut self) -> Self {
317        let page = backbone_orm::repository::PaginationParams::new(self.page, self.limit);
318        self.page = page.page;
319        self.limit = page.per_page;
320        self
321    }
322}
323
324/// The list query extractor every generic list-shaped route takes: [`ListQueryParams`] read off
325/// the query string, where a paging value that is not a number is refused by NAME, as an
326/// `invalid_query` violation on the parameter (`page`, `limit`), instead of axum's bare
327/// "Failed to deserialize query string".
328pub struct ListQuery(pub ListQueryParams);
329
330#[axum::async_trait]
331impl<S: Send + Sync> axum::extract::FromRequestParts<S> for ListQuery {
332    type Rejection = axum::response::Response;
333
334    async fn from_request_parts(
335        parts: &mut axum::http::request::Parts,
336        _state: &S,
337    ) -> Result<Self, Self::Rejection> {
338        use axum::response::IntoResponse;
339        let raw = parts.uri.query().unwrap_or("");
340        match serde_urlencoded::from_str::<ListQueryParams>(raw) {
341            Ok(params) => Ok(ListQuery(params)),
342            Err(e) => {
343                let violation = list_query_violation(raw, &e.to_string());
344                Err((
345                    axum::http::StatusCode::BAD_REQUEST,
346                    axum::Json(PaginatedApiResponse::<serde_json::Value>::rejected(
347                        violation.message.clone(),
348                        vec![violation],
349                    )),
350                )
351                    .into_response())
352            }
353        }
354    }
355}
356
357/// Name the typed parameter (`page`, `limit`, `sort_by`, `sort_order`, `search`, `status`)
358/// a query string failed to read as, or say that the string as a whole is unreadable.
359fn list_query_violation(raw: &str, decode_error: &str) -> crate::violation::Violation {
360    use backbone_orm::QueryParamReason;
361    let pairs: Vec<(String, String)> = serde_urlencoded::from_str(raw).unwrap_or_default();
362    for key in ["page", "limit"] {
363        for (k, v) in pairs.iter().filter(|(k, _)| k == key) {
364            if v.trim().parse::<u32>().is_err() {
365                let e = backbone_orm::QueryParamError::not_a_number(k, v);
366                return query_violation(&e.parameter, e.reason, e.message);
367            }
368        }
369    }
370    for key in ["page", "limit", "sort_by", "sort_order", "search", "status"] {
371        if pairs.iter().filter(|(k, _)| k == key).count() > 1 {
372            return query_violation(
373                key,
374                QueryParamReason::InvalidValue,
375                format!("`{key}` was sent more than once; send it once"),
376            );
377        }
378    }
379    tracing::debug!(target: "backbone::http", error = decode_error, "list query refused");
380    crate::violation::Violation::new("", "invalid_query", "The query string could not be read")
381}
382
383/// One refused query parameter as a violation: the code `invalid_query` on the parameter's
384/// path, `params` carrying the parameter and the stable reason, and a sentence that names both.
385fn query_violation(
386    parameter: &str,
387    reason: backbone_orm::QueryParamReason,
388    message: impl Into<String>,
389) -> crate::violation::Violation {
390    crate::violation::Violation::invalid_query(parameter, reason.as_str(), message)
391}
392
393/// Whether every violation is a refused query parameter (and there is at least one).
394fn is_query_refusal(violations: &[crate::violation::Violation]) -> bool {
395    !violations.is_empty() && violations.iter().all(|v| v.code == crate::violation::INVALID_QUERY)
396}
397
398/// The sentence for a set of refused parameters: their messages, in order.
399fn refusal_sentence(violations: &[crate::violation::Violation]) -> String {
400    violations.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; ")
401}
402
403/// Whether the typed `sort_by` is what sorts this request (no `orderby`/`sort` key in the
404/// filter grammar already does).
405fn typed_sort_applies(params: &ListQueryParams) -> bool {
406    let sorted = params.filters.keys().any(|k| {
407        let base = k.split('[').next().unwrap_or(k);
408        base.eq_ignore_ascii_case("orderby") || base.eq_ignore_ascii_case("sort")
409    });
410    !sorted && params.sort_by.as_deref().map(str::trim).is_some_and(|f| !f.is_empty())
411}
412
413/// A refusal the repository worded for the `orderby` key the typed `sort_by` rides in on,
414/// said for the parameter the client actually sent.
415fn rename_typed_sort(
416    mut violations: Vec<crate::violation::Violation>,
417    params: &ListQueryParams,
418) -> Vec<crate::violation::Violation> {
419    if !typed_sort_applies(params) {
420        return violations;
421    }
422    for v in &mut violations {
423        if v.path == "orderby" {
424            v.path = "sort_by".to_string();
425            v.message = v.message.replace("`orderby`", "`sort_by`");
426            if v.params.contains_key("parameter") {
427                v.params.insert("parameter".into(), "sort_by".into());
428            }
429        }
430    }
431    violations
432}
433
434/// Reserved response-shaping query keys (carved out of the filter grammar).
435const RESERVED_QUERY_KEYS: [&str; 3] = ["fields", "include", "with"];
436
437/// Parse the reserved `fields` key (comma-separated) into trimmed field names.
438/// Absent/empty → empty vec, meaning "no projection — return every field".
439// ─── Per-record history ───────────────────────────────────────────────────────
440
441/// One recorded change to a record.
442///
443/// `changed` is the capture layer's diff shape, carried through verbatim:
444/// `{field: {"from": old, "to": new}}` for an update, and the full image as
445/// `{field: {"to": v}}` / `{field: {"from": v}}` for an insert / delete. The
446/// trail is DIFF-ONLY, so an update entry names only the fields that actually
447/// changed — a consumer wanting the record's state at a point in time must
448/// re-anchor on the nearest full image and replay forward, which is what the
449/// insert/delete images exist for.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct HistoryEntry {
452    pub occurred_at: String,
453    /// `insert` | `update` | `delete`, or a verb-emitted action.
454    pub action: String,
455    pub actor: String,
456    pub changed: serde_json::Value,
457    pub reason: Option<String>,
458    pub correlation_id: Option<String>,
459}
460
461/// Supplies a record's change history to the generic CRUD router.
462///
463/// This exists so `backbone-core` never learns that an audit module exists.
464/// The composing service builds a provider and installs it as an axum
465/// `Extension`; a composition without one simply has no history to serve, and
466/// says so rather than pretending.
467#[async_trait::async_trait]
468pub trait HistoryProvider: Send + Sync {
469    /// History for one row of `table` (schema-qualified, e.g. `sapiens.users`).
470    ///
471    /// `Ok(None)` means **this table is not audited** — deliberately distinct
472    /// from `Ok(Some(vec![]))`, which means it is audited and genuinely never
473    /// changed. Collapsing the two would answer "nothing ever happened" to a
474    /// question that was never actually asked, which is the same defect as a
475    /// filtered count that quietly returns the whole table.
476    async fn history(
477        &self,
478        table: &str,
479        id: &str,
480        limit: u32,
481        offset: u32,
482    ) -> Result<Option<Vec<HistoryEntry>>, String>;
483}
484
485/// Query keys the aggregate endpoint consumes itself.
486///
487/// They name columns and reductions, so they are grammar rather than
488/// predicates — left in the filter map they would be read as filters on
489/// columns called `sum` or `group_by`.
490const AGGREGATE_QUERY_KEYS: [&str; 7] =
491    ["group_by", "sum", "avg", "min", "max", "group_limit", "group_label"];
492
493/// Read an aggregate request off the query string.
494///
495/// Column names are NOT validated here; they are resolved against the entity's
496/// declared columns down in the repository, which is the only layer that knows
497/// them. Nothing between here and there splices a caller's string into SQL.
498fn aggregate_spec(params: &ListQueryParams) -> backbone_orm::repository::AggregateSpec {
499    use backbone_orm::repository::{AggregateFn, AggregateSpec};
500
501    let mut reductions = Vec::new();
502    for (key, func) in [
503        ("sum", AggregateFn::Sum),
504        ("avg", AggregateFn::Avg),
505        ("min", AggregateFn::Min),
506        ("max", AggregateFn::Max),
507    ] {
508        if let Some(raw) = params.filters.get(key) {
509            for field in raw.split(',').map(str::trim).filter(|f| !f.is_empty()) {
510                reductions.push((func, field.to_string()));
511            }
512        }
513    }
514
515    AggregateSpec {
516        group_by: params
517            .filters
518            .get("group_by")
519            .map(|g| g.trim().to_string())
520            .filter(|g| !g.is_empty()),
521        reductions,
522        group_limit: params
523            .filters
524            .get("group_limit")
525            .and_then(|l| l.trim().parse::<usize>().ok())
526            .unwrap_or(0),
527        label_field: params
528            .filters
529            .get("group_label")
530            .map(|l| l.trim().to_string())
531            .filter(|l| !l.is_empty()),
532        label_relation: None,
533    }
534}
535
536/// The fields a list-style request names as columns, walked the same way for secrets and for
537/// permission-gated fields: as a filter (`token_hash=…`, `token_hash[startwith]=…`), a sort
538/// (`orderby`, `sort`, `sort_by`, `orderby[…]`), a search column (`searchFields`), or an
539/// aggregate's group or reduction. Each answer is `(parameter, name it carries)`, as the client
540/// spelled them, in a stable order so a request always names the same one.
541fn named_columns(params: &ListQueryParams) -> Vec<(String, String)> {
542    const COLUMN_LISTS: [&str; 10] = [
543        "orderby", "sort", "searchfields", "search_fields", "group_by", "sum", "avg", "min", "max", "group_label",
544    ];
545    let mut names: Vec<(String, String)> = Vec::new();
546    let mut keys: Vec<&String> = params.filters.keys().collect();
547    keys.sort();
548    for key in keys {
549        let value = &params.filters[key];
550        match key.find('[') {
551            Some(open) => {
552                names.push((key.clone(), key[..open].to_string()));
553                names.push((key.clone(), key[open + 1..].trim_end_matches(']').to_string()));
554            }
555            None => names.push((key.clone(), key.clone())),
556        }
557        if COLUMN_LISTS.contains(&key.to_ascii_lowercase().as_str()) {
558            names.extend(
559                value
560                    .split(',')
561                    .map(|v| (key.clone(), v.trim().trim_start_matches('-').to_string())),
562            );
563        }
564    }
565    names.extend(params.sort_by.iter().map(|n| ("sort_by".to_string(), n.clone())));
566    names
567}
568
569/// The secret field (`@sensitive`, `@hashed`) a list-style request names as a column, if any
570/// (see [`named_columns`]). Each such column answers a question about the secret's value — a
571/// prefix match recovers a digest one character at a time, and `min=` returns one outright — so
572/// the request is refused rather than the column quietly dropped. Answers the parameter that
573/// names it beside the field, as the client spelled them.
574fn named_secret_at(params: &ListQueryParams, secret_fields: &[&str]) -> Option<(String, String)> {
575    if secret_fields.is_empty() {
576        return None;
577    }
578    let secrets: Vec<String> = secret_fields.iter().map(|f| camel_to_snake_case(f)).collect();
579    named_columns(params)
580        .into_iter()
581        .find(|(_, n)| secrets.contains(&camel_to_snake_case(n.trim())))
582}
583
584/// The permission-gated field a list-style request names as a column while not holding the
585/// permission that unlocks it, if any (see [`named_columns`]): `(parameter, field, permission)`.
586/// A holder may ask these questions of the value; a non-holder may not, or a filter's yes/no
587/// answer and a `min`/`max` would hand the value back one comparison at a time.
588fn named_gated_at<'a>(
589    params: &ListQueryParams,
590    gated: &'a [(&'static str, &'static str)],
591    caller: Option<&backbone_auth::middleware::AuthContext>,
592) -> Option<(String, String, &'a str)> {
593    if gated.is_empty() {
594        return None;
595    }
596    let held = |permission: &str| caller.is_some_and(|c| c.has_permission(permission));
597    named_columns(params).into_iter().find_map(|(parameter, name)| {
598        let column = camel_to_snake_case(name.trim());
599        gated.iter()
600            .find(|(field, _)| camel_to_snake_case(field) == column)
601            .filter(|(_, permission)| !held(permission))
602            .map(|(field, permission)| (parameter, (*field).to_string(), *permission))
603    })
604}
605
606/// The answer to a request that names a secret field (see [`named_secret`]).
607fn secret_named_message(field: &str) -> String {
608    format!("'{field}' is a secret field: it cannot be filtered, sorted, searched or aggregated")
609}
610
611/// The refusal of a request that names a secret field as a column, on the parameter that does.
612fn secret_violation(parameter: &str, field: &str) -> crate::violation::Violation {
613    query_violation(
614        parameter,
615        backbone_orm::QueryParamReason::InvalidValue,
616        format!("`{parameter}`: {}", secret_named_message(field)),
617    )
618    .with_param("field", field)
619}
620
621/// The refusal of a list-shaped request that names a permission-gated field as a column without
622/// holding the permission, on the parameter that names it, naming the permission too.
623fn gated_violation(parameter: &str, field: &str, permission: &str) -> crate::violation::Violation {
624    query_violation(
625        parameter,
626        backbone_orm::QueryParamReason::FieldPermission,
627        format!(
628            "`{parameter}`: `{field}` needs the permission `{permission}` to be filtered, sorted, searched or aggregated"
629        ),
630    )
631    .with_param("field", field)
632    .with_param("permission", permission)
633}
634
635/// The refusals of a write body that names a permission-gated field its caller does not hold the
636/// permission for: setting such a field is a holder's act, or a caller could write what it
637/// cannot read and infer the value via the errors. A key's presence in the body is the setting —
638/// `null` included, in either casing — so a caller without the permission omits the key. A
639/// caller with no context holds nothing.
640fn gated_write_refusals<E: backbone_orm::EntityRepoMeta>(
641    keys: impl Iterator<Item = String>,
642    caller: Option<&backbone_auth::middleware::AuthContext>,
643) -> Vec<crate::violation::Violation> {
644    let gated = E::field_permissions();
645    if gated.is_empty() {
646        return Vec::new();
647    }
648    let mut refused = Vec::new();
649    for key in keys {
650        let column = camel_to_snake_case(key.trim());
651        for (field, permission) in gated {
652            if camel_to_snake_case(field) == column
653                && !caller.is_some_and(|c| c.has_permission(permission))
654            {
655                refused.push(
656                    crate::violation::Violation::new(
657                        key.clone(),
658                        "field_permission_required",
659                        format!("`{key}` needs the permission `{permission}` to be set"),
660                    )
661                    .with_param("permission", *permission),
662                );
663            }
664        }
665    }
666    refused
667}
668
669/// What a list-shaped request must satisfy before it reaches the service, beyond what the
670/// service itself checks: each refused parameter named on its own path.
671///
672/// `paged` adds the offset-depth bound (the count and aggregate routes do not page); the
673/// response-shaping keys (`fields`, `include`/`with`) are read against the entity. `caller` is
674/// the request's `AuthContext`, if it has one: a permission-gated field the caller does not
675/// hold may not be named as a column (a caller with no context holds nothing).
676fn query_refusals<E: backbone_orm::EntityRepoMeta>(
677    params: &ListQueryParams,
678    paged: bool,
679    caller: Option<&backbone_auth::middleware::AuthContext>,
680) -> Vec<crate::violation::Violation> {
681    use backbone_orm::QueryParamReason;
682    let mut refused = Vec::new();
683    if paged {
684        if let Some(message) = pagination_depth_error(params.page, params.limit) {
685            refused.push(query_violation("page", QueryParamReason::TooDeep, format!("`page`: {message}")));
686        }
687    }
688    if let Some(order) = params.sort_order.as_deref().map(str::trim).filter(|o| !o.is_empty()) {
689        if !order.eq_ignore_ascii_case("asc") && !order.eq_ignore_ascii_case("desc") {
690            refused.push(query_violation(
691                "sort_order",
692                QueryParamReason::InvalidValue,
693                format!("`sort_order` must be `asc` or `desc`, not `{}`", clip_value(order)),
694            ));
695        }
696    }
697    if let Some((parameter, field)) = named_secret_at(params, E::secret_fields()) {
698        refused.push(secret_violation(&parameter, &field));
699    }
700    if let Some((parameter, field, permission)) = named_gated_at(params, E::field_permissions(), caller) {
701        refused.push(gated_violation(&parameter, &field, permission));
702    }
703    refused.extend(shaping_refusals::<E>(&params.filters));
704    refused
705}
706
707/// A client's value, cut so a long one cannot flood the answer.
708fn clip_value(value: &str) -> String {
709    if value.chars().count() <= 40 {
710        value.to_string()
711    } else {
712        format!("{}…", value.chars().take(40).collect::<String>())
713    }
714}
715
716/// The refusals of the response-shaping keys: a `fields` entry that is not a field name, an
717/// `include`/`with` entry that is not a relation of the entity.
718///
719/// `fields` is read against no list of the entity's fields (the framework has none), so a
720/// well-formed name the response lacks is still ignored, as the sparse fieldset documents.
721fn shaping_refusals<E: backbone_orm::EntityRepoMeta>(
722    query: &HashMap<String, String>,
723) -> Vec<crate::violation::Violation> {
724    use backbone_orm::QueryParamReason;
725    let mut refused = Vec::new();
726    for name in sparse_fields(query) {
727        if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
728            refused.push(query_violation(
729                "fields",
730                QueryParamReason::InvalidFieldName,
731                format!("`fields`: `{}` is not a field name", clip_value(&name)),
732            ));
733        }
734    }
735    let key = if query.contains_key("include") { "include" } else { "with" };
736    for name in include_relations(query) {
737        if !E::relations().iter().any(|(rel, _, _)| *rel == name) {
738            refused.push(query_violation(
739                key,
740                QueryParamReason::UnknownRelation,
741                format!("`{key}`: `{}` is not a relation of this resource", clip_value(&name)),
742            ));
743        }
744    }
745    refused
746}
747
748/// Reduce a list-style query into the filters the repository should actually see.
749///
750/// `fields`/`include`/`with` shape the response, not the row set, so they are
751/// dropped before they can be mistaken for column predicates, as is every
752/// `__`-prefixed key (the framework's internal namespace); `search` and
753/// `status` arrive as typed fields and are folded back in under the names the
754/// repository expects.
755///
756/// `/count` answers a question about the same rows `/` returns, so both must
757/// normalize identically. They did not — the count route never read the query
758/// at all — which is why this lives in one function instead of two copies.
759fn repository_filters(params: &ListQueryParams) -> HashMap<String, String> {
760    let mut filters = params.filters.clone();
761    for key in RESERVED_QUERY_KEYS {
762        filters.remove(key);
763    }
764    // `__`-prefixed keys are the framework's internal namespace (the ORM parser ignores them
765    // too): no client key there may reach the repository, whatever the parser does with it.
766    filters.retain(|key, _| !backbone_orm::is_internal_key(key));
767    if let Some(search) = params.search.clone() {
768        filters.insert("search".to_string(), search);
769    }
770    if let Some(status) = params.status.clone() {
771        filters.insert("status".to_string(), status);
772    }
773    // The typed `sort_by`/`sort_order` pair is the documented sort; it rides into the
774    // filter grammar as `orderby=<field>:<dir>`, so the same field sanitizer and allow-list
775    // the filters take validate it. A sort the filter grammar already names wins.
776    let sorted = filters.keys().any(|k| {
777        let base = k.split('[').next().unwrap_or(k);
778        base.eq_ignore_ascii_case("orderby") || base.eq_ignore_ascii_case("sort")
779    });
780    if let Some(field) = params.sort_by.as_deref().map(str::trim).filter(|f| !f.is_empty()) {
781        if !sorted {
782            let dir = match params.sort_order.as_deref().map(str::trim) {
783                Some(d) if d.eq_ignore_ascii_case("desc") => "desc",
784                _ => "asc",
785            };
786            filters.insert("orderby".to_string(), format!("{field}:{dir}"));
787        }
788    }
789    filters
790}
791
792/// The filters an aggregate should apply: the same rows `/` would list, with
793/// the aggregate's own grammar removed.
794fn aggregate_filters(params: &ListQueryParams) -> HashMap<String, String> {
795    let mut filters = repository_filters(params);
796    for key in AGGREGATE_QUERY_KEYS {
797        filters.remove(key);
798    }
799    filters
800}
801
802fn sparse_fields(query: &HashMap<String, String>) -> Vec<String> {
803    query
804        .get("fields")
805        .map(|s| {
806            s.split(',')
807                .map(str::trim)
808                .filter(|f| !f.is_empty())
809                .map(str::to_string)
810                .collect()
811        })
812        .unwrap_or_default()
813}
814
815/// Serialize a response DTO to a JSON value (for sparse-field projection).
816/// Serialization is infallible for the generated plain-serde DTOs; fall back to null.
817fn to_response_value<R: Serialize>(r: R) -> serde_json::Value {
818    serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
819}
820
821/// Apply a sparse fieldset to an already-serialized response object: keep only the
822/// requested top-level keys plus the always-on core (`id`). Non-objects and an empty
823/// request are returned unchanged; unknown requested keys are ignored.
824fn project_sparse(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
825    if fields.is_empty() {
826        return value;
827    }
828    if let serde_json::Value::Object(map) = &mut value {
829        map.retain(|k, _| k == "id" || fields.iter().any(|f| f == k));
830    }
831    value
832}
833
834/// Per-request access scope, injected by the application's auth middleware as an axum
835/// `Extension`. `Platform` sees everything (a superadmin / root caller); `Company(id)` is
836/// scoped to one tenant.
837///
838/// It governs two boundaries, and they must agree:
839/// - **row visibility** — the SQL fence (`EntityRepoMeta::company_field`), which decides
840///   which rows exist for this caller at all;
841/// - **field visibility** — `@private` fields, gated on the row's `@owner` matching.
842///
843/// # Deriving it
844///
845/// There must be exactly ONE answer to "who is the tenant" per request. Build this
846/// **from** the authenticated principal — `backbone_auth::company::CompanyContext`, whose
847/// `company_id` comes from a signed claim — never populate it independently:
848///
849/// ```rust,ignore
850/// let scope = AccessScope::Company(tenant_ctx.company_id);
851/// ```
852///
853/// Two independently-populated identities can disagree under partial wiring, and the
854/// failure is a silent cross-tenant read. `Uuid` rather than `String` so the fence and
855/// the writer cannot drift on formatting.
856#[derive(Debug, Clone, Copy, PartialEq, Eq)]
857pub enum AccessScope {
858    /// Full visibility — platform/root caller. No company fence is applied.
859    Platform,
860    /// Scoped to a single company (legal entity); only this company's rows are visible.
861    Company(uuid::Uuid),
862}
863
864impl AccessScope {
865    /// The company to fence queries by, or `None` for a platform caller.
866    ///
867    /// Feed this to the ORM's scoped read paths. Note `None` here means "platform, no
868    /// fence" — it is NOT the same as an absent `AccessScope`, which means the request was
869    /// never scoped and must be refused for a company-scoped entity.
870    pub fn company(&self) -> Option<uuid::Uuid> {
871        match self {
872            AccessScope::Platform => None,
873            AccessScope::Company(id) => Some(*id),
874        }
875    }
876}
877
878/// Enforce field-level security on an already-serialized response object: strip the
879/// entity's `@private` fields unless the caller may see them. Visibility rule —
880/// `Platform` → all; `Company(id)` → only when the row's `@owner` field equals `id`;
881/// absent scope → treated as non-owner (fail-closed). Runs BEFORE sparse projection
882/// so the security ceiling always beats a `?fields=` request.
883/// Strip the fields that never leave the server (`@sensitive`: password and token
884/// digests, bearer tokens, credentials), whoever asks. Unlike private fields there is
885/// no reader they are kept for: a platform caller or the row's owner does not get them
886/// either.
887fn strip_secrets(mut value: serde_json::Value, secret_fields: &[&str]) -> serde_json::Value {
888    if let serde_json::Value::Object(map) = &mut value {
889        for f in secret_fields {
890            map.remove(*f);
891        }
892    }
893    value
894}
895
896/// Redact the permission-gated fields of an already-serialized response object: the KEY stays
897/// (so a response shape never changes with the caller) and the value becomes `null`, for every
898/// field whose permission `caller` does not hold. A caller with no context holds nothing
899/// (fail-closed); `*:*` and the wildcards hold everything, as any other permission check.
900///
901/// A client therefore cannot tell a redacted value from a stored `null` — that is the accepted
902/// contract, and it is what keeps the gate silent: no error, no missing key.
903fn redact_gated(
904    mut value: serde_json::Value,
905    gated: &[(&'static str, &'static str)],
906    caller: Option<&backbone_auth::middleware::AuthContext>,
907) -> serde_json::Value {
908    if gated.is_empty() {
909        return value;
910    }
911    if let serde_json::Value::Object(map) = &mut value {
912        for (field, permission) in gated {
913            if !caller.is_some_and(|c| c.has_permission(permission)) {
914                map.insert(field.to_string(), serde_json::Value::Null);
915            }
916        }
917    }
918    value
919}
920
921/// [`redact_gated`] over an answer whose shape the framework does not own (a history provider's
922/// recorded rows): every object key that names a gated field, in either casing, at any depth, is
923/// set to `null` for a caller who does not hold its permission.
924fn redact_gated_deep(
925    value: &mut serde_json::Value,
926    gated: &[(&'static str, &'static str)],
927    caller: Option<&backbone_auth::middleware::AuthContext>,
928) {
929    let held = |permission: &str| caller.is_some_and(|c| c.has_permission(permission));
930    match value {
931        serde_json::Value::Object(map) => {
932            for (key, inner) in map.iter_mut() {
933                let column = camel_to_snake_case(key);
934                if let Some((_, permission)) =
935                    gated.iter().find(|(field, _)| camel_to_snake_case(field) == column)
936                {
937                    if !held(permission) {
938                        *inner = serde_json::Value::Null;
939                        continue;
940                    }
941                }
942                redact_gated_deep(inner, gated, caller);
943            }
944        }
945        serde_json::Value::Array(items) => {
946            for item in items {
947                redact_gated_deep(item, gated, caller);
948            }
949        }
950        _ => {}
951    }
952}
953
954/// The shape every generic route serves one entity in: its secrets stripped for
955/// everyone, then its private fields for anyone but the owner or a platform caller,
956/// then its permission-gated fields redacted for a caller who does not hold the
957/// permission. Write responses pass no scope, so they keep private fields from every
958/// caller; `caller` redacts by the same grants the route layer checked.
959fn secure<E: backbone_orm::EntityRepoMeta>(
960    value: serde_json::Value,
961    scope: Option<&AccessScope>,
962    caller: Option<&backbone_auth::middleware::AuthContext>,
963) -> serde_json::Value {
964    redact_gated(
965        apply_field_security(
966            strip_secrets(value, E::secret_fields()),
967            scope,
968            E::private_fields(),
969            E::owner_field(),
970        ),
971        E::field_permissions(),
972        caller,
973    )
974}
975
976/// A response body for a hand-written or generated route outside the generic handler (a
977/// state transition, a verb) that returns an entity's response DTO: the entity's secrets
978/// stripped, its private fields kept from every caller (as the generic write responses do),
979/// and its permission-gated fields redacted — with no caller named, for every caller. Prefer
980/// [`without_secrets_for`] where the route holds the request's `AuthContext`.
981pub fn without_secrets<E: backbone_orm::EntityRepoMeta, R: Serialize>(response: R) -> serde_json::Value {
982    secure::<E>(to_response_value(response), None, None)
983}
984
985/// [`without_secrets`](Self::without_secrets) for a route that holds the caller's context: the
986/// permission-gated fields a caller holds the permission for stay, the rest are redacted.
987pub fn without_secrets_for<E: backbone_orm::EntityRepoMeta, R: Serialize>(
988    caller: &backbone_auth::middleware::AuthContext,
989    response: R,
990) -> serde_json::Value {
991    secure::<E>(to_response_value(response), None, Some(caller))
992}
993
994/// A related row an `?include=` expands, as the response carries it: camelCase keys,
995/// the related model's secrets stripped, and its permission-gated fields redacted against
996/// `caller`. The row is read raw (`row_to_json`), so without this a session's `?include=user`
997/// would carry the user's password hash — and any caller holding the list's permission would
998/// read a gated field (a salary) it never held the permission for.
999fn related_row<E: backbone_orm::EntityRepoMeta>(
1000    relation: &str,
1001    table: &str,
1002    obj: serde_json::Value,
1003    caller: Option<&backbone_auth::middleware::AuthContext>,
1004) -> serde_json::Value {
1005    let row = strip_secrets(camelize_keys(obj), E::relation_secret_fields(relation));
1006    // A cross-module relation names only a table: strip what that table's entity registered,
1007    // and redact what the target registered as permission-gated (same registry idea).
1008    let row = strip_secrets(row, &backbone_orm::secret_registry::secret_fields_of(table));
1009    redact_gated(
1010        row,
1011        &backbone_orm::field_permission_registry::field_permissions_of(table),
1012        caller,
1013    )
1014}
1015
1016fn apply_field_security(
1017    mut value: serde_json::Value,
1018    scope: Option<&AccessScope>,
1019    private_fields: &[&str],
1020    owner_field: Option<&str>,
1021) -> serde_json::Value {
1022    if private_fields.is_empty() {
1023        return value;
1024    }
1025    let can_see_private = match scope {
1026        Some(AccessScope::Platform) => true,
1027        Some(AccessScope::Company(id)) => owner_field
1028            .and_then(|f| value.get(f))
1029            .and_then(|v| v.as_str())
1030            // Parse rather than compare strings: the row's owner key is serialized JSON and
1031            // the scope is a Uuid, so a formatting difference (case, braces) would silently
1032            // read as "not the owner" and strip fields from their rightful owner.
1033            .and_then(|owner| uuid::Uuid::parse_str(owner).ok())
1034            .is_some_and(|owner| owner == *id),
1035        None => false,
1036    };
1037    if !can_see_private {
1038        if let serde_json::Value::Object(map) = &mut value {
1039            for f in private_fields {
1040                map.remove(*f);
1041            }
1042        }
1043    }
1044    value
1045}
1046
1047/// Parse the reserved `include` (or `with`) key — comma-separated relation names.
1048fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
1049    query
1050        .get("include")
1051        .or_else(|| query.get("with"))
1052        .map(|s| {
1053            s.split(',')
1054                .map(str::trim)
1055                .filter(|s| !s.is_empty())
1056                .map(str::to_string)
1057                .collect()
1058        })
1059        .unwrap_or_default()
1060}
1061
1062fn snake_to_camel(s: &str) -> String {
1063    let mut out = String::with_capacity(s.len());
1064    let mut upper = false;
1065    for c in s.chars() {
1066        if c == '_' {
1067            upper = true;
1068        } else if upper {
1069            out.extend(c.to_uppercase());
1070            upper = false;
1071        } else {
1072            out.push(c);
1073        }
1074    }
1075    out
1076}
1077
1078/// Shallow-camelCase an object's top-level keys so an expanded relation (fetched
1079/// as a raw `row_to_json`) reads consistently with the camelCase response.
1080fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
1081    match v {
1082        serde_json::Value::Object(m) => serde_json::Value::Object(
1083            m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
1084        ),
1085        other => other,
1086    }
1087}
1088
1089/// Expand requested `?include=<rel>` relations into each row's JSON as a sibling
1090/// object keyed by the relation name. Batched: one fetch per relation, keyed by
1091/// the rows' foreign-key values. Only relations declared by the entity (via
1092/// `EntityRepoMeta::relations()`) are honored; unknown names are ignored. Runs
1093/// after field-security and before sparse projection. `caller` drives the
1094/// permission-gated redaction of the expanded row (see [`related_row`]).
1095///
1096/// v1 limitation: the expanded object is the raw related row (camelCased keys),
1097/// NOT run through the target's response DTO or `@private` field-security — fine
1098/// while no includable target has private fields. Revisit if that changes.
1099async fn expand_includes<S, E, C, U>(
1100    service: &S,
1101    rows: &mut [serde_json::Value],
1102    includes: &[String],
1103    caller: Option<&backbone_auth::middleware::AuthContext>,
1104) where
1105    S: CrudService<E, C, U>,
1106    E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
1107    C: Send + Sync + 'static,
1108    U: Send + Sync + 'static,
1109{
1110    if includes.is_empty() || rows.is_empty() {
1111        return;
1112    }
1113    for (rel_name, table, fk_field) in E::relations() {
1114        if !includes.iter().any(|i| i == rel_name) {
1115            continue;
1116        }
1117        let mut ids: Vec<String> = rows
1118            .iter()
1119            .filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
1120            .collect();
1121        ids.sort();
1122        ids.dedup();
1123        if ids.is_empty() {
1124            continue;
1125        }
1126        let related = service.fetch_related_json(table, &ids).await;
1127        let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
1128        for obj in related {
1129            if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
1130                by_id.insert(id, related_row::<E>(rel_name, table, obj, caller));
1131            }
1132        }
1133        for r in rows.iter_mut() {
1134            let related_obj = r
1135                .get(fk_field)
1136                .and_then(|v| v.as_str())
1137                .and_then(|id| by_id.get(id).cloned())
1138                .unwrap_or(serde_json::Value::Null);
1139            if let serde_json::Value::Object(m) = r {
1140                m.insert((*rel_name).to_string(), related_obj);
1141            }
1142        }
1143    }
1144}
1145
1146/// Hard cap on page size. Mirrors the clamp in
1147/// `backbone_orm::PaginationParams::new` (`per_page.clamp(1, 100)`); kept here so
1148/// the offset-depth check below computes the same effective offset the DB uses.
1149pub const MAX_PER_PAGE: u32 = 100;
1150
1151/// Maximum number of rows offset pagination is allowed to skip. Requests that
1152/// would page deeper than this are rejected with `400` so clients narrow their
1153/// query with filters instead of deep-scanning the table (`OFFSET` cost grows
1154/// with depth). At `MAX_PER_PAGE` this allows ~100 pages.
1155pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
1156
1157/// Reject list requests that page beyond [`MAX_PAGINATION_OFFSET`].
1158///
1159/// Returns `Some(message)` describing the violation, or `None` when the request
1160/// is within bounds. The page size is clamped to [`MAX_PER_PAGE`] first so the
1161/// computed offset matches what the repository actually issues to Postgres.
1162fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
1163    let effective_limit = limit.clamp(1, MAX_PER_PAGE);
1164    let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
1165    if offset > MAX_PAGINATION_OFFSET {
1166        Some(format!(
1167            "Result set too deep: offset {offset} exceeds the maximum of \
1168             {MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
1169        ))
1170    } else {
1171        None
1172    }
1173}
1174
1175/// Maximum number of ids / items a single batch request may contain. Larger
1176/// payloads are rejected with `400` to bound memory and transaction size.
1177///
1178/// Re-exported from the service layer, which enforces the same cap for non-HTTP
1179/// callers — see [`crate::service::MAX_BATCH_SIZE`].
1180pub use crate::service::MAX_BATCH_SIZE;
1181
1182/// Reject batch requests larger than [`MAX_BATCH_SIZE`]. Returns `Some(message)`
1183/// when over the limit, `None` otherwise.
1184fn batch_size_error(count: usize) -> Option<String> {
1185    if count > MAX_BATCH_SIZE {
1186        Some(format!(
1187            "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
1188        ))
1189    } else {
1190        None
1191    }
1192}
1193
1194/// Classify a list/query error as a client fault (bad filter or sort key) vs a
1195/// genuine server fault.
1196///
1197/// Unknown query params flow into the filter map and are injected as column
1198/// names, so a typo or a stray param (e.g. camelCase `sortOrder`) surfaces as a
1199/// Postgres `column "..." does not exist` (SQLSTATE 42703) or an
1200/// `invalid input syntax` cast error. Those are caused by the request, so they
1201/// should be a 400, not a 500.
1202fn is_bad_query_error(msg: &str) -> bool {
1203    let m = msg.to_lowercase();
1204    m.contains("does not exist")
1205        || m.contains("invalid input syntax")
1206        || m.contains("42703")
1207        // Aggregate field rejections: the caller named a column this entity
1208        // does not have, or asked to sum something that is not a number. Both
1209        // are faults in the request, so they must not read as server errors.
1210        || m.contains("not a column of this entity")
1211        || m.contains("needs a numeric column")
1212}
1213
1214/// A list request refused before it reached the repository: the sentence and each refused
1215/// parameter, named.
1216fn refused_list(violations: Vec<crate::violation::Violation>) -> PaginatedApiResponse<serde_json::Value> {
1217    PaginatedApiResponse::rejected(refusal_sentence(&violations), violations)
1218}
1219
1220/// Pagination response metadata
1221#[derive(Debug, Serialize, Deserialize, Clone)]
1222#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1223pub struct PaginationResponse {
1224    pub total: u64,
1225    pub page: u32,
1226    pub limit: u32,
1227    pub total_pages: u32,
1228    /// Keyset paging: pass back as `after=` to fetch the page that follows.
1229    /// Present only when the repository walked a deterministic order and
1230    /// more rows follow.
1231    #[serde(skip_serializing_if = "Option::is_none")]
1232    pub next_cursor: Option<String>,
1233    /// Keyset paging: pass back as `before=` to fetch the page that precedes.
1234    #[serde(skip_serializing_if = "Option::is_none")]
1235    pub prev_cursor: Option<String>,
1236    /// Whether another page follows (known without a count on a cursor walk).
1237    #[serde(skip_serializing_if = "Option::is_none")]
1238    pub has_more: Option<bool>,
1239}
1240
1241impl PaginationResponse {
1242    pub fn new(total: u64, page: u32, limit: u32) -> Self {
1243        let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
1244        Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
1245    }
1246
1247    /// From the repository's pagination info — the cursor fields ride along
1248    /// when the repository produced them (a cursor walk or a filtered list
1249    /// with a deterministic order).
1250    pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
1251        Self {
1252            total: info.total,
1253            page: info.page,
1254            limit: info.per_page,
1255            total_pages: info.total_pages,
1256            next_cursor: info.next_cursor.clone(),
1257            prev_cursor: info.prev_cursor.clone(),
1258            has_more: info.has_more,
1259        }
1260    }
1261}
1262
1263/// Generic paginated response (without success wrapper)
1264#[derive(Debug, Serialize)]
1265#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1266pub struct PaginatedResponse<T> {
1267    pub data: Vec<T>,
1268    pub meta: PaginationResponse,
1269}
1270
1271/// Paginated API response with success flag, data array, and metadata at top level
1272/// This is the preferred response format for list endpoints
1273#[derive(Debug, Serialize)]
1274#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1275pub struct PaginatedApiResponse<T> {
1276    pub success: bool,
1277    pub data: Vec<T>,
1278    pub meta: PaginationResponse,
1279    #[serde(skip_serializing_if = "Option::is_none")]
1280    pub error: Option<String>,
1281    /// The coded reasons for a refused list request, beside `error`; absent otherwise.
1282    #[serde(default, skip_serializing_if = "Option::is_none")]
1283    pub violations: Option<Vec<crate::violation::Violation>>,
1284}
1285
1286impl<T> PaginatedApiResponse<T> {
1287    /// A successful response carrying the repository's pagination info —
1288    /// the cursor fields surface only when the repository produced them.
1289    pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
1290        Self {
1291            success: true,
1292            data,
1293            meta: PaginationResponse::from_info(info),
1294            error: None,
1295            violations: None,
1296        }
1297    }
1298
1299    /// Create a successful paginated response
1300    pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
1301        Self {
1302            success: true,
1303            data,
1304            meta: PaginationResponse::new(total, page, limit),
1305            error: None,
1306            violations: None,
1307        }
1308    }
1309
1310    /// Create from a PaginatedResponse
1311    pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
1312        Self {
1313            success: true,
1314            data: resp.data,
1315            meta: resp.meta,
1316            error: None,
1317            violations: None,
1318        }
1319    }
1320
1321    /// Create an error response
1322    pub fn error(error: impl Into<String>) -> Self {
1323        Self {
1324            success: false,
1325            data: Vec::new(),
1326            meta: PaginationResponse::new(0, 0, 0),
1327            error: Some(error.into()),
1328            violations: None,
1329        }
1330    }
1331
1332    /// A refused list request: the sentence and its coded reasons.
1333    pub fn rejected(error: impl Into<String>, violations: Vec<crate::violation::Violation>) -> Self {
1334        Self {
1335            violations: (!violations.is_empty()).then_some(violations),
1336            ..Self::error(error)
1337        }
1338    }
1339}
1340
1341/// Bulk request for multiple entities
1342#[derive(Debug, Serialize, Deserialize)]
1343#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1344pub struct BulkCreateRequest<T> {
1345    pub items: Vec<T>,
1346}
1347
1348/// Bulk response
1349#[derive(Debug, Serialize, Deserialize)]
1350#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1351pub struct BulkResponse<T> {
1352    pub items: Vec<T>,
1353    pub total: usize,
1354    pub failed: usize,
1355    pub errors: Vec<String>,
1356}
1357
1358/// Upsert request (update or insert)
1359#[derive(Debug, Serialize, Deserialize)]
1360#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1361pub struct UpsertRequest<T> {
1362    pub entity: T,
1363    pub create_if_not_exists: bool,
1364}
1365
1366/// Request body carrying a list of entity ids — used by bulk soft-delete, bulk
1367/// restore, and bulk permanent-delete endpoints.
1368#[derive(Debug, Serialize, Deserialize)]
1369#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1370pub struct BatchIdsRequest {
1371    pub ids: Vec<String>,
1372}
1373
1374/// One element of the `PUT {resource}/bulk` array: an id plus the (flattened)
1375/// full update DTO for that row.
1376#[derive(Debug, Deserialize)]
1377#[serde(bound = "U: DeserializeOwned")]
1378#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1379pub struct BulkUpdateItem<U> {
1380    pub id: String,
1381    // The flattened DTO is opaque to the schema (it is generic over the concrete
1382    // update type); documented as an open object — downstream specs reference the
1383    // concrete `Update<Entity>` schema directly.
1384    #[serde(flatten)]
1385    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1386    pub data: U,
1387}
1388
1389/// One element of the per-id form of a bulk PATCH request.
1390#[derive(Debug, Deserialize)]
1391#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1392pub struct BulkPatchItem {
1393    pub id: String,
1394    pub patch: HashMap<String, serde_json::Value>,
1395}
1396
1397/// Body for `PATCH {resource}/bulk`. Accepts either a single patch applied to
1398/// many ids, or a distinct patch per id. Shape is auto-detected.
1399#[derive(Debug, Deserialize)]
1400#[serde(untagged)]
1401#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1402pub enum BulkPatchRequest {
1403    /// `{ "ids": [..], "patch": { .. } }` — same change applied to every id.
1404    Shared {
1405        ids: Vec<String>,
1406        patch: HashMap<String, serde_json::Value>,
1407    },
1408    /// `{ "items": [ { "id": .., "patch": { .. } } ] }` — per-id changes.
1409    PerItem { items: Vec<BulkPatchItem> },
1410}
1411
1412impl BulkPatchRequest {
1413    /// Flatten into `(id, field_map)` pairs the service layer consumes.
1414    fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
1415        match self {
1416            BulkPatchRequest::Shared { ids, patch } => {
1417                ids.into_iter().map(|id| (id, patch.clone())).collect()
1418            }
1419            BulkPatchRequest::PerItem { items } => {
1420                items.into_iter().map(|it| (it.id, it.patch)).collect()
1421            }
1422        }
1423    }
1424}
1425
1426/// The answer of `POST {resource}/bulk/preview`: what a bulk PATCH would do.
1427#[derive(Debug, Default, Serialize)]
1428#[serde(rename_all = "camelCase")]
1429#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1430pub struct BulkPreview {
1431    /// Rows the patch would change, each with its patched fields before and after.
1432    pub will_change: Vec<PreviewChange>,
1433    /// Rows that already hold the patched values.
1434    pub unchanged: Vec<String>,
1435    /// Rows the write would refuse, with why.
1436    pub refused: Vec<PreviewRefusal>,
1437    /// Every row named, in one of the three lists.
1438    pub total: usize,
1439}
1440
1441/// A row a bulk PATCH would change.
1442#[derive(Debug, Serialize)]
1443#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1444pub struct PreviewChange {
1445    /// The row's id.
1446    pub id: String,
1447    /// Each patched field whose value would move.
1448    pub changes: Vec<FieldChange>,
1449}
1450
1451/// One field's value before and after a previewed write.
1452#[derive(Debug, Serialize)]
1453#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1454pub struct FieldChange {
1455    /// The field, as the response names it.
1456    pub field: String,
1457    /// Its value now.
1458    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1459    pub from: serde_json::Value,
1460    /// Its value after the write.
1461    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1462    pub to: serde_json::Value,
1463}
1464
1465/// A row a bulk PATCH would refuse.
1466#[derive(Debug, Serialize)]
1467#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1468pub struct PreviewRefusal {
1469    /// The row's id.
1470    pub id: String,
1471    /// The first rule it breaks (`refused` when the error names none).
1472    pub code: String,
1473    /// The sentence the write would refuse it with.
1474    pub message: String,
1475}
1476
1477/// One dry-run row as the response shows it: `(before, after)`, or `(code, sentence)`.
1478type PreviewOutcome = Result<(serde_json::Value, serde_json::Value), (String, String)>;
1479
1480/// Sort a bulk dry run's rows into the three lists of [`BulkPreview`]. Each row is
1481/// its record before and after as the response shows it (secrets already
1482/// stripped), or the `(code, sentence)` it would be refused with; `patched` names
1483/// the fields each id's patch set (snake_case; read under their camelCase name
1484/// first), and only those are compared, so a value the
1485/// update hook moves on its own (a timestamp) is not reported as the change.
1486fn preview_answer(
1487    rows: Vec<(String, PreviewOutcome)>,
1488    patched: &HashMap<String, Vec<String>>,
1489) -> BulkPreview {
1490    let mut out = BulkPreview::default();
1491    for (id, outcome) in rows {
1492        match outcome {
1493            Ok((before, after)) => {
1494                let mut keys = patched.get(&id).cloned().unwrap_or_default();
1495                keys.sort();
1496                let changes: Vec<FieldChange> = keys
1497                    .into_iter()
1498                    .filter_map(|field| {
1499                        let read = |v: &serde_json::Value| {
1500                            v.get(snake_to_camel(&field))
1501                                .or_else(|| v.get(&field))
1502                                .cloned()
1503                                .unwrap_or(serde_json::Value::Null)
1504                        };
1505                        let (from, to) = (read(&before), read(&after));
1506                        (from != to).then_some(FieldChange { field, from, to })
1507                    })
1508                    .collect();
1509                if changes.is_empty() {
1510                    out.unchanged.push(id);
1511                } else {
1512                    out.will_change.push(PreviewChange { id, changes });
1513                }
1514            }
1515            Err((code, message)) => out.refused.push(PreviewRefusal { id, code, message }),
1516        }
1517    }
1518    out.total = out.will_change.len() + out.unchanged.len() + out.refused.len();
1519    out
1520}
1521
1522/// Filtering and sorting options
1523#[derive(Debug, Serialize, Deserialize)]
1524#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1525pub struct FilterOptions {
1526    pub filters: HashMap<String, String>,
1527    pub sort_by: Option<String>,
1528    pub sort_order: Option<SortOrder>,
1529}
1530
1531/// Sort order enum
1532#[derive(Debug, Serialize, Deserialize, Clone, Default)]
1533#[serde(rename_all = "lowercase")]
1534#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1535pub enum SortOrder {
1536    #[default]
1537    Asc,
1538    Desc,
1539}
1540
1541/// Extended pagination request with filtering and sorting
1542#[derive(Debug, Serialize, Deserialize)]
1543#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1544pub struct ListRequest {
1545    pub page: Option<u32>,
1546    pub limit: Option<u32>,
1547    pub sort_by: Option<String>,
1548    pub sort_order: Option<SortOrder>,
1549    pub filters: Option<HashMap<String, String>>,
1550}
1551
1552impl Default for ListRequest {
1553    fn default() -> Self {
1554        Self {
1555            page: Some(1),
1556            limit: Some(20),
1557            sort_by: None,
1558            sort_order: None,
1559            filters: None,
1560        }
1561    }
1562}
1563
1564// ============================================================
1565// CrudService Trait - Core Generic CRUD Interface
1566// ============================================================
1567
1568/// Core trait that application services must implement for generic CRUD operations.
1569///
1570/// This trait provides the contract for all 11 standard Backbone endpoints.
1571/// Module-specific services implement this trait with their entity types.
1572///
1573/// # Type Parameters
1574/// - `Entity`: The domain entity type
1575/// - `CreateDto`: DTO for create operations
1576/// - `UpdateDto`: DTO for update operations
1577///
1578/// # Example
1579/// ```ignore
1580/// #[async_trait]
1581/// impl CrudService<User, CreateUserDto, UpdateUserDto> for UserCrudService {
1582///     type Error = ApplicationError;
1583///     fn entity_name() -> &'static str { "User" }
1584///     // ... implement all methods
1585/// }
1586/// ```
1587#[async_trait::async_trait]
1588pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
1589where
1590    // `'static` is required so the default batch methods (which hold these types
1591    // across `.await` points) satisfy async-trait's `'async_trait` bound.
1592    Entity: Send + Sync + 'static,
1593    CreateDto: Send + Sync + 'static,
1594    UpdateDto: Send + Sync + 'static,
1595{
1596    /// Error type for service operations
1597    type Error: std::error::Error + Send + Sync;
1598
1599    /// The named rules a refused write broke, when the error carries them, so
1600    /// the response can list them beside the sentence. `None` by default.
1601    fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
1602        let _ = err;
1603        None
1604    }
1605
1606    /// The status a failed call answers with, decided by the error's kind rather than by
1607    /// searching its message. `None` (the default) leaves each route its own fallback: 422 when
1608    /// [`violations_of`](Self::violations_of) names rules, else the route's usual status.
1609    /// `GenericCrudService` answers by variant (see [`service_error_status`]).
1610    fn status_of(err: &Self::Error) -> Option<axum::http::StatusCode> {
1611        let _ = err;
1612        None
1613    }
1614
1615    /// Whether this service has a real upsert. `true` by default, so a hand-written service
1616    /// that implements one keeps its route; `GenericCrudService` answers `false`, and the
1617    /// generic `POST {resource}/upsert` route then answers 501 rather than creating a duplicate.
1618    fn supports_upsert() -> bool {
1619        true
1620    }
1621
1622    /// Entity name for error messages (e.g., "User", "Role")
1623    fn entity_name() -> &'static str;
1624
1625    /// Hydrate `?include=` relations: fetch rows from `table` by id list, as JSON.
1626    /// Default: no expansion. `GenericCrudService` delegates to its repository.
1627    async fn fetch_related_json(
1628        &self,
1629        _table: &str,
1630        _ids: &[String],
1631    ) -> Vec<serde_json::Value> {
1632        Vec::new()
1633    }
1634
1635    /// 1. List entities with pagination and filters
1636    async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1637
1638    /// `list`, carrying the pagination info (cursor positions included) so
1639    /// the HTTP layer can surface keyset paging. Default: the tuple form —
1640    /// generated services override this with the repository's cursors.
1641    async fn list_with_info(
1642        &self,
1643        page: u32,
1644        limit: u32,
1645        filters: HashMap<String, String>,
1646    ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
1647        let (rows, total) = self.list(page, limit, filters).await?;
1648        Ok((
1649            rows,
1650            backbone_orm::repository::PaginationInfo::new(page, limit, total),
1651        ))
1652    }
1653
1654    /// 2. Create a new entity
1655    async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1656
1657    /// 3. Get entity by ID
1658    async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1659
1660    /// 4. Full update of entity
1661    async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
1662
1663    /// 5. Partial update with specific fields
1664    async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
1665
1666    /// 6. Soft delete entity
1667    async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
1668
1669    /// 7. Bulk create multiple entities
1670    async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
1671
1672    /// 8. Upsert (create or update)
1673    async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1674
1675    /// 9. List deleted entities (trash)
1676    async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
1677
1678    /// 10. Restore soft-deleted entity
1679    async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1680
1681    /// 11. Permanently delete all soft-deleted entities
1682    async fn empty_trash(&self) -> Result<u64, Self::Error>;
1683
1684    /// 12. Get deleted entity by ID (from trash)
1685    async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1686
1687    /// 13. Permanently delete a single soft-deleted entity by ID
1688    async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
1689
1690    /// 14. List deleted entities with filters
1691    async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1692
1693    /// 15. Count active (non-deleted) entities
1694    async fn count_active(&self) -> Result<u64, Self::Error>;
1695
1696    /// The schema-qualified table this service reads, when it has one.
1697    ///
1698    /// Used to key a record's history. Derived from the mount path instead,
1699    /// this would silently return an empty history whenever a route segment
1700    /// and a Postgres schema disagreed — so it is read from the repository,
1701    /// which is the same string the capture trigger writes.
1702    fn table_name(&self) -> Option<&str> {
1703        None
1704    }
1705
1706    /// Group and reduce the rows `list` would return, under the same filters.
1707    ///
1708    /// The default refuses rather than inventing an answer — see the repository
1709    /// trait for why a zero-filled default would be worse than an error.
1710    async fn aggregate(
1711        &self,
1712        spec: &backbone_orm::repository::AggregateSpec,
1713        filters: HashMap<String, String>,
1714    ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
1715
1716    /// Count active entities MATCHING the caller's filters.
1717    ///
1718    /// The generated client has always sent filters to `/count`; the handler
1719    /// never read them, so a filtered count silently answered with the whole
1720    /// table. Nothing caught it because the wrong number is a plausible one.
1721    ///
1722    /// The default delegates to `list`, whose total is already built from the
1723    /// same where-clause as its data query — so the filtered count is correct
1724    /// by construction rather than by a second implementation of the same
1725    /// filter parsing, which is exactly how the two drifted apart. It asks for
1726    /// a single row because the rows are discarded; an implementor that can
1727    /// count without fetching may override.
1728    async fn count_active_filtered(
1729        &self,
1730        filters: HashMap<String, String>,
1731    ) -> Result<u64, Self::Error> {
1732        self.list(1, 1, filters).await.map(|(_, total)| total)
1733    }
1734
1735    /// 16. Count deleted entities in trash
1736    async fn count_deleted(&self) -> Result<u64, Self::Error>;
1737
1738    // ── Atomic batch operations ───────────────────────────────────────────────
1739    //
1740    // The default implementations are best-effort loops over the single-row
1741    // methods, provided so non-generic implementors keep compiling. The blanket
1742    // impl on `GenericCrudService` overrides them with transactional,
1743    // all-or-nothing versions (which is what every generated service runs).
1744
1745    /// 17. Soft-delete many entities by id. Returns the number affected.
1746    async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1747        let mut n = 0;
1748        for id in ids {
1749            if self.soft_delete(&id).await? {
1750                n += 1;
1751            }
1752        }
1753        Ok(n)
1754    }
1755
1756    /// 18. Restore many soft-deleted entities by id. Returns the restored rows.
1757    async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1758        let mut out = Vec::with_capacity(ids.len());
1759        for id in ids {
1760            if let Some(e) = self.restore(&id).await? {
1761                out.push(e);
1762            }
1763        }
1764        Ok(out)
1765    }
1766
1767    /// 19. Permanently delete many soft-deleted entities by id.
1768    async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1769        let mut n = 0;
1770        for id in ids {
1771            if self.permanent_delete(&id).await? {
1772                n += 1;
1773            }
1774        }
1775        Ok(n)
1776    }
1777
1778    /// 20. Restore every soft-deleted entity. Returns the number restored.
1779    ///
1780    /// Default is a no-op (`0`) — only the `GenericCrudService` blanket impl can
1781    /// perform this without an id list.
1782    async fn restore_all(&self) -> Result<u64, Self::Error> {
1783        Ok(0)
1784    }
1785
1786    /// 21. Full-update many entities. Each item is `(id, UpdateDto)`.
1787    async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1788        let mut out = Vec::with_capacity(items.len());
1789        for (id, dto) in items {
1790            if let Some(e) = self.update(&id, dto).await? {
1791                out.push(e);
1792            }
1793        }
1794        Ok(out)
1795    }
1796
1797    /// 22. Partial-update many entities. Each item is `(id, field_map)`.
1798    async fn bulk_partial_update(
1799        &self,
1800        items: Vec<(String, HashMap<String, serde_json::Value>)>,
1801    ) -> Result<Vec<Entity>, Self::Error> {
1802        let mut out = Vec::with_capacity(items.len());
1803        for (id, fields) in items {
1804            if let Some(e) = self.partial_update(&id, fields).await? {
1805                out.push(e);
1806            }
1807        }
1808        Ok(out)
1809    }
1810
1811    /// 23. What [`bulk_partial_update`](Self::bulk_partial_update) would do, with
1812    /// nothing written: each row as it is and as it would become, or why the
1813    /// write would refuse it. `Ok(None)` (the default) means this service has no
1814    /// dry run, and the route says so instead of guessing.
1815    async fn preview_bulk_partial_update(
1816        &self,
1817        items: Vec<(String, HashMap<String, serde_json::Value>)>,
1818    ) -> Result<Option<Vec<BulkPreviewRow<Entity, Self::Error>>>, Self::Error> {
1819        let _ = items;
1820        Ok(None)
1821    }
1822}
1823
1824/// One row of a bulk partial update's dry run: the record as it is and as the
1825/// patch would leave it, or the error the write would refuse it with.
1826pub struct BulkPreviewRow<Entity, Err> {
1827    /// The id the patch named.
1828    pub id: String,
1829    /// `(before, after)`, or why the row would be refused.
1830    pub outcome: Result<(Entity, Entity), Err>,
1831}
1832
1833// ============================================================
1834// BackboneCrudHandler - Generic Axum Router Builder
1835// ============================================================
1836
1837/// Generic Backbone CRUD handler that provides all 11 endpoints as an Axum router.
1838///
1839/// This handler wraps a `CrudService` implementation and generates all standard
1840/// Backbone endpoints automatically.
1841///
1842/// # Type Parameters
1843/// - `S`: Service implementing `CrudService`
1844/// - `E`: Entity type
1845/// - `C`: Create DTO type
1846/// - `U`: Update DTO type
1847/// - `R`: Response DTO type (must implement `From<E>`)
1848///
1849/// # Example
1850/// ```ignore
1851/// let user_crud = UserCrudService::new(user_service);
1852/// let routes = BackboneCrudHandler::<UserCrudService, UserAggregate, CreateUserDto, UpdateUserDto, UserResponseDto>
1853///     ::routes(Arc::new(user_crud), "/api/v1/users");
1854/// ```
1855pub struct BackboneCrudHandler<S, E, C, U, R>
1856where
1857    S: CrudService<E, C, U> + 'static,
1858    E: Serialize + Send + Sync + 'static,
1859    C: DeserializeOwned + Send + Sync + 'static,
1860    U: DeserializeOwned + Send + Sync + 'static,
1861    R: From<E> + Serialize + Send + Sync + 'static,
1862{
1863    service: Arc<S>,
1864    _phantom: std::marker::PhantomData<(E, C, U, R)>,
1865}
1866
1867impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1868where
1869    S: CrudService<E, C, U> + 'static,
1870    E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1871    C: DeserializeOwned + Send + Sync + 'static,
1872    U: DeserializeOwned + Send + Sync + 'static,
1873    R: From<E> + Serialize + Send + Sync + 'static,
1874{
1875    pub fn new(service: Arc<S>) -> Self {
1876        Self {
1877            service,
1878            _phantom: std::marker::PhantomData,
1879        }
1880    }
1881
1882    /// Create Axum router with all 16 Backbone endpoints (reads + writes).
1883    ///
1884    /// Equivalent to `read_routes(...).merge(write_routes(...))`. Use the
1885    /// split variants when reads and writes need different middleware
1886    /// (e.g., public reads + authenticated writes).
1887    ///
1888    /// # Routes Created
1889    /// 1. `GET {base_path}` - List with pagination
1890    /// 2. `POST {base_path}` - Create
1891    /// 3. `GET {base_path}/:id` - Get by ID
1892    /// 4. `PUT {base_path}/:id` - Full update
1893    /// 5. `PATCH {base_path}/:id` - Partial update
1894    /// 6. `DELETE {base_path}/:id` - Soft delete
1895    /// 7. `POST {base_path}/bulk` - Bulk create
1896    /// 8. `POST {base_path}/upsert` - Upsert
1897    /// 9. `GET {base_path}/trash` - List deleted (with filters)
1898    /// 10. `POST {base_path}/:id/restore` - Restore
1899    /// 11. `DELETE {base_path}/empty` - Empty trash
1900    /// 12. `GET {base_path}/:id/deleted` - Get deleted by ID
1901    /// 13. `DELETE {base_path}/trash/:id` - Permanent delete from trash
1902    /// 14. (Uses route 9 with filters)
1903    /// 15. `GET {base_path}/count` - Count active entities
1904    /// 16. `GET {base_path}/trash/count` - Count deleted entities
1905    ///
1906    /// These routes check no permission: whatever guards the mount is the only barrier. Generated
1907    /// modules call [`routes_guarded`](Self::routes_guarded) instead, and `schema doctor` flags a
1908    /// hand-written call of this one.
1909    pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1910    where
1911        S: Clone,
1912    {
1913        Self::read_routes(service.clone(), base_path)
1914            .merge(Self::write_routes(service, base_path))
1915    }
1916
1917    /// [`routes`](Self::routes) with every route guarded by its permission. This is what the
1918    /// generated `create_<entity>_routes` calls.
1919    pub fn routes_guarded(
1920        service: Arc<S>,
1921        base_path: &str,
1922        permissions: crate::permission::CrudPermissions,
1923    ) -> axum::Router<()>
1924    where
1925        S: Clone,
1926    {
1927        Self::read_routes_guarded(service.clone(), base_path, permissions.clone())
1928            .merge(Self::write_routes_guarded(service, base_path, permissions))
1929    }
1930
1931    /// Wrap `route` with the permission check `gate` selects, when permissions are in force.
1932    fn gate(
1933        perms: &Option<crate::permission::CrudPermissions>,
1934        gate: crate::permission::Gate,
1935        route: axum::routing::MethodRouter<()>,
1936    ) -> axum::routing::MethodRouter<()> {
1937        match perms {
1938            // A public gate is left unwrapped: no `AuthContext` is read, so an unauthenticated
1939            // request is served where the service mounted the route outside its auth layer.
1940            Some(p) if p.is_public(gate) => route,
1941            Some(p) => crate::permission::guarded_needs(route, p.needs(gate)),
1942            None => route,
1943        }
1944    }
1945
1946    /// Create Axum router with only the read (GET) endpoints.
1947    ///
1948    /// Safe to expose publicly (e.g., for reference data like countries or
1949    /// categories) without auth middleware. Pair with `write_routes` under
1950    /// an auth layer when mutations must be restricted.
1951    ///
1952    /// # Routes Created
1953    /// - `GET {base_path}` - List with pagination
1954    /// - `GET {base_path}/:id` - Get by ID
1955    /// - `GET {base_path}/trash` - List deleted (with filters)
1956    /// - `GET {base_path}/:id/deleted` - Get deleted by ID
1957    /// - `GET {base_path}/count` - Count active entities
1958    /// - `GET {base_path}/trash/count` - Count deleted entities
1959    pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1960    where
1961        S: Clone,
1962    {
1963        Self::read_routes_with(service, base_path, None)
1964    }
1965
1966    /// [`read_routes`](Self::read_routes), each route refusing a caller who lacks the permission
1967    /// `permissions` names for it (`<entity>:read` unless the schema says otherwise).
1968    ///
1969    /// A request with no `AuthContext` is `401`, a caller whose context lacks the permission `403`
1970    /// with the violation `permission_required`. Nothing is allowed by default: with no grant
1971    /// resolver installed the permissions are the ones already on the `AuthContext`.
1972    pub fn read_routes_guarded(
1973        service: Arc<S>,
1974        base_path: &str,
1975        permissions: crate::permission::CrudPermissions,
1976    ) -> axum::Router<()>
1977    where
1978        S: Clone,
1979    {
1980        Self::read_routes_with(service, base_path, Some(permissions))
1981    }
1982
1983    fn read_routes_with(
1984        service: Arc<S>,
1985        base_path: &str,
1986        perms: Option<crate::permission::CrudPermissions>,
1987    ) -> axum::Router<()>
1988    where
1989        S: Clone,
1990    {
1991        use crate::permission::Gate;
1992        // Building a generic surface for this entity records its secrets and its permission-gated
1993        // fields under its table, so a cross-module `?include=` of the table strips the one and
1994        // redacts the other (see `secret_registry`, `field_permission_registry`).
1995        if let Some(table) = service.table_name() {
1996            backbone_orm::secret_registry::register(table, E::secret_fields());
1997            backbone_orm::field_permission_registry::register(table, E::field_permissions());
1998        }
1999        use axum::{
2000            extract::Path,
2001            routing::get,
2002            Extension, Router,
2003        };
2004
2005        let handler = Arc::new(Self::new(service));
2006
2007        Router::new()
2008            // GET /collection - List
2009            .route(base_path, Self::gate(&perms, Gate::Read, get({
2010                let h = handler.clone();
2011                move |query: ListQuery,
2012                      access: Option<Extension<AccessScope>>,
2013                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2014                    Self::list_handler(h, query, access, caller).await
2015                }
2016            })))
2017            // GET /collection/trash - List deleted
2018            .route(&format!("{}/trash", base_path), Self::gate(&perms, Gate::Read, get({
2019                let h = handler.clone();
2020                move |query: ListQuery,
2021                      access: Option<Extension<AccessScope>>,
2022                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2023                    Self::list_deleted_handler(h, query, access, caller).await
2024                }
2025            })))
2026            // GET /collection/:id - Get by ID
2027            .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Read, get({
2028                let h = handler.clone();
2029                move |path: Path<String>,
2030                      query: ListQuery,
2031                      access: Option<Extension<AccessScope>>,
2032                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2033                    Self::get_handler(h, path, query, access, caller).await
2034                }
2035            })))
2036            // GET /collection/:id/deleted - Get deleted by ID
2037            .route(&format!("{}/:id/deleted", base_path), Self::gate(&perms, Gate::Read, get({
2038                let h = handler.clone();
2039                move |path: Path<String>,
2040                      query: ListQuery,
2041                      access: Option<Extension<AccessScope>>,
2042                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2043                    Self::get_deleted_handler(h, path, query, access, caller).await
2044                }
2045            })))
2046            // GET /collection/count - Count active entities
2047            // GET /collection/:id/history - This record's recorded changes
2048            .route(&format!("{}/:id/history", base_path), Self::gate(&perms, Gate::Read, get({
2049                let h = handler.clone();
2050                move |path: axum::extract::Path<String>,
2051                      query: ListQuery,
2052                      provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2053                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2054                    Self::history_handler(h, path, query, provider, caller).await
2055                }
2056            })))
2057            // GET /collection/aggregate - Group and reduce
2058            .route(&format!("{}/aggregate", base_path), Self::gate(&perms, Gate::Read, get({
2059                let h = handler.clone();
2060                move |query: ListQuery,
2061                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2062                    Self::aggregate_handler(h, query, caller).await
2063                }
2064            })))
2065            .route(&format!("{}/count", base_path), Self::gate(&perms, Gate::Read, get({
2066                let h = handler.clone();
2067                move |query: ListQuery,
2068                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2069                    Self::count_active_handler(h, query, caller).await
2070                }
2071            })))
2072            // GET /collection/trash/count - Count deleted entities
2073            .route(&format!("{}/trash/count", base_path), Self::gate(&perms, Gate::Read, get({
2074                let h = handler.clone();
2075                move || async move {
2076                    Self::count_deleted_handler(h).await
2077                }
2078            })))
2079    }
2080
2081    /// Create Axum router with only the write (mutation) endpoints.
2082    ///
2083    /// These routes should not be publicly exposed. Wrap them with an auth
2084    /// middleware (see `BackboneCrudHandler` module docs) before nesting
2085    /// into the application router.
2086    ///
2087    /// # Routes Created
2088    /// - `POST {base_path}` - Create
2089    /// - `POST {base_path}/bulk` - Bulk create
2090    /// - `POST {base_path}/upsert` - Upsert
2091    /// - `DELETE {base_path}/empty` - Empty trash
2092    /// - `DELETE {base_path}/trash/:id` - Permanent delete from trash
2093    /// - `PUT {base_path}/:id` - Full update
2094    /// - `PATCH {base_path}/:id` - Partial update
2095    /// - `DELETE {base_path}/:id` - Soft delete
2096    /// - `POST {base_path}/:id/restore` - Restore
2097    pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
2098    where
2099        S: Clone,
2100    {
2101        Self::write_routes_with(service, base_path, None)
2102    }
2103
2104    /// [`write_routes`](Self::write_routes), each route refusing a caller who lacks the permission
2105    /// `permissions` names for it: `create` for a create, bulk create, `update` for a put, patch
2106    /// and bulk update, `create` and `update` for an upsert, `delete`, `restore`, and `purge` for
2107    /// a hard delete or an emptied trash. See [`read_routes_guarded`](Self::read_routes_guarded).
2108    pub fn write_routes_guarded(
2109        service: Arc<S>,
2110        base_path: &str,
2111        permissions: crate::permission::CrudPermissions,
2112    ) -> axum::Router<()>
2113    where
2114        S: Clone,
2115    {
2116        Self::write_routes_with(service, base_path, Some(permissions))
2117    }
2118
2119    fn write_routes_with(
2120        service: Arc<S>,
2121        base_path: &str,
2122        perms: Option<crate::permission::CrudPermissions>,
2123    ) -> axum::Router<()>
2124    where
2125        S: Clone,
2126    {
2127        use crate::permission::Gate;
2128        // Building a generic surface for this entity records its secrets and its permission-gated
2129        // fields under its table, so a cross-module `?include=` of the table strips the one and
2130        // redacts the other (see `secret_registry`, `field_permission_registry`).
2131        if let Some(table) = service.table_name() {
2132            backbone_orm::secret_registry::register(table, E::secret_fields());
2133            backbone_orm::field_permission_registry::register(table, E::field_permissions());
2134        }
2135        use axum::{
2136            extract::Path,
2137            routing::{delete, patch, post, put},
2138            Extension, Router,
2139        };
2140
2141        let handler = Arc::new(Self::new(service));
2142
2143        Router::new()
2144            // POST /collection - Create
2145            .route(base_path, Self::gate(&perms, Gate::Create, post({
2146                let h = handler.clone();
2147                move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2148                      body: crate::extractors::WriteBody<C>| async move {
2149                    Self::create_handler(h, body, caller).await
2150                }
2151            })))
2152            // POST /collection/bulk - Bulk create
2153            .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Create, post({
2154                let h = handler.clone();
2155                move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2156                      body: crate::extractors::WriteBody<Vec<C>>| async move {
2157                    Self::bulk_create_handler(h, body, caller).await
2158                }
2159            })))
2160            // POST /collection/upsert - Upsert
2161            .route(&format!("{}/upsert", base_path), Self::gate(&perms, Gate::CreateUpdate, post({
2162                let h = handler.clone();
2163                move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2164                      body: crate::extractors::WriteBody<C>| async move {
2165                    Self::upsert_handler(h, body, caller).await
2166                }
2167            })))
2168            // POST /collection/delete/bulk - Bulk soft delete by ids
2169            .route(&format!("{}/delete/bulk", base_path), Self::gate(&perms, Gate::Delete, post({
2170                let h = handler.clone();
2171                move |body: JsonOrForm<BatchIdsRequest>| async move {
2172                    Self::bulk_delete_handler(h, body).await
2173                }
2174            })))
2175            // POST /collection/restore/bulk - Bulk restore by ids
2176            .route(&format!("{}/restore/bulk", base_path), Self::gate(&perms, Gate::Restore, post({
2177                let h = handler.clone();
2178                move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2179                      body: JsonOrForm<BatchIdsRequest>| async move {
2180                    Self::bulk_restore_handler(h, body, caller).await
2181                }
2182            })))
2183            // POST /collection/restore/all - Restore all soft-deleted
2184            .route(&format!("{}/restore/all", base_path), Self::gate(&perms, Gate::Restore, post({
2185                let h = handler.clone();
2186                move || async move {
2187                    Self::restore_all_handler(h).await
2188                }
2189            })))
2190            // DELETE /collection/trash/bulk - Bulk hard delete by ids
2191            // (registered alongside /trash/:id; matchit prioritises the static segment)
2192            .route(&format!("{}/trash/bulk", base_path), Self::gate(&perms, Gate::Purge, delete({
2193                let h = handler.clone();
2194                move |body: JsonOrForm<BatchIdsRequest>| async move {
2195                    Self::bulk_permanent_delete_handler(h, body).await
2196                }
2197            })))
2198            // PUT /collection/bulk - Bulk full update
2199            .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Update, put({
2200                let h = handler.clone();
2201                move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2202                      body: JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>| async move {
2203                    Self::bulk_update_handler(h, body, caller).await
2204                }
2205            })))
2206            // PATCH /collection/bulk - Bulk partial update (shared or per-id)
2207            .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Update, patch({
2208                let h = handler.clone();
2209                move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2210                      body: JsonOrForm<BulkPatchRequest>| async move {
2211                    Self::bulk_patch_handler(h, body, caller).await
2212                }
2213            })))
2214            // POST /collection/bulk/preview - What the bulk partial update would do, nothing written
2215            .route(&format!("{}/bulk/preview", base_path), Self::gate(&perms, Gate::Update, post({
2216                let h = handler.clone();
2217                move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2218                      body: JsonOrForm<BulkPatchRequest>| async move {
2219                    Self::bulk_patch_preview_handler(h, body, caller).await
2220                }
2221            })))
2222            // DELETE /collection/empty - Empty trash
2223            .route(&format!("{}/empty", base_path), Self::gate(&perms, Gate::Purge, delete({
2224                let h = handler.clone();
2225                move || async move {
2226                    Self::empty_trash_handler(h).await
2227                }
2228            })))
2229            // DELETE /collection/trash/:id - Permanent delete from trash
2230            .route(&format!("{}/trash/:id", base_path), Self::gate(&perms, Gate::Purge, delete({
2231                let h = handler.clone();
2232                move |path: Path<String>| async move {
2233                    Self::permanent_delete_handler(h, path).await
2234                }
2235            })))
2236            // PUT /collection/:id - Full update
2237            .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Update, put({
2238                let h = handler.clone();
2239                move |path: Path<String>,
2240                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2241                      body: crate::extractors::WriteBody<U>| async move {
2242                    Self::update_handler(h, path, body, caller).await
2243                }
2244            })))
2245            // PATCH /collection/:id - Partial update
2246            .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Update, patch({
2247                let h = handler.clone();
2248                move |path: Path<String>,
2249                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2250                      body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
2251                    Self::partial_update_handler(h, path, body, caller).await
2252                }
2253            })))
2254            // DELETE /collection/:id - Soft delete
2255            .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Delete, delete({
2256                let h = handler.clone();
2257                move |path: Path<String>| async move {
2258                    Self::delete_handler(h, path).await
2259                }
2260            })))
2261            // POST /collection/:id/restore - Restore
2262            .route(&format!("{}/:id/restore", base_path), Self::gate(&perms, Gate::Restore, post({
2263                let h = handler.clone();
2264                move |path: Path<String>,
2265                      caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2266                    Self::restore_handler(h, path, caller).await
2267                }
2268            })))
2269            // Every write stages its events under the request's correlation id.
2270            .layer(axum::middleware::from_fn(correlation_middleware))
2271    }
2272
2273    // ============================================================
2274    // Handler Implementations
2275    // ============================================================
2276
2277    /// The response for a failed service call: the status the service gives the error's kind
2278    /// ([`CrudService::status_of`]), else 422 when it names broken rules, else `fallback`. A
2279    /// server fault answers with a generic sentence; its detail is logged.
2280    fn failure<T>(err: &S::Error, fallback: axum::http::StatusCode) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
2281        let violations = S::violations_of(err);
2282        let status = S::status_of(err).unwrap_or(match &violations {
2283            Some(v) if is_query_refusal(v) => axum::http::StatusCode::BAD_REQUEST,
2284            Some(_) => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
2285            None => fallback,
2286        });
2287        error_at(status, violations, err)
2288    }
2289
2290    /// The status, sentence and violations for a failed list-shaped call (a list, the trash,
2291    /// a count, an aggregate): a query parameter the request got wrong is 400, named on its
2292    /// own path (`sort_by`, `priority[foo]`, `after`, …); anything else goes through
2293    /// [`Self::failure`].
2294    fn query_failure_parts(
2295        err: &S::Error,
2296        params: &ListQueryParams,
2297        fallback: axum::http::StatusCode,
2298    ) -> (axum::http::StatusCode, String, Vec<crate::violation::Violation>) {
2299        use axum::http::StatusCode;
2300        let violations = S::violations_of(err);
2301        if let Some(v) = violations.as_ref().filter(|v| is_query_refusal(v)) {
2302            let v = rename_typed_sort(v.clone(), params);
2303            return (StatusCode::BAD_REQUEST, refusal_sentence(&v), v);
2304        }
2305        let msg = err.to_string();
2306        if violations.is_none() && is_bad_query_error(&msg) {
2307            // A refusal the repository could not match to a parameter (a hand-written
2308            // repository, a statement built from something other than the query). The detail
2309            // is the database's sentence (SQL, column and table names): logged, never sent.
2310            tracing::debug!(target: "backbone::http", error = %msg, "list query refused");
2311            let message = "Invalid query parameter or filter";
2312            return (
2313                StatusCode::BAD_REQUEST,
2314                message.to_string(),
2315                vec![crate::violation::Violation::new("", crate::violation::INVALID_QUERY, message)],
2316            );
2317        }
2318        let (status, axum::Json(body)) = Self::failure::<()>(err, fallback);
2319        (status, body.error.unwrap_or_default(), body.violations.unwrap_or_default())
2320    }
2321
2322    /// [`Self::query_failure_parts`] as a list response.
2323    fn list_failure(
2324        err: &S::Error,
2325        params: &ListQueryParams,
2326    ) -> (axum::http::StatusCode, axum::Json<PaginatedApiResponse<serde_json::Value>>) {
2327        let (status, error, violations) =
2328            Self::query_failure_parts(err, params, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
2329        (status, axum::Json(PaginatedApiResponse::rejected(error, violations)))
2330    }
2331
2332    /// [`Self::query_failure_parts`] as a single-value response (a count, an aggregate).
2333    fn query_failure<T>(
2334        err: &S::Error,
2335        params: &ListQueryParams,
2336    ) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
2337        let (status, error, violations) =
2338            Self::query_failure_parts(err, params, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
2339        let body = if violations.is_empty() {
2340            ApiResponse::error(error)
2341        } else {
2342            ApiResponse::rejected(error, violations)
2343        };
2344        (status, axum::Json(body))
2345    }
2346
2347    async fn list_handler(
2348        handler: Arc<Self>,
2349        ListQuery(params): ListQuery,
2350        access: Option<axum::Extension<AccessScope>>,
2351        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2352    ) -> impl axum::response::IntoResponse {
2353        use axum::{http::StatusCode, Json};
2354
2355        let caller = caller.map(|axum::Extension(c)| c);
2356        let params = params.normalized();
2357        let refused = query_refusals::<E>(&params, true, caller.as_ref());
2358        if !refused.is_empty() {
2359            return (StatusCode::BAD_REQUEST, Json(refused_list(refused)));
2360        }
2361
2362        // Sparse fieldset (`?fields=a,b,c`) is response-shaping, not a filter — read it,
2363        // then drop the reserved keys so they're never passed to the repository.
2364        let fields = sparse_fields(&params.filters);
2365        let includes = include_relations(&params.filters);
2366        let scope = access.map(|axum::Extension(s)| s);
2367
2368        let filters = repository_filters(&params);
2369
2370        match handler
2371            .service
2372            .list_with_info(params.page, params.limit, filters)
2373            .await
2374        {
2375            Ok((entities, info)) => {
2376                // Security ceiling first, then relation expansion (batched across all
2377                // rows), then sparse projection.
2378                let mut rows: Vec<serde_json::Value> = entities
2379                    .into_iter()
2380                    .map(|e| {
2381                        secure::<E>(to_response_value(R::from(e)), scope.as_ref(), caller.as_ref())
2382                    })
2383                    .collect();
2384                expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes, caller.as_ref()).await;
2385                let items: Vec<serde_json::Value> =
2386                    rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2387                let response = PaginatedApiResponse::ok_with_info(items, &info);
2388                (StatusCode::OK, Json(response))
2389            }
2390            Err(e) => Self::list_failure(&e, &params),
2391        }
2392    }
2393
2394    async fn create_handler(
2395        handler: Arc<Self>,
2396        crate::extractors::WriteBody(dto, keys): crate::extractors::WriteBody<C>,
2397        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2398    ) -> impl axum::response::IntoResponse {
2399        use axum::{http::StatusCode, Json};
2400
2401        let caller = caller.map(|axum::Extension(c)| c);
2402        let refused = gated_write_refusals::<E>(keys.into_iter(), caller.as_ref());
2403        if !refused.is_empty() {
2404            return (
2405                StatusCode::UNPROCESSABLE_ENTITY,
2406                Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2407            );
2408        }
2409        match handler.service.create(dto).await {
2410            Ok(entity) => {
2411                let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2412                (StatusCode::CREATED, Json(ApiResponse::ok(response)))
2413            }
2414            Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2415        }
2416    }
2417
2418    async fn get_handler(
2419        handler: Arc<Self>,
2420        axum::extract::Path(id): axum::extract::Path<String>,
2421        ListQuery(params): ListQuery,
2422        access: Option<axum::Extension<AccessScope>>,
2423        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2424    ) -> impl axum::response::IntoResponse {
2425        use axum::{http::StatusCode, Json};
2426
2427        let caller = caller.map(|axum::Extension(c)| c);
2428        if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2429            return refused;
2430        }
2431        let refused = shaping_refusals::<E>(&params.filters);
2432        if !refused.is_empty() {
2433            return (
2434                StatusCode::BAD_REQUEST,
2435                Json(ApiResponse::rejected(refusal_sentence(&refused), refused)),
2436            );
2437        }
2438
2439        let fields = sparse_fields(&params.filters);
2440        let includes = include_relations(&params.filters);
2441        let scope = access.map(|axum::Extension(s)| s);
2442
2443        match handler.service.get_by_id(&id).await {
2444            Ok(Some(entity)) => {
2445                let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref(), caller.as_ref());
2446                let mut rows = [secured];
2447                expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes, caller.as_ref()).await;
2448                let [secured] = rows;
2449                let value = project_sparse(secured, &fields);
2450                (StatusCode::OK, Json(ApiResponse::ok(value)))
2451            }
2452            Ok(None) => {
2453                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2454            }
2455            Err(e) => {
2456                Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2457            }
2458        }
2459    }
2460
2461    async fn update_handler(
2462        handler: Arc<Self>,
2463        axum::extract::Path(id): axum::extract::Path<String>,
2464        crate::extractors::WriteBody(dto, keys): crate::extractors::WriteBody<U>,
2465        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2466    ) -> impl axum::response::IntoResponse {
2467        use axum::{http::StatusCode, Json};
2468
2469        let caller = caller.map(|axum::Extension(c)| c);
2470        if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2471            return refused;
2472        }
2473        let refused = gated_write_refusals::<E>(keys.into_iter(), caller.as_ref());
2474        if !refused.is_empty() {
2475            return (
2476                StatusCode::UNPROCESSABLE_ENTITY,
2477                Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2478            );
2479        }
2480
2481        match handler.service.update(&id, dto).await {
2482            Ok(Some(entity)) => {
2483                let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2484                (StatusCode::OK, Json(ApiResponse::ok(response)))
2485            }
2486            Ok(None) => {
2487                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2488            }
2489            Err(e) => {
2490                Self::failure(&e, StatusCode::BAD_REQUEST)
2491            }
2492        }
2493    }
2494
2495    async fn partial_update_handler(
2496        handler: Arc<Self>,
2497        axum::extract::Path(id): axum::extract::Path<String>,
2498        JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
2499        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2500    ) -> impl axum::response::IntoResponse {
2501        use axum::{http::StatusCode, Json};
2502
2503        let caller = caller.map(|axum::Extension(c)| c);
2504        if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2505            return refused;
2506        }
2507        let refused = gated_write_refusals::<E>(fields.keys().cloned(), caller.as_ref());
2508        if !refused.is_empty() {
2509            return (
2510                StatusCode::UNPROCESSABLE_ENTITY,
2511                Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2512            );
2513        }
2514
2515        // Normalize incoming JSON keys to snake_case before forwarding to the
2516        // service-layer merge. Entities serialize with Rust's snake_case field
2517        // names by default, so a client sending camelCase (e.g. `isVip`) would
2518        // otherwise produce a merged JSON object with both `is_vip` (from the
2519        // existing entity) and `isVip` (from the patch). On deserialize back
2520        // to the entity, the unknown camelCase key is silently dropped and the
2521        // edit is lost. Normalization makes this class of bug impossible.
2522        //
2523        // The conversion is idempotent — already-snake_case keys pass through
2524        // unchanged — so clients that already conform see no behavior change.
2525        let fields: HashMap<String, serde_json::Value> = fields
2526            .into_iter()
2527            .map(|(k, v)| (camel_to_snake_case(&k), v))
2528            .collect();
2529
2530        match handler.service.partial_update(&id, fields).await {
2531            Ok(Some(entity)) => {
2532                let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2533                (StatusCode::OK, Json(ApiResponse::ok(response)))
2534            }
2535            Ok(None) => {
2536                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2537            }
2538            Err(e) => {
2539                Self::failure(&e, StatusCode::BAD_REQUEST)
2540            }
2541        }
2542    }
2543
2544    async fn delete_handler(
2545        handler: Arc<Self>,
2546        axum::extract::Path(id): axum::extract::Path<String>,
2547    ) -> impl axum::response::IntoResponse {
2548        use axum::{http::StatusCode, Json};
2549
2550        if let Some(refused) = invalid_id::<()>(&id, "id") {
2551            return refused;
2552        }
2553
2554        match handler.service.soft_delete(&id).await {
2555            Ok(true) => {
2556                // Return 200 OK with success response instead of 204 No Content
2557                // to ensure proper JSON response handling
2558                (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
2559            }
2560            Ok(false) => {
2561                (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
2562            }
2563            Err(e) => {
2564                Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2565            }
2566        }
2567    }
2568
2569    async fn bulk_create_handler(
2570        handler: Arc<Self>,
2571        crate::extractors::WriteBody(items, keys): crate::extractors::WriteBody<Vec<C>>,
2572        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2573    ) -> impl axum::response::IntoResponse {
2574        use axum::{http::StatusCode, Json};
2575
2576        let caller = caller.map(|axum::Extension(c)| c);
2577        let refused = gated_write_refusals::<E>(keys.into_iter(), caller.as_ref());
2578        if !refused.is_empty() {
2579            return (
2580                StatusCode::UNPROCESSABLE_ENTITY,
2581                Json(ApiResponse::<BulkResponse<serde_json::Value>>::rejected(refusal_sentence(&refused), refused)),
2582            );
2583        }
2584        if let Some(err) = batch_size_error(items.len()) {
2585            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2586        }
2587        match handler.service.bulk_create(items).await {
2588            Ok(entities) => {
2589                let result_items: Vec<serde_json::Value> = entities
2590                    .into_iter()
2591                    .map(|e| secure::<E>(to_response_value(R::from(e)), None, caller.as_ref()))
2592                    .collect();
2593                let total = result_items.len();
2594                let response = BulkResponse {
2595                    items: result_items,
2596                    total,
2597                    failed: 0,
2598                    errors: vec![],
2599                };
2600                (StatusCode::CREATED, Json(ApiResponse::ok(response)))
2601            }
2602            Err(e) => {
2603                Self::failure(&e, StatusCode::BAD_REQUEST)
2604            }
2605        }
2606    }
2607
2608    // ── Batch handlers ────────────────────────────────────────────────────────
2609
2610    async fn bulk_delete_handler(
2611        handler: Arc<Self>,
2612        JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2613    ) -> impl axum::response::IntoResponse {
2614        use axum::{http::StatusCode, Json};
2615
2616        if let Some(err) = batch_size_error(req.ids.len()) {
2617            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2618        }
2619        if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2620            return refused;
2621        }
2622        match handler.service.bulk_soft_delete(req.ids).await {
2623            Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2624                serde_json::json!({ "soft_deleted": count }),
2625                format!("Soft-deleted {count} item(s)"),
2626            ))),
2627            Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2628        }
2629    }
2630
2631    async fn bulk_restore_handler(
2632        handler: Arc<Self>,
2633        JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2634        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2635    ) -> impl axum::response::IntoResponse {
2636        use axum::{http::StatusCode, Json};
2637
2638        let caller = caller.map(|axum::Extension(c)| c);
2639        if let Some(err) = batch_size_error(req.ids.len()) {
2640            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2641        }
2642        if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2643            return refused;
2644        }
2645        match handler.service.bulk_restore(req.ids).await {
2646            Ok(entities) => {
2647                let items: Vec<serde_json::Value> = entities
2648                    .into_iter()
2649                    .map(|e| secure::<E>(to_response_value(R::from(e)), None, caller.as_ref()))
2650                    .collect();
2651                let total = items.len();
2652                (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2653            }
2654            Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2655        }
2656    }
2657
2658    async fn restore_all_handler(
2659        handler: Arc<Self>,
2660    ) -> impl axum::response::IntoResponse {
2661        use axum::{http::StatusCode, Json};
2662
2663        match handler.service.restore_all().await {
2664            Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2665                serde_json::json!({ "restored": count }),
2666                format!("Restored {count} item(s) from trash"),
2667            ))),
2668            Err(e) => Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR),
2669        }
2670    }
2671
2672    async fn bulk_permanent_delete_handler(
2673        handler: Arc<Self>,
2674        JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2675    ) -> impl axum::response::IntoResponse {
2676        use axum::{http::StatusCode, Json};
2677
2678        if let Some(err) = batch_size_error(req.ids.len()) {
2679            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2680        }
2681        if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2682            return refused;
2683        }
2684        match handler.service.bulk_permanent_delete(req.ids).await {
2685            Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2686                serde_json::json!({ "permanently_deleted": count }),
2687                format!("Permanently deleted {count} item(s)"),
2688            ))),
2689            Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2690        }
2691    }
2692
2693    /// `PUT {resource}/bulk`: an array of `{ "id": .., <update fields> }`. Each item's fields
2694    /// are read as the update DTO with the same unknown-key check a single PUT applies; the
2695    /// `id` is taken out first, since the DTO itself has none.
2696    async fn bulk_update_handler(
2697        handler: Arc<Self>,
2698        JsonOrForm(raw): JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>,
2699        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2700    ) -> axum::response::Response {
2701        use axum::{http::StatusCode, response::IntoResponse, Json};
2702
2703        let caller = caller.map(|axum::Extension(c)| c);
2704        if let Some(err) = batch_size_error(raw.len()) {
2705            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)))
2706                .into_response();
2707        }
2708        let refused = gated_write_refusals::<E>(raw.iter().flat_map(|m| m.keys().cloned()), caller.as_ref());
2709        if !refused.is_empty() {
2710            return (
2711                StatusCode::UNPROCESSABLE_ENTITY,
2712                Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2713            )
2714                .into_response();
2715        }
2716        let mut items: Vec<(String, U)> = Vec::with_capacity(raw.len());
2717        for (index, mut fields) in raw.into_iter().enumerate() {
2718            let id = match fields.remove("id") {
2719                Some(serde_json::Value::String(id)) => id,
2720                _ => {
2721                    let violation = crate::violation::Violation::new(
2722                        crate::violation::batch_item_path(index, "id"),
2723                        "required",
2724                        "id is required",
2725                    );
2726                    return (
2727                        StatusCode::UNPROCESSABLE_ENTITY,
2728                        Json(ApiResponse::<()>::rejected(format!("item {index}: id is required"), vec![violation])),
2729                    )
2730                        .into_response();
2731                }
2732            };
2733            if let Some(refused) = invalid_id::<()>(&id, &crate::violation::batch_item_path(index, "id")) {
2734                return refused.into_response();
2735            }
2736            let bytes = match serde_json::to_vec(&fields) {
2737                Ok(bytes) => bytes,
2738                Err(e) => return error_at::<()>(StatusCode::INTERNAL_SERVER_ERROR, None, &e).into_response(),
2739            };
2740            match crate::extractors::decode_json_or_refusal::<U>(&bytes, true) {
2741                Ok(data) => items.push((id, data)),
2742                Err(refusal) => return refusal.at_item(index).into_response(),
2743            }
2744        }
2745        match handler.service.bulk_update(items).await {
2746            Ok(entities) => {
2747                let items: Vec<serde_json::Value> = entities
2748                    .into_iter()
2749                    .map(|e| secure::<E>(to_response_value(R::from(e)), None, caller.as_ref()))
2750                    .collect();
2751                let total = items.len();
2752                (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2753                    .into_response()
2754            }
2755            Err(e) => Self::failure::<()>(&e, StatusCode::BAD_REQUEST).into_response(),
2756        }
2757    }
2758
2759    async fn bulk_patch_handler(
2760        handler: Arc<Self>,
2761        JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2762        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2763    ) -> impl axum::response::IntoResponse {
2764        use axum::{http::StatusCode, Json};
2765
2766        let caller = caller.map(|axum::Extension(c)| c);
2767        let items = req.into_items();
2768        if let Some(err) = batch_size_error(items.len()) {
2769            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2770        }
2771        if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2772            return refused;
2773        }
2774        let refused = gated_write_refusals::<E>(items.iter().flat_map(|(_, f)| f.keys().cloned()), caller.as_ref());
2775        if !refused.is_empty() {
2776            return (
2777                StatusCode::UNPROCESSABLE_ENTITY,
2778                Json(ApiResponse::<BulkResponse<serde_json::Value>>::rejected(refusal_sentence(&refused), refused)),
2779            );
2780        }
2781        // Normalize patch keys to snake_case (mirrors partial_update_handler).
2782        let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2783            .into_iter()
2784            .map(|(id, fields)| {
2785                let fields = fields
2786                    .into_iter()
2787                    .map(|(k, v)| (camel_to_snake_case(&k), v))
2788                    .collect();
2789                (id, fields)
2790            })
2791            .collect();
2792        match handler.service.bulk_partial_update(items).await {
2793            Ok(entities) => {
2794                let items: Vec<serde_json::Value> = entities
2795                    .into_iter()
2796                    .map(|e| secure::<E>(to_response_value(R::from(e)), None, caller.as_ref()))
2797                    .collect();
2798                let total = items.len();
2799                (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2800            }
2801            Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2802        }
2803    }
2804
2805    /// `POST {resource}/bulk/preview`: the bulk PATCH body, answered with what it
2806    /// would do and nothing written. Each row lands in one of three lists: it
2807    /// would change (each patched field before and after), it already holds the
2808    /// values, or the write would refuse it (the code and sentence it would give).
2809    /// The values pass the same secret and field-security strip as any response.
2810    async fn bulk_patch_preview_handler(
2811        handler: Arc<Self>,
2812        JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2813        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2814    ) -> impl axum::response::IntoResponse {
2815        use axum::{http::StatusCode, Json};
2816
2817        let caller = caller.map(|axum::Extension(c)| c);
2818        let items = req.into_items();
2819        if let Some(err) = batch_size_error(items.len()) {
2820            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkPreview>::error(err)));
2821        }
2822        if let Some(refused) = invalid_ids::<BulkPreview>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2823            return refused;
2824        }
2825        let refused = gated_write_refusals::<E>(items.iter().flat_map(|(_, f)| f.keys().cloned()), caller.as_ref());
2826        if !refused.is_empty() {
2827            return (
2828                StatusCode::UNPROCESSABLE_ENTITY,
2829                Json(ApiResponse::<BulkPreview>::rejected(refusal_sentence(&refused), refused)),
2830            );
2831        }
2832        let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2833            .into_iter()
2834            .map(|(id, fields)| {
2835                let fields = fields
2836                    .into_iter()
2837                    .map(|(k, v)| (camel_to_snake_case(&k), v))
2838                    .collect();
2839                (id, fields)
2840            })
2841            .collect();
2842        let patched: HashMap<String, Vec<String>> = items
2843            .iter()
2844            .map(|(id, f)| (id.clone(), f.keys().cloned().collect()))
2845            .collect();
2846        match handler.service.preview_bulk_partial_update(items).await {
2847            Ok(Some(rows)) => {
2848                let rows = rows
2849                    .into_iter()
2850                    .map(|row| {
2851                        let outcome = match row.outcome {
2852                            Ok((before, after)) => Ok((
2853                                secure::<E>(to_response_value(R::from(before)), None, caller.as_ref()),
2854                                secure::<E>(to_response_value(R::from(after)), None, caller.as_ref()),
2855                            )),
2856                            Err(e) => Err((
2857                                S::violations_of(&e)
2858                                    .and_then(|v| v.into_iter().next().map(|v| v.code))
2859                                    .unwrap_or_else(|| "refused".to_string()),
2860                                e.to_string(),
2861                            )),
2862                        };
2863                        (row.id, outcome)
2864                    })
2865                    .collect();
2866                (StatusCode::OK, Json(ApiResponse::ok(preview_answer(rows, &patched))))
2867            }
2868            Ok(None) => (
2869                StatusCode::NOT_IMPLEMENTED,
2870                Json(ApiResponse::error("this resource has no preview of a bulk change".to_string())),
2871            ),
2872            Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2873        }
2874    }
2875
2876    async fn upsert_handler(
2877        handler: Arc<Self>,
2878        crate::extractors::WriteBody(dto, keys): crate::extractors::WriteBody<C>,
2879        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2880    ) -> impl axum::response::IntoResponse {
2881        use axum::{http::StatusCode, Json};
2882
2883        let caller = caller.map(|axum::Extension(c)| c);
2884        let refused = gated_write_refusals::<E>(keys.into_iter(), caller.as_ref());
2885        if !refused.is_empty() {
2886            return (
2887                StatusCode::UNPROCESSABLE_ENTITY,
2888                Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2889            );
2890        }
2891        // A create body names no record to match, so a service without a real upsert could
2892        // only create — a duplicate on every retry. Say so instead.
2893        if !S::supports_upsert() {
2894            return (
2895                StatusCode::NOT_IMPLEMENTED,
2896                Json(ApiResponse::<serde_json::Value>::error(crate::service::UPSERT_UNSUPPORTED)),
2897            );
2898        }
2899        match handler.service.upsert(dto).await {
2900            Ok(entity) => {
2901                let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2902                (StatusCode::OK, Json(ApiResponse::ok(response)))
2903            }
2904            Err(e) => {
2905                Self::failure(&e, StatusCode::BAD_REQUEST)
2906            }
2907        }
2908    }
2909
2910    async fn list_deleted_handler(
2911        handler: Arc<Self>,
2912        ListQuery(params): ListQuery,
2913        access: Option<axum::Extension<AccessScope>>,
2914        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2915    ) -> impl axum::response::IntoResponse {
2916        use axum::{http::StatusCode, Json};
2917
2918        let caller = caller.map(|axum::Extension(c)| c);
2919        let params = params.normalized();
2920        let refused = query_refusals::<E>(&params, true, caller.as_ref());
2921        if !refused.is_empty() {
2922            return (StatusCode::BAD_REQUEST, Json(refused_list(refused)));
2923        }
2924
2925        let fields = sparse_fields(&params.filters);
2926        let includes = include_relations(&params.filters);
2927        let scope = access.map(|axum::Extension(s)| s);
2928
2929        let filters = repository_filters(&params);
2930        match handler.service.list_deleted_filtered(params.page, params.limit, filters).await {
2931            Ok((entities, total)) => {
2932                // Mirror `list_handler`: field-security ceiling, then batched
2933                // relation expansion (`?include=`), then sparse projection — so the
2934                // trash view hydrates relations exactly like the active list does.
2935                let mut rows: Vec<serde_json::Value> = entities
2936                    .into_iter()
2937                    .map(|e| {
2938                        secure::<E>(to_response_value(R::from(e)), scope.as_ref(), caller.as_ref())
2939                    })
2940                    .collect();
2941                expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes, caller.as_ref()).await;
2942                let items: Vec<serde_json::Value> =
2943                    rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2944                let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
2945                (StatusCode::OK, Json(response))
2946            }
2947            Err(e) => Self::list_failure(&e, &params),
2948        }
2949    }
2950
2951    async fn restore_handler(
2952        handler: Arc<Self>,
2953        axum::extract::Path(id): axum::extract::Path<String>,
2954        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2955    ) -> impl axum::response::IntoResponse {
2956        use axum::{http::StatusCode, Json};
2957
2958        let caller = caller.map(|axum::Extension(c)| c);
2959        if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2960            return refused;
2961        }
2962
2963        match handler.service.restore(&id).await {
2964            Ok(Some(entity)) => {
2965                let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2966                (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
2967            }
2968            Ok(None) => {
2969                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2970            }
2971            Err(e) => {
2972                Self::failure(&e, StatusCode::BAD_REQUEST)
2973            }
2974        }
2975    }
2976
2977    async fn empty_trash_handler(
2978        handler: Arc<Self>,
2979    ) -> impl axum::response::IntoResponse {
2980        use axum::{http::StatusCode, Json};
2981
2982        match handler.service.empty_trash().await {
2983            Ok(count) => {
2984                (StatusCode::OK, Json(ApiResponse::success_with_message(
2985                    serde_json::json!({ "deleted_count": count }),
2986                    format!("Successfully deleted {} items from trash", count)
2987                )))
2988            }
2989            Err(e) => {
2990                Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2991            }
2992        }
2993    }
2994
2995    /// 12. GET /collection/:id/deleted - Get deleted entity by ID
2996    async fn get_deleted_handler(
2997        handler: Arc<Self>,
2998        axum::extract::Path(id): axum::extract::Path<String>,
2999        ListQuery(params): ListQuery,
3000        access: Option<axum::Extension<AccessScope>>,
3001        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
3002    ) -> impl axum::response::IntoResponse {
3003        use axum::{http::StatusCode, Json};
3004
3005        let caller = caller.map(|axum::Extension(c)| c);
3006        if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
3007            return refused;
3008        }
3009
3010        let fields = sparse_fields(&params.filters);
3011        let scope = access.map(|axum::Extension(s)| s);
3012
3013        match handler.service.get_deleted_by_id(&id).await {
3014            Ok(Some(entity)) => {
3015                let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref(), caller.as_ref());
3016                let value = project_sparse(secured, &fields);
3017                (StatusCode::OK, Json(ApiResponse::ok(value)))
3018            }
3019            Ok(None) => {
3020                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
3021            }
3022            Err(e) => {
3023                Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
3024            }
3025        }
3026    }
3027
3028    /// 13. DELETE /collection/trash/:id - Permanently delete from trash
3029    async fn permanent_delete_handler(
3030        handler: Arc<Self>,
3031        axum::extract::Path(id): axum::extract::Path<String>,
3032    ) -> axum::response::Response {
3033        use axum::{http::StatusCode, Json, response::IntoResponse};
3034
3035        if let Some(refused) = invalid_id::<R>(&id, "id") {
3036            return refused.into_response();
3037        }
3038
3039        // First check if the entity exists in trash
3040        match handler.service.get_deleted_by_id(&id).await {
3041            Ok(Some(_)) => {
3042                // Entity exists in trash, proceed with permanent delete
3043                match handler.service.permanent_delete(&id).await {
3044                    Ok(true) => {
3045                        // 204 No Content - successful deletion with no body
3046                        StatusCode::NO_CONTENT.into_response()
3047                    }
3048                    Ok(false) => {
3049                        (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
3050                            .into_response()
3051                    }
3052                    Err(e) => {
3053                        Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
3054                    }
3055                }
3056            }
3057            Ok(None) => {
3058                (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
3059                    &format!("{} in trash", S::entity_name()), &id
3060                ))).into_response()
3061            }
3062            Err(e) => {
3063                Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
3064            }
3065        }
3066    }
3067
3068    /// 15. GET /collection/count - Count active entities
3069    async fn count_active_handler(
3070        handler: Arc<Self>,
3071        ListQuery(params): ListQuery,
3072        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
3073    ) -> impl axum::response::IntoResponse {
3074        use axum::{http::StatusCode, Json};
3075
3076        let caller = caller.map(|axum::Extension(c)| c);
3077        // An unfiltered request still answers the whole-table count, which is
3078        // what every existing caller expects; a filtered one now answers the
3079        // question it actually asked.
3080        let refused = query_refusals::<E>(&params, false, caller.as_ref());
3081        if !refused.is_empty() {
3082            return (
3083                StatusCode::BAD_REQUEST,
3084                Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
3085            );
3086        }
3087        match handler.service.count_active_filtered(repository_filters(&params)).await {
3088            Ok(count) => {
3089                (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
3090            }
3091            // Now that the filters are read, a malformed one is the caller's fault, named —
3092            // the same classification the list path uses.
3093            Err(e) => Self::query_failure(&e, &params),
3094        }
3095    }
3096
3097    /// GET /collection/aggregate - Group and reduce
3098    ///
3099    /// Answers with one entry per distinct group plus the overall total, so a
3100    /// caller can draw a chart and its headline figure from a single reply.
3101    async fn aggregate_handler(
3102        handler: Arc<Self>,
3103        ListQuery(params): ListQuery,
3104        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
3105    ) -> impl axum::response::IntoResponse {
3106        use axum::{http::StatusCode, Json};
3107
3108        let caller = caller.map(|axum::Extension(c)| c);
3109        let refused = query_refusals::<E>(&params, false, caller.as_ref());
3110        if !refused.is_empty() {
3111            return (
3112                StatusCode::BAD_REQUEST,
3113                Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
3114            );
3115        }
3116        let spec = aggregate_spec(&params);
3117        match handler.service.aggregate(&spec, aggregate_filters(&params)).await {
3118            Ok(result) => {
3119                let render = |g: &backbone_orm::repository::AggregateGroup| {
3120                    // Flat `"sum:amount"` keys become nested `sum: { amount }`,
3121                    // so a caller reads `groups[i].sum.amount` without having to
3122                    // reassemble a compound key.
3123                    let mut out = serde_json::Map::new();
3124                    out.insert("key".into(), match &g.key {
3125                        Some(k) => serde_json::Value::String(k.clone()),
3126                        None => serde_json::Value::Null,
3127                    });
3128                    if g.label.is_some() {
3129                        out.insert(
3130                            "label".into(),
3131                            serde_json::Value::String(g.label.clone().unwrap()),
3132                        );
3133                    }
3134                    out.insert("count".into(), serde_json::json!(g.count));
3135                    for (compound, value) in &g.values {
3136                        let Some((func, field)) = compound.split_once(':') else { continue };
3137                        let slot = out
3138                            .entry(func.to_string())
3139                            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
3140                        if let Some(obj) = slot.as_object_mut() {
3141                            obj.insert(field.to_string(), match value {
3142                                // Strings, not numbers: Postgres `numeric` carries
3143                                // more precision than a JSON double, and money
3144                                // columns are exactly where rounding would show.
3145                                Some(v) => serde_json::Value::String(v.clone()),
3146                                None => serde_json::Value::Null,
3147                            });
3148                        }
3149                    }
3150                    serde_json::Value::Object(out)
3151                };
3152
3153                let body = serde_json::json!({
3154                    "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
3155                    "total": render(&result.total),
3156                    // Says plainly that groups were dropped, so a partial chart
3157                    // is never mistaken for a complete one.
3158                    "truncated": result.truncated,
3159                });
3160                (StatusCode::OK, Json(ApiResponse::ok(body)))
3161            }
3162            Err(e) => Self::query_failure(&e, &params),
3163        }
3164    }
3165
3166    /// GET /collection/:id/history - what has been recorded about this record
3167    ///
3168    /// Three outcomes, kept deliberately distinct, because collapsing any two
3169    /// of them would render as "nothing ever happened to this record":
3170    ///
3171    /// - no provider installed -> 501, this service serves no history at all
3172    /// - provider says the table is not audited -> 200, `audited: false`,
3173    ///   `entries: null`
3174    /// - audited -> 200, `audited: true`, `entries: [...]` (possibly empty,
3175    ///   which here genuinely means nothing has changed)
3176    async fn history_handler(
3177        handler: Arc<Self>,
3178        axum::extract::Path(id): axum::extract::Path<String>,
3179        ListQuery(params): ListQuery,
3180        provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
3181        caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
3182    ) -> impl axum::response::IntoResponse {
3183        use axum::{http::StatusCode, Json};
3184
3185        let caller = caller.map(|axum::Extension(c)| c);
3186        if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
3187            return refused;
3188        }
3189
3190        let Some(axum::Extension(provider)) = provider else {
3191            return (
3192                StatusCode::NOT_IMPLEMENTED,
3193                Json(ApiResponse::<serde_json::Value>::error(
3194                    "history is not configured for this service".to_string(),
3195                )),
3196            );
3197        };
3198
3199        // Keyed on the table the repository actually reads — the same string the
3200        // capture trigger writes as `subject_type`. A service that cannot name
3201        // its table has no history rather than a wrong one.
3202        let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
3203            return (
3204                StatusCode::NOT_IMPLEMENTED,
3205                Json(ApiResponse::<serde_json::Value>::error(
3206                    "this entity cannot name its table, so its history cannot be keyed".to_string(),
3207                )),
3208            );
3209        };
3210
3211        let limit = params.limit.clamp(1, 200);
3212        let offset = params.page.saturating_sub(1) * limit;
3213
3214        match provider.history(&table, &id, limit, offset).await {
3215            Ok(Some(entries)) => {
3216                let mut body = serde_json::json!({ "audited": true, "entries": entries });
3217                // The provider answers with the rows it recorded, in its own shape (before and
3218                // after snapshots), so the gate walks the whole answer: a permission-gated field
3219                // a caller does not hold is redacted wherever it appears, casing aside. A name
3220                // that happens to match a gated field's is redacted too — the safe side.
3221                let gated = E::field_permissions();
3222                if !gated.is_empty() {
3223                    redact_gated_deep(&mut body, gated, caller.as_ref());
3224                }
3225                (StatusCode::OK, Json(ApiResponse::ok(body)))
3226            }
3227            Ok(None) => (
3228                StatusCode::OK,
3229                Json(ApiResponse::ok(serde_json::json!({
3230                    // Not a mistake and not an empty timeline: this table
3231                    // records nothing, so there is nothing to have missed.
3232                    "audited": false,
3233                    "entries": serde_json::Value::Null,
3234                }))),
3235            ),
3236            Err(e) => error_at(StatusCode::INTERNAL_SERVER_ERROR, None, &e),
3237        }
3238    }
3239
3240    /// 16. GET /collection/trash/count - Count deleted entities
3241    async fn count_deleted_handler(
3242        handler: Arc<Self>,
3243    ) -> impl axum::response::IntoResponse {
3244        use axum::{http::StatusCode, Json};
3245
3246        match handler.service.count_deleted().await {
3247            Ok(count) => {
3248                (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
3249            }
3250            Err(e) => {
3251                Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
3252            }
3253        }
3254    }
3255}
3256
3257// ============================================================
3258// BackboneHttpHandler Trait - Synchronous HTTP Service Interface
3259// ============================================================
3260
3261/// Synchronous trait for HTTP handlers implementing Backbone CRUD operations.
3262///
3263/// This trait provides a synchronous interface for services that need to implement
3264/// the 11 standard Backbone CRUD endpoints. Unlike `CrudService` which is async,
3265/// this trait is designed for simpler use cases where async is not required.
3266///
3267/// # Type Parameters
3268/// - `T`: The entity type
3269///
3270/// # Example
3271/// ```ignore
3272/// impl BackboneHttpHandler<User> for UserService {
3273///     fn list(&self, request: ListRequest) -> Result<ApiResponse<Vec<User>>> {
3274///         let users = self.users.lock().unwrap();
3275///         let filtered = users.iter().filter(|u| !u.is_deleted()).cloned().collect();
3276///         Ok(ApiResponse::success(filtered, Some("Users listed".to_string())))
3277///     }
3278///     // ... implement other methods
3279/// }
3280/// ```
3281pub trait BackboneHttpHandler<T>: Send + Sync {
3282    /// 1. List entities with pagination and filtering
3283    fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
3284
3285    /// 2. Create a new entity
3286    fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
3287
3288    /// 3. Get entity by ID
3289    fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
3290
3291    /// 4. Full update of entity
3292    fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
3293
3294    /// 5. Partial update with specific fields
3295    fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
3296
3297    /// 6. Soft delete entity
3298    fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
3299
3300    /// 7. Bulk create multiple entities
3301    fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
3302
3303    /// 8. Upsert (create or update)
3304    fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
3305
3306    /// 9. List deleted entities (trash)
3307    fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
3308
3309    /// 10. Restore soft-deleted entity
3310    fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
3311
3312    /// 11. Permanently delete all soft-deleted entities
3313    fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
3314
3315    /// 12. Get deleted entity by ID (from trash)
3316    fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
3317}
3318
3319// ============================================================
3320// Legacy Types (Backwards Compatibility)
3321// ============================================================
3322
3323/// Pagination request (legacy)
3324#[derive(Debug, Serialize, Deserialize)]
3325#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
3326pub struct PaginationRequest {
3327    pub page: u32,
3328    pub limit: u32,
3329    pub sort_by: Option<String>,
3330    pub sort_order: Option<String>,
3331}
3332
3333// ============================================================
3334// Internal Helpers
3335// ============================================================
3336
3337/// Convert a single JSON key from `camelCase` / `PascalCase` to `snake_case`.
3338///
3339/// Idempotent on already-`snake_case` input: `is_vip` → `is_vip`.
3340/// Single words pass through unchanged: `name` → `name`.
3341/// Boundary detection inserts an underscore between a lowercase/digit and the
3342/// following uppercase, and between a run of uppercase and a following
3343/// uppercase-then-lowercase pair (so `IOError` → `io_error`, not `i_o_error`).
3344///
3345/// Used by `BackboneCrudHandler::partial_update_handler` to normalize PATCH
3346/// payload keys so the service-layer JSON merge aligns with snake_case entity
3347/// fields regardless of the client's serialization convention.
3348fn camel_to_snake_case(key: &str) -> String {
3349    let chars: Vec<char> = key.chars().collect();
3350    let mut result = String::with_capacity(key.len() + 2);
3351    for (i, &c) in chars.iter().enumerate() {
3352        if c.is_ascii_uppercase() {
3353            let prev = if i > 0 { chars[i - 1] } else { '\0' };
3354            let next = chars.get(i + 1).copied().unwrap_or('\0');
3355            // Insert separator before this uppercase letter when crossing a
3356            // word boundary. Skip if the result is empty or already ends with
3357            // an underscore (e.g. `_Foo` should stay `_foo`, not `__foo`).
3358            let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
3359            let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
3360            if (crosses_lower || crosses_acronym)
3361                && !result.is_empty()
3362                && !result.ends_with('_')
3363            {
3364                result.push('_');
3365            }
3366            result.push(c.to_ascii_lowercase());
3367        } else {
3368            result.push(c);
3369        }
3370    }
3371    result
3372}
3373
3374#[cfg(test)]
3375mod tests {
3376    use super::*;
3377
3378    #[test]
3379    fn snake_case_input_passes_through_unchanged() {
3380        assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
3381        assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
3382        assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
3383    }
3384
3385    #[test]
3386    fn single_word_unchanged() {
3387        assert_eq!(camel_to_snake_case("name"), "name");
3388        assert_eq!(camel_to_snake_case("id"), "id");
3389        assert_eq!(camel_to_snake_case(""), "");
3390    }
3391
3392    #[test]
3393    fn camel_case_converts() {
3394        assert_eq!(camel_to_snake_case("isVip"), "is_vip");
3395        assert_eq!(camel_to_snake_case("userId"), "user_id");
3396        assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
3397        assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
3398    }
3399
3400    #[test]
3401    fn pascal_case_converts() {
3402        assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
3403        assert_eq!(camel_to_snake_case("UserId"), "user_id");
3404    }
3405
3406    #[test]
3407    fn acronym_runs_stay_together() {
3408        // The transition from an uppercase run to a lowercase letter starts a
3409        // new word at the last uppercase, not between every pair.
3410        assert_eq!(camel_to_snake_case("IOError"), "io_error");
3411        assert_eq!(camel_to_snake_case("httpURL"), "http_url");
3412        assert_eq!(camel_to_snake_case("ABC"), "abc");
3413        assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
3414    }
3415
3416    #[test]
3417    fn digits_count_as_lowercase_for_boundary() {
3418        assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
3419        assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
3420    }
3421
3422    #[test]
3423    fn underscores_not_doubled() {
3424        assert_eq!(camel_to_snake_case("_Foo"), "_foo");
3425        assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
3426    }
3427
3428    #[test]
3429    fn shallow_pages_are_allowed() {
3430        // page 1 has zero offset regardless of size
3431        assert!(pagination_depth_error(1, 100).is_none());
3432        // last in-bounds page at max size: offset = 100 * 100 = 10_000 (== cap)
3433        assert!(pagination_depth_error(101, 100).is_none());
3434    }
3435
3436    #[test]
3437    fn pages_past_the_cap_are_rejected() {
3438        // page 102 at size 100 → offset 10_100 > 10_000
3439        assert!(pagination_depth_error(102, 100).is_some());
3440        // small page size still rejected once deep enough: 1001 * 10 = 10_010
3441        assert!(pagination_depth_error(1002, 10).is_some());
3442    }
3443
3444    #[test]
3445    fn oversized_page_size_is_clamped_before_the_check() {
3446        // limit=200 is clamped to 100, so the effective offset is 101*100=10_100
3447        assert!(pagination_depth_error(102, 200).is_some());
3448        // and a request that would only be in-bounds *because* of the clamp passes
3449        assert!(pagination_depth_error(101, 200).is_none());
3450    }
3451
3452    #[test]
3453    fn huge_page_number_does_not_overflow() {
3454        // saturating arithmetic keeps this a clean rejection, not a panic
3455        assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
3456    }
3457
3458    // ── Batch operations ──────────────────────────────────────────────────────
3459
3460    #[test]
3461    fn batch_size_within_limit_is_allowed() {
3462        assert!(batch_size_error(0).is_none());
3463        assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
3464    }
3465
3466    #[test]
3467    fn batch_size_over_limit_is_rejected() {
3468        assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
3469    }
3470
3471    #[test]
3472    fn a_bulk_preview_sorts_rows_by_what_the_patch_would_do() {
3473        use serde_json::json;
3474        let mut patched: HashMap<String, Vec<String>> = ["a", "b", "c"]
3475            .iter()
3476            .map(|id| (id.to_string(), vec!["category".to_string()]))
3477            .collect();
3478        patched.insert("d".into(), vec!["category_id".into()]);
3479        let out = preview_answer(
3480            vec![
3481                (
3482                    "a".into(),
3483                    Ok((
3484                        json!({"category": "meal", "updated_at": "1"}),
3485                        json!({"category": "travel", "updated_at": "2"}),
3486                    )),
3487                ),
3488                ("b".into(), Ok((json!({"category": "travel"}), json!({"category": "travel"})))),
3489                (
3490                    "d".into(),
3491                    Ok((json!({"categoryId": "x"}), json!({"categoryId": "y"}))),
3492                ),
3493                ("c".into(), Err(("locked".into(), "a posted claim is locked".into()))),
3494            ],
3495            &patched,
3496        );
3497        assert_eq!(out.total, 4);
3498        assert_eq!(out.will_change.len(), 2);
3499        assert_eq!(out.will_change[1].changes[0].field, "category_id", "a camelCase response is read");
3500        let ch = &out.will_change[0].changes;
3501        assert_eq!(ch.len(), 1, "only the patched field is reported, not the timestamp");
3502        assert_eq!((ch[0].from.clone(), ch[0].to.clone()), (json!("meal"), json!("travel")));
3503        assert_eq!(out.unchanged, vec!["b".to_string()]);
3504        assert_eq!(out.refused[0].code, "locked");
3505    }
3506
3507    #[test]
3508    fn bulk_patch_request_parses_shared_shape() {
3509        let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
3510        let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
3511        let items = req.into_items();
3512        assert_eq!(items.len(), 2);
3513        // Both ids carry the same patch.
3514        assert_eq!(items[0].1.get("status").unwrap(), "void");
3515        assert_eq!(items[1].1.get("status").unwrap(), "void");
3516        assert_eq!(items[0].0, "a");
3517        assert_eq!(items[1].0, "b");
3518    }
3519
3520    #[test]
3521    fn bulk_patch_request_parses_per_item_shape() {
3522        let json = r#"{ "items": [
3523            { "id": "a", "patch": { "status": "void" } },
3524            { "id": "b", "patch": { "note": "late" } }
3525        ] }"#;
3526        let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
3527        let items = req.into_items();
3528        assert_eq!(items.len(), 2);
3529        assert_eq!(items[0].0, "a");
3530        assert_eq!(items[0].1.get("status").unwrap(), "void");
3531        assert_eq!(items[1].0, "b");
3532        assert_eq!(items[1].1.get("note").unwrap(), "late");
3533    }
3534
3535    #[test]
3536    fn batch_ids_request_parses() {
3537        let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
3538        assert_eq!(req.ids, vec!["x", "y", "z"]);
3539    }
3540
3541    // ── Sparse fieldsets (?fields=a,b,c) ──
3542
3543    fn fields(q: &[(&str, &str)]) -> Vec<String> {
3544        let map: HashMap<String, String> =
3545            q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
3546        sparse_fields(&map)
3547    }
3548
3549    #[test]
3550    fn sparse_fields_parses_comma_list_and_trims() {
3551        assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
3552    }
3553
3554    #[test]
3555    fn sparse_fields_absent_or_empty_is_no_projection() {
3556        assert!(fields(&[]).is_empty());
3557        assert!(fields(&[("fields", "")]).is_empty());
3558        assert!(fields(&[("fields", " , ")]).is_empty());
3559    }
3560
3561    #[test]
3562    fn project_keeps_requested_keys_plus_id() {
3563        let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
3564        let out = project_sparse(v, &["name".into(), "basePrice".into()]);
3565        assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
3566    }
3567
3568    #[test]
3569    fn project_always_includes_id_even_if_not_requested() {
3570        let v = serde_json::json!({ "id": "1", "name": "n" });
3571        let out = project_sparse(v, &["name".into()]);
3572        assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
3573    }
3574
3575    #[test]
3576    fn project_ignores_unknown_keys() {
3577        let v = serde_json::json!({ "id": "1", "name": "n" });
3578        let out = project_sparse(v, &["name".into(), "nope".into()]);
3579        assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
3580    }
3581
3582    #[test]
3583    fn project_empty_fields_returns_full_object() {
3584        let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
3585        let out = project_sparse(v.clone(), &[]);
3586        assert_eq!(out, v);
3587    }
3588
3589    #[test]
3590    fn project_non_object_returned_unchanged() {
3591        let v = serde_json::json!("scalar");
3592        assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
3593    }
3594
3595    // ── Field-level security (@private / @owner) ──
3596
3597    /// The row's owner. A real tenant id is a Uuid — the scope carries one so the fence and
3598    /// field security cannot drift on formatting.
3599    fn owner_a() -> uuid::Uuid {
3600        uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
3601    }
3602    fn owner_b() -> uuid::Uuid {
3603        uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
3604    }
3605
3606    fn row() -> serde_json::Value {
3607        serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
3608    }
3609    const PRIV: &[&str] = &["hppPerUnit"];
3610
3611    /// An entity with a secret, a private field and a relation to a model with a secret.
3612    struct Secretive;
3613    impl backbone_orm::EntityRepoMeta for Secretive {
3614        fn column_types() -> HashMap<String, String> {
3615            HashMap::new()
3616        }
3617        fn search_fields() -> &'static [&'static str] {
3618            &[]
3619        }
3620        fn secret_fields() -> &'static [&'static str] {
3621            &["tokenHash"]
3622        }
3623        fn private_fields() -> &'static [&'static str] {
3624            PRIV
3625        }
3626        fn owner_field() -> Option<&'static str> {
3627            Some("providerId")
3628        }
3629        fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
3630            match relation {
3631                "user" => &["passwordHash"],
3632                _ => &[],
3633            }
3634        }
3635        fn relations() -> &'static [(&'static str, &'static str, &'static str)] {
3636            &[("user", "users", "userId")]
3637        }
3638    }
3639
3640    fn secret_row() -> serde_json::Value {
3641        let mut r = row();
3642        r["tokenHash"] = serde_json::json!("digest");
3643        r
3644    }
3645
3646    #[test]
3647    fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
3648        for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
3649            let out = secure::<Secretive>(secret_row(), scope.as_ref(), None);
3650            assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
3651            assert!(out.get("name").is_some());
3652        }
3653        let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform), None);
3654        assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
3655    }
3656
3657    #[test]
3658    fn the_typed_sort_pair_reaches_the_repository_as_the_filter_sort() {
3659        let params = |pairs: &[(&str, &str)], by: Option<&str>, order: Option<&str>| ListQueryParams {
3660            filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3661            sort_by: by.map(str::to_string),
3662            sort_order: order.map(str::to_string),
3663            ..Default::default()
3664        };
3665        let f = repository_filters(&params(&[], Some("priority"), Some("DESC")));
3666        assert_eq!(f.get("orderby").map(String::as_str), Some("priority:desc"));
3667        let f = repository_filters(&params(&[], Some("createdAt"), None));
3668        assert_eq!(f.get("orderby").map(String::as_str), Some("createdAt:asc"));
3669        // A sort the filter grammar names wins over the typed pair.
3670        let f = repository_filters(&params(&[("orderby[name]", "asc")], Some("priority"), Some("desc")));
3671        assert!(!f.contains_key("orderby"), "{f:?}");
3672        // It goes through the same sanitizer as any filter sort: an injection is refused there,
3673        // by the parameter that carried it.
3674        let refused = backbone_orm::filter::parse_filters(
3675            &repository_filters(&params(&[], Some("name; drop table x"), None)),
3676            &HashMap::new(),
3677            None,
3678        )
3679        .unwrap_err()
3680        .downcast::<backbone_orm::QueryParamError>()
3681        .expect("a named refusal");
3682        assert_eq!(refused.parameter, "orderby");
3683        let said = rename_typed_sort(
3684            vec![query_violation(&refused.parameter, refused.reason, refused.message)],
3685            &params(&[], Some("name; drop table x"), None),
3686        );
3687        assert_eq!(said[0].path, "sort_by");
3688        assert_eq!(said[0].params["parameter"], "sort_by");
3689        assert!(said[0].message.contains("`sort_by`") && !said[0].message.contains("orderby"), "{}", said[0].message);
3690        // A sort the client wrote in the grammar keeps its own name.
3691        let kept = rename_typed_sort(
3692            vec![query_violation("orderby", refused.reason, "`orderby` x")],
3693            &params(&[("orderby", "name")], Some("priority"), None),
3694        );
3695        assert_eq!(kept[0].path, "orderby");
3696    }
3697
3698    fn ask(pairs: &[(&str, &str)]) -> ListQueryParams {
3699        ListQueryParams {
3700            filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3701            ..Default::default()
3702        }
3703    }
3704
3705    #[test]
3706    fn a_paging_value_that_is_not_a_number_is_refused_on_its_own_parameter() {
3707        for (raw, parameter) in [("page=abc", "page"), ("limit=-3", "limit"), ("page=2&limit=x1", "limit"), ("page=", "page")] {
3708            assert!(serde_urlencoded::from_str::<ListQueryParams>(raw).is_err(), "{raw}");
3709            let v = list_query_violation(raw, "decode");
3710            assert_eq!((v.path.as_str(), v.code.as_str()), (parameter, "invalid_query"), "{raw}");
3711            assert_eq!(v.params["parameter"], parameter);
3712            assert_eq!(v.params["reason"], "not_a_number");
3713            assert!(v.message.contains(parameter), "{}", v.message);
3714        }
3715    }
3716
3717    #[test]
3718    fn a_typed_parameter_sent_twice_is_refused_by_name() {
3719        let raw = "sort_by=a&sort_by=b";
3720        assert!(serde_urlencoded::from_str::<ListQueryParams>(raw).is_err());
3721        let v = list_query_violation(raw, "decode");
3722        assert_eq!((v.path.as_str(), v.params["reason"].as_str()), ("sort_by", Some("invalid_value")));
3723    }
3724
3725    #[test]
3726    fn the_depth_secret_sort_order_and_shaping_refusals_each_name_their_parameter() {
3727        let at = |params: &ListQueryParams, paged: bool| {
3728            query_refusals::<Secretive>(params, paged, None)
3729                .into_iter()
3730                .map(|v| (v.path, v.params["reason"].as_str().unwrap_or("").to_string()))
3731                .collect::<Vec<_>>()
3732        };
3733        let deep = ListQueryParams { page: 500, limit: 100, ..Default::default() };
3734        assert_eq!(at(&deep, true), vec![("page".to_string(), "too_deep".to_string())]);
3735        assert!(at(&deep, false).is_empty(), "count and aggregate do not page");
3736        let mut p = ask(&[]);
3737        p.sort_order = Some("sideways".into());
3738        assert_eq!(at(&p, true), vec![("sort_order".to_string(), "invalid_value".to_string())]);
3739        p.sort_order = Some("DESC".into());
3740        assert!(at(&p, true).is_empty());
3741        let mut p = ask(&[]);
3742        p.sort_by = Some("tokenHash".into());
3743        let named = query_refusals::<Secretive>(&p, true, None);
3744        assert_eq!(named[0].path, "sort_by");
3745        assert!(named[0].message.contains("sort_by") && named[0].message.contains("secret"), "{}", named[0].message);
3746        assert_eq!(at(&ask(&[("token_hash[startwith]", "a")]), true)[0].0, "token_hash[startwith]");
3747        assert_eq!(at(&ask(&[("include", "user,nothing")]), true), vec![("include".to_string(), "unknown_relation".to_string())]);
3748        assert_eq!(at(&ask(&[("with", "nothing")]), true), vec![("with".to_string(), "unknown_relation".to_string())]);
3749        assert!(at(&ask(&[("include", "user")]), true).is_empty());
3750        assert_eq!(at(&ask(&[("fields", "id,na me")]), true), vec![("fields".to_string(), "invalid_field_name".to_string())]);
3751        assert!(at(&ask(&[("fields", "id,someUnknownButWellFormed")]), true).is_empty(), "an unknown field is still ignored");
3752    }
3753
3754    #[test]
3755    fn a_refused_query_parameter_answers_400_not_422() {
3756        use crate::persistence::traits::RepositoryError;
3757        use crate::service::ServiceError;
3758        let named = vec![crate::violation::Violation::invalid_query("page", "not_a_number", "x")];
3759        let broken = vec![crate::violation::Violation::new("name", "max_length", "x")];
3760        assert_eq!(service_error_status(&ServiceError::Repository(RepositoryError::Violations(named.clone()))), axum::http::StatusCode::BAD_REQUEST);
3761        assert_eq!(service_error_status(&ServiceError::Violations(named)), axum::http::StatusCode::BAD_REQUEST);
3762        assert_eq!(service_error_status(&ServiceError::Violations(broken.clone())), axum::http::StatusCode::UNPROCESSABLE_ENTITY);
3763        // A mix of a refused parameter and a broken rule is a broken rule.
3764        let mixed = vec![crate::violation::Violation::invalid_query("page", "x", "x"), broken[0].clone()];
3765        assert!(!is_query_refusal(&mixed));
3766        assert!(!is_query_refusal(&[]));
3767    }
3768
3769    #[test]
3770    fn a_named_refusal_from_the_repository_becomes_an_invalid_query_violation() {
3771        use crate::persistence::traits::RepositoryError;
3772        let refused = backbone_orm::QueryParamError::unknown_column("sort_by", "full_name");
3773        let err = RepositoryError::from_source(anyhow::Error::from(refused).context("while listing"));
3774        let RepositoryError::Violations(v) = err else { panic!("{err:?}") };
3775        assert_eq!(v.len(), 1);
3776        assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("sort_by", "invalid_query"));
3777        assert_eq!(v[0].params["parameter"], "sort_by");
3778        assert_eq!(v[0].params["reason"], "unknown_column");
3779        assert!(v[0].message.contains("sort_by") && v[0].message.contains("full_name"));
3780    }
3781
3782    #[test]
3783    fn list_params_normalize_page_zero_and_an_oversized_limit() {
3784        let p = ListQueryParams { page: 0, limit: 1000, ..Default::default() }.normalized();
3785        assert_eq!((p.page, p.limit), (1, MAX_PER_PAGE));
3786        let p = ListQueryParams { page: 3, limit: 0, ..Default::default() }.normalized();
3787        assert_eq!((p.page, p.limit), (3, 1));
3788    }
3789
3790    #[test]
3791    fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
3792        let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
3793            let params = ListQueryParams {
3794                filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3795                sort_by: sort_by.map(str::to_string),
3796                ..Default::default()
3797            };
3798            named_secret_at(&params, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
3799        };
3800        for pairs in [
3801            vec![("token_hash", "abc")],
3802            vec![("tokenHash[startwith]", "a")],
3803            vec![("orderby", "name,-token_hash")],
3804            vec![("orderby[token_hash]", "asc")],
3805            vec![("searchFields", "name,tokenHash")],
3806            vec![("min", "token_hash")],
3807            vec![("group_by", "tokenHash")],
3808        ] {
3809            let (parameter, field) = ask(&pairs, None).expect("named");
3810            assert_eq!(camel_to_snake_case(&field), "token_hash", "{pairs:?}");
3811            assert_eq!(parameter, pairs[0].0, "the refusal names the parameter that carried it");
3812        }
3813        assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
3814        assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
3815    }
3816
3817    #[test]
3818    fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
3819        let out = secure::<Secretive>(secret_row(), None, None);
3820        assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3821    }
3822
3823    #[test]
3824    fn a_routed_response_dto_loses_the_entitys_secrets() {
3825        let out = without_secrets::<Secretive, _>(secret_row());
3826        assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3827        assert!(out.get("name").is_some());
3828    }
3829
3830    #[test]
3831    fn an_included_row_loses_the_related_models_secrets() {
3832        let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
3833        let out = related_row::<Secretive>("user", "users", raw.clone(), None);
3834        assert!(out.get("passwordHash").is_none(), "{out}");
3835        assert_eq!(out["email"], "a@b.c");
3836        assert!(related_row::<Secretive>("other", "others", raw.clone(), None).get("passwordHash").is_some(), "only the named relation");
3837        // A cross-module relation: the table's entity registered its secrets.
3838        backbone_orm::secret_registry::register("employee_probe.staff", &["passwordHash"]);
3839        assert!(related_row::<Secretive>("staff", "employee_probe.staff", raw, None).get("passwordHash").is_none(), "registered table");
3840    }
3841
3842    // ── Field permissions (requires: on a field) ─────────────────────────────
3843
3844    /// An entity whose `name` field only a holder of `widget:see_name` may read, filter or set.
3845    struct Gated;
3846    impl backbone_orm::EntityRepoMeta for Gated {
3847        fn column_types() -> HashMap<String, String> {
3848            HashMap::new()
3849        }
3850        fn search_fields() -> &'static [&'static str] {
3851            &[]
3852        }
3853        fn field_permissions() -> &'static [(&'static str, &'static str)] {
3854            &[("name", "widget:see_name")]
3855        }
3856    }
3857
3858    fn gated_row() -> serde_json::Value {
3859        serde_json::json!({ "id": "w1", "name": "Hush", "hppPerUnit": 3 })
3860    }
3861
3862    fn caller_holding(perms: &[&str]) -> backbone_auth::middleware::AuthContext {
3863        backbone_auth::middleware::AuthContext {
3864            user_id: "u".into(),
3865            roles: vec![],
3866            permissions: perms.iter().map(|p| p.to_string()).collect(),
3867        }
3868    }
3869
3870    #[test]
3871    fn a_gated_field_is_redacted_to_null_for_a_non_holder_and_kept_for_a_holder() {
3872        let none = secure::<Gated>(gated_row(), None, None);
3873        assert_eq!(none.get("name"), Some(&serde_json::Value::Null), "redacted, not omitted: {none}");
3874        assert_eq!(none.get("hppPerUnit"), Some(&serde_json::json!(3)), "only the gated field");
3875
3876        let holder = caller_holding(&["widget:see_name"]);
3877        assert_eq!(secure::<Gated>(gated_row(), None, Some(&holder)).get("name"), Some(&serde_json::json!("Hush")));
3878
3879        let god = caller_holding(&["*:*"]);
3880        assert_eq!(secure::<Gated>(gated_row(), None, Some(&god)).get("name"), Some(&serde_json::json!("Hush")), "*:* sees everything");
3881
3882        // A caller that exists but holds nothing is the same as no caller: fail closed.
3883        assert_eq!(secure::<Gated>(gated_row(), None, Some(&caller_holding(&[]))).get("name"), Some(&serde_json::Value::Null));
3884        // The caller-less seam generated verbs used before the grants-aware one redacts too.
3885        assert_eq!(without_secrets::<Gated, _>(gated_row()).get("name"), Some(&serde_json::Value::Null));
3886        assert_eq!(without_secrets_for::<Gated, _>(&holder, gated_row()).get("name"), Some(&serde_json::json!("Hush")));
3887    }
3888
3889    #[test]
3890    fn a_gated_field_may_not_be_a_column_of_a_non_holders_query() {
3891        let holder = caller_holding(&["widget:see_name"]);
3892        for (pairs, parameter) in [
3893            (vec![("name", "Hush")], "name"),
3894            (vec![("min", "name")], "min"),
3895            (vec![("group_by", "name")], "group_by"),
3896            (vec![("orderby", "name")], "orderby"),
3897        ] {
3898            let params = ListQueryParams {
3899                filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3900                ..Default::default()
3901            };
3902            let refused = query_refusals::<Gated>(&params, false, None);
3903            assert_eq!((refused[0].path.as_str(), refused[0].params["reason"].as_str()), (parameter, Some("field_permission")), "{pairs:?}");
3904            assert_eq!(refused[0].params["permission"], "widget:see_name", "{pairs:?}");
3905            assert_eq!(refused[0].params["field"], "name", "{pairs:?}");
3906            assert!(query_refusals::<Gated>(&params, false, Some(&holder)).is_empty(), "a holder asks: {pairs:?}");
3907        }
3908        let mut p = ask(&[]);
3909        p.sort_by = Some("name".into());
3910        let refused = query_refusals::<Gated>(&p, true, None);
3911        assert_eq!((refused[0].path.as_str(), refused[0].params["reason"].as_str()), ("sort_by", Some("field_permission")));
3912    }
3913
3914    #[test]
3915    fn a_write_that_names_a_gated_field_is_refused_for_a_non_holder_in_either_casing() {
3916        let holder = caller_holding(&["widget:see_name"]);
3917        for key in ["name", "Name", "NAME"] {
3918            let refused = gated_write_refusals::<Gated>(std::iter::once(key.to_string()), None);
3919            assert_eq!(refused.len(), 1, "{key}");
3920            assert_eq!((refused[0].path.as_str(), refused[0].code.as_str()), (key, "field_permission_required"), "{key}");
3921            assert_eq!(refused[0].params["permission"], "widget:see_name");
3922            assert!(gated_write_refusals::<Gated>(std::iter::once(key.to_string()), Some(&holder)).is_empty(), "a holder sets it: {key}");
3923        }
3924        assert!(gated_write_refusals::<Gated>(std::iter::once("hppPerUnit".to_string()), None).is_empty(), "an ungated field is free to set");
3925        assert!(gated_write_refusals::<Gated>(Vec::<String>::new().into_iter(), None).is_empty());
3926    }
3927
3928    #[test]
3929    fn an_included_row_redacts_the_gated_fields_the_target_registered() {
3930        let raw = serde_json::json!({ "id": "u1", "name": "Hush", "email": "a@b.c" });
3931        backbone_orm::field_permission_registry::register("gated_probe.widgets", &[("name", "widget:see_name")]);
3932        let out = related_row::<Gated>("probe", "gated_probe.widgets", raw.clone(), None);
3933        assert_eq!(out.get("name"), Some(&serde_json::Value::Null), "redacted by the table's own gate: {out}");
3934        assert_eq!(out["email"], "a@b.c");
3935        let holder = caller_holding(&["widget:see_name"]);
3936        let kept = related_row::<Gated>("probe", "gated_probe.widgets", raw, Some(&holder));
3937        assert_eq!(kept.get("name"), Some(&serde_json::json!("Hush")));
3938    }
3939
3940    #[test]
3941    fn security_no_private_fields_is_noop() {
3942        let v = row();
3943        assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
3944    }
3945
3946    #[test]
3947    fn security_platform_sees_private() {
3948        let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
3949        assert!(out.get("hppPerUnit").is_some());
3950    }
3951
3952    #[test]
3953    fn security_owner_tenant_sees_private() {
3954        let out = apply_field_security(
3955            row(),
3956            Some(&AccessScope::Company(owner_a())),
3957            PRIV,
3958            Some("providerId"),
3959        );
3960        assert!(out.get("hppPerUnit").is_some());
3961    }
3962
3963    #[test]
3964    fn security_other_tenant_stripped() {
3965        let out = apply_field_security(
3966            row(),
3967            Some(&AccessScope::Company(owner_b())),
3968            PRIV,
3969            Some("providerId"),
3970        );
3971        assert!(out.get("hppPerUnit").is_none());
3972        assert!(out.get("name").is_some());
3973    }
3974
3975    #[test]
3976    fn security_absent_scope_fails_closed() {
3977        let out = apply_field_security(row(), None, PRIV, Some("providerId"));
3978        assert!(out.get("hppPerUnit").is_none());
3979    }
3980
3981    // ── Relation expansion (?include=) helpers ──
3982
3983    #[test]
3984    fn include_relations_parses_include_and_with() {
3985        let mut q = HashMap::new();
3986        q.insert("include".to_string(), "provider, outlet ".to_string());
3987        assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
3988        let mut q2 = HashMap::new();
3989        q2.insert("with".to_string(), "category".to_string());
3990        assert_eq!(include_relations(&q2), vec!["category"]);
3991        assert!(include_relations(&HashMap::new()).is_empty());
3992    }
3993
3994    #[test]
3995    fn snake_to_camel_converts() {
3996        assert_eq!(snake_to_camel("provider_id"), "providerId");
3997        assert_eq!(snake_to_camel("business_name"), "businessName");
3998        assert_eq!(snake_to_camel("id"), "id");
3999    }
4000
4001    #[test]
4002    fn camelize_keys_top_level_only() {
4003        let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
4004        let out = camelize_keys(v);
4005        assert!(out.get("providerId").is_some());
4006        // nested keys are left untouched (per-struct camelCase, shallow).
4007        assert!(out.get("metaData").unwrap().get("created_at").is_some());
4008    }
4009
4010    #[test]
4011    fn security_null_owner_only_platform_sees() {
4012        let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
4013        let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
4014        assert!(tenant.get("hppPerUnit").is_none());
4015        let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
4016        assert!(plat.get("hppPerUnit").is_some());
4017    }
4018
4019    #[test]
4020    fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
4021        let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
4022        let (status, axum::Json(body)) =
4023            write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
4024        assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
4025        let json = serde_json::to_value(&body).unwrap();
4026        assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
4027        assert_eq!(json["violations"][0]["path"], "status");
4028        assert_eq!(json["violations"][0]["code"], "field_not_writable");
4029
4030        // Any other client failure keeps its status and sentence, coded by its status.
4031        let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
4032        assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
4033        let json = serde_json::to_value(&body).unwrap();
4034        assert_eq!(json["error"], "boom");
4035        assert_eq!(json["violations"][0]["code"], "invalid_request");
4036        assert_eq!(json["violations"][0]["path"], "");
4037        // A server fault stays uncoded and generic.
4038        let (_, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::INTERNAL_SERVER_ERROR);
4039        assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
4040    }
4041}
4042
4043#[cfg(test)]
4044mod internal_key_tests {
4045    use super::*;
4046
4047    fn params_with(filters: &[(&str, &str)]) -> ListQueryParams {
4048        ListQueryParams {
4049            page: 1,
4050            limit: 20,
4051            sort_by: None,
4052            sort_order: None,
4053            search: None,
4054            status: None,
4055            filters: filters
4056                .iter()
4057                .map(|(k, v)| (k.to_string(), v.to_string()))
4058                .collect(),
4059        }
4060    }
4061
4062    #[test]
4063    fn internal_keys_never_reach_the_repository() {
4064        let params = params_with(&[
4065            ("__base_condition", "EXISTS(SELECT 1)"),
4066            ("__base_condition[eq]", "1=1"),
4067            ("__anything", "x"),
4068            ("name", "acme"),
4069        ]);
4070        let filters = repository_filters(&params);
4071        assert_eq!(filters.len(), 1, "{filters:?}");
4072        assert_eq!(filters.get("name").map(String::as_str), Some("acme"));
4073        let filters = aggregate_filters(&params);
4074        assert!(filters.keys().all(|k| !k.starts_with("__")), "{filters:?}");
4075    }
4076}
4077
4078#[cfg(test)]
4079mod service_error_response_tests {
4080    use super::*;
4081    use crate::service::ServiceError;
4082
4083    #[test]
4084    fn a_refused_field_is_422_with_its_violation_never_500() {
4085        let err = ServiceError::Violations(vec![crate::violation::Violation::new(
4086            "state",
4087            "field_not_writable",
4088            "state cannot be changed by a generic write",
4089        )]);
4090        let (status, body) = service_error_response::<()>(&err);
4091        assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
4092        assert_eq!(body.0.violations.as_ref().map(Vec::len), Some(1));
4093    }
4094
4095    #[test]
4096    fn other_errors_keep_their_own_status() {
4097        use axum::http::StatusCode;
4098        let status = |e: ServiceError| service_error_response::<()>(&e).0;
4099        assert_eq!(
4100            status(ServiceError::Validation("bad".into())),
4101            StatusCode::BAD_REQUEST
4102        );
4103        assert_eq!(status(ServiceError::NotFound), StatusCode::NOT_FOUND);
4104        assert_eq!(
4105            status(ServiceError::AlreadyExists("x".into())),
4106            StatusCode::CONFLICT
4107        );
4108        assert_eq!(
4109            status(ServiceError::Internal("boom".into())),
4110            StatusCode::INTERNAL_SERVER_ERROR
4111        );
4112    }
4113}