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