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