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