1use crate::extractors::{JsonOrForm, StrictJsonOrForm};
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 {
192 use crate::persistence::traits::RepositoryError;
193 use crate::service::ServiceError;
194 use axum::http::StatusCode;
195 match err {
196 ServiceError::Violations(_) | ServiceError::Repository(RepositoryError::Violations(_)) => {
197 StatusCode::UNPROCESSABLE_ENTITY
198 }
199 ServiceError::Validation(_)
200 | ServiceError::Repository(RepositoryError::ValidationError(_)) => StatusCode::BAD_REQUEST,
201 ServiceError::NotFound | ServiceError::Repository(RepositoryError::NotFound) => {
202 StatusCode::NOT_FOUND
203 }
204 ServiceError::AlreadyExists(_)
205 | ServiceError::Repository(RepositoryError::AlreadyExists(_))
206 | ServiceError::Repository(RepositoryError::Conflict(_)) => StatusCode::CONFLICT,
207 _ => StatusCode::INTERNAL_SERVER_ERROR,
208 }
209}
210
211pub fn service_error_response<T>(
216 err: &crate::service::ServiceError,
217) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
218 use crate::persistence::traits::RepositoryError;
219 use crate::service::ServiceError;
220 let violations = match err {
221 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => {
222 Some(v.clone())
223 }
224 _ => None,
225 };
226 error_at(service_error_status(err), violations, err)
227}
228
229#[cfg(test)]
232fn write_error<T>(
233 violations: Option<Vec<crate::violation::Violation>>,
234 err: &impl std::fmt::Display,
235 fallback: axum::http::StatusCode,
236) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
237 let status = if violations.is_some() { axum::http::StatusCode::UNPROCESSABLE_ENTITY } else { fallback };
238 error_at(status, violations, err)
239}
240
241fn invalid_id<T>(id: &str, path: &str) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
245 if uuid::Uuid::parse_str(id).is_ok() {
246 return None;
247 }
248 let violation = crate::violation::Violation::new(
249 path,
250 "invalid_id",
251 format!("`{id}` is not a valid record id"),
252 );
253 Some((
254 axum::http::StatusCode::BAD_REQUEST,
255 axum::Json(ApiResponse::rejected(format!("invalid id: `{id}` is not a valid record id"), vec![violation])),
256 ))
257}
258
259fn invalid_ids<'a, T>(
262 ids: impl IntoIterator<Item = &'a str>,
263 path_of: impl Fn(usize) -> String,
264) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
265 let violations: Vec<crate::violation::Violation> = ids
266 .into_iter()
267 .enumerate()
268 .filter(|(_, id)| uuid::Uuid::parse_str(id).is_err())
269 .map(|(i, id)| {
270 crate::violation::Violation::new(path_of(i), "invalid_id", format!("`{id}` is not a valid record id"))
271 })
272 .collect();
273 if violations.is_empty() {
274 return None;
275 }
276 Some((
277 axum::http::StatusCode::BAD_REQUEST,
278 axum::Json(ApiResponse::rejected("invalid id in the batch", violations)),
279 ))
280}
281
282#[derive(Debug, Deserialize, Default, Clone)]
284#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
285pub struct ListQueryParams {
286 #[serde(default = "default_page")]
287 pub page: u32,
288 #[serde(default = "default_limit")]
289 pub limit: u32,
290 #[serde(default)]
291 pub sort_by: Option<String>,
292 #[serde(default)]
293 pub sort_order: Option<String>,
294 #[serde(default)]
295 pub search: Option<String>,
296 #[serde(default)]
297 pub status: Option<String>,
298 #[serde(flatten)]
299 pub filters: HashMap<String, String>,
300}
301
302fn default_page() -> u32 { 1 }
303fn default_limit() -> u32 { 20 }
304
305impl ListQueryParams {
306 pub fn normalized(mut self) -> Self {
310 let page = backbone_orm::repository::PaginationParams::new(self.page, self.limit);
311 self.page = page.page;
312 self.limit = page.per_page;
313 self
314 }
315}
316
317const RESERVED_QUERY_KEYS: [&str; 3] = ["fields", "include", "with"];
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct HistoryEntry {
335 pub occurred_at: String,
336 pub action: String,
338 pub actor: String,
339 pub changed: serde_json::Value,
340 pub reason: Option<String>,
341 pub correlation_id: Option<String>,
342}
343
344#[async_trait::async_trait]
351pub trait HistoryProvider: Send + Sync {
352 async fn history(
360 &self,
361 table: &str,
362 id: &str,
363 limit: u32,
364 offset: u32,
365 ) -> Result<Option<Vec<HistoryEntry>>, String>;
366}
367
368const AGGREGATE_QUERY_KEYS: [&str; 7] =
374 ["group_by", "sum", "avg", "min", "max", "group_limit", "group_label"];
375
376fn aggregate_spec(params: &ListQueryParams) -> backbone_orm::repository::AggregateSpec {
382 use backbone_orm::repository::{AggregateFn, AggregateSpec};
383
384 let mut reductions = Vec::new();
385 for (key, func) in [
386 ("sum", AggregateFn::Sum),
387 ("avg", AggregateFn::Avg),
388 ("min", AggregateFn::Min),
389 ("max", AggregateFn::Max),
390 ] {
391 if let Some(raw) = params.filters.get(key) {
392 for field in raw.split(',').map(str::trim).filter(|f| !f.is_empty()) {
393 reductions.push((func, field.to_string()));
394 }
395 }
396 }
397
398 AggregateSpec {
399 group_by: params
400 .filters
401 .get("group_by")
402 .map(|g| g.trim().to_string())
403 .filter(|g| !g.is_empty()),
404 reductions,
405 group_limit: params
406 .filters
407 .get("group_limit")
408 .and_then(|l| l.trim().parse::<usize>().ok())
409 .unwrap_or(0),
410 label_field: params
411 .filters
412 .get("group_label")
413 .map(|l| l.trim().to_string())
414 .filter(|l| !l.is_empty()),
415 label_relation: None,
416 }
417}
418
419fn named_secret(params: &ListQueryParams, secret_fields: &[&str]) -> Option<String> {
426 if secret_fields.is_empty() {
427 return None;
428 }
429 let secrets: Vec<String> = secret_fields.iter().map(|f| camel_to_snake_case(f)).collect();
430 const COLUMN_LISTS: [&str; 10] = [
431 "orderby", "sort", "searchfields", "search_fields", "group_by", "sum", "avg", "min", "max", "group_label",
432 ];
433 let mut names: Vec<String> = Vec::new();
434 for (key, value) in ¶ms.filters {
435 match key.find('[') {
436 Some(open) => {
437 names.push(key[..open].to_string());
438 names.push(key[open + 1..].trim_end_matches(']').to_string());
439 }
440 None => names.push(key.clone()),
441 }
442 if COLUMN_LISTS.contains(&key.to_ascii_lowercase().as_str()) {
443 names.extend(value.split(',').map(|v| v.trim().trim_start_matches('-').to_string()));
444 }
445 }
446 names.extend(params.sort_by.iter().cloned());
447 names
448 .into_iter()
449 .find(|n| secrets.contains(&camel_to_snake_case(n.trim())))
450}
451
452fn secret_named_message(field: &str) -> String {
454 format!("'{field}' is a secret field: it cannot be filtered, sorted, searched or aggregated")
455}
456
457fn repository_filters(params: &ListQueryParams) -> HashMap<String, String> {
469 let mut filters = params.filters.clone();
470 for key in RESERVED_QUERY_KEYS {
471 filters.remove(key);
472 }
473 filters.retain(|key, _| !backbone_orm::is_internal_key(key));
476 if let Some(search) = params.search.clone() {
477 filters.insert("search".to_string(), search);
478 }
479 if let Some(status) = params.status.clone() {
480 filters.insert("status".to_string(), status);
481 }
482 let sorted = filters.keys().any(|k| {
486 let base = k.split('[').next().unwrap_or(k);
487 base.eq_ignore_ascii_case("orderby") || base.eq_ignore_ascii_case("sort")
488 });
489 if let Some(field) = params.sort_by.as_deref().map(str::trim).filter(|f| !f.is_empty()) {
490 if !sorted {
491 let dir = match params.sort_order.as_deref().map(str::trim) {
492 Some(d) if d.eq_ignore_ascii_case("desc") => "desc",
493 _ => "asc",
494 };
495 filters.insert("orderby".to_string(), format!("{field}:{dir}"));
496 }
497 }
498 filters
499}
500
501fn aggregate_filters(params: &ListQueryParams) -> HashMap<String, String> {
504 let mut filters = repository_filters(params);
505 for key in AGGREGATE_QUERY_KEYS {
506 filters.remove(key);
507 }
508 filters
509}
510
511fn sparse_fields(query: &HashMap<String, String>) -> Vec<String> {
512 query
513 .get("fields")
514 .map(|s| {
515 s.split(',')
516 .map(str::trim)
517 .filter(|f| !f.is_empty())
518 .map(str::to_string)
519 .collect()
520 })
521 .unwrap_or_default()
522}
523
524fn to_response_value<R: Serialize>(r: R) -> serde_json::Value {
527 serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
528}
529
530fn project_sparse(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
534 if fields.is_empty() {
535 return value;
536 }
537 if let serde_json::Value::Object(map) = &mut value {
538 map.retain(|k, _| k == "id" || fields.iter().any(|f| f == k));
539 }
540 value
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum AccessScope {
567 Platform,
569 Company(uuid::Uuid),
571}
572
573impl AccessScope {
574 pub fn company(&self) -> Option<uuid::Uuid> {
580 match self {
581 AccessScope::Platform => None,
582 AccessScope::Company(id) => Some(*id),
583 }
584 }
585}
586
587fn strip_secrets(mut value: serde_json::Value, secret_fields: &[&str]) -> serde_json::Value {
597 if let serde_json::Value::Object(map) = &mut value {
598 for f in secret_fields {
599 map.remove(*f);
600 }
601 }
602 value
603}
604
605fn secure<E: backbone_orm::EntityRepoMeta>(
609 value: serde_json::Value,
610 scope: Option<&AccessScope>,
611) -> serde_json::Value {
612 apply_field_security(
613 strip_secrets(value, E::secret_fields()),
614 scope,
615 E::private_fields(),
616 E::owner_field(),
617 )
618}
619
620pub fn without_secrets<E: backbone_orm::EntityRepoMeta, R: Serialize>(response: R) -> serde_json::Value {
624 secure::<E>(to_response_value(response), None)
625}
626
627fn related_row<E: backbone_orm::EntityRepoMeta>(
631 relation: &str,
632 table: &str,
633 obj: serde_json::Value,
634) -> serde_json::Value {
635 let row = strip_secrets(camelize_keys(obj), E::relation_secret_fields(relation));
636 strip_secrets(row, &backbone_orm::secret_registry::secret_fields_of(table))
638}
639
640fn apply_field_security(
641 mut value: serde_json::Value,
642 scope: Option<&AccessScope>,
643 private_fields: &[&str],
644 owner_field: Option<&str>,
645) -> serde_json::Value {
646 if private_fields.is_empty() {
647 return value;
648 }
649 let can_see_private = match scope {
650 Some(AccessScope::Platform) => true,
651 Some(AccessScope::Company(id)) => owner_field
652 .and_then(|f| value.get(f))
653 .and_then(|v| v.as_str())
654 .and_then(|owner| uuid::Uuid::parse_str(owner).ok())
658 .is_some_and(|owner| owner == *id),
659 None => false,
660 };
661 if !can_see_private {
662 if let serde_json::Value::Object(map) = &mut value {
663 for f in private_fields {
664 map.remove(*f);
665 }
666 }
667 }
668 value
669}
670
671fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
673 query
674 .get("include")
675 .or_else(|| query.get("with"))
676 .map(|s| {
677 s.split(',')
678 .map(str::trim)
679 .filter(|s| !s.is_empty())
680 .map(str::to_string)
681 .collect()
682 })
683 .unwrap_or_default()
684}
685
686fn snake_to_camel(s: &str) -> String {
687 let mut out = String::with_capacity(s.len());
688 let mut upper = false;
689 for c in s.chars() {
690 if c == '_' {
691 upper = true;
692 } else if upper {
693 out.extend(c.to_uppercase());
694 upper = false;
695 } else {
696 out.push(c);
697 }
698 }
699 out
700}
701
702fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
705 match v {
706 serde_json::Value::Object(m) => serde_json::Value::Object(
707 m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
708 ),
709 other => other,
710 }
711}
712
713async fn expand_includes<S, E, C, U>(
723 service: &S,
724 rows: &mut [serde_json::Value],
725 includes: &[String],
726) where
727 S: CrudService<E, C, U>,
728 E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
729 C: Send + Sync + 'static,
730 U: Send + Sync + 'static,
731{
732 if includes.is_empty() || rows.is_empty() {
733 return;
734 }
735 for (rel_name, table, fk_field) in E::relations() {
736 if !includes.iter().any(|i| i == rel_name) {
737 continue;
738 }
739 let mut ids: Vec<String> = rows
740 .iter()
741 .filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
742 .collect();
743 ids.sort();
744 ids.dedup();
745 if ids.is_empty() {
746 continue;
747 }
748 let related = service.fetch_related_json(table, &ids).await;
749 let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
750 for obj in related {
751 if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
752 by_id.insert(id, related_row::<E>(rel_name, table, obj));
753 }
754 }
755 for r in rows.iter_mut() {
756 let related_obj = r
757 .get(fk_field)
758 .and_then(|v| v.as_str())
759 .and_then(|id| by_id.get(id).cloned())
760 .unwrap_or(serde_json::Value::Null);
761 if let serde_json::Value::Object(m) = r {
762 m.insert((*rel_name).to_string(), related_obj);
763 }
764 }
765 }
766}
767
768pub const MAX_PER_PAGE: u32 = 100;
772
773pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
778
779fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
785 let effective_limit = limit.clamp(1, MAX_PER_PAGE);
786 let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
787 if offset > MAX_PAGINATION_OFFSET {
788 Some(format!(
789 "Result set too deep: offset {offset} exceeds the maximum of \
790 {MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
791 ))
792 } else {
793 None
794 }
795}
796
797pub use crate::service::MAX_BATCH_SIZE;
803
804fn batch_size_error(count: usize) -> Option<String> {
807 if count > MAX_BATCH_SIZE {
808 Some(format!(
809 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
810 ))
811 } else {
812 None
813 }
814}
815
816fn is_bad_query_error(msg: &str) -> bool {
825 let m = msg.to_lowercase();
826 m.contains("does not exist")
827 || m.contains("invalid input syntax")
828 || m.contains("42703")
829 || m.contains("not a column of this entity")
833 || m.contains("needs a numeric column")
834}
835
836fn bad_list_request(message: String) -> PaginatedApiResponse<serde_json::Value> {
839 let violation = crate::violation::Violation::new("", "invalid_request", message.clone());
840 PaginatedApiResponse::rejected(message, vec![violation])
841}
842
843#[derive(Debug, Serialize, Deserialize, Clone)]
845#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
846pub struct PaginationResponse {
847 pub total: u64,
848 pub page: u32,
849 pub limit: u32,
850 pub total_pages: u32,
851 #[serde(skip_serializing_if = "Option::is_none")]
855 pub next_cursor: Option<String>,
856 #[serde(skip_serializing_if = "Option::is_none")]
858 pub prev_cursor: Option<String>,
859 #[serde(skip_serializing_if = "Option::is_none")]
861 pub has_more: Option<bool>,
862}
863
864impl PaginationResponse {
865 pub fn new(total: u64, page: u32, limit: u32) -> Self {
866 let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
867 Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
868 }
869
870 pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
874 Self {
875 total: info.total,
876 page: info.page,
877 limit: info.per_page,
878 total_pages: info.total_pages,
879 next_cursor: info.next_cursor.clone(),
880 prev_cursor: info.prev_cursor.clone(),
881 has_more: info.has_more,
882 }
883 }
884}
885
886#[derive(Debug, Serialize)]
888#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
889pub struct PaginatedResponse<T> {
890 pub data: Vec<T>,
891 pub meta: PaginationResponse,
892}
893
894#[derive(Debug, Serialize)]
897#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
898pub struct PaginatedApiResponse<T> {
899 pub success: bool,
900 pub data: Vec<T>,
901 pub meta: PaginationResponse,
902 #[serde(skip_serializing_if = "Option::is_none")]
903 pub error: Option<String>,
904 #[serde(default, skip_serializing_if = "Option::is_none")]
906 pub violations: Option<Vec<crate::violation::Violation>>,
907}
908
909impl<T> PaginatedApiResponse<T> {
910 pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
913 Self {
914 success: true,
915 data,
916 meta: PaginationResponse::from_info(info),
917 error: None,
918 violations: None,
919 }
920 }
921
922 pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
924 Self {
925 success: true,
926 data,
927 meta: PaginationResponse::new(total, page, limit),
928 error: None,
929 violations: None,
930 }
931 }
932
933 pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
935 Self {
936 success: true,
937 data: resp.data,
938 meta: resp.meta,
939 error: None,
940 violations: None,
941 }
942 }
943
944 pub fn error(error: impl Into<String>) -> Self {
946 Self {
947 success: false,
948 data: Vec::new(),
949 meta: PaginationResponse::new(0, 0, 0),
950 error: Some(error.into()),
951 violations: None,
952 }
953 }
954
955 pub fn rejected(error: impl Into<String>, violations: Vec<crate::violation::Violation>) -> Self {
957 Self {
958 violations: (!violations.is_empty()).then_some(violations),
959 ..Self::error(error)
960 }
961 }
962}
963
964#[derive(Debug, Serialize, Deserialize)]
966#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
967pub struct BulkCreateRequest<T> {
968 pub items: Vec<T>,
969}
970
971#[derive(Debug, Serialize, Deserialize)]
973#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
974pub struct BulkResponse<T> {
975 pub items: Vec<T>,
976 pub total: usize,
977 pub failed: usize,
978 pub errors: Vec<String>,
979}
980
981#[derive(Debug, Serialize, Deserialize)]
983#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
984pub struct UpsertRequest<T> {
985 pub entity: T,
986 pub create_if_not_exists: bool,
987}
988
989#[derive(Debug, Serialize, Deserialize)]
992#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
993pub struct BatchIdsRequest {
994 pub ids: Vec<String>,
995}
996
997#[derive(Debug, Deserialize)]
1000#[serde(bound = "U: DeserializeOwned")]
1001#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1002pub struct BulkUpdateItem<U> {
1003 pub id: String,
1004 #[serde(flatten)]
1008 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1009 pub data: U,
1010}
1011
1012#[derive(Debug, Deserialize)]
1014#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1015pub struct BulkPatchItem {
1016 pub id: String,
1017 pub patch: HashMap<String, serde_json::Value>,
1018}
1019
1020#[derive(Debug, Deserialize)]
1023#[serde(untagged)]
1024#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1025pub enum BulkPatchRequest {
1026 Shared {
1028 ids: Vec<String>,
1029 patch: HashMap<String, serde_json::Value>,
1030 },
1031 PerItem { items: Vec<BulkPatchItem> },
1033}
1034
1035impl BulkPatchRequest {
1036 fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
1038 match self {
1039 BulkPatchRequest::Shared { ids, patch } => {
1040 ids.into_iter().map(|id| (id, patch.clone())).collect()
1041 }
1042 BulkPatchRequest::PerItem { items } => {
1043 items.into_iter().map(|it| (it.id, it.patch)).collect()
1044 }
1045 }
1046 }
1047}
1048
1049#[derive(Debug, Default, Serialize)]
1051#[serde(rename_all = "camelCase")]
1052#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1053pub struct BulkPreview {
1054 pub will_change: Vec<PreviewChange>,
1056 pub unchanged: Vec<String>,
1058 pub refused: Vec<PreviewRefusal>,
1060 pub total: usize,
1062}
1063
1064#[derive(Debug, Serialize)]
1066#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1067pub struct PreviewChange {
1068 pub id: String,
1070 pub changes: Vec<FieldChange>,
1072}
1073
1074#[derive(Debug, Serialize)]
1076#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1077pub struct FieldChange {
1078 pub field: String,
1080 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1082 pub from: serde_json::Value,
1083 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1085 pub to: serde_json::Value,
1086}
1087
1088#[derive(Debug, Serialize)]
1090#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1091pub struct PreviewRefusal {
1092 pub id: String,
1094 pub code: String,
1096 pub message: String,
1098}
1099
1100type PreviewOutcome = Result<(serde_json::Value, serde_json::Value), (String, String)>;
1102
1103fn preview_answer(
1110 rows: Vec<(String, PreviewOutcome)>,
1111 patched: &HashMap<String, Vec<String>>,
1112) -> BulkPreview {
1113 let mut out = BulkPreview::default();
1114 for (id, outcome) in rows {
1115 match outcome {
1116 Ok((before, after)) => {
1117 let mut keys = patched.get(&id).cloned().unwrap_or_default();
1118 keys.sort();
1119 let changes: Vec<FieldChange> = keys
1120 .into_iter()
1121 .filter_map(|field| {
1122 let read = |v: &serde_json::Value| {
1123 v.get(snake_to_camel(&field))
1124 .or_else(|| v.get(&field))
1125 .cloned()
1126 .unwrap_or(serde_json::Value::Null)
1127 };
1128 let (from, to) = (read(&before), read(&after));
1129 (from != to).then_some(FieldChange { field, from, to })
1130 })
1131 .collect();
1132 if changes.is_empty() {
1133 out.unchanged.push(id);
1134 } else {
1135 out.will_change.push(PreviewChange { id, changes });
1136 }
1137 }
1138 Err((code, message)) => out.refused.push(PreviewRefusal { id, code, message }),
1139 }
1140 }
1141 out.total = out.will_change.len() + out.unchanged.len() + out.refused.len();
1142 out
1143}
1144
1145#[derive(Debug, Serialize, Deserialize)]
1147#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1148pub struct FilterOptions {
1149 pub filters: HashMap<String, String>,
1150 pub sort_by: Option<String>,
1151 pub sort_order: Option<SortOrder>,
1152}
1153
1154#[derive(Debug, Serialize, Deserialize, Clone, Default)]
1156#[serde(rename_all = "lowercase")]
1157#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1158pub enum SortOrder {
1159 #[default]
1160 Asc,
1161 Desc,
1162}
1163
1164#[derive(Debug, Serialize, Deserialize)]
1166#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1167pub struct ListRequest {
1168 pub page: Option<u32>,
1169 pub limit: Option<u32>,
1170 pub sort_by: Option<String>,
1171 pub sort_order: Option<SortOrder>,
1172 pub filters: Option<HashMap<String, String>>,
1173}
1174
1175impl Default for ListRequest {
1176 fn default() -> Self {
1177 Self {
1178 page: Some(1),
1179 limit: Some(20),
1180 sort_by: None,
1181 sort_order: None,
1182 filters: None,
1183 }
1184 }
1185}
1186
1187#[async_trait::async_trait]
1211pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
1212where
1213 Entity: Send + Sync + 'static,
1216 CreateDto: Send + Sync + 'static,
1217 UpdateDto: Send + Sync + 'static,
1218{
1219 type Error: std::error::Error + Send + Sync;
1221
1222 fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
1225 let _ = err;
1226 None
1227 }
1228
1229 fn status_of(err: &Self::Error) -> Option<axum::http::StatusCode> {
1234 let _ = err;
1235 None
1236 }
1237
1238 fn supports_upsert() -> bool {
1242 true
1243 }
1244
1245 fn entity_name() -> &'static str;
1247
1248 async fn fetch_related_json(
1251 &self,
1252 _table: &str,
1253 _ids: &[String],
1254 ) -> Vec<serde_json::Value> {
1255 Vec::new()
1256 }
1257
1258 async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1260
1261 async fn list_with_info(
1265 &self,
1266 page: u32,
1267 limit: u32,
1268 filters: HashMap<String, String>,
1269 ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
1270 let (rows, total) = self.list(page, limit, filters).await?;
1271 Ok((
1272 rows,
1273 backbone_orm::repository::PaginationInfo::new(page, limit, total),
1274 ))
1275 }
1276
1277 async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1279
1280 async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1282
1283 async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
1285
1286 async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
1288
1289 async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
1291
1292 async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
1294
1295 async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1297
1298 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
1300
1301 async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1303
1304 async fn empty_trash(&self) -> Result<u64, Self::Error>;
1306
1307 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1309
1310 async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
1312
1313 async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1315
1316 async fn count_active(&self) -> Result<u64, Self::Error>;
1318
1319 fn table_name(&self) -> Option<&str> {
1326 None
1327 }
1328
1329 async fn aggregate(
1334 &self,
1335 spec: &backbone_orm::repository::AggregateSpec,
1336 filters: HashMap<String, String>,
1337 ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
1338
1339 async fn count_active_filtered(
1352 &self,
1353 filters: HashMap<String, String>,
1354 ) -> Result<u64, Self::Error> {
1355 self.list(1, 1, filters).await.map(|(_, total)| total)
1356 }
1357
1358 async fn count_deleted(&self) -> Result<u64, Self::Error>;
1360
1361 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1370 let mut n = 0;
1371 for id in ids {
1372 if self.soft_delete(&id).await? {
1373 n += 1;
1374 }
1375 }
1376 Ok(n)
1377 }
1378
1379 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1381 let mut out = Vec::with_capacity(ids.len());
1382 for id in ids {
1383 if let Some(e) = self.restore(&id).await? {
1384 out.push(e);
1385 }
1386 }
1387 Ok(out)
1388 }
1389
1390 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1392 let mut n = 0;
1393 for id in ids {
1394 if self.permanent_delete(&id).await? {
1395 n += 1;
1396 }
1397 }
1398 Ok(n)
1399 }
1400
1401 async fn restore_all(&self) -> Result<u64, Self::Error> {
1406 Ok(0)
1407 }
1408
1409 async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1411 let mut out = Vec::with_capacity(items.len());
1412 for (id, dto) in items {
1413 if let Some(e) = self.update(&id, dto).await? {
1414 out.push(e);
1415 }
1416 }
1417 Ok(out)
1418 }
1419
1420 async fn bulk_partial_update(
1422 &self,
1423 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1424 ) -> Result<Vec<Entity>, Self::Error> {
1425 let mut out = Vec::with_capacity(items.len());
1426 for (id, fields) in items {
1427 if let Some(e) = self.partial_update(&id, fields).await? {
1428 out.push(e);
1429 }
1430 }
1431 Ok(out)
1432 }
1433
1434 async fn preview_bulk_partial_update(
1439 &self,
1440 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1441 ) -> Result<Option<Vec<BulkPreviewRow<Entity, Self::Error>>>, Self::Error> {
1442 let _ = items;
1443 Ok(None)
1444 }
1445}
1446
1447pub struct BulkPreviewRow<Entity, Err> {
1450 pub id: String,
1452 pub outcome: Result<(Entity, Entity), Err>,
1454}
1455
1456pub struct BackboneCrudHandler<S, E, C, U, R>
1479where
1480 S: CrudService<E, C, U> + 'static,
1481 E: Serialize + Send + Sync + 'static,
1482 C: DeserializeOwned + Send + Sync + 'static,
1483 U: DeserializeOwned + Send + Sync + 'static,
1484 R: From<E> + Serialize + Send + Sync + 'static,
1485{
1486 service: Arc<S>,
1487 _phantom: std::marker::PhantomData<(E, C, U, R)>,
1488}
1489
1490impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1491where
1492 S: CrudService<E, C, U> + 'static,
1493 E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1494 C: DeserializeOwned + Send + Sync + 'static,
1495 U: DeserializeOwned + Send + Sync + 'static,
1496 R: From<E> + Serialize + Send + Sync + 'static,
1497{
1498 pub fn new(service: Arc<S>) -> Self {
1499 Self {
1500 service,
1501 _phantom: std::marker::PhantomData,
1502 }
1503 }
1504
1505 pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1533 where
1534 S: Clone,
1535 {
1536 Self::read_routes(service.clone(), base_path)
1537 .merge(Self::write_routes(service, base_path))
1538 }
1539
1540 pub fn routes_guarded(
1543 service: Arc<S>,
1544 base_path: &str,
1545 permissions: crate::permission::CrudPermissions,
1546 ) -> axum::Router<()>
1547 where
1548 S: Clone,
1549 {
1550 Self::read_routes_guarded(service.clone(), base_path, permissions.clone())
1551 .merge(Self::write_routes_guarded(service, base_path, permissions))
1552 }
1553
1554 fn gate(
1556 perms: &Option<crate::permission::CrudPermissions>,
1557 gate: crate::permission::Gate,
1558 route: axum::routing::MethodRouter<()>,
1559 ) -> axum::routing::MethodRouter<()> {
1560 match perms {
1561 Some(p) if p.is_public(gate) => route,
1564 Some(p) => crate::permission::guarded_needs(route, p.needs(gate)),
1565 None => route,
1566 }
1567 }
1568
1569 pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1583 where
1584 S: Clone,
1585 {
1586 Self::read_routes_with(service, base_path, None)
1587 }
1588
1589 pub fn read_routes_guarded(
1596 service: Arc<S>,
1597 base_path: &str,
1598 permissions: crate::permission::CrudPermissions,
1599 ) -> axum::Router<()>
1600 where
1601 S: Clone,
1602 {
1603 Self::read_routes_with(service, base_path, Some(permissions))
1604 }
1605
1606 fn read_routes_with(
1607 service: Arc<S>,
1608 base_path: &str,
1609 perms: Option<crate::permission::CrudPermissions>,
1610 ) -> axum::Router<()>
1611 where
1612 S: Clone,
1613 {
1614 use crate::permission::Gate;
1615 if let Some(table) = service.table_name() {
1618 backbone_orm::secret_registry::register(table, E::secret_fields());
1619 }
1620 use axum::{
1621 extract::{Path, Query},
1622 routing::get,
1623 Extension, Router,
1624 };
1625
1626 let handler = Arc::new(Self::new(service));
1627
1628 Router::new()
1629 .route(base_path, Self::gate(&perms, Gate::Read, get({
1631 let h = handler.clone();
1632 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1633 Self::list_handler(h, query, access).await
1634 }
1635 })))
1636 .route(&format!("{}/trash", base_path), Self::gate(&perms, Gate::Read, get({
1638 let h = handler.clone();
1639 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1640 Self::list_deleted_handler(h, query, access).await
1641 }
1642 })))
1643 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Read, get({
1645 let h = handler.clone();
1646 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1647 Self::get_handler(h, path, query, access).await
1648 }
1649 })))
1650 .route(&format!("{}/:id/deleted", base_path), Self::gate(&perms, Gate::Read, get({
1652 let h = handler.clone();
1653 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1654 Self::get_deleted_handler(h, path, query, access).await
1655 }
1656 })))
1657 .route(&format!("{}/:id/history", base_path), Self::gate(&perms, Gate::Read, get({
1660 let h = handler.clone();
1661 move |path: axum::extract::Path<String>,
1662 query: axum::extract::Query<ListQueryParams>,
1663 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
1664 Self::history_handler(h, path, query, provider).await
1665 }
1666 })))
1667 .route(&format!("{}/aggregate", base_path), Self::gate(&perms, Gate::Read, get({
1669 let h = handler.clone();
1670 move |query: axum::extract::Query<ListQueryParams>| async move {
1671 Self::aggregate_handler(h, query).await
1672 }
1673 })))
1674 .route(&format!("{}/count", base_path), Self::gate(&perms, Gate::Read, get({
1675 let h = handler.clone();
1676 move |query: axum::extract::Query<ListQueryParams>| async move {
1677 Self::count_active_handler(h, query).await
1678 }
1679 })))
1680 .route(&format!("{}/trash/count", base_path), Self::gate(&perms, Gate::Read, get({
1682 let h = handler.clone();
1683 move || async move {
1684 Self::count_deleted_handler(h).await
1685 }
1686 })))
1687 }
1688
1689 pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1706 where
1707 S: Clone,
1708 {
1709 Self::write_routes_with(service, base_path, None)
1710 }
1711
1712 pub fn write_routes_guarded(
1717 service: Arc<S>,
1718 base_path: &str,
1719 permissions: crate::permission::CrudPermissions,
1720 ) -> axum::Router<()>
1721 where
1722 S: Clone,
1723 {
1724 Self::write_routes_with(service, base_path, Some(permissions))
1725 }
1726
1727 fn write_routes_with(
1728 service: Arc<S>,
1729 base_path: &str,
1730 perms: Option<crate::permission::CrudPermissions>,
1731 ) -> axum::Router<()>
1732 where
1733 S: Clone,
1734 {
1735 use crate::permission::Gate;
1736 if let Some(table) = service.table_name() {
1739 backbone_orm::secret_registry::register(table, E::secret_fields());
1740 }
1741 use axum::{
1742 extract::Path,
1743 routing::{delete, patch, post, put},
1744 Router,
1745 };
1746
1747 let handler = Arc::new(Self::new(service));
1748
1749 Router::new()
1750 .route(base_path, Self::gate(&perms, Gate::Create, post({
1752 let h = handler.clone();
1753 move |body: StrictJsonOrForm<C>| async move {
1754 Self::create_handler(h, body).await
1755 }
1756 })))
1757 .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Create, post({
1759 let h = handler.clone();
1760 move |body: StrictJsonOrForm<Vec<C>>| async move {
1761 Self::bulk_create_handler(h, body).await
1762 }
1763 })))
1764 .route(&format!("{}/upsert", base_path), Self::gate(&perms, Gate::CreateUpdate, post({
1766 let h = handler.clone();
1767 move |body: StrictJsonOrForm<C>| async move {
1768 Self::upsert_handler(h, body).await
1769 }
1770 })))
1771 .route(&format!("{}/delete/bulk", base_path), Self::gate(&perms, Gate::Delete, post({
1773 let h = handler.clone();
1774 move |body: JsonOrForm<BatchIdsRequest>| async move {
1775 Self::bulk_delete_handler(h, body).await
1776 }
1777 })))
1778 .route(&format!("{}/restore/bulk", base_path), Self::gate(&perms, Gate::Restore, post({
1780 let h = handler.clone();
1781 move |body: JsonOrForm<BatchIdsRequest>| async move {
1782 Self::bulk_restore_handler(h, body).await
1783 }
1784 })))
1785 .route(&format!("{}/restore/all", base_path), Self::gate(&perms, Gate::Restore, post({
1787 let h = handler.clone();
1788 move || async move {
1789 Self::restore_all_handler(h).await
1790 }
1791 })))
1792 .route(&format!("{}/trash/bulk", base_path), Self::gate(&perms, Gate::Purge, delete({
1795 let h = handler.clone();
1796 move |body: JsonOrForm<BatchIdsRequest>| async move {
1797 Self::bulk_permanent_delete_handler(h, body).await
1798 }
1799 })))
1800 .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Update, put({
1802 let h = handler.clone();
1803 move |body: JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>| async move {
1804 Self::bulk_update_handler(h, body).await
1805 }
1806 })))
1807 .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Update, patch({
1809 let h = handler.clone();
1810 move |body: JsonOrForm<BulkPatchRequest>| async move {
1811 Self::bulk_patch_handler(h, body).await
1812 }
1813 })))
1814 .route(&format!("{}/bulk/preview", base_path), Self::gate(&perms, Gate::Update, post({
1816 let h = handler.clone();
1817 move |body: JsonOrForm<BulkPatchRequest>| async move {
1818 Self::bulk_patch_preview_handler(h, body).await
1819 }
1820 })))
1821 .route(&format!("{}/empty", base_path), Self::gate(&perms, Gate::Purge, delete({
1823 let h = handler.clone();
1824 move || async move {
1825 Self::empty_trash_handler(h).await
1826 }
1827 })))
1828 .route(&format!("{}/trash/:id", base_path), Self::gate(&perms, Gate::Purge, delete({
1830 let h = handler.clone();
1831 move |path: Path<String>| async move {
1832 Self::permanent_delete_handler(h, path).await
1833 }
1834 })))
1835 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Update, put({
1837 let h = handler.clone();
1838 move |path: Path<String>, body: StrictJsonOrForm<U>| async move {
1839 Self::update_handler(h, path, body).await
1840 }
1841 })))
1842 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Update, patch({
1844 let h = handler.clone();
1845 move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
1846 Self::partial_update_handler(h, path, body).await
1847 }
1848 })))
1849 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Delete, delete({
1851 let h = handler.clone();
1852 move |path: Path<String>| async move {
1853 Self::delete_handler(h, path).await
1854 }
1855 })))
1856 .route(&format!("{}/:id/restore", base_path), Self::gate(&perms, Gate::Restore, post({
1858 let h = handler.clone();
1859 move |path: Path<String>| async move {
1860 Self::restore_handler(h, path).await
1861 }
1862 })))
1863 .layer(axum::middleware::from_fn(correlation_middleware))
1865 }
1866
1867 fn failure<T>(err: &S::Error, fallback: axum::http::StatusCode) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
1875 let violations = S::violations_of(err);
1876 let status = S::status_of(err).unwrap_or(if violations.is_some() {
1877 axum::http::StatusCode::UNPROCESSABLE_ENTITY
1878 } else {
1879 fallback
1880 });
1881 error_at(status, violations, err)
1882 }
1883
1884 fn list_failure(err: &S::Error) -> (axum::http::StatusCode, axum::Json<PaginatedApiResponse<serde_json::Value>>) {
1887 use axum::{http::StatusCode, Json};
1888 let msg = err.to_string();
1889 if is_bad_query_error(&msg) {
1890 tracing::debug!(target: "backbone::http", error = %msg, "list query refused");
1893 let message = "Invalid query parameter or filter";
1894 return (
1895 StatusCode::BAD_REQUEST,
1896 Json(PaginatedApiResponse::rejected(
1897 message,
1898 vec![crate::violation::Violation::new("", "invalid_query", message)],
1899 )),
1900 );
1901 }
1902 let (status, Json(body)) = Self::failure::<()>(err, StatusCode::INTERNAL_SERVER_ERROR);
1903 let error = body.error.unwrap_or_default();
1904 (status, Json(PaginatedApiResponse::rejected(error, body.violations.unwrap_or_default())))
1905 }
1906
1907 async fn list_handler(
1908 handler: Arc<Self>,
1909 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1910 access: Option<axum::Extension<AccessScope>>,
1911 ) -> impl axum::response::IntoResponse {
1912 use axum::{http::StatusCode, Json};
1913
1914 let params = params.normalized();
1915 if let Some(err) = pagination_depth_error(params.page, params.limit) {
1916 return (StatusCode::BAD_REQUEST, Json(bad_list_request(err)));
1917 }
1918 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1919 return (
1920 StatusCode::BAD_REQUEST,
1921 Json(bad_list_request(secret_named_message(&field))),
1922 );
1923 }
1924
1925 let fields = sparse_fields(¶ms.filters);
1928 let includes = include_relations(¶ms.filters);
1929 let scope = access.map(|axum::Extension(s)| s);
1930
1931 let filters = repository_filters(¶ms);
1932
1933 match handler
1934 .service
1935 .list_with_info(params.page, params.limit, filters)
1936 .await
1937 {
1938 Ok((entities, info)) => {
1939 let mut rows: Vec<serde_json::Value> = entities
1942 .into_iter()
1943 .map(|e| {
1944 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1945 })
1946 .collect();
1947 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1948 let items: Vec<serde_json::Value> =
1949 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1950 let response = PaginatedApiResponse::ok_with_info(items, &info);
1951 (StatusCode::OK, Json(response))
1952 }
1953 Err(e) => Self::list_failure(&e),
1954 }
1955 }
1956
1957 async fn create_handler(
1958 handler: Arc<Self>,
1959 StrictJsonOrForm(dto): StrictJsonOrForm<C>,
1960 ) -> impl axum::response::IntoResponse {
1961 use axum::{http::StatusCode, Json};
1962
1963 match handler.service.create(dto).await {
1964 Ok(entity) => {
1965 let response = secure::<E>(to_response_value(R::from(entity)), None);
1966 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1967 }
1968 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
1969 }
1970 }
1971
1972 async fn get_handler(
1973 handler: Arc<Self>,
1974 axum::extract::Path(id): axum::extract::Path<String>,
1975 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1976 access: Option<axum::Extension<AccessScope>>,
1977 ) -> impl axum::response::IntoResponse {
1978 use axum::{http::StatusCode, Json};
1979
1980 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
1981 return refused;
1982 }
1983
1984 let fields = sparse_fields(¶ms.filters);
1985 let includes = include_relations(¶ms.filters);
1986 let scope = access.map(|axum::Extension(s)| s);
1987
1988 match handler.service.get_by_id(&id).await {
1989 Ok(Some(entity)) => {
1990 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
1991 let mut rows = [secured];
1992 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1993 let [secured] = rows;
1994 let value = project_sparse(secured, &fields);
1995 (StatusCode::OK, Json(ApiResponse::ok(value)))
1996 }
1997 Ok(None) => {
1998 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1999 }
2000 Err(e) => {
2001 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2002 }
2003 }
2004 }
2005
2006 async fn update_handler(
2007 handler: Arc<Self>,
2008 axum::extract::Path(id): axum::extract::Path<String>,
2009 StrictJsonOrForm(dto): StrictJsonOrForm<U>,
2010 ) -> impl axum::response::IntoResponse {
2011 use axum::{http::StatusCode, Json};
2012
2013 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2014 return refused;
2015 }
2016
2017 match handler.service.update(&id, dto).await {
2018 Ok(Some(entity)) => {
2019 let response = secure::<E>(to_response_value(R::from(entity)), None);
2020 (StatusCode::OK, Json(ApiResponse::ok(response)))
2021 }
2022 Ok(None) => {
2023 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2024 }
2025 Err(e) => {
2026 Self::failure(&e, StatusCode::BAD_REQUEST)
2027 }
2028 }
2029 }
2030
2031 async fn partial_update_handler(
2032 handler: Arc<Self>,
2033 axum::extract::Path(id): axum::extract::Path<String>,
2034 JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
2035 ) -> impl axum::response::IntoResponse {
2036 use axum::{http::StatusCode, Json};
2037
2038 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2039 return refused;
2040 }
2041
2042 let fields: HashMap<String, serde_json::Value> = fields
2053 .into_iter()
2054 .map(|(k, v)| (camel_to_snake_case(&k), v))
2055 .collect();
2056
2057 match handler.service.partial_update(&id, fields).await {
2058 Ok(Some(entity)) => {
2059 let response = secure::<E>(to_response_value(R::from(entity)), None);
2060 (StatusCode::OK, Json(ApiResponse::ok(response)))
2061 }
2062 Ok(None) => {
2063 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2064 }
2065 Err(e) => {
2066 Self::failure(&e, StatusCode::BAD_REQUEST)
2067 }
2068 }
2069 }
2070
2071 async fn delete_handler(
2072 handler: Arc<Self>,
2073 axum::extract::Path(id): axum::extract::Path<String>,
2074 ) -> impl axum::response::IntoResponse {
2075 use axum::{http::StatusCode, Json};
2076
2077 if let Some(refused) = invalid_id::<()>(&id, "id") {
2078 return refused;
2079 }
2080
2081 match handler.service.soft_delete(&id).await {
2082 Ok(true) => {
2083 (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
2086 }
2087 Ok(false) => {
2088 (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
2089 }
2090 Err(e) => {
2091 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2092 }
2093 }
2094 }
2095
2096 async fn bulk_create_handler(
2097 handler: Arc<Self>,
2098 StrictJsonOrForm(items): StrictJsonOrForm<Vec<C>>,
2099 ) -> impl axum::response::IntoResponse {
2100 use axum::{http::StatusCode, Json};
2101
2102 if let Some(err) = batch_size_error(items.len()) {
2103 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2104 }
2105 match handler.service.bulk_create(items).await {
2106 Ok(entities) => {
2107 let result_items: Vec<serde_json::Value> = entities
2108 .into_iter()
2109 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2110 .collect();
2111 let total = result_items.len();
2112 let response = BulkResponse {
2113 items: result_items,
2114 total,
2115 failed: 0,
2116 errors: vec![],
2117 };
2118 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
2119 }
2120 Err(e) => {
2121 Self::failure(&e, StatusCode::BAD_REQUEST)
2122 }
2123 }
2124 }
2125
2126 async fn bulk_delete_handler(
2129 handler: Arc<Self>,
2130 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2131 ) -> impl axum::response::IntoResponse {
2132 use axum::{http::StatusCode, Json};
2133
2134 if let Some(err) = batch_size_error(req.ids.len()) {
2135 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2136 }
2137 if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2138 return refused;
2139 }
2140 match handler.service.bulk_soft_delete(req.ids).await {
2141 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2142 serde_json::json!({ "soft_deleted": count }),
2143 format!("Soft-deleted {count} item(s)"),
2144 ))),
2145 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2146 }
2147 }
2148
2149 async fn bulk_restore_handler(
2150 handler: Arc<Self>,
2151 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2152 ) -> impl axum::response::IntoResponse {
2153 use axum::{http::StatusCode, Json};
2154
2155 if let Some(err) = batch_size_error(req.ids.len()) {
2156 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2157 }
2158 if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2159 return refused;
2160 }
2161 match handler.service.bulk_restore(req.ids).await {
2162 Ok(entities) => {
2163 let items: Vec<serde_json::Value> = entities
2164 .into_iter()
2165 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2166 .collect();
2167 let total = items.len();
2168 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2169 }
2170 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2171 }
2172 }
2173
2174 async fn restore_all_handler(
2175 handler: Arc<Self>,
2176 ) -> impl axum::response::IntoResponse {
2177 use axum::{http::StatusCode, Json};
2178
2179 match handler.service.restore_all().await {
2180 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2181 serde_json::json!({ "restored": count }),
2182 format!("Restored {count} item(s) from trash"),
2183 ))),
2184 Err(e) => Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR),
2185 }
2186 }
2187
2188 async fn bulk_permanent_delete_handler(
2189 handler: Arc<Self>,
2190 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2191 ) -> impl axum::response::IntoResponse {
2192 use axum::{http::StatusCode, Json};
2193
2194 if let Some(err) = batch_size_error(req.ids.len()) {
2195 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2196 }
2197 if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2198 return refused;
2199 }
2200 match handler.service.bulk_permanent_delete(req.ids).await {
2201 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2202 serde_json::json!({ "permanently_deleted": count }),
2203 format!("Permanently deleted {count} item(s)"),
2204 ))),
2205 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2206 }
2207 }
2208
2209 async fn bulk_update_handler(
2213 handler: Arc<Self>,
2214 JsonOrForm(raw): JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>,
2215 ) -> axum::response::Response {
2216 use axum::{http::StatusCode, response::IntoResponse, Json};
2217
2218 if let Some(err) = batch_size_error(raw.len()) {
2219 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)))
2220 .into_response();
2221 }
2222 let mut items: Vec<(String, U)> = Vec::with_capacity(raw.len());
2223 for (index, mut fields) in raw.into_iter().enumerate() {
2224 let id = match fields.remove("id") {
2225 Some(serde_json::Value::String(id)) => id,
2226 _ => {
2227 let violation = crate::violation::Violation::new(
2228 crate::violation::batch_item_path(index, "id"),
2229 "required",
2230 "id is required",
2231 );
2232 return (
2233 StatusCode::UNPROCESSABLE_ENTITY,
2234 Json(ApiResponse::<()>::rejected(format!("item {index}: id is required"), vec![violation])),
2235 )
2236 .into_response();
2237 }
2238 };
2239 if let Some(refused) = invalid_id::<()>(&id, &crate::violation::batch_item_path(index, "id")) {
2240 return refused.into_response();
2241 }
2242 let bytes = match serde_json::to_vec(&fields) {
2243 Ok(bytes) => bytes,
2244 Err(e) => return error_at::<()>(StatusCode::INTERNAL_SERVER_ERROR, None, &e).into_response(),
2245 };
2246 match crate::extractors::decode_json_or_refusal::<U>(&bytes, true) {
2247 Ok(data) => items.push((id, data)),
2248 Err(refusal) => return refusal.at_item(index).into_response(),
2249 }
2250 }
2251 match handler.service.bulk_update(items).await {
2252 Ok(entities) => {
2253 let items: Vec<serde_json::Value> = entities
2254 .into_iter()
2255 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2256 .collect();
2257 let total = items.len();
2258 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2259 .into_response()
2260 }
2261 Err(e) => Self::failure::<()>(&e, StatusCode::BAD_REQUEST).into_response(),
2262 }
2263 }
2264
2265 async fn bulk_patch_handler(
2266 handler: Arc<Self>,
2267 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2268 ) -> impl axum::response::IntoResponse {
2269 use axum::{http::StatusCode, Json};
2270
2271 let items = req.into_items();
2272 if let Some(err) = batch_size_error(items.len()) {
2273 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2274 }
2275 if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2276 return refused;
2277 }
2278 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2280 .into_iter()
2281 .map(|(id, fields)| {
2282 let fields = fields
2283 .into_iter()
2284 .map(|(k, v)| (camel_to_snake_case(&k), v))
2285 .collect();
2286 (id, fields)
2287 })
2288 .collect();
2289 match handler.service.bulk_partial_update(items).await {
2290 Ok(entities) => {
2291 let items: Vec<serde_json::Value> = entities
2292 .into_iter()
2293 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2294 .collect();
2295 let total = items.len();
2296 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2297 }
2298 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2299 }
2300 }
2301
2302 async fn bulk_patch_preview_handler(
2308 handler: Arc<Self>,
2309 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2310 ) -> impl axum::response::IntoResponse {
2311 use axum::{http::StatusCode, Json};
2312
2313 let items = req.into_items();
2314 if let Some(err) = batch_size_error(items.len()) {
2315 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkPreview>::error(err)));
2316 }
2317 if let Some(refused) = invalid_ids::<BulkPreview>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2318 return refused;
2319 }
2320 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2321 .into_iter()
2322 .map(|(id, fields)| {
2323 let fields = fields
2324 .into_iter()
2325 .map(|(k, v)| (camel_to_snake_case(&k), v))
2326 .collect();
2327 (id, fields)
2328 })
2329 .collect();
2330 let patched: HashMap<String, Vec<String>> = items
2331 .iter()
2332 .map(|(id, f)| (id.clone(), f.keys().cloned().collect()))
2333 .collect();
2334 match handler.service.preview_bulk_partial_update(items).await {
2335 Ok(Some(rows)) => {
2336 let rows = rows
2337 .into_iter()
2338 .map(|row| {
2339 let outcome = match row.outcome {
2340 Ok((before, after)) => Ok((
2341 secure::<E>(to_response_value(R::from(before)), None),
2342 secure::<E>(to_response_value(R::from(after)), None),
2343 )),
2344 Err(e) => Err((
2345 S::violations_of(&e)
2346 .and_then(|v| v.into_iter().next().map(|v| v.code))
2347 .unwrap_or_else(|| "refused".to_string()),
2348 e.to_string(),
2349 )),
2350 };
2351 (row.id, outcome)
2352 })
2353 .collect();
2354 (StatusCode::OK, Json(ApiResponse::ok(preview_answer(rows, &patched))))
2355 }
2356 Ok(None) => (
2357 StatusCode::NOT_IMPLEMENTED,
2358 Json(ApiResponse::error("this resource has no preview of a bulk change".to_string())),
2359 ),
2360 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2361 }
2362 }
2363
2364 async fn upsert_handler(
2365 handler: Arc<Self>,
2366 StrictJsonOrForm(dto): StrictJsonOrForm<C>,
2367 ) -> impl axum::response::IntoResponse {
2368 use axum::{http::StatusCode, Json};
2369
2370 if !S::supports_upsert() {
2373 return (
2374 StatusCode::NOT_IMPLEMENTED,
2375 Json(ApiResponse::<serde_json::Value>::error(crate::service::UPSERT_UNSUPPORTED)),
2376 );
2377 }
2378 match handler.service.upsert(dto).await {
2379 Ok(entity) => {
2380 let response = secure::<E>(to_response_value(R::from(entity)), None);
2381 (StatusCode::OK, Json(ApiResponse::ok(response)))
2382 }
2383 Err(e) => {
2384 Self::failure(&e, StatusCode::BAD_REQUEST)
2385 }
2386 }
2387 }
2388
2389 async fn list_deleted_handler(
2390 handler: Arc<Self>,
2391 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2392 access: Option<axum::Extension<AccessScope>>,
2393 ) -> impl axum::response::IntoResponse {
2394 use axum::{http::StatusCode, Json};
2395
2396 let params = params.normalized();
2397 if let Some(err) = pagination_depth_error(params.page, params.limit) {
2398 return (StatusCode::BAD_REQUEST, Json(bad_list_request(err)));
2399 }
2400 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2401 return (
2402 StatusCode::BAD_REQUEST,
2403 Json(bad_list_request(secret_named_message(&field))),
2404 );
2405 }
2406
2407 let fields = sparse_fields(¶ms.filters);
2408 let includes = include_relations(¶ms.filters);
2409 let scope = access.map(|axum::Extension(s)| s);
2410
2411 let filters = repository_filters(¶ms);
2412 match handler.service.list_deleted_filtered(params.page, params.limit, filters).await {
2413 Ok((entities, total)) => {
2414 let mut rows: Vec<serde_json::Value> = entities
2418 .into_iter()
2419 .map(|e| {
2420 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
2421 })
2422 .collect();
2423 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
2424 let items: Vec<serde_json::Value> =
2425 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2426 let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
2427 (StatusCode::OK, Json(response))
2428 }
2429 Err(e) => Self::list_failure(&e),
2430 }
2431 }
2432
2433 async fn restore_handler(
2434 handler: Arc<Self>,
2435 axum::extract::Path(id): axum::extract::Path<String>,
2436 ) -> impl axum::response::IntoResponse {
2437 use axum::{http::StatusCode, Json};
2438
2439 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2440 return refused;
2441 }
2442
2443 match handler.service.restore(&id).await {
2444 Ok(Some(entity)) => {
2445 let response = secure::<E>(to_response_value(R::from(entity)), None);
2446 (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
2447 }
2448 Ok(None) => {
2449 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2450 }
2451 Err(e) => {
2452 Self::failure(&e, StatusCode::BAD_REQUEST)
2453 }
2454 }
2455 }
2456
2457 async fn empty_trash_handler(
2458 handler: Arc<Self>,
2459 ) -> impl axum::response::IntoResponse {
2460 use axum::{http::StatusCode, Json};
2461
2462 match handler.service.empty_trash().await {
2463 Ok(count) => {
2464 (StatusCode::OK, Json(ApiResponse::success_with_message(
2465 serde_json::json!({ "deleted_count": count }),
2466 format!("Successfully deleted {} items from trash", count)
2467 )))
2468 }
2469 Err(e) => {
2470 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2471 }
2472 }
2473 }
2474
2475 async fn get_deleted_handler(
2477 handler: Arc<Self>,
2478 axum::extract::Path(id): axum::extract::Path<String>,
2479 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2480 access: Option<axum::Extension<AccessScope>>,
2481 ) -> impl axum::response::IntoResponse {
2482 use axum::{http::StatusCode, Json};
2483
2484 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2485 return refused;
2486 }
2487
2488 let fields = sparse_fields(¶ms.filters);
2489 let scope = access.map(|axum::Extension(s)| s);
2490
2491 match handler.service.get_deleted_by_id(&id).await {
2492 Ok(Some(entity)) => {
2493 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
2494 let value = project_sparse(secured, &fields);
2495 (StatusCode::OK, Json(ApiResponse::ok(value)))
2496 }
2497 Ok(None) => {
2498 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
2499 }
2500 Err(e) => {
2501 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2502 }
2503 }
2504 }
2505
2506 async fn permanent_delete_handler(
2508 handler: Arc<Self>,
2509 axum::extract::Path(id): axum::extract::Path<String>,
2510 ) -> axum::response::Response {
2511 use axum::{http::StatusCode, Json, response::IntoResponse};
2512
2513 if let Some(refused) = invalid_id::<R>(&id, "id") {
2514 return refused.into_response();
2515 }
2516
2517 match handler.service.get_deleted_by_id(&id).await {
2519 Ok(Some(_)) => {
2520 match handler.service.permanent_delete(&id).await {
2522 Ok(true) => {
2523 StatusCode::NO_CONTENT.into_response()
2525 }
2526 Ok(false) => {
2527 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
2528 .into_response()
2529 }
2530 Err(e) => {
2531 Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2532 }
2533 }
2534 }
2535 Ok(None) => {
2536 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
2537 &format!("{} in trash", S::entity_name()), &id
2538 ))).into_response()
2539 }
2540 Err(e) => {
2541 Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2542 }
2543 }
2544 }
2545
2546 async fn count_active_handler(
2548 handler: Arc<Self>,
2549 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2550 ) -> impl axum::response::IntoResponse {
2551 use axum::{http::StatusCode, Json};
2552
2553 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2557 return (
2558 StatusCode::BAD_REQUEST,
2559 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2560 );
2561 }
2562 match handler.service.count_active_filtered(repository_filters(¶ms)).await {
2563 Ok(count) => {
2564 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2565 }
2566 Err(e) => {
2567 if is_bad_query_error(&e.to_string()) {
2570 (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2571 } else {
2572 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2573 }
2574 }
2575 }
2576 }
2577
2578 async fn aggregate_handler(
2583 handler: Arc<Self>,
2584 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2585 ) -> impl axum::response::IntoResponse {
2586 use axum::{http::StatusCode, Json};
2587
2588 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2589 return (
2590 StatusCode::BAD_REQUEST,
2591 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2592 );
2593 }
2594 let spec = aggregate_spec(¶ms);
2595 match handler.service.aggregate(&spec, aggregate_filters(¶ms)).await {
2596 Ok(result) => {
2597 let render = |g: &backbone_orm::repository::AggregateGroup| {
2598 let mut out = serde_json::Map::new();
2602 out.insert("key".into(), match &g.key {
2603 Some(k) => serde_json::Value::String(k.clone()),
2604 None => serde_json::Value::Null,
2605 });
2606 if g.label.is_some() {
2607 out.insert(
2608 "label".into(),
2609 serde_json::Value::String(g.label.clone().unwrap()),
2610 );
2611 }
2612 out.insert("count".into(), serde_json::json!(g.count));
2613 for (compound, value) in &g.values {
2614 let Some((func, field)) = compound.split_once(':') else { continue };
2615 let slot = out
2616 .entry(func.to_string())
2617 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2618 if let Some(obj) = slot.as_object_mut() {
2619 obj.insert(field.to_string(), match value {
2620 Some(v) => serde_json::Value::String(v.clone()),
2624 None => serde_json::Value::Null,
2625 });
2626 }
2627 }
2628 serde_json::Value::Object(out)
2629 };
2630
2631 let body = serde_json::json!({
2632 "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
2633 "total": render(&result.total),
2634 "truncated": result.truncated,
2637 });
2638 (StatusCode::OK, Json(ApiResponse::ok(body)))
2639 }
2640 Err(e) => {
2641 if is_bad_query_error(&e.to_string()) {
2642 (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2643 } else {
2644 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2645 }
2646 }
2647 }
2648 }
2649
2650 async fn history_handler(
2661 handler: Arc<Self>,
2662 axum::extract::Path(id): axum::extract::Path<String>,
2663 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2664 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2665 ) -> impl axum::response::IntoResponse {
2666 use axum::{http::StatusCode, Json};
2667
2668 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2669 return refused;
2670 }
2671
2672 let Some(axum::Extension(provider)) = provider else {
2673 return (
2674 StatusCode::NOT_IMPLEMENTED,
2675 Json(ApiResponse::<serde_json::Value>::error(
2676 "history is not configured for this service".to_string(),
2677 )),
2678 );
2679 };
2680
2681 let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
2685 return (
2686 StatusCode::NOT_IMPLEMENTED,
2687 Json(ApiResponse::<serde_json::Value>::error(
2688 "this entity cannot name its table, so its history cannot be keyed".to_string(),
2689 )),
2690 );
2691 };
2692
2693 let limit = params.limit.clamp(1, 200);
2694 let offset = params.page.saturating_sub(1) * limit;
2695
2696 match provider.history(&table, &id, limit, offset).await {
2697 Ok(Some(entries)) => (
2698 StatusCode::OK,
2699 Json(ApiResponse::ok(serde_json::json!({
2700 "audited": true,
2701 "entries": entries,
2702 }))),
2703 ),
2704 Ok(None) => (
2705 StatusCode::OK,
2706 Json(ApiResponse::ok(serde_json::json!({
2707 "audited": false,
2710 "entries": serde_json::Value::Null,
2711 }))),
2712 ),
2713 Err(e) => error_at(StatusCode::INTERNAL_SERVER_ERROR, None, &e),
2714 }
2715 }
2716
2717 async fn count_deleted_handler(
2719 handler: Arc<Self>,
2720 ) -> impl axum::response::IntoResponse {
2721 use axum::{http::StatusCode, Json};
2722
2723 match handler.service.count_deleted().await {
2724 Ok(count) => {
2725 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2726 }
2727 Err(e) => {
2728 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2729 }
2730 }
2731 }
2732}
2733
2734pub trait BackboneHttpHandler<T>: Send + Sync {
2759 fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2761
2762 fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
2764
2765 fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2767
2768 fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
2770
2771 fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
2773
2774 fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
2776
2777 fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
2779
2780 fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
2782
2783 fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2785
2786 fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2788
2789 fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
2791
2792 fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2794}
2795
2796#[derive(Debug, Serialize, Deserialize)]
2802#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
2803pub struct PaginationRequest {
2804 pub page: u32,
2805 pub limit: u32,
2806 pub sort_by: Option<String>,
2807 pub sort_order: Option<String>,
2808}
2809
2810fn camel_to_snake_case(key: &str) -> String {
2826 let chars: Vec<char> = key.chars().collect();
2827 let mut result = String::with_capacity(key.len() + 2);
2828 for (i, &c) in chars.iter().enumerate() {
2829 if c.is_ascii_uppercase() {
2830 let prev = if i > 0 { chars[i - 1] } else { '\0' };
2831 let next = chars.get(i + 1).copied().unwrap_or('\0');
2832 let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
2836 let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
2837 if (crosses_lower || crosses_acronym)
2838 && !result.is_empty()
2839 && !result.ends_with('_')
2840 {
2841 result.push('_');
2842 }
2843 result.push(c.to_ascii_lowercase());
2844 } else {
2845 result.push(c);
2846 }
2847 }
2848 result
2849}
2850
2851#[cfg(test)]
2852mod tests {
2853 use super::*;
2854
2855 #[test]
2856 fn snake_case_input_passes_through_unchanged() {
2857 assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
2858 assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
2859 assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
2860 }
2861
2862 #[test]
2863 fn single_word_unchanged() {
2864 assert_eq!(camel_to_snake_case("name"), "name");
2865 assert_eq!(camel_to_snake_case("id"), "id");
2866 assert_eq!(camel_to_snake_case(""), "");
2867 }
2868
2869 #[test]
2870 fn camel_case_converts() {
2871 assert_eq!(camel_to_snake_case("isVip"), "is_vip");
2872 assert_eq!(camel_to_snake_case("userId"), "user_id");
2873 assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
2874 assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
2875 }
2876
2877 #[test]
2878 fn pascal_case_converts() {
2879 assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
2880 assert_eq!(camel_to_snake_case("UserId"), "user_id");
2881 }
2882
2883 #[test]
2884 fn acronym_runs_stay_together() {
2885 assert_eq!(camel_to_snake_case("IOError"), "io_error");
2888 assert_eq!(camel_to_snake_case("httpURL"), "http_url");
2889 assert_eq!(camel_to_snake_case("ABC"), "abc");
2890 assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
2891 }
2892
2893 #[test]
2894 fn digits_count_as_lowercase_for_boundary() {
2895 assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
2896 assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
2897 }
2898
2899 #[test]
2900 fn underscores_not_doubled() {
2901 assert_eq!(camel_to_snake_case("_Foo"), "_foo");
2902 assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
2903 }
2904
2905 #[test]
2906 fn shallow_pages_are_allowed() {
2907 assert!(pagination_depth_error(1, 100).is_none());
2909 assert!(pagination_depth_error(101, 100).is_none());
2911 }
2912
2913 #[test]
2914 fn pages_past_the_cap_are_rejected() {
2915 assert!(pagination_depth_error(102, 100).is_some());
2917 assert!(pagination_depth_error(1002, 10).is_some());
2919 }
2920
2921 #[test]
2922 fn oversized_page_size_is_clamped_before_the_check() {
2923 assert!(pagination_depth_error(102, 200).is_some());
2925 assert!(pagination_depth_error(101, 200).is_none());
2927 }
2928
2929 #[test]
2930 fn huge_page_number_does_not_overflow() {
2931 assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
2933 }
2934
2935 #[test]
2938 fn batch_size_within_limit_is_allowed() {
2939 assert!(batch_size_error(0).is_none());
2940 assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
2941 }
2942
2943 #[test]
2944 fn batch_size_over_limit_is_rejected() {
2945 assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
2946 }
2947
2948 #[test]
2949 fn a_bulk_preview_sorts_rows_by_what_the_patch_would_do() {
2950 use serde_json::json;
2951 let mut patched: HashMap<String, Vec<String>> = ["a", "b", "c"]
2952 .iter()
2953 .map(|id| (id.to_string(), vec!["category".to_string()]))
2954 .collect();
2955 patched.insert("d".into(), vec!["category_id".into()]);
2956 let out = preview_answer(
2957 vec![
2958 (
2959 "a".into(),
2960 Ok((
2961 json!({"category": "meal", "updated_at": "1"}),
2962 json!({"category": "travel", "updated_at": "2"}),
2963 )),
2964 ),
2965 ("b".into(), Ok((json!({"category": "travel"}), json!({"category": "travel"})))),
2966 (
2967 "d".into(),
2968 Ok((json!({"categoryId": "x"}), json!({"categoryId": "y"}))),
2969 ),
2970 ("c".into(), Err(("locked".into(), "a posted claim is locked".into()))),
2971 ],
2972 &patched,
2973 );
2974 assert_eq!(out.total, 4);
2975 assert_eq!(out.will_change.len(), 2);
2976 assert_eq!(out.will_change[1].changes[0].field, "category_id", "a camelCase response is read");
2977 let ch = &out.will_change[0].changes;
2978 assert_eq!(ch.len(), 1, "only the patched field is reported, not the timestamp");
2979 assert_eq!((ch[0].from.clone(), ch[0].to.clone()), (json!("meal"), json!("travel")));
2980 assert_eq!(out.unchanged, vec!["b".to_string()]);
2981 assert_eq!(out.refused[0].code, "locked");
2982 }
2983
2984 #[test]
2985 fn bulk_patch_request_parses_shared_shape() {
2986 let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
2987 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2988 let items = req.into_items();
2989 assert_eq!(items.len(), 2);
2990 assert_eq!(items[0].1.get("status").unwrap(), "void");
2992 assert_eq!(items[1].1.get("status").unwrap(), "void");
2993 assert_eq!(items[0].0, "a");
2994 assert_eq!(items[1].0, "b");
2995 }
2996
2997 #[test]
2998 fn bulk_patch_request_parses_per_item_shape() {
2999 let json = r#"{ "items": [
3000 { "id": "a", "patch": { "status": "void" } },
3001 { "id": "b", "patch": { "note": "late" } }
3002 ] }"#;
3003 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
3004 let items = req.into_items();
3005 assert_eq!(items.len(), 2);
3006 assert_eq!(items[0].0, "a");
3007 assert_eq!(items[0].1.get("status").unwrap(), "void");
3008 assert_eq!(items[1].0, "b");
3009 assert_eq!(items[1].1.get("note").unwrap(), "late");
3010 }
3011
3012 #[test]
3013 fn batch_ids_request_parses() {
3014 let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
3015 assert_eq!(req.ids, vec!["x", "y", "z"]);
3016 }
3017
3018 fn fields(q: &[(&str, &str)]) -> Vec<String> {
3021 let map: HashMap<String, String> =
3022 q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
3023 sparse_fields(&map)
3024 }
3025
3026 #[test]
3027 fn sparse_fields_parses_comma_list_and_trims() {
3028 assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
3029 }
3030
3031 #[test]
3032 fn sparse_fields_absent_or_empty_is_no_projection() {
3033 assert!(fields(&[]).is_empty());
3034 assert!(fields(&[("fields", "")]).is_empty());
3035 assert!(fields(&[("fields", " , ")]).is_empty());
3036 }
3037
3038 #[test]
3039 fn project_keeps_requested_keys_plus_id() {
3040 let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
3041 let out = project_sparse(v, &["name".into(), "basePrice".into()]);
3042 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
3043 }
3044
3045 #[test]
3046 fn project_always_includes_id_even_if_not_requested() {
3047 let v = serde_json::json!({ "id": "1", "name": "n" });
3048 let out = project_sparse(v, &["name".into()]);
3049 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
3050 }
3051
3052 #[test]
3053 fn project_ignores_unknown_keys() {
3054 let v = serde_json::json!({ "id": "1", "name": "n" });
3055 let out = project_sparse(v, &["name".into(), "nope".into()]);
3056 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
3057 }
3058
3059 #[test]
3060 fn project_empty_fields_returns_full_object() {
3061 let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
3062 let out = project_sparse(v.clone(), &[]);
3063 assert_eq!(out, v);
3064 }
3065
3066 #[test]
3067 fn project_non_object_returned_unchanged() {
3068 let v = serde_json::json!("scalar");
3069 assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
3070 }
3071
3072 fn owner_a() -> uuid::Uuid {
3077 uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
3078 }
3079 fn owner_b() -> uuid::Uuid {
3080 uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
3081 }
3082
3083 fn row() -> serde_json::Value {
3084 serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
3085 }
3086 const PRIV: &[&str] = &["hppPerUnit"];
3087
3088 struct Secretive;
3090 impl backbone_orm::EntityRepoMeta for Secretive {
3091 fn column_types() -> HashMap<String, String> {
3092 HashMap::new()
3093 }
3094 fn search_fields() -> &'static [&'static str] {
3095 &[]
3096 }
3097 fn secret_fields() -> &'static [&'static str] {
3098 &["tokenHash"]
3099 }
3100 fn private_fields() -> &'static [&'static str] {
3101 PRIV
3102 }
3103 fn owner_field() -> Option<&'static str> {
3104 Some("providerId")
3105 }
3106 fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
3107 match relation {
3108 "user" => &["passwordHash"],
3109 _ => &[],
3110 }
3111 }
3112 }
3113
3114 fn secret_row() -> serde_json::Value {
3115 let mut r = row();
3116 r["tokenHash"] = serde_json::json!("digest");
3117 r
3118 }
3119
3120 #[test]
3121 fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
3122 for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
3123 let out = secure::<Secretive>(secret_row(), scope.as_ref());
3124 assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
3125 assert!(out.get("name").is_some());
3126 }
3127 let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform));
3128 assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
3129 }
3130
3131 #[test]
3132 fn the_typed_sort_pair_reaches_the_repository_as_the_filter_sort() {
3133 let params = |pairs: &[(&str, &str)], by: Option<&str>, order: Option<&str>| ListQueryParams {
3134 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3135 sort_by: by.map(str::to_string),
3136 sort_order: order.map(str::to_string),
3137 ..Default::default()
3138 };
3139 let f = repository_filters(¶ms(&[], Some("priority"), Some("DESC")));
3140 assert_eq!(f.get("orderby").map(String::as_str), Some("priority:desc"));
3141 let f = repository_filters(¶ms(&[], Some("createdAt"), None));
3142 assert_eq!(f.get("orderby").map(String::as_str), Some("createdAt:asc"));
3143 let f = repository_filters(¶ms(&[("orderby[name]", "asc")], Some("priority"), Some("desc")));
3145 assert!(!f.contains_key("orderby"), "{f:?}");
3146 let parsed = backbone_orm::filter::parse_filters(
3148 &repository_filters(¶ms(&[], Some("name; drop table x"), None)),
3149 &HashMap::new(),
3150 None,
3151 )
3152 .unwrap();
3153 assert!(parsed.sorts.is_empty(), "{:?}", parsed.sorts);
3154 }
3155
3156 #[test]
3157 fn list_params_normalize_page_zero_and_an_oversized_limit() {
3158 let p = ListQueryParams { page: 0, limit: 1000, ..Default::default() }.normalized();
3159 assert_eq!((p.page, p.limit), (1, MAX_PER_PAGE));
3160 let p = ListQueryParams { page: 3, limit: 0, ..Default::default() }.normalized();
3161 assert_eq!((p.page, p.limit), (3, 1));
3162 }
3163
3164 #[test]
3165 fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
3166 let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
3167 let params = ListQueryParams {
3168 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3169 sort_by: sort_by.map(str::to_string),
3170 ..Default::default()
3171 };
3172 named_secret(¶ms, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
3173 };
3174 for pairs in [
3175 vec![("token_hash", "abc")],
3176 vec![("tokenHash[startwith]", "a")],
3177 vec![("orderby", "name,-token_hash")],
3178 vec![("orderby[token_hash]", "asc")],
3179 vec![("searchFields", "name,tokenHash")],
3180 vec![("min", "token_hash")],
3181 vec![("group_by", "tokenHash")],
3182 ] {
3183 assert_eq!(ask(&pairs, None).as_deref().map(camel_to_snake_case), Some("token_hash".into()), "{pairs:?}");
3184 }
3185 assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
3186 assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
3187 }
3188
3189 #[test]
3190 fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
3191 let out = secure::<Secretive>(secret_row(), None);
3192 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3193 }
3194
3195 #[test]
3196 fn a_routed_response_dto_loses_the_entitys_secrets() {
3197 let out = without_secrets::<Secretive, _>(secret_row());
3198 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3199 assert!(out.get("name").is_some());
3200 }
3201
3202 #[test]
3203 fn an_included_row_loses_the_related_models_secrets() {
3204 let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
3205 let out = related_row::<Secretive>("user", "users", raw.clone());
3206 assert!(out.get("passwordHash").is_none(), "{out}");
3207 assert_eq!(out["email"], "a@b.c");
3208 assert!(related_row::<Secretive>("other", "others", raw.clone()).get("passwordHash").is_some(), "only the named relation");
3209 backbone_orm::secret_registry::register("employee_probe.staff", &["passwordHash"]);
3211 assert!(related_row::<Secretive>("staff", "employee_probe.staff", raw).get("passwordHash").is_none(), "registered table");
3212 }
3213
3214 #[test]
3215 fn security_no_private_fields_is_noop() {
3216 let v = row();
3217 assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
3218 }
3219
3220 #[test]
3221 fn security_platform_sees_private() {
3222 let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
3223 assert!(out.get("hppPerUnit").is_some());
3224 }
3225
3226 #[test]
3227 fn security_owner_tenant_sees_private() {
3228 let out = apply_field_security(
3229 row(),
3230 Some(&AccessScope::Company(owner_a())),
3231 PRIV,
3232 Some("providerId"),
3233 );
3234 assert!(out.get("hppPerUnit").is_some());
3235 }
3236
3237 #[test]
3238 fn security_other_tenant_stripped() {
3239 let out = apply_field_security(
3240 row(),
3241 Some(&AccessScope::Company(owner_b())),
3242 PRIV,
3243 Some("providerId"),
3244 );
3245 assert!(out.get("hppPerUnit").is_none());
3246 assert!(out.get("name").is_some());
3247 }
3248
3249 #[test]
3250 fn security_absent_scope_fails_closed() {
3251 let out = apply_field_security(row(), None, PRIV, Some("providerId"));
3252 assert!(out.get("hppPerUnit").is_none());
3253 }
3254
3255 #[test]
3258 fn include_relations_parses_include_and_with() {
3259 let mut q = HashMap::new();
3260 q.insert("include".to_string(), "provider, outlet ".to_string());
3261 assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
3262 let mut q2 = HashMap::new();
3263 q2.insert("with".to_string(), "category".to_string());
3264 assert_eq!(include_relations(&q2), vec!["category"]);
3265 assert!(include_relations(&HashMap::new()).is_empty());
3266 }
3267
3268 #[test]
3269 fn snake_to_camel_converts() {
3270 assert_eq!(snake_to_camel("provider_id"), "providerId");
3271 assert_eq!(snake_to_camel("business_name"), "businessName");
3272 assert_eq!(snake_to_camel("id"), "id");
3273 }
3274
3275 #[test]
3276 fn camelize_keys_top_level_only() {
3277 let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
3278 let out = camelize_keys(v);
3279 assert!(out.get("providerId").is_some());
3280 assert!(out.get("metaData").unwrap().get("created_at").is_some());
3282 }
3283
3284 #[test]
3285 fn security_null_owner_only_platform_sees() {
3286 let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
3287 let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
3288 assert!(tenant.get("hppPerUnit").is_none());
3289 let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
3290 assert!(plat.get("hppPerUnit").is_some());
3291 }
3292
3293 #[test]
3294 fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
3295 let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
3296 let (status, axum::Json(body)) =
3297 write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
3298 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
3299 let json = serde_json::to_value(&body).unwrap();
3300 assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
3301 assert_eq!(json["violations"][0]["path"], "status");
3302 assert_eq!(json["violations"][0]["code"], "field_not_writable");
3303
3304 let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
3306 assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
3307 let json = serde_json::to_value(&body).unwrap();
3308 assert_eq!(json["error"], "boom");
3309 assert_eq!(json["violations"][0]["code"], "invalid_request");
3310 assert_eq!(json["violations"][0]["path"], "");
3311 let (_, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::INTERNAL_SERVER_ERROR);
3313 assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
3314 }
3315}
3316
3317#[cfg(test)]
3318mod internal_key_tests {
3319 use super::*;
3320
3321 fn params_with(filters: &[(&str, &str)]) -> ListQueryParams {
3322 ListQueryParams {
3323 page: 1,
3324 limit: 20,
3325 sort_by: None,
3326 sort_order: None,
3327 search: None,
3328 status: None,
3329 filters: filters
3330 .iter()
3331 .map(|(k, v)| (k.to_string(), v.to_string()))
3332 .collect(),
3333 }
3334 }
3335
3336 #[test]
3337 fn internal_keys_never_reach_the_repository() {
3338 let params = params_with(&[
3339 ("__base_condition", "EXISTS(SELECT 1)"),
3340 ("__base_condition[eq]", "1=1"),
3341 ("__anything", "x"),
3342 ("name", "acme"),
3343 ]);
3344 let filters = repository_filters(¶ms);
3345 assert_eq!(filters.len(), 1, "{filters:?}");
3346 assert_eq!(filters.get("name").map(String::as_str), Some("acme"));
3347 let filters = aggregate_filters(¶ms);
3348 assert!(filters.keys().all(|k| !k.starts_with("__")), "{filters:?}");
3349 }
3350}
3351
3352#[cfg(test)]
3353mod service_error_response_tests {
3354 use super::*;
3355 use crate::service::ServiceError;
3356
3357 #[test]
3358 fn a_refused_field_is_422_with_its_violation_never_500() {
3359 let err = ServiceError::Violations(vec![crate::violation::Violation::new(
3360 "state",
3361 "field_not_writable",
3362 "state cannot be changed by a generic write",
3363 )]);
3364 let (status, body) = service_error_response::<()>(&err);
3365 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
3366 assert_eq!(body.0.violations.as_ref().map(Vec::len), Some(1));
3367 }
3368
3369 #[test]
3370 fn other_errors_keep_their_own_status() {
3371 use axum::http::StatusCode;
3372 let status = |e: ServiceError| service_error_response::<()>(&e).0;
3373 assert_eq!(
3374 status(ServiceError::Validation("bad".into())),
3375 StatusCode::BAD_REQUEST
3376 );
3377 assert_eq!(status(ServiceError::NotFound), StatusCode::NOT_FOUND);
3378 assert_eq!(
3379 status(ServiceError::AlreadyExists("x".into())),
3380 StatusCode::CONFLICT
3381 );
3382 assert_eq!(
3383 status(ServiceError::Internal("boom".into())),
3384 StatusCode::INTERNAL_SERVER_ERROR
3385 );
3386 }
3387}