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