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