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/// The answer of `POST {resource}/bulk/preview`: what a bulk PATCH would do.
828#[derive(Debug, Default, Serialize)]
829#[serde(rename_all = "camelCase")]
830#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
831pub struct BulkPreview {
832    /// Rows the patch would change, each with its patched fields before and after.
833    pub will_change: Vec<PreviewChange>,
834    /// Rows that already hold the patched values.
835    pub unchanged: Vec<String>,
836    /// Rows the write would refuse, with why.
837    pub refused: Vec<PreviewRefusal>,
838    /// Every row named, in one of the three lists.
839    pub total: usize,
840}
841
842/// A row a bulk PATCH would change.
843#[derive(Debug, Serialize)]
844#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
845pub struct PreviewChange {
846    /// The row's id.
847    pub id: String,
848    /// Each patched field whose value would move.
849    pub changes: Vec<FieldChange>,
850}
851
852/// One field's value before and after a previewed write.
853#[derive(Debug, Serialize)]
854#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
855pub struct FieldChange {
856    /// The field, as the response names it.
857    pub field: String,
858    /// Its value now.
859    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
860    pub from: serde_json::Value,
861    /// Its value after the write.
862    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
863    pub to: serde_json::Value,
864}
865
866/// A row a bulk PATCH would refuse.
867#[derive(Debug, Serialize)]
868#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
869pub struct PreviewRefusal {
870    /// The row's id.
871    pub id: String,
872    /// The first rule it breaks (`refused` when the error names none).
873    pub code: String,
874    /// The sentence the write would refuse it with.
875    pub message: String,
876}
877
878/// One dry-run row as the response shows it: `(before, after)`, or `(code, sentence)`.
879type PreviewOutcome = Result<(serde_json::Value, serde_json::Value), (String, String)>;
880
881/// Sort a bulk dry run's rows into the three lists of [`BulkPreview`]. Each row is
882/// its record before and after as the response shows it (secrets already
883/// stripped), or the `(code, sentence)` it would be refused with; `patched` names
884/// the fields each id's patch set (snake_case; read under their camelCase name
885/// first), and only those are compared, so a value the
886/// update hook moves on its own (a timestamp) is not reported as the change.
887fn preview_answer(
888    rows: Vec<(String, PreviewOutcome)>,
889    patched: &HashMap<String, Vec<String>>,
890) -> BulkPreview {
891    let mut out = BulkPreview::default();
892    for (id, outcome) in rows {
893        match outcome {
894            Ok((before, after)) => {
895                let mut keys = patched.get(&id).cloned().unwrap_or_default();
896                keys.sort();
897                let changes: Vec<FieldChange> = keys
898                    .into_iter()
899                    .filter_map(|field| {
900                        let read = |v: &serde_json::Value| {
901                            v.get(snake_to_camel(&field))
902                                .or_else(|| v.get(&field))
903                                .cloned()
904                                .unwrap_or(serde_json::Value::Null)
905                        };
906                        let (from, to) = (read(&before), read(&after));
907                        (from != to).then_some(FieldChange { field, from, to })
908                    })
909                    .collect();
910                if changes.is_empty() {
911                    out.unchanged.push(id);
912                } else {
913                    out.will_change.push(PreviewChange { id, changes });
914                }
915            }
916            Err((code, message)) => out.refused.push(PreviewRefusal { id, code, message }),
917        }
918    }
919    out.total = out.will_change.len() + out.unchanged.len() + out.refused.len();
920    out
921}
922
923/// Filtering and sorting options
924#[derive(Debug, Serialize, Deserialize)]
925#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
926pub struct FilterOptions {
927    pub filters: HashMap<String, String>,
928    pub sort_by: Option<String>,
929    pub sort_order: Option<SortOrder>,
930}
931
932/// Sort order enum
933#[derive(Debug, Serialize, Deserialize, Clone, Default)]
934#[serde(rename_all = "lowercase")]
935#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
936pub enum SortOrder {
937    #[default]
938    Asc,
939    Desc,
940}
941
942/// Extended pagination request with filtering and sorting
943#[derive(Debug, Serialize, Deserialize)]
944#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
945pub struct ListRequest {
946    pub page: Option<u32>,
947    pub limit: Option<u32>,
948    pub sort_by: Option<String>,
949    pub sort_order: Option<SortOrder>,
950    pub filters: Option<HashMap<String, String>>,
951}
952
953impl Default for ListRequest {
954    fn default() -> Self {
955        Self {
956            page: Some(1),
957            limit: Some(20),
958            sort_by: None,
959            sort_order: None,
960            filters: None,
961        }
962    }
963}
964
965// ============================================================
966// CrudService Trait - Core Generic CRUD Interface
967// ============================================================
968
969/// Core trait that application services must implement for generic CRUD operations.
970///
971/// This trait provides the contract for all 11 standard Backbone endpoints.
972/// Module-specific services implement this trait with their entity types.
973///
974/// # Type Parameters
975/// - `Entity`: The domain entity type
976/// - `CreateDto`: DTO for create operations
977/// - `UpdateDto`: DTO for update operations
978///
979/// # Example
980/// ```ignore
981/// #[async_trait]
982/// impl CrudService<User, CreateUserDto, UpdateUserDto> for UserCrudService {
983///     type Error = ApplicationError;
984///     fn entity_name() -> &'static str { "User" }
985///     // ... implement all methods
986/// }
987/// ```
988#[async_trait::async_trait]
989pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
990where
991    // `'static` is required so the default batch methods (which hold these types
992    // across `.await` points) satisfy async-trait's `'async_trait` bound.
993    Entity: Send + Sync + 'static,
994    CreateDto: Send + Sync + 'static,
995    UpdateDto: Send + Sync + 'static,
996{
997    /// Error type for service operations
998    type Error: std::error::Error + Send + Sync;
999
1000    /// The named rules a refused write broke, when the error carries them, so
1001    /// the response can list them beside the sentence. `None` by default.
1002    fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
1003        let _ = err;
1004        None
1005    }
1006
1007    /// Entity name for error messages (e.g., "User", "Role")
1008    fn entity_name() -> &'static str;
1009
1010    /// Hydrate `?include=` relations: fetch rows from `table` by id list, as JSON.
1011    /// Default: no expansion. `GenericCrudService` delegates to its repository.
1012    async fn fetch_related_json(
1013        &self,
1014        _table: &str,
1015        _ids: &[String],
1016    ) -> Vec<serde_json::Value> {
1017        Vec::new()
1018    }
1019
1020    /// 1. List entities with pagination and filters
1021    async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1022
1023    /// `list`, carrying the pagination info (cursor positions included) so
1024    /// the HTTP layer can surface keyset paging. Default: the tuple form —
1025    /// generated services override this with the repository's cursors.
1026    async fn list_with_info(
1027        &self,
1028        page: u32,
1029        limit: u32,
1030        filters: HashMap<String, String>,
1031    ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
1032        let (rows, total) = self.list(page, limit, filters).await?;
1033        Ok((
1034            rows,
1035            backbone_orm::repository::PaginationInfo::new(page, limit, total),
1036        ))
1037    }
1038
1039    /// 2. Create a new entity
1040    async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1041
1042    /// 3. Get entity by ID
1043    async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1044
1045    /// 4. Full update of entity
1046    async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
1047
1048    /// 5. Partial update with specific fields
1049    async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
1050
1051    /// 6. Soft delete entity
1052    async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
1053
1054    /// 7. Bulk create multiple entities
1055    async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
1056
1057    /// 8. Upsert (create or update)
1058    async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1059
1060    /// 9. List deleted entities (trash)
1061    async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
1062
1063    /// 10. Restore soft-deleted entity
1064    async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1065
1066    /// 11. Permanently delete all soft-deleted entities
1067    async fn empty_trash(&self) -> Result<u64, Self::Error>;
1068
1069    /// 12. Get deleted entity by ID (from trash)
1070    async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1071
1072    /// 13. Permanently delete a single soft-deleted entity by ID
1073    async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
1074
1075    /// 14. List deleted entities with filters
1076    async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1077
1078    /// 15. Count active (non-deleted) entities
1079    async fn count_active(&self) -> Result<u64, Self::Error>;
1080
1081    /// The schema-qualified table this service reads, when it has one.
1082    ///
1083    /// Used to key a record's history. Derived from the mount path instead,
1084    /// this would silently return an empty history whenever a route segment
1085    /// and a Postgres schema disagreed — so it is read from the repository,
1086    /// which is the same string the capture trigger writes.
1087    fn table_name(&self) -> Option<&str> {
1088        None
1089    }
1090
1091    /// Group and reduce the rows `list` would return, under the same filters.
1092    ///
1093    /// The default refuses rather than inventing an answer — see the repository
1094    /// trait for why a zero-filled default would be worse than an error.
1095    async fn aggregate(
1096        &self,
1097        spec: &backbone_orm::repository::AggregateSpec,
1098        filters: HashMap<String, String>,
1099    ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
1100
1101    /// Count active entities MATCHING the caller's filters.
1102    ///
1103    /// The generated client has always sent filters to `/count`; the handler
1104    /// never read them, so a filtered count silently answered with the whole
1105    /// table. Nothing caught it because the wrong number is a plausible one.
1106    ///
1107    /// The default delegates to `list`, whose total is already built from the
1108    /// same where-clause as its data query — so the filtered count is correct
1109    /// by construction rather than by a second implementation of the same
1110    /// filter parsing, which is exactly how the two drifted apart. It asks for
1111    /// a single row because the rows are discarded; an implementor that can
1112    /// count without fetching may override.
1113    async fn count_active_filtered(
1114        &self,
1115        filters: HashMap<String, String>,
1116    ) -> Result<u64, Self::Error> {
1117        self.list(1, 1, filters).await.map(|(_, total)| total)
1118    }
1119
1120    /// 16. Count deleted entities in trash
1121    async fn count_deleted(&self) -> Result<u64, Self::Error>;
1122
1123    // ── Atomic batch operations ───────────────────────────────────────────────
1124    //
1125    // The default implementations are best-effort loops over the single-row
1126    // methods, provided so non-generic implementors keep compiling. The blanket
1127    // impl on `GenericCrudService` overrides them with transactional,
1128    // all-or-nothing versions (which is what every generated service runs).
1129
1130    /// 17. Soft-delete many entities by id. Returns the number affected.
1131    async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1132        let mut n = 0;
1133        for id in ids {
1134            if self.soft_delete(&id).await? {
1135                n += 1;
1136            }
1137        }
1138        Ok(n)
1139    }
1140
1141    /// 18. Restore many soft-deleted entities by id. Returns the restored rows.
1142    async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1143        let mut out = Vec::with_capacity(ids.len());
1144        for id in ids {
1145            if let Some(e) = self.restore(&id).await? {
1146                out.push(e);
1147            }
1148        }
1149        Ok(out)
1150    }
1151
1152    /// 19. Permanently delete many soft-deleted entities by id.
1153    async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1154        let mut n = 0;
1155        for id in ids {
1156            if self.permanent_delete(&id).await? {
1157                n += 1;
1158            }
1159        }
1160        Ok(n)
1161    }
1162
1163    /// 20. Restore every soft-deleted entity. Returns the number restored.
1164    ///
1165    /// Default is a no-op (`0`) — only the `GenericCrudService` blanket impl can
1166    /// perform this without an id list.
1167    async fn restore_all(&self) -> Result<u64, Self::Error> {
1168        Ok(0)
1169    }
1170
1171    /// 21. Full-update many entities. Each item is `(id, UpdateDto)`.
1172    async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1173        let mut out = Vec::with_capacity(items.len());
1174        for (id, dto) in items {
1175            if let Some(e) = self.update(&id, dto).await? {
1176                out.push(e);
1177            }
1178        }
1179        Ok(out)
1180    }
1181
1182    /// 22. Partial-update many entities. Each item is `(id, field_map)`.
1183    async fn bulk_partial_update(
1184        &self,
1185        items: Vec<(String, HashMap<String, serde_json::Value>)>,
1186    ) -> Result<Vec<Entity>, Self::Error> {
1187        let mut out = Vec::with_capacity(items.len());
1188        for (id, fields) in items {
1189            if let Some(e) = self.partial_update(&id, fields).await? {
1190                out.push(e);
1191            }
1192        }
1193        Ok(out)
1194    }
1195
1196    /// 23. What [`bulk_partial_update`](Self::bulk_partial_update) would do, with
1197    /// nothing written: each row as it is and as it would become, or why the
1198    /// write would refuse it. `Ok(None)` (the default) means this service has no
1199    /// dry run, and the route says so instead of guessing.
1200    async fn preview_bulk_partial_update(
1201        &self,
1202        items: Vec<(String, HashMap<String, serde_json::Value>)>,
1203    ) -> Result<Option<Vec<BulkPreviewRow<Entity, Self::Error>>>, Self::Error> {
1204        let _ = items;
1205        Ok(None)
1206    }
1207}
1208
1209/// One row of a bulk partial update's dry run: the record as it is and as the
1210/// patch would leave it, or the error the write would refuse it with.
1211pub struct BulkPreviewRow<Entity, Err> {
1212    /// The id the patch named.
1213    pub id: String,
1214    /// `(before, after)`, or why the row would be refused.
1215    pub outcome: Result<(Entity, Entity), Err>,
1216}
1217
1218// ============================================================
1219// BackboneCrudHandler - Generic Axum Router Builder
1220// ============================================================
1221
1222/// Generic Backbone CRUD handler that provides all 11 endpoints as an Axum router.
1223///
1224/// This handler wraps a `CrudService` implementation and generates all standard
1225/// Backbone endpoints automatically.
1226///
1227/// # Type Parameters
1228/// - `S`: Service implementing `CrudService`
1229/// - `E`: Entity type
1230/// - `C`: Create DTO type
1231/// - `U`: Update DTO type
1232/// - `R`: Response DTO type (must implement `From<E>`)
1233///
1234/// # Example
1235/// ```ignore
1236/// let user_crud = UserCrudService::new(user_service);
1237/// let routes = BackboneCrudHandler::<UserCrudService, UserAggregate, CreateUserDto, UpdateUserDto, UserResponseDto>
1238///     ::routes(Arc::new(user_crud), "/api/v1/users");
1239/// ```
1240pub struct BackboneCrudHandler<S, E, C, U, R>
1241where
1242    S: CrudService<E, C, U> + 'static,
1243    E: Serialize + Send + Sync + 'static,
1244    C: DeserializeOwned + Send + Sync + 'static,
1245    U: DeserializeOwned + Send + Sync + 'static,
1246    R: From<E> + Serialize + Send + Sync + 'static,
1247{
1248    service: Arc<S>,
1249    _phantom: std::marker::PhantomData<(E, C, U, R)>,
1250}
1251
1252impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1253where
1254    S: CrudService<E, C, U> + 'static,
1255    E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1256    C: DeserializeOwned + Send + Sync + 'static,
1257    U: DeserializeOwned + Send + Sync + 'static,
1258    R: From<E> + Serialize + Send + Sync + 'static,
1259{
1260    pub fn new(service: Arc<S>) -> Self {
1261        Self {
1262            service,
1263            _phantom: std::marker::PhantomData,
1264        }
1265    }
1266
1267    /// Create Axum router with all 16 Backbone endpoints (reads + writes).
1268    ///
1269    /// Equivalent to `read_routes(...).merge(write_routes(...))`. Use the
1270    /// split variants when reads and writes need different middleware
1271    /// (e.g., public reads + authenticated writes).
1272    ///
1273    /// # Routes Created
1274    /// 1. `GET {base_path}` - List with pagination
1275    /// 2. `POST {base_path}` - Create
1276    /// 3. `GET {base_path}/:id` - Get by ID
1277    /// 4. `PUT {base_path}/:id` - Full update
1278    /// 5. `PATCH {base_path}/:id` - Partial update
1279    /// 6. `DELETE {base_path}/:id` - Soft delete
1280    /// 7. `POST {base_path}/bulk` - Bulk create
1281    /// 8. `POST {base_path}/upsert` - Upsert
1282    /// 9. `GET {base_path}/trash` - List deleted (with filters)
1283    /// 10. `POST {base_path}/:id/restore` - Restore
1284    /// 11. `DELETE {base_path}/empty` - Empty trash
1285    /// 12. `GET {base_path}/:id/deleted` - Get deleted by ID
1286    /// 13. `DELETE {base_path}/trash/:id` - Permanent delete from trash
1287    /// 14. (Uses route 9 with filters)
1288    /// 15. `GET {base_path}/count` - Count active entities
1289    /// 16. `GET {base_path}/trash/count` - Count deleted entities
1290    pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1291    where
1292        S: Clone,
1293    {
1294        Self::read_routes(service.clone(), base_path)
1295            .merge(Self::write_routes(service, base_path))
1296    }
1297
1298    /// Create Axum router with only the read (GET) endpoints.
1299    ///
1300    /// Safe to expose publicly (e.g., for reference data like countries or
1301    /// categories) without auth middleware. Pair with `write_routes` under
1302    /// an auth layer when mutations must be restricted.
1303    ///
1304    /// # Routes Created
1305    /// - `GET {base_path}` - List with pagination
1306    /// - `GET {base_path}/:id` - Get by ID
1307    /// - `GET {base_path}/trash` - List deleted (with filters)
1308    /// - `GET {base_path}/:id/deleted` - Get deleted by ID
1309    /// - `GET {base_path}/count` - Count active entities
1310    /// - `GET {base_path}/trash/count` - Count deleted entities
1311    pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1312    where
1313        S: Clone,
1314    {
1315        // Building a generic surface for this entity records its secrets under its table, so
1316        // a cross-module `?include=` of the table strips them (see `secret_registry`).
1317        if let Some(table) = service.table_name() {
1318            backbone_orm::secret_registry::register(table, E::secret_fields());
1319        }
1320        use axum::{
1321            extract::{Path, Query},
1322            routing::get,
1323            Extension, Router,
1324        };
1325
1326        let handler = Arc::new(Self::new(service));
1327
1328        Router::new()
1329            // GET /collection - List
1330            .route(base_path, get({
1331                let h = handler.clone();
1332                move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1333                    Self::list_handler(h, query, access).await
1334                }
1335            }))
1336            // GET /collection/trash - List deleted
1337            .route(&format!("{}/trash", base_path), get({
1338                let h = handler.clone();
1339                move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1340                    Self::list_deleted_handler(h, query, access).await
1341                }
1342            }))
1343            // GET /collection/:id - Get by ID
1344            .route(&format!("{}/:id", base_path), get({
1345                let h = handler.clone();
1346                move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1347                    Self::get_handler(h, path, query, access).await
1348                }
1349            }))
1350            // GET /collection/:id/deleted - Get deleted by ID
1351            .route(&format!("{}/:id/deleted", base_path), get({
1352                let h = handler.clone();
1353                move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1354                    Self::get_deleted_handler(h, path, query, access).await
1355                }
1356            }))
1357            // GET /collection/count - Count active entities
1358            // GET /collection/:id/history - This record's recorded changes
1359            .route(&format!("{}/:id/history", base_path), get({
1360                let h = handler.clone();
1361                move |path: axum::extract::Path<String>,
1362                      query: axum::extract::Query<ListQueryParams>,
1363                      provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
1364                    Self::history_handler(h, path, query, provider).await
1365                }
1366            }))
1367            // GET /collection/aggregate - Group and reduce
1368            .route(&format!("{}/aggregate", base_path), get({
1369                let h = handler.clone();
1370                move |query: axum::extract::Query<ListQueryParams>| async move {
1371                    Self::aggregate_handler(h, query).await
1372                }
1373            }))
1374            .route(&format!("{}/count", base_path), get({
1375                let h = handler.clone();
1376                move |query: axum::extract::Query<ListQueryParams>| async move {
1377                    Self::count_active_handler(h, query).await
1378                }
1379            }))
1380            // GET /collection/trash/count - Count deleted entities
1381            .route(&format!("{}/trash/count", base_path), get({
1382                let h = handler.clone();
1383                move || async move {
1384                    Self::count_deleted_handler(h).await
1385                }
1386            }))
1387    }
1388
1389    /// Create Axum router with only the write (mutation) endpoints.
1390    ///
1391    /// These routes should not be publicly exposed. Wrap them with an auth
1392    /// middleware (see `BackboneCrudHandler` module docs) before nesting
1393    /// into the application router.
1394    ///
1395    /// # Routes Created
1396    /// - `POST {base_path}` - Create
1397    /// - `POST {base_path}/bulk` - Bulk create
1398    /// - `POST {base_path}/upsert` - Upsert
1399    /// - `DELETE {base_path}/empty` - Empty trash
1400    /// - `DELETE {base_path}/trash/:id` - Permanent delete from trash
1401    /// - `PUT {base_path}/:id` - Full update
1402    /// - `PATCH {base_path}/:id` - Partial update
1403    /// - `DELETE {base_path}/:id` - Soft delete
1404    /// - `POST {base_path}/:id/restore` - Restore
1405    pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1406    where
1407        S: Clone,
1408    {
1409        // Building a generic surface for this entity records its secrets under its table, so
1410        // a cross-module `?include=` of the table strips them (see `secret_registry`).
1411        if let Some(table) = service.table_name() {
1412            backbone_orm::secret_registry::register(table, E::secret_fields());
1413        }
1414        use axum::{
1415            extract::Path,
1416            routing::{delete, patch, post, put},
1417            Router,
1418        };
1419
1420        let handler = Arc::new(Self::new(service));
1421
1422        Router::new()
1423            // POST /collection - Create
1424            .route(base_path, post({
1425                let h = handler.clone();
1426                move |body: JsonOrForm<C>| async move {
1427                    Self::create_handler(h, body).await
1428                }
1429            }))
1430            // POST /collection/bulk - Bulk create
1431            .route(&format!("{}/bulk", base_path), post({
1432                let h = handler.clone();
1433                move |body: JsonOrForm<Vec<C>>| async move {
1434                    Self::bulk_create_handler(h, body).await
1435                }
1436            }))
1437            // POST /collection/upsert - Upsert
1438            .route(&format!("{}/upsert", base_path), post({
1439                let h = handler.clone();
1440                move |body: JsonOrForm<C>| async move {
1441                    Self::upsert_handler(h, body).await
1442                }
1443            }))
1444            // POST /collection/delete/bulk - Bulk soft delete by ids
1445            .route(&format!("{}/delete/bulk", base_path), post({
1446                let h = handler.clone();
1447                move |body: JsonOrForm<BatchIdsRequest>| async move {
1448                    Self::bulk_delete_handler(h, body).await
1449                }
1450            }))
1451            // POST /collection/restore/bulk - Bulk restore by ids
1452            .route(&format!("{}/restore/bulk", base_path), post({
1453                let h = handler.clone();
1454                move |body: JsonOrForm<BatchIdsRequest>| async move {
1455                    Self::bulk_restore_handler(h, body).await
1456                }
1457            }))
1458            // POST /collection/restore/all - Restore all soft-deleted
1459            .route(&format!("{}/restore/all", base_path), post({
1460                let h = handler.clone();
1461                move || async move {
1462                    Self::restore_all_handler(h).await
1463                }
1464            }))
1465            // DELETE /collection/trash/bulk - Bulk hard delete by ids
1466            // (registered alongside /trash/:id; matchit prioritises the static segment)
1467            .route(&format!("{}/trash/bulk", base_path), delete({
1468                let h = handler.clone();
1469                move |body: JsonOrForm<BatchIdsRequest>| async move {
1470                    Self::bulk_permanent_delete_handler(h, body).await
1471                }
1472            }))
1473            // PUT /collection/bulk - Bulk full update
1474            .route(&format!("{}/bulk", base_path), put({
1475                let h = handler.clone();
1476                move |body: JsonOrForm<Vec<BulkUpdateItem<U>>>| async move {
1477                    Self::bulk_update_handler(h, body).await
1478                }
1479            }))
1480            // PATCH /collection/bulk - Bulk partial update (shared or per-id)
1481            .route(&format!("{}/bulk", base_path), patch({
1482                let h = handler.clone();
1483                move |body: JsonOrForm<BulkPatchRequest>| async move {
1484                    Self::bulk_patch_handler(h, body).await
1485                }
1486            }))
1487            // POST /collection/bulk/preview - What the bulk partial update would do, nothing written
1488            .route(&format!("{}/bulk/preview", base_path), post({
1489                let h = handler.clone();
1490                move |body: JsonOrForm<BulkPatchRequest>| async move {
1491                    Self::bulk_patch_preview_handler(h, body).await
1492                }
1493            }))
1494            // DELETE /collection/empty - Empty trash
1495            .route(&format!("{}/empty", base_path), delete({
1496                let h = handler.clone();
1497                move || async move {
1498                    Self::empty_trash_handler(h).await
1499                }
1500            }))
1501            // DELETE /collection/trash/:id - Permanent delete from trash
1502            .route(&format!("{}/trash/:id", base_path), delete({
1503                let h = handler.clone();
1504                move |path: Path<String>| async move {
1505                    Self::permanent_delete_handler(h, path).await
1506                }
1507            }))
1508            // PUT /collection/:id - Full update
1509            .route(&format!("{}/:id", base_path), put({
1510                let h = handler.clone();
1511                move |path: Path<String>, body: JsonOrForm<U>| async move {
1512                    Self::update_handler(h, path, body).await
1513                }
1514            }))
1515            // PATCH /collection/:id - Partial update
1516            .route(&format!("{}/:id", base_path), patch({
1517                let h = handler.clone();
1518                move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
1519                    Self::partial_update_handler(h, path, body).await
1520                }
1521            }))
1522            // DELETE /collection/:id - Soft delete
1523            .route(&format!("{}/:id", base_path), delete({
1524                let h = handler.clone();
1525                move |path: Path<String>| async move {
1526                    Self::delete_handler(h, path).await
1527                }
1528            }))
1529            // POST /collection/:id/restore - Restore
1530            .route(&format!("{}/:id/restore", base_path), post({
1531                let h = handler.clone();
1532                move |path: Path<String>| async move {
1533                    Self::restore_handler(h, path).await
1534                }
1535            }))
1536    }
1537
1538    // ============================================================
1539    // Handler Implementations
1540    // ============================================================
1541
1542    async fn list_handler(
1543        handler: Arc<Self>,
1544        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1545        access: Option<axum::Extension<AccessScope>>,
1546    ) -> impl axum::response::IntoResponse {
1547        use axum::{http::StatusCode, Json};
1548
1549        if let Some(err) = pagination_depth_error(params.page, params.limit) {
1550            return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1551        }
1552        if let Some(field) = named_secret(&params, E::secret_fields()) {
1553            return (
1554                StatusCode::BAD_REQUEST,
1555                Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1556            );
1557        }
1558
1559        // Sparse fieldset (`?fields=a,b,c`) is response-shaping, not a filter — read it,
1560        // then drop the reserved keys so they're never passed to the repository.
1561        let fields = sparse_fields(&params.filters);
1562        let includes = include_relations(&params.filters);
1563        let scope = access.map(|axum::Extension(s)| s);
1564
1565        let filters = repository_filters(&params);
1566
1567        match handler
1568            .service
1569            .list_with_info(params.page, params.limit, filters)
1570            .await
1571        {
1572            Ok((entities, info)) => {
1573                // Security ceiling first, then relation expansion (batched across all
1574                // rows), then sparse projection.
1575                let mut rows: Vec<serde_json::Value> = entities
1576                    .into_iter()
1577                    .map(|e| {
1578                        secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1579                    })
1580                    .collect();
1581                expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1582                let items: Vec<serde_json::Value> =
1583                    rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1584                let response = PaginatedApiResponse::ok_with_info(items, &info);
1585                (StatusCode::OK, Json(response))
1586            }
1587            Err(e) => {
1588                let msg = e.to_string();
1589                if is_bad_query_error(&msg) {
1590                    (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
1591                        format!("Invalid query parameter or filter: {msg}"),
1592                    )))
1593                } else {
1594                    (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
1595                }
1596            }
1597        }
1598    }
1599
1600    async fn create_handler(
1601        handler: Arc<Self>,
1602        JsonOrForm(dto): JsonOrForm<C>,
1603    ) -> impl axum::response::IntoResponse {
1604        use axum::{http::StatusCode, Json};
1605
1606        match handler.service.create(dto).await {
1607            Ok(entity) => {
1608                let response = secure::<E>(to_response_value(R::from(entity)), None);
1609                (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1610            }
1611            Err(e) if S::violations_of(&e).is_some() => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1612            Err(e) => {
1613                let error_str = e.to_string();
1614                if error_str.contains("conflict") || error_str.contains("already exists") {
1615                    (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1616                } else {
1617                    (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1618                }
1619            }
1620        }
1621    }
1622
1623    async fn get_handler(
1624        handler: Arc<Self>,
1625        axum::extract::Path(id): axum::extract::Path<String>,
1626        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1627        access: Option<axum::Extension<AccessScope>>,
1628    ) -> impl axum::response::IntoResponse {
1629        use axum::{http::StatusCode, Json};
1630
1631        let fields = sparse_fields(&params.filters);
1632        let includes = include_relations(&params.filters);
1633        let scope = access.map(|axum::Extension(s)| s);
1634
1635        match handler.service.get_by_id(&id).await {
1636            Ok(Some(entity)) => {
1637                let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
1638                let mut rows = [secured];
1639                expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1640                let [secured] = rows;
1641                let value = project_sparse(secured, &fields);
1642                (StatusCode::OK, Json(ApiResponse::ok(value)))
1643            }
1644            Ok(None) => {
1645                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1646            }
1647            Err(e) => {
1648                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1649            }
1650        }
1651    }
1652
1653    async fn update_handler(
1654        handler: Arc<Self>,
1655        axum::extract::Path(id): axum::extract::Path<String>,
1656        JsonOrForm(dto): JsonOrForm<U>,
1657    ) -> impl axum::response::IntoResponse {
1658        use axum::{http::StatusCode, Json};
1659
1660        match handler.service.update(&id, dto).await {
1661            Ok(Some(entity)) => {
1662                let response = secure::<E>(to_response_value(R::from(entity)), None);
1663                (StatusCode::OK, Json(ApiResponse::ok(response)))
1664            }
1665            Ok(None) => {
1666                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1667            }
1668            Err(e) => {
1669                write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1670            }
1671        }
1672    }
1673
1674    async fn partial_update_handler(
1675        handler: Arc<Self>,
1676        axum::extract::Path(id): axum::extract::Path<String>,
1677        JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
1678    ) -> impl axum::response::IntoResponse {
1679        use axum::{http::StatusCode, Json};
1680
1681        // Normalize incoming JSON keys to snake_case before forwarding to the
1682        // service-layer merge. Entities serialize with Rust's snake_case field
1683        // names by default, so a client sending camelCase (e.g. `isVip`) would
1684        // otherwise produce a merged JSON object with both `is_vip` (from the
1685        // existing entity) and `isVip` (from the patch). On deserialize back
1686        // to the entity, the unknown camelCase key is silently dropped and the
1687        // edit is lost. Normalization makes this class of bug impossible.
1688        //
1689        // The conversion is idempotent — already-snake_case keys pass through
1690        // unchanged — so clients that already conform see no behavior change.
1691        let fields: HashMap<String, serde_json::Value> = fields
1692            .into_iter()
1693            .map(|(k, v)| (camel_to_snake_case(&k), v))
1694            .collect();
1695
1696        match handler.service.partial_update(&id, fields).await {
1697            Ok(Some(entity)) => {
1698                let response = secure::<E>(to_response_value(R::from(entity)), None);
1699                (StatusCode::OK, Json(ApiResponse::ok(response)))
1700            }
1701            Ok(None) => {
1702                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1703            }
1704            Err(e) => {
1705                write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1706            }
1707        }
1708    }
1709
1710    async fn delete_handler(
1711        handler: Arc<Self>,
1712        axum::extract::Path(id): axum::extract::Path<String>,
1713    ) -> impl axum::response::IntoResponse {
1714        use axum::{http::StatusCode, Json};
1715
1716        match handler.service.soft_delete(&id).await {
1717            Ok(true) => {
1718                // Return 200 OK with success response instead of 204 No Content
1719                // to ensure proper JSON response handling
1720                (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
1721            }
1722            Ok(false) => {
1723                (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
1724            }
1725            Err(e) => {
1726                write_error(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR)
1727            }
1728        }
1729    }
1730
1731    async fn bulk_create_handler(
1732        handler: Arc<Self>,
1733        JsonOrForm(items): JsonOrForm<Vec<C>>,
1734    ) -> impl axum::response::IntoResponse {
1735        use axum::{http::StatusCode, Json};
1736
1737        match handler.service.bulk_create(items).await {
1738            Ok(entities) => {
1739                let result_items: Vec<serde_json::Value> = entities
1740                    .into_iter()
1741                    .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1742                    .collect();
1743                let total = result_items.len();
1744                let response = BulkResponse {
1745                    items: result_items,
1746                    total,
1747                    failed: 0,
1748                    errors: vec![],
1749                };
1750                (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1751            }
1752            Err(e) => {
1753                write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1754            }
1755        }
1756    }
1757
1758    // ── Batch handlers ────────────────────────────────────────────────────────
1759
1760    async fn bulk_delete_handler(
1761        handler: Arc<Self>,
1762        JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1763    ) -> impl axum::response::IntoResponse {
1764        use axum::{http::StatusCode, Json};
1765
1766        if let Some(err) = batch_size_error(req.ids.len()) {
1767            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1768        }
1769        match handler.service.bulk_soft_delete(req.ids).await {
1770            Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1771                serde_json::json!({ "soft_deleted": count }),
1772                format!("Soft-deleted {count} item(s)"),
1773            ))),
1774            Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1775        }
1776    }
1777
1778    async fn bulk_restore_handler(
1779        handler: Arc<Self>,
1780        JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1781    ) -> impl axum::response::IntoResponse {
1782        use axum::{http::StatusCode, Json};
1783
1784        if let Some(err) = batch_size_error(req.ids.len()) {
1785            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1786        }
1787        match handler.service.bulk_restore(req.ids).await {
1788            Ok(entities) => {
1789                let items: Vec<serde_json::Value> = entities
1790                    .into_iter()
1791                    .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1792                    .collect();
1793                let total = items.len();
1794                (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1795            }
1796            Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1797        }
1798    }
1799
1800    async fn restore_all_handler(
1801        handler: Arc<Self>,
1802    ) -> impl axum::response::IntoResponse {
1803        use axum::{http::StatusCode, Json};
1804
1805        match handler.service.restore_all().await {
1806            Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1807                serde_json::json!({ "restored": count }),
1808                format!("Restored {count} item(s) from trash"),
1809            ))),
1810            Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
1811        }
1812    }
1813
1814    async fn bulk_permanent_delete_handler(
1815        handler: Arc<Self>,
1816        JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1817    ) -> impl axum::response::IntoResponse {
1818        use axum::{http::StatusCode, Json};
1819
1820        if let Some(err) = batch_size_error(req.ids.len()) {
1821            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1822        }
1823        match handler.service.bulk_permanent_delete(req.ids).await {
1824            Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1825                serde_json::json!({ "permanently_deleted": count }),
1826                format!("Permanently deleted {count} item(s)"),
1827            ))),
1828            Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1829        }
1830    }
1831
1832    async fn bulk_update_handler(
1833        handler: Arc<Self>,
1834        JsonOrForm(items): JsonOrForm<Vec<BulkUpdateItem<U>>>,
1835    ) -> impl axum::response::IntoResponse {
1836        use axum::{http::StatusCode, Json};
1837
1838        if let Some(err) = batch_size_error(items.len()) {
1839            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1840        }
1841        let items: Vec<(String, U)> = items.into_iter().map(|it| (it.id, it.data)).collect();
1842        match handler.service.bulk_update(items).await {
1843            Ok(entities) => {
1844                let items: Vec<serde_json::Value> = entities
1845                    .into_iter()
1846                    .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1847                    .collect();
1848                let total = items.len();
1849                (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1850            }
1851            Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1852        }
1853    }
1854
1855    async fn bulk_patch_handler(
1856        handler: Arc<Self>,
1857        JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
1858    ) -> impl axum::response::IntoResponse {
1859        use axum::{http::StatusCode, Json};
1860
1861        let items = req.into_items();
1862        if let Some(err) = batch_size_error(items.len()) {
1863            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1864        }
1865        // Normalize patch keys to snake_case (mirrors partial_update_handler).
1866        let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
1867            .into_iter()
1868            .map(|(id, fields)| {
1869                let fields = fields
1870                    .into_iter()
1871                    .map(|(k, v)| (camel_to_snake_case(&k), v))
1872                    .collect();
1873                (id, fields)
1874            })
1875            .collect();
1876        match handler.service.bulk_partial_update(items).await {
1877            Ok(entities) => {
1878                let items: Vec<serde_json::Value> = entities
1879                    .into_iter()
1880                    .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1881                    .collect();
1882                let total = items.len();
1883                (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1884            }
1885            Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1886        }
1887    }
1888
1889    /// `POST {resource}/bulk/preview`: the bulk PATCH body, answered with what it
1890    /// would do and nothing written. Each row lands in one of three lists: it
1891    /// would change (each patched field before and after), it already holds the
1892    /// values, or the write would refuse it (the code and sentence it would give).
1893    /// The values pass the same secret and field-security strip as any response.
1894    async fn bulk_patch_preview_handler(
1895        handler: Arc<Self>,
1896        JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
1897    ) -> impl axum::response::IntoResponse {
1898        use axum::{http::StatusCode, Json};
1899
1900        let items = req.into_items();
1901        if let Some(err) = batch_size_error(items.len()) {
1902            return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkPreview>::error(err)));
1903        }
1904        let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
1905            .into_iter()
1906            .map(|(id, fields)| {
1907                let fields = fields
1908                    .into_iter()
1909                    .map(|(k, v)| (camel_to_snake_case(&k), v))
1910                    .collect();
1911                (id, fields)
1912            })
1913            .collect();
1914        let patched: HashMap<String, Vec<String>> = items
1915            .iter()
1916            .map(|(id, f)| (id.clone(), f.keys().cloned().collect()))
1917            .collect();
1918        match handler.service.preview_bulk_partial_update(items).await {
1919            Ok(Some(rows)) => {
1920                let rows = rows
1921                    .into_iter()
1922                    .map(|row| {
1923                        let outcome = match row.outcome {
1924                            Ok((before, after)) => Ok((
1925                                secure::<E>(to_response_value(R::from(before)), None),
1926                                secure::<E>(to_response_value(R::from(after)), None),
1927                            )),
1928                            Err(e) => Err((
1929                                S::violations_of(&e)
1930                                    .and_then(|v| v.into_iter().next().map(|v| v.code))
1931                                    .unwrap_or_else(|| "refused".to_string()),
1932                                e.to_string(),
1933                            )),
1934                        };
1935                        (row.id, outcome)
1936                    })
1937                    .collect();
1938                (StatusCode::OK, Json(ApiResponse::ok(preview_answer(rows, &patched))))
1939            }
1940            Ok(None) => (
1941                StatusCode::NOT_IMPLEMENTED,
1942                Json(ApiResponse::error("this resource has no preview of a bulk change".to_string())),
1943            ),
1944            Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1945        }
1946    }
1947
1948    async fn upsert_handler(
1949        handler: Arc<Self>,
1950        JsonOrForm(dto): JsonOrForm<C>,
1951    ) -> impl axum::response::IntoResponse {
1952        use axum::{http::StatusCode, Json};
1953
1954        match handler.service.upsert(dto).await {
1955            Ok(entity) => {
1956                let response = secure::<E>(to_response_value(R::from(entity)), None);
1957                (StatusCode::OK, Json(ApiResponse::ok(response)))
1958            }
1959            Err(e) => {
1960                write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1961            }
1962        }
1963    }
1964
1965    async fn list_deleted_handler(
1966        handler: Arc<Self>,
1967        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1968        access: Option<axum::Extension<AccessScope>>,
1969    ) -> impl axum::response::IntoResponse {
1970        use axum::{http::StatusCode, Json};
1971
1972        if let Some(err) = pagination_depth_error(params.page, params.limit) {
1973            return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1974        }
1975        if let Some(field) = named_secret(&params, E::secret_fields()) {
1976            return (
1977                StatusCode::BAD_REQUEST,
1978                Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1979            );
1980        }
1981
1982        let fields = sparse_fields(&params.filters);
1983        let includes = include_relations(&params.filters);
1984        let scope = access.map(|axum::Extension(s)| s);
1985
1986        match handler.service.list_deleted(params.page, params.limit).await {
1987            Ok((entities, total)) => {
1988                // Mirror `list_handler`: field-security ceiling, then batched
1989                // relation expansion (`?include=`), then sparse projection — so the
1990                // trash view hydrates relations exactly like the active list does.
1991                let mut rows: Vec<serde_json::Value> = entities
1992                    .into_iter()
1993                    .map(|e| {
1994                        secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1995                    })
1996                    .collect();
1997                expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1998                let items: Vec<serde_json::Value> =
1999                    rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2000                let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
2001                (StatusCode::OK, Json(response))
2002            }
2003            Err(e) => {
2004                let msg = e.to_string();
2005                if is_bad_query_error(&msg) {
2006                    (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
2007                        format!("Invalid query parameter or filter: {msg}"),
2008                    )))
2009                } else {
2010                    (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
2011                }
2012            }
2013        }
2014    }
2015
2016    async fn restore_handler(
2017        handler: Arc<Self>,
2018        axum::extract::Path(id): axum::extract::Path<String>,
2019    ) -> impl axum::response::IntoResponse {
2020        use axum::{http::StatusCode, Json};
2021
2022        match handler.service.restore(&id).await {
2023            Ok(Some(entity)) => {
2024                let response = secure::<E>(to_response_value(R::from(entity)), None);
2025                (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
2026            }
2027            Ok(None) => {
2028                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2029            }
2030            Err(e) => {
2031                write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
2032            }
2033        }
2034    }
2035
2036    async fn empty_trash_handler(
2037        handler: Arc<Self>,
2038    ) -> impl axum::response::IntoResponse {
2039        use axum::{http::StatusCode, Json};
2040
2041        match handler.service.empty_trash().await {
2042            Ok(count) => {
2043                (StatusCode::OK, Json(ApiResponse::success_with_message(
2044                    serde_json::json!({ "deleted_count": count }),
2045                    format!("Successfully deleted {} items from trash", count)
2046                )))
2047            }
2048            Err(e) => {
2049                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2050            }
2051        }
2052    }
2053
2054    /// 12. GET /collection/:id/deleted - Get deleted entity by ID
2055    async fn get_deleted_handler(
2056        handler: Arc<Self>,
2057        axum::extract::Path(id): axum::extract::Path<String>,
2058        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2059        access: Option<axum::Extension<AccessScope>>,
2060    ) -> impl axum::response::IntoResponse {
2061        use axum::{http::StatusCode, Json};
2062
2063        let fields = sparse_fields(&params.filters);
2064        let scope = access.map(|axum::Extension(s)| s);
2065
2066        match handler.service.get_deleted_by_id(&id).await {
2067            Ok(Some(entity)) => {
2068                let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
2069                let value = project_sparse(secured, &fields);
2070                (StatusCode::OK, Json(ApiResponse::ok(value)))
2071            }
2072            Ok(None) => {
2073                (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
2074            }
2075            Err(e) => {
2076                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2077            }
2078        }
2079    }
2080
2081    /// 13. DELETE /collection/trash/:id - Permanently delete from trash
2082    async fn permanent_delete_handler(
2083        handler: Arc<Self>,
2084        axum::extract::Path(id): axum::extract::Path<String>,
2085    ) -> axum::response::Response {
2086        use axum::{http::StatusCode, Json, response::IntoResponse};
2087
2088        // First check if the entity exists in trash
2089        match handler.service.get_deleted_by_id(&id).await {
2090            Ok(Some(_)) => {
2091                // Entity exists in trash, proceed with permanent delete
2092                match handler.service.permanent_delete(&id).await {
2093                    Ok(true) => {
2094                        // 204 No Content - successful deletion with no body
2095                        StatusCode::NO_CONTENT.into_response()
2096                    }
2097                    Ok(false) => {
2098                        (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::error(
2099                            format!("Failed to permanently delete {}", S::entity_name())
2100                        ))).into_response()
2101                    }
2102                    Err(e) => {
2103                        write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2104                    }
2105                }
2106            }
2107            Ok(None) => {
2108                (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
2109                    &format!("{} in trash", S::entity_name()), &id
2110                ))).into_response()
2111            }
2112            Err(e) => {
2113                write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2114            }
2115        }
2116    }
2117
2118    /// 15. GET /collection/count - Count active entities
2119    async fn count_active_handler(
2120        handler: Arc<Self>,
2121        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2122    ) -> impl axum::response::IntoResponse {
2123        use axum::{http::StatusCode, Json};
2124
2125        // An unfiltered request still answers the whole-table count, which is
2126        // what every existing caller expects; a filtered one now answers the
2127        // question it actually asked.
2128        if let Some(field) = named_secret(&params, E::secret_fields()) {
2129            return (
2130                StatusCode::BAD_REQUEST,
2131                Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2132            );
2133        }
2134        match handler.service.count_active_filtered(repository_filters(&params)).await {
2135            Ok(count) => {
2136                (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2137            }
2138            Err(e) => {
2139                // Now that the filters are read, a malformed one is the
2140                // caller's fault — the same classification the list path uses.
2141                let msg = e.to_string();
2142                let code = if is_bad_query_error(&msg) {
2143                    StatusCode::BAD_REQUEST
2144                } else {
2145                    StatusCode::INTERNAL_SERVER_ERROR
2146                };
2147                (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
2148            }
2149        }
2150    }
2151
2152    /// GET /collection/aggregate - Group and reduce
2153    ///
2154    /// Answers with one entry per distinct group plus the overall total, so a
2155    /// caller can draw a chart and its headline figure from a single reply.
2156    async fn aggregate_handler(
2157        handler: Arc<Self>,
2158        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2159    ) -> impl axum::response::IntoResponse {
2160        use axum::{http::StatusCode, Json};
2161
2162        if let Some(field) = named_secret(&params, E::secret_fields()) {
2163            return (
2164                StatusCode::BAD_REQUEST,
2165                Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2166            );
2167        }
2168        let spec = aggregate_spec(&params);
2169        match handler.service.aggregate(&spec, aggregate_filters(&params)).await {
2170            Ok(result) => {
2171                let render = |g: &backbone_orm::repository::AggregateGroup| {
2172                    // Flat `"sum:amount"` keys become nested `sum: { amount }`,
2173                    // so a caller reads `groups[i].sum.amount` without having to
2174                    // reassemble a compound key.
2175                    let mut out = serde_json::Map::new();
2176                    out.insert("key".into(), match &g.key {
2177                        Some(k) => serde_json::Value::String(k.clone()),
2178                        None => serde_json::Value::Null,
2179                    });
2180                    if g.label.is_some() {
2181                        out.insert(
2182                            "label".into(),
2183                            serde_json::Value::String(g.label.clone().unwrap()),
2184                        );
2185                    }
2186                    out.insert("count".into(), serde_json::json!(g.count));
2187                    for (compound, value) in &g.values {
2188                        let Some((func, field)) = compound.split_once(':') else { continue };
2189                        let slot = out
2190                            .entry(func.to_string())
2191                            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2192                        if let Some(obj) = slot.as_object_mut() {
2193                            obj.insert(field.to_string(), match value {
2194                                // Strings, not numbers: Postgres `numeric` carries
2195                                // more precision than a JSON double, and money
2196                                // columns are exactly where rounding would show.
2197                                Some(v) => serde_json::Value::String(v.clone()),
2198                                None => serde_json::Value::Null,
2199                            });
2200                        }
2201                    }
2202                    serde_json::Value::Object(out)
2203                };
2204
2205                let body = serde_json::json!({
2206                    "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
2207                    "total": render(&result.total),
2208                    // Says plainly that groups were dropped, so a partial chart
2209                    // is never mistaken for a complete one.
2210                    "truncated": result.truncated,
2211                });
2212                (StatusCode::OK, Json(ApiResponse::ok(body)))
2213            }
2214            Err(e) => {
2215                let msg = e.to_string();
2216                let code = if is_bad_query_error(&msg) {
2217                    StatusCode::BAD_REQUEST
2218                } else {
2219                    StatusCode::INTERNAL_SERVER_ERROR
2220                };
2221                (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
2222            }
2223        }
2224    }
2225
2226    /// GET /collection/:id/history - what has been recorded about this record
2227    ///
2228    /// Three outcomes, kept deliberately distinct, because collapsing any two
2229    /// of them would render as "nothing ever happened to this record":
2230    ///
2231    /// - no provider installed -> 501, this service serves no history at all
2232    /// - provider says the table is not audited -> 200, `audited: false`,
2233    ///   `entries: null`
2234    /// - audited -> 200, `audited: true`, `entries: [...]` (possibly empty,
2235    ///   which here genuinely means nothing has changed)
2236    async fn history_handler(
2237        handler: Arc<Self>,
2238        axum::extract::Path(id): axum::extract::Path<String>,
2239        axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2240        provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2241    ) -> impl axum::response::IntoResponse {
2242        use axum::{http::StatusCode, Json};
2243
2244        let Some(axum::Extension(provider)) = provider else {
2245            return (
2246                StatusCode::NOT_IMPLEMENTED,
2247                Json(ApiResponse::<serde_json::Value>::error(
2248                    "history is not configured for this service".to_string(),
2249                )),
2250            );
2251        };
2252
2253        // Keyed on the table the repository actually reads — the same string the
2254        // capture trigger writes as `subject_type`. A service that cannot name
2255        // its table has no history rather than a wrong one.
2256        let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
2257            return (
2258                StatusCode::NOT_IMPLEMENTED,
2259                Json(ApiResponse::<serde_json::Value>::error(
2260                    "this entity cannot name its table, so its history cannot be keyed".to_string(),
2261                )),
2262            );
2263        };
2264
2265        let limit = params.limit.clamp(1, 200);
2266        let offset = params.page.saturating_sub(1) * limit;
2267
2268        match provider.history(&table, &id, limit, offset).await {
2269            Ok(Some(entries)) => (
2270                StatusCode::OK,
2271                Json(ApiResponse::ok(serde_json::json!({
2272                    "audited": true,
2273                    "entries": entries,
2274                }))),
2275            ),
2276            Ok(None) => (
2277                StatusCode::OK,
2278                Json(ApiResponse::ok(serde_json::json!({
2279                    // Not a mistake and not an empty timeline: this table
2280                    // records nothing, so there is nothing to have missed.
2281                    "audited": false,
2282                    "entries": serde_json::Value::Null,
2283                }))),
2284            ),
2285            Err(e) => (
2286                StatusCode::INTERNAL_SERVER_ERROR,
2287                Json(ApiResponse::<serde_json::Value>::error(e)),
2288            ),
2289        }
2290    }
2291
2292    /// 16. GET /collection/trash/count - Count deleted entities
2293    async fn count_deleted_handler(
2294        handler: Arc<Self>,
2295    ) -> impl axum::response::IntoResponse {
2296        use axum::{http::StatusCode, Json};
2297
2298        match handler.service.count_deleted().await {
2299            Ok(count) => {
2300                (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2301            }
2302            Err(e) => {
2303                (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2304            }
2305        }
2306    }
2307}
2308
2309// ============================================================
2310// BackboneHttpHandler Trait - Synchronous HTTP Service Interface
2311// ============================================================
2312
2313/// Synchronous trait for HTTP handlers implementing Backbone CRUD operations.
2314///
2315/// This trait provides a synchronous interface for services that need to implement
2316/// the 11 standard Backbone CRUD endpoints. Unlike `CrudService` which is async,
2317/// this trait is designed for simpler use cases where async is not required.
2318///
2319/// # Type Parameters
2320/// - `T`: The entity type
2321///
2322/// # Example
2323/// ```ignore
2324/// impl BackboneHttpHandler<User> for UserService {
2325///     fn list(&self, request: ListRequest) -> Result<ApiResponse<Vec<User>>> {
2326///         let users = self.users.lock().unwrap();
2327///         let filtered = users.iter().filter(|u| !u.is_deleted()).cloned().collect();
2328///         Ok(ApiResponse::success(filtered, Some("Users listed".to_string())))
2329///     }
2330///     // ... implement other methods
2331/// }
2332/// ```
2333pub trait BackboneHttpHandler<T>: Send + Sync {
2334    /// 1. List entities with pagination and filtering
2335    fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2336
2337    /// 2. Create a new entity
2338    fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
2339
2340    /// 3. Get entity by ID
2341    fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2342
2343    /// 4. Full update of entity
2344    fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
2345
2346    /// 5. Partial update with specific fields
2347    fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
2348
2349    /// 6. Soft delete entity
2350    fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
2351
2352    /// 7. Bulk create multiple entities
2353    fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
2354
2355    /// 8. Upsert (create or update)
2356    fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
2357
2358    /// 9. List deleted entities (trash)
2359    fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2360
2361    /// 10. Restore soft-deleted entity
2362    fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2363
2364    /// 11. Permanently delete all soft-deleted entities
2365    fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
2366
2367    /// 12. Get deleted entity by ID (from trash)
2368    fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2369}
2370
2371// ============================================================
2372// Legacy Types (Backwards Compatibility)
2373// ============================================================
2374
2375/// Pagination request (legacy)
2376#[derive(Debug, Serialize, Deserialize)]
2377#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
2378pub struct PaginationRequest {
2379    pub page: u32,
2380    pub limit: u32,
2381    pub sort_by: Option<String>,
2382    pub sort_order: Option<String>,
2383}
2384
2385// ============================================================
2386// Internal Helpers
2387// ============================================================
2388
2389/// Convert a single JSON key from `camelCase` / `PascalCase` to `snake_case`.
2390///
2391/// Idempotent on already-`snake_case` input: `is_vip` → `is_vip`.
2392/// Single words pass through unchanged: `name` → `name`.
2393/// Boundary detection inserts an underscore between a lowercase/digit and the
2394/// following uppercase, and between a run of uppercase and a following
2395/// uppercase-then-lowercase pair (so `IOError` → `io_error`, not `i_o_error`).
2396///
2397/// Used by `BackboneCrudHandler::partial_update_handler` to normalize PATCH
2398/// payload keys so the service-layer JSON merge aligns with snake_case entity
2399/// fields regardless of the client's serialization convention.
2400fn camel_to_snake_case(key: &str) -> String {
2401    let chars: Vec<char> = key.chars().collect();
2402    let mut result = String::with_capacity(key.len() + 2);
2403    for (i, &c) in chars.iter().enumerate() {
2404        if c.is_ascii_uppercase() {
2405            let prev = if i > 0 { chars[i - 1] } else { '\0' };
2406            let next = chars.get(i + 1).copied().unwrap_or('\0');
2407            // Insert separator before this uppercase letter when crossing a
2408            // word boundary. Skip if the result is empty or already ends with
2409            // an underscore (e.g. `_Foo` should stay `_foo`, not `__foo`).
2410            let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
2411            let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
2412            if (crosses_lower || crosses_acronym)
2413                && !result.is_empty()
2414                && !result.ends_with('_')
2415            {
2416                result.push('_');
2417            }
2418            result.push(c.to_ascii_lowercase());
2419        } else {
2420            result.push(c);
2421        }
2422    }
2423    result
2424}
2425
2426#[cfg(test)]
2427mod tests {
2428    use super::*;
2429
2430    #[test]
2431    fn snake_case_input_passes_through_unchanged() {
2432        assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
2433        assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
2434        assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
2435    }
2436
2437    #[test]
2438    fn single_word_unchanged() {
2439        assert_eq!(camel_to_snake_case("name"), "name");
2440        assert_eq!(camel_to_snake_case("id"), "id");
2441        assert_eq!(camel_to_snake_case(""), "");
2442    }
2443
2444    #[test]
2445    fn camel_case_converts() {
2446        assert_eq!(camel_to_snake_case("isVip"), "is_vip");
2447        assert_eq!(camel_to_snake_case("userId"), "user_id");
2448        assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
2449        assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
2450    }
2451
2452    #[test]
2453    fn pascal_case_converts() {
2454        assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
2455        assert_eq!(camel_to_snake_case("UserId"), "user_id");
2456    }
2457
2458    #[test]
2459    fn acronym_runs_stay_together() {
2460        // The transition from an uppercase run to a lowercase letter starts a
2461        // new word at the last uppercase, not between every pair.
2462        assert_eq!(camel_to_snake_case("IOError"), "io_error");
2463        assert_eq!(camel_to_snake_case("httpURL"), "http_url");
2464        assert_eq!(camel_to_snake_case("ABC"), "abc");
2465        assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
2466    }
2467
2468    #[test]
2469    fn digits_count_as_lowercase_for_boundary() {
2470        assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
2471        assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
2472    }
2473
2474    #[test]
2475    fn underscores_not_doubled() {
2476        assert_eq!(camel_to_snake_case("_Foo"), "_foo");
2477        assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
2478    }
2479
2480    #[test]
2481    fn shallow_pages_are_allowed() {
2482        // page 1 has zero offset regardless of size
2483        assert!(pagination_depth_error(1, 100).is_none());
2484        // last in-bounds page at max size: offset = 100 * 100 = 10_000 (== cap)
2485        assert!(pagination_depth_error(101, 100).is_none());
2486    }
2487
2488    #[test]
2489    fn pages_past_the_cap_are_rejected() {
2490        // page 102 at size 100 → offset 10_100 > 10_000
2491        assert!(pagination_depth_error(102, 100).is_some());
2492        // small page size still rejected once deep enough: 1001 * 10 = 10_010
2493        assert!(pagination_depth_error(1002, 10).is_some());
2494    }
2495
2496    #[test]
2497    fn oversized_page_size_is_clamped_before_the_check() {
2498        // limit=200 is clamped to 100, so the effective offset is 101*100=10_100
2499        assert!(pagination_depth_error(102, 200).is_some());
2500        // and a request that would only be in-bounds *because* of the clamp passes
2501        assert!(pagination_depth_error(101, 200).is_none());
2502    }
2503
2504    #[test]
2505    fn huge_page_number_does_not_overflow() {
2506        // saturating arithmetic keeps this a clean rejection, not a panic
2507        assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
2508    }
2509
2510    // ── Batch operations ──────────────────────────────────────────────────────
2511
2512    #[test]
2513    fn batch_size_within_limit_is_allowed() {
2514        assert!(batch_size_error(0).is_none());
2515        assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
2516    }
2517
2518    #[test]
2519    fn batch_size_over_limit_is_rejected() {
2520        assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
2521    }
2522
2523    #[test]
2524    fn a_bulk_preview_sorts_rows_by_what_the_patch_would_do() {
2525        use serde_json::json;
2526        let mut patched: HashMap<String, Vec<String>> = ["a", "b", "c"]
2527            .iter()
2528            .map(|id| (id.to_string(), vec!["category".to_string()]))
2529            .collect();
2530        patched.insert("d".into(), vec!["category_id".into()]);
2531        let out = preview_answer(
2532            vec![
2533                (
2534                    "a".into(),
2535                    Ok((
2536                        json!({"category": "meal", "updated_at": "1"}),
2537                        json!({"category": "travel", "updated_at": "2"}),
2538                    )),
2539                ),
2540                ("b".into(), Ok((json!({"category": "travel"}), json!({"category": "travel"})))),
2541                (
2542                    "d".into(),
2543                    Ok((json!({"categoryId": "x"}), json!({"categoryId": "y"}))),
2544                ),
2545                ("c".into(), Err(("locked".into(), "a posted claim is locked".into()))),
2546            ],
2547            &patched,
2548        );
2549        assert_eq!(out.total, 4);
2550        assert_eq!(out.will_change.len(), 2);
2551        assert_eq!(out.will_change[1].changes[0].field, "category_id", "a camelCase response is read");
2552        let ch = &out.will_change[0].changes;
2553        assert_eq!(ch.len(), 1, "only the patched field is reported, not the timestamp");
2554        assert_eq!((ch[0].from.clone(), ch[0].to.clone()), (json!("meal"), json!("travel")));
2555        assert_eq!(out.unchanged, vec!["b".to_string()]);
2556        assert_eq!(out.refused[0].code, "locked");
2557    }
2558
2559    #[test]
2560    fn bulk_patch_request_parses_shared_shape() {
2561        let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
2562        let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2563        let items = req.into_items();
2564        assert_eq!(items.len(), 2);
2565        // Both ids carry the same patch.
2566        assert_eq!(items[0].1.get("status").unwrap(), "void");
2567        assert_eq!(items[1].1.get("status").unwrap(), "void");
2568        assert_eq!(items[0].0, "a");
2569        assert_eq!(items[1].0, "b");
2570    }
2571
2572    #[test]
2573    fn bulk_patch_request_parses_per_item_shape() {
2574        let json = r#"{ "items": [
2575            { "id": "a", "patch": { "status": "void" } },
2576            { "id": "b", "patch": { "note": "late" } }
2577        ] }"#;
2578        let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2579        let items = req.into_items();
2580        assert_eq!(items.len(), 2);
2581        assert_eq!(items[0].0, "a");
2582        assert_eq!(items[0].1.get("status").unwrap(), "void");
2583        assert_eq!(items[1].0, "b");
2584        assert_eq!(items[1].1.get("note").unwrap(), "late");
2585    }
2586
2587    #[test]
2588    fn batch_ids_request_parses() {
2589        let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
2590        assert_eq!(req.ids, vec!["x", "y", "z"]);
2591    }
2592
2593    // ── Sparse fieldsets (?fields=a,b,c) ──
2594
2595    fn fields(q: &[(&str, &str)]) -> Vec<String> {
2596        let map: HashMap<String, String> =
2597            q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
2598        sparse_fields(&map)
2599    }
2600
2601    #[test]
2602    fn sparse_fields_parses_comma_list_and_trims() {
2603        assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
2604    }
2605
2606    #[test]
2607    fn sparse_fields_absent_or_empty_is_no_projection() {
2608        assert!(fields(&[]).is_empty());
2609        assert!(fields(&[("fields", "")]).is_empty());
2610        assert!(fields(&[("fields", " , ")]).is_empty());
2611    }
2612
2613    #[test]
2614    fn project_keeps_requested_keys_plus_id() {
2615        let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
2616        let out = project_sparse(v, &["name".into(), "basePrice".into()]);
2617        assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
2618    }
2619
2620    #[test]
2621    fn project_always_includes_id_even_if_not_requested() {
2622        let v = serde_json::json!({ "id": "1", "name": "n" });
2623        let out = project_sparse(v, &["name".into()]);
2624        assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2625    }
2626
2627    #[test]
2628    fn project_ignores_unknown_keys() {
2629        let v = serde_json::json!({ "id": "1", "name": "n" });
2630        let out = project_sparse(v, &["name".into(), "nope".into()]);
2631        assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2632    }
2633
2634    #[test]
2635    fn project_empty_fields_returns_full_object() {
2636        let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
2637        let out = project_sparse(v.clone(), &[]);
2638        assert_eq!(out, v);
2639    }
2640
2641    #[test]
2642    fn project_non_object_returned_unchanged() {
2643        let v = serde_json::json!("scalar");
2644        assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
2645    }
2646
2647    // ── Field-level security (@private / @owner) ──
2648
2649    /// The row's owner. A real tenant id is a Uuid — the scope carries one so the fence and
2650    /// field security cannot drift on formatting.
2651    fn owner_a() -> uuid::Uuid {
2652        uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
2653    }
2654    fn owner_b() -> uuid::Uuid {
2655        uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
2656    }
2657
2658    fn row() -> serde_json::Value {
2659        serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
2660    }
2661    const PRIV: &[&str] = &["hppPerUnit"];
2662
2663    /// An entity with a secret, a private field and a relation to a model with a secret.
2664    struct Secretive;
2665    impl backbone_orm::EntityRepoMeta for Secretive {
2666        fn column_types() -> HashMap<String, String> {
2667            HashMap::new()
2668        }
2669        fn search_fields() -> &'static [&'static str] {
2670            &[]
2671        }
2672        fn secret_fields() -> &'static [&'static str] {
2673            &["tokenHash"]
2674        }
2675        fn private_fields() -> &'static [&'static str] {
2676            PRIV
2677        }
2678        fn owner_field() -> Option<&'static str> {
2679            Some("providerId")
2680        }
2681        fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
2682            match relation {
2683                "user" => &["passwordHash"],
2684                _ => &[],
2685            }
2686        }
2687    }
2688
2689    fn secret_row() -> serde_json::Value {
2690        let mut r = row();
2691        r["tokenHash"] = serde_json::json!("digest");
2692        r
2693    }
2694
2695    #[test]
2696    fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
2697        for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
2698            let out = secure::<Secretive>(secret_row(), scope.as_ref());
2699            assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
2700            assert!(out.get("name").is_some());
2701        }
2702        let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform));
2703        assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
2704    }
2705
2706    #[test]
2707    fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
2708        let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
2709            let params = ListQueryParams {
2710                filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
2711                sort_by: sort_by.map(str::to_string),
2712                ..Default::default()
2713            };
2714            named_secret(&params, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
2715        };
2716        for pairs in [
2717            vec![("token_hash", "abc")],
2718            vec![("tokenHash[startwith]", "a")],
2719            vec![("orderby", "name,-token_hash")],
2720            vec![("orderby[token_hash]", "asc")],
2721            vec![("searchFields", "name,tokenHash")],
2722            vec![("min", "token_hash")],
2723            vec![("group_by", "tokenHash")],
2724        ] {
2725            assert_eq!(ask(&pairs, None).as_deref().map(camel_to_snake_case), Some("token_hash".into()), "{pairs:?}");
2726        }
2727        assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
2728        assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
2729    }
2730
2731    #[test]
2732    fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
2733        let out = secure::<Secretive>(secret_row(), None);
2734        assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
2735    }
2736
2737    #[test]
2738    fn a_routed_response_dto_loses_the_entitys_secrets() {
2739        let out = without_secrets::<Secretive, _>(secret_row());
2740        assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
2741        assert!(out.get("name").is_some());
2742    }
2743
2744    #[test]
2745    fn an_included_row_loses_the_related_models_secrets() {
2746        let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
2747        let out = related_row::<Secretive>("user", "users", raw.clone());
2748        assert!(out.get("passwordHash").is_none(), "{out}");
2749        assert_eq!(out["email"], "a@b.c");
2750        assert!(related_row::<Secretive>("other", "others", raw.clone()).get("passwordHash").is_some(), "only the named relation");
2751        // A cross-module relation: the table's entity registered its secrets.
2752        backbone_orm::secret_registry::register("employee_probe.staff", &["passwordHash"]);
2753        assert!(related_row::<Secretive>("staff", "employee_probe.staff", raw).get("passwordHash").is_none(), "registered table");
2754    }
2755
2756    #[test]
2757    fn security_no_private_fields_is_noop() {
2758        let v = row();
2759        assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
2760    }
2761
2762    #[test]
2763    fn security_platform_sees_private() {
2764        let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
2765        assert!(out.get("hppPerUnit").is_some());
2766    }
2767
2768    #[test]
2769    fn security_owner_tenant_sees_private() {
2770        let out = apply_field_security(
2771            row(),
2772            Some(&AccessScope::Company(owner_a())),
2773            PRIV,
2774            Some("providerId"),
2775        );
2776        assert!(out.get("hppPerUnit").is_some());
2777    }
2778
2779    #[test]
2780    fn security_other_tenant_stripped() {
2781        let out = apply_field_security(
2782            row(),
2783            Some(&AccessScope::Company(owner_b())),
2784            PRIV,
2785            Some("providerId"),
2786        );
2787        assert!(out.get("hppPerUnit").is_none());
2788        assert!(out.get("name").is_some());
2789    }
2790
2791    #[test]
2792    fn security_absent_scope_fails_closed() {
2793        let out = apply_field_security(row(), None, PRIV, Some("providerId"));
2794        assert!(out.get("hppPerUnit").is_none());
2795    }
2796
2797    // ── Relation expansion (?include=) helpers ──
2798
2799    #[test]
2800    fn include_relations_parses_include_and_with() {
2801        let mut q = HashMap::new();
2802        q.insert("include".to_string(), "provider, outlet ".to_string());
2803        assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
2804        let mut q2 = HashMap::new();
2805        q2.insert("with".to_string(), "category".to_string());
2806        assert_eq!(include_relations(&q2), vec!["category"]);
2807        assert!(include_relations(&HashMap::new()).is_empty());
2808    }
2809
2810    #[test]
2811    fn snake_to_camel_converts() {
2812        assert_eq!(snake_to_camel("provider_id"), "providerId");
2813        assert_eq!(snake_to_camel("business_name"), "businessName");
2814        assert_eq!(snake_to_camel("id"), "id");
2815    }
2816
2817    #[test]
2818    fn camelize_keys_top_level_only() {
2819        let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
2820        let out = camelize_keys(v);
2821        assert!(out.get("providerId").is_some());
2822        // nested keys are left untouched (per-struct camelCase, shallow).
2823        assert!(out.get("metaData").unwrap().get("created_at").is_some());
2824    }
2825
2826    #[test]
2827    fn security_null_owner_only_platform_sees() {
2828        let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
2829        let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
2830        assert!(tenant.get("hppPerUnit").is_none());
2831        let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
2832        assert!(plat.get("hppPerUnit").is_some());
2833    }
2834
2835    #[test]
2836    fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
2837        let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
2838        let (status, axum::Json(body)) =
2839            write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
2840        assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
2841        let json = serde_json::to_value(&body).unwrap();
2842        assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
2843        assert_eq!(json["violations"][0]["path"], "status");
2844        assert_eq!(json["violations"][0]["code"], "field_not_writable");
2845
2846        // Any other failure keeps its status and body: no `violations` key at all.
2847        let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
2848        assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
2849        assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
2850    }
2851}