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
114pub fn service_error_response<T>(
123 err: &crate::service::ServiceError,
124) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
125 use crate::persistence::traits::RepositoryError;
126 use crate::service::ServiceError;
127 use axum::http::StatusCode;
128 let (violations, fallback) = match err {
129 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => {
130 (Some(v.clone()), StatusCode::UNPROCESSABLE_ENTITY)
131 }
132 ServiceError::Validation(_)
133 | ServiceError::Repository(RepositoryError::ValidationError(_)) => {
134 (None, StatusCode::BAD_REQUEST)
135 }
136 ServiceError::NotFound | ServiceError::Repository(RepositoryError::NotFound) => {
137 (None, StatusCode::NOT_FOUND)
138 }
139 ServiceError::AlreadyExists(_)
140 | ServiceError::Repository(RepositoryError::AlreadyExists(_))
141 | ServiceError::Repository(RepositoryError::Conflict(_)) => (None, StatusCode::CONFLICT),
142 _ => (None, StatusCode::INTERNAL_SERVER_ERROR),
143 };
144 write_error(violations, err, fallback)
145}
146
147#[derive(Debug, Deserialize, Default, Clone)]
149#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
150pub struct ListQueryParams {
151 #[serde(default = "default_page")]
152 pub page: u32,
153 #[serde(default = "default_limit")]
154 pub limit: u32,
155 #[serde(default)]
156 pub sort_by: Option<String>,
157 #[serde(default)]
158 pub sort_order: Option<String>,
159 #[serde(default)]
160 pub search: Option<String>,
161 #[serde(default)]
162 pub status: Option<String>,
163 #[serde(flatten)]
164 pub filters: HashMap<String, String>,
165}
166
167fn default_page() -> u32 { 1 }
168fn default_limit() -> u32 { 20 }
169
170const RESERVED_QUERY_KEYS: [&str; 3] = ["fields", "include", "with"];
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct HistoryEntry {
188 pub occurred_at: String,
189 pub action: String,
191 pub actor: String,
192 pub changed: serde_json::Value,
193 pub reason: Option<String>,
194 pub correlation_id: Option<String>,
195}
196
197#[async_trait::async_trait]
204pub trait HistoryProvider: Send + Sync {
205 async fn history(
213 &self,
214 table: &str,
215 id: &str,
216 limit: u32,
217 offset: u32,
218 ) -> Result<Option<Vec<HistoryEntry>>, String>;
219}
220
221const AGGREGATE_QUERY_KEYS: [&str; 7] =
227 ["group_by", "sum", "avg", "min", "max", "group_limit", "group_label"];
228
229fn aggregate_spec(params: &ListQueryParams) -> backbone_orm::repository::AggregateSpec {
235 use backbone_orm::repository::{AggregateFn, AggregateSpec};
236
237 let mut reductions = Vec::new();
238 for (key, func) in [
239 ("sum", AggregateFn::Sum),
240 ("avg", AggregateFn::Avg),
241 ("min", AggregateFn::Min),
242 ("max", AggregateFn::Max),
243 ] {
244 if let Some(raw) = params.filters.get(key) {
245 for field in raw.split(',').map(str::trim).filter(|f| !f.is_empty()) {
246 reductions.push((func, field.to_string()));
247 }
248 }
249 }
250
251 AggregateSpec {
252 group_by: params
253 .filters
254 .get("group_by")
255 .map(|g| g.trim().to_string())
256 .filter(|g| !g.is_empty()),
257 reductions,
258 group_limit: params
259 .filters
260 .get("group_limit")
261 .and_then(|l| l.trim().parse::<usize>().ok())
262 .unwrap_or(0),
263 label_field: params
264 .filters
265 .get("group_label")
266 .map(|l| l.trim().to_string())
267 .filter(|l| !l.is_empty()),
268 label_relation: None,
269 }
270}
271
272fn named_secret(params: &ListQueryParams, secret_fields: &[&str]) -> Option<String> {
279 if secret_fields.is_empty() {
280 return None;
281 }
282 let secrets: Vec<String> = secret_fields.iter().map(|f| camel_to_snake_case(f)).collect();
283 const COLUMN_LISTS: [&str; 10] = [
284 "orderby", "sort", "searchfields", "search_fields", "group_by", "sum", "avg", "min", "max", "group_label",
285 ];
286 let mut names: Vec<String> = Vec::new();
287 for (key, value) in ¶ms.filters {
288 match key.find('[') {
289 Some(open) => {
290 names.push(key[..open].to_string());
291 names.push(key[open + 1..].trim_end_matches(']').to_string());
292 }
293 None => names.push(key.clone()),
294 }
295 if COLUMN_LISTS.contains(&key.to_ascii_lowercase().as_str()) {
296 names.extend(value.split(',').map(|v| v.trim().trim_start_matches('-').to_string()));
297 }
298 }
299 names.extend(params.sort_by.iter().cloned());
300 names
301 .into_iter()
302 .find(|n| secrets.contains(&camel_to_snake_case(n.trim())))
303}
304
305fn secret_named_message(field: &str) -> String {
307 format!("'{field}' is a secret field: it cannot be filtered, sorted, searched or aggregated")
308}
309
310fn repository_filters(params: &ListQueryParams) -> HashMap<String, String> {
322 let mut filters = params.filters.clone();
323 for key in RESERVED_QUERY_KEYS {
324 filters.remove(key);
325 }
326 filters.retain(|key, _| !backbone_orm::is_internal_key(key));
329 if let Some(search) = params.search.clone() {
330 filters.insert("search".to_string(), search);
331 }
332 if let Some(status) = params.status.clone() {
333 filters.insert("status".to_string(), status);
334 }
335 filters
336}
337
338fn aggregate_filters(params: &ListQueryParams) -> HashMap<String, String> {
341 let mut filters = repository_filters(params);
342 for key in AGGREGATE_QUERY_KEYS {
343 filters.remove(key);
344 }
345 filters
346}
347
348fn sparse_fields(query: &HashMap<String, String>) -> Vec<String> {
349 query
350 .get("fields")
351 .map(|s| {
352 s.split(',')
353 .map(str::trim)
354 .filter(|f| !f.is_empty())
355 .map(str::to_string)
356 .collect()
357 })
358 .unwrap_or_default()
359}
360
361fn to_response_value<R: Serialize>(r: R) -> serde_json::Value {
364 serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
365}
366
367fn project_sparse(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
371 if fields.is_empty() {
372 return value;
373 }
374 if let serde_json::Value::Object(map) = &mut value {
375 map.retain(|k, _| k == "id" || fields.iter().any(|f| f == k));
376 }
377 value
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403pub enum AccessScope {
404 Platform,
406 Company(uuid::Uuid),
408}
409
410impl AccessScope {
411 pub fn company(&self) -> Option<uuid::Uuid> {
417 match self {
418 AccessScope::Platform => None,
419 AccessScope::Company(id) => Some(*id),
420 }
421 }
422}
423
424fn strip_secrets(mut value: serde_json::Value, secret_fields: &[&str]) -> serde_json::Value {
434 if let serde_json::Value::Object(map) = &mut value {
435 for f in secret_fields {
436 map.remove(*f);
437 }
438 }
439 value
440}
441
442fn secure<E: backbone_orm::EntityRepoMeta>(
446 value: serde_json::Value,
447 scope: Option<&AccessScope>,
448) -> serde_json::Value {
449 apply_field_security(
450 strip_secrets(value, E::secret_fields()),
451 scope,
452 E::private_fields(),
453 E::owner_field(),
454 )
455}
456
457pub fn without_secrets<E: backbone_orm::EntityRepoMeta, R: Serialize>(response: R) -> serde_json::Value {
461 secure::<E>(to_response_value(response), None)
462}
463
464fn related_row<E: backbone_orm::EntityRepoMeta>(
468 relation: &str,
469 table: &str,
470 obj: serde_json::Value,
471) -> serde_json::Value {
472 let row = strip_secrets(camelize_keys(obj), E::relation_secret_fields(relation));
473 strip_secrets(row, &backbone_orm::secret_registry::secret_fields_of(table))
475}
476
477fn apply_field_security(
478 mut value: serde_json::Value,
479 scope: Option<&AccessScope>,
480 private_fields: &[&str],
481 owner_field: Option<&str>,
482) -> serde_json::Value {
483 if private_fields.is_empty() {
484 return value;
485 }
486 let can_see_private = match scope {
487 Some(AccessScope::Platform) => true,
488 Some(AccessScope::Company(id)) => owner_field
489 .and_then(|f| value.get(f))
490 .and_then(|v| v.as_str())
491 .and_then(|owner| uuid::Uuid::parse_str(owner).ok())
495 .is_some_and(|owner| owner == *id),
496 None => false,
497 };
498 if !can_see_private {
499 if let serde_json::Value::Object(map) = &mut value {
500 for f in private_fields {
501 map.remove(*f);
502 }
503 }
504 }
505 value
506}
507
508fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
510 query
511 .get("include")
512 .or_else(|| query.get("with"))
513 .map(|s| {
514 s.split(',')
515 .map(str::trim)
516 .filter(|s| !s.is_empty())
517 .map(str::to_string)
518 .collect()
519 })
520 .unwrap_or_default()
521}
522
523fn snake_to_camel(s: &str) -> String {
524 let mut out = String::with_capacity(s.len());
525 let mut upper = false;
526 for c in s.chars() {
527 if c == '_' {
528 upper = true;
529 } else if upper {
530 out.extend(c.to_uppercase());
531 upper = false;
532 } else {
533 out.push(c);
534 }
535 }
536 out
537}
538
539fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
542 match v {
543 serde_json::Value::Object(m) => serde_json::Value::Object(
544 m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
545 ),
546 other => other,
547 }
548}
549
550async fn expand_includes<S, E, C, U>(
560 service: &S,
561 rows: &mut [serde_json::Value],
562 includes: &[String],
563) where
564 S: CrudService<E, C, U>,
565 E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
566 C: Send + Sync + 'static,
567 U: Send + Sync + 'static,
568{
569 if includes.is_empty() || rows.is_empty() {
570 return;
571 }
572 for (rel_name, table, fk_field) in E::relations() {
573 if !includes.iter().any(|i| i == rel_name) {
574 continue;
575 }
576 let mut ids: Vec<String> = rows
577 .iter()
578 .filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
579 .collect();
580 ids.sort();
581 ids.dedup();
582 if ids.is_empty() {
583 continue;
584 }
585 let related = service.fetch_related_json(table, &ids).await;
586 let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
587 for obj in related {
588 if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
589 by_id.insert(id, related_row::<E>(rel_name, table, obj));
590 }
591 }
592 for r in rows.iter_mut() {
593 let related_obj = r
594 .get(fk_field)
595 .and_then(|v| v.as_str())
596 .and_then(|id| by_id.get(id).cloned())
597 .unwrap_or(serde_json::Value::Null);
598 if let serde_json::Value::Object(m) = r {
599 m.insert((*rel_name).to_string(), related_obj);
600 }
601 }
602 }
603}
604
605pub const MAX_PER_PAGE: u32 = 100;
609
610pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
615
616fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
622 let effective_limit = limit.clamp(1, MAX_PER_PAGE);
623 let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
624 if offset > MAX_PAGINATION_OFFSET {
625 Some(format!(
626 "Result set too deep: offset {offset} exceeds the maximum of \
627 {MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
628 ))
629 } else {
630 None
631 }
632}
633
634pub use crate::service::MAX_BATCH_SIZE;
640
641fn batch_size_error(count: usize) -> Option<String> {
644 if count > MAX_BATCH_SIZE {
645 Some(format!(
646 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
647 ))
648 } else {
649 None
650 }
651}
652
653fn is_bad_query_error(msg: &str) -> bool {
662 let m = msg.to_lowercase();
663 m.contains("does not exist")
664 || m.contains("invalid input syntax")
665 || m.contains("42703")
666 || m.contains("not a column of this entity")
670 || m.contains("needs a numeric column")
671}
672
673#[derive(Debug, Serialize, Deserialize, Clone)]
675#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
676pub struct PaginationResponse {
677 pub total: u64,
678 pub page: u32,
679 pub limit: u32,
680 pub total_pages: u32,
681 #[serde(skip_serializing_if = "Option::is_none")]
685 pub next_cursor: Option<String>,
686 #[serde(skip_serializing_if = "Option::is_none")]
688 pub prev_cursor: Option<String>,
689 #[serde(skip_serializing_if = "Option::is_none")]
691 pub has_more: Option<bool>,
692}
693
694impl PaginationResponse {
695 pub fn new(total: u64, page: u32, limit: u32) -> Self {
696 let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
697 Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
698 }
699
700 pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
704 Self {
705 total: info.total,
706 page: info.page,
707 limit: info.per_page,
708 total_pages: info.total_pages,
709 next_cursor: info.next_cursor.clone(),
710 prev_cursor: info.prev_cursor.clone(),
711 has_more: info.has_more,
712 }
713 }
714}
715
716#[derive(Debug, Serialize)]
718#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
719pub struct PaginatedResponse<T> {
720 pub data: Vec<T>,
721 pub meta: PaginationResponse,
722}
723
724#[derive(Debug, Serialize)]
727#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
728pub struct PaginatedApiResponse<T> {
729 pub success: bool,
730 pub data: Vec<T>,
731 pub meta: PaginationResponse,
732 #[serde(skip_serializing_if = "Option::is_none")]
733 pub error: Option<String>,
734}
735
736impl<T> PaginatedApiResponse<T> {
737 pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
740 Self {
741 success: true,
742 data,
743 meta: PaginationResponse::from_info(info),
744 error: None,
745 }
746 }
747
748 pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
750 Self {
751 success: true,
752 data,
753 meta: PaginationResponse::new(total, page, limit),
754 error: None,
755 }
756 }
757
758 pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
760 Self {
761 success: true,
762 data: resp.data,
763 meta: resp.meta,
764 error: None,
765 }
766 }
767
768 pub fn error(error: impl Into<String>) -> Self {
770 Self {
771 success: false,
772 data: Vec::new(),
773 meta: PaginationResponse::new(0, 0, 0),
774 error: Some(error.into()),
775 }
776 }
777}
778
779#[derive(Debug, Serialize, Deserialize)]
781#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
782pub struct BulkCreateRequest<T> {
783 pub items: Vec<T>,
784}
785
786#[derive(Debug, Serialize, Deserialize)]
788#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
789pub struct BulkResponse<T> {
790 pub items: Vec<T>,
791 pub total: usize,
792 pub failed: usize,
793 pub errors: Vec<String>,
794}
795
796#[derive(Debug, Serialize, Deserialize)]
798#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
799pub struct UpsertRequest<T> {
800 pub entity: T,
801 pub create_if_not_exists: bool,
802}
803
804#[derive(Debug, Serialize, Deserialize)]
807#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
808pub struct BatchIdsRequest {
809 pub ids: Vec<String>,
810}
811
812#[derive(Debug, Deserialize)]
815#[serde(bound = "U: DeserializeOwned")]
816#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
817pub struct BulkUpdateItem<U> {
818 pub id: String,
819 #[serde(flatten)]
823 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
824 pub data: U,
825}
826
827#[derive(Debug, Deserialize)]
829#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
830pub struct BulkPatchItem {
831 pub id: String,
832 pub patch: HashMap<String, serde_json::Value>,
833}
834
835#[derive(Debug, Deserialize)]
838#[serde(untagged)]
839#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
840pub enum BulkPatchRequest {
841 Shared {
843 ids: Vec<String>,
844 patch: HashMap<String, serde_json::Value>,
845 },
846 PerItem { items: Vec<BulkPatchItem> },
848}
849
850impl BulkPatchRequest {
851 fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
853 match self {
854 BulkPatchRequest::Shared { ids, patch } => {
855 ids.into_iter().map(|id| (id, patch.clone())).collect()
856 }
857 BulkPatchRequest::PerItem { items } => {
858 items.into_iter().map(|it| (it.id, it.patch)).collect()
859 }
860 }
861 }
862}
863
864#[derive(Debug, Default, Serialize)]
866#[serde(rename_all = "camelCase")]
867#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
868pub struct BulkPreview {
869 pub will_change: Vec<PreviewChange>,
871 pub unchanged: Vec<String>,
873 pub refused: Vec<PreviewRefusal>,
875 pub total: usize,
877}
878
879#[derive(Debug, Serialize)]
881#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
882pub struct PreviewChange {
883 pub id: String,
885 pub changes: Vec<FieldChange>,
887}
888
889#[derive(Debug, Serialize)]
891#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
892pub struct FieldChange {
893 pub field: String,
895 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
897 pub from: serde_json::Value,
898 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
900 pub to: serde_json::Value,
901}
902
903#[derive(Debug, Serialize)]
905#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
906pub struct PreviewRefusal {
907 pub id: String,
909 pub code: String,
911 pub message: String,
913}
914
915type PreviewOutcome = Result<(serde_json::Value, serde_json::Value), (String, String)>;
917
918fn preview_answer(
925 rows: Vec<(String, PreviewOutcome)>,
926 patched: &HashMap<String, Vec<String>>,
927) -> BulkPreview {
928 let mut out = BulkPreview::default();
929 for (id, outcome) in rows {
930 match outcome {
931 Ok((before, after)) => {
932 let mut keys = patched.get(&id).cloned().unwrap_or_default();
933 keys.sort();
934 let changes: Vec<FieldChange> = keys
935 .into_iter()
936 .filter_map(|field| {
937 let read = |v: &serde_json::Value| {
938 v.get(snake_to_camel(&field))
939 .or_else(|| v.get(&field))
940 .cloned()
941 .unwrap_or(serde_json::Value::Null)
942 };
943 let (from, to) = (read(&before), read(&after));
944 (from != to).then_some(FieldChange { field, from, to })
945 })
946 .collect();
947 if changes.is_empty() {
948 out.unchanged.push(id);
949 } else {
950 out.will_change.push(PreviewChange { id, changes });
951 }
952 }
953 Err((code, message)) => out.refused.push(PreviewRefusal { id, code, message }),
954 }
955 }
956 out.total = out.will_change.len() + out.unchanged.len() + out.refused.len();
957 out
958}
959
960#[derive(Debug, Serialize, Deserialize)]
962#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
963pub struct FilterOptions {
964 pub filters: HashMap<String, String>,
965 pub sort_by: Option<String>,
966 pub sort_order: Option<SortOrder>,
967}
968
969#[derive(Debug, Serialize, Deserialize, Clone, Default)]
971#[serde(rename_all = "lowercase")]
972#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
973pub enum SortOrder {
974 #[default]
975 Asc,
976 Desc,
977}
978
979#[derive(Debug, Serialize, Deserialize)]
981#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
982pub struct ListRequest {
983 pub page: Option<u32>,
984 pub limit: Option<u32>,
985 pub sort_by: Option<String>,
986 pub sort_order: Option<SortOrder>,
987 pub filters: Option<HashMap<String, String>>,
988}
989
990impl Default for ListRequest {
991 fn default() -> Self {
992 Self {
993 page: Some(1),
994 limit: Some(20),
995 sort_by: None,
996 sort_order: None,
997 filters: None,
998 }
999 }
1000}
1001
1002#[async_trait::async_trait]
1026pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
1027where
1028 Entity: Send + Sync + 'static,
1031 CreateDto: Send + Sync + 'static,
1032 UpdateDto: Send + Sync + 'static,
1033{
1034 type Error: std::error::Error + Send + Sync;
1036
1037 fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
1040 let _ = err;
1041 None
1042 }
1043
1044 fn entity_name() -> &'static str;
1046
1047 async fn fetch_related_json(
1050 &self,
1051 _table: &str,
1052 _ids: &[String],
1053 ) -> Vec<serde_json::Value> {
1054 Vec::new()
1055 }
1056
1057 async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1059
1060 async fn list_with_info(
1064 &self,
1065 page: u32,
1066 limit: u32,
1067 filters: HashMap<String, String>,
1068 ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
1069 let (rows, total) = self.list(page, limit, filters).await?;
1070 Ok((
1071 rows,
1072 backbone_orm::repository::PaginationInfo::new(page, limit, total),
1073 ))
1074 }
1075
1076 async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1078
1079 async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1081
1082 async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
1084
1085 async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
1087
1088 async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
1090
1091 async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
1093
1094 async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1096
1097 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
1099
1100 async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1102
1103 async fn empty_trash(&self) -> Result<u64, Self::Error>;
1105
1106 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1108
1109 async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
1111
1112 async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1114
1115 async fn count_active(&self) -> Result<u64, Self::Error>;
1117
1118 fn table_name(&self) -> Option<&str> {
1125 None
1126 }
1127
1128 async fn aggregate(
1133 &self,
1134 spec: &backbone_orm::repository::AggregateSpec,
1135 filters: HashMap<String, String>,
1136 ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
1137
1138 async fn count_active_filtered(
1151 &self,
1152 filters: HashMap<String, String>,
1153 ) -> Result<u64, Self::Error> {
1154 self.list(1, 1, filters).await.map(|(_, total)| total)
1155 }
1156
1157 async fn count_deleted(&self) -> Result<u64, Self::Error>;
1159
1160 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1169 let mut n = 0;
1170 for id in ids {
1171 if self.soft_delete(&id).await? {
1172 n += 1;
1173 }
1174 }
1175 Ok(n)
1176 }
1177
1178 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1180 let mut out = Vec::with_capacity(ids.len());
1181 for id in ids {
1182 if let Some(e) = self.restore(&id).await? {
1183 out.push(e);
1184 }
1185 }
1186 Ok(out)
1187 }
1188
1189 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1191 let mut n = 0;
1192 for id in ids {
1193 if self.permanent_delete(&id).await? {
1194 n += 1;
1195 }
1196 }
1197 Ok(n)
1198 }
1199
1200 async fn restore_all(&self) -> Result<u64, Self::Error> {
1205 Ok(0)
1206 }
1207
1208 async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1210 let mut out = Vec::with_capacity(items.len());
1211 for (id, dto) in items {
1212 if let Some(e) = self.update(&id, dto).await? {
1213 out.push(e);
1214 }
1215 }
1216 Ok(out)
1217 }
1218
1219 async fn bulk_partial_update(
1221 &self,
1222 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1223 ) -> Result<Vec<Entity>, Self::Error> {
1224 let mut out = Vec::with_capacity(items.len());
1225 for (id, fields) in items {
1226 if let Some(e) = self.partial_update(&id, fields).await? {
1227 out.push(e);
1228 }
1229 }
1230 Ok(out)
1231 }
1232
1233 async fn preview_bulk_partial_update(
1238 &self,
1239 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1240 ) -> Result<Option<Vec<BulkPreviewRow<Entity, Self::Error>>>, Self::Error> {
1241 let _ = items;
1242 Ok(None)
1243 }
1244}
1245
1246pub struct BulkPreviewRow<Entity, Err> {
1249 pub id: String,
1251 pub outcome: Result<(Entity, Entity), Err>,
1253}
1254
1255pub struct BackboneCrudHandler<S, E, C, U, R>
1278where
1279 S: CrudService<E, C, U> + 'static,
1280 E: Serialize + Send + Sync + 'static,
1281 C: DeserializeOwned + Send + Sync + 'static,
1282 U: DeserializeOwned + Send + Sync + 'static,
1283 R: From<E> + Serialize + Send + Sync + 'static,
1284{
1285 service: Arc<S>,
1286 _phantom: std::marker::PhantomData<(E, C, U, R)>,
1287}
1288
1289impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1290where
1291 S: CrudService<E, C, U> + 'static,
1292 E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1293 C: DeserializeOwned + Send + Sync + 'static,
1294 U: DeserializeOwned + Send + Sync + 'static,
1295 R: From<E> + Serialize + Send + Sync + 'static,
1296{
1297 pub fn new(service: Arc<S>) -> Self {
1298 Self {
1299 service,
1300 _phantom: std::marker::PhantomData,
1301 }
1302 }
1303
1304 pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1328 where
1329 S: Clone,
1330 {
1331 Self::read_routes(service.clone(), base_path)
1332 .merge(Self::write_routes(service, base_path))
1333 }
1334
1335 pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1349 where
1350 S: Clone,
1351 {
1352 if let Some(table) = service.table_name() {
1355 backbone_orm::secret_registry::register(table, E::secret_fields());
1356 }
1357 use axum::{
1358 extract::{Path, Query},
1359 routing::get,
1360 Extension, Router,
1361 };
1362
1363 let handler = Arc::new(Self::new(service));
1364
1365 Router::new()
1366 .route(base_path, get({
1368 let h = handler.clone();
1369 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1370 Self::list_handler(h, query, access).await
1371 }
1372 }))
1373 .route(&format!("{}/trash", base_path), get({
1375 let h = handler.clone();
1376 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1377 Self::list_deleted_handler(h, query, access).await
1378 }
1379 }))
1380 .route(&format!("{}/:id", base_path), get({
1382 let h = handler.clone();
1383 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1384 Self::get_handler(h, path, query, access).await
1385 }
1386 }))
1387 .route(&format!("{}/:id/deleted", base_path), get({
1389 let h = handler.clone();
1390 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1391 Self::get_deleted_handler(h, path, query, access).await
1392 }
1393 }))
1394 .route(&format!("{}/:id/history", base_path), get({
1397 let h = handler.clone();
1398 move |path: axum::extract::Path<String>,
1399 query: axum::extract::Query<ListQueryParams>,
1400 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
1401 Self::history_handler(h, path, query, provider).await
1402 }
1403 }))
1404 .route(&format!("{}/aggregate", base_path), get({
1406 let h = handler.clone();
1407 move |query: axum::extract::Query<ListQueryParams>| async move {
1408 Self::aggregate_handler(h, query).await
1409 }
1410 }))
1411 .route(&format!("{}/count", base_path), get({
1412 let h = handler.clone();
1413 move |query: axum::extract::Query<ListQueryParams>| async move {
1414 Self::count_active_handler(h, query).await
1415 }
1416 }))
1417 .route(&format!("{}/trash/count", base_path), get({
1419 let h = handler.clone();
1420 move || async move {
1421 Self::count_deleted_handler(h).await
1422 }
1423 }))
1424 }
1425
1426 pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1443 where
1444 S: Clone,
1445 {
1446 if let Some(table) = service.table_name() {
1449 backbone_orm::secret_registry::register(table, E::secret_fields());
1450 }
1451 use axum::{
1452 extract::Path,
1453 routing::{delete, patch, post, put},
1454 Router,
1455 };
1456
1457 let handler = Arc::new(Self::new(service));
1458
1459 Router::new()
1460 .route(base_path, post({
1462 let h = handler.clone();
1463 move |body: JsonOrForm<C>| async move {
1464 Self::create_handler(h, body).await
1465 }
1466 }))
1467 .route(&format!("{}/bulk", base_path), post({
1469 let h = handler.clone();
1470 move |body: JsonOrForm<Vec<C>>| async move {
1471 Self::bulk_create_handler(h, body).await
1472 }
1473 }))
1474 .route(&format!("{}/upsert", base_path), post({
1476 let h = handler.clone();
1477 move |body: JsonOrForm<C>| async move {
1478 Self::upsert_handler(h, body).await
1479 }
1480 }))
1481 .route(&format!("{}/delete/bulk", base_path), post({
1483 let h = handler.clone();
1484 move |body: JsonOrForm<BatchIdsRequest>| async move {
1485 Self::bulk_delete_handler(h, body).await
1486 }
1487 }))
1488 .route(&format!("{}/restore/bulk", base_path), post({
1490 let h = handler.clone();
1491 move |body: JsonOrForm<BatchIdsRequest>| async move {
1492 Self::bulk_restore_handler(h, body).await
1493 }
1494 }))
1495 .route(&format!("{}/restore/all", base_path), post({
1497 let h = handler.clone();
1498 move || async move {
1499 Self::restore_all_handler(h).await
1500 }
1501 }))
1502 .route(&format!("{}/trash/bulk", base_path), delete({
1505 let h = handler.clone();
1506 move |body: JsonOrForm<BatchIdsRequest>| async move {
1507 Self::bulk_permanent_delete_handler(h, body).await
1508 }
1509 }))
1510 .route(&format!("{}/bulk", base_path), put({
1512 let h = handler.clone();
1513 move |body: JsonOrForm<Vec<BulkUpdateItem<U>>>| async move {
1514 Self::bulk_update_handler(h, body).await
1515 }
1516 }))
1517 .route(&format!("{}/bulk", base_path), patch({
1519 let h = handler.clone();
1520 move |body: JsonOrForm<BulkPatchRequest>| async move {
1521 Self::bulk_patch_handler(h, body).await
1522 }
1523 }))
1524 .route(&format!("{}/bulk/preview", base_path), post({
1526 let h = handler.clone();
1527 move |body: JsonOrForm<BulkPatchRequest>| async move {
1528 Self::bulk_patch_preview_handler(h, body).await
1529 }
1530 }))
1531 .route(&format!("{}/empty", base_path), delete({
1533 let h = handler.clone();
1534 move || async move {
1535 Self::empty_trash_handler(h).await
1536 }
1537 }))
1538 .route(&format!("{}/trash/:id", base_path), delete({
1540 let h = handler.clone();
1541 move |path: Path<String>| async move {
1542 Self::permanent_delete_handler(h, path).await
1543 }
1544 }))
1545 .route(&format!("{}/:id", base_path), put({
1547 let h = handler.clone();
1548 move |path: Path<String>, body: JsonOrForm<U>| async move {
1549 Self::update_handler(h, path, body).await
1550 }
1551 }))
1552 .route(&format!("{}/:id", base_path), patch({
1554 let h = handler.clone();
1555 move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
1556 Self::partial_update_handler(h, path, body).await
1557 }
1558 }))
1559 .route(&format!("{}/:id", base_path), delete({
1561 let h = handler.clone();
1562 move |path: Path<String>| async move {
1563 Self::delete_handler(h, path).await
1564 }
1565 }))
1566 .route(&format!("{}/:id/restore", base_path), post({
1568 let h = handler.clone();
1569 move |path: Path<String>| async move {
1570 Self::restore_handler(h, path).await
1571 }
1572 }))
1573 }
1574
1575 async fn list_handler(
1580 handler: Arc<Self>,
1581 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1582 access: Option<axum::Extension<AccessScope>>,
1583 ) -> impl axum::response::IntoResponse {
1584 use axum::{http::StatusCode, Json};
1585
1586 if let Some(err) = pagination_depth_error(params.page, params.limit) {
1587 return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
1588 }
1589 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1590 return (
1591 StatusCode::BAD_REQUEST,
1592 Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
1593 );
1594 }
1595
1596 let fields = sparse_fields(¶ms.filters);
1599 let includes = include_relations(¶ms.filters);
1600 let scope = access.map(|axum::Extension(s)| s);
1601
1602 let filters = repository_filters(¶ms);
1603
1604 match handler
1605 .service
1606 .list_with_info(params.page, params.limit, filters)
1607 .await
1608 {
1609 Ok((entities, info)) => {
1610 let mut rows: Vec<serde_json::Value> = entities
1613 .into_iter()
1614 .map(|e| {
1615 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1616 })
1617 .collect();
1618 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1619 let items: Vec<serde_json::Value> =
1620 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1621 let response = PaginatedApiResponse::ok_with_info(items, &info);
1622 (StatusCode::OK, Json(response))
1623 }
1624 Err(e) => {
1625 let msg = e.to_string();
1626 if is_bad_query_error(&msg) {
1627 (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
1628 format!("Invalid query parameter or filter: {msg}"),
1629 )))
1630 } else {
1631 (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
1632 }
1633 }
1634 }
1635 }
1636
1637 async fn create_handler(
1638 handler: Arc<Self>,
1639 JsonOrForm(dto): JsonOrForm<C>,
1640 ) -> impl axum::response::IntoResponse {
1641 use axum::{http::StatusCode, Json};
1642
1643 match handler.service.create(dto).await {
1644 Ok(entity) => {
1645 let response = secure::<E>(to_response_value(R::from(entity)), None);
1646 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1647 }
1648 Err(e) if S::violations_of(&e).is_some() => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1649 Err(e) => {
1650 let error_str = e.to_string();
1651 if error_str.contains("conflict") || error_str.contains("already exists") {
1652 (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1653 } else {
1654 (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(error_str)))
1655 }
1656 }
1657 }
1658 }
1659
1660 async fn get_handler(
1661 handler: Arc<Self>,
1662 axum::extract::Path(id): axum::extract::Path<String>,
1663 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1664 access: Option<axum::Extension<AccessScope>>,
1665 ) -> impl axum::response::IntoResponse {
1666 use axum::{http::StatusCode, Json};
1667
1668 let fields = sparse_fields(¶ms.filters);
1669 let includes = include_relations(¶ms.filters);
1670 let scope = access.map(|axum::Extension(s)| s);
1671
1672 match handler.service.get_by_id(&id).await {
1673 Ok(Some(entity)) => {
1674 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
1675 let mut rows = [secured];
1676 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1677 let [secured] = rows;
1678 let value = project_sparse(secured, &fields);
1679 (StatusCode::OK, Json(ApiResponse::ok(value)))
1680 }
1681 Ok(None) => {
1682 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1683 }
1684 Err(e) => {
1685 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
1686 }
1687 }
1688 }
1689
1690 async fn update_handler(
1691 handler: Arc<Self>,
1692 axum::extract::Path(id): axum::extract::Path<String>,
1693 JsonOrForm(dto): JsonOrForm<U>,
1694 ) -> impl axum::response::IntoResponse {
1695 use axum::{http::StatusCode, Json};
1696
1697 match handler.service.update(&id, dto).await {
1698 Ok(Some(entity)) => {
1699 let response = secure::<E>(to_response_value(R::from(entity)), None);
1700 (StatusCode::OK, Json(ApiResponse::ok(response)))
1701 }
1702 Ok(None) => {
1703 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1704 }
1705 Err(e) => {
1706 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1707 }
1708 }
1709 }
1710
1711 async fn partial_update_handler(
1712 handler: Arc<Self>,
1713 axum::extract::Path(id): axum::extract::Path<String>,
1714 JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
1715 ) -> impl axum::response::IntoResponse {
1716 use axum::{http::StatusCode, Json};
1717
1718 let fields: HashMap<String, serde_json::Value> = fields
1729 .into_iter()
1730 .map(|(k, v)| (camel_to_snake_case(&k), v))
1731 .collect();
1732
1733 match handler.service.partial_update(&id, fields).await {
1734 Ok(Some(entity)) => {
1735 let response = secure::<E>(to_response_value(R::from(entity)), None);
1736 (StatusCode::OK, Json(ApiResponse::ok(response)))
1737 }
1738 Ok(None) => {
1739 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1740 }
1741 Err(e) => {
1742 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1743 }
1744 }
1745 }
1746
1747 async fn delete_handler(
1748 handler: Arc<Self>,
1749 axum::extract::Path(id): axum::extract::Path<String>,
1750 ) -> impl axum::response::IntoResponse {
1751 use axum::{http::StatusCode, Json};
1752
1753 match handler.service.soft_delete(&id).await {
1754 Ok(true) => {
1755 (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
1758 }
1759 Ok(false) => {
1760 (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
1761 }
1762 Err(e) => {
1763 write_error(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR)
1764 }
1765 }
1766 }
1767
1768 async fn bulk_create_handler(
1769 handler: Arc<Self>,
1770 JsonOrForm(items): JsonOrForm<Vec<C>>,
1771 ) -> impl axum::response::IntoResponse {
1772 use axum::{http::StatusCode, Json};
1773
1774 match handler.service.bulk_create(items).await {
1775 Ok(entities) => {
1776 let result_items: Vec<serde_json::Value> = entities
1777 .into_iter()
1778 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1779 .collect();
1780 let total = result_items.len();
1781 let response = BulkResponse {
1782 items: result_items,
1783 total,
1784 failed: 0,
1785 errors: vec![],
1786 };
1787 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1788 }
1789 Err(e) => {
1790 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1791 }
1792 }
1793 }
1794
1795 async fn bulk_delete_handler(
1798 handler: Arc<Self>,
1799 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1800 ) -> impl axum::response::IntoResponse {
1801 use axum::{http::StatusCode, Json};
1802
1803 if let Some(err) = batch_size_error(req.ids.len()) {
1804 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1805 }
1806 match handler.service.bulk_soft_delete(req.ids).await {
1807 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1808 serde_json::json!({ "soft_deleted": count }),
1809 format!("Soft-deleted {count} item(s)"),
1810 ))),
1811 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1812 }
1813 }
1814
1815 async fn bulk_restore_handler(
1816 handler: Arc<Self>,
1817 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1818 ) -> impl axum::response::IntoResponse {
1819 use axum::{http::StatusCode, Json};
1820
1821 if let Some(err) = batch_size_error(req.ids.len()) {
1822 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1823 }
1824 match handler.service.bulk_restore(req.ids).await {
1825 Ok(entities) => {
1826 let items: Vec<serde_json::Value> = entities
1827 .into_iter()
1828 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1829 .collect();
1830 let total = items.len();
1831 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1832 }
1833 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1834 }
1835 }
1836
1837 async fn restore_all_handler(
1838 handler: Arc<Self>,
1839 ) -> impl axum::response::IntoResponse {
1840 use axum::{http::StatusCode, Json};
1841
1842 match handler.service.restore_all().await {
1843 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1844 serde_json::json!({ "restored": count }),
1845 format!("Restored {count} item(s) from trash"),
1846 ))),
1847 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
1848 }
1849 }
1850
1851 async fn bulk_permanent_delete_handler(
1852 handler: Arc<Self>,
1853 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
1854 ) -> impl axum::response::IntoResponse {
1855 use axum::{http::StatusCode, Json};
1856
1857 if let Some(err) = batch_size_error(req.ids.len()) {
1858 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
1859 }
1860 match handler.service.bulk_permanent_delete(req.ids).await {
1861 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
1862 serde_json::json!({ "permanently_deleted": count }),
1863 format!("Permanently deleted {count} item(s)"),
1864 ))),
1865 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1866 }
1867 }
1868
1869 async fn bulk_update_handler(
1870 handler: Arc<Self>,
1871 JsonOrForm(items): JsonOrForm<Vec<BulkUpdateItem<U>>>,
1872 ) -> impl axum::response::IntoResponse {
1873 use axum::{http::StatusCode, Json};
1874
1875 if let Some(err) = batch_size_error(items.len()) {
1876 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1877 }
1878 let items: Vec<(String, U)> = items.into_iter().map(|it| (it.id, it.data)).collect();
1879 match handler.service.bulk_update(items).await {
1880 Ok(entities) => {
1881 let items: Vec<serde_json::Value> = entities
1882 .into_iter()
1883 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1884 .collect();
1885 let total = items.len();
1886 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1887 }
1888 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1889 }
1890 }
1891
1892 async fn bulk_patch_handler(
1893 handler: Arc<Self>,
1894 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
1895 ) -> impl axum::response::IntoResponse {
1896 use axum::{http::StatusCode, Json};
1897
1898 let items = req.into_items();
1899 if let Some(err) = batch_size_error(items.len()) {
1900 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1901 }
1902 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
1904 .into_iter()
1905 .map(|(id, fields)| {
1906 let fields = fields
1907 .into_iter()
1908 .map(|(k, v)| (camel_to_snake_case(&k), v))
1909 .collect();
1910 (id, fields)
1911 })
1912 .collect();
1913 match handler.service.bulk_partial_update(items).await {
1914 Ok(entities) => {
1915 let items: Vec<serde_json::Value> = entities
1916 .into_iter()
1917 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1918 .collect();
1919 let total = items.len();
1920 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
1921 }
1922 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1923 }
1924 }
1925
1926 async fn bulk_patch_preview_handler(
1932 handler: Arc<Self>,
1933 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
1934 ) -> impl axum::response::IntoResponse {
1935 use axum::{http::StatusCode, Json};
1936
1937 let items = req.into_items();
1938 if let Some(err) = batch_size_error(items.len()) {
1939 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkPreview>::error(err)));
1940 }
1941 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
1942 .into_iter()
1943 .map(|(id, fields)| {
1944 let fields = fields
1945 .into_iter()
1946 .map(|(k, v)| (camel_to_snake_case(&k), v))
1947 .collect();
1948 (id, fields)
1949 })
1950 .collect();
1951 let patched: HashMap<String, Vec<String>> = items
1952 .iter()
1953 .map(|(id, f)| (id.clone(), f.keys().cloned().collect()))
1954 .collect();
1955 match handler.service.preview_bulk_partial_update(items).await {
1956 Ok(Some(rows)) => {
1957 let rows = rows
1958 .into_iter()
1959 .map(|row| {
1960 let outcome = match row.outcome {
1961 Ok((before, after)) => Ok((
1962 secure::<E>(to_response_value(R::from(before)), None),
1963 secure::<E>(to_response_value(R::from(after)), None),
1964 )),
1965 Err(e) => Err((
1966 S::violations_of(&e)
1967 .and_then(|v| v.into_iter().next().map(|v| v.code))
1968 .unwrap_or_else(|| "refused".to_string()),
1969 e.to_string(),
1970 )),
1971 };
1972 (row.id, outcome)
1973 })
1974 .collect();
1975 (StatusCode::OK, Json(ApiResponse::ok(preview_answer(rows, &patched))))
1976 }
1977 Ok(None) => (
1978 StatusCode::NOT_IMPLEMENTED,
1979 Json(ApiResponse::error("this resource has no preview of a bulk change".to_string())),
1980 ),
1981 Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
1982 }
1983 }
1984
1985 async fn upsert_handler(
1986 handler: Arc<Self>,
1987 JsonOrForm(dto): JsonOrForm<C>,
1988 ) -> impl axum::response::IntoResponse {
1989 use axum::{http::StatusCode, Json};
1990
1991 match handler.service.upsert(dto).await {
1992 Ok(entity) => {
1993 let response = secure::<E>(to_response_value(R::from(entity)), None);
1994 (StatusCode::OK, Json(ApiResponse::ok(response)))
1995 }
1996 Err(e) => {
1997 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
1998 }
1999 }
2000 }
2001
2002 async fn list_deleted_handler(
2003 handler: Arc<Self>,
2004 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2005 access: Option<axum::Extension<AccessScope>>,
2006 ) -> impl axum::response::IntoResponse {
2007 use axum::{http::StatusCode, Json};
2008
2009 if let Some(err) = pagination_depth_error(params.page, params.limit) {
2010 return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
2011 }
2012 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2013 return (
2014 StatusCode::BAD_REQUEST,
2015 Json(PaginatedApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2016 );
2017 }
2018
2019 let fields = sparse_fields(¶ms.filters);
2020 let includes = include_relations(¶ms.filters);
2021 let scope = access.map(|axum::Extension(s)| s);
2022
2023 match handler.service.list_deleted(params.page, params.limit).await {
2024 Ok((entities, total)) => {
2025 let mut rows: Vec<serde_json::Value> = entities
2029 .into_iter()
2030 .map(|e| {
2031 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
2032 })
2033 .collect();
2034 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
2035 let items: Vec<serde_json::Value> =
2036 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2037 let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
2038 (StatusCode::OK, Json(response))
2039 }
2040 Err(e) => {
2041 let msg = e.to_string();
2042 if is_bad_query_error(&msg) {
2043 (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
2044 format!("Invalid query parameter or filter: {msg}"),
2045 )))
2046 } else {
2047 (StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
2048 }
2049 }
2050 }
2051 }
2052
2053 async fn restore_handler(
2054 handler: Arc<Self>,
2055 axum::extract::Path(id): axum::extract::Path<String>,
2056 ) -> impl axum::response::IntoResponse {
2057 use axum::{http::StatusCode, Json};
2058
2059 match handler.service.restore(&id).await {
2060 Ok(Some(entity)) => {
2061 let response = secure::<E>(to_response_value(R::from(entity)), None);
2062 (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
2063 }
2064 Ok(None) => {
2065 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2066 }
2067 Err(e) => {
2068 write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
2069 }
2070 }
2071 }
2072
2073 async fn empty_trash_handler(
2074 handler: Arc<Self>,
2075 ) -> impl axum::response::IntoResponse {
2076 use axum::{http::StatusCode, Json};
2077
2078 match handler.service.empty_trash().await {
2079 Ok(count) => {
2080 (StatusCode::OK, Json(ApiResponse::success_with_message(
2081 serde_json::json!({ "deleted_count": count }),
2082 format!("Successfully deleted {} items from trash", count)
2083 )))
2084 }
2085 Err(e) => {
2086 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2087 }
2088 }
2089 }
2090
2091 async fn get_deleted_handler(
2093 handler: Arc<Self>,
2094 axum::extract::Path(id): axum::extract::Path<String>,
2095 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2096 access: Option<axum::Extension<AccessScope>>,
2097 ) -> impl axum::response::IntoResponse {
2098 use axum::{http::StatusCode, Json};
2099
2100 let fields = sparse_fields(¶ms.filters);
2101 let scope = access.map(|axum::Extension(s)| s);
2102
2103 match handler.service.get_deleted_by_id(&id).await {
2104 Ok(Some(entity)) => {
2105 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
2106 let value = project_sparse(secured, &fields);
2107 (StatusCode::OK, Json(ApiResponse::ok(value)))
2108 }
2109 Ok(None) => {
2110 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
2111 }
2112 Err(e) => {
2113 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2114 }
2115 }
2116 }
2117
2118 async fn permanent_delete_handler(
2120 handler: Arc<Self>,
2121 axum::extract::Path(id): axum::extract::Path<String>,
2122 ) -> axum::response::Response {
2123 use axum::{http::StatusCode, Json, response::IntoResponse};
2124
2125 match handler.service.get_deleted_by_id(&id).await {
2127 Ok(Some(_)) => {
2128 match handler.service.permanent_delete(&id).await {
2130 Ok(true) => {
2131 StatusCode::NO_CONTENT.into_response()
2133 }
2134 Ok(false) => {
2135 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::error(
2136 format!("Failed to permanently delete {}", S::entity_name())
2137 ))).into_response()
2138 }
2139 Err(e) => {
2140 write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2141 }
2142 }
2143 }
2144 Ok(None) => {
2145 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
2146 &format!("{} in trash", S::entity_name()), &id
2147 ))).into_response()
2148 }
2149 Err(e) => {
2150 write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2151 }
2152 }
2153 }
2154
2155 async fn count_active_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()) {
2166 return (
2167 StatusCode::BAD_REQUEST,
2168 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2169 );
2170 }
2171 match handler.service.count_active_filtered(repository_filters(¶ms)).await {
2172 Ok(count) => {
2173 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2174 }
2175 Err(e) => {
2176 let msg = e.to_string();
2179 let code = if is_bad_query_error(&msg) {
2180 StatusCode::BAD_REQUEST
2181 } else {
2182 StatusCode::INTERNAL_SERVER_ERROR
2183 };
2184 (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
2185 }
2186 }
2187 }
2188
2189 async fn aggregate_handler(
2194 handler: Arc<Self>,
2195 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2196 ) -> impl axum::response::IntoResponse {
2197 use axum::{http::StatusCode, Json};
2198
2199 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2200 return (
2201 StatusCode::BAD_REQUEST,
2202 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2203 );
2204 }
2205 let spec = aggregate_spec(¶ms);
2206 match handler.service.aggregate(&spec, aggregate_filters(¶ms)).await {
2207 Ok(result) => {
2208 let render = |g: &backbone_orm::repository::AggregateGroup| {
2209 let mut out = serde_json::Map::new();
2213 out.insert("key".into(), match &g.key {
2214 Some(k) => serde_json::Value::String(k.clone()),
2215 None => serde_json::Value::Null,
2216 });
2217 if g.label.is_some() {
2218 out.insert(
2219 "label".into(),
2220 serde_json::Value::String(g.label.clone().unwrap()),
2221 );
2222 }
2223 out.insert("count".into(), serde_json::json!(g.count));
2224 for (compound, value) in &g.values {
2225 let Some((func, field)) = compound.split_once(':') else { continue };
2226 let slot = out
2227 .entry(func.to_string())
2228 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2229 if let Some(obj) = slot.as_object_mut() {
2230 obj.insert(field.to_string(), match value {
2231 Some(v) => serde_json::Value::String(v.clone()),
2235 None => serde_json::Value::Null,
2236 });
2237 }
2238 }
2239 serde_json::Value::Object(out)
2240 };
2241
2242 let body = serde_json::json!({
2243 "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
2244 "total": render(&result.total),
2245 "truncated": result.truncated,
2248 });
2249 (StatusCode::OK, Json(ApiResponse::ok(body)))
2250 }
2251 Err(e) => {
2252 let msg = e.to_string();
2253 let code = if is_bad_query_error(&msg) {
2254 StatusCode::BAD_REQUEST
2255 } else {
2256 StatusCode::INTERNAL_SERVER_ERROR
2257 };
2258 (code, Json(ApiResponse::<serde_json::Value>::error(msg)))
2259 }
2260 }
2261 }
2262
2263 async fn history_handler(
2274 handler: Arc<Self>,
2275 axum::extract::Path(id): axum::extract::Path<String>,
2276 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2277 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2278 ) -> impl axum::response::IntoResponse {
2279 use axum::{http::StatusCode, Json};
2280
2281 let Some(axum::Extension(provider)) = provider else {
2282 return (
2283 StatusCode::NOT_IMPLEMENTED,
2284 Json(ApiResponse::<serde_json::Value>::error(
2285 "history is not configured for this service".to_string(),
2286 )),
2287 );
2288 };
2289
2290 let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
2294 return (
2295 StatusCode::NOT_IMPLEMENTED,
2296 Json(ApiResponse::<serde_json::Value>::error(
2297 "this entity cannot name its table, so its history cannot be keyed".to_string(),
2298 )),
2299 );
2300 };
2301
2302 let limit = params.limit.clamp(1, 200);
2303 let offset = params.page.saturating_sub(1) * limit;
2304
2305 match provider.history(&table, &id, limit, offset).await {
2306 Ok(Some(entries)) => (
2307 StatusCode::OK,
2308 Json(ApiResponse::ok(serde_json::json!({
2309 "audited": true,
2310 "entries": entries,
2311 }))),
2312 ),
2313 Ok(None) => (
2314 StatusCode::OK,
2315 Json(ApiResponse::ok(serde_json::json!({
2316 "audited": false,
2319 "entries": serde_json::Value::Null,
2320 }))),
2321 ),
2322 Err(e) => (
2323 StatusCode::INTERNAL_SERVER_ERROR,
2324 Json(ApiResponse::<serde_json::Value>::error(e)),
2325 ),
2326 }
2327 }
2328
2329 async fn count_deleted_handler(
2331 handler: Arc<Self>,
2332 ) -> impl axum::response::IntoResponse {
2333 use axum::{http::StatusCode, Json};
2334
2335 match handler.service.count_deleted().await {
2336 Ok(count) => {
2337 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2338 }
2339 Err(e) => {
2340 (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2341 }
2342 }
2343 }
2344}
2345
2346pub trait BackboneHttpHandler<T>: Send + Sync {
2371 fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2373
2374 fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
2376
2377 fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2379
2380 fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
2382
2383 fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
2385
2386 fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
2388
2389 fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
2391
2392 fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
2394
2395 fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2397
2398 fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2400
2401 fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
2403
2404 fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2406}
2407
2408#[derive(Debug, Serialize, Deserialize)]
2414#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
2415pub struct PaginationRequest {
2416 pub page: u32,
2417 pub limit: u32,
2418 pub sort_by: Option<String>,
2419 pub sort_order: Option<String>,
2420}
2421
2422fn camel_to_snake_case(key: &str) -> String {
2438 let chars: Vec<char> = key.chars().collect();
2439 let mut result = String::with_capacity(key.len() + 2);
2440 for (i, &c) in chars.iter().enumerate() {
2441 if c.is_ascii_uppercase() {
2442 let prev = if i > 0 { chars[i - 1] } else { '\0' };
2443 let next = chars.get(i + 1).copied().unwrap_or('\0');
2444 let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
2448 let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
2449 if (crosses_lower || crosses_acronym)
2450 && !result.is_empty()
2451 && !result.ends_with('_')
2452 {
2453 result.push('_');
2454 }
2455 result.push(c.to_ascii_lowercase());
2456 } else {
2457 result.push(c);
2458 }
2459 }
2460 result
2461}
2462
2463#[cfg(test)]
2464mod tests {
2465 use super::*;
2466
2467 #[test]
2468 fn snake_case_input_passes_through_unchanged() {
2469 assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
2470 assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
2471 assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
2472 }
2473
2474 #[test]
2475 fn single_word_unchanged() {
2476 assert_eq!(camel_to_snake_case("name"), "name");
2477 assert_eq!(camel_to_snake_case("id"), "id");
2478 assert_eq!(camel_to_snake_case(""), "");
2479 }
2480
2481 #[test]
2482 fn camel_case_converts() {
2483 assert_eq!(camel_to_snake_case("isVip"), "is_vip");
2484 assert_eq!(camel_to_snake_case("userId"), "user_id");
2485 assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
2486 assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
2487 }
2488
2489 #[test]
2490 fn pascal_case_converts() {
2491 assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
2492 assert_eq!(camel_to_snake_case("UserId"), "user_id");
2493 }
2494
2495 #[test]
2496 fn acronym_runs_stay_together() {
2497 assert_eq!(camel_to_snake_case("IOError"), "io_error");
2500 assert_eq!(camel_to_snake_case("httpURL"), "http_url");
2501 assert_eq!(camel_to_snake_case("ABC"), "abc");
2502 assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
2503 }
2504
2505 #[test]
2506 fn digits_count_as_lowercase_for_boundary() {
2507 assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
2508 assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
2509 }
2510
2511 #[test]
2512 fn underscores_not_doubled() {
2513 assert_eq!(camel_to_snake_case("_Foo"), "_foo");
2514 assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
2515 }
2516
2517 #[test]
2518 fn shallow_pages_are_allowed() {
2519 assert!(pagination_depth_error(1, 100).is_none());
2521 assert!(pagination_depth_error(101, 100).is_none());
2523 }
2524
2525 #[test]
2526 fn pages_past_the_cap_are_rejected() {
2527 assert!(pagination_depth_error(102, 100).is_some());
2529 assert!(pagination_depth_error(1002, 10).is_some());
2531 }
2532
2533 #[test]
2534 fn oversized_page_size_is_clamped_before_the_check() {
2535 assert!(pagination_depth_error(102, 200).is_some());
2537 assert!(pagination_depth_error(101, 200).is_none());
2539 }
2540
2541 #[test]
2542 fn huge_page_number_does_not_overflow() {
2543 assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
2545 }
2546
2547 #[test]
2550 fn batch_size_within_limit_is_allowed() {
2551 assert!(batch_size_error(0).is_none());
2552 assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
2553 }
2554
2555 #[test]
2556 fn batch_size_over_limit_is_rejected() {
2557 assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
2558 }
2559
2560 #[test]
2561 fn a_bulk_preview_sorts_rows_by_what_the_patch_would_do() {
2562 use serde_json::json;
2563 let mut patched: HashMap<String, Vec<String>> = ["a", "b", "c"]
2564 .iter()
2565 .map(|id| (id.to_string(), vec!["category".to_string()]))
2566 .collect();
2567 patched.insert("d".into(), vec!["category_id".into()]);
2568 let out = preview_answer(
2569 vec![
2570 (
2571 "a".into(),
2572 Ok((
2573 json!({"category": "meal", "updated_at": "1"}),
2574 json!({"category": "travel", "updated_at": "2"}),
2575 )),
2576 ),
2577 ("b".into(), Ok((json!({"category": "travel"}), json!({"category": "travel"})))),
2578 (
2579 "d".into(),
2580 Ok((json!({"categoryId": "x"}), json!({"categoryId": "y"}))),
2581 ),
2582 ("c".into(), Err(("locked".into(), "a posted claim is locked".into()))),
2583 ],
2584 &patched,
2585 );
2586 assert_eq!(out.total, 4);
2587 assert_eq!(out.will_change.len(), 2);
2588 assert_eq!(out.will_change[1].changes[0].field, "category_id", "a camelCase response is read");
2589 let ch = &out.will_change[0].changes;
2590 assert_eq!(ch.len(), 1, "only the patched field is reported, not the timestamp");
2591 assert_eq!((ch[0].from.clone(), ch[0].to.clone()), (json!("meal"), json!("travel")));
2592 assert_eq!(out.unchanged, vec!["b".to_string()]);
2593 assert_eq!(out.refused[0].code, "locked");
2594 }
2595
2596 #[test]
2597 fn bulk_patch_request_parses_shared_shape() {
2598 let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
2599 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2600 let items = req.into_items();
2601 assert_eq!(items.len(), 2);
2602 assert_eq!(items[0].1.get("status").unwrap(), "void");
2604 assert_eq!(items[1].1.get("status").unwrap(), "void");
2605 assert_eq!(items[0].0, "a");
2606 assert_eq!(items[1].0, "b");
2607 }
2608
2609 #[test]
2610 fn bulk_patch_request_parses_per_item_shape() {
2611 let json = r#"{ "items": [
2612 { "id": "a", "patch": { "status": "void" } },
2613 { "id": "b", "patch": { "note": "late" } }
2614 ] }"#;
2615 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2616 let items = req.into_items();
2617 assert_eq!(items.len(), 2);
2618 assert_eq!(items[0].0, "a");
2619 assert_eq!(items[0].1.get("status").unwrap(), "void");
2620 assert_eq!(items[1].0, "b");
2621 assert_eq!(items[1].1.get("note").unwrap(), "late");
2622 }
2623
2624 #[test]
2625 fn batch_ids_request_parses() {
2626 let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
2627 assert_eq!(req.ids, vec!["x", "y", "z"]);
2628 }
2629
2630 fn fields(q: &[(&str, &str)]) -> Vec<String> {
2633 let map: HashMap<String, String> =
2634 q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
2635 sparse_fields(&map)
2636 }
2637
2638 #[test]
2639 fn sparse_fields_parses_comma_list_and_trims() {
2640 assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
2641 }
2642
2643 #[test]
2644 fn sparse_fields_absent_or_empty_is_no_projection() {
2645 assert!(fields(&[]).is_empty());
2646 assert!(fields(&[("fields", "")]).is_empty());
2647 assert!(fields(&[("fields", " , ")]).is_empty());
2648 }
2649
2650 #[test]
2651 fn project_keeps_requested_keys_plus_id() {
2652 let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
2653 let out = project_sparse(v, &["name".into(), "basePrice".into()]);
2654 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
2655 }
2656
2657 #[test]
2658 fn project_always_includes_id_even_if_not_requested() {
2659 let v = serde_json::json!({ "id": "1", "name": "n" });
2660 let out = project_sparse(v, &["name".into()]);
2661 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2662 }
2663
2664 #[test]
2665 fn project_ignores_unknown_keys() {
2666 let v = serde_json::json!({ "id": "1", "name": "n" });
2667 let out = project_sparse(v, &["name".into(), "nope".into()]);
2668 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2669 }
2670
2671 #[test]
2672 fn project_empty_fields_returns_full_object() {
2673 let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
2674 let out = project_sparse(v.clone(), &[]);
2675 assert_eq!(out, v);
2676 }
2677
2678 #[test]
2679 fn project_non_object_returned_unchanged() {
2680 let v = serde_json::json!("scalar");
2681 assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
2682 }
2683
2684 fn owner_a() -> uuid::Uuid {
2689 uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
2690 }
2691 fn owner_b() -> uuid::Uuid {
2692 uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
2693 }
2694
2695 fn row() -> serde_json::Value {
2696 serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
2697 }
2698 const PRIV: &[&str] = &["hppPerUnit"];
2699
2700 struct Secretive;
2702 impl backbone_orm::EntityRepoMeta for Secretive {
2703 fn column_types() -> HashMap<String, String> {
2704 HashMap::new()
2705 }
2706 fn search_fields() -> &'static [&'static str] {
2707 &[]
2708 }
2709 fn secret_fields() -> &'static [&'static str] {
2710 &["tokenHash"]
2711 }
2712 fn private_fields() -> &'static [&'static str] {
2713 PRIV
2714 }
2715 fn owner_field() -> Option<&'static str> {
2716 Some("providerId")
2717 }
2718 fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
2719 match relation {
2720 "user" => &["passwordHash"],
2721 _ => &[],
2722 }
2723 }
2724 }
2725
2726 fn secret_row() -> serde_json::Value {
2727 let mut r = row();
2728 r["tokenHash"] = serde_json::json!("digest");
2729 r
2730 }
2731
2732 #[test]
2733 fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
2734 for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
2735 let out = secure::<Secretive>(secret_row(), scope.as_ref());
2736 assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
2737 assert!(out.get("name").is_some());
2738 }
2739 let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform));
2740 assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
2741 }
2742
2743 #[test]
2744 fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
2745 let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
2746 let params = ListQueryParams {
2747 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
2748 sort_by: sort_by.map(str::to_string),
2749 ..Default::default()
2750 };
2751 named_secret(¶ms, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
2752 };
2753 for pairs in [
2754 vec![("token_hash", "abc")],
2755 vec![("tokenHash[startwith]", "a")],
2756 vec![("orderby", "name,-token_hash")],
2757 vec![("orderby[token_hash]", "asc")],
2758 vec![("searchFields", "name,tokenHash")],
2759 vec![("min", "token_hash")],
2760 vec![("group_by", "tokenHash")],
2761 ] {
2762 assert_eq!(ask(&pairs, None).as_deref().map(camel_to_snake_case), Some("token_hash".into()), "{pairs:?}");
2763 }
2764 assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
2765 assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
2766 }
2767
2768 #[test]
2769 fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
2770 let out = secure::<Secretive>(secret_row(), None);
2771 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
2772 }
2773
2774 #[test]
2775 fn a_routed_response_dto_loses_the_entitys_secrets() {
2776 let out = without_secrets::<Secretive, _>(secret_row());
2777 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
2778 assert!(out.get("name").is_some());
2779 }
2780
2781 #[test]
2782 fn an_included_row_loses_the_related_models_secrets() {
2783 let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
2784 let out = related_row::<Secretive>("user", "users", raw.clone());
2785 assert!(out.get("passwordHash").is_none(), "{out}");
2786 assert_eq!(out["email"], "a@b.c");
2787 assert!(related_row::<Secretive>("other", "others", raw.clone()).get("passwordHash").is_some(), "only the named relation");
2788 backbone_orm::secret_registry::register("employee_probe.staff", &["passwordHash"]);
2790 assert!(related_row::<Secretive>("staff", "employee_probe.staff", raw).get("passwordHash").is_none(), "registered table");
2791 }
2792
2793 #[test]
2794 fn security_no_private_fields_is_noop() {
2795 let v = row();
2796 assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
2797 }
2798
2799 #[test]
2800 fn security_platform_sees_private() {
2801 let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
2802 assert!(out.get("hppPerUnit").is_some());
2803 }
2804
2805 #[test]
2806 fn security_owner_tenant_sees_private() {
2807 let out = apply_field_security(
2808 row(),
2809 Some(&AccessScope::Company(owner_a())),
2810 PRIV,
2811 Some("providerId"),
2812 );
2813 assert!(out.get("hppPerUnit").is_some());
2814 }
2815
2816 #[test]
2817 fn security_other_tenant_stripped() {
2818 let out = apply_field_security(
2819 row(),
2820 Some(&AccessScope::Company(owner_b())),
2821 PRIV,
2822 Some("providerId"),
2823 );
2824 assert!(out.get("hppPerUnit").is_none());
2825 assert!(out.get("name").is_some());
2826 }
2827
2828 #[test]
2829 fn security_absent_scope_fails_closed() {
2830 let out = apply_field_security(row(), None, PRIV, Some("providerId"));
2831 assert!(out.get("hppPerUnit").is_none());
2832 }
2833
2834 #[test]
2837 fn include_relations_parses_include_and_with() {
2838 let mut q = HashMap::new();
2839 q.insert("include".to_string(), "provider, outlet ".to_string());
2840 assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
2841 let mut q2 = HashMap::new();
2842 q2.insert("with".to_string(), "category".to_string());
2843 assert_eq!(include_relations(&q2), vec!["category"]);
2844 assert!(include_relations(&HashMap::new()).is_empty());
2845 }
2846
2847 #[test]
2848 fn snake_to_camel_converts() {
2849 assert_eq!(snake_to_camel("provider_id"), "providerId");
2850 assert_eq!(snake_to_camel("business_name"), "businessName");
2851 assert_eq!(snake_to_camel("id"), "id");
2852 }
2853
2854 #[test]
2855 fn camelize_keys_top_level_only() {
2856 let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
2857 let out = camelize_keys(v);
2858 assert!(out.get("providerId").is_some());
2859 assert!(out.get("metaData").unwrap().get("created_at").is_some());
2861 }
2862
2863 #[test]
2864 fn security_null_owner_only_platform_sees() {
2865 let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
2866 let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
2867 assert!(tenant.get("hppPerUnit").is_none());
2868 let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
2869 assert!(plat.get("hppPerUnit").is_some());
2870 }
2871
2872 #[test]
2873 fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
2874 let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
2875 let (status, axum::Json(body)) =
2876 write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
2877 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
2878 let json = serde_json::to_value(&body).unwrap();
2879 assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
2880 assert_eq!(json["violations"][0]["path"], "status");
2881 assert_eq!(json["violations"][0]["code"], "field_not_writable");
2882
2883 let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
2885 assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
2886 assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
2887 }
2888}
2889
2890#[cfg(test)]
2891mod internal_key_tests {
2892 use super::*;
2893
2894 fn params_with(filters: &[(&str, &str)]) -> ListQueryParams {
2895 ListQueryParams {
2896 page: 1,
2897 limit: 20,
2898 sort_by: None,
2899 sort_order: None,
2900 search: None,
2901 status: None,
2902 filters: filters
2903 .iter()
2904 .map(|(k, v)| (k.to_string(), v.to_string()))
2905 .collect(),
2906 }
2907 }
2908
2909 #[test]
2910 fn internal_keys_never_reach_the_repository() {
2911 let params = params_with(&[
2912 ("__base_condition", "EXISTS(SELECT 1)"),
2913 ("__base_condition[eq]", "1=1"),
2914 ("__anything", "x"),
2915 ("name", "acme"),
2916 ]);
2917 let filters = repository_filters(¶ms);
2918 assert_eq!(filters.len(), 1, "{filters:?}");
2919 assert_eq!(filters.get("name").map(String::as_str), Some("acme"));
2920 let filters = aggregate_filters(¶ms);
2921 assert!(filters.keys().all(|k| !k.starts_with("__")), "{filters:?}");
2922 }
2923}
2924
2925#[cfg(test)]
2926mod service_error_response_tests {
2927 use super::*;
2928 use crate::service::ServiceError;
2929
2930 #[test]
2931 fn a_refused_field_is_422_with_its_violation_never_500() {
2932 let err = ServiceError::Violations(vec![crate::violation::Violation::new(
2933 "state",
2934 "field_not_writable",
2935 "state cannot be changed by a generic write",
2936 )]);
2937 let (status, body) = service_error_response::<()>(&err);
2938 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
2939 assert_eq!(body.0.violations.as_ref().map(Vec::len), Some(1));
2940 }
2941
2942 #[test]
2943 fn other_errors_keep_their_own_status() {
2944 use axum::http::StatusCode;
2945 let status = |e: ServiceError| service_error_response::<()>(&e).0;
2946 assert_eq!(
2947 status(ServiceError::Validation("bad".into())),
2948 StatusCode::BAD_REQUEST
2949 );
2950 assert_eq!(status(ServiceError::NotFound), StatusCode::NOT_FOUND);
2951 assert_eq!(
2952 status(ServiceError::AlreadyExists("x".into())),
2953 StatusCode::CONFLICT
2954 );
2955 assert_eq!(
2956 status(ServiceError::Internal("boom".into())),
2957 StatusCode::INTERNAL_SERVER_ERROR
2958 );
2959 }
2960}