1use crate::extractors::{JsonOrForm, StrictJsonOrForm};
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 {
94 tracing::debug!(target: "backbone::http", entity, id, "record not found");
95 Self::rejected(NOT_FOUND_MESSAGE, vec![not_found_violation()])
96 }
97}
98
99pub const NOT_FOUND_MESSAGE: &str = "Not found";
101
102fn not_found_violation() -> crate::violation::Violation {
103 crate::violation::Violation::new("", "not_found", NOT_FOUND_MESSAGE)
104}
105
106fn status_code_violation(status: axum::http::StatusCode, message: &str) -> Option<crate::violation::Violation> {
109 use axum::http::StatusCode;
110 let code = match status {
111 StatusCode::NOT_FOUND => return Some(not_found_violation()),
112 StatusCode::CONFLICT => "conflict",
113 StatusCode::BAD_REQUEST => "invalid_request",
114 StatusCode::UNPROCESSABLE_ENTITY => "invalid_value",
115 _ => return None,
116 };
117 Some(crate::violation::Violation::new("", code, message))
118}
119
120pub const INTERNAL_ERROR_MESSAGE: &str = "An internal error occurred. It has been logged.";
123
124fn error_at<T>(
128 status: axum::http::StatusCode,
129 violations: Option<Vec<crate::violation::Violation>>,
130 err: &impl std::fmt::Display,
131) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
132 if status.is_server_error() {
133 tracing::error!(target: "backbone::http", status = status.as_u16(), error = %err, "request failed");
134 return (status, axum::Json(ApiResponse::error(INTERNAL_ERROR_MESSAGE)));
135 }
136 match violations {
137 Some(v) if !v.is_empty() => (status, axum::Json(ApiResponse::rejected(err.to_string(), v))),
138 _ if status == axum::http::StatusCode::NOT_FOUND => {
139 tracing::debug!(target: "backbone::http", error = %err, "not found");
140 (status, axum::Json(ApiResponse::rejected(NOT_FOUND_MESSAGE, vec![not_found_violation()])))
141 }
142 _ => {
143 let message = err.to_string();
144 match status_code_violation(status, &message) {
145 Some(v) => (status, axum::Json(ApiResponse::rejected(message, vec![v]))),
146 None => (status, axum::Json(ApiResponse::error(message))),
147 }
148 }
149 }
150}
151
152pub fn service_error_status(err: &crate::service::ServiceError) -> axum::http::StatusCode {
158 use crate::persistence::traits::RepositoryError;
159 use crate::service::ServiceError;
160 use axum::http::StatusCode;
161 match err {
162 ServiceError::Violations(_) | ServiceError::Repository(RepositoryError::Violations(_)) => {
163 StatusCode::UNPROCESSABLE_ENTITY
164 }
165 ServiceError::Validation(_)
166 | ServiceError::Repository(RepositoryError::ValidationError(_)) => StatusCode::BAD_REQUEST,
167 ServiceError::NotFound | ServiceError::Repository(RepositoryError::NotFound) => {
168 StatusCode::NOT_FOUND
169 }
170 ServiceError::AlreadyExists(_)
171 | ServiceError::Repository(RepositoryError::AlreadyExists(_))
172 | ServiceError::Repository(RepositoryError::Conflict(_)) => StatusCode::CONFLICT,
173 _ => StatusCode::INTERNAL_SERVER_ERROR,
174 }
175}
176
177pub fn service_error_response<T>(
182 err: &crate::service::ServiceError,
183) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
184 use crate::persistence::traits::RepositoryError;
185 use crate::service::ServiceError;
186 let violations = match err {
187 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => {
188 Some(v.clone())
189 }
190 _ => None,
191 };
192 error_at(service_error_status(err), violations, err)
193}
194
195#[cfg(test)]
198fn write_error<T>(
199 violations: Option<Vec<crate::violation::Violation>>,
200 err: &impl std::fmt::Display,
201 fallback: axum::http::StatusCode,
202) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
203 let status = if violations.is_some() { axum::http::StatusCode::UNPROCESSABLE_ENTITY } else { fallback };
204 error_at(status, violations, err)
205}
206
207fn invalid_id<T>(id: &str, path: &str) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
211 if uuid::Uuid::parse_str(id).is_ok() {
212 return None;
213 }
214 let violation = crate::violation::Violation::new(
215 path,
216 "invalid_id",
217 format!("`{id}` is not a valid record id"),
218 );
219 Some((
220 axum::http::StatusCode::BAD_REQUEST,
221 axum::Json(ApiResponse::rejected(format!("invalid id: `{id}` is not a valid record id"), vec![violation])),
222 ))
223}
224
225fn invalid_ids<'a, T>(
228 ids: impl IntoIterator<Item = &'a str>,
229 path_of: impl Fn(usize) -> String,
230) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
231 let violations: Vec<crate::violation::Violation> = ids
232 .into_iter()
233 .enumerate()
234 .filter(|(_, id)| uuid::Uuid::parse_str(id).is_err())
235 .map(|(i, id)| {
236 crate::violation::Violation::new(path_of(i), "invalid_id", format!("`{id}` is not a valid record id"))
237 })
238 .collect();
239 if violations.is_empty() {
240 return None;
241 }
242 Some((
243 axum::http::StatusCode::BAD_REQUEST,
244 axum::Json(ApiResponse::rejected("invalid id in the batch", violations)),
245 ))
246}
247
248#[derive(Debug, Deserialize, Default, Clone)]
250#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
251pub struct ListQueryParams {
252 #[serde(default = "default_page")]
253 pub page: u32,
254 #[serde(default = "default_limit")]
255 pub limit: u32,
256 #[serde(default)]
257 pub sort_by: Option<String>,
258 #[serde(default)]
259 pub sort_order: Option<String>,
260 #[serde(default)]
261 pub search: Option<String>,
262 #[serde(default)]
263 pub status: Option<String>,
264 #[serde(flatten)]
265 pub filters: HashMap<String, String>,
266}
267
268fn default_page() -> u32 { 1 }
269fn default_limit() -> u32 { 20 }
270
271impl ListQueryParams {
272 pub fn normalized(mut self) -> Self {
276 let page = backbone_orm::repository::PaginationParams::new(self.page, self.limit);
277 self.page = page.page;
278 self.limit = page.per_page;
279 self
280 }
281}
282
283const RESERVED_QUERY_KEYS: [&str; 3] = ["fields", "include", "with"];
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct HistoryEntry {
301 pub occurred_at: String,
302 pub action: String,
304 pub actor: String,
305 pub changed: serde_json::Value,
306 pub reason: Option<String>,
307 pub correlation_id: Option<String>,
308}
309
310#[async_trait::async_trait]
317pub trait HistoryProvider: Send + Sync {
318 async fn history(
326 &self,
327 table: &str,
328 id: &str,
329 limit: u32,
330 offset: u32,
331 ) -> Result<Option<Vec<HistoryEntry>>, String>;
332}
333
334const AGGREGATE_QUERY_KEYS: [&str; 7] =
340 ["group_by", "sum", "avg", "min", "max", "group_limit", "group_label"];
341
342fn aggregate_spec(params: &ListQueryParams) -> backbone_orm::repository::AggregateSpec {
348 use backbone_orm::repository::{AggregateFn, AggregateSpec};
349
350 let mut reductions = Vec::new();
351 for (key, func) in [
352 ("sum", AggregateFn::Sum),
353 ("avg", AggregateFn::Avg),
354 ("min", AggregateFn::Min),
355 ("max", AggregateFn::Max),
356 ] {
357 if let Some(raw) = params.filters.get(key) {
358 for field in raw.split(',').map(str::trim).filter(|f| !f.is_empty()) {
359 reductions.push((func, field.to_string()));
360 }
361 }
362 }
363
364 AggregateSpec {
365 group_by: params
366 .filters
367 .get("group_by")
368 .map(|g| g.trim().to_string())
369 .filter(|g| !g.is_empty()),
370 reductions,
371 group_limit: params
372 .filters
373 .get("group_limit")
374 .and_then(|l| l.trim().parse::<usize>().ok())
375 .unwrap_or(0),
376 label_field: params
377 .filters
378 .get("group_label")
379 .map(|l| l.trim().to_string())
380 .filter(|l| !l.is_empty()),
381 label_relation: None,
382 }
383}
384
385fn named_secret(params: &ListQueryParams, secret_fields: &[&str]) -> Option<String> {
392 if secret_fields.is_empty() {
393 return None;
394 }
395 let secrets: Vec<String> = secret_fields.iter().map(|f| camel_to_snake_case(f)).collect();
396 const COLUMN_LISTS: [&str; 10] = [
397 "orderby", "sort", "searchfields", "search_fields", "group_by", "sum", "avg", "min", "max", "group_label",
398 ];
399 let mut names: Vec<String> = Vec::new();
400 for (key, value) in ¶ms.filters {
401 match key.find('[') {
402 Some(open) => {
403 names.push(key[..open].to_string());
404 names.push(key[open + 1..].trim_end_matches(']').to_string());
405 }
406 None => names.push(key.clone()),
407 }
408 if COLUMN_LISTS.contains(&key.to_ascii_lowercase().as_str()) {
409 names.extend(value.split(',').map(|v| v.trim().trim_start_matches('-').to_string()));
410 }
411 }
412 names.extend(params.sort_by.iter().cloned());
413 names
414 .into_iter()
415 .find(|n| secrets.contains(&camel_to_snake_case(n.trim())))
416}
417
418fn secret_named_message(field: &str) -> String {
420 format!("'{field}' is a secret field: it cannot be filtered, sorted, searched or aggregated")
421}
422
423fn repository_filters(params: &ListQueryParams) -> HashMap<String, String> {
435 let mut filters = params.filters.clone();
436 for key in RESERVED_QUERY_KEYS {
437 filters.remove(key);
438 }
439 filters.retain(|key, _| !backbone_orm::is_internal_key(key));
442 if let Some(search) = params.search.clone() {
443 filters.insert("search".to_string(), search);
444 }
445 if let Some(status) = params.status.clone() {
446 filters.insert("status".to_string(), status);
447 }
448 let sorted = filters.keys().any(|k| {
452 let base = k.split('[').next().unwrap_or(k);
453 base.eq_ignore_ascii_case("orderby") || base.eq_ignore_ascii_case("sort")
454 });
455 if let Some(field) = params.sort_by.as_deref().map(str::trim).filter(|f| !f.is_empty()) {
456 if !sorted {
457 let dir = match params.sort_order.as_deref().map(str::trim) {
458 Some(d) if d.eq_ignore_ascii_case("desc") => "desc",
459 _ => "asc",
460 };
461 filters.insert("orderby".to_string(), format!("{field}:{dir}"));
462 }
463 }
464 filters
465}
466
467fn aggregate_filters(params: &ListQueryParams) -> HashMap<String, String> {
470 let mut filters = repository_filters(params);
471 for key in AGGREGATE_QUERY_KEYS {
472 filters.remove(key);
473 }
474 filters
475}
476
477fn sparse_fields(query: &HashMap<String, String>) -> Vec<String> {
478 query
479 .get("fields")
480 .map(|s| {
481 s.split(',')
482 .map(str::trim)
483 .filter(|f| !f.is_empty())
484 .map(str::to_string)
485 .collect()
486 })
487 .unwrap_or_default()
488}
489
490fn to_response_value<R: Serialize>(r: R) -> serde_json::Value {
493 serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
494}
495
496fn project_sparse(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
500 if fields.is_empty() {
501 return value;
502 }
503 if let serde_json::Value::Object(map) = &mut value {
504 map.retain(|k, _| k == "id" || fields.iter().any(|f| f == k));
505 }
506 value
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532pub enum AccessScope {
533 Platform,
535 Company(uuid::Uuid),
537}
538
539impl AccessScope {
540 pub fn company(&self) -> Option<uuid::Uuid> {
546 match self {
547 AccessScope::Platform => None,
548 AccessScope::Company(id) => Some(*id),
549 }
550 }
551}
552
553fn strip_secrets(mut value: serde_json::Value, secret_fields: &[&str]) -> serde_json::Value {
563 if let serde_json::Value::Object(map) = &mut value {
564 for f in secret_fields {
565 map.remove(*f);
566 }
567 }
568 value
569}
570
571fn secure<E: backbone_orm::EntityRepoMeta>(
575 value: serde_json::Value,
576 scope: Option<&AccessScope>,
577) -> serde_json::Value {
578 apply_field_security(
579 strip_secrets(value, E::secret_fields()),
580 scope,
581 E::private_fields(),
582 E::owner_field(),
583 )
584}
585
586pub fn without_secrets<E: backbone_orm::EntityRepoMeta, R: Serialize>(response: R) -> serde_json::Value {
590 secure::<E>(to_response_value(response), None)
591}
592
593fn related_row<E: backbone_orm::EntityRepoMeta>(
597 relation: &str,
598 table: &str,
599 obj: serde_json::Value,
600) -> serde_json::Value {
601 let row = strip_secrets(camelize_keys(obj), E::relation_secret_fields(relation));
602 strip_secrets(row, &backbone_orm::secret_registry::secret_fields_of(table))
604}
605
606fn apply_field_security(
607 mut value: serde_json::Value,
608 scope: Option<&AccessScope>,
609 private_fields: &[&str],
610 owner_field: Option<&str>,
611) -> serde_json::Value {
612 if private_fields.is_empty() {
613 return value;
614 }
615 let can_see_private = match scope {
616 Some(AccessScope::Platform) => true,
617 Some(AccessScope::Company(id)) => owner_field
618 .and_then(|f| value.get(f))
619 .and_then(|v| v.as_str())
620 .and_then(|owner| uuid::Uuid::parse_str(owner).ok())
624 .is_some_and(|owner| owner == *id),
625 None => false,
626 };
627 if !can_see_private {
628 if let serde_json::Value::Object(map) = &mut value {
629 for f in private_fields {
630 map.remove(*f);
631 }
632 }
633 }
634 value
635}
636
637fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
639 query
640 .get("include")
641 .or_else(|| query.get("with"))
642 .map(|s| {
643 s.split(',')
644 .map(str::trim)
645 .filter(|s| !s.is_empty())
646 .map(str::to_string)
647 .collect()
648 })
649 .unwrap_or_default()
650}
651
652fn snake_to_camel(s: &str) -> String {
653 let mut out = String::with_capacity(s.len());
654 let mut upper = false;
655 for c in s.chars() {
656 if c == '_' {
657 upper = true;
658 } else if upper {
659 out.extend(c.to_uppercase());
660 upper = false;
661 } else {
662 out.push(c);
663 }
664 }
665 out
666}
667
668fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
671 match v {
672 serde_json::Value::Object(m) => serde_json::Value::Object(
673 m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
674 ),
675 other => other,
676 }
677}
678
679async fn expand_includes<S, E, C, U>(
689 service: &S,
690 rows: &mut [serde_json::Value],
691 includes: &[String],
692) where
693 S: CrudService<E, C, U>,
694 E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
695 C: Send + Sync + 'static,
696 U: Send + Sync + 'static,
697{
698 if includes.is_empty() || rows.is_empty() {
699 return;
700 }
701 for (rel_name, table, fk_field) in E::relations() {
702 if !includes.iter().any(|i| i == rel_name) {
703 continue;
704 }
705 let mut ids: Vec<String> = rows
706 .iter()
707 .filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
708 .collect();
709 ids.sort();
710 ids.dedup();
711 if ids.is_empty() {
712 continue;
713 }
714 let related = service.fetch_related_json(table, &ids).await;
715 let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
716 for obj in related {
717 if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
718 by_id.insert(id, related_row::<E>(rel_name, table, obj));
719 }
720 }
721 for r in rows.iter_mut() {
722 let related_obj = r
723 .get(fk_field)
724 .and_then(|v| v.as_str())
725 .and_then(|id| by_id.get(id).cloned())
726 .unwrap_or(serde_json::Value::Null);
727 if let serde_json::Value::Object(m) = r {
728 m.insert((*rel_name).to_string(), related_obj);
729 }
730 }
731 }
732}
733
734pub const MAX_PER_PAGE: u32 = 100;
738
739pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
744
745fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
751 let effective_limit = limit.clamp(1, MAX_PER_PAGE);
752 let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
753 if offset > MAX_PAGINATION_OFFSET {
754 Some(format!(
755 "Result set too deep: offset {offset} exceeds the maximum of \
756 {MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
757 ))
758 } else {
759 None
760 }
761}
762
763pub use crate::service::MAX_BATCH_SIZE;
769
770fn batch_size_error(count: usize) -> Option<String> {
773 if count > MAX_BATCH_SIZE {
774 Some(format!(
775 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
776 ))
777 } else {
778 None
779 }
780}
781
782fn is_bad_query_error(msg: &str) -> bool {
791 let m = msg.to_lowercase();
792 m.contains("does not exist")
793 || m.contains("invalid input syntax")
794 || m.contains("42703")
795 || m.contains("not a column of this entity")
799 || m.contains("needs a numeric column")
800}
801
802fn bad_list_request(message: String) -> PaginatedApiResponse<serde_json::Value> {
805 let violation = crate::violation::Violation::new("", "invalid_request", message.clone());
806 PaginatedApiResponse::rejected(message, vec![violation])
807}
808
809#[derive(Debug, Serialize, Deserialize, Clone)]
811#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
812pub struct PaginationResponse {
813 pub total: u64,
814 pub page: u32,
815 pub limit: u32,
816 pub total_pages: u32,
817 #[serde(skip_serializing_if = "Option::is_none")]
821 pub next_cursor: Option<String>,
822 #[serde(skip_serializing_if = "Option::is_none")]
824 pub prev_cursor: Option<String>,
825 #[serde(skip_serializing_if = "Option::is_none")]
827 pub has_more: Option<bool>,
828}
829
830impl PaginationResponse {
831 pub fn new(total: u64, page: u32, limit: u32) -> Self {
832 let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
833 Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
834 }
835
836 pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
840 Self {
841 total: info.total,
842 page: info.page,
843 limit: info.per_page,
844 total_pages: info.total_pages,
845 next_cursor: info.next_cursor.clone(),
846 prev_cursor: info.prev_cursor.clone(),
847 has_more: info.has_more,
848 }
849 }
850}
851
852#[derive(Debug, Serialize)]
854#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
855pub struct PaginatedResponse<T> {
856 pub data: Vec<T>,
857 pub meta: PaginationResponse,
858}
859
860#[derive(Debug, Serialize)]
863#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
864pub struct PaginatedApiResponse<T> {
865 pub success: bool,
866 pub data: Vec<T>,
867 pub meta: PaginationResponse,
868 #[serde(skip_serializing_if = "Option::is_none")]
869 pub error: Option<String>,
870 #[serde(default, skip_serializing_if = "Option::is_none")]
872 pub violations: Option<Vec<crate::violation::Violation>>,
873}
874
875impl<T> PaginatedApiResponse<T> {
876 pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
879 Self {
880 success: true,
881 data,
882 meta: PaginationResponse::from_info(info),
883 error: None,
884 violations: None,
885 }
886 }
887
888 pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
890 Self {
891 success: true,
892 data,
893 meta: PaginationResponse::new(total, page, limit),
894 error: None,
895 violations: None,
896 }
897 }
898
899 pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
901 Self {
902 success: true,
903 data: resp.data,
904 meta: resp.meta,
905 error: None,
906 violations: None,
907 }
908 }
909
910 pub fn error(error: impl Into<String>) -> Self {
912 Self {
913 success: false,
914 data: Vec::new(),
915 meta: PaginationResponse::new(0, 0, 0),
916 error: Some(error.into()),
917 violations: None,
918 }
919 }
920
921 pub fn rejected(error: impl Into<String>, violations: Vec<crate::violation::Violation>) -> Self {
923 Self {
924 violations: (!violations.is_empty()).then_some(violations),
925 ..Self::error(error)
926 }
927 }
928}
929
930#[derive(Debug, Serialize, Deserialize)]
932#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
933pub struct BulkCreateRequest<T> {
934 pub items: Vec<T>,
935}
936
937#[derive(Debug, Serialize, Deserialize)]
939#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
940pub struct BulkResponse<T> {
941 pub items: Vec<T>,
942 pub total: usize,
943 pub failed: usize,
944 pub errors: Vec<String>,
945}
946
947#[derive(Debug, Serialize, Deserialize)]
949#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
950pub struct UpsertRequest<T> {
951 pub entity: T,
952 pub create_if_not_exists: bool,
953}
954
955#[derive(Debug, Serialize, Deserialize)]
958#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
959pub struct BatchIdsRequest {
960 pub ids: Vec<String>,
961}
962
963#[derive(Debug, Deserialize)]
966#[serde(bound = "U: DeserializeOwned")]
967#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
968pub struct BulkUpdateItem<U> {
969 pub id: String,
970 #[serde(flatten)]
974 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
975 pub data: U,
976}
977
978#[derive(Debug, Deserialize)]
980#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
981pub struct BulkPatchItem {
982 pub id: String,
983 pub patch: HashMap<String, serde_json::Value>,
984}
985
986#[derive(Debug, Deserialize)]
989#[serde(untagged)]
990#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
991pub enum BulkPatchRequest {
992 Shared {
994 ids: Vec<String>,
995 patch: HashMap<String, serde_json::Value>,
996 },
997 PerItem { items: Vec<BulkPatchItem> },
999}
1000
1001impl BulkPatchRequest {
1002 fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
1004 match self {
1005 BulkPatchRequest::Shared { ids, patch } => {
1006 ids.into_iter().map(|id| (id, patch.clone())).collect()
1007 }
1008 BulkPatchRequest::PerItem { items } => {
1009 items.into_iter().map(|it| (it.id, it.patch)).collect()
1010 }
1011 }
1012 }
1013}
1014
1015#[derive(Debug, Default, Serialize)]
1017#[serde(rename_all = "camelCase")]
1018#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1019pub struct BulkPreview {
1020 pub will_change: Vec<PreviewChange>,
1022 pub unchanged: Vec<String>,
1024 pub refused: Vec<PreviewRefusal>,
1026 pub total: usize,
1028}
1029
1030#[derive(Debug, Serialize)]
1032#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1033pub struct PreviewChange {
1034 pub id: String,
1036 pub changes: Vec<FieldChange>,
1038}
1039
1040#[derive(Debug, Serialize)]
1042#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1043pub struct FieldChange {
1044 pub field: String,
1046 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1048 pub from: serde_json::Value,
1049 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1051 pub to: serde_json::Value,
1052}
1053
1054#[derive(Debug, Serialize)]
1056#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1057pub struct PreviewRefusal {
1058 pub id: String,
1060 pub code: String,
1062 pub message: String,
1064}
1065
1066type PreviewOutcome = Result<(serde_json::Value, serde_json::Value), (String, String)>;
1068
1069fn preview_answer(
1076 rows: Vec<(String, PreviewOutcome)>,
1077 patched: &HashMap<String, Vec<String>>,
1078) -> BulkPreview {
1079 let mut out = BulkPreview::default();
1080 for (id, outcome) in rows {
1081 match outcome {
1082 Ok((before, after)) => {
1083 let mut keys = patched.get(&id).cloned().unwrap_or_default();
1084 keys.sort();
1085 let changes: Vec<FieldChange> = keys
1086 .into_iter()
1087 .filter_map(|field| {
1088 let read = |v: &serde_json::Value| {
1089 v.get(snake_to_camel(&field))
1090 .or_else(|| v.get(&field))
1091 .cloned()
1092 .unwrap_or(serde_json::Value::Null)
1093 };
1094 let (from, to) = (read(&before), read(&after));
1095 (from != to).then_some(FieldChange { field, from, to })
1096 })
1097 .collect();
1098 if changes.is_empty() {
1099 out.unchanged.push(id);
1100 } else {
1101 out.will_change.push(PreviewChange { id, changes });
1102 }
1103 }
1104 Err((code, message)) => out.refused.push(PreviewRefusal { id, code, message }),
1105 }
1106 }
1107 out.total = out.will_change.len() + out.unchanged.len() + out.refused.len();
1108 out
1109}
1110
1111#[derive(Debug, Serialize, Deserialize)]
1113#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1114pub struct FilterOptions {
1115 pub filters: HashMap<String, String>,
1116 pub sort_by: Option<String>,
1117 pub sort_order: Option<SortOrder>,
1118}
1119
1120#[derive(Debug, Serialize, Deserialize, Clone, Default)]
1122#[serde(rename_all = "lowercase")]
1123#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1124pub enum SortOrder {
1125 #[default]
1126 Asc,
1127 Desc,
1128}
1129
1130#[derive(Debug, Serialize, Deserialize)]
1132#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1133pub struct ListRequest {
1134 pub page: Option<u32>,
1135 pub limit: Option<u32>,
1136 pub sort_by: Option<String>,
1137 pub sort_order: Option<SortOrder>,
1138 pub filters: Option<HashMap<String, String>>,
1139}
1140
1141impl Default for ListRequest {
1142 fn default() -> Self {
1143 Self {
1144 page: Some(1),
1145 limit: Some(20),
1146 sort_by: None,
1147 sort_order: None,
1148 filters: None,
1149 }
1150 }
1151}
1152
1153#[async_trait::async_trait]
1177pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
1178where
1179 Entity: Send + Sync + 'static,
1182 CreateDto: Send + Sync + 'static,
1183 UpdateDto: Send + Sync + 'static,
1184{
1185 type Error: std::error::Error + Send + Sync;
1187
1188 fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
1191 let _ = err;
1192 None
1193 }
1194
1195 fn status_of(err: &Self::Error) -> Option<axum::http::StatusCode> {
1200 let _ = err;
1201 None
1202 }
1203
1204 fn supports_upsert() -> bool {
1208 true
1209 }
1210
1211 fn entity_name() -> &'static str;
1213
1214 async fn fetch_related_json(
1217 &self,
1218 _table: &str,
1219 _ids: &[String],
1220 ) -> Vec<serde_json::Value> {
1221 Vec::new()
1222 }
1223
1224 async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1226
1227 async fn list_with_info(
1231 &self,
1232 page: u32,
1233 limit: u32,
1234 filters: HashMap<String, String>,
1235 ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
1236 let (rows, total) = self.list(page, limit, filters).await?;
1237 Ok((
1238 rows,
1239 backbone_orm::repository::PaginationInfo::new(page, limit, total),
1240 ))
1241 }
1242
1243 async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1245
1246 async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1248
1249 async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
1251
1252 async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
1254
1255 async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
1257
1258 async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
1260
1261 async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1263
1264 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
1266
1267 async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1269
1270 async fn empty_trash(&self) -> Result<u64, Self::Error>;
1272
1273 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1275
1276 async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
1278
1279 async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1281
1282 async fn count_active(&self) -> Result<u64, Self::Error>;
1284
1285 fn table_name(&self) -> Option<&str> {
1292 None
1293 }
1294
1295 async fn aggregate(
1300 &self,
1301 spec: &backbone_orm::repository::AggregateSpec,
1302 filters: HashMap<String, String>,
1303 ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
1304
1305 async fn count_active_filtered(
1318 &self,
1319 filters: HashMap<String, String>,
1320 ) -> Result<u64, Self::Error> {
1321 self.list(1, 1, filters).await.map(|(_, total)| total)
1322 }
1323
1324 async fn count_deleted(&self) -> Result<u64, Self::Error>;
1326
1327 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1336 let mut n = 0;
1337 for id in ids {
1338 if self.soft_delete(&id).await? {
1339 n += 1;
1340 }
1341 }
1342 Ok(n)
1343 }
1344
1345 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1347 let mut out = Vec::with_capacity(ids.len());
1348 for id in ids {
1349 if let Some(e) = self.restore(&id).await? {
1350 out.push(e);
1351 }
1352 }
1353 Ok(out)
1354 }
1355
1356 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1358 let mut n = 0;
1359 for id in ids {
1360 if self.permanent_delete(&id).await? {
1361 n += 1;
1362 }
1363 }
1364 Ok(n)
1365 }
1366
1367 async fn restore_all(&self) -> Result<u64, Self::Error> {
1372 Ok(0)
1373 }
1374
1375 async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1377 let mut out = Vec::with_capacity(items.len());
1378 for (id, dto) in items {
1379 if let Some(e) = self.update(&id, dto).await? {
1380 out.push(e);
1381 }
1382 }
1383 Ok(out)
1384 }
1385
1386 async fn bulk_partial_update(
1388 &self,
1389 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1390 ) -> Result<Vec<Entity>, Self::Error> {
1391 let mut out = Vec::with_capacity(items.len());
1392 for (id, fields) in items {
1393 if let Some(e) = self.partial_update(&id, fields).await? {
1394 out.push(e);
1395 }
1396 }
1397 Ok(out)
1398 }
1399
1400 async fn preview_bulk_partial_update(
1405 &self,
1406 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1407 ) -> Result<Option<Vec<BulkPreviewRow<Entity, Self::Error>>>, Self::Error> {
1408 let _ = items;
1409 Ok(None)
1410 }
1411}
1412
1413pub struct BulkPreviewRow<Entity, Err> {
1416 pub id: String,
1418 pub outcome: Result<(Entity, Entity), Err>,
1420}
1421
1422pub struct BackboneCrudHandler<S, E, C, U, R>
1445where
1446 S: CrudService<E, C, U> + 'static,
1447 E: Serialize + Send + Sync + 'static,
1448 C: DeserializeOwned + Send + Sync + 'static,
1449 U: DeserializeOwned + Send + Sync + 'static,
1450 R: From<E> + Serialize + Send + Sync + 'static,
1451{
1452 service: Arc<S>,
1453 _phantom: std::marker::PhantomData<(E, C, U, R)>,
1454}
1455
1456impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1457where
1458 S: CrudService<E, C, U> + 'static,
1459 E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1460 C: DeserializeOwned + Send + Sync + 'static,
1461 U: DeserializeOwned + Send + Sync + 'static,
1462 R: From<E> + Serialize + Send + Sync + 'static,
1463{
1464 pub fn new(service: Arc<S>) -> Self {
1465 Self {
1466 service,
1467 _phantom: std::marker::PhantomData,
1468 }
1469 }
1470
1471 pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1495 where
1496 S: Clone,
1497 {
1498 Self::read_routes(service.clone(), base_path)
1499 .merge(Self::write_routes(service, base_path))
1500 }
1501
1502 pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1516 where
1517 S: Clone,
1518 {
1519 if let Some(table) = service.table_name() {
1522 backbone_orm::secret_registry::register(table, E::secret_fields());
1523 }
1524 use axum::{
1525 extract::{Path, Query},
1526 routing::get,
1527 Extension, Router,
1528 };
1529
1530 let handler = Arc::new(Self::new(service));
1531
1532 Router::new()
1533 .route(base_path, get({
1535 let h = handler.clone();
1536 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1537 Self::list_handler(h, query, access).await
1538 }
1539 }))
1540 .route(&format!("{}/trash", base_path), get({
1542 let h = handler.clone();
1543 move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1544 Self::list_deleted_handler(h, query, access).await
1545 }
1546 }))
1547 .route(&format!("{}/:id", base_path), get({
1549 let h = handler.clone();
1550 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1551 Self::get_handler(h, path, query, access).await
1552 }
1553 }))
1554 .route(&format!("{}/:id/deleted", base_path), get({
1556 let h = handler.clone();
1557 move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
1558 Self::get_deleted_handler(h, path, query, access).await
1559 }
1560 }))
1561 .route(&format!("{}/:id/history", base_path), get({
1564 let h = handler.clone();
1565 move |path: axum::extract::Path<String>,
1566 query: axum::extract::Query<ListQueryParams>,
1567 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
1568 Self::history_handler(h, path, query, provider).await
1569 }
1570 }))
1571 .route(&format!("{}/aggregate", base_path), get({
1573 let h = handler.clone();
1574 move |query: axum::extract::Query<ListQueryParams>| async move {
1575 Self::aggregate_handler(h, query).await
1576 }
1577 }))
1578 .route(&format!("{}/count", base_path), get({
1579 let h = handler.clone();
1580 move |query: axum::extract::Query<ListQueryParams>| async move {
1581 Self::count_active_handler(h, query).await
1582 }
1583 }))
1584 .route(&format!("{}/trash/count", base_path), get({
1586 let h = handler.clone();
1587 move || async move {
1588 Self::count_deleted_handler(h).await
1589 }
1590 }))
1591 }
1592
1593 pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1610 where
1611 S: Clone,
1612 {
1613 if let Some(table) = service.table_name() {
1616 backbone_orm::secret_registry::register(table, E::secret_fields());
1617 }
1618 use axum::{
1619 extract::Path,
1620 routing::{delete, patch, post, put},
1621 Router,
1622 };
1623
1624 let handler = Arc::new(Self::new(service));
1625
1626 Router::new()
1627 .route(base_path, post({
1629 let h = handler.clone();
1630 move |body: StrictJsonOrForm<C>| async move {
1631 Self::create_handler(h, body).await
1632 }
1633 }))
1634 .route(&format!("{}/bulk", base_path), post({
1636 let h = handler.clone();
1637 move |body: StrictJsonOrForm<Vec<C>>| async move {
1638 Self::bulk_create_handler(h, body).await
1639 }
1640 }))
1641 .route(&format!("{}/upsert", base_path), post({
1643 let h = handler.clone();
1644 move |body: StrictJsonOrForm<C>| async move {
1645 Self::upsert_handler(h, body).await
1646 }
1647 }))
1648 .route(&format!("{}/delete/bulk", base_path), post({
1650 let h = handler.clone();
1651 move |body: JsonOrForm<BatchIdsRequest>| async move {
1652 Self::bulk_delete_handler(h, body).await
1653 }
1654 }))
1655 .route(&format!("{}/restore/bulk", base_path), post({
1657 let h = handler.clone();
1658 move |body: JsonOrForm<BatchIdsRequest>| async move {
1659 Self::bulk_restore_handler(h, body).await
1660 }
1661 }))
1662 .route(&format!("{}/restore/all", base_path), post({
1664 let h = handler.clone();
1665 move || async move {
1666 Self::restore_all_handler(h).await
1667 }
1668 }))
1669 .route(&format!("{}/trash/bulk", base_path), delete({
1672 let h = handler.clone();
1673 move |body: JsonOrForm<BatchIdsRequest>| async move {
1674 Self::bulk_permanent_delete_handler(h, body).await
1675 }
1676 }))
1677 .route(&format!("{}/bulk", base_path), put({
1679 let h = handler.clone();
1680 move |body: JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>| async move {
1681 Self::bulk_update_handler(h, body).await
1682 }
1683 }))
1684 .route(&format!("{}/bulk", base_path), patch({
1686 let h = handler.clone();
1687 move |body: JsonOrForm<BulkPatchRequest>| async move {
1688 Self::bulk_patch_handler(h, body).await
1689 }
1690 }))
1691 .route(&format!("{}/bulk/preview", base_path), post({
1693 let h = handler.clone();
1694 move |body: JsonOrForm<BulkPatchRequest>| async move {
1695 Self::bulk_patch_preview_handler(h, body).await
1696 }
1697 }))
1698 .route(&format!("{}/empty", base_path), delete({
1700 let h = handler.clone();
1701 move || async move {
1702 Self::empty_trash_handler(h).await
1703 }
1704 }))
1705 .route(&format!("{}/trash/:id", base_path), delete({
1707 let h = handler.clone();
1708 move |path: Path<String>| async move {
1709 Self::permanent_delete_handler(h, path).await
1710 }
1711 }))
1712 .route(&format!("{}/:id", base_path), put({
1714 let h = handler.clone();
1715 move |path: Path<String>, body: StrictJsonOrForm<U>| async move {
1716 Self::update_handler(h, path, body).await
1717 }
1718 }))
1719 .route(&format!("{}/:id", base_path), patch({
1721 let h = handler.clone();
1722 move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
1723 Self::partial_update_handler(h, path, body).await
1724 }
1725 }))
1726 .route(&format!("{}/:id", base_path), delete({
1728 let h = handler.clone();
1729 move |path: Path<String>| async move {
1730 Self::delete_handler(h, path).await
1731 }
1732 }))
1733 .route(&format!("{}/:id/restore", base_path), post({
1735 let h = handler.clone();
1736 move |path: Path<String>| async move {
1737 Self::restore_handler(h, path).await
1738 }
1739 }))
1740 }
1741
1742 fn failure<T>(err: &S::Error, fallback: axum::http::StatusCode) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
1750 let violations = S::violations_of(err);
1751 let status = S::status_of(err).unwrap_or(if violations.is_some() {
1752 axum::http::StatusCode::UNPROCESSABLE_ENTITY
1753 } else {
1754 fallback
1755 });
1756 error_at(status, violations, err)
1757 }
1758
1759 fn list_failure(err: &S::Error) -> (axum::http::StatusCode, axum::Json<PaginatedApiResponse<serde_json::Value>>) {
1762 use axum::{http::StatusCode, Json};
1763 let msg = err.to_string();
1764 if is_bad_query_error(&msg) {
1765 tracing::debug!(target: "backbone::http", error = %msg, "list query refused");
1768 let message = "Invalid query parameter or filter";
1769 return (
1770 StatusCode::BAD_REQUEST,
1771 Json(PaginatedApiResponse::rejected(
1772 message,
1773 vec![crate::violation::Violation::new("", "invalid_query", message)],
1774 )),
1775 );
1776 }
1777 let (status, Json(body)) = Self::failure::<()>(err, StatusCode::INTERNAL_SERVER_ERROR);
1778 let error = body.error.unwrap_or_default();
1779 (status, Json(PaginatedApiResponse::rejected(error, body.violations.unwrap_or_default())))
1780 }
1781
1782 async fn list_handler(
1783 handler: Arc<Self>,
1784 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1785 access: Option<axum::Extension<AccessScope>>,
1786 ) -> impl axum::response::IntoResponse {
1787 use axum::{http::StatusCode, Json};
1788
1789 let params = params.normalized();
1790 if let Some(err) = pagination_depth_error(params.page, params.limit) {
1791 return (StatusCode::BAD_REQUEST, Json(bad_list_request(err)));
1792 }
1793 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
1794 return (
1795 StatusCode::BAD_REQUEST,
1796 Json(bad_list_request(secret_named_message(&field))),
1797 );
1798 }
1799
1800 let fields = sparse_fields(¶ms.filters);
1803 let includes = include_relations(¶ms.filters);
1804 let scope = access.map(|axum::Extension(s)| s);
1805
1806 let filters = repository_filters(¶ms);
1807
1808 match handler
1809 .service
1810 .list_with_info(params.page, params.limit, filters)
1811 .await
1812 {
1813 Ok((entities, info)) => {
1814 let mut rows: Vec<serde_json::Value> = entities
1817 .into_iter()
1818 .map(|e| {
1819 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
1820 })
1821 .collect();
1822 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1823 let items: Vec<serde_json::Value> =
1824 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
1825 let response = PaginatedApiResponse::ok_with_info(items, &info);
1826 (StatusCode::OK, Json(response))
1827 }
1828 Err(e) => Self::list_failure(&e),
1829 }
1830 }
1831
1832 async fn create_handler(
1833 handler: Arc<Self>,
1834 StrictJsonOrForm(dto): StrictJsonOrForm<C>,
1835 ) -> impl axum::response::IntoResponse {
1836 use axum::{http::StatusCode, Json};
1837
1838 match handler.service.create(dto).await {
1839 Ok(entity) => {
1840 let response = secure::<E>(to_response_value(R::from(entity)), None);
1841 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1842 }
1843 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
1844 }
1845 }
1846
1847 async fn get_handler(
1848 handler: Arc<Self>,
1849 axum::extract::Path(id): axum::extract::Path<String>,
1850 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
1851 access: Option<axum::Extension<AccessScope>>,
1852 ) -> impl axum::response::IntoResponse {
1853 use axum::{http::StatusCode, Json};
1854
1855 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
1856 return refused;
1857 }
1858
1859 let fields = sparse_fields(¶ms.filters);
1860 let includes = include_relations(¶ms.filters);
1861 let scope = access.map(|axum::Extension(s)| s);
1862
1863 match handler.service.get_by_id(&id).await {
1864 Ok(Some(entity)) => {
1865 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
1866 let mut rows = [secured];
1867 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
1868 let [secured] = rows;
1869 let value = project_sparse(secured, &fields);
1870 (StatusCode::OK, Json(ApiResponse::ok(value)))
1871 }
1872 Ok(None) => {
1873 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1874 }
1875 Err(e) => {
1876 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
1877 }
1878 }
1879 }
1880
1881 async fn update_handler(
1882 handler: Arc<Self>,
1883 axum::extract::Path(id): axum::extract::Path<String>,
1884 StrictJsonOrForm(dto): StrictJsonOrForm<U>,
1885 ) -> impl axum::response::IntoResponse {
1886 use axum::{http::StatusCode, Json};
1887
1888 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
1889 return refused;
1890 }
1891
1892 match handler.service.update(&id, dto).await {
1893 Ok(Some(entity)) => {
1894 let response = secure::<E>(to_response_value(R::from(entity)), None);
1895 (StatusCode::OK, Json(ApiResponse::ok(response)))
1896 }
1897 Ok(None) => {
1898 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1899 }
1900 Err(e) => {
1901 Self::failure(&e, StatusCode::BAD_REQUEST)
1902 }
1903 }
1904 }
1905
1906 async fn partial_update_handler(
1907 handler: Arc<Self>,
1908 axum::extract::Path(id): axum::extract::Path<String>,
1909 JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
1910 ) -> impl axum::response::IntoResponse {
1911 use axum::{http::StatusCode, Json};
1912
1913 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
1914 return refused;
1915 }
1916
1917 let fields: HashMap<String, serde_json::Value> = fields
1928 .into_iter()
1929 .map(|(k, v)| (camel_to_snake_case(&k), v))
1930 .collect();
1931
1932 match handler.service.partial_update(&id, fields).await {
1933 Ok(Some(entity)) => {
1934 let response = secure::<E>(to_response_value(R::from(entity)), None);
1935 (StatusCode::OK, Json(ApiResponse::ok(response)))
1936 }
1937 Ok(None) => {
1938 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
1939 }
1940 Err(e) => {
1941 Self::failure(&e, StatusCode::BAD_REQUEST)
1942 }
1943 }
1944 }
1945
1946 async fn delete_handler(
1947 handler: Arc<Self>,
1948 axum::extract::Path(id): axum::extract::Path<String>,
1949 ) -> impl axum::response::IntoResponse {
1950 use axum::{http::StatusCode, Json};
1951
1952 if let Some(refused) = invalid_id::<()>(&id, "id") {
1953 return refused;
1954 }
1955
1956 match handler.service.soft_delete(&id).await {
1957 Ok(true) => {
1958 (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
1961 }
1962 Ok(false) => {
1963 (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
1964 }
1965 Err(e) => {
1966 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
1967 }
1968 }
1969 }
1970
1971 async fn bulk_create_handler(
1972 handler: Arc<Self>,
1973 StrictJsonOrForm(items): StrictJsonOrForm<Vec<C>>,
1974 ) -> impl axum::response::IntoResponse {
1975 use axum::{http::StatusCode, Json};
1976
1977 if let Some(err) = batch_size_error(items.len()) {
1978 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
1979 }
1980 match handler.service.bulk_create(items).await {
1981 Ok(entities) => {
1982 let result_items: Vec<serde_json::Value> = entities
1983 .into_iter()
1984 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
1985 .collect();
1986 let total = result_items.len();
1987 let response = BulkResponse {
1988 items: result_items,
1989 total,
1990 failed: 0,
1991 errors: vec![],
1992 };
1993 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
1994 }
1995 Err(e) => {
1996 Self::failure(&e, StatusCode::BAD_REQUEST)
1997 }
1998 }
1999 }
2000
2001 async fn bulk_delete_handler(
2004 handler: Arc<Self>,
2005 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2006 ) -> impl axum::response::IntoResponse {
2007 use axum::{http::StatusCode, Json};
2008
2009 if let Some(err) = batch_size_error(req.ids.len()) {
2010 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2011 }
2012 if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2013 return refused;
2014 }
2015 match handler.service.bulk_soft_delete(req.ids).await {
2016 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2017 serde_json::json!({ "soft_deleted": count }),
2018 format!("Soft-deleted {count} item(s)"),
2019 ))),
2020 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2021 }
2022 }
2023
2024 async fn bulk_restore_handler(
2025 handler: Arc<Self>,
2026 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2027 ) -> impl axum::response::IntoResponse {
2028 use axum::{http::StatusCode, Json};
2029
2030 if let Some(err) = batch_size_error(req.ids.len()) {
2031 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2032 }
2033 if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2034 return refused;
2035 }
2036 match handler.service.bulk_restore(req.ids).await {
2037 Ok(entities) => {
2038 let items: Vec<serde_json::Value> = entities
2039 .into_iter()
2040 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2041 .collect();
2042 let total = items.len();
2043 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2044 }
2045 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2046 }
2047 }
2048
2049 async fn restore_all_handler(
2050 handler: Arc<Self>,
2051 ) -> impl axum::response::IntoResponse {
2052 use axum::{http::StatusCode, Json};
2053
2054 match handler.service.restore_all().await {
2055 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2056 serde_json::json!({ "restored": count }),
2057 format!("Restored {count} item(s) from trash"),
2058 ))),
2059 Err(e) => Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR),
2060 }
2061 }
2062
2063 async fn bulk_permanent_delete_handler(
2064 handler: Arc<Self>,
2065 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2066 ) -> impl axum::response::IntoResponse {
2067 use axum::{http::StatusCode, Json};
2068
2069 if let Some(err) = batch_size_error(req.ids.len()) {
2070 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2071 }
2072 if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2073 return refused;
2074 }
2075 match handler.service.bulk_permanent_delete(req.ids).await {
2076 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2077 serde_json::json!({ "permanently_deleted": count }),
2078 format!("Permanently deleted {count} item(s)"),
2079 ))),
2080 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2081 }
2082 }
2083
2084 async fn bulk_update_handler(
2088 handler: Arc<Self>,
2089 JsonOrForm(raw): JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>,
2090 ) -> axum::response::Response {
2091 use axum::{http::StatusCode, response::IntoResponse, Json};
2092
2093 if let Some(err) = batch_size_error(raw.len()) {
2094 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)))
2095 .into_response();
2096 }
2097 let mut items: Vec<(String, U)> = Vec::with_capacity(raw.len());
2098 for (index, mut fields) in raw.into_iter().enumerate() {
2099 let id = match fields.remove("id") {
2100 Some(serde_json::Value::String(id)) => id,
2101 _ => {
2102 let violation = crate::violation::Violation::new(
2103 crate::violation::batch_item_path(index, "id"),
2104 "required",
2105 "id is required",
2106 );
2107 return (
2108 StatusCode::UNPROCESSABLE_ENTITY,
2109 Json(ApiResponse::<()>::rejected(format!("item {index}: id is required"), vec![violation])),
2110 )
2111 .into_response();
2112 }
2113 };
2114 if let Some(refused) = invalid_id::<()>(&id, &crate::violation::batch_item_path(index, "id")) {
2115 return refused.into_response();
2116 }
2117 let bytes = match serde_json::to_vec(&fields) {
2118 Ok(bytes) => bytes,
2119 Err(e) => return error_at::<()>(StatusCode::INTERNAL_SERVER_ERROR, None, &e).into_response(),
2120 };
2121 match crate::extractors::decode_json_or_refusal::<U>(&bytes, true) {
2122 Ok(data) => items.push((id, data)),
2123 Err(refusal) => return refusal.at_item(index).into_response(),
2124 }
2125 }
2126 match handler.service.bulk_update(items).await {
2127 Ok(entities) => {
2128 let items: Vec<serde_json::Value> = entities
2129 .into_iter()
2130 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2131 .collect();
2132 let total = items.len();
2133 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2134 .into_response()
2135 }
2136 Err(e) => Self::failure::<()>(&e, StatusCode::BAD_REQUEST).into_response(),
2137 }
2138 }
2139
2140 async fn bulk_patch_handler(
2141 handler: Arc<Self>,
2142 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2143 ) -> impl axum::response::IntoResponse {
2144 use axum::{http::StatusCode, Json};
2145
2146 let items = req.into_items();
2147 if let Some(err) = batch_size_error(items.len()) {
2148 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2149 }
2150 if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2151 return refused;
2152 }
2153 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2155 .into_iter()
2156 .map(|(id, fields)| {
2157 let fields = fields
2158 .into_iter()
2159 .map(|(k, v)| (camel_to_snake_case(&k), v))
2160 .collect();
2161 (id, fields)
2162 })
2163 .collect();
2164 match handler.service.bulk_partial_update(items).await {
2165 Ok(entities) => {
2166 let items: Vec<serde_json::Value> = entities
2167 .into_iter()
2168 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2169 .collect();
2170 let total = items.len();
2171 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2172 }
2173 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2174 }
2175 }
2176
2177 async fn bulk_patch_preview_handler(
2183 handler: Arc<Self>,
2184 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2185 ) -> impl axum::response::IntoResponse {
2186 use axum::{http::StatusCode, Json};
2187
2188 let items = req.into_items();
2189 if let Some(err) = batch_size_error(items.len()) {
2190 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkPreview>::error(err)));
2191 }
2192 if let Some(refused) = invalid_ids::<BulkPreview>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2193 return refused;
2194 }
2195 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2196 .into_iter()
2197 .map(|(id, fields)| {
2198 let fields = fields
2199 .into_iter()
2200 .map(|(k, v)| (camel_to_snake_case(&k), v))
2201 .collect();
2202 (id, fields)
2203 })
2204 .collect();
2205 let patched: HashMap<String, Vec<String>> = items
2206 .iter()
2207 .map(|(id, f)| (id.clone(), f.keys().cloned().collect()))
2208 .collect();
2209 match handler.service.preview_bulk_partial_update(items).await {
2210 Ok(Some(rows)) => {
2211 let rows = rows
2212 .into_iter()
2213 .map(|row| {
2214 let outcome = match row.outcome {
2215 Ok((before, after)) => Ok((
2216 secure::<E>(to_response_value(R::from(before)), None),
2217 secure::<E>(to_response_value(R::from(after)), None),
2218 )),
2219 Err(e) => Err((
2220 S::violations_of(&e)
2221 .and_then(|v| v.into_iter().next().map(|v| v.code))
2222 .unwrap_or_else(|| "refused".to_string()),
2223 e.to_string(),
2224 )),
2225 };
2226 (row.id, outcome)
2227 })
2228 .collect();
2229 (StatusCode::OK, Json(ApiResponse::ok(preview_answer(rows, &patched))))
2230 }
2231 Ok(None) => (
2232 StatusCode::NOT_IMPLEMENTED,
2233 Json(ApiResponse::error("this resource has no preview of a bulk change".to_string())),
2234 ),
2235 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2236 }
2237 }
2238
2239 async fn upsert_handler(
2240 handler: Arc<Self>,
2241 StrictJsonOrForm(dto): StrictJsonOrForm<C>,
2242 ) -> impl axum::response::IntoResponse {
2243 use axum::{http::StatusCode, Json};
2244
2245 if !S::supports_upsert() {
2248 return (
2249 StatusCode::NOT_IMPLEMENTED,
2250 Json(ApiResponse::<serde_json::Value>::error(crate::service::UPSERT_UNSUPPORTED)),
2251 );
2252 }
2253 match handler.service.upsert(dto).await {
2254 Ok(entity) => {
2255 let response = secure::<E>(to_response_value(R::from(entity)), None);
2256 (StatusCode::OK, Json(ApiResponse::ok(response)))
2257 }
2258 Err(e) => {
2259 Self::failure(&e, StatusCode::BAD_REQUEST)
2260 }
2261 }
2262 }
2263
2264 async fn list_deleted_handler(
2265 handler: Arc<Self>,
2266 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2267 access: Option<axum::Extension<AccessScope>>,
2268 ) -> impl axum::response::IntoResponse {
2269 use axum::{http::StatusCode, Json};
2270
2271 let params = params.normalized();
2272 if let Some(err) = pagination_depth_error(params.page, params.limit) {
2273 return (StatusCode::BAD_REQUEST, Json(bad_list_request(err)));
2274 }
2275 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2276 return (
2277 StatusCode::BAD_REQUEST,
2278 Json(bad_list_request(secret_named_message(&field))),
2279 );
2280 }
2281
2282 let fields = sparse_fields(¶ms.filters);
2283 let includes = include_relations(¶ms.filters);
2284 let scope = access.map(|axum::Extension(s)| s);
2285
2286 let filters = repository_filters(¶ms);
2287 match handler.service.list_deleted_filtered(params.page, params.limit, filters).await {
2288 Ok((entities, total)) => {
2289 let mut rows: Vec<serde_json::Value> = entities
2293 .into_iter()
2294 .map(|e| {
2295 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
2296 })
2297 .collect();
2298 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
2299 let items: Vec<serde_json::Value> =
2300 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2301 let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
2302 (StatusCode::OK, Json(response))
2303 }
2304 Err(e) => Self::list_failure(&e),
2305 }
2306 }
2307
2308 async fn restore_handler(
2309 handler: Arc<Self>,
2310 axum::extract::Path(id): axum::extract::Path<String>,
2311 ) -> impl axum::response::IntoResponse {
2312 use axum::{http::StatusCode, Json};
2313
2314 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2315 return refused;
2316 }
2317
2318 match handler.service.restore(&id).await {
2319 Ok(Some(entity)) => {
2320 let response = secure::<E>(to_response_value(R::from(entity)), None);
2321 (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
2322 }
2323 Ok(None) => {
2324 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2325 }
2326 Err(e) => {
2327 Self::failure(&e, StatusCode::BAD_REQUEST)
2328 }
2329 }
2330 }
2331
2332 async fn empty_trash_handler(
2333 handler: Arc<Self>,
2334 ) -> impl axum::response::IntoResponse {
2335 use axum::{http::StatusCode, Json};
2336
2337 match handler.service.empty_trash().await {
2338 Ok(count) => {
2339 (StatusCode::OK, Json(ApiResponse::success_with_message(
2340 serde_json::json!({ "deleted_count": count }),
2341 format!("Successfully deleted {} items from trash", count)
2342 )))
2343 }
2344 Err(e) => {
2345 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2346 }
2347 }
2348 }
2349
2350 async fn get_deleted_handler(
2352 handler: Arc<Self>,
2353 axum::extract::Path(id): axum::extract::Path<String>,
2354 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2355 access: Option<axum::Extension<AccessScope>>,
2356 ) -> impl axum::response::IntoResponse {
2357 use axum::{http::StatusCode, Json};
2358
2359 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2360 return refused;
2361 }
2362
2363 let fields = sparse_fields(¶ms.filters);
2364 let scope = access.map(|axum::Extension(s)| s);
2365
2366 match handler.service.get_deleted_by_id(&id).await {
2367 Ok(Some(entity)) => {
2368 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
2369 let value = project_sparse(secured, &fields);
2370 (StatusCode::OK, Json(ApiResponse::ok(value)))
2371 }
2372 Ok(None) => {
2373 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
2374 }
2375 Err(e) => {
2376 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2377 }
2378 }
2379 }
2380
2381 async fn permanent_delete_handler(
2383 handler: Arc<Self>,
2384 axum::extract::Path(id): axum::extract::Path<String>,
2385 ) -> axum::response::Response {
2386 use axum::{http::StatusCode, Json, response::IntoResponse};
2387
2388 if let Some(refused) = invalid_id::<R>(&id, "id") {
2389 return refused.into_response();
2390 }
2391
2392 match handler.service.get_deleted_by_id(&id).await {
2394 Ok(Some(_)) => {
2395 match handler.service.permanent_delete(&id).await {
2397 Ok(true) => {
2398 StatusCode::NO_CONTENT.into_response()
2400 }
2401 Ok(false) => {
2402 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
2403 .into_response()
2404 }
2405 Err(e) => {
2406 Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2407 }
2408 }
2409 }
2410 Ok(None) => {
2411 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
2412 &format!("{} in trash", S::entity_name()), &id
2413 ))).into_response()
2414 }
2415 Err(e) => {
2416 Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2417 }
2418 }
2419 }
2420
2421 async fn count_active_handler(
2423 handler: Arc<Self>,
2424 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2425 ) -> impl axum::response::IntoResponse {
2426 use axum::{http::StatusCode, Json};
2427
2428 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2432 return (
2433 StatusCode::BAD_REQUEST,
2434 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2435 );
2436 }
2437 match handler.service.count_active_filtered(repository_filters(¶ms)).await {
2438 Ok(count) => {
2439 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2440 }
2441 Err(e) => {
2442 if is_bad_query_error(&e.to_string()) {
2445 (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2446 } else {
2447 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2448 }
2449 }
2450 }
2451 }
2452
2453 async fn aggregate_handler(
2458 handler: Arc<Self>,
2459 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2460 ) -> impl axum::response::IntoResponse {
2461 use axum::{http::StatusCode, Json};
2462
2463 if let Some(field) = named_secret(¶ms, E::secret_fields()) {
2464 return (
2465 StatusCode::BAD_REQUEST,
2466 Json(ApiResponse::<serde_json::Value>::error(secret_named_message(&field))),
2467 );
2468 }
2469 let spec = aggregate_spec(¶ms);
2470 match handler.service.aggregate(&spec, aggregate_filters(¶ms)).await {
2471 Ok(result) => {
2472 let render = |g: &backbone_orm::repository::AggregateGroup| {
2473 let mut out = serde_json::Map::new();
2477 out.insert("key".into(), match &g.key {
2478 Some(k) => serde_json::Value::String(k.clone()),
2479 None => serde_json::Value::Null,
2480 });
2481 if g.label.is_some() {
2482 out.insert(
2483 "label".into(),
2484 serde_json::Value::String(g.label.clone().unwrap()),
2485 );
2486 }
2487 out.insert("count".into(), serde_json::json!(g.count));
2488 for (compound, value) in &g.values {
2489 let Some((func, field)) = compound.split_once(':') else { continue };
2490 let slot = out
2491 .entry(func.to_string())
2492 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2493 if let Some(obj) = slot.as_object_mut() {
2494 obj.insert(field.to_string(), match value {
2495 Some(v) => serde_json::Value::String(v.clone()),
2499 None => serde_json::Value::Null,
2500 });
2501 }
2502 }
2503 serde_json::Value::Object(out)
2504 };
2505
2506 let body = serde_json::json!({
2507 "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
2508 "total": render(&result.total),
2509 "truncated": result.truncated,
2512 });
2513 (StatusCode::OK, Json(ApiResponse::ok(body)))
2514 }
2515 Err(e) => {
2516 if is_bad_query_error(&e.to_string()) {
2517 (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
2518 } else {
2519 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2520 }
2521 }
2522 }
2523 }
2524
2525 async fn history_handler(
2536 handler: Arc<Self>,
2537 axum::extract::Path(id): axum::extract::Path<String>,
2538 axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
2539 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2540 ) -> impl axum::response::IntoResponse {
2541 use axum::{http::StatusCode, Json};
2542
2543 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2544 return refused;
2545 }
2546
2547 let Some(axum::Extension(provider)) = provider else {
2548 return (
2549 StatusCode::NOT_IMPLEMENTED,
2550 Json(ApiResponse::<serde_json::Value>::error(
2551 "history is not configured for this service".to_string(),
2552 )),
2553 );
2554 };
2555
2556 let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
2560 return (
2561 StatusCode::NOT_IMPLEMENTED,
2562 Json(ApiResponse::<serde_json::Value>::error(
2563 "this entity cannot name its table, so its history cannot be keyed".to_string(),
2564 )),
2565 );
2566 };
2567
2568 let limit = params.limit.clamp(1, 200);
2569 let offset = params.page.saturating_sub(1) * limit;
2570
2571 match provider.history(&table, &id, limit, offset).await {
2572 Ok(Some(entries)) => (
2573 StatusCode::OK,
2574 Json(ApiResponse::ok(serde_json::json!({
2575 "audited": true,
2576 "entries": entries,
2577 }))),
2578 ),
2579 Ok(None) => (
2580 StatusCode::OK,
2581 Json(ApiResponse::ok(serde_json::json!({
2582 "audited": false,
2585 "entries": serde_json::Value::Null,
2586 }))),
2587 ),
2588 Err(e) => error_at(StatusCode::INTERNAL_SERVER_ERROR, None, &e),
2589 }
2590 }
2591
2592 async fn count_deleted_handler(
2594 handler: Arc<Self>,
2595 ) -> impl axum::response::IntoResponse {
2596 use axum::{http::StatusCode, Json};
2597
2598 match handler.service.count_deleted().await {
2599 Ok(count) => {
2600 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2601 }
2602 Err(e) => {
2603 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2604 }
2605 }
2606 }
2607}
2608
2609pub trait BackboneHttpHandler<T>: Send + Sync {
2634 fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2636
2637 fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
2639
2640 fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2642
2643 fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
2645
2646 fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
2648
2649 fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
2651
2652 fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
2654
2655 fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
2657
2658 fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2660
2661 fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2663
2664 fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
2666
2667 fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2669}
2670
2671#[derive(Debug, Serialize, Deserialize)]
2677#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
2678pub struct PaginationRequest {
2679 pub page: u32,
2680 pub limit: u32,
2681 pub sort_by: Option<String>,
2682 pub sort_order: Option<String>,
2683}
2684
2685fn camel_to_snake_case(key: &str) -> String {
2701 let chars: Vec<char> = key.chars().collect();
2702 let mut result = String::with_capacity(key.len() + 2);
2703 for (i, &c) in chars.iter().enumerate() {
2704 if c.is_ascii_uppercase() {
2705 let prev = if i > 0 { chars[i - 1] } else { '\0' };
2706 let next = chars.get(i + 1).copied().unwrap_or('\0');
2707 let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
2711 let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
2712 if (crosses_lower || crosses_acronym)
2713 && !result.is_empty()
2714 && !result.ends_with('_')
2715 {
2716 result.push('_');
2717 }
2718 result.push(c.to_ascii_lowercase());
2719 } else {
2720 result.push(c);
2721 }
2722 }
2723 result
2724}
2725
2726#[cfg(test)]
2727mod tests {
2728 use super::*;
2729
2730 #[test]
2731 fn snake_case_input_passes_through_unchanged() {
2732 assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
2733 assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
2734 assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
2735 }
2736
2737 #[test]
2738 fn single_word_unchanged() {
2739 assert_eq!(camel_to_snake_case("name"), "name");
2740 assert_eq!(camel_to_snake_case("id"), "id");
2741 assert_eq!(camel_to_snake_case(""), "");
2742 }
2743
2744 #[test]
2745 fn camel_case_converts() {
2746 assert_eq!(camel_to_snake_case("isVip"), "is_vip");
2747 assert_eq!(camel_to_snake_case("userId"), "user_id");
2748 assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
2749 assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
2750 }
2751
2752 #[test]
2753 fn pascal_case_converts() {
2754 assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
2755 assert_eq!(camel_to_snake_case("UserId"), "user_id");
2756 }
2757
2758 #[test]
2759 fn acronym_runs_stay_together() {
2760 assert_eq!(camel_to_snake_case("IOError"), "io_error");
2763 assert_eq!(camel_to_snake_case("httpURL"), "http_url");
2764 assert_eq!(camel_to_snake_case("ABC"), "abc");
2765 assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
2766 }
2767
2768 #[test]
2769 fn digits_count_as_lowercase_for_boundary() {
2770 assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
2771 assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
2772 }
2773
2774 #[test]
2775 fn underscores_not_doubled() {
2776 assert_eq!(camel_to_snake_case("_Foo"), "_foo");
2777 assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
2778 }
2779
2780 #[test]
2781 fn shallow_pages_are_allowed() {
2782 assert!(pagination_depth_error(1, 100).is_none());
2784 assert!(pagination_depth_error(101, 100).is_none());
2786 }
2787
2788 #[test]
2789 fn pages_past_the_cap_are_rejected() {
2790 assert!(pagination_depth_error(102, 100).is_some());
2792 assert!(pagination_depth_error(1002, 10).is_some());
2794 }
2795
2796 #[test]
2797 fn oversized_page_size_is_clamped_before_the_check() {
2798 assert!(pagination_depth_error(102, 200).is_some());
2800 assert!(pagination_depth_error(101, 200).is_none());
2802 }
2803
2804 #[test]
2805 fn huge_page_number_does_not_overflow() {
2806 assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
2808 }
2809
2810 #[test]
2813 fn batch_size_within_limit_is_allowed() {
2814 assert!(batch_size_error(0).is_none());
2815 assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
2816 }
2817
2818 #[test]
2819 fn batch_size_over_limit_is_rejected() {
2820 assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
2821 }
2822
2823 #[test]
2824 fn a_bulk_preview_sorts_rows_by_what_the_patch_would_do() {
2825 use serde_json::json;
2826 let mut patched: HashMap<String, Vec<String>> = ["a", "b", "c"]
2827 .iter()
2828 .map(|id| (id.to_string(), vec!["category".to_string()]))
2829 .collect();
2830 patched.insert("d".into(), vec!["category_id".into()]);
2831 let out = preview_answer(
2832 vec![
2833 (
2834 "a".into(),
2835 Ok((
2836 json!({"category": "meal", "updated_at": "1"}),
2837 json!({"category": "travel", "updated_at": "2"}),
2838 )),
2839 ),
2840 ("b".into(), Ok((json!({"category": "travel"}), json!({"category": "travel"})))),
2841 (
2842 "d".into(),
2843 Ok((json!({"categoryId": "x"}), json!({"categoryId": "y"}))),
2844 ),
2845 ("c".into(), Err(("locked".into(), "a posted claim is locked".into()))),
2846 ],
2847 &patched,
2848 );
2849 assert_eq!(out.total, 4);
2850 assert_eq!(out.will_change.len(), 2);
2851 assert_eq!(out.will_change[1].changes[0].field, "category_id", "a camelCase response is read");
2852 let ch = &out.will_change[0].changes;
2853 assert_eq!(ch.len(), 1, "only the patched field is reported, not the timestamp");
2854 assert_eq!((ch[0].from.clone(), ch[0].to.clone()), (json!("meal"), json!("travel")));
2855 assert_eq!(out.unchanged, vec!["b".to_string()]);
2856 assert_eq!(out.refused[0].code, "locked");
2857 }
2858
2859 #[test]
2860 fn bulk_patch_request_parses_shared_shape() {
2861 let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
2862 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2863 let items = req.into_items();
2864 assert_eq!(items.len(), 2);
2865 assert_eq!(items[0].1.get("status").unwrap(), "void");
2867 assert_eq!(items[1].1.get("status").unwrap(), "void");
2868 assert_eq!(items[0].0, "a");
2869 assert_eq!(items[1].0, "b");
2870 }
2871
2872 #[test]
2873 fn bulk_patch_request_parses_per_item_shape() {
2874 let json = r#"{ "items": [
2875 { "id": "a", "patch": { "status": "void" } },
2876 { "id": "b", "patch": { "note": "late" } }
2877 ] }"#;
2878 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
2879 let items = req.into_items();
2880 assert_eq!(items.len(), 2);
2881 assert_eq!(items[0].0, "a");
2882 assert_eq!(items[0].1.get("status").unwrap(), "void");
2883 assert_eq!(items[1].0, "b");
2884 assert_eq!(items[1].1.get("note").unwrap(), "late");
2885 }
2886
2887 #[test]
2888 fn batch_ids_request_parses() {
2889 let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
2890 assert_eq!(req.ids, vec!["x", "y", "z"]);
2891 }
2892
2893 fn fields(q: &[(&str, &str)]) -> Vec<String> {
2896 let map: HashMap<String, String> =
2897 q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
2898 sparse_fields(&map)
2899 }
2900
2901 #[test]
2902 fn sparse_fields_parses_comma_list_and_trims() {
2903 assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
2904 }
2905
2906 #[test]
2907 fn sparse_fields_absent_or_empty_is_no_projection() {
2908 assert!(fields(&[]).is_empty());
2909 assert!(fields(&[("fields", "")]).is_empty());
2910 assert!(fields(&[("fields", " , ")]).is_empty());
2911 }
2912
2913 #[test]
2914 fn project_keeps_requested_keys_plus_id() {
2915 let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
2916 let out = project_sparse(v, &["name".into(), "basePrice".into()]);
2917 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
2918 }
2919
2920 #[test]
2921 fn project_always_includes_id_even_if_not_requested() {
2922 let v = serde_json::json!({ "id": "1", "name": "n" });
2923 let out = project_sparse(v, &["name".into()]);
2924 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2925 }
2926
2927 #[test]
2928 fn project_ignores_unknown_keys() {
2929 let v = serde_json::json!({ "id": "1", "name": "n" });
2930 let out = project_sparse(v, &["name".into(), "nope".into()]);
2931 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
2932 }
2933
2934 #[test]
2935 fn project_empty_fields_returns_full_object() {
2936 let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
2937 let out = project_sparse(v.clone(), &[]);
2938 assert_eq!(out, v);
2939 }
2940
2941 #[test]
2942 fn project_non_object_returned_unchanged() {
2943 let v = serde_json::json!("scalar");
2944 assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
2945 }
2946
2947 fn owner_a() -> uuid::Uuid {
2952 uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
2953 }
2954 fn owner_b() -> uuid::Uuid {
2955 uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
2956 }
2957
2958 fn row() -> serde_json::Value {
2959 serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
2960 }
2961 const PRIV: &[&str] = &["hppPerUnit"];
2962
2963 struct Secretive;
2965 impl backbone_orm::EntityRepoMeta for Secretive {
2966 fn column_types() -> HashMap<String, String> {
2967 HashMap::new()
2968 }
2969 fn search_fields() -> &'static [&'static str] {
2970 &[]
2971 }
2972 fn secret_fields() -> &'static [&'static str] {
2973 &["tokenHash"]
2974 }
2975 fn private_fields() -> &'static [&'static str] {
2976 PRIV
2977 }
2978 fn owner_field() -> Option<&'static str> {
2979 Some("providerId")
2980 }
2981 fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
2982 match relation {
2983 "user" => &["passwordHash"],
2984 _ => &[],
2985 }
2986 }
2987 }
2988
2989 fn secret_row() -> serde_json::Value {
2990 let mut r = row();
2991 r["tokenHash"] = serde_json::json!("digest");
2992 r
2993 }
2994
2995 #[test]
2996 fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
2997 for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
2998 let out = secure::<Secretive>(secret_row(), scope.as_ref());
2999 assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
3000 assert!(out.get("name").is_some());
3001 }
3002 let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform));
3003 assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
3004 }
3005
3006 #[test]
3007 fn the_typed_sort_pair_reaches_the_repository_as_the_filter_sort() {
3008 let params = |pairs: &[(&str, &str)], by: Option<&str>, order: Option<&str>| ListQueryParams {
3009 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3010 sort_by: by.map(str::to_string),
3011 sort_order: order.map(str::to_string),
3012 ..Default::default()
3013 };
3014 let f = repository_filters(¶ms(&[], Some("priority"), Some("DESC")));
3015 assert_eq!(f.get("orderby").map(String::as_str), Some("priority:desc"));
3016 let f = repository_filters(¶ms(&[], Some("createdAt"), None));
3017 assert_eq!(f.get("orderby").map(String::as_str), Some("createdAt:asc"));
3018 let f = repository_filters(¶ms(&[("orderby[name]", "asc")], Some("priority"), Some("desc")));
3020 assert!(!f.contains_key("orderby"), "{f:?}");
3021 let parsed = backbone_orm::filter::parse_filters(
3023 &repository_filters(¶ms(&[], Some("name; drop table x"), None)),
3024 &HashMap::new(),
3025 None,
3026 )
3027 .unwrap();
3028 assert!(parsed.sorts.is_empty(), "{:?}", parsed.sorts);
3029 }
3030
3031 #[test]
3032 fn list_params_normalize_page_zero_and_an_oversized_limit() {
3033 let p = ListQueryParams { page: 0, limit: 1000, ..Default::default() }.normalized();
3034 assert_eq!((p.page, p.limit), (1, MAX_PER_PAGE));
3035 let p = ListQueryParams { page: 3, limit: 0, ..Default::default() }.normalized();
3036 assert_eq!((p.page, p.limit), (3, 1));
3037 }
3038
3039 #[test]
3040 fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
3041 let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
3042 let params = ListQueryParams {
3043 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3044 sort_by: sort_by.map(str::to_string),
3045 ..Default::default()
3046 };
3047 named_secret(¶ms, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
3048 };
3049 for pairs in [
3050 vec![("token_hash", "abc")],
3051 vec![("tokenHash[startwith]", "a")],
3052 vec![("orderby", "name,-token_hash")],
3053 vec![("orderby[token_hash]", "asc")],
3054 vec![("searchFields", "name,tokenHash")],
3055 vec![("min", "token_hash")],
3056 vec![("group_by", "tokenHash")],
3057 ] {
3058 assert_eq!(ask(&pairs, None).as_deref().map(camel_to_snake_case), Some("token_hash".into()), "{pairs:?}");
3059 }
3060 assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
3061 assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
3062 }
3063
3064 #[test]
3065 fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
3066 let out = secure::<Secretive>(secret_row(), None);
3067 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3068 }
3069
3070 #[test]
3071 fn a_routed_response_dto_loses_the_entitys_secrets() {
3072 let out = without_secrets::<Secretive, _>(secret_row());
3073 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3074 assert!(out.get("name").is_some());
3075 }
3076
3077 #[test]
3078 fn an_included_row_loses_the_related_models_secrets() {
3079 let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
3080 let out = related_row::<Secretive>("user", "users", raw.clone());
3081 assert!(out.get("passwordHash").is_none(), "{out}");
3082 assert_eq!(out["email"], "a@b.c");
3083 assert!(related_row::<Secretive>("other", "others", raw.clone()).get("passwordHash").is_some(), "only the named relation");
3084 backbone_orm::secret_registry::register("employee_probe.staff", &["passwordHash"]);
3086 assert!(related_row::<Secretive>("staff", "employee_probe.staff", raw).get("passwordHash").is_none(), "registered table");
3087 }
3088
3089 #[test]
3090 fn security_no_private_fields_is_noop() {
3091 let v = row();
3092 assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
3093 }
3094
3095 #[test]
3096 fn security_platform_sees_private() {
3097 let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
3098 assert!(out.get("hppPerUnit").is_some());
3099 }
3100
3101 #[test]
3102 fn security_owner_tenant_sees_private() {
3103 let out = apply_field_security(
3104 row(),
3105 Some(&AccessScope::Company(owner_a())),
3106 PRIV,
3107 Some("providerId"),
3108 );
3109 assert!(out.get("hppPerUnit").is_some());
3110 }
3111
3112 #[test]
3113 fn security_other_tenant_stripped() {
3114 let out = apply_field_security(
3115 row(),
3116 Some(&AccessScope::Company(owner_b())),
3117 PRIV,
3118 Some("providerId"),
3119 );
3120 assert!(out.get("hppPerUnit").is_none());
3121 assert!(out.get("name").is_some());
3122 }
3123
3124 #[test]
3125 fn security_absent_scope_fails_closed() {
3126 let out = apply_field_security(row(), None, PRIV, Some("providerId"));
3127 assert!(out.get("hppPerUnit").is_none());
3128 }
3129
3130 #[test]
3133 fn include_relations_parses_include_and_with() {
3134 let mut q = HashMap::new();
3135 q.insert("include".to_string(), "provider, outlet ".to_string());
3136 assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
3137 let mut q2 = HashMap::new();
3138 q2.insert("with".to_string(), "category".to_string());
3139 assert_eq!(include_relations(&q2), vec!["category"]);
3140 assert!(include_relations(&HashMap::new()).is_empty());
3141 }
3142
3143 #[test]
3144 fn snake_to_camel_converts() {
3145 assert_eq!(snake_to_camel("provider_id"), "providerId");
3146 assert_eq!(snake_to_camel("business_name"), "businessName");
3147 assert_eq!(snake_to_camel("id"), "id");
3148 }
3149
3150 #[test]
3151 fn camelize_keys_top_level_only() {
3152 let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
3153 let out = camelize_keys(v);
3154 assert!(out.get("providerId").is_some());
3155 assert!(out.get("metaData").unwrap().get("created_at").is_some());
3157 }
3158
3159 #[test]
3160 fn security_null_owner_only_platform_sees() {
3161 let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
3162 let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
3163 assert!(tenant.get("hppPerUnit").is_none());
3164 let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
3165 assert!(plat.get("hppPerUnit").is_some());
3166 }
3167
3168 #[test]
3169 fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
3170 let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
3171 let (status, axum::Json(body)) =
3172 write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
3173 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
3174 let json = serde_json::to_value(&body).unwrap();
3175 assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
3176 assert_eq!(json["violations"][0]["path"], "status");
3177 assert_eq!(json["violations"][0]["code"], "field_not_writable");
3178
3179 let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
3181 assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
3182 let json = serde_json::to_value(&body).unwrap();
3183 assert_eq!(json["error"], "boom");
3184 assert_eq!(json["violations"][0]["code"], "invalid_request");
3185 assert_eq!(json["violations"][0]["path"], "");
3186 let (_, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::INTERNAL_SERVER_ERROR);
3188 assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
3189 }
3190}
3191
3192#[cfg(test)]
3193mod internal_key_tests {
3194 use super::*;
3195
3196 fn params_with(filters: &[(&str, &str)]) -> ListQueryParams {
3197 ListQueryParams {
3198 page: 1,
3199 limit: 20,
3200 sort_by: None,
3201 sort_order: None,
3202 search: None,
3203 status: None,
3204 filters: filters
3205 .iter()
3206 .map(|(k, v)| (k.to_string(), v.to_string()))
3207 .collect(),
3208 }
3209 }
3210
3211 #[test]
3212 fn internal_keys_never_reach_the_repository() {
3213 let params = params_with(&[
3214 ("__base_condition", "EXISTS(SELECT 1)"),
3215 ("__base_condition[eq]", "1=1"),
3216 ("__anything", "x"),
3217 ("name", "acme"),
3218 ]);
3219 let filters = repository_filters(¶ms);
3220 assert_eq!(filters.len(), 1, "{filters:?}");
3221 assert_eq!(filters.get("name").map(String::as_str), Some("acme"));
3222 let filters = aggregate_filters(¶ms);
3223 assert!(filters.keys().all(|k| !k.starts_with("__")), "{filters:?}");
3224 }
3225}
3226
3227#[cfg(test)]
3228mod service_error_response_tests {
3229 use super::*;
3230 use crate::service::ServiceError;
3231
3232 #[test]
3233 fn a_refused_field_is_422_with_its_violation_never_500() {
3234 let err = ServiceError::Violations(vec![crate::violation::Violation::new(
3235 "state",
3236 "field_not_writable",
3237 "state cannot be changed by a generic write",
3238 )]);
3239 let (status, body) = service_error_response::<()>(&err);
3240 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
3241 assert_eq!(body.0.violations.as_ref().map(Vec::len), Some(1));
3242 }
3243
3244 #[test]
3245 fn other_errors_keep_their_own_status() {
3246 use axum::http::StatusCode;
3247 let status = |e: ServiceError| service_error_response::<()>(&e).0;
3248 assert_eq!(
3249 status(ServiceError::Validation("bad".into())),
3250 StatusCode::BAD_REQUEST
3251 );
3252 assert_eq!(status(ServiceError::NotFound), StatusCode::NOT_FOUND);
3253 assert_eq!(
3254 status(ServiceError::AlreadyExists("x".into())),
3255 StatusCode::CONFLICT
3256 );
3257 assert_eq!(
3258 status(ServiceError::Internal("boom".into())),
3259 StatusCode::INTERNAL_SERVER_ERROR
3260 );
3261 }
3262}