1use crate::extractors::JsonOrForm;
9pub use crate::permission::{
10 guarded, guarded_all, permission_refusal, unauthenticated_refusal, CrudPermissions,
11};
12use serde::{de::DeserializeOwned, Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15
16#[derive(Debug, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23pub struct ApiResponse<T> {
24 pub success: bool,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub data: Option<T>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub message: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub error: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub violations: Option<Vec<crate::violation::Violation>>,
36}
37
38impl<T> ApiResponse<T> {
39 pub fn rejected(error: impl Into<String>, violations: Vec<crate::violation::Violation>) -> Self {
42 Self {
43 success: false,
44 data: None,
45 message: None,
46 error: Some(error.into()),
47 violations: (!violations.is_empty()).then_some(violations),
48 }
49 }
50
51 pub fn success(data: T, message: Option<String>) -> Self {
53 Self {
54 success: true,
55 data: Some(data),
56 message,
57 error: None,
58 violations: None,
59 }
60 }
61
62 pub fn ok(data: T) -> Self {
64 Self {
65 success: true,
66 data: Some(data),
67 message: None,
68 error: None,
69 violations: None,
70 }
71 }
72
73 pub fn success_with_message(data: T, message: impl Into<String>) -> Self {
74 Self {
75 success: true,
76 data: Some(data),
77 message: Some(message.into()),
78 error: None,
79 violations: None,
80 }
81 }
82
83 pub fn error(error: impl Into<String>) -> Self {
84 Self {
85 success: false,
86 data: None,
87 message: None,
88 error: Some(error.into()),
89 violations: None,
90 }
91 }
92
93 pub fn not_found(entity: &str, id: &str) -> Self {
97 tracing::debug!(target: "backbone::http", entity, id, "record not found");
98 Self::rejected(NOT_FOUND_MESSAGE, vec![not_found_violation()])
99 }
100}
101
102pub const NOT_FOUND_MESSAGE: &str = "Not found";
104
105fn not_found_violation() -> crate::violation::Violation {
106 crate::violation::Violation::new("", "not_found", NOT_FOUND_MESSAGE)
107}
108
109fn status_code_violation(status: axum::http::StatusCode, message: &str) -> Option<crate::violation::Violation> {
112 use axum::http::StatusCode;
113 let code = match status {
114 StatusCode::NOT_FOUND => return Some(not_found_violation()),
115 StatusCode::CONFLICT => "conflict",
116 StatusCode::BAD_REQUEST => "invalid_request",
117 StatusCode::UNPROCESSABLE_ENTITY => "invalid_value",
118 _ => return None,
119 };
120 Some(crate::violation::Violation::new("", code, message))
121}
122
123pub const INTERNAL_ERROR_MESSAGE: &str = "An internal error occurred. It has been logged.";
126
127fn error_at<T>(
131 status: axum::http::StatusCode,
132 violations: Option<Vec<crate::violation::Violation>>,
133 err: &impl std::fmt::Display,
134) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
135 if status.is_server_error() {
136 tracing::error!(target: "backbone::http", status = status.as_u16(), error = %err, "request failed");
137 return (status, axum::Json(ApiResponse::error(INTERNAL_ERROR_MESSAGE)));
138 }
139 match violations {
140 Some(v) if !v.is_empty() => (status, axum::Json(ApiResponse::rejected(err.to_string(), v))),
141 _ if status == axum::http::StatusCode::NOT_FOUND => {
142 tracing::debug!(target: "backbone::http", error = %err, "not found");
143 (status, axum::Json(ApiResponse::rejected(NOT_FOUND_MESSAGE, vec![not_found_violation()])))
144 }
145 _ => {
146 let message = err.to_string();
147 match status_code_violation(status, &message) {
148 Some(v) => (status, axum::Json(ApiResponse::rejected(message, vec![v]))),
149 None => (status, axum::Json(ApiResponse::error(message))),
150 }
151 }
152 }
153}
154
155pub async fn correlation_middleware(
167 req: axum::extract::Request,
168 next: axum::middleware::Next,
169) -> axum::response::Response {
170 let attributed = backbone_orm::audit_context::current_request_audit()
171 .is_some_and(|audit| !audit.correlation_id.is_empty());
172 if attributed {
173 return next.run(req).await;
174 }
175 let id = req
176 .headers()
177 .get("x-correlation-id")
178 .and_then(|v| v.to_str().ok())
179 .map(str::trim)
180 .filter(|v| !v.is_empty() && v.len() <= 128 && v.bytes().all(|b| b.is_ascii_graphic()))
181 .map(str::to_string)
182 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
183 crate::write_hooks::with_correlation_id(id, next.run(req)).await
184}
185
186pub fn service_error_status(err: &crate::service::ServiceError) -> axum::http::StatusCode {
193 use crate::persistence::traits::RepositoryError;
194 use crate::service::ServiceError;
195 use axum::http::StatusCode;
196 match err {
197 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v))
199 if is_query_refusal(v) =>
200 {
201 StatusCode::BAD_REQUEST
202 }
203 ServiceError::Violations(_) | ServiceError::Repository(RepositoryError::Violations(_)) => {
204 StatusCode::UNPROCESSABLE_ENTITY
205 }
206 ServiceError::Validation(_)
207 | ServiceError::Repository(RepositoryError::ValidationError(_)) => StatusCode::BAD_REQUEST,
208 ServiceError::NotFound | ServiceError::Repository(RepositoryError::NotFound) => {
209 StatusCode::NOT_FOUND
210 }
211 ServiceError::AlreadyExists(_)
212 | ServiceError::Repository(RepositoryError::AlreadyExists(_))
213 | ServiceError::Repository(RepositoryError::Conflict(_)) => StatusCode::CONFLICT,
214 _ => StatusCode::INTERNAL_SERVER_ERROR,
215 }
216}
217
218pub fn service_error_response<T>(
223 err: &crate::service::ServiceError,
224) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
225 use crate::persistence::traits::RepositoryError;
226 use crate::service::ServiceError;
227 let violations = match err {
228 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => {
229 Some(v.clone())
230 }
231 _ => None,
232 };
233 error_at(service_error_status(err), violations, err)
234}
235
236#[cfg(test)]
239fn write_error<T>(
240 violations: Option<Vec<crate::violation::Violation>>,
241 err: &impl std::fmt::Display,
242 fallback: axum::http::StatusCode,
243) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
244 let status = if violations.is_some() { axum::http::StatusCode::UNPROCESSABLE_ENTITY } else { fallback };
245 error_at(status, violations, err)
246}
247
248fn invalid_id<T>(id: &str, path: &str) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
252 if uuid::Uuid::parse_str(id).is_ok() {
253 return None;
254 }
255 let violation = crate::violation::Violation::new(
256 path,
257 "invalid_id",
258 format!("`{id}` is not a valid record id"),
259 );
260 Some((
261 axum::http::StatusCode::BAD_REQUEST,
262 axum::Json(ApiResponse::rejected(format!("invalid id: `{id}` is not a valid record id"), vec![violation])),
263 ))
264}
265
266fn invalid_ids<'a, T>(
269 ids: impl IntoIterator<Item = &'a str>,
270 path_of: impl Fn(usize) -> String,
271) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
272 let violations: Vec<crate::violation::Violation> = ids
273 .into_iter()
274 .enumerate()
275 .filter(|(_, id)| uuid::Uuid::parse_str(id).is_err())
276 .map(|(i, id)| {
277 crate::violation::Violation::new(path_of(i), "invalid_id", format!("`{id}` is not a valid record id"))
278 })
279 .collect();
280 if violations.is_empty() {
281 return None;
282 }
283 Some((
284 axum::http::StatusCode::BAD_REQUEST,
285 axum::Json(ApiResponse::rejected("invalid id in the batch", violations)),
286 ))
287}
288
289#[derive(Debug, Deserialize, Default, Clone)]
291#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
292pub struct ListQueryParams {
293 #[serde(default = "default_page")]
294 pub page: u32,
295 #[serde(default = "default_limit")]
296 pub limit: u32,
297 #[serde(default)]
298 pub sort_by: Option<String>,
299 #[serde(default)]
300 pub sort_order: Option<String>,
301 #[serde(default)]
302 pub search: Option<String>,
303 #[serde(default)]
304 pub status: Option<String>,
305 #[serde(flatten)]
306 pub filters: HashMap<String, String>,
307}
308
309fn default_page() -> u32 { 1 }
310fn default_limit() -> u32 { 20 }
311
312impl ListQueryParams {
313 pub fn normalized(mut self) -> Self {
317 let page = backbone_orm::repository::PaginationParams::new(self.page, self.limit);
318 self.page = page.page;
319 self.limit = page.per_page;
320 self
321 }
322}
323
324pub struct ListQuery(pub ListQueryParams);
329
330#[axum::async_trait]
331impl<S: Send + Sync> axum::extract::FromRequestParts<S> for ListQuery {
332 type Rejection = axum::response::Response;
333
334 async fn from_request_parts(
335 parts: &mut axum::http::request::Parts,
336 _state: &S,
337 ) -> Result<Self, Self::Rejection> {
338 use axum::response::IntoResponse;
339 let raw = parts.uri.query().unwrap_or("");
340 match serde_urlencoded::from_str::<ListQueryParams>(raw) {
341 Ok(params) => Ok(ListQuery(params)),
342 Err(e) => {
343 let violation = list_query_violation(raw, &e.to_string());
344 Err((
345 axum::http::StatusCode::BAD_REQUEST,
346 axum::Json(PaginatedApiResponse::<serde_json::Value>::rejected(
347 violation.message.clone(),
348 vec![violation],
349 )),
350 )
351 .into_response())
352 }
353 }
354 }
355}
356
357fn list_query_violation(raw: &str, decode_error: &str) -> crate::violation::Violation {
360 use backbone_orm::QueryParamReason;
361 let pairs: Vec<(String, String)> = serde_urlencoded::from_str(raw).unwrap_or_default();
362 for key in ["page", "limit"] {
363 for (k, v) in pairs.iter().filter(|(k, _)| k == key) {
364 if v.trim().parse::<u32>().is_err() {
365 let e = backbone_orm::QueryParamError::not_a_number(k, v);
366 return query_violation(&e.parameter, e.reason, e.message);
367 }
368 }
369 }
370 for key in ["page", "limit", "sort_by", "sort_order", "search", "status"] {
371 if pairs.iter().filter(|(k, _)| k == key).count() > 1 {
372 return query_violation(
373 key,
374 QueryParamReason::InvalidValue,
375 format!("`{key}` was sent more than once; send it once"),
376 );
377 }
378 }
379 tracing::debug!(target: "backbone::http", error = decode_error, "list query refused");
380 crate::violation::Violation::new("", "invalid_query", "The query string could not be read")
381}
382
383fn query_violation(
386 parameter: &str,
387 reason: backbone_orm::QueryParamReason,
388 message: impl Into<String>,
389) -> crate::violation::Violation {
390 crate::violation::Violation::invalid_query(parameter, reason.as_str(), message)
391}
392
393fn is_query_refusal(violations: &[crate::violation::Violation]) -> bool {
395 !violations.is_empty() && violations.iter().all(|v| v.code == crate::violation::INVALID_QUERY)
396}
397
398fn refusal_sentence(violations: &[crate::violation::Violation]) -> String {
400 violations.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; ")
401}
402
403fn typed_sort_applies(params: &ListQueryParams) -> bool {
406 let sorted = params.filters.keys().any(|k| {
407 let base = k.split('[').next().unwrap_or(k);
408 base.eq_ignore_ascii_case("orderby") || base.eq_ignore_ascii_case("sort")
409 });
410 !sorted && params.sort_by.as_deref().map(str::trim).is_some_and(|f| !f.is_empty())
411}
412
413fn rename_typed_sort(
416 mut violations: Vec<crate::violation::Violation>,
417 params: &ListQueryParams,
418) -> Vec<crate::violation::Violation> {
419 if !typed_sort_applies(params) {
420 return violations;
421 }
422 for v in &mut violations {
423 if v.path == "orderby" {
424 v.path = "sort_by".to_string();
425 v.message = v.message.replace("`orderby`", "`sort_by`");
426 if v.params.contains_key("parameter") {
427 v.params.insert("parameter".into(), "sort_by".into());
428 }
429 }
430 }
431 violations
432}
433
434const RESERVED_QUERY_KEYS: [&str; 3] = ["fields", "include", "with"];
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct HistoryEntry {
452 pub occurred_at: String,
453 pub action: String,
455 pub actor: String,
456 pub changed: serde_json::Value,
457 pub reason: Option<String>,
458 pub correlation_id: Option<String>,
459}
460
461#[async_trait::async_trait]
468pub trait HistoryProvider: Send + Sync {
469 async fn history(
477 &self,
478 table: &str,
479 id: &str,
480 limit: u32,
481 offset: u32,
482 ) -> Result<Option<Vec<HistoryEntry>>, String>;
483}
484
485const AGGREGATE_QUERY_KEYS: [&str; 7] =
491 ["group_by", "sum", "avg", "min", "max", "group_limit", "group_label"];
492
493fn aggregate_spec(params: &ListQueryParams) -> backbone_orm::repository::AggregateSpec {
499 use backbone_orm::repository::{AggregateFn, AggregateSpec};
500
501 let mut reductions = Vec::new();
502 for (key, func) in [
503 ("sum", AggregateFn::Sum),
504 ("avg", AggregateFn::Avg),
505 ("min", AggregateFn::Min),
506 ("max", AggregateFn::Max),
507 ] {
508 if let Some(raw) = params.filters.get(key) {
509 for field in raw.split(',').map(str::trim).filter(|f| !f.is_empty()) {
510 reductions.push((func, field.to_string()));
511 }
512 }
513 }
514
515 AggregateSpec {
516 group_by: params
517 .filters
518 .get("group_by")
519 .map(|g| g.trim().to_string())
520 .filter(|g| !g.is_empty()),
521 reductions,
522 group_limit: params
523 .filters
524 .get("group_limit")
525 .and_then(|l| l.trim().parse::<usize>().ok())
526 .unwrap_or(0),
527 label_field: params
528 .filters
529 .get("group_label")
530 .map(|l| l.trim().to_string())
531 .filter(|l| !l.is_empty()),
532 label_relation: None,
533 }
534}
535
536fn named_columns(params: &ListQueryParams) -> Vec<(String, String)> {
542 const COLUMN_LISTS: [&str; 10] = [
543 "orderby", "sort", "searchfields", "search_fields", "group_by", "sum", "avg", "min", "max", "group_label",
544 ];
545 let mut names: Vec<(String, String)> = Vec::new();
546 let mut keys: Vec<&String> = params.filters.keys().collect();
547 keys.sort();
548 for key in keys {
549 let value = ¶ms.filters[key];
550 match key.find('[') {
551 Some(open) => {
552 names.push((key.clone(), key[..open].to_string()));
553 names.push((key.clone(), key[open + 1..].trim_end_matches(']').to_string()));
554 }
555 None => names.push((key.clone(), key.clone())),
556 }
557 if COLUMN_LISTS.contains(&key.to_ascii_lowercase().as_str()) {
558 names.extend(
559 value
560 .split(',')
561 .map(|v| (key.clone(), v.trim().trim_start_matches('-').to_string())),
562 );
563 }
564 }
565 names.extend(params.sort_by.iter().map(|n| ("sort_by".to_string(), n.clone())));
566 names
567}
568
569fn named_secret_at(params: &ListQueryParams, secret_fields: &[&str]) -> Option<(String, String)> {
575 if secret_fields.is_empty() {
576 return None;
577 }
578 let secrets: Vec<String> = secret_fields.iter().map(|f| camel_to_snake_case(f)).collect();
579 named_columns(params)
580 .into_iter()
581 .find(|(_, n)| secrets.contains(&camel_to_snake_case(n.trim())))
582}
583
584fn named_gated_at<'a>(
589 params: &ListQueryParams,
590 gated: &'a [(&'static str, &'static str)],
591 caller: Option<&backbone_auth::middleware::AuthContext>,
592) -> Option<(String, String, &'a str)> {
593 if gated.is_empty() {
594 return None;
595 }
596 let held = |permission: &str| caller.is_some_and(|c| c.has_permission(permission));
597 named_columns(params).into_iter().find_map(|(parameter, name)| {
598 let column = camel_to_snake_case(name.trim());
599 gated.iter()
600 .find(|(field, _)| camel_to_snake_case(field) == column)
601 .filter(|(_, permission)| !held(permission))
602 .map(|(field, permission)| (parameter, (*field).to_string(), *permission))
603 })
604}
605
606fn secret_named_message(field: &str) -> String {
608 format!("'{field}' is a secret field: it cannot be filtered, sorted, searched or aggregated")
609}
610
611fn secret_violation(parameter: &str, field: &str) -> crate::violation::Violation {
613 query_violation(
614 parameter,
615 backbone_orm::QueryParamReason::InvalidValue,
616 format!("`{parameter}`: {}", secret_named_message(field)),
617 )
618 .with_param("field", field)
619}
620
621fn gated_violation(parameter: &str, field: &str, permission: &str) -> crate::violation::Violation {
624 query_violation(
625 parameter,
626 backbone_orm::QueryParamReason::FieldPermission,
627 format!(
628 "`{parameter}`: `{field}` needs the permission `{permission}` to be filtered, sorted, searched or aggregated"
629 ),
630 )
631 .with_param("field", field)
632 .with_param("permission", permission)
633}
634
635fn gated_write_refusals<E: backbone_orm::EntityRepoMeta>(
641 keys: impl Iterator<Item = String>,
642 caller: Option<&backbone_auth::middleware::AuthContext>,
643) -> Vec<crate::violation::Violation> {
644 let gated = E::field_permissions();
645 if gated.is_empty() {
646 return Vec::new();
647 }
648 let mut refused = Vec::new();
649 for key in keys {
650 let column = camel_to_snake_case(key.trim());
651 for (field, permission) in gated {
652 if camel_to_snake_case(field) == column
653 && !caller.is_some_and(|c| c.has_permission(permission))
654 {
655 refused.push(
656 crate::violation::Violation::new(
657 key.clone(),
658 "field_permission_required",
659 format!("`{key}` needs the permission `{permission}` to be set"),
660 )
661 .with_param("permission", *permission),
662 );
663 }
664 }
665 }
666 refused
667}
668
669fn query_refusals<E: backbone_orm::EntityRepoMeta>(
677 params: &ListQueryParams,
678 paged: bool,
679 caller: Option<&backbone_auth::middleware::AuthContext>,
680) -> Vec<crate::violation::Violation> {
681 use backbone_orm::QueryParamReason;
682 let mut refused = Vec::new();
683 if paged {
684 if let Some(message) = pagination_depth_error(params.page, params.limit) {
685 refused.push(query_violation("page", QueryParamReason::TooDeep, format!("`page`: {message}")));
686 }
687 }
688 if let Some(order) = params.sort_order.as_deref().map(str::trim).filter(|o| !o.is_empty()) {
689 if !order.eq_ignore_ascii_case("asc") && !order.eq_ignore_ascii_case("desc") {
690 refused.push(query_violation(
691 "sort_order",
692 QueryParamReason::InvalidValue,
693 format!("`sort_order` must be `asc` or `desc`, not `{}`", clip_value(order)),
694 ));
695 }
696 }
697 if let Some((parameter, field)) = named_secret_at(params, E::secret_fields()) {
698 refused.push(secret_violation(¶meter, &field));
699 }
700 if let Some((parameter, field, permission)) = named_gated_at(params, E::field_permissions(), caller) {
701 refused.push(gated_violation(¶meter, &field, permission));
702 }
703 refused.extend(shaping_refusals::<E>(¶ms.filters));
704 refused
705}
706
707fn clip_value(value: &str) -> String {
709 if value.chars().count() <= 40 {
710 value.to_string()
711 } else {
712 format!("{}…", value.chars().take(40).collect::<String>())
713 }
714}
715
716fn shaping_refusals<E: backbone_orm::EntityRepoMeta>(
722 query: &HashMap<String, String>,
723) -> Vec<crate::violation::Violation> {
724 use backbone_orm::QueryParamReason;
725 let mut refused = Vec::new();
726 for name in sparse_fields(query) {
727 if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
728 refused.push(query_violation(
729 "fields",
730 QueryParamReason::InvalidFieldName,
731 format!("`fields`: `{}` is not a field name", clip_value(&name)),
732 ));
733 }
734 }
735 let key = if query.contains_key("include") { "include" } else { "with" };
736 for name in include_relations(query) {
737 if !E::relations().iter().any(|(rel, _, _)| *rel == name) {
738 refused.push(query_violation(
739 key,
740 QueryParamReason::UnknownRelation,
741 format!("`{key}`: `{}` is not a relation of this resource", clip_value(&name)),
742 ));
743 }
744 }
745 refused
746}
747
748fn repository_filters(params: &ListQueryParams) -> HashMap<String, String> {
760 let mut filters = params.filters.clone();
761 for key in RESERVED_QUERY_KEYS {
762 filters.remove(key);
763 }
764 filters.retain(|key, _| !backbone_orm::is_internal_key(key));
767 if let Some(search) = params.search.clone() {
768 filters.insert("search".to_string(), search);
769 }
770 if let Some(status) = params.status.clone() {
771 filters.insert("status".to_string(), status);
772 }
773 let sorted = filters.keys().any(|k| {
777 let base = k.split('[').next().unwrap_or(k);
778 base.eq_ignore_ascii_case("orderby") || base.eq_ignore_ascii_case("sort")
779 });
780 if let Some(field) = params.sort_by.as_deref().map(str::trim).filter(|f| !f.is_empty()) {
781 if !sorted {
782 let dir = match params.sort_order.as_deref().map(str::trim) {
783 Some(d) if d.eq_ignore_ascii_case("desc") => "desc",
784 _ => "asc",
785 };
786 filters.insert("orderby".to_string(), format!("{field}:{dir}"));
787 }
788 }
789 filters
790}
791
792fn aggregate_filters(params: &ListQueryParams) -> HashMap<String, String> {
795 let mut filters = repository_filters(params);
796 for key in AGGREGATE_QUERY_KEYS {
797 filters.remove(key);
798 }
799 filters
800}
801
802fn sparse_fields(query: &HashMap<String, String>) -> Vec<String> {
803 query
804 .get("fields")
805 .map(|s| {
806 s.split(',')
807 .map(str::trim)
808 .filter(|f| !f.is_empty())
809 .map(str::to_string)
810 .collect()
811 })
812 .unwrap_or_default()
813}
814
815fn to_response_value<R: Serialize>(r: R) -> serde_json::Value {
818 serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
819}
820
821fn project_sparse(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
825 if fields.is_empty() {
826 return value;
827 }
828 if let serde_json::Value::Object(map) = &mut value {
829 map.retain(|k, _| k == "id" || fields.iter().any(|f| f == k));
830 }
831 value
832}
833
834#[derive(Debug, Clone, Copy, PartialEq, Eq)]
857pub enum AccessScope {
858 Platform,
860 Company(uuid::Uuid),
862}
863
864impl AccessScope {
865 pub fn company(&self) -> Option<uuid::Uuid> {
871 match self {
872 AccessScope::Platform => None,
873 AccessScope::Company(id) => Some(*id),
874 }
875 }
876}
877
878fn strip_secrets(mut value: serde_json::Value, secret_fields: &[&str]) -> serde_json::Value {
888 if let serde_json::Value::Object(map) = &mut value {
889 for f in secret_fields {
890 map.remove(*f);
891 }
892 }
893 value
894}
895
896fn redact_gated(
904 mut value: serde_json::Value,
905 gated: &[(&'static str, &'static str)],
906 caller: Option<&backbone_auth::middleware::AuthContext>,
907) -> serde_json::Value {
908 if gated.is_empty() {
909 return value;
910 }
911 if let serde_json::Value::Object(map) = &mut value {
912 for (field, permission) in gated {
913 if !caller.is_some_and(|c| c.has_permission(permission)) {
914 map.insert(field.to_string(), serde_json::Value::Null);
915 }
916 }
917 }
918 value
919}
920
921fn redact_gated_deep(
925 value: &mut serde_json::Value,
926 gated: &[(&'static str, &'static str)],
927 caller: Option<&backbone_auth::middleware::AuthContext>,
928) {
929 let held = |permission: &str| caller.is_some_and(|c| c.has_permission(permission));
930 match value {
931 serde_json::Value::Object(map) => {
932 for (key, inner) in map.iter_mut() {
933 let column = camel_to_snake_case(key);
934 if let Some((_, permission)) =
935 gated.iter().find(|(field, _)| camel_to_snake_case(field) == column)
936 {
937 if !held(permission) {
938 *inner = serde_json::Value::Null;
939 continue;
940 }
941 }
942 redact_gated_deep(inner, gated, caller);
943 }
944 }
945 serde_json::Value::Array(items) => {
946 for item in items {
947 redact_gated_deep(item, gated, caller);
948 }
949 }
950 _ => {}
951 }
952}
953
954fn secure<E: backbone_orm::EntityRepoMeta>(
960 value: serde_json::Value,
961 scope: Option<&AccessScope>,
962 caller: Option<&backbone_auth::middleware::AuthContext>,
963) -> serde_json::Value {
964 redact_gated(
965 apply_field_security(
966 strip_secrets(value, E::secret_fields()),
967 scope,
968 E::private_fields(),
969 E::owner_field(),
970 ),
971 E::field_permissions(),
972 caller,
973 )
974}
975
976pub fn without_secrets<E: backbone_orm::EntityRepoMeta, R: Serialize>(response: R) -> serde_json::Value {
982 secure::<E>(to_response_value(response), None, None)
983}
984
985pub fn without_secrets_for<E: backbone_orm::EntityRepoMeta, R: Serialize>(
988 caller: &backbone_auth::middleware::AuthContext,
989 response: R,
990) -> serde_json::Value {
991 secure::<E>(to_response_value(response), None, Some(caller))
992}
993
994fn related_row<E: backbone_orm::EntityRepoMeta>(
1000 relation: &str,
1001 table: &str,
1002 obj: serde_json::Value,
1003 caller: Option<&backbone_auth::middleware::AuthContext>,
1004) -> serde_json::Value {
1005 let row = strip_secrets(camelize_keys(obj), E::relation_secret_fields(relation));
1006 let row = strip_secrets(row, &backbone_orm::secret_registry::secret_fields_of(table));
1009 redact_gated(
1010 row,
1011 &backbone_orm::field_permission_registry::field_permissions_of(table),
1012 caller,
1013 )
1014}
1015
1016fn apply_field_security(
1017 mut value: serde_json::Value,
1018 scope: Option<&AccessScope>,
1019 private_fields: &[&str],
1020 owner_field: Option<&str>,
1021) -> serde_json::Value {
1022 if private_fields.is_empty() {
1023 return value;
1024 }
1025 let can_see_private = match scope {
1026 Some(AccessScope::Platform) => true,
1027 Some(AccessScope::Company(id)) => owner_field
1028 .and_then(|f| value.get(f))
1029 .and_then(|v| v.as_str())
1030 .and_then(|owner| uuid::Uuid::parse_str(owner).ok())
1034 .is_some_and(|owner| owner == *id),
1035 None => false,
1036 };
1037 if !can_see_private {
1038 if let serde_json::Value::Object(map) = &mut value {
1039 for f in private_fields {
1040 map.remove(*f);
1041 }
1042 }
1043 }
1044 value
1045}
1046
1047fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
1049 query
1050 .get("include")
1051 .or_else(|| query.get("with"))
1052 .map(|s| {
1053 s.split(',')
1054 .map(str::trim)
1055 .filter(|s| !s.is_empty())
1056 .map(str::to_string)
1057 .collect()
1058 })
1059 .unwrap_or_default()
1060}
1061
1062fn snake_to_camel(s: &str) -> String {
1063 let mut out = String::with_capacity(s.len());
1064 let mut upper = false;
1065 for c in s.chars() {
1066 if c == '_' {
1067 upper = true;
1068 } else if upper {
1069 out.extend(c.to_uppercase());
1070 upper = false;
1071 } else {
1072 out.push(c);
1073 }
1074 }
1075 out
1076}
1077
1078fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
1081 match v {
1082 serde_json::Value::Object(m) => serde_json::Value::Object(
1083 m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
1084 ),
1085 other => other,
1086 }
1087}
1088
1089async fn expand_includes<S, E, C, U>(
1100 service: &S,
1101 rows: &mut [serde_json::Value],
1102 includes: &[String],
1103 caller: Option<&backbone_auth::middleware::AuthContext>,
1104) where
1105 S: CrudService<E, C, U>,
1106 E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
1107 C: Send + Sync + 'static,
1108 U: Send + Sync + 'static,
1109{
1110 if includes.is_empty() || rows.is_empty() {
1111 return;
1112 }
1113 for (rel_name, table, fk_field) in E::relations() {
1114 if !includes.iter().any(|i| i == rel_name) {
1115 continue;
1116 }
1117 let mut ids: Vec<String> = rows
1118 .iter()
1119 .filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
1120 .collect();
1121 ids.sort();
1122 ids.dedup();
1123 if ids.is_empty() {
1124 continue;
1125 }
1126 let related = service.fetch_related_json(table, &ids).await;
1127 let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
1128 for obj in related {
1129 if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
1130 by_id.insert(id, related_row::<E>(rel_name, table, obj, caller));
1131 }
1132 }
1133 for r in rows.iter_mut() {
1134 let related_obj = r
1135 .get(fk_field)
1136 .and_then(|v| v.as_str())
1137 .and_then(|id| by_id.get(id).cloned())
1138 .unwrap_or(serde_json::Value::Null);
1139 if let serde_json::Value::Object(m) = r {
1140 m.insert((*rel_name).to_string(), related_obj);
1141 }
1142 }
1143 }
1144}
1145
1146pub const MAX_PER_PAGE: u32 = 100;
1150
1151pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
1156
1157fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
1163 let effective_limit = limit.clamp(1, MAX_PER_PAGE);
1164 let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
1165 if offset > MAX_PAGINATION_OFFSET {
1166 Some(format!(
1167 "Result set too deep: offset {offset} exceeds the maximum of \
1168 {MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
1169 ))
1170 } else {
1171 None
1172 }
1173}
1174
1175pub use crate::service::MAX_BATCH_SIZE;
1181
1182fn batch_size_error(count: usize) -> Option<String> {
1185 if count > MAX_BATCH_SIZE {
1186 Some(format!(
1187 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
1188 ))
1189 } else {
1190 None
1191 }
1192}
1193
1194fn is_bad_query_error(msg: &str) -> bool {
1203 let m = msg.to_lowercase();
1204 m.contains("does not exist")
1205 || m.contains("invalid input syntax")
1206 || m.contains("42703")
1207 || m.contains("not a column of this entity")
1211 || m.contains("needs a numeric column")
1212}
1213
1214fn refused_list(violations: Vec<crate::violation::Violation>) -> PaginatedApiResponse<serde_json::Value> {
1217 PaginatedApiResponse::rejected(refusal_sentence(&violations), violations)
1218}
1219
1220#[derive(Debug, Serialize, Deserialize, Clone)]
1222#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1223pub struct PaginationResponse {
1224 pub total: u64,
1225 pub page: u32,
1226 pub limit: u32,
1227 pub total_pages: u32,
1228 #[serde(skip_serializing_if = "Option::is_none")]
1232 pub next_cursor: Option<String>,
1233 #[serde(skip_serializing_if = "Option::is_none")]
1235 pub prev_cursor: Option<String>,
1236 #[serde(skip_serializing_if = "Option::is_none")]
1238 pub has_more: Option<bool>,
1239}
1240
1241impl PaginationResponse {
1242 pub fn new(total: u64, page: u32, limit: u32) -> Self {
1243 let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
1244 Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
1245 }
1246
1247 pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
1251 Self {
1252 total: info.total,
1253 page: info.page,
1254 limit: info.per_page,
1255 total_pages: info.total_pages,
1256 next_cursor: info.next_cursor.clone(),
1257 prev_cursor: info.prev_cursor.clone(),
1258 has_more: info.has_more,
1259 }
1260 }
1261}
1262
1263#[derive(Debug, Serialize)]
1265#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1266pub struct PaginatedResponse<T> {
1267 pub data: Vec<T>,
1268 pub meta: PaginationResponse,
1269}
1270
1271#[derive(Debug, Serialize)]
1274#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1275pub struct PaginatedApiResponse<T> {
1276 pub success: bool,
1277 pub data: Vec<T>,
1278 pub meta: PaginationResponse,
1279 #[serde(skip_serializing_if = "Option::is_none")]
1280 pub error: Option<String>,
1281 #[serde(default, skip_serializing_if = "Option::is_none")]
1283 pub violations: Option<Vec<crate::violation::Violation>>,
1284}
1285
1286impl<T> PaginatedApiResponse<T> {
1287 pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
1290 Self {
1291 success: true,
1292 data,
1293 meta: PaginationResponse::from_info(info),
1294 error: None,
1295 violations: None,
1296 }
1297 }
1298
1299 pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
1301 Self {
1302 success: true,
1303 data,
1304 meta: PaginationResponse::new(total, page, limit),
1305 error: None,
1306 violations: None,
1307 }
1308 }
1309
1310 pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
1312 Self {
1313 success: true,
1314 data: resp.data,
1315 meta: resp.meta,
1316 error: None,
1317 violations: None,
1318 }
1319 }
1320
1321 pub fn error(error: impl Into<String>) -> Self {
1323 Self {
1324 success: false,
1325 data: Vec::new(),
1326 meta: PaginationResponse::new(0, 0, 0),
1327 error: Some(error.into()),
1328 violations: None,
1329 }
1330 }
1331
1332 pub fn rejected(error: impl Into<String>, violations: Vec<crate::violation::Violation>) -> Self {
1334 Self {
1335 violations: (!violations.is_empty()).then_some(violations),
1336 ..Self::error(error)
1337 }
1338 }
1339}
1340
1341#[derive(Debug, Serialize, Deserialize)]
1343#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1344pub struct BulkCreateRequest<T> {
1345 pub items: Vec<T>,
1346}
1347
1348#[derive(Debug, Serialize, Deserialize)]
1350#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1351pub struct BulkResponse<T> {
1352 pub items: Vec<T>,
1353 pub total: usize,
1354 pub failed: usize,
1355 pub errors: Vec<String>,
1356}
1357
1358#[derive(Debug, Serialize, Deserialize)]
1360#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1361pub struct UpsertRequest<T> {
1362 pub entity: T,
1363 pub create_if_not_exists: bool,
1364}
1365
1366#[derive(Debug, Serialize, Deserialize)]
1369#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1370pub struct BatchIdsRequest {
1371 pub ids: Vec<String>,
1372}
1373
1374#[derive(Debug, Deserialize)]
1377#[serde(bound = "U: DeserializeOwned")]
1378#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1379pub struct BulkUpdateItem<U> {
1380 pub id: String,
1381 #[serde(flatten)]
1385 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1386 pub data: U,
1387}
1388
1389#[derive(Debug, Deserialize)]
1391#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1392pub struct BulkPatchItem {
1393 pub id: String,
1394 pub patch: HashMap<String, serde_json::Value>,
1395}
1396
1397#[derive(Debug, Deserialize)]
1400#[serde(untagged)]
1401#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1402pub enum BulkPatchRequest {
1403 Shared {
1405 ids: Vec<String>,
1406 patch: HashMap<String, serde_json::Value>,
1407 },
1408 PerItem { items: Vec<BulkPatchItem> },
1410}
1411
1412impl BulkPatchRequest {
1413 fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
1415 match self {
1416 BulkPatchRequest::Shared { ids, patch } => {
1417 ids.into_iter().map(|id| (id, patch.clone())).collect()
1418 }
1419 BulkPatchRequest::PerItem { items } => {
1420 items.into_iter().map(|it| (it.id, it.patch)).collect()
1421 }
1422 }
1423 }
1424}
1425
1426#[derive(Debug, Default, Serialize)]
1428#[serde(rename_all = "camelCase")]
1429#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1430pub struct BulkPreview {
1431 pub will_change: Vec<PreviewChange>,
1433 pub unchanged: Vec<String>,
1435 pub refused: Vec<PreviewRefusal>,
1437 pub total: usize,
1439}
1440
1441#[derive(Debug, Serialize)]
1443#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1444pub struct PreviewChange {
1445 pub id: String,
1447 pub changes: Vec<FieldChange>,
1449}
1450
1451#[derive(Debug, Serialize)]
1453#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1454pub struct FieldChange {
1455 pub field: String,
1457 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1459 pub from: serde_json::Value,
1460 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1462 pub to: serde_json::Value,
1463}
1464
1465#[derive(Debug, Serialize)]
1467#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1468pub struct PreviewRefusal {
1469 pub id: String,
1471 pub code: String,
1473 pub message: String,
1475}
1476
1477type PreviewOutcome = Result<(serde_json::Value, serde_json::Value), (String, String)>;
1479
1480fn preview_answer(
1487 rows: Vec<(String, PreviewOutcome)>,
1488 patched: &HashMap<String, Vec<String>>,
1489) -> BulkPreview {
1490 let mut out = BulkPreview::default();
1491 for (id, outcome) in rows {
1492 match outcome {
1493 Ok((before, after)) => {
1494 let mut keys = patched.get(&id).cloned().unwrap_or_default();
1495 keys.sort();
1496 let changes: Vec<FieldChange> = keys
1497 .into_iter()
1498 .filter_map(|field| {
1499 let read = |v: &serde_json::Value| {
1500 v.get(snake_to_camel(&field))
1501 .or_else(|| v.get(&field))
1502 .cloned()
1503 .unwrap_or(serde_json::Value::Null)
1504 };
1505 let (from, to) = (read(&before), read(&after));
1506 (from != to).then_some(FieldChange { field, from, to })
1507 })
1508 .collect();
1509 if changes.is_empty() {
1510 out.unchanged.push(id);
1511 } else {
1512 out.will_change.push(PreviewChange { id, changes });
1513 }
1514 }
1515 Err((code, message)) => out.refused.push(PreviewRefusal { id, code, message }),
1516 }
1517 }
1518 out.total = out.will_change.len() + out.unchanged.len() + out.refused.len();
1519 out
1520}
1521
1522#[derive(Debug, Serialize, Deserialize)]
1524#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1525pub struct FilterOptions {
1526 pub filters: HashMap<String, String>,
1527 pub sort_by: Option<String>,
1528 pub sort_order: Option<SortOrder>,
1529}
1530
1531#[derive(Debug, Serialize, Deserialize, Clone, Default)]
1533#[serde(rename_all = "lowercase")]
1534#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1535pub enum SortOrder {
1536 #[default]
1537 Asc,
1538 Desc,
1539}
1540
1541#[derive(Debug, Serialize, Deserialize)]
1543#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1544pub struct ListRequest {
1545 pub page: Option<u32>,
1546 pub limit: Option<u32>,
1547 pub sort_by: Option<String>,
1548 pub sort_order: Option<SortOrder>,
1549 pub filters: Option<HashMap<String, String>>,
1550}
1551
1552impl Default for ListRequest {
1553 fn default() -> Self {
1554 Self {
1555 page: Some(1),
1556 limit: Some(20),
1557 sort_by: None,
1558 sort_order: None,
1559 filters: None,
1560 }
1561 }
1562}
1563
1564#[async_trait::async_trait]
1588pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
1589where
1590 Entity: Send + Sync + 'static,
1593 CreateDto: Send + Sync + 'static,
1594 UpdateDto: Send + Sync + 'static,
1595{
1596 type Error: std::error::Error + Send + Sync;
1598
1599 fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
1602 let _ = err;
1603 None
1604 }
1605
1606 fn status_of(err: &Self::Error) -> Option<axum::http::StatusCode> {
1611 let _ = err;
1612 None
1613 }
1614
1615 fn supports_upsert() -> bool {
1619 true
1620 }
1621
1622 fn entity_name() -> &'static str;
1624
1625 async fn fetch_related_json(
1628 &self,
1629 _table: &str,
1630 _ids: &[String],
1631 ) -> Vec<serde_json::Value> {
1632 Vec::new()
1633 }
1634
1635 async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1637
1638 async fn list_with_info(
1642 &self,
1643 page: u32,
1644 limit: u32,
1645 filters: HashMap<String, String>,
1646 ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
1647 let (rows, total) = self.list(page, limit, filters).await?;
1648 Ok((
1649 rows,
1650 backbone_orm::repository::PaginationInfo::new(page, limit, total),
1651 ))
1652 }
1653
1654 async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1656
1657 async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1659
1660 async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
1662
1663 async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
1665
1666 async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
1668
1669 async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
1671
1672 async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1674
1675 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
1677
1678 async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1680
1681 async fn empty_trash(&self) -> Result<u64, Self::Error>;
1683
1684 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1686
1687 async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
1689
1690 async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1692
1693 async fn count_active(&self) -> Result<u64, Self::Error>;
1695
1696 fn table_name(&self) -> Option<&str> {
1703 None
1704 }
1705
1706 async fn aggregate(
1711 &self,
1712 spec: &backbone_orm::repository::AggregateSpec,
1713 filters: HashMap<String, String>,
1714 ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
1715
1716 async fn count_active_filtered(
1729 &self,
1730 filters: HashMap<String, String>,
1731 ) -> Result<u64, Self::Error> {
1732 self.list(1, 1, filters).await.map(|(_, total)| total)
1733 }
1734
1735 async fn count_deleted(&self) -> Result<u64, Self::Error>;
1737
1738 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1747 let mut n = 0;
1748 for id in ids {
1749 if self.soft_delete(&id).await? {
1750 n += 1;
1751 }
1752 }
1753 Ok(n)
1754 }
1755
1756 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1758 let mut out = Vec::with_capacity(ids.len());
1759 for id in ids {
1760 if let Some(e) = self.restore(&id).await? {
1761 out.push(e);
1762 }
1763 }
1764 Ok(out)
1765 }
1766
1767 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1769 let mut n = 0;
1770 for id in ids {
1771 if self.permanent_delete(&id).await? {
1772 n += 1;
1773 }
1774 }
1775 Ok(n)
1776 }
1777
1778 async fn restore_all(&self) -> Result<u64, Self::Error> {
1783 Ok(0)
1784 }
1785
1786 async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1788 let mut out = Vec::with_capacity(items.len());
1789 for (id, dto) in items {
1790 if let Some(e) = self.update(&id, dto).await? {
1791 out.push(e);
1792 }
1793 }
1794 Ok(out)
1795 }
1796
1797 async fn bulk_partial_update(
1799 &self,
1800 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1801 ) -> Result<Vec<Entity>, Self::Error> {
1802 let mut out = Vec::with_capacity(items.len());
1803 for (id, fields) in items {
1804 if let Some(e) = self.partial_update(&id, fields).await? {
1805 out.push(e);
1806 }
1807 }
1808 Ok(out)
1809 }
1810
1811 async fn preview_bulk_partial_update(
1816 &self,
1817 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1818 ) -> Result<Option<Vec<BulkPreviewRow<Entity, Self::Error>>>, Self::Error> {
1819 let _ = items;
1820 Ok(None)
1821 }
1822}
1823
1824pub struct BulkPreviewRow<Entity, Err> {
1827 pub id: String,
1829 pub outcome: Result<(Entity, Entity), Err>,
1831}
1832
1833pub struct BackboneCrudHandler<S, E, C, U, R>
1856where
1857 S: CrudService<E, C, U> + 'static,
1858 E: Serialize + Send + Sync + 'static,
1859 C: DeserializeOwned + Send + Sync + 'static,
1860 U: DeserializeOwned + Send + Sync + 'static,
1861 R: From<E> + Serialize + Send + Sync + 'static,
1862{
1863 service: Arc<S>,
1864 _phantom: std::marker::PhantomData<(E, C, U, R)>,
1865}
1866
1867impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1868where
1869 S: CrudService<E, C, U> + 'static,
1870 E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1871 C: DeserializeOwned + Send + Sync + 'static,
1872 U: DeserializeOwned + Send + Sync + 'static,
1873 R: From<E> + Serialize + Send + Sync + 'static,
1874{
1875 pub fn new(service: Arc<S>) -> Self {
1876 Self {
1877 service,
1878 _phantom: std::marker::PhantomData,
1879 }
1880 }
1881
1882 pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1910 where
1911 S: Clone,
1912 {
1913 Self::read_routes(service.clone(), base_path)
1914 .merge(Self::write_routes(service, base_path))
1915 }
1916
1917 pub fn routes_guarded(
1920 service: Arc<S>,
1921 base_path: &str,
1922 permissions: crate::permission::CrudPermissions,
1923 ) -> axum::Router<()>
1924 where
1925 S: Clone,
1926 {
1927 Self::read_routes_guarded(service.clone(), base_path, permissions.clone())
1928 .merge(Self::write_routes_guarded(service, base_path, permissions))
1929 }
1930
1931 fn gate(
1933 perms: &Option<crate::permission::CrudPermissions>,
1934 gate: crate::permission::Gate,
1935 route: axum::routing::MethodRouter<()>,
1936 ) -> axum::routing::MethodRouter<()> {
1937 match perms {
1938 Some(p) if p.is_public(gate) => route,
1941 Some(p) => crate::permission::guarded_needs(route, p.needs(gate)),
1942 None => route,
1943 }
1944 }
1945
1946 pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1960 where
1961 S: Clone,
1962 {
1963 Self::read_routes_with(service, base_path, None)
1964 }
1965
1966 pub fn read_routes_guarded(
1973 service: Arc<S>,
1974 base_path: &str,
1975 permissions: crate::permission::CrudPermissions,
1976 ) -> axum::Router<()>
1977 where
1978 S: Clone,
1979 {
1980 Self::read_routes_with(service, base_path, Some(permissions))
1981 }
1982
1983 fn read_routes_with(
1984 service: Arc<S>,
1985 base_path: &str,
1986 perms: Option<crate::permission::CrudPermissions>,
1987 ) -> axum::Router<()>
1988 where
1989 S: Clone,
1990 {
1991 use crate::permission::Gate;
1992 if let Some(table) = service.table_name() {
1996 backbone_orm::secret_registry::register(table, E::secret_fields());
1997 backbone_orm::field_permission_registry::register(table, E::field_permissions());
1998 }
1999 use axum::{
2000 extract::Path,
2001 routing::get,
2002 Extension, Router,
2003 };
2004
2005 let handler = Arc::new(Self::new(service));
2006
2007 Router::new()
2008 .route(base_path, Self::gate(&perms, Gate::Read, get({
2010 let h = handler.clone();
2011 move |query: ListQuery,
2012 access: Option<Extension<AccessScope>>,
2013 caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2014 Self::list_handler(h, query, access, caller).await
2015 }
2016 })))
2017 .route(&format!("{}/trash", base_path), Self::gate(&perms, Gate::Read, get({
2019 let h = handler.clone();
2020 move |query: ListQuery,
2021 access: Option<Extension<AccessScope>>,
2022 caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2023 Self::list_deleted_handler(h, query, access, caller).await
2024 }
2025 })))
2026 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Read, get({
2028 let h = handler.clone();
2029 move |path: Path<String>,
2030 query: ListQuery,
2031 access: Option<Extension<AccessScope>>,
2032 caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2033 Self::get_handler(h, path, query, access, caller).await
2034 }
2035 })))
2036 .route(&format!("{}/:id/deleted", base_path), Self::gate(&perms, Gate::Read, get({
2038 let h = handler.clone();
2039 move |path: Path<String>,
2040 query: ListQuery,
2041 access: Option<Extension<AccessScope>>,
2042 caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2043 Self::get_deleted_handler(h, path, query, access, caller).await
2044 }
2045 })))
2046 .route(&format!("{}/:id/history", base_path), Self::gate(&perms, Gate::Read, get({
2049 let h = handler.clone();
2050 move |path: axum::extract::Path<String>,
2051 query: ListQuery,
2052 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2053 caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2054 Self::history_handler(h, path, query, provider, caller).await
2055 }
2056 })))
2057 .route(&format!("{}/aggregate", base_path), Self::gate(&perms, Gate::Read, get({
2059 let h = handler.clone();
2060 move |query: ListQuery,
2061 caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2062 Self::aggregate_handler(h, query, caller).await
2063 }
2064 })))
2065 .route(&format!("{}/count", base_path), Self::gate(&perms, Gate::Read, get({
2066 let h = handler.clone();
2067 move |query: ListQuery,
2068 caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2069 Self::count_active_handler(h, query, caller).await
2070 }
2071 })))
2072 .route(&format!("{}/trash/count", base_path), Self::gate(&perms, Gate::Read, get({
2074 let h = handler.clone();
2075 move || async move {
2076 Self::count_deleted_handler(h).await
2077 }
2078 })))
2079 }
2080
2081 pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
2098 where
2099 S: Clone,
2100 {
2101 Self::write_routes_with(service, base_path, None)
2102 }
2103
2104 pub fn write_routes_guarded(
2109 service: Arc<S>,
2110 base_path: &str,
2111 permissions: crate::permission::CrudPermissions,
2112 ) -> axum::Router<()>
2113 where
2114 S: Clone,
2115 {
2116 Self::write_routes_with(service, base_path, Some(permissions))
2117 }
2118
2119 fn write_routes_with(
2120 service: Arc<S>,
2121 base_path: &str,
2122 perms: Option<crate::permission::CrudPermissions>,
2123 ) -> axum::Router<()>
2124 where
2125 S: Clone,
2126 {
2127 use crate::permission::Gate;
2128 if let Some(table) = service.table_name() {
2132 backbone_orm::secret_registry::register(table, E::secret_fields());
2133 backbone_orm::field_permission_registry::register(table, E::field_permissions());
2134 }
2135 use axum::{
2136 extract::Path,
2137 routing::{delete, patch, post, put},
2138 Extension, Router,
2139 };
2140
2141 let handler = Arc::new(Self::new(service));
2142
2143 Router::new()
2144 .route(base_path, Self::gate(&perms, Gate::Create, post({
2146 let h = handler.clone();
2147 move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2148 body: crate::extractors::WriteBody<C>| async move {
2149 Self::create_handler(h, body, caller).await
2150 }
2151 })))
2152 .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Create, post({
2154 let h = handler.clone();
2155 move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2156 body: crate::extractors::WriteBody<Vec<C>>| async move {
2157 Self::bulk_create_handler(h, body, caller).await
2158 }
2159 })))
2160 .route(&format!("{}/upsert", base_path), Self::gate(&perms, Gate::CreateUpdate, post({
2162 let h = handler.clone();
2163 move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2164 body: crate::extractors::WriteBody<C>| async move {
2165 Self::upsert_handler(h, body, caller).await
2166 }
2167 })))
2168 .route(&format!("{}/delete/bulk", base_path), Self::gate(&perms, Gate::Delete, post({
2170 let h = handler.clone();
2171 move |body: JsonOrForm<BatchIdsRequest>| async move {
2172 Self::bulk_delete_handler(h, body).await
2173 }
2174 })))
2175 .route(&format!("{}/restore/bulk", base_path), Self::gate(&perms, Gate::Restore, post({
2177 let h = handler.clone();
2178 move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2179 body: JsonOrForm<BatchIdsRequest>| async move {
2180 Self::bulk_restore_handler(h, body, caller).await
2181 }
2182 })))
2183 .route(&format!("{}/restore/all", base_path), Self::gate(&perms, Gate::Restore, post({
2185 let h = handler.clone();
2186 move || async move {
2187 Self::restore_all_handler(h).await
2188 }
2189 })))
2190 .route(&format!("{}/trash/bulk", base_path), Self::gate(&perms, Gate::Purge, delete({
2193 let h = handler.clone();
2194 move |body: JsonOrForm<BatchIdsRequest>| async move {
2195 Self::bulk_permanent_delete_handler(h, body).await
2196 }
2197 })))
2198 .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Update, put({
2200 let h = handler.clone();
2201 move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2202 body: JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>| async move {
2203 Self::bulk_update_handler(h, body, caller).await
2204 }
2205 })))
2206 .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Update, patch({
2208 let h = handler.clone();
2209 move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2210 body: JsonOrForm<BulkPatchRequest>| async move {
2211 Self::bulk_patch_handler(h, body, caller).await
2212 }
2213 })))
2214 .route(&format!("{}/bulk/preview", base_path), Self::gate(&perms, Gate::Update, post({
2216 let h = handler.clone();
2217 move |caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2218 body: JsonOrForm<BulkPatchRequest>| async move {
2219 Self::bulk_patch_preview_handler(h, body, caller).await
2220 }
2221 })))
2222 .route(&format!("{}/empty", base_path), Self::gate(&perms, Gate::Purge, delete({
2224 let h = handler.clone();
2225 move || async move {
2226 Self::empty_trash_handler(h).await
2227 }
2228 })))
2229 .route(&format!("{}/trash/:id", base_path), Self::gate(&perms, Gate::Purge, delete({
2231 let h = handler.clone();
2232 move |path: Path<String>| async move {
2233 Self::permanent_delete_handler(h, path).await
2234 }
2235 })))
2236 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Update, put({
2238 let h = handler.clone();
2239 move |path: Path<String>,
2240 caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2241 body: crate::extractors::WriteBody<U>| async move {
2242 Self::update_handler(h, path, body, caller).await
2243 }
2244 })))
2245 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Update, patch({
2247 let h = handler.clone();
2248 move |path: Path<String>,
2249 caller: Option<Extension<backbone_auth::middleware::AuthContext>>,
2250 body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
2251 Self::partial_update_handler(h, path, body, caller).await
2252 }
2253 })))
2254 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Delete, delete({
2256 let h = handler.clone();
2257 move |path: Path<String>| async move {
2258 Self::delete_handler(h, path).await
2259 }
2260 })))
2261 .route(&format!("{}/:id/restore", base_path), Self::gate(&perms, Gate::Restore, post({
2263 let h = handler.clone();
2264 move |path: Path<String>,
2265 caller: Option<Extension<backbone_auth::middleware::AuthContext>>| async move {
2266 Self::restore_handler(h, path, caller).await
2267 }
2268 })))
2269 .layer(axum::middleware::from_fn(correlation_middleware))
2271 }
2272
2273 fn failure<T>(err: &S::Error, fallback: axum::http::StatusCode) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
2281 let violations = S::violations_of(err);
2282 let status = S::status_of(err).unwrap_or(match &violations {
2283 Some(v) if is_query_refusal(v) => axum::http::StatusCode::BAD_REQUEST,
2284 Some(_) => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
2285 None => fallback,
2286 });
2287 error_at(status, violations, err)
2288 }
2289
2290 fn query_failure_parts(
2295 err: &S::Error,
2296 params: &ListQueryParams,
2297 fallback: axum::http::StatusCode,
2298 ) -> (axum::http::StatusCode, String, Vec<crate::violation::Violation>) {
2299 use axum::http::StatusCode;
2300 let violations = S::violations_of(err);
2301 if let Some(v) = violations.as_ref().filter(|v| is_query_refusal(v)) {
2302 let v = rename_typed_sort(v.clone(), params);
2303 return (StatusCode::BAD_REQUEST, refusal_sentence(&v), v);
2304 }
2305 let msg = err.to_string();
2306 if violations.is_none() && is_bad_query_error(&msg) {
2307 tracing::debug!(target: "backbone::http", error = %msg, "list query refused");
2311 let message = "Invalid query parameter or filter";
2312 return (
2313 StatusCode::BAD_REQUEST,
2314 message.to_string(),
2315 vec![crate::violation::Violation::new("", crate::violation::INVALID_QUERY, message)],
2316 );
2317 }
2318 let (status, axum::Json(body)) = Self::failure::<()>(err, fallback);
2319 (status, body.error.unwrap_or_default(), body.violations.unwrap_or_default())
2320 }
2321
2322 fn list_failure(
2324 err: &S::Error,
2325 params: &ListQueryParams,
2326 ) -> (axum::http::StatusCode, axum::Json<PaginatedApiResponse<serde_json::Value>>) {
2327 let (status, error, violations) =
2328 Self::query_failure_parts(err, params, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
2329 (status, axum::Json(PaginatedApiResponse::rejected(error, violations)))
2330 }
2331
2332 fn query_failure<T>(
2334 err: &S::Error,
2335 params: &ListQueryParams,
2336 ) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
2337 let (status, error, violations) =
2338 Self::query_failure_parts(err, params, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
2339 let body = if violations.is_empty() {
2340 ApiResponse::error(error)
2341 } else {
2342 ApiResponse::rejected(error, violations)
2343 };
2344 (status, axum::Json(body))
2345 }
2346
2347 async fn list_handler(
2348 handler: Arc<Self>,
2349 ListQuery(params): ListQuery,
2350 access: Option<axum::Extension<AccessScope>>,
2351 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2352 ) -> impl axum::response::IntoResponse {
2353 use axum::{http::StatusCode, Json};
2354
2355 let caller = caller.map(|axum::Extension(c)| c);
2356 let params = params.normalized();
2357 let refused = query_refusals::<E>(¶ms, true, caller.as_ref());
2358 if !refused.is_empty() {
2359 return (StatusCode::BAD_REQUEST, Json(refused_list(refused)));
2360 }
2361
2362 let fields = sparse_fields(¶ms.filters);
2365 let includes = include_relations(¶ms.filters);
2366 let scope = access.map(|axum::Extension(s)| s);
2367
2368 let filters = repository_filters(¶ms);
2369
2370 match handler
2371 .service
2372 .list_with_info(params.page, params.limit, filters)
2373 .await
2374 {
2375 Ok((entities, info)) => {
2376 let mut rows: Vec<serde_json::Value> = entities
2379 .into_iter()
2380 .map(|e| {
2381 secure::<E>(to_response_value(R::from(e)), scope.as_ref(), caller.as_ref())
2382 })
2383 .collect();
2384 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes, caller.as_ref()).await;
2385 let items: Vec<serde_json::Value> =
2386 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2387 let response = PaginatedApiResponse::ok_with_info(items, &info);
2388 (StatusCode::OK, Json(response))
2389 }
2390 Err(e) => Self::list_failure(&e, ¶ms),
2391 }
2392 }
2393
2394 async fn create_handler(
2395 handler: Arc<Self>,
2396 crate::extractors::WriteBody(dto, keys): crate::extractors::WriteBody<C>,
2397 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2398 ) -> impl axum::response::IntoResponse {
2399 use axum::{http::StatusCode, Json};
2400
2401 let caller = caller.map(|axum::Extension(c)| c);
2402 let refused = gated_write_refusals::<E>(keys.into_iter(), caller.as_ref());
2403 if !refused.is_empty() {
2404 return (
2405 StatusCode::UNPROCESSABLE_ENTITY,
2406 Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2407 );
2408 }
2409 match handler.service.create(dto).await {
2410 Ok(entity) => {
2411 let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2412 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
2413 }
2414 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2415 }
2416 }
2417
2418 async fn get_handler(
2419 handler: Arc<Self>,
2420 axum::extract::Path(id): axum::extract::Path<String>,
2421 ListQuery(params): ListQuery,
2422 access: Option<axum::Extension<AccessScope>>,
2423 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2424 ) -> impl axum::response::IntoResponse {
2425 use axum::{http::StatusCode, Json};
2426
2427 let caller = caller.map(|axum::Extension(c)| c);
2428 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2429 return refused;
2430 }
2431 let refused = shaping_refusals::<E>(¶ms.filters);
2432 if !refused.is_empty() {
2433 return (
2434 StatusCode::BAD_REQUEST,
2435 Json(ApiResponse::rejected(refusal_sentence(&refused), refused)),
2436 );
2437 }
2438
2439 let fields = sparse_fields(¶ms.filters);
2440 let includes = include_relations(¶ms.filters);
2441 let scope = access.map(|axum::Extension(s)| s);
2442
2443 match handler.service.get_by_id(&id).await {
2444 Ok(Some(entity)) => {
2445 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref(), caller.as_ref());
2446 let mut rows = [secured];
2447 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes, caller.as_ref()).await;
2448 let [secured] = rows;
2449 let value = project_sparse(secured, &fields);
2450 (StatusCode::OK, Json(ApiResponse::ok(value)))
2451 }
2452 Ok(None) => {
2453 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2454 }
2455 Err(e) => {
2456 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2457 }
2458 }
2459 }
2460
2461 async fn update_handler(
2462 handler: Arc<Self>,
2463 axum::extract::Path(id): axum::extract::Path<String>,
2464 crate::extractors::WriteBody(dto, keys): crate::extractors::WriteBody<U>,
2465 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2466 ) -> impl axum::response::IntoResponse {
2467 use axum::{http::StatusCode, Json};
2468
2469 let caller = caller.map(|axum::Extension(c)| c);
2470 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2471 return refused;
2472 }
2473 let refused = gated_write_refusals::<E>(keys.into_iter(), caller.as_ref());
2474 if !refused.is_empty() {
2475 return (
2476 StatusCode::UNPROCESSABLE_ENTITY,
2477 Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2478 );
2479 }
2480
2481 match handler.service.update(&id, dto).await {
2482 Ok(Some(entity)) => {
2483 let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2484 (StatusCode::OK, Json(ApiResponse::ok(response)))
2485 }
2486 Ok(None) => {
2487 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2488 }
2489 Err(e) => {
2490 Self::failure(&e, StatusCode::BAD_REQUEST)
2491 }
2492 }
2493 }
2494
2495 async fn partial_update_handler(
2496 handler: Arc<Self>,
2497 axum::extract::Path(id): axum::extract::Path<String>,
2498 JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
2499 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2500 ) -> impl axum::response::IntoResponse {
2501 use axum::{http::StatusCode, Json};
2502
2503 let caller = caller.map(|axum::Extension(c)| c);
2504 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2505 return refused;
2506 }
2507 let refused = gated_write_refusals::<E>(fields.keys().cloned(), caller.as_ref());
2508 if !refused.is_empty() {
2509 return (
2510 StatusCode::UNPROCESSABLE_ENTITY,
2511 Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2512 );
2513 }
2514
2515 let fields: HashMap<String, serde_json::Value> = fields
2526 .into_iter()
2527 .map(|(k, v)| (camel_to_snake_case(&k), v))
2528 .collect();
2529
2530 match handler.service.partial_update(&id, fields).await {
2531 Ok(Some(entity)) => {
2532 let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2533 (StatusCode::OK, Json(ApiResponse::ok(response)))
2534 }
2535 Ok(None) => {
2536 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2537 }
2538 Err(e) => {
2539 Self::failure(&e, StatusCode::BAD_REQUEST)
2540 }
2541 }
2542 }
2543
2544 async fn delete_handler(
2545 handler: Arc<Self>,
2546 axum::extract::Path(id): axum::extract::Path<String>,
2547 ) -> impl axum::response::IntoResponse {
2548 use axum::{http::StatusCode, Json};
2549
2550 if let Some(refused) = invalid_id::<()>(&id, "id") {
2551 return refused;
2552 }
2553
2554 match handler.service.soft_delete(&id).await {
2555 Ok(true) => {
2556 (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
2559 }
2560 Ok(false) => {
2561 (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
2562 }
2563 Err(e) => {
2564 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2565 }
2566 }
2567 }
2568
2569 async fn bulk_create_handler(
2570 handler: Arc<Self>,
2571 crate::extractors::WriteBody(items, keys): crate::extractors::WriteBody<Vec<C>>,
2572 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2573 ) -> impl axum::response::IntoResponse {
2574 use axum::{http::StatusCode, Json};
2575
2576 let caller = caller.map(|axum::Extension(c)| c);
2577 let refused = gated_write_refusals::<E>(keys.into_iter(), caller.as_ref());
2578 if !refused.is_empty() {
2579 return (
2580 StatusCode::UNPROCESSABLE_ENTITY,
2581 Json(ApiResponse::<BulkResponse<serde_json::Value>>::rejected(refusal_sentence(&refused), refused)),
2582 );
2583 }
2584 if let Some(err) = batch_size_error(items.len()) {
2585 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2586 }
2587 match handler.service.bulk_create(items).await {
2588 Ok(entities) => {
2589 let result_items: Vec<serde_json::Value> = entities
2590 .into_iter()
2591 .map(|e| secure::<E>(to_response_value(R::from(e)), None, caller.as_ref()))
2592 .collect();
2593 let total = result_items.len();
2594 let response = BulkResponse {
2595 items: result_items,
2596 total,
2597 failed: 0,
2598 errors: vec![],
2599 };
2600 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
2601 }
2602 Err(e) => {
2603 Self::failure(&e, StatusCode::BAD_REQUEST)
2604 }
2605 }
2606 }
2607
2608 async fn bulk_delete_handler(
2611 handler: Arc<Self>,
2612 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2613 ) -> impl axum::response::IntoResponse {
2614 use axum::{http::StatusCode, Json};
2615
2616 if let Some(err) = batch_size_error(req.ids.len()) {
2617 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2618 }
2619 if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2620 return refused;
2621 }
2622 match handler.service.bulk_soft_delete(req.ids).await {
2623 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2624 serde_json::json!({ "soft_deleted": count }),
2625 format!("Soft-deleted {count} item(s)"),
2626 ))),
2627 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2628 }
2629 }
2630
2631 async fn bulk_restore_handler(
2632 handler: Arc<Self>,
2633 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2634 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2635 ) -> impl axum::response::IntoResponse {
2636 use axum::{http::StatusCode, Json};
2637
2638 let caller = caller.map(|axum::Extension(c)| c);
2639 if let Some(err) = batch_size_error(req.ids.len()) {
2640 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2641 }
2642 if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2643 return refused;
2644 }
2645 match handler.service.bulk_restore(req.ids).await {
2646 Ok(entities) => {
2647 let items: Vec<serde_json::Value> = entities
2648 .into_iter()
2649 .map(|e| secure::<E>(to_response_value(R::from(e)), None, caller.as_ref()))
2650 .collect();
2651 let total = items.len();
2652 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2653 }
2654 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2655 }
2656 }
2657
2658 async fn restore_all_handler(
2659 handler: Arc<Self>,
2660 ) -> impl axum::response::IntoResponse {
2661 use axum::{http::StatusCode, Json};
2662
2663 match handler.service.restore_all().await {
2664 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2665 serde_json::json!({ "restored": count }),
2666 format!("Restored {count} item(s) from trash"),
2667 ))),
2668 Err(e) => Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR),
2669 }
2670 }
2671
2672 async fn bulk_permanent_delete_handler(
2673 handler: Arc<Self>,
2674 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2675 ) -> impl axum::response::IntoResponse {
2676 use axum::{http::StatusCode, Json};
2677
2678 if let Some(err) = batch_size_error(req.ids.len()) {
2679 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2680 }
2681 if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2682 return refused;
2683 }
2684 match handler.service.bulk_permanent_delete(req.ids).await {
2685 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2686 serde_json::json!({ "permanently_deleted": count }),
2687 format!("Permanently deleted {count} item(s)"),
2688 ))),
2689 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2690 }
2691 }
2692
2693 async fn bulk_update_handler(
2697 handler: Arc<Self>,
2698 JsonOrForm(raw): JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>,
2699 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2700 ) -> axum::response::Response {
2701 use axum::{http::StatusCode, response::IntoResponse, Json};
2702
2703 let caller = caller.map(|axum::Extension(c)| c);
2704 if let Some(err) = batch_size_error(raw.len()) {
2705 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)))
2706 .into_response();
2707 }
2708 let refused = gated_write_refusals::<E>(raw.iter().flat_map(|m| m.keys().cloned()), caller.as_ref());
2709 if !refused.is_empty() {
2710 return (
2711 StatusCode::UNPROCESSABLE_ENTITY,
2712 Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2713 )
2714 .into_response();
2715 }
2716 let mut items: Vec<(String, U)> = Vec::with_capacity(raw.len());
2717 for (index, mut fields) in raw.into_iter().enumerate() {
2718 let id = match fields.remove("id") {
2719 Some(serde_json::Value::String(id)) => id,
2720 _ => {
2721 let violation = crate::violation::Violation::new(
2722 crate::violation::batch_item_path(index, "id"),
2723 "required",
2724 "id is required",
2725 );
2726 return (
2727 StatusCode::UNPROCESSABLE_ENTITY,
2728 Json(ApiResponse::<()>::rejected(format!("item {index}: id is required"), vec![violation])),
2729 )
2730 .into_response();
2731 }
2732 };
2733 if let Some(refused) = invalid_id::<()>(&id, &crate::violation::batch_item_path(index, "id")) {
2734 return refused.into_response();
2735 }
2736 let bytes = match serde_json::to_vec(&fields) {
2737 Ok(bytes) => bytes,
2738 Err(e) => return error_at::<()>(StatusCode::INTERNAL_SERVER_ERROR, None, &e).into_response(),
2739 };
2740 match crate::extractors::decode_json_or_refusal::<U>(&bytes, true) {
2741 Ok(data) => items.push((id, data)),
2742 Err(refusal) => return refusal.at_item(index).into_response(),
2743 }
2744 }
2745 match handler.service.bulk_update(items).await {
2746 Ok(entities) => {
2747 let items: Vec<serde_json::Value> = entities
2748 .into_iter()
2749 .map(|e| secure::<E>(to_response_value(R::from(e)), None, caller.as_ref()))
2750 .collect();
2751 let total = items.len();
2752 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2753 .into_response()
2754 }
2755 Err(e) => Self::failure::<()>(&e, StatusCode::BAD_REQUEST).into_response(),
2756 }
2757 }
2758
2759 async fn bulk_patch_handler(
2760 handler: Arc<Self>,
2761 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2762 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2763 ) -> impl axum::response::IntoResponse {
2764 use axum::{http::StatusCode, Json};
2765
2766 let caller = caller.map(|axum::Extension(c)| c);
2767 let items = req.into_items();
2768 if let Some(err) = batch_size_error(items.len()) {
2769 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2770 }
2771 if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2772 return refused;
2773 }
2774 let refused = gated_write_refusals::<E>(items.iter().flat_map(|(_, f)| f.keys().cloned()), caller.as_ref());
2775 if !refused.is_empty() {
2776 return (
2777 StatusCode::UNPROCESSABLE_ENTITY,
2778 Json(ApiResponse::<BulkResponse<serde_json::Value>>::rejected(refusal_sentence(&refused), refused)),
2779 );
2780 }
2781 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2783 .into_iter()
2784 .map(|(id, fields)| {
2785 let fields = fields
2786 .into_iter()
2787 .map(|(k, v)| (camel_to_snake_case(&k), v))
2788 .collect();
2789 (id, fields)
2790 })
2791 .collect();
2792 match handler.service.bulk_partial_update(items).await {
2793 Ok(entities) => {
2794 let items: Vec<serde_json::Value> = entities
2795 .into_iter()
2796 .map(|e| secure::<E>(to_response_value(R::from(e)), None, caller.as_ref()))
2797 .collect();
2798 let total = items.len();
2799 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2800 }
2801 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2802 }
2803 }
2804
2805 async fn bulk_patch_preview_handler(
2811 handler: Arc<Self>,
2812 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2813 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2814 ) -> impl axum::response::IntoResponse {
2815 use axum::{http::StatusCode, Json};
2816
2817 let caller = caller.map(|axum::Extension(c)| c);
2818 let items = req.into_items();
2819 if let Some(err) = batch_size_error(items.len()) {
2820 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkPreview>::error(err)));
2821 }
2822 if let Some(refused) = invalid_ids::<BulkPreview>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2823 return refused;
2824 }
2825 let refused = gated_write_refusals::<E>(items.iter().flat_map(|(_, f)| f.keys().cloned()), caller.as_ref());
2826 if !refused.is_empty() {
2827 return (
2828 StatusCode::UNPROCESSABLE_ENTITY,
2829 Json(ApiResponse::<BulkPreview>::rejected(refusal_sentence(&refused), refused)),
2830 );
2831 }
2832 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2833 .into_iter()
2834 .map(|(id, fields)| {
2835 let fields = fields
2836 .into_iter()
2837 .map(|(k, v)| (camel_to_snake_case(&k), v))
2838 .collect();
2839 (id, fields)
2840 })
2841 .collect();
2842 let patched: HashMap<String, Vec<String>> = items
2843 .iter()
2844 .map(|(id, f)| (id.clone(), f.keys().cloned().collect()))
2845 .collect();
2846 match handler.service.preview_bulk_partial_update(items).await {
2847 Ok(Some(rows)) => {
2848 let rows = rows
2849 .into_iter()
2850 .map(|row| {
2851 let outcome = match row.outcome {
2852 Ok((before, after)) => Ok((
2853 secure::<E>(to_response_value(R::from(before)), None, caller.as_ref()),
2854 secure::<E>(to_response_value(R::from(after)), None, caller.as_ref()),
2855 )),
2856 Err(e) => Err((
2857 S::violations_of(&e)
2858 .and_then(|v| v.into_iter().next().map(|v| v.code))
2859 .unwrap_or_else(|| "refused".to_string()),
2860 e.to_string(),
2861 )),
2862 };
2863 (row.id, outcome)
2864 })
2865 .collect();
2866 (StatusCode::OK, Json(ApiResponse::ok(preview_answer(rows, &patched))))
2867 }
2868 Ok(None) => (
2869 StatusCode::NOT_IMPLEMENTED,
2870 Json(ApiResponse::error("this resource has no preview of a bulk change".to_string())),
2871 ),
2872 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2873 }
2874 }
2875
2876 async fn upsert_handler(
2877 handler: Arc<Self>,
2878 crate::extractors::WriteBody(dto, keys): crate::extractors::WriteBody<C>,
2879 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2880 ) -> impl axum::response::IntoResponse {
2881 use axum::{http::StatusCode, Json};
2882
2883 let caller = caller.map(|axum::Extension(c)| c);
2884 let refused = gated_write_refusals::<E>(keys.into_iter(), caller.as_ref());
2885 if !refused.is_empty() {
2886 return (
2887 StatusCode::UNPROCESSABLE_ENTITY,
2888 Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2889 );
2890 }
2891 if !S::supports_upsert() {
2894 return (
2895 StatusCode::NOT_IMPLEMENTED,
2896 Json(ApiResponse::<serde_json::Value>::error(crate::service::UPSERT_UNSUPPORTED)),
2897 );
2898 }
2899 match handler.service.upsert(dto).await {
2900 Ok(entity) => {
2901 let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2902 (StatusCode::OK, Json(ApiResponse::ok(response)))
2903 }
2904 Err(e) => {
2905 Self::failure(&e, StatusCode::BAD_REQUEST)
2906 }
2907 }
2908 }
2909
2910 async fn list_deleted_handler(
2911 handler: Arc<Self>,
2912 ListQuery(params): ListQuery,
2913 access: Option<axum::Extension<AccessScope>>,
2914 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2915 ) -> impl axum::response::IntoResponse {
2916 use axum::{http::StatusCode, Json};
2917
2918 let caller = caller.map(|axum::Extension(c)| c);
2919 let params = params.normalized();
2920 let refused = query_refusals::<E>(¶ms, true, caller.as_ref());
2921 if !refused.is_empty() {
2922 return (StatusCode::BAD_REQUEST, Json(refused_list(refused)));
2923 }
2924
2925 let fields = sparse_fields(¶ms.filters);
2926 let includes = include_relations(¶ms.filters);
2927 let scope = access.map(|axum::Extension(s)| s);
2928
2929 let filters = repository_filters(¶ms);
2930 match handler.service.list_deleted_filtered(params.page, params.limit, filters).await {
2931 Ok((entities, total)) => {
2932 let mut rows: Vec<serde_json::Value> = entities
2936 .into_iter()
2937 .map(|e| {
2938 secure::<E>(to_response_value(R::from(e)), scope.as_ref(), caller.as_ref())
2939 })
2940 .collect();
2941 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes, caller.as_ref()).await;
2942 let items: Vec<serde_json::Value> =
2943 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2944 let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
2945 (StatusCode::OK, Json(response))
2946 }
2947 Err(e) => Self::list_failure(&e, ¶ms),
2948 }
2949 }
2950
2951 async fn restore_handler(
2952 handler: Arc<Self>,
2953 axum::extract::Path(id): axum::extract::Path<String>,
2954 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
2955 ) -> impl axum::response::IntoResponse {
2956 use axum::{http::StatusCode, Json};
2957
2958 let caller = caller.map(|axum::Extension(c)| c);
2959 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2960 return refused;
2961 }
2962
2963 match handler.service.restore(&id).await {
2964 Ok(Some(entity)) => {
2965 let response = secure::<E>(to_response_value(R::from(entity)), None, caller.as_ref());
2966 (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
2967 }
2968 Ok(None) => {
2969 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2970 }
2971 Err(e) => {
2972 Self::failure(&e, StatusCode::BAD_REQUEST)
2973 }
2974 }
2975 }
2976
2977 async fn empty_trash_handler(
2978 handler: Arc<Self>,
2979 ) -> impl axum::response::IntoResponse {
2980 use axum::{http::StatusCode, Json};
2981
2982 match handler.service.empty_trash().await {
2983 Ok(count) => {
2984 (StatusCode::OK, Json(ApiResponse::success_with_message(
2985 serde_json::json!({ "deleted_count": count }),
2986 format!("Successfully deleted {} items from trash", count)
2987 )))
2988 }
2989 Err(e) => {
2990 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2991 }
2992 }
2993 }
2994
2995 async fn get_deleted_handler(
2997 handler: Arc<Self>,
2998 axum::extract::Path(id): axum::extract::Path<String>,
2999 ListQuery(params): ListQuery,
3000 access: Option<axum::Extension<AccessScope>>,
3001 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
3002 ) -> impl axum::response::IntoResponse {
3003 use axum::{http::StatusCode, Json};
3004
3005 let caller = caller.map(|axum::Extension(c)| c);
3006 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
3007 return refused;
3008 }
3009
3010 let fields = sparse_fields(¶ms.filters);
3011 let scope = access.map(|axum::Extension(s)| s);
3012
3013 match handler.service.get_deleted_by_id(&id).await {
3014 Ok(Some(entity)) => {
3015 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref(), caller.as_ref());
3016 let value = project_sparse(secured, &fields);
3017 (StatusCode::OK, Json(ApiResponse::ok(value)))
3018 }
3019 Ok(None) => {
3020 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
3021 }
3022 Err(e) => {
3023 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
3024 }
3025 }
3026 }
3027
3028 async fn permanent_delete_handler(
3030 handler: Arc<Self>,
3031 axum::extract::Path(id): axum::extract::Path<String>,
3032 ) -> axum::response::Response {
3033 use axum::{http::StatusCode, Json, response::IntoResponse};
3034
3035 if let Some(refused) = invalid_id::<R>(&id, "id") {
3036 return refused.into_response();
3037 }
3038
3039 match handler.service.get_deleted_by_id(&id).await {
3041 Ok(Some(_)) => {
3042 match handler.service.permanent_delete(&id).await {
3044 Ok(true) => {
3045 StatusCode::NO_CONTENT.into_response()
3047 }
3048 Ok(false) => {
3049 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
3050 .into_response()
3051 }
3052 Err(e) => {
3053 Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
3054 }
3055 }
3056 }
3057 Ok(None) => {
3058 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
3059 &format!("{} in trash", S::entity_name()), &id
3060 ))).into_response()
3061 }
3062 Err(e) => {
3063 Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
3064 }
3065 }
3066 }
3067
3068 async fn count_active_handler(
3070 handler: Arc<Self>,
3071 ListQuery(params): ListQuery,
3072 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
3073 ) -> impl axum::response::IntoResponse {
3074 use axum::{http::StatusCode, Json};
3075
3076 let caller = caller.map(|axum::Extension(c)| c);
3077 let refused = query_refusals::<E>(¶ms, false, caller.as_ref());
3081 if !refused.is_empty() {
3082 return (
3083 StatusCode::BAD_REQUEST,
3084 Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
3085 );
3086 }
3087 match handler.service.count_active_filtered(repository_filters(¶ms)).await {
3088 Ok(count) => {
3089 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
3090 }
3091 Err(e) => Self::query_failure(&e, ¶ms),
3094 }
3095 }
3096
3097 async fn aggregate_handler(
3102 handler: Arc<Self>,
3103 ListQuery(params): ListQuery,
3104 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
3105 ) -> impl axum::response::IntoResponse {
3106 use axum::{http::StatusCode, Json};
3107
3108 let caller = caller.map(|axum::Extension(c)| c);
3109 let refused = query_refusals::<E>(¶ms, false, caller.as_ref());
3110 if !refused.is_empty() {
3111 return (
3112 StatusCode::BAD_REQUEST,
3113 Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
3114 );
3115 }
3116 let spec = aggregate_spec(¶ms);
3117 match handler.service.aggregate(&spec, aggregate_filters(¶ms)).await {
3118 Ok(result) => {
3119 let render = |g: &backbone_orm::repository::AggregateGroup| {
3120 let mut out = serde_json::Map::new();
3124 out.insert("key".into(), match &g.key {
3125 Some(k) => serde_json::Value::String(k.clone()),
3126 None => serde_json::Value::Null,
3127 });
3128 if g.label.is_some() {
3129 out.insert(
3130 "label".into(),
3131 serde_json::Value::String(g.label.clone().unwrap()),
3132 );
3133 }
3134 out.insert("count".into(), serde_json::json!(g.count));
3135 for (compound, value) in &g.values {
3136 let Some((func, field)) = compound.split_once(':') else { continue };
3137 let slot = out
3138 .entry(func.to_string())
3139 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
3140 if let Some(obj) = slot.as_object_mut() {
3141 obj.insert(field.to_string(), match value {
3142 Some(v) => serde_json::Value::String(v.clone()),
3146 None => serde_json::Value::Null,
3147 });
3148 }
3149 }
3150 serde_json::Value::Object(out)
3151 };
3152
3153 let body = serde_json::json!({
3154 "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
3155 "total": render(&result.total),
3156 "truncated": result.truncated,
3159 });
3160 (StatusCode::OK, Json(ApiResponse::ok(body)))
3161 }
3162 Err(e) => Self::query_failure(&e, ¶ms),
3163 }
3164 }
3165
3166 async fn history_handler(
3177 handler: Arc<Self>,
3178 axum::extract::Path(id): axum::extract::Path<String>,
3179 ListQuery(params): ListQuery,
3180 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
3181 caller: Option<axum::Extension<backbone_auth::middleware::AuthContext>>,
3182 ) -> impl axum::response::IntoResponse {
3183 use axum::{http::StatusCode, Json};
3184
3185 let caller = caller.map(|axum::Extension(c)| c);
3186 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
3187 return refused;
3188 }
3189
3190 let Some(axum::Extension(provider)) = provider else {
3191 return (
3192 StatusCode::NOT_IMPLEMENTED,
3193 Json(ApiResponse::<serde_json::Value>::error(
3194 "history is not configured for this service".to_string(),
3195 )),
3196 );
3197 };
3198
3199 let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
3203 return (
3204 StatusCode::NOT_IMPLEMENTED,
3205 Json(ApiResponse::<serde_json::Value>::error(
3206 "this entity cannot name its table, so its history cannot be keyed".to_string(),
3207 )),
3208 );
3209 };
3210
3211 let limit = params.limit.clamp(1, 200);
3212 let offset = params.page.saturating_sub(1) * limit;
3213
3214 match provider.history(&table, &id, limit, offset).await {
3215 Ok(Some(entries)) => {
3216 let mut body = serde_json::json!({ "audited": true, "entries": entries });
3217 let gated = E::field_permissions();
3222 if !gated.is_empty() {
3223 redact_gated_deep(&mut body, gated, caller.as_ref());
3224 }
3225 (StatusCode::OK, Json(ApiResponse::ok(body)))
3226 }
3227 Ok(None) => (
3228 StatusCode::OK,
3229 Json(ApiResponse::ok(serde_json::json!({
3230 "audited": false,
3233 "entries": serde_json::Value::Null,
3234 }))),
3235 ),
3236 Err(e) => error_at(StatusCode::INTERNAL_SERVER_ERROR, None, &e),
3237 }
3238 }
3239
3240 async fn count_deleted_handler(
3242 handler: Arc<Self>,
3243 ) -> impl axum::response::IntoResponse {
3244 use axum::{http::StatusCode, Json};
3245
3246 match handler.service.count_deleted().await {
3247 Ok(count) => {
3248 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
3249 }
3250 Err(e) => {
3251 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
3252 }
3253 }
3254 }
3255}
3256
3257pub trait BackboneHttpHandler<T>: Send + Sync {
3282 fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
3284
3285 fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
3287
3288 fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
3290
3291 fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
3293
3294 fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
3296
3297 fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
3299
3300 fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
3302
3303 fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
3305
3306 fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
3308
3309 fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
3311
3312 fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
3314
3315 fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
3317}
3318
3319#[derive(Debug, Serialize, Deserialize)]
3325#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
3326pub struct PaginationRequest {
3327 pub page: u32,
3328 pub limit: u32,
3329 pub sort_by: Option<String>,
3330 pub sort_order: Option<String>,
3331}
3332
3333fn camel_to_snake_case(key: &str) -> String {
3349 let chars: Vec<char> = key.chars().collect();
3350 let mut result = String::with_capacity(key.len() + 2);
3351 for (i, &c) in chars.iter().enumerate() {
3352 if c.is_ascii_uppercase() {
3353 let prev = if i > 0 { chars[i - 1] } else { '\0' };
3354 let next = chars.get(i + 1).copied().unwrap_or('\0');
3355 let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
3359 let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
3360 if (crosses_lower || crosses_acronym)
3361 && !result.is_empty()
3362 && !result.ends_with('_')
3363 {
3364 result.push('_');
3365 }
3366 result.push(c.to_ascii_lowercase());
3367 } else {
3368 result.push(c);
3369 }
3370 }
3371 result
3372}
3373
3374#[cfg(test)]
3375mod tests {
3376 use super::*;
3377
3378 #[test]
3379 fn snake_case_input_passes_through_unchanged() {
3380 assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
3381 assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
3382 assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
3383 }
3384
3385 #[test]
3386 fn single_word_unchanged() {
3387 assert_eq!(camel_to_snake_case("name"), "name");
3388 assert_eq!(camel_to_snake_case("id"), "id");
3389 assert_eq!(camel_to_snake_case(""), "");
3390 }
3391
3392 #[test]
3393 fn camel_case_converts() {
3394 assert_eq!(camel_to_snake_case("isVip"), "is_vip");
3395 assert_eq!(camel_to_snake_case("userId"), "user_id");
3396 assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
3397 assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
3398 }
3399
3400 #[test]
3401 fn pascal_case_converts() {
3402 assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
3403 assert_eq!(camel_to_snake_case("UserId"), "user_id");
3404 }
3405
3406 #[test]
3407 fn acronym_runs_stay_together() {
3408 assert_eq!(camel_to_snake_case("IOError"), "io_error");
3411 assert_eq!(camel_to_snake_case("httpURL"), "http_url");
3412 assert_eq!(camel_to_snake_case("ABC"), "abc");
3413 assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
3414 }
3415
3416 #[test]
3417 fn digits_count_as_lowercase_for_boundary() {
3418 assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
3419 assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
3420 }
3421
3422 #[test]
3423 fn underscores_not_doubled() {
3424 assert_eq!(camel_to_snake_case("_Foo"), "_foo");
3425 assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
3426 }
3427
3428 #[test]
3429 fn shallow_pages_are_allowed() {
3430 assert!(pagination_depth_error(1, 100).is_none());
3432 assert!(pagination_depth_error(101, 100).is_none());
3434 }
3435
3436 #[test]
3437 fn pages_past_the_cap_are_rejected() {
3438 assert!(pagination_depth_error(102, 100).is_some());
3440 assert!(pagination_depth_error(1002, 10).is_some());
3442 }
3443
3444 #[test]
3445 fn oversized_page_size_is_clamped_before_the_check() {
3446 assert!(pagination_depth_error(102, 200).is_some());
3448 assert!(pagination_depth_error(101, 200).is_none());
3450 }
3451
3452 #[test]
3453 fn huge_page_number_does_not_overflow() {
3454 assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
3456 }
3457
3458 #[test]
3461 fn batch_size_within_limit_is_allowed() {
3462 assert!(batch_size_error(0).is_none());
3463 assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
3464 }
3465
3466 #[test]
3467 fn batch_size_over_limit_is_rejected() {
3468 assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
3469 }
3470
3471 #[test]
3472 fn a_bulk_preview_sorts_rows_by_what_the_patch_would_do() {
3473 use serde_json::json;
3474 let mut patched: HashMap<String, Vec<String>> = ["a", "b", "c"]
3475 .iter()
3476 .map(|id| (id.to_string(), vec!["category".to_string()]))
3477 .collect();
3478 patched.insert("d".into(), vec!["category_id".into()]);
3479 let out = preview_answer(
3480 vec![
3481 (
3482 "a".into(),
3483 Ok((
3484 json!({"category": "meal", "updated_at": "1"}),
3485 json!({"category": "travel", "updated_at": "2"}),
3486 )),
3487 ),
3488 ("b".into(), Ok((json!({"category": "travel"}), json!({"category": "travel"})))),
3489 (
3490 "d".into(),
3491 Ok((json!({"categoryId": "x"}), json!({"categoryId": "y"}))),
3492 ),
3493 ("c".into(), Err(("locked".into(), "a posted claim is locked".into()))),
3494 ],
3495 &patched,
3496 );
3497 assert_eq!(out.total, 4);
3498 assert_eq!(out.will_change.len(), 2);
3499 assert_eq!(out.will_change[1].changes[0].field, "category_id", "a camelCase response is read");
3500 let ch = &out.will_change[0].changes;
3501 assert_eq!(ch.len(), 1, "only the patched field is reported, not the timestamp");
3502 assert_eq!((ch[0].from.clone(), ch[0].to.clone()), (json!("meal"), json!("travel")));
3503 assert_eq!(out.unchanged, vec!["b".to_string()]);
3504 assert_eq!(out.refused[0].code, "locked");
3505 }
3506
3507 #[test]
3508 fn bulk_patch_request_parses_shared_shape() {
3509 let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
3510 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
3511 let items = req.into_items();
3512 assert_eq!(items.len(), 2);
3513 assert_eq!(items[0].1.get("status").unwrap(), "void");
3515 assert_eq!(items[1].1.get("status").unwrap(), "void");
3516 assert_eq!(items[0].0, "a");
3517 assert_eq!(items[1].0, "b");
3518 }
3519
3520 #[test]
3521 fn bulk_patch_request_parses_per_item_shape() {
3522 let json = r#"{ "items": [
3523 { "id": "a", "patch": { "status": "void" } },
3524 { "id": "b", "patch": { "note": "late" } }
3525 ] }"#;
3526 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
3527 let items = req.into_items();
3528 assert_eq!(items.len(), 2);
3529 assert_eq!(items[0].0, "a");
3530 assert_eq!(items[0].1.get("status").unwrap(), "void");
3531 assert_eq!(items[1].0, "b");
3532 assert_eq!(items[1].1.get("note").unwrap(), "late");
3533 }
3534
3535 #[test]
3536 fn batch_ids_request_parses() {
3537 let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
3538 assert_eq!(req.ids, vec!["x", "y", "z"]);
3539 }
3540
3541 fn fields(q: &[(&str, &str)]) -> Vec<String> {
3544 let map: HashMap<String, String> =
3545 q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
3546 sparse_fields(&map)
3547 }
3548
3549 #[test]
3550 fn sparse_fields_parses_comma_list_and_trims() {
3551 assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
3552 }
3553
3554 #[test]
3555 fn sparse_fields_absent_or_empty_is_no_projection() {
3556 assert!(fields(&[]).is_empty());
3557 assert!(fields(&[("fields", "")]).is_empty());
3558 assert!(fields(&[("fields", " , ")]).is_empty());
3559 }
3560
3561 #[test]
3562 fn project_keeps_requested_keys_plus_id() {
3563 let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
3564 let out = project_sparse(v, &["name".into(), "basePrice".into()]);
3565 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
3566 }
3567
3568 #[test]
3569 fn project_always_includes_id_even_if_not_requested() {
3570 let v = serde_json::json!({ "id": "1", "name": "n" });
3571 let out = project_sparse(v, &["name".into()]);
3572 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
3573 }
3574
3575 #[test]
3576 fn project_ignores_unknown_keys() {
3577 let v = serde_json::json!({ "id": "1", "name": "n" });
3578 let out = project_sparse(v, &["name".into(), "nope".into()]);
3579 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
3580 }
3581
3582 #[test]
3583 fn project_empty_fields_returns_full_object() {
3584 let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
3585 let out = project_sparse(v.clone(), &[]);
3586 assert_eq!(out, v);
3587 }
3588
3589 #[test]
3590 fn project_non_object_returned_unchanged() {
3591 let v = serde_json::json!("scalar");
3592 assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
3593 }
3594
3595 fn owner_a() -> uuid::Uuid {
3600 uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
3601 }
3602 fn owner_b() -> uuid::Uuid {
3603 uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
3604 }
3605
3606 fn row() -> serde_json::Value {
3607 serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
3608 }
3609 const PRIV: &[&str] = &["hppPerUnit"];
3610
3611 struct Secretive;
3613 impl backbone_orm::EntityRepoMeta for Secretive {
3614 fn column_types() -> HashMap<String, String> {
3615 HashMap::new()
3616 }
3617 fn search_fields() -> &'static [&'static str] {
3618 &[]
3619 }
3620 fn secret_fields() -> &'static [&'static str] {
3621 &["tokenHash"]
3622 }
3623 fn private_fields() -> &'static [&'static str] {
3624 PRIV
3625 }
3626 fn owner_field() -> Option<&'static str> {
3627 Some("providerId")
3628 }
3629 fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
3630 match relation {
3631 "user" => &["passwordHash"],
3632 _ => &[],
3633 }
3634 }
3635 fn relations() -> &'static [(&'static str, &'static str, &'static str)] {
3636 &[("user", "users", "userId")]
3637 }
3638 }
3639
3640 fn secret_row() -> serde_json::Value {
3641 let mut r = row();
3642 r["tokenHash"] = serde_json::json!("digest");
3643 r
3644 }
3645
3646 #[test]
3647 fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
3648 for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
3649 let out = secure::<Secretive>(secret_row(), scope.as_ref(), None);
3650 assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
3651 assert!(out.get("name").is_some());
3652 }
3653 let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform), None);
3654 assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
3655 }
3656
3657 #[test]
3658 fn the_typed_sort_pair_reaches_the_repository_as_the_filter_sort() {
3659 let params = |pairs: &[(&str, &str)], by: Option<&str>, order: Option<&str>| ListQueryParams {
3660 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3661 sort_by: by.map(str::to_string),
3662 sort_order: order.map(str::to_string),
3663 ..Default::default()
3664 };
3665 let f = repository_filters(¶ms(&[], Some("priority"), Some("DESC")));
3666 assert_eq!(f.get("orderby").map(String::as_str), Some("priority:desc"));
3667 let f = repository_filters(¶ms(&[], Some("createdAt"), None));
3668 assert_eq!(f.get("orderby").map(String::as_str), Some("createdAt:asc"));
3669 let f = repository_filters(¶ms(&[("orderby[name]", "asc")], Some("priority"), Some("desc")));
3671 assert!(!f.contains_key("orderby"), "{f:?}");
3672 let refused = backbone_orm::filter::parse_filters(
3675 &repository_filters(¶ms(&[], Some("name; drop table x"), None)),
3676 &HashMap::new(),
3677 None,
3678 )
3679 .unwrap_err()
3680 .downcast::<backbone_orm::QueryParamError>()
3681 .expect("a named refusal");
3682 assert_eq!(refused.parameter, "orderby");
3683 let said = rename_typed_sort(
3684 vec![query_violation(&refused.parameter, refused.reason, refused.message)],
3685 ¶ms(&[], Some("name; drop table x"), None),
3686 );
3687 assert_eq!(said[0].path, "sort_by");
3688 assert_eq!(said[0].params["parameter"], "sort_by");
3689 assert!(said[0].message.contains("`sort_by`") && !said[0].message.contains("orderby"), "{}", said[0].message);
3690 let kept = rename_typed_sort(
3692 vec![query_violation("orderby", refused.reason, "`orderby` x")],
3693 ¶ms(&[("orderby", "name")], Some("priority"), None),
3694 );
3695 assert_eq!(kept[0].path, "orderby");
3696 }
3697
3698 fn ask(pairs: &[(&str, &str)]) -> ListQueryParams {
3699 ListQueryParams {
3700 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3701 ..Default::default()
3702 }
3703 }
3704
3705 #[test]
3706 fn a_paging_value_that_is_not_a_number_is_refused_on_its_own_parameter() {
3707 for (raw, parameter) in [("page=abc", "page"), ("limit=-3", "limit"), ("page=2&limit=x1", "limit"), ("page=", "page")] {
3708 assert!(serde_urlencoded::from_str::<ListQueryParams>(raw).is_err(), "{raw}");
3709 let v = list_query_violation(raw, "decode");
3710 assert_eq!((v.path.as_str(), v.code.as_str()), (parameter, "invalid_query"), "{raw}");
3711 assert_eq!(v.params["parameter"], parameter);
3712 assert_eq!(v.params["reason"], "not_a_number");
3713 assert!(v.message.contains(parameter), "{}", v.message);
3714 }
3715 }
3716
3717 #[test]
3718 fn a_typed_parameter_sent_twice_is_refused_by_name() {
3719 let raw = "sort_by=a&sort_by=b";
3720 assert!(serde_urlencoded::from_str::<ListQueryParams>(raw).is_err());
3721 let v = list_query_violation(raw, "decode");
3722 assert_eq!((v.path.as_str(), v.params["reason"].as_str()), ("sort_by", Some("invalid_value")));
3723 }
3724
3725 #[test]
3726 fn the_depth_secret_sort_order_and_shaping_refusals_each_name_their_parameter() {
3727 let at = |params: &ListQueryParams, paged: bool| {
3728 query_refusals::<Secretive>(params, paged, None)
3729 .into_iter()
3730 .map(|v| (v.path, v.params["reason"].as_str().unwrap_or("").to_string()))
3731 .collect::<Vec<_>>()
3732 };
3733 let deep = ListQueryParams { page: 500, limit: 100, ..Default::default() };
3734 assert_eq!(at(&deep, true), vec![("page".to_string(), "too_deep".to_string())]);
3735 assert!(at(&deep, false).is_empty(), "count and aggregate do not page");
3736 let mut p = ask(&[]);
3737 p.sort_order = Some("sideways".into());
3738 assert_eq!(at(&p, true), vec![("sort_order".to_string(), "invalid_value".to_string())]);
3739 p.sort_order = Some("DESC".into());
3740 assert!(at(&p, true).is_empty());
3741 let mut p = ask(&[]);
3742 p.sort_by = Some("tokenHash".into());
3743 let named = query_refusals::<Secretive>(&p, true, None);
3744 assert_eq!(named[0].path, "sort_by");
3745 assert!(named[0].message.contains("sort_by") && named[0].message.contains("secret"), "{}", named[0].message);
3746 assert_eq!(at(&ask(&[("token_hash[startwith]", "a")]), true)[0].0, "token_hash[startwith]");
3747 assert_eq!(at(&ask(&[("include", "user,nothing")]), true), vec![("include".to_string(), "unknown_relation".to_string())]);
3748 assert_eq!(at(&ask(&[("with", "nothing")]), true), vec![("with".to_string(), "unknown_relation".to_string())]);
3749 assert!(at(&ask(&[("include", "user")]), true).is_empty());
3750 assert_eq!(at(&ask(&[("fields", "id,na me")]), true), vec![("fields".to_string(), "invalid_field_name".to_string())]);
3751 assert!(at(&ask(&[("fields", "id,someUnknownButWellFormed")]), true).is_empty(), "an unknown field is still ignored");
3752 }
3753
3754 #[test]
3755 fn a_refused_query_parameter_answers_400_not_422() {
3756 use crate::persistence::traits::RepositoryError;
3757 use crate::service::ServiceError;
3758 let named = vec![crate::violation::Violation::invalid_query("page", "not_a_number", "x")];
3759 let broken = vec![crate::violation::Violation::new("name", "max_length", "x")];
3760 assert_eq!(service_error_status(&ServiceError::Repository(RepositoryError::Violations(named.clone()))), axum::http::StatusCode::BAD_REQUEST);
3761 assert_eq!(service_error_status(&ServiceError::Violations(named)), axum::http::StatusCode::BAD_REQUEST);
3762 assert_eq!(service_error_status(&ServiceError::Violations(broken.clone())), axum::http::StatusCode::UNPROCESSABLE_ENTITY);
3763 let mixed = vec![crate::violation::Violation::invalid_query("page", "x", "x"), broken[0].clone()];
3765 assert!(!is_query_refusal(&mixed));
3766 assert!(!is_query_refusal(&[]));
3767 }
3768
3769 #[test]
3770 fn a_named_refusal_from_the_repository_becomes_an_invalid_query_violation() {
3771 use crate::persistence::traits::RepositoryError;
3772 let refused = backbone_orm::QueryParamError::unknown_column("sort_by", "full_name");
3773 let err = RepositoryError::from_source(anyhow::Error::from(refused).context("while listing"));
3774 let RepositoryError::Violations(v) = err else { panic!("{err:?}") };
3775 assert_eq!(v.len(), 1);
3776 assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("sort_by", "invalid_query"));
3777 assert_eq!(v[0].params["parameter"], "sort_by");
3778 assert_eq!(v[0].params["reason"], "unknown_column");
3779 assert!(v[0].message.contains("sort_by") && v[0].message.contains("full_name"));
3780 }
3781
3782 #[test]
3783 fn list_params_normalize_page_zero_and_an_oversized_limit() {
3784 let p = ListQueryParams { page: 0, limit: 1000, ..Default::default() }.normalized();
3785 assert_eq!((p.page, p.limit), (1, MAX_PER_PAGE));
3786 let p = ListQueryParams { page: 3, limit: 0, ..Default::default() }.normalized();
3787 assert_eq!((p.page, p.limit), (3, 1));
3788 }
3789
3790 #[test]
3791 fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
3792 let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
3793 let params = ListQueryParams {
3794 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3795 sort_by: sort_by.map(str::to_string),
3796 ..Default::default()
3797 };
3798 named_secret_at(¶ms, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
3799 };
3800 for pairs in [
3801 vec![("token_hash", "abc")],
3802 vec![("tokenHash[startwith]", "a")],
3803 vec![("orderby", "name,-token_hash")],
3804 vec![("orderby[token_hash]", "asc")],
3805 vec![("searchFields", "name,tokenHash")],
3806 vec![("min", "token_hash")],
3807 vec![("group_by", "tokenHash")],
3808 ] {
3809 let (parameter, field) = ask(&pairs, None).expect("named");
3810 assert_eq!(camel_to_snake_case(&field), "token_hash", "{pairs:?}");
3811 assert_eq!(parameter, pairs[0].0, "the refusal names the parameter that carried it");
3812 }
3813 assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
3814 assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
3815 }
3816
3817 #[test]
3818 fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
3819 let out = secure::<Secretive>(secret_row(), None, None);
3820 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3821 }
3822
3823 #[test]
3824 fn a_routed_response_dto_loses_the_entitys_secrets() {
3825 let out = without_secrets::<Secretive, _>(secret_row());
3826 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3827 assert!(out.get("name").is_some());
3828 }
3829
3830 #[test]
3831 fn an_included_row_loses_the_related_models_secrets() {
3832 let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
3833 let out = related_row::<Secretive>("user", "users", raw.clone(), None);
3834 assert!(out.get("passwordHash").is_none(), "{out}");
3835 assert_eq!(out["email"], "a@b.c");
3836 assert!(related_row::<Secretive>("other", "others", raw.clone(), None).get("passwordHash").is_some(), "only the named relation");
3837 backbone_orm::secret_registry::register("employee_probe.staff", &["passwordHash"]);
3839 assert!(related_row::<Secretive>("staff", "employee_probe.staff", raw, None).get("passwordHash").is_none(), "registered table");
3840 }
3841
3842 struct Gated;
3846 impl backbone_orm::EntityRepoMeta for Gated {
3847 fn column_types() -> HashMap<String, String> {
3848 HashMap::new()
3849 }
3850 fn search_fields() -> &'static [&'static str] {
3851 &[]
3852 }
3853 fn field_permissions() -> &'static [(&'static str, &'static str)] {
3854 &[("name", "widget:see_name")]
3855 }
3856 }
3857
3858 fn gated_row() -> serde_json::Value {
3859 serde_json::json!({ "id": "w1", "name": "Hush", "hppPerUnit": 3 })
3860 }
3861
3862 fn caller_holding(perms: &[&str]) -> backbone_auth::middleware::AuthContext {
3863 backbone_auth::middleware::AuthContext {
3864 user_id: "u".into(),
3865 roles: vec![],
3866 permissions: perms.iter().map(|p| p.to_string()).collect(),
3867 }
3868 }
3869
3870 #[test]
3871 fn a_gated_field_is_redacted_to_null_for_a_non_holder_and_kept_for_a_holder() {
3872 let none = secure::<Gated>(gated_row(), None, None);
3873 assert_eq!(none.get("name"), Some(&serde_json::Value::Null), "redacted, not omitted: {none}");
3874 assert_eq!(none.get("hppPerUnit"), Some(&serde_json::json!(3)), "only the gated field");
3875
3876 let holder = caller_holding(&["widget:see_name"]);
3877 assert_eq!(secure::<Gated>(gated_row(), None, Some(&holder)).get("name"), Some(&serde_json::json!("Hush")));
3878
3879 let god = caller_holding(&["*:*"]);
3880 assert_eq!(secure::<Gated>(gated_row(), None, Some(&god)).get("name"), Some(&serde_json::json!("Hush")), "*:* sees everything");
3881
3882 assert_eq!(secure::<Gated>(gated_row(), None, Some(&caller_holding(&[]))).get("name"), Some(&serde_json::Value::Null));
3884 assert_eq!(without_secrets::<Gated, _>(gated_row()).get("name"), Some(&serde_json::Value::Null));
3886 assert_eq!(without_secrets_for::<Gated, _>(&holder, gated_row()).get("name"), Some(&serde_json::json!("Hush")));
3887 }
3888
3889 #[test]
3890 fn a_gated_field_may_not_be_a_column_of_a_non_holders_query() {
3891 let holder = caller_holding(&["widget:see_name"]);
3892 for (pairs, parameter) in [
3893 (vec![("name", "Hush")], "name"),
3894 (vec![("min", "name")], "min"),
3895 (vec![("group_by", "name")], "group_by"),
3896 (vec![("orderby", "name")], "orderby"),
3897 ] {
3898 let params = ListQueryParams {
3899 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3900 ..Default::default()
3901 };
3902 let refused = query_refusals::<Gated>(¶ms, false, None);
3903 assert_eq!((refused[0].path.as_str(), refused[0].params["reason"].as_str()), (parameter, Some("field_permission")), "{pairs:?}");
3904 assert_eq!(refused[0].params["permission"], "widget:see_name", "{pairs:?}");
3905 assert_eq!(refused[0].params["field"], "name", "{pairs:?}");
3906 assert!(query_refusals::<Gated>(¶ms, false, Some(&holder)).is_empty(), "a holder asks: {pairs:?}");
3907 }
3908 let mut p = ask(&[]);
3909 p.sort_by = Some("name".into());
3910 let refused = query_refusals::<Gated>(&p, true, None);
3911 assert_eq!((refused[0].path.as_str(), refused[0].params["reason"].as_str()), ("sort_by", Some("field_permission")));
3912 }
3913
3914 #[test]
3915 fn a_write_that_names_a_gated_field_is_refused_for_a_non_holder_in_either_casing() {
3916 let holder = caller_holding(&["widget:see_name"]);
3917 for key in ["name", "Name", "NAME"] {
3918 let refused = gated_write_refusals::<Gated>(std::iter::once(key.to_string()), None);
3919 assert_eq!(refused.len(), 1, "{key}");
3920 assert_eq!((refused[0].path.as_str(), refused[0].code.as_str()), (key, "field_permission_required"), "{key}");
3921 assert_eq!(refused[0].params["permission"], "widget:see_name");
3922 assert!(gated_write_refusals::<Gated>(std::iter::once(key.to_string()), Some(&holder)).is_empty(), "a holder sets it: {key}");
3923 }
3924 assert!(gated_write_refusals::<Gated>(std::iter::once("hppPerUnit".to_string()), None).is_empty(), "an ungated field is free to set");
3925 assert!(gated_write_refusals::<Gated>(Vec::<String>::new().into_iter(), None).is_empty());
3926 }
3927
3928 #[test]
3929 fn an_included_row_redacts_the_gated_fields_the_target_registered() {
3930 let raw = serde_json::json!({ "id": "u1", "name": "Hush", "email": "a@b.c" });
3931 backbone_orm::field_permission_registry::register("gated_probe.widgets", &[("name", "widget:see_name")]);
3932 let out = related_row::<Gated>("probe", "gated_probe.widgets", raw.clone(), None);
3933 assert_eq!(out.get("name"), Some(&serde_json::Value::Null), "redacted by the table's own gate: {out}");
3934 assert_eq!(out["email"], "a@b.c");
3935 let holder = caller_holding(&["widget:see_name"]);
3936 let kept = related_row::<Gated>("probe", "gated_probe.widgets", raw, Some(&holder));
3937 assert_eq!(kept.get("name"), Some(&serde_json::json!("Hush")));
3938 }
3939
3940 #[test]
3941 fn security_no_private_fields_is_noop() {
3942 let v = row();
3943 assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
3944 }
3945
3946 #[test]
3947 fn security_platform_sees_private() {
3948 let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
3949 assert!(out.get("hppPerUnit").is_some());
3950 }
3951
3952 #[test]
3953 fn security_owner_tenant_sees_private() {
3954 let out = apply_field_security(
3955 row(),
3956 Some(&AccessScope::Company(owner_a())),
3957 PRIV,
3958 Some("providerId"),
3959 );
3960 assert!(out.get("hppPerUnit").is_some());
3961 }
3962
3963 #[test]
3964 fn security_other_tenant_stripped() {
3965 let out = apply_field_security(
3966 row(),
3967 Some(&AccessScope::Company(owner_b())),
3968 PRIV,
3969 Some("providerId"),
3970 );
3971 assert!(out.get("hppPerUnit").is_none());
3972 assert!(out.get("name").is_some());
3973 }
3974
3975 #[test]
3976 fn security_absent_scope_fails_closed() {
3977 let out = apply_field_security(row(), None, PRIV, Some("providerId"));
3978 assert!(out.get("hppPerUnit").is_none());
3979 }
3980
3981 #[test]
3984 fn include_relations_parses_include_and_with() {
3985 let mut q = HashMap::new();
3986 q.insert("include".to_string(), "provider, outlet ".to_string());
3987 assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
3988 let mut q2 = HashMap::new();
3989 q2.insert("with".to_string(), "category".to_string());
3990 assert_eq!(include_relations(&q2), vec!["category"]);
3991 assert!(include_relations(&HashMap::new()).is_empty());
3992 }
3993
3994 #[test]
3995 fn snake_to_camel_converts() {
3996 assert_eq!(snake_to_camel("provider_id"), "providerId");
3997 assert_eq!(snake_to_camel("business_name"), "businessName");
3998 assert_eq!(snake_to_camel("id"), "id");
3999 }
4000
4001 #[test]
4002 fn camelize_keys_top_level_only() {
4003 let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
4004 let out = camelize_keys(v);
4005 assert!(out.get("providerId").is_some());
4006 assert!(out.get("metaData").unwrap().get("created_at").is_some());
4008 }
4009
4010 #[test]
4011 fn security_null_owner_only_platform_sees() {
4012 let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
4013 let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
4014 assert!(tenant.get("hppPerUnit").is_none());
4015 let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
4016 assert!(plat.get("hppPerUnit").is_some());
4017 }
4018
4019 #[test]
4020 fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
4021 let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
4022 let (status, axum::Json(body)) =
4023 write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
4024 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
4025 let json = serde_json::to_value(&body).unwrap();
4026 assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
4027 assert_eq!(json["violations"][0]["path"], "status");
4028 assert_eq!(json["violations"][0]["code"], "field_not_writable");
4029
4030 let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
4032 assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
4033 let json = serde_json::to_value(&body).unwrap();
4034 assert_eq!(json["error"], "boom");
4035 assert_eq!(json["violations"][0]["code"], "invalid_request");
4036 assert_eq!(json["violations"][0]["path"], "");
4037 let (_, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::INTERNAL_SERVER_ERROR);
4039 assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
4040 }
4041}
4042
4043#[cfg(test)]
4044mod internal_key_tests {
4045 use super::*;
4046
4047 fn params_with(filters: &[(&str, &str)]) -> ListQueryParams {
4048 ListQueryParams {
4049 page: 1,
4050 limit: 20,
4051 sort_by: None,
4052 sort_order: None,
4053 search: None,
4054 status: None,
4055 filters: filters
4056 .iter()
4057 .map(|(k, v)| (k.to_string(), v.to_string()))
4058 .collect(),
4059 }
4060 }
4061
4062 #[test]
4063 fn internal_keys_never_reach_the_repository() {
4064 let params = params_with(&[
4065 ("__base_condition", "EXISTS(SELECT 1)"),
4066 ("__base_condition[eq]", "1=1"),
4067 ("__anything", "x"),
4068 ("name", "acme"),
4069 ]);
4070 let filters = repository_filters(¶ms);
4071 assert_eq!(filters.len(), 1, "{filters:?}");
4072 assert_eq!(filters.get("name").map(String::as_str), Some("acme"));
4073 let filters = aggregate_filters(¶ms);
4074 assert!(filters.keys().all(|k| !k.starts_with("__")), "{filters:?}");
4075 }
4076}
4077
4078#[cfg(test)]
4079mod service_error_response_tests {
4080 use super::*;
4081 use crate::service::ServiceError;
4082
4083 #[test]
4084 fn a_refused_field_is_422_with_its_violation_never_500() {
4085 let err = ServiceError::Violations(vec![crate::violation::Violation::new(
4086 "state",
4087 "field_not_writable",
4088 "state cannot be changed by a generic write",
4089 )]);
4090 let (status, body) = service_error_response::<()>(&err);
4091 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
4092 assert_eq!(body.0.violations.as_ref().map(Vec::len), Some(1));
4093 }
4094
4095 #[test]
4096 fn other_errors_keep_their_own_status() {
4097 use axum::http::StatusCode;
4098 let status = |e: ServiceError| service_error_response::<()>(&e).0;
4099 assert_eq!(
4100 status(ServiceError::Validation("bad".into())),
4101 StatusCode::BAD_REQUEST
4102 );
4103 assert_eq!(status(ServiceError::NotFound), StatusCode::NOT_FOUND);
4104 assert_eq!(
4105 status(ServiceError::AlreadyExists("x".into())),
4106 StatusCode::CONFLICT
4107 );
4108 assert_eq!(
4109 status(ServiceError::Internal("boom".into())),
4110 StatusCode::INTERNAL_SERVER_ERROR
4111 );
4112 }
4113}