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