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, Default, Serialize)]
829#[serde(rename_all = "camelCase")]
830#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
831pub struct BulkPreview {
832 pub will_change: Vec<PreviewChange>,
834 pub unchanged: Vec<String>,
836 pub refused: Vec<PreviewRefusal>,
838 pub total: usize,
840}
841
842#[derive(Debug, Serialize)]
844#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
845pub struct PreviewChange {
846 pub id: String,
848 pub changes: Vec<FieldChange>,
850}
851
852#[derive(Debug, Serialize)]
854#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
855pub struct FieldChange {
856 pub field: String,
858 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
860 pub from: serde_json::Value,
861 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
863 pub to: serde_json::Value,
864}
865
866#[derive(Debug, Serialize)]
868#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
869pub struct PreviewRefusal {
870 pub id: String,
872 pub code: String,
874 pub message: String,
876}
877
878type PreviewOutcome = Result<(serde_json::Value, serde_json::Value), (String, String)>;
880
881fn preview_answer(
888 rows: Vec<(String, PreviewOutcome)>,
889 patched: &HashMap<String, Vec<String>>,
890) -> BulkPreview {
891 let mut out = BulkPreview::default();
892 for (id, outcome) in rows {
893 match outcome {
894 Ok((before, after)) => {
895 let mut keys = patched.get(&id).cloned().unwrap_or_default();
896 keys.sort();
897 let changes: Vec<FieldChange> = keys
898 .into_iter()
899 .filter_map(|field| {
900 let read = |v: &serde_json::Value| {
901 v.get(snake_to_camel(&field))
902 .or_else(|| v.get(&field))
903 .cloned()
904 .unwrap_or(serde_json::Value::Null)
905 };
906 let (from, to) = (read(&before), read(&after));
907 (from != to).then_some(FieldChange { field, from, to })
908 })
909 .collect();
910 if changes.is_empty() {
911 out.unchanged.push(id);
912 } else {
913 out.will_change.push(PreviewChange { id, changes });
914 }
915 }
916 Err((code, message)) => out.refused.push(PreviewRefusal { id, code, message }),
917 }
918 }
919 out.total = out.will_change.len() + out.unchanged.len() + out.refused.len();
920 out
921}
922
923#[derive(Debug, Serialize, Deserialize)]
925#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
926pub struct FilterOptions {
927 pub filters: HashMap<String, String>,
928 pub sort_by: Option<String>,
929 pub sort_order: Option<SortOrder>,
930}
931
932#[derive(Debug, Serialize, Deserialize, Clone, Default)]
934#[serde(rename_all = "lowercase")]
935#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
936pub enum SortOrder {
937 #[default]
938 Asc,
939 Desc,
940}
941
942#[derive(Debug, Serialize, Deserialize)]
944#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
945pub struct ListRequest {
946 pub page: Option<u32>,
947 pub limit: Option<u32>,
948 pub sort_by: Option<String>,
949 pub sort_order: Option<SortOrder>,
950 pub filters: Option<HashMap<String, String>>,
951}
952
953impl Default for ListRequest {
954 fn default() -> Self {
955 Self {
956 page: Some(1),
957 limit: Some(20),
958 sort_by: None,
959 sort_order: None,
960 filters: None,
961 }
962 }
963}
964
965#[async_trait::async_trait]
989pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
990where
991 Entity: Send + Sync + 'static,
994 CreateDto: Send + Sync + 'static,
995 UpdateDto: Send + Sync + 'static,
996{
997 type Error: std::error::Error + Send + Sync;
999
1000 fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
1003 let _ = err;
1004 None
1005 }
1006
1007 fn entity_name() -> &'static str;
1009
1010 async fn fetch_related_json(
1013 &self,
1014 _table: &str,
1015 _ids: &[String],
1016 ) -> Vec<serde_json::Value> {
1017 Vec::new()
1018 }
1019
1020 async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1022
1023 async fn list_with_info(
1027 &self,
1028 page: u32,
1029 limit: u32,
1030 filters: HashMap<String, String>,
1031 ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
1032 let (rows, total) = self.list(page, limit, filters).await?;
1033 Ok((
1034 rows,
1035 backbone_orm::repository::PaginationInfo::new(page, limit, total),
1036 ))
1037 }
1038
1039 async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1041
1042 async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1044
1045 async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
1047
1048 async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
1050
1051 async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
1053
1054 async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
1056
1057 async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1059
1060 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
1062
1063 async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1065
1066 async fn empty_trash(&self) -> Result<u64, Self::Error>;
1068
1069 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1071
1072 async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
1074
1075 async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1077
1078 async fn count_active(&self) -> Result<u64, Self::Error>;
1080
1081 fn table_name(&self) -> Option<&str> {
1088 None
1089 }
1090
1091 async fn aggregate(
1096 &self,
1097 spec: &backbone_orm::repository::AggregateSpec,
1098 filters: HashMap<String, String>,
1099 ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
1100
1101 async fn count_active_filtered(
1114 &self,
1115 filters: HashMap<String, String>,
1116 ) -> Result<u64, Self::Error> {
1117 self.list(1, 1, filters).await.map(|(_, total)| total)
1118 }
1119
1120 async fn count_deleted(&self) -> Result<u64, Self::Error>;
1122
1123 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1132 let mut n = 0;
1133 for id in ids {
1134 if self.soft_delete(&id).await? {
1135 n += 1;
1136 }
1137 }
1138 Ok(n)
1139 }
1140
1141 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1143 let mut out = Vec::with_capacity(ids.len());
1144 for id in ids {
1145 if let Some(e) = self.restore(&id).await? {
1146 out.push(e);
1147 }
1148 }
1149 Ok(out)
1150 }
1151
1152 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1154 let mut n = 0;
1155 for id in ids {
1156 if self.permanent_delete(&id).await? {
1157 n += 1;
1158 }
1159 }
1160 Ok(n)
1161 }
1162
1163 async fn restore_all(&self) -> Result<u64, Self::Error> {
1168 Ok(0)
1169 }
1170
1171 async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1173 let mut out = Vec::with_capacity(items.len());
1174 for (id, dto) in items {
1175 if let Some(e) = self.update(&id, dto).await? {
1176 out.push(e);
1177 }
1178 }
1179 Ok(out)
1180 }
1181
1182 async fn bulk_partial_update(
1184 &self,
1185 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1186 ) -> Result<Vec<Entity>, Self::Error> {
1187 let mut out = Vec::with_capacity(items.len());
1188 for (id, fields) in items {
1189 if let Some(e) = self.partial_update(&id, fields).await? {
1190 out.push(e);
1191 }
1192 }
1193 Ok(out)
1194 }
1195
1196 async fn preview_bulk_partial_update(
1201 &self,
1202 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1203 ) -> Result<Option<Vec<BulkPreviewRow<Entity, Self::Error>>>, Self::Error> {
1204 let _ = items;
1205 Ok(None)
1206 }
1207}
1208
1209pub struct BulkPreviewRow<Entity, Err> {
1212 pub id: String,
1214 pub outcome: Result<(Entity, Entity), Err>,
1216}
1217
1218pub struct BackboneCrudHandler<S, E, C, U, R>
1241where
1242 S: CrudService<E, C, U> + 'static,
1243 E: Serialize + Send + Sync + 'static,
1244 C: DeserializeOwned + Send + Sync + 'static,
1245 U: DeserializeOwned + Send + Sync + 'static,
1246 R: From<E> + Serialize + Send + Sync + 'static,
1247{
1248 service: Arc<S>,
1249 _phantom: std::marker::PhantomData<(E, C, U, R)>,
1250}
1251
1252impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1253where
1254 S: CrudService<E, C, U> + 'static,
1255 E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1256 C: DeserializeOwned + Send + Sync + 'static,
1257 U: DeserializeOwned + Send + Sync + 'static,
1258 R: From<E> + Serialize + Send + Sync + 'static,
1259{
1260 pub fn new(service: Arc<S>) -> Self {
1261 Self {
1262 service,
1263 _phantom: std::marker::PhantomData,
1264 }
1265 }
1266
1267 pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1291 where
1292 S: Clone,
1293 {
1294 Self::read_routes(service.clone(), base_path)
1295 .merge(Self::write_routes(service, base_path))
1296 }
1297
1298 pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1312 where
1313 S: Clone,
1314 {
1315 if let Some(table) = service.table_name() {
1318 backbone_orm::secret_registry::register(table, E::secret_fields());
1319 }
1320 use axum::{
1321 extract::{Path, Query},
1322 routing::get,
1323 Extension, Router,
1324 };
1325
1326 let handler = Arc::new(Self::new(service));
1327
1328 Router::new()
1329 .route(base_path, get({
1331 let h = handler.clone();
1332 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1333 Self::list_handler(h, query, access).await
1334 }
1335 }))
1336 .route(&format!("{}/trash", base_path), get({
1338 let h = handler.clone();
1339 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1340 Self::list_deleted_handler(h, query, access).await
1341 }
1342 }))
1343 .route(&format!("{}/:id", base_path), get({
1345 let h = handler.clone();
1346 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1347 Self::get_handler(h, path, query, access).await
1348 }
1349 }))
1350 .route(&format!("{}/:id/deleted", base_path), get({
1352 let h = handler.clone();
1353 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1354 Self::get_deleted_handler(h, path, query, access).await
1355 }
1356 }))
1357 .route(&format!("{}/:id/history", base_path), get({
1360 let h = handler.clone();
1361 move |path: axum::extract::Path<String>,
1362 query: axum::extract::Query<ListQueryParams>,
1363 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
1364 Self::history_handler(h, path, query, provider).await
1365 }
1366 }))
1367 .route(&format!("{}/aggregate", base_path), get({
1369 let h = handler.clone();
1370 move |query: axum::extract::Query<ListQueryParams>| async move {
1371 Self::aggregate_handler(h, query).await
1372 }
1373 }))
1374 .route(&format!("{}/count", base_path), get({
1375 let h = handler.clone();
1376 move |query: axum::extract::Query<ListQueryParams>| async move {
1377 Self::count_active_handler(h, query).await
1378 }
1379 }))
1380 .route(&format!("{}/trash/count", base_path), get({
1382 let h = handler.clone();
1383 move || async move {
1384 Self::count_deleted_handler(h).await
1385 }
1386 }))
1387 }
1388
1389 pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1406 where
1407 S: Clone,
1408 {
1409 if let Some(table) = service.table_name() {
1412 backbone_orm::secret_registry::register(table, E::secret_fields());
1413 }
1414 use axum::{
1415 extract::Path,
1416 routing::{delete, patch, post, put},
1417 Router,
1418 };
1419
1420 let handler = Arc::new(Self::new(service));
1421
1422 Router::new()
1423 .route(base_path, post({
1425 let h = handler.clone();
1426 move |body: JsonOrForm<C>| async move {
1427 Self::create_handler(h, body).await
1428 }
1429 }))
1430 .route(&format!("{}/bulk", base_path), post({
1432 let h = handler.clone();
1433 move |body: JsonOrForm<Vec<C>>| async move {
1434 Self::bulk_create_handler(h, body).await
1435 }
1436 }))
1437 .route(&format!("{}/upsert", base_path), post({
1439 let h = handler.clone();
1440 move |body: JsonOrForm<C>| async move {
1441 Self::upsert_handler(h, body).await
1442 }
1443 }))
1444 .route(&format!("{}/delete/bulk", base_path), post({
1446 let h = handler.clone();
1447 move |body: JsonOrForm<BatchIdsRequest>| async move {
1448 Self::bulk_delete_handler(h, body).await
1449 }
1450 }))
1451 .route(&format!("{}/restore/bulk", base_path), post({
1453 let h = handler.clone();
1454 move |body: JsonOrForm<BatchIdsRequest>| async move {
1455 Self::bulk_restore_handler(h, body).await
1456 }
1457 }))
1458 .route(&format!("{}/restore/all", base_path), post({
1460 let h = handler.clone();
1461 move || async move {
1462 Self::restore_all_handler(h).await
1463 }
1464 }))
1465 .route(&format!("{}/trash/bulk", base_path), delete({
1468 let h = handler.clone();
1469 move |body: JsonOrForm<BatchIdsRequest>| async move {
1470 Self::bulk_permanent_delete_handler(h, body).await
1471 }
1472 }))
1473 .route(&format!("{}/bulk", base_path), put({
1475 let h = handler.clone();
1476 move |body: JsonOrForm<Vec<BulkUpdateItem<U>>>| async move {
1477 Self::bulk_update_handler(h, body).await
1478 }
1479 }))
1480 .route(&format!("{}/bulk", base_path), patch({
1482 let h = handler.clone();
1483 move |body: JsonOrForm<BulkPatchRequest>| async move {
1484 Self::bulk_patch_handler(h, body).await
1485 }
1486 }))
1487 .route(&format!("{}/bulk/preview", base_path), post({
1489 let h = handler.clone();
1490 move |body: JsonOrForm<BulkPatchRequest>| async move {
1491 Self::bulk_patch_preview_handler(h, body).await
1492 }
1493 }))
1494 .route(&format!("{}/empty", base_path), delete({
1496 let h = handler.clone();
1497 move || async move {
1498 Self::empty_trash_handler(h).await
1499 }
1500 }))
1501 .route(&format!("{}/trash/:id", base_path), delete({
1503 let h = handler.clone();
1504 move |path: Path<String>| async move {
1505 Self::permanent_delete_handler(h, path).await
1506 }
1507 }))
1508 .route(&format!("{}/:id", base_path), put({
1510 let h = handler.clone();
1511 move |path: Path<String>, body: JsonOrForm<U>| async move {
1512 Self::update_handler(h, path, body).await
1513 }
1514 }))
1515 .route(&format!("{}/:id", base_path), patch({
1517 let h = handler.clone();
1518 move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
1519 Self::partial_update_handler(h, path, body).await
1520 }
1521 }))
1522 .route(&format!("{}/:id", base_path), delete({
1524 let h = handler.clone();
1525 move |path: Path<String>| async move {
1526 Self::delete_handler(h, path).await
1527 }
1528 }))
1529 .route(&format!("{}/:id/restore", base_path), post({
1531 let h = handler.clone();
1532 move |path: Path<String>| async move {
1533 Self::restore_handler(h, path).await
1534 }
1535 }))
1536 }
1537
1538 async fn list_handler(
1543 handler: Arc<Self>,
1544 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1545 access: Option<axum::Extension<AccessScope>>,
1546 ) -> impl axum::response::IntoResponse {
1547 use axum::{http::StatusCode, Json};
1548
1549 if let Some(err) = pagination_depth_error(params.page, params.limit) {
1550 return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1551 }
1552 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1553 return (
1554 StatusCode::BAD_REQUEST,
1555 Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1556 );
1557 }
1558
1559 let fields = sparse_fields(¶ms.filters);
1562 let includes = include_relations(¶ms.filters);
1563 let scope = access.map(|axum::Extension(s)| s);
1564
1565 let filters = repository_filters(¶ms);
1566
1567 match handler
1568 .service
1569 .list_with_info(params.page, params.limit, filters)
1570 .await
1571 {
1572 Ok((entities, info)) => {
1573 let mut rows: Vec<serde_json::Value> = entities
1576 .into_iter()
1577 .map(|e| {
1578 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1579 })
1580 .collect();
1581 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1582 let items: Vec<serde_json::Value> =
1583 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1584 let response = PaginatedApiResponse::ok_with_info(items, &info);
1585 (StatusCode::OK, Json(response))
1586 }
1587 Err(e) => {
1588 let msg = e.to_string();
1589 if is_bad_query_error(&msg) {
1590 (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
1591 format!("Invalid query parameter or filter: {msg}"),
1592 )))
1593 } else {
1594 (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
1595 }
1596 }
1597 }
1598 }
1599
1600 async fn create_handler(
1601 handler: Arc<Self>,
1602 JsonOrForm(dto): JsonOrForm<C>,
1603 ) -> impl axum::response::IntoResponse {
1604 use axum::{http::StatusCode, Json};
1605
1606 match handler.service.create(dto).await {
1607 Ok(entity) => {
1608 let response = secure::<E>(to_response_value(R::from(entity)), None);
1609 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1610 }
1611 Err(e) if S::violations_of(&e).is_some() => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1612 Err(e) => {
1613 let error_str = e.to_string();
1614 if error_str.contains("conflict") || error_str.contains("already exists") {
1615 (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1616 } else {
1617 (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1618 }
1619 }
1620 }
1621 }
1622
1623 async fn get_handler(
1624 handler: Arc<Self>,
1625 axum::extract::Path(id): axum::extract::Path<String>,
1626 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1627 access: Option<axum::Extension<AccessScope>>,
1628 ) -> impl axum::response::IntoResponse {
1629 use axum::{http::StatusCode, Json};
1630
1631 let fields = sparse_fields(¶ms.filters);
1632 let includes = include_relations(¶ms.filters);
1633 let scope = access.map(|axum::Extension(s)| s);
1634
1635 match handler.service.get_by_id(&id).await {
1636 Ok(Some(entity)) => {
1637 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
1638 let mut rows = [secured];
1639 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1640 let [secured] = rows;
1641 let value = project_sparse(secured, &fields);
1642 (StatusCode::OK, Json(ApiResponse::ok(value)))
1643 }
1644 Ok(None) => {
1645 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1646 }
1647 Err(e) => {
1648 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1649 }
1650 }
1651 }
1652
1653 async fn update_handler(
1654 handler: Arc<Self>,
1655 axum::extract::Path(id): axum::extract::Path<String>,
1656 JsonOrForm(dto): JsonOrForm<U>,
1657 ) -> impl axum::response::IntoResponse {
1658 use axum::{http::StatusCode, Json};
1659
1660 match handler.service.update(&id, dto).await {
1661 Ok(Some(entity)) => {
1662 let response = secure::<E>(to_response_value(R::from(entity)), None);
1663 (StatusCode::OK, Json(ApiResponse::ok(response)))
1664 }
1665 Ok(None) => {
1666 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1667 }
1668 Err(e) => {
1669 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1670 }
1671 }
1672 }
1673
1674 async fn partial_update_handler(
1675 handler: Arc<Self>,
1676 axum::extract::Path(id): axum::extract::Path<String>,
1677 JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
1678 ) -> impl axum::response::IntoResponse {
1679 use axum::{http::StatusCode, Json};
1680
1681 let fields: HashMap<String, serde_json::Value> = fields
1692 .into_iter()
1693 .map(|(k, v)| (camel_to_snake_case(&k), v))
1694 .collect();
1695
1696 match handler.service.partial_update(&id, fields).await {
1697 Ok(Some(entity)) => {
1698 let response = secure::<E>(to_response_value(R::from(entity)), None);
1699 (StatusCode::OK, Json(ApiResponse::ok(response)))
1700 }
1701 Ok(None) => {
1702 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1703 }
1704 Err(e) => {
1705 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1706 }
1707 }
1708 }
1709
1710 async fn delete_handler(
1711 handler: Arc<Self>,
1712 axum::extract::Path(id): axum::extract::Path<String>,
1713 ) -> impl axum::response::IntoResponse {
1714 use axum::{http::StatusCode, Json};
1715
1716 match handler.service.soft_delete(&id).await {
1717 Ok(true) => {
1718 (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
1721 }
1722 Ok(false) => {
1723 (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
1724 }
1725 Err(e) => {
1726 write_error(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR)
1727 }
1728 }
1729 }
1730
1731 async fn bulk_create_handler(
1732 handler: Arc<Self>,
1733 JsonOrForm(items): JsonOrForm<Vec<C>>,
1734 ) -> impl axum::response::IntoResponse {
1735 use axum::{http::StatusCode, Json};
1736
1737 match handler.service.bulk_create(items).await {
1738 Ok(entities) => {
1739 let result_items: Vec<serde_json::Value> = entities
1740 .into_iter()
1741 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1742 .collect();
1743 let total = result_items.len();
1744 let response = BulkResponse {
1745 items: result_items,
1746 total,
1747 failed: 0,
1748 errors: vec![],
1749 };
1750 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1751 }
1752 Err(e) => {
1753 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1754 }
1755 }
1756 }
1757
1758 async fn bulk_delete_handler(
1761 handler: Arc<Self>,
1762 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1763 ) -> impl axum::response::IntoResponse {
1764 use axum::{http::StatusCode, Json};
1765
1766 if let Some(err) = batch_size_error(req.ids.len()) {
1767 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1768 }
1769 match handler.service.bulk_soft_delete(req.ids).await {
1770 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1771 serde_json::json!({ "soft_deleted": count }),
1772 format!("Soft-deleted {count} item(s)"),
1773 ))),
1774 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1775 }
1776 }
1777
1778 async fn bulk_restore_handler(
1779 handler: Arc<Self>,
1780 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1781 ) -> impl axum::response::IntoResponse {
1782 use axum::{http::StatusCode, Json};
1783
1784 if let Some(err) = batch_size_error(req.ids.len()) {
1785 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1786 }
1787 match handler.service.bulk_restore(req.ids).await {
1788 Ok(entities) => {
1789 let items: Vec<serde_json::Value> = entities
1790 .into_iter()
1791 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1792 .collect();
1793 let total = items.len();
1794 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1795 }
1796 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1797 }
1798 }
1799
1800 async fn restore_all_handler(
1801 handler: Arc<Self>,
1802 ) -> impl axum::response::IntoResponse {
1803 use axum::{http::StatusCode, Json};
1804
1805 match handler.service.restore_all().await {
1806 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1807 serde_json::json!({ "restored": count }),
1808 format!("Restored {count} item(s) from trash"),
1809 ))),
1810 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
1811 }
1812 }
1813
1814 async fn bulk_permanent_delete_handler(
1815 handler: Arc<Self>,
1816 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1817 ) -> impl axum::response::IntoResponse {
1818 use axum::{http::StatusCode, Json};
1819
1820 if let Some(err) = batch_size_error(req.ids.len()) {
1821 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1822 }
1823 match handler.service.bulk_permanent_delete(req.ids).await {
1824 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1825 serde_json::json!({ "permanently_deleted": count }),
1826 format!("Permanently deleted {count} item(s)"),
1827 ))),
1828 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1829 }
1830 }
1831
1832 async fn bulk_update_handler(
1833 handler: Arc<Self>,
1834 JsonOrForm(items): JsonOrForm<Vec<BulkUpdateItem<U>>>,
1835 ) -> impl axum::response::IntoResponse {
1836 use axum::{http::StatusCode, Json};
1837
1838 if let Some(err) = batch_size_error(items.len()) {
1839 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1840 }
1841 let items: Vec<(String, U)> = items.into_iter().map(|it| (it.id, it.data)).collect();
1842 match handler.service.bulk_update(items).await {
1843 Ok(entities) => {
1844 let items: Vec<serde_json::Value> = entities
1845 .into_iter()
1846 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1847 .collect();
1848 let total = items.len();
1849 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1850 }
1851 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1852 }
1853 }
1854
1855 async fn bulk_patch_handler(
1856 handler: Arc<Self>,
1857 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
1858 ) -> impl axum::response::IntoResponse {
1859 use axum::{http::StatusCode, Json};
1860
1861 let items = req.into_items();
1862 if let Some(err) = batch_size_error(items.len()) {
1863 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1864 }
1865 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
1867 .into_iter()
1868 .map(|(id, fields)| {
1869 let fields = fields
1870 .into_iter()
1871 .map(|(k, v)| (camel_to_snake_case(&k), v))
1872 .collect();
1873 (id, fields)
1874 })
1875 .collect();
1876 match handler.service.bulk_partial_update(items).await {
1877 Ok(entities) => {
1878 let items: Vec<serde_json::Value> = entities
1879 .into_iter()
1880 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1881 .collect();
1882 let total = items.len();
1883 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1884 }
1885 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1886 }
1887 }
1888
1889 async fn bulk_patch_preview_handler(
1895 handler: Arc<Self>,
1896 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
1897 ) -> impl axum::response::IntoResponse {
1898 use axum::{http::StatusCode, Json};
1899
1900 let items = req.into_items();
1901 if let Some(err) = batch_size_error(items.len()) {
1902 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkPreview>::error(err)));
1903 }
1904 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
1905 .into_iter()
1906 .map(|(id, fields)| {
1907 let fields = fields
1908 .into_iter()
1909 .map(|(k, v)| (camel_to_snake_case(&k), v))
1910 .collect();
1911 (id, fields)
1912 })
1913 .collect();
1914 let patched: HashMap<String, Vec<String>> = items
1915 .iter()
1916 .map(|(id, f)| (id.clone(), f.keys().cloned().collect()))
1917 .collect();
1918 match handler.service.preview_bulk_partial_update(items).await {
1919 Ok(Some(rows)) => {
1920 let rows = rows
1921 .into_iter()
1922 .map(|row| {
1923 let outcome = match row.outcome {
1924 Ok((before, after)) => Ok((
1925 secure::<E>(to_response_value(R::from(before)), None),
1926 secure::<E>(to_response_value(R::from(after)), None),
1927 )),
1928 Err(e) => Err((
1929 S::violations_of(&e)
1930 .and_then(|v| v.into_iter().next().map(|v| v.code))
1931 .unwrap_or_else(|| "refused".to_string()),
1932 e.to_string(),
1933 )),
1934 };
1935 (row.id, outcome)
1936 })
1937 .collect();
1938 (StatusCode::OK, Json(ApiResponse::ok(preview_answer(rows, &patched))))
1939 }
1940 Ok(None) => (
1941 StatusCode::NOT_IMPLEMENTED,
1942 Json(ApiResponse::error("this resource has no preview of a bulk change".to_string())),
1943 ),
1944 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1945 }
1946 }
1947
1948 async fn upsert_handler(
1949 handler: Arc<Self>,
1950 JsonOrForm(dto): JsonOrForm<C>,
1951 ) -> impl axum::response::IntoResponse {
1952 use axum::{http::StatusCode, Json};
1953
1954 match handler.service.upsert(dto).await {
1955 Ok(entity) => {
1956 let response = secure::<E>(to_response_value(R::from(entity)), None);
1957 (StatusCode::OK, Json(ApiResponse::ok(response)))
1958 }
1959 Err(e) => {
1960 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1961 }
1962 }
1963 }
1964
1965 async fn list_deleted_handler(
1966 handler: Arc<Self>,
1967 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1968 access: Option<axum::Extension<AccessScope>>,
1969 ) -> impl axum::response::IntoResponse {
1970 use axum::{http::StatusCode, Json};
1971
1972 if let Some(err) = pagination_depth_error(params.page, params.limit) {
1973 return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1974 }
1975 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1976 return (
1977 StatusCode::BAD_REQUEST,
1978 Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1979 );
1980 }
1981
1982 let fields = sparse_fields(¶ms.filters);
1983 let includes = include_relations(¶ms.filters);
1984 let scope = access.map(|axum::Extension(s)| s);
1985
1986 match handler.service.list_deleted(params.page, params.limit).await {
1987 Ok((entities, total)) => {
1988 let mut rows: Vec<serde_json::Value> = entities
1992 .into_iter()
1993 .map(|e| {
1994 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1995 })
1996 .collect();
1997 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1998 let items: Vec<serde_json::Value> =
1999 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2000 let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
2001 (StatusCode::OK, Json(response))
2002 }
2003 Err(e) => {
2004 let msg = e.to_string();
2005 if is_bad_query_error(&msg) {
2006 (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
2007 format!("Invalid query parameter or filter: {msg}"),
2008 )))
2009 } else {
2010 (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
2011 }
2012 }
2013 }
2014 }
2015
2016 async fn restore_handler(
2017 handler: Arc<Self>,
2018 axum::extract::Path(id): axum::extract::Path<String>,
2019 ) -> impl axum::response::IntoResponse {
2020 use axum::{http::StatusCode, Json};
2021
2022 match handler.service.restore(&id).await {
2023 Ok(Some(entity)) => {
2024 let response = secure::<E>(to_response_value(R::from(entity)), None);
2025 (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
2026 }
2027 Ok(None) => {
2028 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2029 }
2030 Err(e) => {
2031 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
2032 }
2033 }
2034 }
2035
2036 async fn empty_trash_handler(
2037 handler: Arc<Self>,
2038 ) -> impl axum::response::IntoResponse {
2039 use axum::{http::StatusCode, Json};
2040
2041 match handler.service.empty_trash().await {
2042 Ok(count) => {
2043 (StatusCode::OK, Json(ApiResponse::success_with_message(
2044 serde_json::json!({ "deleted_count": count }),
2045 format!("Successfully deleted {} items from trash", count)
2046 )))
2047 }
2048 Err(e) => {
2049 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2050 }
2051 }
2052 }
2053
2054 async fn get_deleted_handler(
2056 handler: Arc<Self>,
2057 axum::extract::Path(id): axum::extract::Path<String>,
2058 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2059 access: Option<axum::Extension<AccessScope>>,
2060 ) -> impl axum::response::IntoResponse {
2061 use axum::{http::StatusCode, Json};
2062
2063 let fields = sparse_fields(¶ms.filters);
2064 let scope = access.map(|axum::Extension(s)| s);
2065
2066 match handler.service.get_deleted_by_id(&id).await {
2067 Ok(Some(entity)) => {
2068 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
2069 let value = project_sparse(secured, &fields);
2070 (StatusCode::OK, Json(ApiResponse::ok(value)))
2071 }
2072 Ok(None) => {
2073 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
2074 }
2075 Err(e) => {
2076 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2077 }
2078 }
2079 }
2080
2081 async fn permanent_delete_handler(
2083 handler: Arc<Self>,
2084 axum::extract::Path(id): axum::extract::Path<String>,
2085 ) -> axum::response::Response {
2086 use axum::{http::StatusCode, Json, response::IntoResponse};
2087
2088 match handler.service.get_deleted_by_id(&id).await {
2090 Ok(Some(_)) => {
2091 match handler.service.permanent_delete(&id).await {
2093 Ok(true) => {
2094 StatusCode::NO_CONTENT.into_response()
2096 }
2097 Ok(false) => {
2098 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::error(
2099 format!("Failed to permanently delete {}", S::entity_name())
2100 ))).into_response()
2101 }
2102 Err(e) => {
2103 write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2104 }
2105 }
2106 }
2107 Ok(None) => {
2108 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
2109 &format!("{} in trash", S::entity_name()), &id
2110 ))).into_response()
2111 }
2112 Err(e) => {
2113 write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2114 }
2115 }
2116 }
2117
2118 async fn count_active_handler(
2120 handler: Arc<Self>,
2121 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2122 ) -> impl axum::response::IntoResponse {
2123 use axum::{http::StatusCode, Json};
2124
2125 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2129 return (
2130 StatusCode::BAD_REQUEST,
2131 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2132 );
2133 }
2134 match handler.service.count_active_filtered(repository_filters(¶ms)).await {
2135 Ok(count) => {
2136 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2137 }
2138 Err(e) => {
2139 let msg = e.to_string();
2142 let code = if is_bad_query_error(&msg) {
2143 StatusCode::BAD_REQUEST
2144 } else {
2145 StatusCode::INTERNAL_SERVER_ERROR
2146 };
2147 (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
2148 }
2149 }
2150 }
2151
2152 async fn aggregate_handler(
2157 handler: Arc<Self>,
2158 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2159 ) -> impl axum::response::IntoResponse {
2160 use axum::{http::StatusCode, Json};
2161
2162 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2163 return (
2164 StatusCode::BAD_REQUEST,
2165 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2166 );
2167 }
2168 let spec = aggregate_spec(¶ms);
2169 match handler.service.aggregate(&spec, aggregate_filters(¶ms)).await {
2170 Ok(result) => {
2171 let render = |g: &backbone_orm::repository::AggregateGroup| {
2172 let mut out = serde_json::Map::new();
2176 out.insert("key".into(), match &g.key {
2177 Some(k) => serde_json::Value::String(k.clone()),
2178 None => serde_json::Value::Null,
2179 });
2180 if g.label.is_some() {
2181 out.insert(
2182 "label".into(),
2183 serde_json::Value::String(g.label.clone().unwrap()),
2184 );
2185 }
2186 out.insert("count".into(), serde_json::json!(g.count));
2187 for (compound, value) in &g.values {
2188 let Some((func, field)) = compound.split_once(':') else { continue };
2189 let slot = out
2190 .entry(func.to_string())
2191 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2192 if let Some(obj) = slot.as_object_mut() {
2193 obj.insert(field.to_string(), match value {
2194 Some(v) => serde_json::Value::String(v.clone()),
2198 None => serde_json::Value::Null,
2199 });
2200 }
2201 }
2202 serde_json::Value::Object(out)
2203 };
2204
2205 let body = serde_json::json!({
2206 "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
2207 "total": render(&result.total),
2208 "truncated": result.truncated,
2211 });
2212 (StatusCode::OK, Json(ApiResponse::ok(body)))
2213 }
2214 Err(e) => {
2215 let msg = e.to_string();
2216 let code = if is_bad_query_error(&msg) {
2217 StatusCode::BAD_REQUEST
2218 } else {
2219 StatusCode::INTERNAL_SERVER_ERROR
2220 };
2221 (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
2222 }
2223 }
2224 }
2225
2226 async fn history_handler(
2237 handler: Arc<Self>,
2238 axum::extract::Path(id): axum::extract::Path<String>,
2239 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2240 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2241 ) -> impl axum::response::IntoResponse {
2242 use axum::{http::StatusCode, Json};
2243
2244 let Some(axum::Extension(provider)) = provider else {
2245 return (
2246 StatusCode::NOT_IMPLEMENTED,
2247 Json(ApiResponse::<serde_json::Value>::error(
2248 "history is not configured for this service".to_string(),
2249 )),
2250 );
2251 };
2252
2253 let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
2257 return (
2258 StatusCode::NOT_IMPLEMENTED,
2259 Json(ApiResponse::<serde_json::Value>::error(
2260 "this entity cannot name its table, so its history cannot be keyed".to_string(),
2261 )),
2262 );
2263 };
2264
2265 let limit = params.limit.clamp(1, 200);
2266 let offset = params.page.saturating_sub(1) * limit;
2267
2268 match provider.history(&table, &id, limit, offset).await {
2269 Ok(Some(entries)) => (
2270 StatusCode::OK,
2271 Json(ApiResponse::ok(serde_json::json!({
2272 "audited": true,
2273 "entries": entries,
2274 }))),
2275 ),
2276 Ok(None) => (
2277 StatusCode::OK,
2278 Json(ApiResponse::ok(serde_json::json!({
2279 "audited": false,
2282 "entries": serde_json::Value::Null,
2283 }))),
2284 ),
2285 Err(e) => (
2286 StatusCode::INTERNAL_SERVER_ERROR,
2287 Json(ApiResponse::<serde_json::Value>::error(e)),
2288 ),
2289 }
2290 }
2291
2292 async fn count_deleted_handler(
2294 handler: Arc<Self>,
2295 ) -> impl axum::response::IntoResponse {
2296 use axum::{http::StatusCode, Json};
2297
2298 match handler.service.count_deleted().await {
2299 Ok(count) => {
2300 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2301 }
2302 Err(e) => {
2303 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2304 }
2305 }
2306 }
2307}
2308
2309pub trait BackboneHttpHandler<T>: Send + Sync {
2334 fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2336
2337 fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
2339
2340 fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2342
2343 fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
2345
2346 fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
2348
2349 fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
2351
2352 fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
2354
2355 fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
2357
2358 fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2360
2361 fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2363
2364 fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
2366
2367 fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2369}
2370
2371#[derive(Debug, Serialize, Deserialize)]
2377#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
2378pub struct PaginationRequest {
2379 pub page: u32,
2380 pub limit: u32,
2381 pub sort_by: Option<String>,
2382 pub sort_order: Option<String>,
2383}
2384
2385fn camel_to_snake_case(key: &str) -> String {
2401 let chars: Vec<char> = key.chars().collect();
2402 let mut result = String::with_capacity(key.len() + 2);
2403 for (i, &c) in chars.iter().enumerate() {
2404 if c.is_ascii_uppercase() {
2405 let prev = if i > 0 { chars[i - 1] } else { '\0' };
2406 let next = chars.get(i + 1).copied().unwrap_or('\0');
2407 let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
2411 let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
2412 if (crosses_lower || crosses_acronym)
2413 && !result.is_empty()
2414 && !result.ends_with('_')
2415 {
2416 result.push('_');
2417 }
2418 result.push(c.to_ascii_lowercase());
2419 } else {
2420 result.push(c);
2421 }
2422 }
2423 result
2424}
2425
2426#[cfg(test)]
2427mod tests {
2428 use super::*;
2429
2430 #[test]
2431 fn snake_case_input_passes_through_unchanged() {
2432 assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
2433 assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
2434 assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
2435 }
2436
2437 #[test]
2438 fn single_word_unchanged() {
2439 assert_eq!(camel_to_snake_case("name"), "name");
2440 assert_eq!(camel_to_snake_case("id"), "id");
2441 assert_eq!(camel_to_snake_case(""), "");
2442 }
2443
2444 #[test]
2445 fn camel_case_converts() {
2446 assert_eq!(camel_to_snake_case("isVip"), "is_vip");
2447 assert_eq!(camel_to_snake_case("userId"), "user_id");
2448 assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
2449 assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
2450 }
2451
2452 #[test]
2453 fn pascal_case_converts() {
2454 assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
2455 assert_eq!(camel_to_snake_case("UserId"), "user_id");
2456 }
2457
2458 #[test]
2459 fn acronym_runs_stay_together() {
2460 assert_eq!(camel_to_snake_case("IOError"), "io_error");
2463 assert_eq!(camel_to_snake_case("httpURL"), "http_url");
2464 assert_eq!(camel_to_snake_case("ABC"), "abc");
2465 assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
2466 }
2467
2468 #[test]
2469 fn digits_count_as_lowercase_for_boundary() {
2470 assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
2471 assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
2472 }
2473
2474 #[test]
2475 fn underscores_not_doubled() {
2476 assert_eq!(camel_to_snake_case("_Foo"), "_foo");
2477 assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
2478 }
2479
2480 #[test]
2481 fn shallow_pages_are_allowed() {
2482 assert!(pagination_depth_error(1, 100).is_none());
2484 assert!(pagination_depth_error(101, 100).is_none());
2486 }
2487
2488 #[test]
2489 fn pages_past_the_cap_are_rejected() {
2490 assert!(pagination_depth_error(102, 100).is_some());
2492 assert!(pagination_depth_error(1002, 10).is_some());
2494 }
2495
2496 #[test]
2497 fn oversized_page_size_is_clamped_before_the_check() {
2498 assert!(pagination_depth_error(102, 200).is_some());
2500 assert!(pagination_depth_error(101, 200).is_none());
2502 }
2503
2504 #[test]
2505 fn huge_page_number_does_not_overflow() {
2506 assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
2508 }
2509
2510 #[test]
2513 fn batch_size_within_limit_is_allowed() {
2514 assert!(batch_size_error(0).is_none());
2515 assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
2516 }
2517
2518 #[test]
2519 fn batch_size_over_limit_is_rejected() {
2520 assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
2521 }
2522
2523 #[test]
2524 fn a_bulk_preview_sorts_rows_by_what_the_patch_would_do() {
2525 use serde_json::json;
2526 let mut patched: HashMap<String, Vec<String>> = ["a", "b", "c"]
2527 .iter()
2528 .map(|id| (id.to_string(), vec!["category".to_string()]))
2529 .collect();
2530 patched.insert("d".into(), vec!["category_id".into()]);
2531 let out = preview_answer(
2532 vec![
2533 (
2534 "a".into(),
2535 Ok((
2536 json!({"category": "meal", "updated_at": "1"}),
2537 json!({"category": "travel", "updated_at": "2"}),
2538 )),
2539 ),
2540 ("b".into(), Ok((json!({"category": "travel"}), json!({"category": "travel"})))),
2541 (
2542 "d".into(),
2543 Ok((json!({"categoryId": "x"}), json!({"categoryId": "y"}))),
2544 ),
2545 ("c".into(), Err(("locked".into(), "a posted claim is locked".into()))),
2546 ],
2547 &patched,
2548 );
2549 assert_eq!(out.total, 4);
2550 assert_eq!(out.will_change.len(), 2);
2551 assert_eq!(out.will_change[1].changes[0].field, "category_id", "a camelCase response is read");
2552 let ch = &out.will_change[0].changes;
2553 assert_eq!(ch.len(), 1, "only the patched field is reported, not the timestamp");
2554 assert_eq!((ch[0].from.clone(), ch[0].to.clone()), (json!("meal"), json!("travel")));
2555 assert_eq!(out.unchanged, vec!["b".to_string()]);
2556 assert_eq!(out.refused[0].code, "locked");
2557 }
2558
2559 #[test]
2560 fn bulk_patch_request_parses_shared_shape() {
2561 let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
2562 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2563 let items = req.into_items();
2564 assert_eq!(items.len(), 2);
2565 assert_eq!(items[0].1.get("status").unwrap(), "void");
2567 assert_eq!(items[1].1.get("status").unwrap(), "void");
2568 assert_eq!(items[0].0, "a");
2569 assert_eq!(items[1].0, "b");
2570 }
2571
2572 #[test]
2573 fn bulk_patch_request_parses_per_item_shape() {
2574 let json = r#"{ "items": [
2575 { "id": "a", "patch": { "status": "void" } },
2576 { "id": "b", "patch": { "note": "late" } }
2577 ] }"#;
2578 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2579 let items = req.into_items();
2580 assert_eq!(items.len(), 2);
2581 assert_eq!(items[0].0, "a");
2582 assert_eq!(items[0].1.get("status").unwrap(), "void");
2583 assert_eq!(items[1].0, "b");
2584 assert_eq!(items[1].1.get("note").unwrap(), "late");
2585 }
2586
2587 #[test]
2588 fn batch_ids_request_parses() {
2589 let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
2590 assert_eq!(req.ids, vec!["x", "y", "z"]);
2591 }
2592
2593 fn fields(q: &[(&str, &str)]) -> Vec<String> {
2596 let map: HashMap<String, String> =
2597 q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
2598 sparse_fields(&map)
2599 }
2600
2601 #[test]
2602 fn sparse_fields_parses_comma_list_and_trims() {
2603 assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
2604 }
2605
2606 #[test]
2607 fn sparse_fields_absent_or_empty_is_no_projection() {
2608 assert!(fields(&[]).is_empty());
2609 assert!(fields(&[("fields", "")]).is_empty());
2610 assert!(fields(&[("fields", " , ")]).is_empty());
2611 }
2612
2613 #[test]
2614 fn project_keeps_requested_keys_plus_id() {
2615 let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
2616 let out = project_sparse(v, &["name".into(), "basePrice".into()]);
2617 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
2618 }
2619
2620 #[test]
2621 fn project_always_includes_id_even_if_not_requested() {
2622 let v = serde_json::json!({ "id": "1", "name": "n" });
2623 let out = project_sparse(v, &["name".into()]);
2624 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2625 }
2626
2627 #[test]
2628 fn project_ignores_unknown_keys() {
2629 let v = serde_json::json!({ "id": "1", "name": "n" });
2630 let out = project_sparse(v, &["name".into(), "nope".into()]);
2631 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2632 }
2633
2634 #[test]
2635 fn project_empty_fields_returns_full_object() {
2636 let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
2637 let out = project_sparse(v.clone(), &[]);
2638 assert_eq!(out, v);
2639 }
2640
2641 #[test]
2642 fn project_non_object_returned_unchanged() {
2643 let v = serde_json::json!("scalar");
2644 assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
2645 }
2646
2647 fn owner_a() -> uuid::Uuid {
2652 uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
2653 }
2654 fn owner_b() -> uuid::Uuid {
2655 uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
2656 }
2657
2658 fn row() -> serde_json::Value {
2659 serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
2660 }
2661 const PRIV: &[&str] = &["hppPerUnit"];
2662
2663 struct Secretive;
2665 impl backbone_orm::EntityRepoMeta for Secretive {
2666 fn column_types() -> HashMap<String, String> {
2667 HashMap::new()
2668 }
2669 fn search_fields() -> &'static [&'static str] {
2670 &[]
2671 }
2672 fn secret_fields() -> &'static [&'static str] {
2673 &["tokenHash"]
2674 }
2675 fn private_fields() -> &'static [&'static str] {
2676 PRIV
2677 }
2678 fn owner_field() -> Option<&'static str> {
2679 Some("providerId")
2680 }
2681 fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
2682 match relation {
2683 "user" => &["passwordHash"],
2684 _ => &[],
2685 }
2686 }
2687 }
2688
2689 fn secret_row() -> serde_json::Value {
2690 let mut r = row();
2691 r["tokenHash"] = serde_json::json!("digest");
2692 r
2693 }
2694
2695 #[test]
2696 fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
2697 for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
2698 let out = secure::<Secretive>(secret_row(), scope.as_ref());
2699 assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
2700 assert!(out.get("name").is_some());
2701 }
2702 let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform));
2703 assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
2704 }
2705
2706 #[test]
2707 fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
2708 let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
2709 let params = ListQueryParams {
2710 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
2711 sort_by: sort_by.map(str::to_string),
2712 ..Default::default()
2713 };
2714 named_secret(¶ms, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
2715 };
2716 for pairs in [
2717 vec![("token_hash", "abc")],
2718 vec![("tokenHash[startwith]", "a")],
2719 vec![("orderby", "name,-token_hash")],
2720 vec![("orderby[token_hash]", "asc")],
2721 vec![("searchFields", "name,tokenHash")],
2722 vec![("min", "token_hash")],
2723 vec![("group_by", "tokenHash")],
2724 ] {
2725 assert_eq!(ask(&pairs, None).as_deref().map(camel_to_snake_case), Some("token_hash".into()), "{pairs:?}");
2726 }
2727 assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
2728 assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
2729 }
2730
2731 #[test]
2732 fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
2733 let out = secure::<Secretive>(secret_row(), None);
2734 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
2735 }
2736
2737 #[test]
2738 fn a_routed_response_dto_loses_the_entitys_secrets() {
2739 let out = without_secrets::<Secretive, _>(secret_row());
2740 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
2741 assert!(out.get("name").is_some());
2742 }
2743
2744 #[test]
2745 fn an_included_row_loses_the_related_models_secrets() {
2746 let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
2747 let out = related_row::<Secretive>("user", "users", raw.clone());
2748 assert!(out.get("passwordHash").is_none(), "{out}");
2749 assert_eq!(out["email"], "a@b.c");
2750 assert!(related_row::<Secretive>("other", "others", raw.clone()).get("passwordHash").is_some(), "only the named relation");
2751 backbone_orm::secret_registry::register("employee_probe.staff", &["passwordHash"]);
2753 assert!(related_row::<Secretive>("staff", "employee_probe.staff", raw).get("passwordHash").is_none(), "registered table");
2754 }
2755
2756 #[test]
2757 fn security_no_private_fields_is_noop() {
2758 let v = row();
2759 assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
2760 }
2761
2762 #[test]
2763 fn security_platform_sees_private() {
2764 let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
2765 assert!(out.get("hppPerUnit").is_some());
2766 }
2767
2768 #[test]
2769 fn security_owner_tenant_sees_private() {
2770 let out = apply_field_security(
2771 row(),
2772 Some(&AccessScope::Company(owner_a())),
2773 PRIV,
2774 Some("providerId"),
2775 );
2776 assert!(out.get("hppPerUnit").is_some());
2777 }
2778
2779 #[test]
2780 fn security_other_tenant_stripped() {
2781 let out = apply_field_security(
2782 row(),
2783 Some(&AccessScope::Company(owner_b())),
2784 PRIV,
2785 Some("providerId"),
2786 );
2787 assert!(out.get("hppPerUnit").is_none());
2788 assert!(out.get("name").is_some());
2789 }
2790
2791 #[test]
2792 fn security_absent_scope_fails_closed() {
2793 let out = apply_field_security(row(), None, PRIV, Some("providerId"));
2794 assert!(out.get("hppPerUnit").is_none());
2795 }
2796
2797 #[test]
2800 fn include_relations_parses_include_and_with() {
2801 let mut q = HashMap::new();
2802 q.insert("include".to_string(), "provider, outlet ".to_string());
2803 assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
2804 let mut q2 = HashMap::new();
2805 q2.insert("with".to_string(), "category".to_string());
2806 assert_eq!(include_relations(&q2), vec!["category"]);
2807 assert!(include_relations(&HashMap::new()).is_empty());
2808 }
2809
2810 #[test]
2811 fn snake_to_camel_converts() {
2812 assert_eq!(snake_to_camel("provider_id"), "providerId");
2813 assert_eq!(snake_to_camel("business_name"), "businessName");
2814 assert_eq!(snake_to_camel("id"), "id");
2815 }
2816
2817 #[test]
2818 fn camelize_keys_top_level_only() {
2819 let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
2820 let out = camelize_keys(v);
2821 assert!(out.get("providerId").is_some());
2822 assert!(out.get("metaData").unwrap().get("created_at").is_some());
2824 }
2825
2826 #[test]
2827 fn security_null_owner_only_platform_sees() {
2828 let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
2829 let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
2830 assert!(tenant.get("hppPerUnit").is_none());
2831 let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
2832 assert!(plat.get("hppPerUnit").is_some());
2833 }
2834
2835 #[test]
2836 fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
2837 let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
2838 let (status, axum::Json(body)) =
2839 write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
2840 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
2841 let json = serde_json::to_value(&body).unwrap();
2842 assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
2843 assert_eq!(json["violations"][0]["path"], "status");
2844 assert_eq!(json["violations"][0]["code"], "field_not_writable");
2845
2846 let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
2848 assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
2849 assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
2850 }
2851}