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
420fn related_row<E: backbone_orm::EntityRepoMeta>(relation: &str, obj: serde_json::Value) -> serde_json::Value {
424 strip_secrets(camelize_keys(obj), E::relation_secret_fields(relation))
425}
426
427fn apply_field_security(
428 mut value: serde_json::Value,
429 scope: Option<&AccessScope>,
430 private_fields: &[&str],
431 owner_field: Option<&str>,
432) -> serde_json::Value {
433 if private_fields.is_empty() {
434 return value;
435 }
436 let can_see_private = match scope {
437 Some(AccessScope::Platform) => true,
438 Some(AccessScope::Company(id)) => owner_field
439 .and_then(|f| value.get(f))
440 .and_then(|v| v.as_str())
441 .and_then(|owner| uuid::Uuid::parse_str(owner).ok())
445 .is_some_and(|owner| owner == *id),
446 None => false,
447 };
448 if !can_see_private {
449 if let serde_json::Value::Object(map) = &mut value {
450 for f in private_fields {
451 map.remove(*f);
452 }
453 }
454 }
455 value
456}
457
458fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
460 query
461 .get("include")
462 .or_else(|| query.get("with"))
463 .map(|s| {
464 s.split(',')
465 .map(str::trim)
466 .filter(|s| !s.is_empty())
467 .map(str::to_string)
468 .collect()
469 })
470 .unwrap_or_default()
471}
472
473fn snake_to_camel(s: &str) -> String {
474 let mut out = String::with_capacity(s.len());
475 let mut upper = false;
476 for c in s.chars() {
477 if c == '_' {
478 upper = true;
479 } else if upper {
480 out.extend(c.to_uppercase());
481 upper = false;
482 } else {
483 out.push(c);
484 }
485 }
486 out
487}
488
489fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
492 match v {
493 serde_json::Value::Object(m) => serde_json::Value::Object(
494 m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
495 ),
496 other => other,
497 }
498}
499
500async fn expand_includes<S, E, C, U>(
510 service: &S,
511 rows: &mut [serde_json::Value],
512 includes: &[String],
513) where
514 S: CrudService<E, C, U>,
515 E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
516 C: Send + Sync + 'static,
517 U: Send + Sync + 'static,
518{
519 if includes.is_empty() || rows.is_empty() {
520 return;
521 }
522 for (rel_name, table, fk_field) in E::relations() {
523 if !includes.iter().any(|i| i == rel_name) {
524 continue;
525 }
526 let mut ids: Vec<String> = rows
527 .iter()
528 .filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
529 .collect();
530 ids.sort();
531 ids.dedup();
532 if ids.is_empty() {
533 continue;
534 }
535 let related = service.fetch_related_json(table, &ids).await;
536 let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
537 for obj in related {
538 if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
539 by_id.insert(id, related_row::<E>(rel_name, obj));
540 }
541 }
542 for r in rows.iter_mut() {
543 let related_obj = r
544 .get(fk_field)
545 .and_then(|v| v.as_str())
546 .and_then(|id| by_id.get(id).cloned())
547 .unwrap_or(serde_json::Value::Null);
548 if let serde_json::Value::Object(m) = r {
549 m.insert((*rel_name).to_string(), related_obj);
550 }
551 }
552 }
553}
554
555pub const MAX_PER_PAGE: u32 = 100;
559
560pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
565
566fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
572 let effective_limit = limit.clamp(1, MAX_PER_PAGE);
573 let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
574 if offset > MAX_PAGINATION_OFFSET {
575 Some(format!(
576 "Result set too deep: offset {offset} exceeds the maximum of \
577 {MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
578 ))
579 } else {
580 None
581 }
582}
583
584pub use crate::service::MAX_BATCH_SIZE;
590
591fn batch_size_error(count: usize) -> Option<String> {
594 if count > MAX_BATCH_SIZE {
595 Some(format!(
596 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
597 ))
598 } else {
599 None
600 }
601}
602
603fn is_bad_query_error(msg: &str) -> bool {
612 let m = msg.to_lowercase();
613 m.contains("does not exist")
614 || m.contains("invalid input syntax")
615 || m.contains("42703")
616 || m.contains("not a column of this entity")
620 || m.contains("needs a numeric column")
621}
622
623#[derive(Debug, Serialize, Deserialize, Clone)]
625#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
626pub struct PaginationResponse {
627 pub total: u64,
628 pub page: u32,
629 pub limit: u32,
630 pub total_pages: u32,
631 #[serde(skip_serializing_if = "Option::is_none")]
635 pub next_cursor: Option<String>,
636 #[serde(skip_serializing_if = "Option::is_none")]
638 pub prev_cursor: Option<String>,
639 #[serde(skip_serializing_if = "Option::is_none")]
641 pub has_more: Option<bool>,
642}
643
644impl PaginationResponse {
645 pub fn new(total: u64, page: u32, limit: u32) -> Self {
646 let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
647 Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
648 }
649
650 pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
654 Self {
655 total: info.total,
656 page: info.page,
657 limit: info.per_page,
658 total_pages: info.total_pages,
659 next_cursor: info.next_cursor.clone(),
660 prev_cursor: info.prev_cursor.clone(),
661 has_more: info.has_more,
662 }
663 }
664}
665
666#[derive(Debug, Serialize)]
668#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
669pub struct PaginatedResponse<T> {
670 pub data: Vec<T>,
671 pub meta: PaginationResponse,
672}
673
674#[derive(Debug, Serialize)]
677#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
678pub struct PaginatedApiResponse<T> {
679 pub success: bool,
680 pub data: Vec<T>,
681 pub meta: PaginationResponse,
682 #[serde(skip_serializing_if = "Option::is_none")]
683 pub error: Option<String>,
684}
685
686impl<T> PaginatedApiResponse<T> {
687 pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
690 Self {
691 success: true,
692 data,
693 meta: PaginationResponse::from_info(info),
694 error: None,
695 }
696 }
697
698 pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
700 Self {
701 success: true,
702 data,
703 meta: PaginationResponse::new(total, page, limit),
704 error: None,
705 }
706 }
707
708 pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
710 Self {
711 success: true,
712 data: resp.data,
713 meta: resp.meta,
714 error: None,
715 }
716 }
717
718 pub fn error(error: impl Into<String>) -> Self {
720 Self {
721 success: false,
722 data: Vec::new(),
723 meta: PaginationResponse::new(0, 0, 0),
724 error: Some(error.into()),
725 }
726 }
727}
728
729#[derive(Debug, Serialize, Deserialize)]
731#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
732pub struct BulkCreateRequest<T> {
733 pub items: Vec<T>,
734}
735
736#[derive(Debug, Serialize, Deserialize)]
738#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
739pub struct BulkResponse<T> {
740 pub items: Vec<T>,
741 pub total: usize,
742 pub failed: usize,
743 pub errors: Vec<String>,
744}
745
746#[derive(Debug, Serialize, Deserialize)]
748#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
749pub struct UpsertRequest<T> {
750 pub entity: T,
751 pub create_if_not_exists: bool,
752}
753
754#[derive(Debug, Serialize, Deserialize)]
757#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
758pub struct BatchIdsRequest {
759 pub ids: Vec<String>,
760}
761
762#[derive(Debug, Deserialize)]
765#[serde(bound = "U: DeserializeOwned")]
766#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
767pub struct BulkUpdateItem<U> {
768 pub id: String,
769 #[serde(flatten)]
773 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
774 pub data: U,
775}
776
777#[derive(Debug, Deserialize)]
779#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
780pub struct BulkPatchItem {
781 pub id: String,
782 pub patch: HashMap<String, serde_json::Value>,
783}
784
785#[derive(Debug, Deserialize)]
788#[serde(untagged)]
789#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
790pub enum BulkPatchRequest {
791 Shared {
793 ids: Vec<String>,
794 patch: HashMap<String, serde_json::Value>,
795 },
796 PerItem { items: Vec<BulkPatchItem> },
798}
799
800impl BulkPatchRequest {
801 fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
803 match self {
804 BulkPatchRequest::Shared { ids, patch } => {
805 ids.into_iter().map(|id| (id, patch.clone())).collect()
806 }
807 BulkPatchRequest::PerItem { items } => {
808 items.into_iter().map(|it| (it.id, it.patch)).collect()
809 }
810 }
811 }
812}
813
814#[derive(Debug, Serialize, Deserialize)]
816#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
817pub struct FilterOptions {
818 pub filters: HashMap<String, String>,
819 pub sort_by: Option<String>,
820 pub sort_order: Option<SortOrder>,
821}
822
823#[derive(Debug, Serialize, Deserialize, Clone, Default)]
825#[serde(rename_all = "lowercase")]
826#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
827pub enum SortOrder {
828 #[default]
829 Asc,
830 Desc,
831}
832
833#[derive(Debug, Serialize, Deserialize)]
835#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
836pub struct ListRequest {
837 pub page: Option<u32>,
838 pub limit: Option<u32>,
839 pub sort_by: Option<String>,
840 pub sort_order: Option<SortOrder>,
841 pub filters: Option<HashMap<String, String>>,
842}
843
844impl Default for ListRequest {
845 fn default() -> Self {
846 Self {
847 page: Some(1),
848 limit: Some(20),
849 sort_by: None,
850 sort_order: None,
851 filters: None,
852 }
853 }
854}
855
856#[async_trait::async_trait]
880pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
881where
882 Entity: Send + Sync + 'static,
885 CreateDto: Send + Sync + 'static,
886 UpdateDto: Send + Sync + 'static,
887{
888 type Error: std::error::Error + Send + Sync;
890
891 fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
894 let _ = err;
895 None
896 }
897
898 fn entity_name() -> &'static str;
900
901 async fn fetch_related_json(
904 &self,
905 _table: &str,
906 _ids: &[String],
907 ) -> Vec<serde_json::Value> {
908 Vec::new()
909 }
910
911 async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
913
914 async fn list_with_info(
918 &self,
919 page: u32,
920 limit: u32,
921 filters: HashMap<String, String>,
922 ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
923 let (rows, total) = self.list(page, limit, filters).await?;
924 Ok((
925 rows,
926 backbone_orm::repository::PaginationInfo::new(page, limit, total),
927 ))
928 }
929
930 async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
932
933 async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
935
936 async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
938
939 async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
941
942 async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
944
945 async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
947
948 async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
950
951 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
953
954 async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
956
957 async fn empty_trash(&self) -> Result<u64, Self::Error>;
959
960 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
962
963 async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
965
966 async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
968
969 async fn count_active(&self) -> Result<u64, Self::Error>;
971
972 fn table_name(&self) -> Option<&str> {
979 None
980 }
981
982 async fn aggregate(
987 &self,
988 spec: &backbone_orm::repository::AggregateSpec,
989 filters: HashMap<String, String>,
990 ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
991
992 async fn count_active_filtered(
1005 &self,
1006 filters: HashMap<String, String>,
1007 ) -> Result<u64, Self::Error> {
1008 self.list(1, 1, filters).await.map(|(_, total)| total)
1009 }
1010
1011 async fn count_deleted(&self) -> Result<u64, Self::Error>;
1013
1014 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1023 let mut n = 0;
1024 for id in ids {
1025 if self.soft_delete(&id).await? {
1026 n += 1;
1027 }
1028 }
1029 Ok(n)
1030 }
1031
1032 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1034 let mut out = Vec::with_capacity(ids.len());
1035 for id in ids {
1036 if let Some(e) = self.restore(&id).await? {
1037 out.push(e);
1038 }
1039 }
1040 Ok(out)
1041 }
1042
1043 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1045 let mut n = 0;
1046 for id in ids {
1047 if self.permanent_delete(&id).await? {
1048 n += 1;
1049 }
1050 }
1051 Ok(n)
1052 }
1053
1054 async fn restore_all(&self) -> Result<u64, Self::Error> {
1059 Ok(0)
1060 }
1061
1062 async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1064 let mut out = Vec::with_capacity(items.len());
1065 for (id, dto) in items {
1066 if let Some(e) = self.update(&id, dto).await? {
1067 out.push(e);
1068 }
1069 }
1070 Ok(out)
1071 }
1072
1073 async fn bulk_partial_update(
1075 &self,
1076 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1077 ) -> Result<Vec<Entity>, Self::Error> {
1078 let mut out = Vec::with_capacity(items.len());
1079 for (id, fields) in items {
1080 if let Some(e) = self.partial_update(&id, fields).await? {
1081 out.push(e);
1082 }
1083 }
1084 Ok(out)
1085 }
1086}
1087
1088pub struct BackboneCrudHandler<S, E, C, U, R>
1111where
1112 S: CrudService<E, C, U> + 'static,
1113 E: Serialize + Send + Sync + 'static,
1114 C: DeserializeOwned + Send + Sync + 'static,
1115 U: DeserializeOwned + Send + Sync + 'static,
1116 R: From<E> + Serialize + Send + Sync + 'static,
1117{
1118 service: Arc<S>,
1119 _phantom: std::marker::PhantomData<(E, C, U, R)>,
1120}
1121
1122impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1123where
1124 S: CrudService<E, C, U> + 'static,
1125 E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1126 C: DeserializeOwned + Send + Sync + 'static,
1127 U: DeserializeOwned + Send + Sync + 'static,
1128 R: From<E> + Serialize + Send + Sync + 'static,
1129{
1130 pub fn new(service: Arc<S>) -> Self {
1131 Self {
1132 service,
1133 _phantom: std::marker::PhantomData,
1134 }
1135 }
1136
1137 pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1161 where
1162 S: Clone,
1163 {
1164 Self::read_routes(service.clone(), base_path)
1165 .merge(Self::write_routes(service, base_path))
1166 }
1167
1168 pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1182 where
1183 S: Clone,
1184 {
1185 use axum::{
1186 extract::{Path, Query},
1187 routing::get,
1188 Extension, Router,
1189 };
1190
1191 let handler = Arc::new(Self::new(service));
1192
1193 Router::new()
1194 .route(base_path, get({
1196 let h = handler.clone();
1197 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1198 Self::list_handler(h, query, access).await
1199 }
1200 }))
1201 .route(&format!("{}/trash", base_path), get({
1203 let h = handler.clone();
1204 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1205 Self::list_deleted_handler(h, query, access).await
1206 }
1207 }))
1208 .route(&format!("{}/:id", base_path), get({
1210 let h = handler.clone();
1211 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1212 Self::get_handler(h, path, query, access).await
1213 }
1214 }))
1215 .route(&format!("{}/:id/deleted", base_path), get({
1217 let h = handler.clone();
1218 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1219 Self::get_deleted_handler(h, path, query, access).await
1220 }
1221 }))
1222 .route(&format!("{}/:id/history", base_path), get({
1225 let h = handler.clone();
1226 move |path: axum::extract::Path<String>,
1227 query: axum::extract::Query<ListQueryParams>,
1228 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
1229 Self::history_handler(h, path, query, provider).await
1230 }
1231 }))
1232 .route(&format!("{}/aggregate", base_path), get({
1234 let h = handler.clone();
1235 move |query: axum::extract::Query<ListQueryParams>| async move {
1236 Self::aggregate_handler(h, query).await
1237 }
1238 }))
1239 .route(&format!("{}/count", base_path), get({
1240 let h = handler.clone();
1241 move |query: axum::extract::Query<ListQueryParams>| async move {
1242 Self::count_active_handler(h, query).await
1243 }
1244 }))
1245 .route(&format!("{}/trash/count", base_path), get({
1247 let h = handler.clone();
1248 move || async move {
1249 Self::count_deleted_handler(h).await
1250 }
1251 }))
1252 }
1253
1254 pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1271 where
1272 S: Clone,
1273 {
1274 use axum::{
1275 extract::Path,
1276 routing::{delete, patch, post, put},
1277 Router,
1278 };
1279
1280 let handler = Arc::new(Self::new(service));
1281
1282 Router::new()
1283 .route(base_path, post({
1285 let h = handler.clone();
1286 move |body: JsonOrForm<C>| async move {
1287 Self::create_handler(h, body).await
1288 }
1289 }))
1290 .route(&format!("{}/bulk", base_path), post({
1292 let h = handler.clone();
1293 move |body: JsonOrForm<Vec<C>>| async move {
1294 Self::bulk_create_handler(h, body).await
1295 }
1296 }))
1297 .route(&format!("{}/upsert", base_path), post({
1299 let h = handler.clone();
1300 move |body: JsonOrForm<C>| async move {
1301 Self::upsert_handler(h, body).await
1302 }
1303 }))
1304 .route(&format!("{}/delete/bulk", base_path), post({
1306 let h = handler.clone();
1307 move |body: JsonOrForm<BatchIdsRequest>| async move {
1308 Self::bulk_delete_handler(h, body).await
1309 }
1310 }))
1311 .route(&format!("{}/restore/bulk", base_path), post({
1313 let h = handler.clone();
1314 move |body: JsonOrForm<BatchIdsRequest>| async move {
1315 Self::bulk_restore_handler(h, body).await
1316 }
1317 }))
1318 .route(&format!("{}/restore/all", base_path), post({
1320 let h = handler.clone();
1321 move || async move {
1322 Self::restore_all_handler(h).await
1323 }
1324 }))
1325 .route(&format!("{}/trash/bulk", base_path), delete({
1328 let h = handler.clone();
1329 move |body: JsonOrForm<BatchIdsRequest>| async move {
1330 Self::bulk_permanent_delete_handler(h, body).await
1331 }
1332 }))
1333 .route(&format!("{}/bulk", base_path), put({
1335 let h = handler.clone();
1336 move |body: JsonOrForm<Vec<BulkUpdateItem<U>>>| async move {
1337 Self::bulk_update_handler(h, body).await
1338 }
1339 }))
1340 .route(&format!("{}/bulk", base_path), patch({
1342 let h = handler.clone();
1343 move |body: JsonOrForm<BulkPatchRequest>| async move {
1344 Self::bulk_patch_handler(h, body).await
1345 }
1346 }))
1347 .route(&format!("{}/empty", base_path), delete({
1349 let h = handler.clone();
1350 move || async move {
1351 Self::empty_trash_handler(h).await
1352 }
1353 }))
1354 .route(&format!("{}/trash/:id", base_path), delete({
1356 let h = handler.clone();
1357 move |path: Path<String>| async move {
1358 Self::permanent_delete_handler(h, path).await
1359 }
1360 }))
1361 .route(&format!("{}/:id", base_path), put({
1363 let h = handler.clone();
1364 move |path: Path<String>, body: JsonOrForm<U>| async move {
1365 Self::update_handler(h, path, body).await
1366 }
1367 }))
1368 .route(&format!("{}/:id", base_path), patch({
1370 let h = handler.clone();
1371 move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
1372 Self::partial_update_handler(h, path, body).await
1373 }
1374 }))
1375 .route(&format!("{}/:id", base_path), delete({
1377 let h = handler.clone();
1378 move |path: Path<String>| async move {
1379 Self::delete_handler(h, path).await
1380 }
1381 }))
1382 .route(&format!("{}/:id/restore", base_path), post({
1384 let h = handler.clone();
1385 move |path: Path<String>| async move {
1386 Self::restore_handler(h, path).await
1387 }
1388 }))
1389 }
1390
1391 async fn list_handler(
1396 handler: Arc<Self>,
1397 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1398 access: Option<axum::Extension<AccessScope>>,
1399 ) -> impl axum::response::IntoResponse {
1400 use axum::{http::StatusCode, Json};
1401
1402 if let Some(err) = pagination_depth_error(params.page, params.limit) {
1403 return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1404 }
1405 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1406 return (
1407 StatusCode::BAD_REQUEST,
1408 Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1409 );
1410 }
1411
1412 let fields = sparse_fields(¶ms.filters);
1415 let includes = include_relations(¶ms.filters);
1416 let scope = access.map(|axum::Extension(s)| s);
1417
1418 let filters = repository_filters(¶ms);
1419
1420 match handler
1421 .service
1422 .list_with_info(params.page, params.limit, filters)
1423 .await
1424 {
1425 Ok((entities, info)) => {
1426 let mut rows: Vec<serde_json::Value> = entities
1429 .into_iter()
1430 .map(|e| {
1431 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1432 })
1433 .collect();
1434 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1435 let items: Vec<serde_json::Value> =
1436 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1437 let response = PaginatedApiResponse::ok_with_info(items, &info);
1438 (StatusCode::OK, Json(response))
1439 }
1440 Err(e) => {
1441 let msg = e.to_string();
1442 if is_bad_query_error(&msg) {
1443 (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
1444 format!("Invalid query parameter or filter: {msg}"),
1445 )))
1446 } else {
1447 (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
1448 }
1449 }
1450 }
1451 }
1452
1453 async fn create_handler(
1454 handler: Arc<Self>,
1455 JsonOrForm(dto): JsonOrForm<C>,
1456 ) -> impl axum::response::IntoResponse {
1457 use axum::{http::StatusCode, Json};
1458
1459 match handler.service.create(dto).await {
1460 Ok(entity) => {
1461 let response = secure::<E>(to_response_value(R::from(entity)), None);
1462 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1463 }
1464 Err(e) if S::violations_of(&e).is_some() => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1465 Err(e) => {
1466 let error_str = e.to_string();
1467 if error_str.contains("conflict") || error_str.contains("already exists") {
1468 (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1469 } else {
1470 (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1471 }
1472 }
1473 }
1474 }
1475
1476 async fn get_handler(
1477 handler: Arc<Self>,
1478 axum::extract::Path(id): axum::extract::Path<String>,
1479 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1480 access: Option<axum::Extension<AccessScope>>,
1481 ) -> impl axum::response::IntoResponse {
1482 use axum::{http::StatusCode, Json};
1483
1484 let fields = sparse_fields(¶ms.filters);
1485 let includes = include_relations(¶ms.filters);
1486 let scope = access.map(|axum::Extension(s)| s);
1487
1488 match handler.service.get_by_id(&id).await {
1489 Ok(Some(entity)) => {
1490 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
1491 let mut rows = [secured];
1492 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1493 let [secured] = rows;
1494 let value = project_sparse(secured, &fields);
1495 (StatusCode::OK, Json(ApiResponse::ok(value)))
1496 }
1497 Ok(None) => {
1498 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1499 }
1500 Err(e) => {
1501 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1502 }
1503 }
1504 }
1505
1506 async fn update_handler(
1507 handler: Arc<Self>,
1508 axum::extract::Path(id): axum::extract::Path<String>,
1509 JsonOrForm(dto): JsonOrForm<U>,
1510 ) -> impl axum::response::IntoResponse {
1511 use axum::{http::StatusCode, Json};
1512
1513 match handler.service.update(&id, dto).await {
1514 Ok(Some(entity)) => {
1515 let response = secure::<E>(to_response_value(R::from(entity)), None);
1516 (StatusCode::OK, Json(ApiResponse::ok(response)))
1517 }
1518 Ok(None) => {
1519 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1520 }
1521 Err(e) => {
1522 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1523 }
1524 }
1525 }
1526
1527 async fn partial_update_handler(
1528 handler: Arc<Self>,
1529 axum::extract::Path(id): axum::extract::Path<String>,
1530 JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
1531 ) -> impl axum::response::IntoResponse {
1532 use axum::{http::StatusCode, Json};
1533
1534 let fields: HashMap<String, serde_json::Value> = fields
1545 .into_iter()
1546 .map(|(k, v)| (camel_to_snake_case(&k), v))
1547 .collect();
1548
1549 match handler.service.partial_update(&id, fields).await {
1550 Ok(Some(entity)) => {
1551 let response = secure::<E>(to_response_value(R::from(entity)), None);
1552 (StatusCode::OK, Json(ApiResponse::ok(response)))
1553 }
1554 Ok(None) => {
1555 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1556 }
1557 Err(e) => {
1558 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1559 }
1560 }
1561 }
1562
1563 async fn delete_handler(
1564 handler: Arc<Self>,
1565 axum::extract::Path(id): axum::extract::Path<String>,
1566 ) -> impl axum::response::IntoResponse {
1567 use axum::{http::StatusCode, Json};
1568
1569 match handler.service.soft_delete(&id).await {
1570 Ok(true) => {
1571 (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
1574 }
1575 Ok(false) => {
1576 (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
1577 }
1578 Err(e) => {
1579 write_error(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR)
1580 }
1581 }
1582 }
1583
1584 async fn bulk_create_handler(
1585 handler: Arc<Self>,
1586 JsonOrForm(items): JsonOrForm<Vec<C>>,
1587 ) -> impl axum::response::IntoResponse {
1588 use axum::{http::StatusCode, Json};
1589
1590 match handler.service.bulk_create(items).await {
1591 Ok(entities) => {
1592 let result_items: Vec<serde_json::Value> = entities
1593 .into_iter()
1594 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1595 .collect();
1596 let total = result_items.len();
1597 let response = BulkResponse {
1598 items: result_items,
1599 total,
1600 failed: 0,
1601 errors: vec![],
1602 };
1603 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1604 }
1605 Err(e) => {
1606 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1607 }
1608 }
1609 }
1610
1611 async fn bulk_delete_handler(
1614 handler: Arc<Self>,
1615 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1616 ) -> impl axum::response::IntoResponse {
1617 use axum::{http::StatusCode, Json};
1618
1619 if let Some(err) = batch_size_error(req.ids.len()) {
1620 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1621 }
1622 match handler.service.bulk_soft_delete(req.ids).await {
1623 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1624 serde_json::json!({ "soft_deleted": count }),
1625 format!("Soft-deleted {count} item(s)"),
1626 ))),
1627 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1628 }
1629 }
1630
1631 async fn bulk_restore_handler(
1632 handler: Arc<Self>,
1633 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1634 ) -> impl axum::response::IntoResponse {
1635 use axum::{http::StatusCode, Json};
1636
1637 if let Some(err) = batch_size_error(req.ids.len()) {
1638 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1639 }
1640 match handler.service.bulk_restore(req.ids).await {
1641 Ok(entities) => {
1642 let items: Vec<serde_json::Value> = entities
1643 .into_iter()
1644 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1645 .collect();
1646 let total = items.len();
1647 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1648 }
1649 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1650 }
1651 }
1652
1653 async fn restore_all_handler(
1654 handler: Arc<Self>,
1655 ) -> impl axum::response::IntoResponse {
1656 use axum::{http::StatusCode, Json};
1657
1658 match handler.service.restore_all().await {
1659 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1660 serde_json::json!({ "restored": count }),
1661 format!("Restored {count} item(s) from trash"),
1662 ))),
1663 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
1664 }
1665 }
1666
1667 async fn bulk_permanent_delete_handler(
1668 handler: Arc<Self>,
1669 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1670 ) -> impl axum::response::IntoResponse {
1671 use axum::{http::StatusCode, Json};
1672
1673 if let Some(err) = batch_size_error(req.ids.len()) {
1674 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1675 }
1676 match handler.service.bulk_permanent_delete(req.ids).await {
1677 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1678 serde_json::json!({ "permanently_deleted": count }),
1679 format!("Permanently deleted {count} item(s)"),
1680 ))),
1681 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1682 }
1683 }
1684
1685 async fn bulk_update_handler(
1686 handler: Arc<Self>,
1687 JsonOrForm(items): JsonOrForm<Vec<BulkUpdateItem<U>>>,
1688 ) -> impl axum::response::IntoResponse {
1689 use axum::{http::StatusCode, Json};
1690
1691 if let Some(err) = batch_size_error(items.len()) {
1692 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1693 }
1694 let items: Vec<(String, U)> = items.into_iter().map(|it| (it.id, it.data)).collect();
1695 match handler.service.bulk_update(items).await {
1696 Ok(entities) => {
1697 let items: Vec<serde_json::Value> = entities
1698 .into_iter()
1699 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1700 .collect();
1701 let total = items.len();
1702 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1703 }
1704 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1705 }
1706 }
1707
1708 async fn bulk_patch_handler(
1709 handler: Arc<Self>,
1710 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
1711 ) -> impl axum::response::IntoResponse {
1712 use axum::{http::StatusCode, Json};
1713
1714 let items = req.into_items();
1715 if let Some(err) = batch_size_error(items.len()) {
1716 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1717 }
1718 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
1720 .into_iter()
1721 .map(|(id, fields)| {
1722 let fields = fields
1723 .into_iter()
1724 .map(|(k, v)| (camel_to_snake_case(&k), v))
1725 .collect();
1726 (id, fields)
1727 })
1728 .collect();
1729 match handler.service.bulk_partial_update(items).await {
1730 Ok(entities) => {
1731 let items: Vec<serde_json::Value> = entities
1732 .into_iter()
1733 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1734 .collect();
1735 let total = items.len();
1736 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1737 }
1738 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1739 }
1740 }
1741
1742 async fn upsert_handler(
1743 handler: Arc<Self>,
1744 JsonOrForm(dto): JsonOrForm<C>,
1745 ) -> impl axum::response::IntoResponse {
1746 use axum::{http::StatusCode, Json};
1747
1748 match handler.service.upsert(dto).await {
1749 Ok(entity) => {
1750 let response = secure::<E>(to_response_value(R::from(entity)), None);
1751 (StatusCode::OK, Json(ApiResponse::ok(response)))
1752 }
1753 Err(e) => {
1754 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1755 }
1756 }
1757 }
1758
1759 async fn list_deleted_handler(
1760 handler: Arc<Self>,
1761 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1762 access: Option<axum::Extension<AccessScope>>,
1763 ) -> impl axum::response::IntoResponse {
1764 use axum::{http::StatusCode, Json};
1765
1766 if let Some(err) = pagination_depth_error(params.page, params.limit) {
1767 return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1768 }
1769 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1770 return (
1771 StatusCode::BAD_REQUEST,
1772 Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1773 );
1774 }
1775
1776 let fields = sparse_fields(¶ms.filters);
1777 let includes = include_relations(¶ms.filters);
1778 let scope = access.map(|axum::Extension(s)| s);
1779
1780 match handler.service.list_deleted(params.page, params.limit).await {
1781 Ok((entities, total)) => {
1782 let mut rows: Vec<serde_json::Value> = entities
1786 .into_iter()
1787 .map(|e| {
1788 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1789 })
1790 .collect();
1791 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1792 let items: Vec<serde_json::Value> =
1793 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1794 let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
1795 (StatusCode::OK, Json(response))
1796 }
1797 Err(e) => {
1798 let msg = e.to_string();
1799 if is_bad_query_error(&msg) {
1800 (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
1801 format!("Invalid query parameter or filter: {msg}"),
1802 )))
1803 } else {
1804 (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
1805 }
1806 }
1807 }
1808 }
1809
1810 async fn restore_handler(
1811 handler: Arc<Self>,
1812 axum::extract::Path(id): axum::extract::Path<String>,
1813 ) -> impl axum::response::IntoResponse {
1814 use axum::{http::StatusCode, Json};
1815
1816 match handler.service.restore(&id).await {
1817 Ok(Some(entity)) => {
1818 let response = secure::<E>(to_response_value(R::from(entity)), None);
1819 (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
1820 }
1821 Ok(None) => {
1822 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1823 }
1824 Err(e) => {
1825 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1826 }
1827 }
1828 }
1829
1830 async fn empty_trash_handler(
1831 handler: Arc<Self>,
1832 ) -> impl axum::response::IntoResponse {
1833 use axum::{http::StatusCode, Json};
1834
1835 match handler.service.empty_trash().await {
1836 Ok(count) => {
1837 (StatusCode::OK, Json(ApiResponse::success_with_message(
1838 serde_json::json!({ "deleted_count": count }),
1839 format!("Successfully deleted {} items from trash", count)
1840 )))
1841 }
1842 Err(e) => {
1843 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1844 }
1845 }
1846 }
1847
1848 async fn get_deleted_handler(
1850 handler: Arc<Self>,
1851 axum::extract::Path(id): axum::extract::Path<String>,
1852 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1853 access: Option<axum::Extension<AccessScope>>,
1854 ) -> impl axum::response::IntoResponse {
1855 use axum::{http::StatusCode, Json};
1856
1857 let fields = sparse_fields(¶ms.filters);
1858 let scope = access.map(|axum::Extension(s)| s);
1859
1860 match handler.service.get_deleted_by_id(&id).await {
1861 Ok(Some(entity)) => {
1862 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
1863 let value = project_sparse(secured, &fields);
1864 (StatusCode::OK, Json(ApiResponse::ok(value)))
1865 }
1866 Ok(None) => {
1867 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
1868 }
1869 Err(e) => {
1870 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1871 }
1872 }
1873 }
1874
1875 async fn permanent_delete_handler(
1877 handler: Arc<Self>,
1878 axum::extract::Path(id): axum::extract::Path<String>,
1879 ) -> axum::response::Response {
1880 use axum::{http::StatusCode, Json, response::IntoResponse};
1881
1882 match handler.service.get_deleted_by_id(&id).await {
1884 Ok(Some(_)) => {
1885 match handler.service.permanent_delete(&id).await {
1887 Ok(true) => {
1888 StatusCode::NO_CONTENT.into_response()
1890 }
1891 Ok(false) => {
1892 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::error(
1893 format!("Failed to permanently delete {}", S::entity_name())
1894 ))).into_response()
1895 }
1896 Err(e) => {
1897 write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
1898 }
1899 }
1900 }
1901 Ok(None) => {
1902 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
1903 &format!("{} in trash", S::entity_name()), &id
1904 ))).into_response()
1905 }
1906 Err(e) => {
1907 write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
1908 }
1909 }
1910 }
1911
1912 async fn count_active_handler(
1914 handler: Arc<Self>,
1915 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1916 ) -> impl axum::response::IntoResponse {
1917 use axum::{http::StatusCode, Json};
1918
1919 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1923 return (
1924 StatusCode::BAD_REQUEST,
1925 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1926 );
1927 }
1928 match handler.service.count_active_filtered(repository_filters(¶ms)).await {
1929 Ok(count) => {
1930 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
1931 }
1932 Err(e) => {
1933 let msg = e.to_string();
1936 let code = if is_bad_query_error(&msg) {
1937 StatusCode::BAD_REQUEST
1938 } else {
1939 StatusCode::INTERNAL_SERVER_ERROR
1940 };
1941 (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
1942 }
1943 }
1944 }
1945
1946 async fn aggregate_handler(
1951 handler: Arc<Self>,
1952 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1953 ) -> impl axum::response::IntoResponse {
1954 use axum::{http::StatusCode, Json};
1955
1956 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1957 return (
1958 StatusCode::BAD_REQUEST,
1959 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1960 );
1961 }
1962 let spec = aggregate_spec(¶ms);
1963 match handler.service.aggregate(&spec, aggregate_filters(¶ms)).await {
1964 Ok(result) => {
1965 let render = |g: &backbone_orm::repository::AggregateGroup| {
1966 let mut out = serde_json::Map::new();
1970 out.insert("key".into(), match &g.key {
1971 Some(k) => serde_json::Value::String(k.clone()),
1972 None => serde_json::Value::Null,
1973 });
1974 if g.label.is_some() {
1975 out.insert(
1976 "label".into(),
1977 serde_json::Value::String(g.label.clone().unwrap()),
1978 );
1979 }
1980 out.insert("count".into(), serde_json::json!(g.count));
1981 for (compound, value) in &g.values {
1982 let Some((func, field)) = compound.split_once(':') else { continue };
1983 let slot = out
1984 .entry(func.to_string())
1985 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1986 if let Some(obj) = slot.as_object_mut() {
1987 obj.insert(field.to_string(), match value {
1988 Some(v) => serde_json::Value::String(v.clone()),
1992 None => serde_json::Value::Null,
1993 });
1994 }
1995 }
1996 serde_json::Value::Object(out)
1997 };
1998
1999 let body = serde_json::json!({
2000 "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
2001 "total": render(&result.total),
2002 "truncated": result.truncated,
2005 });
2006 (StatusCode::OK, Json(ApiResponse::ok(body)))
2007 }
2008 Err(e) => {
2009 let msg = e.to_string();
2010 let code = if is_bad_query_error(&msg) {
2011 StatusCode::BAD_REQUEST
2012 } else {
2013 StatusCode::INTERNAL_SERVER_ERROR
2014 };
2015 (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
2016 }
2017 }
2018 }
2019
2020 async fn history_handler(
2031 handler: Arc<Self>,
2032 axum::extract::Path(id): axum::extract::Path<String>,
2033 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2034 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2035 ) -> impl axum::response::IntoResponse {
2036 use axum::{http::StatusCode, Json};
2037
2038 let Some(axum::Extension(provider)) = provider else {
2039 return (
2040 StatusCode::NOT_IMPLEMENTED,
2041 Json(ApiResponse::<serde_json::Value>::error(
2042 "history is not configured for this service".to_string(),
2043 )),
2044 );
2045 };
2046
2047 let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
2051 return (
2052 StatusCode::NOT_IMPLEMENTED,
2053 Json(ApiResponse::<serde_json::Value>::error(
2054 "this entity cannot name its table, so its history cannot be keyed".to_string(),
2055 )),
2056 );
2057 };
2058
2059 let limit = params.limit.clamp(1, 200);
2060 let offset = params.page.saturating_sub(1) * limit;
2061
2062 match provider.history(&table, &id, limit, offset).await {
2063 Ok(Some(entries)) => (
2064 StatusCode::OK,
2065 Json(ApiResponse::ok(serde_json::json!({
2066 "audited": true,
2067 "entries": entries,
2068 }))),
2069 ),
2070 Ok(None) => (
2071 StatusCode::OK,
2072 Json(ApiResponse::ok(serde_json::json!({
2073 "audited": false,
2076 "entries": serde_json::Value::Null,
2077 }))),
2078 ),
2079 Err(e) => (
2080 StatusCode::INTERNAL_SERVER_ERROR,
2081 Json(ApiResponse::<serde_json::Value>::error(e)),
2082 ),
2083 }
2084 }
2085
2086 async fn count_deleted_handler(
2088 handler: Arc<Self>,
2089 ) -> impl axum::response::IntoResponse {
2090 use axum::{http::StatusCode, Json};
2091
2092 match handler.service.count_deleted().await {
2093 Ok(count) => {
2094 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2095 }
2096 Err(e) => {
2097 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2098 }
2099 }
2100 }
2101}
2102
2103pub trait BackboneHttpHandler<T>: Send + Sync {
2128 fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2130
2131 fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
2133
2134 fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2136
2137 fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
2139
2140 fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
2142
2143 fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
2145
2146 fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
2148
2149 fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
2151
2152 fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2154
2155 fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2157
2158 fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
2160
2161 fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2163}
2164
2165#[derive(Debug, Serialize, Deserialize)]
2171#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
2172pub struct PaginationRequest {
2173 pub page: u32,
2174 pub limit: u32,
2175 pub sort_by: Option<String>,
2176 pub sort_order: Option<String>,
2177}
2178
2179fn camel_to_snake_case(key: &str) -> String {
2195 let chars: Vec<char> = key.chars().collect();
2196 let mut result = String::with_capacity(key.len() + 2);
2197 for (i, &c) in chars.iter().enumerate() {
2198 if c.is_ascii_uppercase() {
2199 let prev = if i > 0 { chars[i - 1] } else { '\0' };
2200 let next = chars.get(i + 1).copied().unwrap_or('\0');
2201 let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
2205 let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
2206 if (crosses_lower || crosses_acronym)
2207 && !result.is_empty()
2208 && !result.ends_with('_')
2209 {
2210 result.push('_');
2211 }
2212 result.push(c.to_ascii_lowercase());
2213 } else {
2214 result.push(c);
2215 }
2216 }
2217 result
2218}
2219
2220#[cfg(test)]
2221mod tests {
2222 use super::*;
2223
2224 #[test]
2225 fn snake_case_input_passes_through_unchanged() {
2226 assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
2227 assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
2228 assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
2229 }
2230
2231 #[test]
2232 fn single_word_unchanged() {
2233 assert_eq!(camel_to_snake_case("name"), "name");
2234 assert_eq!(camel_to_snake_case("id"), "id");
2235 assert_eq!(camel_to_snake_case(""), "");
2236 }
2237
2238 #[test]
2239 fn camel_case_converts() {
2240 assert_eq!(camel_to_snake_case("isVip"), "is_vip");
2241 assert_eq!(camel_to_snake_case("userId"), "user_id");
2242 assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
2243 assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
2244 }
2245
2246 #[test]
2247 fn pascal_case_converts() {
2248 assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
2249 assert_eq!(camel_to_snake_case("UserId"), "user_id");
2250 }
2251
2252 #[test]
2253 fn acronym_runs_stay_together() {
2254 assert_eq!(camel_to_snake_case("IOError"), "io_error");
2257 assert_eq!(camel_to_snake_case("httpURL"), "http_url");
2258 assert_eq!(camel_to_snake_case("ABC"), "abc");
2259 assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
2260 }
2261
2262 #[test]
2263 fn digits_count_as_lowercase_for_boundary() {
2264 assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
2265 assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
2266 }
2267
2268 #[test]
2269 fn underscores_not_doubled() {
2270 assert_eq!(camel_to_snake_case("_Foo"), "_foo");
2271 assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
2272 }
2273
2274 #[test]
2275 fn shallow_pages_are_allowed() {
2276 assert!(pagination_depth_error(1, 100).is_none());
2278 assert!(pagination_depth_error(101, 100).is_none());
2280 }
2281
2282 #[test]
2283 fn pages_past_the_cap_are_rejected() {
2284 assert!(pagination_depth_error(102, 100).is_some());
2286 assert!(pagination_depth_error(1002, 10).is_some());
2288 }
2289
2290 #[test]
2291 fn oversized_page_size_is_clamped_before_the_check() {
2292 assert!(pagination_depth_error(102, 200).is_some());
2294 assert!(pagination_depth_error(101, 200).is_none());
2296 }
2297
2298 #[test]
2299 fn huge_page_number_does_not_overflow() {
2300 assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
2302 }
2303
2304 #[test]
2307 fn batch_size_within_limit_is_allowed() {
2308 assert!(batch_size_error(0).is_none());
2309 assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
2310 }
2311
2312 #[test]
2313 fn batch_size_over_limit_is_rejected() {
2314 assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
2315 }
2316
2317 #[test]
2318 fn bulk_patch_request_parses_shared_shape() {
2319 let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
2320 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2321 let items = req.into_items();
2322 assert_eq!(items.len(), 2);
2323 assert_eq!(items[0].1.get("status").unwrap(), "void");
2325 assert_eq!(items[1].1.get("status").unwrap(), "void");
2326 assert_eq!(items[0].0, "a");
2327 assert_eq!(items[1].0, "b");
2328 }
2329
2330 #[test]
2331 fn bulk_patch_request_parses_per_item_shape() {
2332 let json = r#"{ "items": [
2333 { "id": "a", "patch": { "status": "void" } },
2334 { "id": "b", "patch": { "note": "late" } }
2335 ] }"#;
2336 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2337 let items = req.into_items();
2338 assert_eq!(items.len(), 2);
2339 assert_eq!(items[0].0, "a");
2340 assert_eq!(items[0].1.get("status").unwrap(), "void");
2341 assert_eq!(items[1].0, "b");
2342 assert_eq!(items[1].1.get("note").unwrap(), "late");
2343 }
2344
2345 #[test]
2346 fn batch_ids_request_parses() {
2347 let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
2348 assert_eq!(req.ids, vec!["x", "y", "z"]);
2349 }
2350
2351 fn fields(q: &[(&str, &str)]) -> Vec<String> {
2354 let map: HashMap<String, String> =
2355 q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
2356 sparse_fields(&map)
2357 }
2358
2359 #[test]
2360 fn sparse_fields_parses_comma_list_and_trims() {
2361 assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
2362 }
2363
2364 #[test]
2365 fn sparse_fields_absent_or_empty_is_no_projection() {
2366 assert!(fields(&[]).is_empty());
2367 assert!(fields(&[("fields", "")]).is_empty());
2368 assert!(fields(&[("fields", " , ")]).is_empty());
2369 }
2370
2371 #[test]
2372 fn project_keeps_requested_keys_plus_id() {
2373 let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
2374 let out = project_sparse(v, &["name".into(), "basePrice".into()]);
2375 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
2376 }
2377
2378 #[test]
2379 fn project_always_includes_id_even_if_not_requested() {
2380 let v = serde_json::json!({ "id": "1", "name": "n" });
2381 let out = project_sparse(v, &["name".into()]);
2382 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2383 }
2384
2385 #[test]
2386 fn project_ignores_unknown_keys() {
2387 let v = serde_json::json!({ "id": "1", "name": "n" });
2388 let out = project_sparse(v, &["name".into(), "nope".into()]);
2389 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2390 }
2391
2392 #[test]
2393 fn project_empty_fields_returns_full_object() {
2394 let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
2395 let out = project_sparse(v.clone(), &[]);
2396 assert_eq!(out, v);
2397 }
2398
2399 #[test]
2400 fn project_non_object_returned_unchanged() {
2401 let v = serde_json::json!("scalar");
2402 assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
2403 }
2404
2405 fn owner_a() -> uuid::Uuid {
2410 uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
2411 }
2412 fn owner_b() -> uuid::Uuid {
2413 uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
2414 }
2415
2416 fn row() -> serde_json::Value {
2417 serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
2418 }
2419 const PRIV: &[&str] = &["hppPerUnit"];
2420
2421 struct Secretive;
2423 impl backbone_orm::EntityRepoMeta for Secretive {
2424 fn column_types() -> HashMap<String, String> {
2425 HashMap::new()
2426 }
2427 fn search_fields() -> &'static [&'static str] {
2428 &[]
2429 }
2430 fn secret_fields() -> &'static [&'static str] {
2431 &["tokenHash"]
2432 }
2433 fn private_fields() -> &'static [&'static str] {
2434 PRIV
2435 }
2436 fn owner_field() -> Option<&'static str> {
2437 Some("providerId")
2438 }
2439 fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
2440 match relation {
2441 "user" => &["passwordHash"],
2442 _ => &[],
2443 }
2444 }
2445 }
2446
2447 fn secret_row() -> serde_json::Value {
2448 let mut r = row();
2449 r["tokenHash"] = serde_json::json!("digest");
2450 r
2451 }
2452
2453 #[test]
2454 fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
2455 for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
2456 let out = secure::<Secretive>(secret_row(), scope.as_ref());
2457 assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
2458 assert!(out.get("name").is_some());
2459 }
2460 let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform));
2461 assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
2462 }
2463
2464 #[test]
2465 fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
2466 let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
2467 let params = ListQueryParams {
2468 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
2469 sort_by: sort_by.map(str::to_string),
2470 ..Default::default()
2471 };
2472 named_secret(¶ms, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
2473 };
2474 for pairs in [
2475 vec![("token_hash", "abc")],
2476 vec![("tokenHash[startwith]", "a")],
2477 vec![("orderby", "name,-token_hash")],
2478 vec![("orderby[token_hash]", "asc")],
2479 vec![("searchFields", "name,tokenHash")],
2480 vec![("min", "token_hash")],
2481 vec![("group_by", "tokenHash")],
2482 ] {
2483 assert_eq!(ask(&pairs, None).as_deref().map(camel_to_snake_case), Some("token_hash".into()), "{pairs:?}");
2484 }
2485 assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
2486 assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
2487 }
2488
2489 #[test]
2490 fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
2491 let out = secure::<Secretive>(secret_row(), None);
2492 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
2493 }
2494
2495 #[test]
2496 fn an_included_row_loses_the_related_models_secrets() {
2497 let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
2498 let out = related_row::<Secretive>("user", raw.clone());
2499 assert!(out.get("passwordHash").is_none(), "{out}");
2500 assert_eq!(out["email"], "a@b.c");
2501 assert!(related_row::<Secretive>("other", raw).get("passwordHash").is_some(), "only the named relation");
2502 }
2503
2504 #[test]
2505 fn security_no_private_fields_is_noop() {
2506 let v = row();
2507 assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
2508 }
2509
2510 #[test]
2511 fn security_platform_sees_private() {
2512 let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
2513 assert!(out.get("hppPerUnit").is_some());
2514 }
2515
2516 #[test]
2517 fn security_owner_tenant_sees_private() {
2518 let out = apply_field_security(
2519 row(),
2520 Some(&AccessScope::Company(owner_a())),
2521 PRIV,
2522 Some("providerId"),
2523 );
2524 assert!(out.get("hppPerUnit").is_some());
2525 }
2526
2527 #[test]
2528 fn security_other_tenant_stripped() {
2529 let out = apply_field_security(
2530 row(),
2531 Some(&AccessScope::Company(owner_b())),
2532 PRIV,
2533 Some("providerId"),
2534 );
2535 assert!(out.get("hppPerUnit").is_none());
2536 assert!(out.get("name").is_some());
2537 }
2538
2539 #[test]
2540 fn security_absent_scope_fails_closed() {
2541 let out = apply_field_security(row(), None, PRIV, Some("providerId"));
2542 assert!(out.get("hppPerUnit").is_none());
2543 }
2544
2545 #[test]
2548 fn include_relations_parses_include_and_with() {
2549 let mut q = HashMap::new();
2550 q.insert("include".to_string(), "provider, outlet ".to_string());
2551 assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
2552 let mut q2 = HashMap::new();
2553 q2.insert("with".to_string(), "category".to_string());
2554 assert_eq!(include_relations(&q2), vec!["category"]);
2555 assert!(include_relations(&HashMap::new()).is_empty());
2556 }
2557
2558 #[test]
2559 fn snake_to_camel_converts() {
2560 assert_eq!(snake_to_camel("provider_id"), "providerId");
2561 assert_eq!(snake_to_camel("business_name"), "businessName");
2562 assert_eq!(snake_to_camel("id"), "id");
2563 }
2564
2565 #[test]
2566 fn camelize_keys_top_level_only() {
2567 let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
2568 let out = camelize_keys(v);
2569 assert!(out.get("providerId").is_some());
2570 assert!(out.get("metaData").unwrap().get("created_at").is_some());
2572 }
2573
2574 #[test]
2575 fn security_null_owner_only_platform_sees() {
2576 let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
2577 let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
2578 assert!(tenant.get("hppPerUnit").is_none());
2579 let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
2580 assert!(plat.get("hppPerUnit").is_some());
2581 }
2582
2583 #[test]
2584 fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
2585 let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
2586 let (status, axum::Json(body)) =
2587 write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
2588 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
2589 let json = serde_json::to_value(&body).unwrap();
2590 assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
2591 assert_eq!(json["violations"][0]["path"], "status");
2592 assert_eq!(json["violations"][0]["code"], "field_not_writable");
2593
2594 let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
2596 assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
2597 assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
2598 }
2599}