1use crate::extractors::JsonOrForm;
9use serde::{de::DeserializeOwned, Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12
13#[derive(Debug, Serialize, Deserialize)]
19#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20pub struct ApiResponse<T> {
21 pub success: bool,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub data: Option<T>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub message: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub error: Option<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub violations: Option<Vec<crate::violation::Violation>>,
33}
34
35impl<T> ApiResponse<T> {
36 pub fn rejected(error: impl Into<String>, violations: Vec<crate::violation::Violation>) -> Self {
39 Self {
40 success: false,
41 data: None,
42 message: None,
43 error: Some(error.into()),
44 violations: (!violations.is_empty()).then_some(violations),
45 }
46 }
47
48 pub fn success(data: T, message: Option<String>) -> Self {
50 Self {
51 success: true,
52 data: Some(data),
53 message,
54 error: None,
55 violations: None,
56 }
57 }
58
59 pub fn ok(data: T) -> Self {
61 Self {
62 success: true,
63 data: Some(data),
64 message: None,
65 error: None,
66 violations: None,
67 }
68 }
69
70 pub fn success_with_message(data: T, message: impl Into<String>) -> Self {
71 Self {
72 success: true,
73 data: Some(data),
74 message: Some(message.into()),
75 error: None,
76 violations: None,
77 }
78 }
79
80 pub fn error(error: impl Into<String>) -> Self {
81 Self {
82 success: false,
83 data: None,
84 message: None,
85 error: Some(error.into()),
86 violations: None,
87 }
88 }
89
90 pub fn not_found(entity: &str, id: &str) -> Self {
91 Self {
92 success: false,
93 data: None,
94 message: None,
95 error: Some(format!("{} with id '{}' not found", entity, id)),
96 violations: None,
97 }
98 }
99}
100
101fn write_error<T>(
104 violations: Option<Vec<crate::violation::Violation>>,
105 err: &impl std::fmt::Display,
106 fallback: axum::http::StatusCode,
107) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
108 match violations {
109 Some(v) => (axum::http::StatusCode::UNPROCESSABLE_ENTITY, axum::Json(ApiResponse::rejected(err.to_string(), v))),
110 None => (fallback, axum::Json(ApiResponse::error(err.to_string()))),
111 }
112}
113
114#[derive(Debug, Deserialize, Default, Clone)]
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117pub struct ListQueryParams {
118 #[serde(default = "default_page")]
119 pub page: u32,
120 #[serde(default = "default_limit")]
121 pub limit: u32,
122 #[serde(default)]
123 pub sort_by: Option<String>,
124 #[serde(default)]
125 pub sort_order: Option<String>,
126 #[serde(default)]
127 pub search: Option<String>,
128 #[serde(default)]
129 pub status: Option<String>,
130 #[serde(flatten)]
131 pub filters: HashMap<String, String>,
132}
133
134fn default_page() -> u32 { 1 }
135fn default_limit() -> u32 { 20 }
136
137const RESERVED_QUERY_KEYS: [&str; 3] = ["fields", "include", "with"];
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct HistoryEntry {
155 pub occurred_at: String,
156 pub action: String,
158 pub actor: String,
159 pub changed: serde_json::Value,
160 pub reason: Option<String>,
161 pub correlation_id: Option<String>,
162}
163
164#[async_trait::async_trait]
171pub trait HistoryProvider: Send + Sync {
172 async fn history(
180 &self,
181 table: &str,
182 id: &str,
183 limit: u32,
184 offset: u32,
185 ) -> Result<Option<Vec<HistoryEntry>>, String>;
186}
187
188const AGGREGATE_QUERY_KEYS: [&str; 7] =
194 ["group_by", "sum", "avg", "min", "max", "group_limit", "group_label"];
195
196fn aggregate_spec(params: &ListQueryParams) -> backbone_orm::repository::AggregateSpec {
202 use backbone_orm::repository::{AggregateFn, AggregateSpec};
203
204 let mut reductions = Vec::new();
205 for (key, func) in [
206 ("sum", AggregateFn::Sum),
207 ("avg", AggregateFn::Avg),
208 ("min", AggregateFn::Min),
209 ("max", AggregateFn::Max),
210 ] {
211 if let Some(raw) = params.filters.get(key) {
212 for field in raw.split(',').map(str::trim).filter(|f| !f.is_empty()) {
213 reductions.push((func, field.to_string()));
214 }
215 }
216 }
217
218 AggregateSpec {
219 group_by: params
220 .filters
221 .get("group_by")
222 .map(|g| g.trim().to_string())
223 .filter(|g| !g.is_empty()),
224 reductions,
225 group_limit: params
226 .filters
227 .get("group_limit")
228 .and_then(|l| l.trim().parse::<usize>().ok())
229 .unwrap_or(0),
230 label_field: params
231 .filters
232 .get("group_label")
233 .map(|l| l.trim().to_string())
234 .filter(|l| !l.is_empty()),
235 label_relation: None,
236 }
237}
238
239fn named_secret(params: &ListQueryParams, secret_fields: &[&str]) -> Option<String> {
246 if secret_fields.is_empty() {
247 return None;
248 }
249 let secrets: Vec<String> = secret_fields.iter().map(|f| camel_to_snake_case(f)).collect();
250 const COLUMN_LISTS: [&str; 10] = [
251 "orderby", "sort", "searchfields", "search_fields", "group_by", "sum", "avg", "min", "max", "group_label",
252 ];
253 let mut names: Vec<String> = Vec::new();
254 for (key, value) in ¶ms.filters {
255 match key.find('[') {
256 Some(open) => {
257 names.push(key[..open].to_string());
258 names.push(key[open + 1..].trim_end_matches(']').to_string());
259 }
260 None => names.push(key.clone()),
261 }
262 if COLUMN_LISTS.contains(&key.to_ascii_lowercase().as_str()) {
263 names.extend(value.split(',').map(|v| v.trim().trim_start_matches('-').to_string()));
264 }
265 }
266 names.extend(params.sort_by.iter().cloned());
267 names
268 .into_iter()
269 .find(|n| secrets.contains(&camel_to_snake_case(n.trim())))
270}
271
272fn secret_named_message(field: &str) -> String {
274 format!("'{field}' is a secret field: it cannot be filtered, sorted, searched or aggregated")
275}
276
277fn repository_filters(params: &ListQueryParams) -> HashMap<String, String> {
288 let mut filters = params.filters.clone();
289 for key in RESERVED_QUERY_KEYS {
290 filters.remove(key);
291 }
292 if let Some(search) = params.search.clone() {
293 filters.insert("search".to_string(), search);
294 }
295 if let Some(status) = params.status.clone() {
296 filters.insert("status".to_string(), status);
297 }
298 filters
299}
300
301fn aggregate_filters(params: &ListQueryParams) -> HashMap<String, String> {
304 let mut filters = repository_filters(params);
305 for key in AGGREGATE_QUERY_KEYS {
306 filters.remove(key);
307 }
308 filters
309}
310
311fn sparse_fields(query: &HashMap<String, String>) -> Vec<String> {
312 query
313 .get("fields")
314 .map(|s| {
315 s.split(',')
316 .map(str::trim)
317 .filter(|f| !f.is_empty())
318 .map(str::to_string)
319 .collect()
320 })
321 .unwrap_or_default()
322}
323
324fn to_response_value<R: Serialize>(r: R) -> serde_json::Value {
327 serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
328}
329
330fn project_sparse(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
334 if fields.is_empty() {
335 return value;
336 }
337 if let serde_json::Value::Object(map) = &mut value {
338 map.retain(|k, _| k == "id" || fields.iter().any(|f| f == k));
339 }
340 value
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub enum AccessScope {
367 Platform,
369 Company(uuid::Uuid),
371}
372
373impl AccessScope {
374 pub fn company(&self) -> Option<uuid::Uuid> {
380 match self {
381 AccessScope::Platform => None,
382 AccessScope::Company(id) => Some(*id),
383 }
384 }
385}
386
387fn strip_secrets(mut value: serde_json::Value, secret_fields: &[&str]) -> serde_json::Value {
397 if let serde_json::Value::Object(map) = &mut value {
398 for f in secret_fields {
399 map.remove(*f);
400 }
401 }
402 value
403}
404
405fn secure<E: backbone_orm::EntityRepoMeta>(
409 value: serde_json::Value,
410 scope: Option<&AccessScope>,
411) -> serde_json::Value {
412 apply_field_security(
413 strip_secrets(value, E::secret_fields()),
414 scope,
415 E::private_fields(),
416 E::owner_field(),
417 )
418}
419
420pub fn without_secrets<E: backbone_orm::EntityRepoMeta, R: Serialize>(response: R) -> serde_json::Value {
424 secure::<E>(to_response_value(response), None)
425}
426
427fn related_row<E: backbone_orm::EntityRepoMeta>(
431 relation: &str,
432 table: &str,
433 obj: serde_json::Value,
434) -> serde_json::Value {
435 let row = strip_secrets(camelize_keys(obj), E::relation_secret_fields(relation));
436 strip_secrets(row, &backbone_orm::secret_registry::secret_fields_of(table))
438}
439
440fn apply_field_security(
441 mut value: serde_json::Value,
442 scope: Option<&AccessScope>,
443 private_fields: &[&str],
444 owner_field: Option<&str>,
445) -> serde_json::Value {
446 if private_fields.is_empty() {
447 return value;
448 }
449 let can_see_private = match scope {
450 Some(AccessScope::Platform) => true,
451 Some(AccessScope::Company(id)) => owner_field
452 .and_then(|f| value.get(f))
453 .and_then(|v| v.as_str())
454 .and_then(|owner| uuid::Uuid::parse_str(owner).ok())
458 .is_some_and(|owner| owner == *id),
459 None => false,
460 };
461 if !can_see_private {
462 if let serde_json::Value::Object(map) = &mut value {
463 for f in private_fields {
464 map.remove(*f);
465 }
466 }
467 }
468 value
469}
470
471fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
473 query
474 .get("include")
475 .or_else(|| query.get("with"))
476 .map(|s| {
477 s.split(',')
478 .map(str::trim)
479 .filter(|s| !s.is_empty())
480 .map(str::to_string)
481 .collect()
482 })
483 .unwrap_or_default()
484}
485
486fn snake_to_camel(s: &str) -> String {
487 let mut out = String::with_capacity(s.len());
488 let mut upper = false;
489 for c in s.chars() {
490 if c == '_' {
491 upper = true;
492 } else if upper {
493 out.extend(c.to_uppercase());
494 upper = false;
495 } else {
496 out.push(c);
497 }
498 }
499 out
500}
501
502fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
505 match v {
506 serde_json::Value::Object(m) => serde_json::Value::Object(
507 m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
508 ),
509 other => other,
510 }
511}
512
513async fn expand_includes<S, E, C, U>(
523 service: &S,
524 rows: &mut [serde_json::Value],
525 includes: &[String],
526) where
527 S: CrudService<E, C, U>,
528 E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
529 C: Send + Sync + 'static,
530 U: Send + Sync + 'static,
531{
532 if includes.is_empty() || rows.is_empty() {
533 return;
534 }
535 for (rel_name, table, fk_field) in E::relations() {
536 if !includes.iter().any(|i| i == rel_name) {
537 continue;
538 }
539 let mut ids: Vec<String> = rows
540 .iter()
541 .filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
542 .collect();
543 ids.sort();
544 ids.dedup();
545 if ids.is_empty() {
546 continue;
547 }
548 let related = service.fetch_related_json(table, &ids).await;
549 let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
550 for obj in related {
551 if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
552 by_id.insert(id, related_row::<E>(rel_name, table, obj));
553 }
554 }
555 for r in rows.iter_mut() {
556 let related_obj = r
557 .get(fk_field)
558 .and_then(|v| v.as_str())
559 .and_then(|id| by_id.get(id).cloned())
560 .unwrap_or(serde_json::Value::Null);
561 if let serde_json::Value::Object(m) = r {
562 m.insert((*rel_name).to_string(), related_obj);
563 }
564 }
565 }
566}
567
568pub const MAX_PER_PAGE: u32 = 100;
572
573pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
578
579fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
585 let effective_limit = limit.clamp(1, MAX_PER_PAGE);
586 let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
587 if offset > MAX_PAGINATION_OFFSET {
588 Some(format!(
589 "Result set too deep: offset {offset} exceeds the maximum of \
590 {MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
591 ))
592 } else {
593 None
594 }
595}
596
597pub use crate::service::MAX_BATCH_SIZE;
603
604fn batch_size_error(count: usize) -> Option<String> {
607 if count > MAX_BATCH_SIZE {
608 Some(format!(
609 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
610 ))
611 } else {
612 None
613 }
614}
615
616fn is_bad_query_error(msg: &str) -> bool {
625 let m = msg.to_lowercase();
626 m.contains("does not exist")
627 || m.contains("invalid input syntax")
628 || m.contains("42703")
629 || m.contains("not a column of this entity")
633 || m.contains("needs a numeric column")
634}
635
636#[derive(Debug, Serialize, Deserialize, Clone)]
638#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
639pub struct PaginationResponse {
640 pub total: u64,
641 pub page: u32,
642 pub limit: u32,
643 pub total_pages: u32,
644 #[serde(skip_serializing_if = "Option::is_none")]
648 pub next_cursor: Option<String>,
649 #[serde(skip_serializing_if = "Option::is_none")]
651 pub prev_cursor: Option<String>,
652 #[serde(skip_serializing_if = "Option::is_none")]
654 pub has_more: Option<bool>,
655}
656
657impl PaginationResponse {
658 pub fn new(total: u64, page: u32, limit: u32) -> Self {
659 let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
660 Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
661 }
662
663 pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
667 Self {
668 total: info.total,
669 page: info.page,
670 limit: info.per_page,
671 total_pages: info.total_pages,
672 next_cursor: info.next_cursor.clone(),
673 prev_cursor: info.prev_cursor.clone(),
674 has_more: info.has_more,
675 }
676 }
677}
678
679#[derive(Debug, Serialize)]
681#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
682pub struct PaginatedResponse<T> {
683 pub data: Vec<T>,
684 pub meta: PaginationResponse,
685}
686
687#[derive(Debug, Serialize)]
690#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
691pub struct PaginatedApiResponse<T> {
692 pub success: bool,
693 pub data: Vec<T>,
694 pub meta: PaginationResponse,
695 #[serde(skip_serializing_if = "Option::is_none")]
696 pub error: Option<String>,
697}
698
699impl<T> PaginatedApiResponse<T> {
700 pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
703 Self {
704 success: true,
705 data,
706 meta: PaginationResponse::from_info(info),
707 error: None,
708 }
709 }
710
711 pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
713 Self {
714 success: true,
715 data,
716 meta: PaginationResponse::new(total, page, limit),
717 error: None,
718 }
719 }
720
721 pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
723 Self {
724 success: true,
725 data: resp.data,
726 meta: resp.meta,
727 error: None,
728 }
729 }
730
731 pub fn error(error: impl Into<String>) -> Self {
733 Self {
734 success: false,
735 data: Vec::new(),
736 meta: PaginationResponse::new(0, 0, 0),
737 error: Some(error.into()),
738 }
739 }
740}
741
742#[derive(Debug, Serialize, Deserialize)]
744#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
745pub struct BulkCreateRequest<T> {
746 pub items: Vec<T>,
747}
748
749#[derive(Debug, Serialize, Deserialize)]
751#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
752pub struct BulkResponse<T> {
753 pub items: Vec<T>,
754 pub total: usize,
755 pub failed: usize,
756 pub errors: Vec<String>,
757}
758
759#[derive(Debug, Serialize, Deserialize)]
761#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
762pub struct UpsertRequest<T> {
763 pub entity: T,
764 pub create_if_not_exists: bool,
765}
766
767#[derive(Debug, Serialize, Deserialize)]
770#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
771pub struct BatchIdsRequest {
772 pub ids: Vec<String>,
773}
774
775#[derive(Debug, Deserialize)]
778#[serde(bound = "U: DeserializeOwned")]
779#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
780pub struct BulkUpdateItem<U> {
781 pub id: String,
782 #[serde(flatten)]
786 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
787 pub data: U,
788}
789
790#[derive(Debug, Deserialize)]
792#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
793pub struct BulkPatchItem {
794 pub id: String,
795 pub patch: HashMap<String, serde_json::Value>,
796}
797
798#[derive(Debug, Deserialize)]
801#[serde(untagged)]
802#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
803pub enum BulkPatchRequest {
804 Shared {
806 ids: Vec<String>,
807 patch: HashMap<String, serde_json::Value>,
808 },
809 PerItem { items: Vec<BulkPatchItem> },
811}
812
813impl BulkPatchRequest {
814 fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
816 match self {
817 BulkPatchRequest::Shared { ids, patch } => {
818 ids.into_iter().map(|id| (id, patch.clone())).collect()
819 }
820 BulkPatchRequest::PerItem { items } => {
821 items.into_iter().map(|it| (it.id, it.patch)).collect()
822 }
823 }
824 }
825}
826
827#[derive(Debug, Serialize, Deserialize)]
829#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
830pub struct FilterOptions {
831 pub filters: HashMap<String, String>,
832 pub sort_by: Option<String>,
833 pub sort_order: Option<SortOrder>,
834}
835
836#[derive(Debug, Serialize, Deserialize, Clone, Default)]
838#[serde(rename_all = "lowercase")]
839#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
840pub enum SortOrder {
841 #[default]
842 Asc,
843 Desc,
844}
845
846#[derive(Debug, Serialize, Deserialize)]
848#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
849pub struct ListRequest {
850 pub page: Option<u32>,
851 pub limit: Option<u32>,
852 pub sort_by: Option<String>,
853 pub sort_order: Option<SortOrder>,
854 pub filters: Option<HashMap<String, String>>,
855}
856
857impl Default for ListRequest {
858 fn default() -> Self {
859 Self {
860 page: Some(1),
861 limit: Some(20),
862 sort_by: None,
863 sort_order: None,
864 filters: None,
865 }
866 }
867}
868
869#[async_trait::async_trait]
893pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
894where
895 Entity: Send + Sync + 'static,
898 CreateDto: Send + Sync + 'static,
899 UpdateDto: Send + Sync + 'static,
900{
901 type Error: std::error::Error + Send + Sync;
903
904 fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
907 let _ = err;
908 None
909 }
910
911 fn entity_name() -> &'static str;
913
914 async fn fetch_related_json(
917 &self,
918 _table: &str,
919 _ids: &[String],
920 ) -> Vec<serde_json::Value> {
921 Vec::new()
922 }
923
924 async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
926
927 async fn list_with_info(
931 &self,
932 page: u32,
933 limit: u32,
934 filters: HashMap<String, String>,
935 ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
936 let (rows, total) = self.list(page, limit, filters).await?;
937 Ok((
938 rows,
939 backbone_orm::repository::PaginationInfo::new(page, limit, total),
940 ))
941 }
942
943 async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
945
946 async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
948
949 async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
951
952 async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
954
955 async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
957
958 async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
960
961 async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
963
964 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
966
967 async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
969
970 async fn empty_trash(&self) -> Result<u64, Self::Error>;
972
973 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
975
976 async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
978
979 async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
981
982 async fn count_active(&self) -> Result<u64, Self::Error>;
984
985 fn table_name(&self) -> Option<&str> {
992 None
993 }
994
995 async fn aggregate(
1000 &self,
1001 spec: &backbone_orm::repository::AggregateSpec,
1002 filters: HashMap<String, String>,
1003 ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
1004
1005 async fn count_active_filtered(
1018 &self,
1019 filters: HashMap<String, String>,
1020 ) -> Result<u64, Self::Error> {
1021 self.list(1, 1, filters).await.map(|(_, total)| total)
1022 }
1023
1024 async fn count_deleted(&self) -> Result<u64, Self::Error>;
1026
1027 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1036 let mut n = 0;
1037 for id in ids {
1038 if self.soft_delete(&id).await? {
1039 n += 1;
1040 }
1041 }
1042 Ok(n)
1043 }
1044
1045 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1047 let mut out = Vec::with_capacity(ids.len());
1048 for id in ids {
1049 if let Some(e) = self.restore(&id).await? {
1050 out.push(e);
1051 }
1052 }
1053 Ok(out)
1054 }
1055
1056 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1058 let mut n = 0;
1059 for id in ids {
1060 if self.permanent_delete(&id).await? {
1061 n += 1;
1062 }
1063 }
1064 Ok(n)
1065 }
1066
1067 async fn restore_all(&self) -> Result<u64, Self::Error> {
1072 Ok(0)
1073 }
1074
1075 async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1077 let mut out = Vec::with_capacity(items.len());
1078 for (id, dto) in items {
1079 if let Some(e) = self.update(&id, dto).await? {
1080 out.push(e);
1081 }
1082 }
1083 Ok(out)
1084 }
1085
1086 async fn bulk_partial_update(
1088 &self,
1089 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1090 ) -> Result<Vec<Entity>, Self::Error> {
1091 let mut out = Vec::with_capacity(items.len());
1092 for (id, fields) in items {
1093 if let Some(e) = self.partial_update(&id, fields).await? {
1094 out.push(e);
1095 }
1096 }
1097 Ok(out)
1098 }
1099}
1100
1101pub struct BackboneCrudHandler<S, E, C, U, R>
1124where
1125 S: CrudService<E, C, U> + 'static,
1126 E: Serialize + Send + Sync + 'static,
1127 C: DeserializeOwned + Send + Sync + 'static,
1128 U: DeserializeOwned + Send + Sync + 'static,
1129 R: From<E> + Serialize + Send + Sync + 'static,
1130{
1131 service: Arc<S>,
1132 _phantom: std::marker::PhantomData<(E, C, U, R)>,
1133}
1134
1135impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1136where
1137 S: CrudService<E, C, U> + 'static,
1138 E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1139 C: DeserializeOwned + Send + Sync + 'static,
1140 U: DeserializeOwned + Send + Sync + 'static,
1141 R: From<E> + Serialize + Send + Sync + 'static,
1142{
1143 pub fn new(service: Arc<S>) -> Self {
1144 Self {
1145 service,
1146 _phantom: std::marker::PhantomData,
1147 }
1148 }
1149
1150 pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1174 where
1175 S: Clone,
1176 {
1177 Self::read_routes(service.clone(), base_path)
1178 .merge(Self::write_routes(service, base_path))
1179 }
1180
1181 pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1195 where
1196 S: Clone,
1197 {
1198 if let Some(table) = service.table_name() {
1201 backbone_orm::secret_registry::register(table, E::secret_fields());
1202 }
1203 use axum::{
1204 extract::{Path, Query},
1205 routing::get,
1206 Extension, Router,
1207 };
1208
1209 let handler = Arc::new(Self::new(service));
1210
1211 Router::new()
1212 .route(base_path, get({
1214 let h = handler.clone();
1215 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1216 Self::list_handler(h, query, access).await
1217 }
1218 }))
1219 .route(&format!("{}/trash", base_path), get({
1221 let h = handler.clone();
1222 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1223 Self::list_deleted_handler(h, query, access).await
1224 }
1225 }))
1226 .route(&format!("{}/:id", base_path), get({
1228 let h = handler.clone();
1229 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1230 Self::get_handler(h, path, query, access).await
1231 }
1232 }))
1233 .route(&format!("{}/:id/deleted", base_path), get({
1235 let h = handler.clone();
1236 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1237 Self::get_deleted_handler(h, path, query, access).await
1238 }
1239 }))
1240 .route(&format!("{}/:id/history", base_path), get({
1243 let h = handler.clone();
1244 move |path: axum::extract::Path<String>,
1245 query: axum::extract::Query<ListQueryParams>,
1246 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
1247 Self::history_handler(h, path, query, provider).await
1248 }
1249 }))
1250 .route(&format!("{}/aggregate", base_path), get({
1252 let h = handler.clone();
1253 move |query: axum::extract::Query<ListQueryParams>| async move {
1254 Self::aggregate_handler(h, query).await
1255 }
1256 }))
1257 .route(&format!("{}/count", base_path), get({
1258 let h = handler.clone();
1259 move |query: axum::extract::Query<ListQueryParams>| async move {
1260 Self::count_active_handler(h, query).await
1261 }
1262 }))
1263 .route(&format!("{}/trash/count", base_path), get({
1265 let h = handler.clone();
1266 move || async move {
1267 Self::count_deleted_handler(h).await
1268 }
1269 }))
1270 }
1271
1272 pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1289 where
1290 S: Clone,
1291 {
1292 if let Some(table) = service.table_name() {
1295 backbone_orm::secret_registry::register(table, E::secret_fields());
1296 }
1297 use axum::{
1298 extract::Path,
1299 routing::{delete, patch, post, put},
1300 Router,
1301 };
1302
1303 let handler = Arc::new(Self::new(service));
1304
1305 Router::new()
1306 .route(base_path, post({
1308 let h = handler.clone();
1309 move |body: JsonOrForm<C>| async move {
1310 Self::create_handler(h, body).await
1311 }
1312 }))
1313 .route(&format!("{}/bulk", base_path), post({
1315 let h = handler.clone();
1316 move |body: JsonOrForm<Vec<C>>| async move {
1317 Self::bulk_create_handler(h, body).await
1318 }
1319 }))
1320 .route(&format!("{}/upsert", base_path), post({
1322 let h = handler.clone();
1323 move |body: JsonOrForm<C>| async move {
1324 Self::upsert_handler(h, body).await
1325 }
1326 }))
1327 .route(&format!("{}/delete/bulk", base_path), post({
1329 let h = handler.clone();
1330 move |body: JsonOrForm<BatchIdsRequest>| async move {
1331 Self::bulk_delete_handler(h, body).await
1332 }
1333 }))
1334 .route(&format!("{}/restore/bulk", base_path), post({
1336 let h = handler.clone();
1337 move |body: JsonOrForm<BatchIdsRequest>| async move {
1338 Self::bulk_restore_handler(h, body).await
1339 }
1340 }))
1341 .route(&format!("{}/restore/all", base_path), post({
1343 let h = handler.clone();
1344 move || async move {
1345 Self::restore_all_handler(h).await
1346 }
1347 }))
1348 .route(&format!("{}/trash/bulk", base_path), delete({
1351 let h = handler.clone();
1352 move |body: JsonOrForm<BatchIdsRequest>| async move {
1353 Self::bulk_permanent_delete_handler(h, body).await
1354 }
1355 }))
1356 .route(&format!("{}/bulk", base_path), put({
1358 let h = handler.clone();
1359 move |body: JsonOrForm<Vec<BulkUpdateItem<U>>>| async move {
1360 Self::bulk_update_handler(h, body).await
1361 }
1362 }))
1363 .route(&format!("{}/bulk", base_path), patch({
1365 let h = handler.clone();
1366 move |body: JsonOrForm<BulkPatchRequest>| async move {
1367 Self::bulk_patch_handler(h, body).await
1368 }
1369 }))
1370 .route(&format!("{}/empty", base_path), delete({
1372 let h = handler.clone();
1373 move || async move {
1374 Self::empty_trash_handler(h).await
1375 }
1376 }))
1377 .route(&format!("{}/trash/:id", base_path), delete({
1379 let h = handler.clone();
1380 move |path: Path<String>| async move {
1381 Self::permanent_delete_handler(h, path).await
1382 }
1383 }))
1384 .route(&format!("{}/:id", base_path), put({
1386 let h = handler.clone();
1387 move |path: Path<String>, body: JsonOrForm<U>| async move {
1388 Self::update_handler(h, path, body).await
1389 }
1390 }))
1391 .route(&format!("{}/:id", base_path), patch({
1393 let h = handler.clone();
1394 move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
1395 Self::partial_update_handler(h, path, body).await
1396 }
1397 }))
1398 .route(&format!("{}/:id", base_path), delete({
1400 let h = handler.clone();
1401 move |path: Path<String>| async move {
1402 Self::delete_handler(h, path).await
1403 }
1404 }))
1405 .route(&format!("{}/:id/restore", base_path), post({
1407 let h = handler.clone();
1408 move |path: Path<String>| async move {
1409 Self::restore_handler(h, path).await
1410 }
1411 }))
1412 }
1413
1414 async fn list_handler(
1419 handler: Arc<Self>,
1420 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1421 access: Option<axum::Extension<AccessScope>>,
1422 ) -> impl axum::response::IntoResponse {
1423 use axum::{http::StatusCode, Json};
1424
1425 if let Some(err) = pagination_depth_error(params.page, params.limit) {
1426 return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1427 }
1428 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1429 return (
1430 StatusCode::BAD_REQUEST,
1431 Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1432 );
1433 }
1434
1435 let fields = sparse_fields(¶ms.filters);
1438 let includes = include_relations(¶ms.filters);
1439 let scope = access.map(|axum::Extension(s)| s);
1440
1441 let filters = repository_filters(¶ms);
1442
1443 match handler
1444 .service
1445 .list_with_info(params.page, params.limit, filters)
1446 .await
1447 {
1448 Ok((entities, info)) => {
1449 let mut rows: Vec<serde_json::Value> = entities
1452 .into_iter()
1453 .map(|e| {
1454 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1455 })
1456 .collect();
1457 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1458 let items: Vec<serde_json::Value> =
1459 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1460 let response = PaginatedApiResponse::ok_with_info(items, &info);
1461 (StatusCode::OK, Json(response))
1462 }
1463 Err(e) => {
1464 let msg = e.to_string();
1465 if is_bad_query_error(&msg) {
1466 (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
1467 format!("Invalid query parameter or filter: {msg}"),
1468 )))
1469 } else {
1470 (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
1471 }
1472 }
1473 }
1474 }
1475
1476 async fn create_handler(
1477 handler: Arc<Self>,
1478 JsonOrForm(dto): JsonOrForm<C>,
1479 ) -> impl axum::response::IntoResponse {
1480 use axum::{http::StatusCode, Json};
1481
1482 match handler.service.create(dto).await {
1483 Ok(entity) => {
1484 let response = secure::<E>(to_response_value(R::from(entity)), None);
1485 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1486 }
1487 Err(e) if S::violations_of(&e).is_some() => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1488 Err(e) => {
1489 let error_str = e.to_string();
1490 if error_str.contains("conflict") || error_str.contains("already exists") {
1491 (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1492 } else {
1493 (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1494 }
1495 }
1496 }
1497 }
1498
1499 async fn get_handler(
1500 handler: Arc<Self>,
1501 axum::extract::Path(id): axum::extract::Path<String>,
1502 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1503 access: Option<axum::Extension<AccessScope>>,
1504 ) -> impl axum::response::IntoResponse {
1505 use axum::{http::StatusCode, Json};
1506
1507 let fields = sparse_fields(¶ms.filters);
1508 let includes = include_relations(¶ms.filters);
1509 let scope = access.map(|axum::Extension(s)| s);
1510
1511 match handler.service.get_by_id(&id).await {
1512 Ok(Some(entity)) => {
1513 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
1514 let mut rows = [secured];
1515 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1516 let [secured] = rows;
1517 let value = project_sparse(secured, &fields);
1518 (StatusCode::OK, Json(ApiResponse::ok(value)))
1519 }
1520 Ok(None) => {
1521 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1522 }
1523 Err(e) => {
1524 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1525 }
1526 }
1527 }
1528
1529 async fn update_handler(
1530 handler: Arc<Self>,
1531 axum::extract::Path(id): axum::extract::Path<String>,
1532 JsonOrForm(dto): JsonOrForm<U>,
1533 ) -> impl axum::response::IntoResponse {
1534 use axum::{http::StatusCode, Json};
1535
1536 match handler.service.update(&id, dto).await {
1537 Ok(Some(entity)) => {
1538 let response = secure::<E>(to_response_value(R::from(entity)), None);
1539 (StatusCode::OK, Json(ApiResponse::ok(response)))
1540 }
1541 Ok(None) => {
1542 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1543 }
1544 Err(e) => {
1545 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1546 }
1547 }
1548 }
1549
1550 async fn partial_update_handler(
1551 handler: Arc<Self>,
1552 axum::extract::Path(id): axum::extract::Path<String>,
1553 JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
1554 ) -> impl axum::response::IntoResponse {
1555 use axum::{http::StatusCode, Json};
1556
1557 let fields: HashMap<String, serde_json::Value> = fields
1568 .into_iter()
1569 .map(|(k, v)| (camel_to_snake_case(&k), v))
1570 .collect();
1571
1572 match handler.service.partial_update(&id, fields).await {
1573 Ok(Some(entity)) => {
1574 let response = secure::<E>(to_response_value(R::from(entity)), None);
1575 (StatusCode::OK, Json(ApiResponse::ok(response)))
1576 }
1577 Ok(None) => {
1578 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1579 }
1580 Err(e) => {
1581 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1582 }
1583 }
1584 }
1585
1586 async fn delete_handler(
1587 handler: Arc<Self>,
1588 axum::extract::Path(id): axum::extract::Path<String>,
1589 ) -> impl axum::response::IntoResponse {
1590 use axum::{http::StatusCode, Json};
1591
1592 match handler.service.soft_delete(&id).await {
1593 Ok(true) => {
1594 (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
1597 }
1598 Ok(false) => {
1599 (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
1600 }
1601 Err(e) => {
1602 write_error(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR)
1603 }
1604 }
1605 }
1606
1607 async fn bulk_create_handler(
1608 handler: Arc<Self>,
1609 JsonOrForm(items): JsonOrForm<Vec<C>>,
1610 ) -> impl axum::response::IntoResponse {
1611 use axum::{http::StatusCode, Json};
1612
1613 match handler.service.bulk_create(items).await {
1614 Ok(entities) => {
1615 let result_items: Vec<serde_json::Value> = entities
1616 .into_iter()
1617 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1618 .collect();
1619 let total = result_items.len();
1620 let response = BulkResponse {
1621 items: result_items,
1622 total,
1623 failed: 0,
1624 errors: vec![],
1625 };
1626 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1627 }
1628 Err(e) => {
1629 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1630 }
1631 }
1632 }
1633
1634 async fn bulk_delete_handler(
1637 handler: Arc<Self>,
1638 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1639 ) -> impl axum::response::IntoResponse {
1640 use axum::{http::StatusCode, Json};
1641
1642 if let Some(err) = batch_size_error(req.ids.len()) {
1643 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1644 }
1645 match handler.service.bulk_soft_delete(req.ids).await {
1646 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1647 serde_json::json!({ "soft_deleted": count }),
1648 format!("Soft-deleted {count} item(s)"),
1649 ))),
1650 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1651 }
1652 }
1653
1654 async fn bulk_restore_handler(
1655 handler: Arc<Self>,
1656 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1657 ) -> impl axum::response::IntoResponse {
1658 use axum::{http::StatusCode, Json};
1659
1660 if let Some(err) = batch_size_error(req.ids.len()) {
1661 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1662 }
1663 match handler.service.bulk_restore(req.ids).await {
1664 Ok(entities) => {
1665 let items: Vec<serde_json::Value> = entities
1666 .into_iter()
1667 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1668 .collect();
1669 let total = items.len();
1670 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1671 }
1672 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1673 }
1674 }
1675
1676 async fn restore_all_handler(
1677 handler: Arc<Self>,
1678 ) -> impl axum::response::IntoResponse {
1679 use axum::{http::StatusCode, Json};
1680
1681 match handler.service.restore_all().await {
1682 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1683 serde_json::json!({ "restored": count }),
1684 format!("Restored {count} item(s) from trash"),
1685 ))),
1686 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
1687 }
1688 }
1689
1690 async fn bulk_permanent_delete_handler(
1691 handler: Arc<Self>,
1692 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1693 ) -> impl axum::response::IntoResponse {
1694 use axum::{http::StatusCode, Json};
1695
1696 if let Some(err) = batch_size_error(req.ids.len()) {
1697 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1698 }
1699 match handler.service.bulk_permanent_delete(req.ids).await {
1700 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1701 serde_json::json!({ "permanently_deleted": count }),
1702 format!("Permanently deleted {count} item(s)"),
1703 ))),
1704 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1705 }
1706 }
1707
1708 async fn bulk_update_handler(
1709 handler: Arc<Self>,
1710 JsonOrForm(items): JsonOrForm<Vec<BulkUpdateItem<U>>>,
1711 ) -> impl axum::response::IntoResponse {
1712 use axum::{http::StatusCode, Json};
1713
1714 if let Some(err) = batch_size_error(items.len()) {
1715 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1716 }
1717 let items: Vec<(String, U)> = items.into_iter().map(|it| (it.id, it.data)).collect();
1718 match handler.service.bulk_update(items).await {
1719 Ok(entities) => {
1720 let items: Vec<serde_json::Value> = entities
1721 .into_iter()
1722 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1723 .collect();
1724 let total = items.len();
1725 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1726 }
1727 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1728 }
1729 }
1730
1731 async fn bulk_patch_handler(
1732 handler: Arc<Self>,
1733 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
1734 ) -> impl axum::response::IntoResponse {
1735 use axum::{http::StatusCode, Json};
1736
1737 let items = req.into_items();
1738 if let Some(err) = batch_size_error(items.len()) {
1739 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1740 }
1741 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
1743 .into_iter()
1744 .map(|(id, fields)| {
1745 let fields = fields
1746 .into_iter()
1747 .map(|(k, v)| (camel_to_snake_case(&k), v))
1748 .collect();
1749 (id, fields)
1750 })
1751 .collect();
1752 match handler.service.bulk_partial_update(items).await {
1753 Ok(entities) => {
1754 let items: Vec<serde_json::Value> = entities
1755 .into_iter()
1756 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1757 .collect();
1758 let total = items.len();
1759 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1760 }
1761 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1762 }
1763 }
1764
1765 async fn upsert_handler(
1766 handler: Arc<Self>,
1767 JsonOrForm(dto): JsonOrForm<C>,
1768 ) -> impl axum::response::IntoResponse {
1769 use axum::{http::StatusCode, Json};
1770
1771 match handler.service.upsert(dto).await {
1772 Ok(entity) => {
1773 let response = secure::<E>(to_response_value(R::from(entity)), None);
1774 (StatusCode::OK, Json(ApiResponse::ok(response)))
1775 }
1776 Err(e) => {
1777 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1778 }
1779 }
1780 }
1781
1782 async fn list_deleted_handler(
1783 handler: Arc<Self>,
1784 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1785 access: Option<axum::Extension<AccessScope>>,
1786 ) -> impl axum::response::IntoResponse {
1787 use axum::{http::StatusCode, Json};
1788
1789 if let Some(err) = pagination_depth_error(params.page, params.limit) {
1790 return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1791 }
1792 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1793 return (
1794 StatusCode::BAD_REQUEST,
1795 Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1796 );
1797 }
1798
1799 let fields = sparse_fields(¶ms.filters);
1800 let includes = include_relations(¶ms.filters);
1801 let scope = access.map(|axum::Extension(s)| s);
1802
1803 match handler.service.list_deleted(params.page, params.limit).await {
1804 Ok((entities, total)) => {
1805 let mut rows: Vec<serde_json::Value> = entities
1809 .into_iter()
1810 .map(|e| {
1811 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1812 })
1813 .collect();
1814 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1815 let items: Vec<serde_json::Value> =
1816 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1817 let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
1818 (StatusCode::OK, Json(response))
1819 }
1820 Err(e) => {
1821 let msg = e.to_string();
1822 if is_bad_query_error(&msg) {
1823 (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
1824 format!("Invalid query parameter or filter: {msg}"),
1825 )))
1826 } else {
1827 (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
1828 }
1829 }
1830 }
1831 }
1832
1833 async fn restore_handler(
1834 handler: Arc<Self>,
1835 axum::extract::Path(id): axum::extract::Path<String>,
1836 ) -> impl axum::response::IntoResponse {
1837 use axum::{http::StatusCode, Json};
1838
1839 match handler.service.restore(&id).await {
1840 Ok(Some(entity)) => {
1841 let response = secure::<E>(to_response_value(R::from(entity)), None);
1842 (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
1843 }
1844 Ok(None) => {
1845 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1846 }
1847 Err(e) => {
1848 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1849 }
1850 }
1851 }
1852
1853 async fn empty_trash_handler(
1854 handler: Arc<Self>,
1855 ) -> impl axum::response::IntoResponse {
1856 use axum::{http::StatusCode, Json};
1857
1858 match handler.service.empty_trash().await {
1859 Ok(count) => {
1860 (StatusCode::OK, Json(ApiResponse::success_with_message(
1861 serde_json::json!({ "deleted_count": count }),
1862 format!("Successfully deleted {} items from trash", count)
1863 )))
1864 }
1865 Err(e) => {
1866 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1867 }
1868 }
1869 }
1870
1871 async fn get_deleted_handler(
1873 handler: Arc<Self>,
1874 axum::extract::Path(id): axum::extract::Path<String>,
1875 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1876 access: Option<axum::Extension<AccessScope>>,
1877 ) -> impl axum::response::IntoResponse {
1878 use axum::{http::StatusCode, Json};
1879
1880 let fields = sparse_fields(¶ms.filters);
1881 let scope = access.map(|axum::Extension(s)| s);
1882
1883 match handler.service.get_deleted_by_id(&id).await {
1884 Ok(Some(entity)) => {
1885 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
1886 let value = project_sparse(secured, &fields);
1887 (StatusCode::OK, Json(ApiResponse::ok(value)))
1888 }
1889 Ok(None) => {
1890 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
1891 }
1892 Err(e) => {
1893 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1894 }
1895 }
1896 }
1897
1898 async fn permanent_delete_handler(
1900 handler: Arc<Self>,
1901 axum::extract::Path(id): axum::extract::Path<String>,
1902 ) -> axum::response::Response {
1903 use axum::{http::StatusCode, Json, response::IntoResponse};
1904
1905 match handler.service.get_deleted_by_id(&id).await {
1907 Ok(Some(_)) => {
1908 match handler.service.permanent_delete(&id).await {
1910 Ok(true) => {
1911 StatusCode::NO_CONTENT.into_response()
1913 }
1914 Ok(false) => {
1915 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::error(
1916 format!("Failed to permanently delete {}", S::entity_name())
1917 ))).into_response()
1918 }
1919 Err(e) => {
1920 write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
1921 }
1922 }
1923 }
1924 Ok(None) => {
1925 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
1926 &format!("{} in trash", S::entity_name()), &id
1927 ))).into_response()
1928 }
1929 Err(e) => {
1930 write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
1931 }
1932 }
1933 }
1934
1935 async fn count_active_handler(
1937 handler: Arc<Self>,
1938 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1939 ) -> impl axum::response::IntoResponse {
1940 use axum::{http::StatusCode, Json};
1941
1942 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1946 return (
1947 StatusCode::BAD_REQUEST,
1948 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1949 );
1950 }
1951 match handler.service.count_active_filtered(repository_filters(¶ms)).await {
1952 Ok(count) => {
1953 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
1954 }
1955 Err(e) => {
1956 let msg = e.to_string();
1959 let code = if is_bad_query_error(&msg) {
1960 StatusCode::BAD_REQUEST
1961 } else {
1962 StatusCode::INTERNAL_SERVER_ERROR
1963 };
1964 (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
1965 }
1966 }
1967 }
1968
1969 async fn aggregate_handler(
1974 handler: Arc<Self>,
1975 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1976 ) -> impl axum::response::IntoResponse {
1977 use axum::{http::StatusCode, Json};
1978
1979 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1980 return (
1981 StatusCode::BAD_REQUEST,
1982 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1983 );
1984 }
1985 let spec = aggregate_spec(¶ms);
1986 match handler.service.aggregate(&spec, aggregate_filters(¶ms)).await {
1987 Ok(result) => {
1988 let render = |g: &backbone_orm::repository::AggregateGroup| {
1989 let mut out = serde_json::Map::new();
1993 out.insert("key".into(), match &g.key {
1994 Some(k) => serde_json::Value::String(k.clone()),
1995 None => serde_json::Value::Null,
1996 });
1997 if g.label.is_some() {
1998 out.insert(
1999 "label".into(),
2000 serde_json::Value::String(g.label.clone().unwrap()),
2001 );
2002 }
2003 out.insert("count".into(), serde_json::json!(g.count));
2004 for (compound, value) in &g.values {
2005 let Some((func, field)) = compound.split_once(':') else { continue };
2006 let slot = out
2007 .entry(func.to_string())
2008 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2009 if let Some(obj) = slot.as_object_mut() {
2010 obj.insert(field.to_string(), match value {
2011 Some(v) => serde_json::Value::String(v.clone()),
2015 None => serde_json::Value::Null,
2016 });
2017 }
2018 }
2019 serde_json::Value::Object(out)
2020 };
2021
2022 let body = serde_json::json!({
2023 "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
2024 "total": render(&result.total),
2025 "truncated": result.truncated,
2028 });
2029 (StatusCode::OK, Json(ApiResponse::ok(body)))
2030 }
2031 Err(e) => {
2032 let msg = e.to_string();
2033 let code = if is_bad_query_error(&msg) {
2034 StatusCode::BAD_REQUEST
2035 } else {
2036 StatusCode::INTERNAL_SERVER_ERROR
2037 };
2038 (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
2039 }
2040 }
2041 }
2042
2043 async fn history_handler(
2054 handler: Arc<Self>,
2055 axum::extract::Path(id): axum::extract::Path<String>,
2056 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2057 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2058 ) -> impl axum::response::IntoResponse {
2059 use axum::{http::StatusCode, Json};
2060
2061 let Some(axum::Extension(provider)) = provider else {
2062 return (
2063 StatusCode::NOT_IMPLEMENTED,
2064 Json(ApiResponse::<serde_json::Value>::error(
2065 "history is not configured for this service".to_string(),
2066 )),
2067 );
2068 };
2069
2070 let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
2074 return (
2075 StatusCode::NOT_IMPLEMENTED,
2076 Json(ApiResponse::<serde_json::Value>::error(
2077 "this entity cannot name its table, so its history cannot be keyed".to_string(),
2078 )),
2079 );
2080 };
2081
2082 let limit = params.limit.clamp(1, 200);
2083 let offset = params.page.saturating_sub(1) * limit;
2084
2085 match provider.history(&table, &id, limit, offset).await {
2086 Ok(Some(entries)) => (
2087 StatusCode::OK,
2088 Json(ApiResponse::ok(serde_json::json!({
2089 "audited": true,
2090 "entries": entries,
2091 }))),
2092 ),
2093 Ok(None) => (
2094 StatusCode::OK,
2095 Json(ApiResponse::ok(serde_json::json!({
2096 "audited": false,
2099 "entries": serde_json::Value::Null,
2100 }))),
2101 ),
2102 Err(e) => (
2103 StatusCode::INTERNAL_SERVER_ERROR,
2104 Json(ApiResponse::<serde_json::Value>::error(e)),
2105 ),
2106 }
2107 }
2108
2109 async fn count_deleted_handler(
2111 handler: Arc<Self>,
2112 ) -> impl axum::response::IntoResponse {
2113 use axum::{http::StatusCode, Json};
2114
2115 match handler.service.count_deleted().await {
2116 Ok(count) => {
2117 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2118 }
2119 Err(e) => {
2120 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2121 }
2122 }
2123 }
2124}
2125
2126pub trait BackboneHttpHandler<T>: Send + Sync {
2151 fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2153
2154 fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
2156
2157 fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2159
2160 fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
2162
2163 fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
2165
2166 fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
2168
2169 fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
2171
2172 fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
2174
2175 fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2177
2178 fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2180
2181 fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
2183
2184 fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2186}
2187
2188#[derive(Debug, Serialize, Deserialize)]
2194#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
2195pub struct PaginationRequest {
2196 pub page: u32,
2197 pub limit: u32,
2198 pub sort_by: Option<String>,
2199 pub sort_order: Option<String>,
2200}
2201
2202fn camel_to_snake_case(key: &str) -> String {
2218 let chars: Vec<char> = key.chars().collect();
2219 let mut result = String::with_capacity(key.len() + 2);
2220 for (i, &c) in chars.iter().enumerate() {
2221 if c.is_ascii_uppercase() {
2222 let prev = if i > 0 { chars[i - 1] } else { '\0' };
2223 let next = chars.get(i + 1).copied().unwrap_or('\0');
2224 let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
2228 let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
2229 if (crosses_lower || crosses_acronym)
2230 && !result.is_empty()
2231 && !result.ends_with('_')
2232 {
2233 result.push('_');
2234 }
2235 result.push(c.to_ascii_lowercase());
2236 } else {
2237 result.push(c);
2238 }
2239 }
2240 result
2241}
2242
2243#[cfg(test)]
2244mod tests {
2245 use super::*;
2246
2247 #[test]
2248 fn snake_case_input_passes_through_unchanged() {
2249 assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
2250 assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
2251 assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
2252 }
2253
2254 #[test]
2255 fn single_word_unchanged() {
2256 assert_eq!(camel_to_snake_case("name"), "name");
2257 assert_eq!(camel_to_snake_case("id"), "id");
2258 assert_eq!(camel_to_snake_case(""), "");
2259 }
2260
2261 #[test]
2262 fn camel_case_converts() {
2263 assert_eq!(camel_to_snake_case("isVip"), "is_vip");
2264 assert_eq!(camel_to_snake_case("userId"), "user_id");
2265 assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
2266 assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
2267 }
2268
2269 #[test]
2270 fn pascal_case_converts() {
2271 assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
2272 assert_eq!(camel_to_snake_case("UserId"), "user_id");
2273 }
2274
2275 #[test]
2276 fn acronym_runs_stay_together() {
2277 assert_eq!(camel_to_snake_case("IOError"), "io_error");
2280 assert_eq!(camel_to_snake_case("httpURL"), "http_url");
2281 assert_eq!(camel_to_snake_case("ABC"), "abc");
2282 assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
2283 }
2284
2285 #[test]
2286 fn digits_count_as_lowercase_for_boundary() {
2287 assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
2288 assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
2289 }
2290
2291 #[test]
2292 fn underscores_not_doubled() {
2293 assert_eq!(camel_to_snake_case("_Foo"), "_foo");
2294 assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
2295 }
2296
2297 #[test]
2298 fn shallow_pages_are_allowed() {
2299 assert!(pagination_depth_error(1, 100).is_none());
2301 assert!(pagination_depth_error(101, 100).is_none());
2303 }
2304
2305 #[test]
2306 fn pages_past_the_cap_are_rejected() {
2307 assert!(pagination_depth_error(102, 100).is_some());
2309 assert!(pagination_depth_error(1002, 10).is_some());
2311 }
2312
2313 #[test]
2314 fn oversized_page_size_is_clamped_before_the_check() {
2315 assert!(pagination_depth_error(102, 200).is_some());
2317 assert!(pagination_depth_error(101, 200).is_none());
2319 }
2320
2321 #[test]
2322 fn huge_page_number_does_not_overflow() {
2323 assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
2325 }
2326
2327 #[test]
2330 fn batch_size_within_limit_is_allowed() {
2331 assert!(batch_size_error(0).is_none());
2332 assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
2333 }
2334
2335 #[test]
2336 fn batch_size_over_limit_is_rejected() {
2337 assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
2338 }
2339
2340 #[test]
2341 fn bulk_patch_request_parses_shared_shape() {
2342 let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
2343 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2344 let items = req.into_items();
2345 assert_eq!(items.len(), 2);
2346 assert_eq!(items[0].1.get("status").unwrap(), "void");
2348 assert_eq!(items[1].1.get("status").unwrap(), "void");
2349 assert_eq!(items[0].0, "a");
2350 assert_eq!(items[1].0, "b");
2351 }
2352
2353 #[test]
2354 fn bulk_patch_request_parses_per_item_shape() {
2355 let json = r#"{ "items": [
2356 { "id": "a", "patch": { "status": "void" } },
2357 { "id": "b", "patch": { "note": "late" } }
2358 ] }"#;
2359 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2360 let items = req.into_items();
2361 assert_eq!(items.len(), 2);
2362 assert_eq!(items[0].0, "a");
2363 assert_eq!(items[0].1.get("status").unwrap(), "void");
2364 assert_eq!(items[1].0, "b");
2365 assert_eq!(items[1].1.get("note").unwrap(), "late");
2366 }
2367
2368 #[test]
2369 fn batch_ids_request_parses() {
2370 let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
2371 assert_eq!(req.ids, vec!["x", "y", "z"]);
2372 }
2373
2374 fn fields(q: &[(&str, &str)]) -> Vec<String> {
2377 let map: HashMap<String, String> =
2378 q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
2379 sparse_fields(&map)
2380 }
2381
2382 #[test]
2383 fn sparse_fields_parses_comma_list_and_trims() {
2384 assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
2385 }
2386
2387 #[test]
2388 fn sparse_fields_absent_or_empty_is_no_projection() {
2389 assert!(fields(&[]).is_empty());
2390 assert!(fields(&[("fields", "")]).is_empty());
2391 assert!(fields(&[("fields", " , ")]).is_empty());
2392 }
2393
2394 #[test]
2395 fn project_keeps_requested_keys_plus_id() {
2396 let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
2397 let out = project_sparse(v, &["name".into(), "basePrice".into()]);
2398 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
2399 }
2400
2401 #[test]
2402 fn project_always_includes_id_even_if_not_requested() {
2403 let v = serde_json::json!({ "id": "1", "name": "n" });
2404 let out = project_sparse(v, &["name".into()]);
2405 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2406 }
2407
2408 #[test]
2409 fn project_ignores_unknown_keys() {
2410 let v = serde_json::json!({ "id": "1", "name": "n" });
2411 let out = project_sparse(v, &["name".into(), "nope".into()]);
2412 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2413 }
2414
2415 #[test]
2416 fn project_empty_fields_returns_full_object() {
2417 let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
2418 let out = project_sparse(v.clone(), &[]);
2419 assert_eq!(out, v);
2420 }
2421
2422 #[test]
2423 fn project_non_object_returned_unchanged() {
2424 let v = serde_json::json!("scalar");
2425 assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
2426 }
2427
2428 fn owner_a() -> uuid::Uuid {
2433 uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
2434 }
2435 fn owner_b() -> uuid::Uuid {
2436 uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
2437 }
2438
2439 fn row() -> serde_json::Value {
2440 serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
2441 }
2442 const PRIV: &[&str] = &["hppPerUnit"];
2443
2444 struct Secretive;
2446 impl backbone_orm::EntityRepoMeta for Secretive {
2447 fn column_types() -> HashMap<String, String> {
2448 HashMap::new()
2449 }
2450 fn search_fields() -> &'static [&'static str] {
2451 &[]
2452 }
2453 fn secret_fields() -> &'static [&'static str] {
2454 &["tokenHash"]
2455 }
2456 fn private_fields() -> &'static [&'static str] {
2457 PRIV
2458 }
2459 fn owner_field() -> Option<&'static str> {
2460 Some("providerId")
2461 }
2462 fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
2463 match relation {
2464 "user" => &["passwordHash"],
2465 _ => &[],
2466 }
2467 }
2468 }
2469
2470 fn secret_row() -> serde_json::Value {
2471 let mut r = row();
2472 r["tokenHash"] = serde_json::json!("digest");
2473 r
2474 }
2475
2476 #[test]
2477 fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
2478 for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
2479 let out = secure::<Secretive>(secret_row(), scope.as_ref());
2480 assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
2481 assert!(out.get("name").is_some());
2482 }
2483 let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform));
2484 assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
2485 }
2486
2487 #[test]
2488 fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
2489 let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
2490 let params = ListQueryParams {
2491 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
2492 sort_by: sort_by.map(str::to_string),
2493 ..Default::default()
2494 };
2495 named_secret(¶ms, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
2496 };
2497 for pairs in [
2498 vec![("token_hash", "abc")],
2499 vec![("tokenHash[startwith]", "a")],
2500 vec![("orderby", "name,-token_hash")],
2501 vec![("orderby[token_hash]", "asc")],
2502 vec![("searchFields", "name,tokenHash")],
2503 vec![("min", "token_hash")],
2504 vec![("group_by", "tokenHash")],
2505 ] {
2506 assert_eq!(ask(&pairs, None).as_deref().map(camel_to_snake_case), Some("token_hash".into()), "{pairs:?}");
2507 }
2508 assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
2509 assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
2510 }
2511
2512 #[test]
2513 fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
2514 let out = secure::<Secretive>(secret_row(), None);
2515 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
2516 }
2517
2518 #[test]
2519 fn a_routed_response_dto_loses_the_entitys_secrets() {
2520 let out = without_secrets::<Secretive, _>(secret_row());
2521 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
2522 assert!(out.get("name").is_some());
2523 }
2524
2525 #[test]
2526 fn an_included_row_loses_the_related_models_secrets() {
2527 let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
2528 let out = related_row::<Secretive>("user", "users", raw.clone());
2529 assert!(out.get("passwordHash").is_none(), "{out}");
2530 assert_eq!(out["email"], "a@b.c");
2531 assert!(related_row::<Secretive>("other", "others", raw.clone()).get("passwordHash").is_some(), "only the named relation");
2532 backbone_orm::secret_registry::register("employee_probe.staff", &["passwordHash"]);
2534 assert!(related_row::<Secretive>("staff", "employee_probe.staff", raw).get("passwordHash").is_none(), "registered table");
2535 }
2536
2537 #[test]
2538 fn security_no_private_fields_is_noop() {
2539 let v = row();
2540 assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
2541 }
2542
2543 #[test]
2544 fn security_platform_sees_private() {
2545 let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
2546 assert!(out.get("hppPerUnit").is_some());
2547 }
2548
2549 #[test]
2550 fn security_owner_tenant_sees_private() {
2551 let out = apply_field_security(
2552 row(),
2553 Some(&AccessScope::Company(owner_a())),
2554 PRIV,
2555 Some("providerId"),
2556 );
2557 assert!(out.get("hppPerUnit").is_some());
2558 }
2559
2560 #[test]
2561 fn security_other_tenant_stripped() {
2562 let out = apply_field_security(
2563 row(),
2564 Some(&AccessScope::Company(owner_b())),
2565 PRIV,
2566 Some("providerId"),
2567 );
2568 assert!(out.get("hppPerUnit").is_none());
2569 assert!(out.get("name").is_some());
2570 }
2571
2572 #[test]
2573 fn security_absent_scope_fails_closed() {
2574 let out = apply_field_security(row(), None, PRIV, Some("providerId"));
2575 assert!(out.get("hppPerUnit").is_none());
2576 }
2577
2578 #[test]
2581 fn include_relations_parses_include_and_with() {
2582 let mut q = HashMap::new();
2583 q.insert("include".to_string(), "provider, outlet ".to_string());
2584 assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
2585 let mut q2 = HashMap::new();
2586 q2.insert("with".to_string(), "category".to_string());
2587 assert_eq!(include_relations(&q2), vec!["category"]);
2588 assert!(include_relations(&HashMap::new()).is_empty());
2589 }
2590
2591 #[test]
2592 fn snake_to_camel_converts() {
2593 assert_eq!(snake_to_camel("provider_id"), "providerId");
2594 assert_eq!(snake_to_camel("business_name"), "businessName");
2595 assert_eq!(snake_to_camel("id"), "id");
2596 }
2597
2598 #[test]
2599 fn camelize_keys_top_level_only() {
2600 let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
2601 let out = camelize_keys(v);
2602 assert!(out.get("providerId").is_some());
2603 assert!(out.get("metaData").unwrap().get("created_at").is_some());
2605 }
2606
2607 #[test]
2608 fn security_null_owner_only_platform_sees() {
2609 let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
2610 let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
2611 assert!(tenant.get("hppPerUnit").is_none());
2612 let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
2613 assert!(plat.get("hppPerUnit").is_some());
2614 }
2615
2616 #[test]
2617 fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
2618 let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
2619 let (status, axum::Json(body)) =
2620 write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
2621 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
2622 let json = serde_json::to_value(&body).unwrap();
2623 assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
2624 assert_eq!(json["violations"][0]["path"], "status");
2625 assert_eq!(json["violations"][0]["code"], "field_not_writable");
2626
2627 let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
2629 assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
2630 assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
2631 }
2632}