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