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