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