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 {
193 use crate::persistence::traits::RepositoryError;
194 use crate::service::ServiceError;
195 use axum::http::StatusCode;
196 match err {
197 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v))
199 if is_query_refusal(v) =>
200 {
201 StatusCode::BAD_REQUEST
202 }
203 ServiceError::Violations(_) | ServiceError::Repository(RepositoryError::Violations(_)) => {
204 StatusCode::UNPROCESSABLE_ENTITY
205 }
206 ServiceError::Validation(_)
207 | ServiceError::Repository(RepositoryError::ValidationError(_)) => StatusCode::BAD_REQUEST,
208 ServiceError::NotFound | ServiceError::Repository(RepositoryError::NotFound) => {
209 StatusCode::NOT_FOUND
210 }
211 ServiceError::AlreadyExists(_)
212 | ServiceError::Repository(RepositoryError::AlreadyExists(_))
213 | ServiceError::Repository(RepositoryError::Conflict(_)) => StatusCode::CONFLICT,
214 _ => StatusCode::INTERNAL_SERVER_ERROR,
215 }
216}
217
218pub fn service_error_response<T>(
223 err: &crate::service::ServiceError,
224) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
225 use crate::persistence::traits::RepositoryError;
226 use crate::service::ServiceError;
227 let violations = match err {
228 ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => {
229 Some(v.clone())
230 }
231 _ => None,
232 };
233 error_at(service_error_status(err), violations, err)
234}
235
236#[cfg(test)]
239fn write_error<T>(
240 violations: Option<Vec<crate::violation::Violation>>,
241 err: &impl std::fmt::Display,
242 fallback: axum::http::StatusCode,
243) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
244 let status = if violations.is_some() { axum::http::StatusCode::UNPROCESSABLE_ENTITY } else { fallback };
245 error_at(status, violations, err)
246}
247
248fn invalid_id<T>(id: &str, path: &str) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
252 if uuid::Uuid::parse_str(id).is_ok() {
253 return None;
254 }
255 let violation = crate::violation::Violation::new(
256 path,
257 "invalid_id",
258 format!("`{id}` is not a valid record id"),
259 );
260 Some((
261 axum::http::StatusCode::BAD_REQUEST,
262 axum::Json(ApiResponse::rejected(format!("invalid id: `{id}` is not a valid record id"), vec![violation])),
263 ))
264}
265
266fn invalid_ids<'a, T>(
269 ids: impl IntoIterator<Item = &'a str>,
270 path_of: impl Fn(usize) -> String,
271) -> Option<(axum::http::StatusCode, axum::Json<ApiResponse<T>>)> {
272 let violations: Vec<crate::violation::Violation> = ids
273 .into_iter()
274 .enumerate()
275 .filter(|(_, id)| uuid::Uuid::parse_str(id).is_err())
276 .map(|(i, id)| {
277 crate::violation::Violation::new(path_of(i), "invalid_id", format!("`{id}` is not a valid record id"))
278 })
279 .collect();
280 if violations.is_empty() {
281 return None;
282 }
283 Some((
284 axum::http::StatusCode::BAD_REQUEST,
285 axum::Json(ApiResponse::rejected("invalid id in the batch", violations)),
286 ))
287}
288
289#[derive(Debug, Deserialize, Default, Clone)]
291#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
292pub struct ListQueryParams {
293 #[serde(default = "default_page")]
294 pub page: u32,
295 #[serde(default = "default_limit")]
296 pub limit: u32,
297 #[serde(default)]
298 pub sort_by: Option<String>,
299 #[serde(default)]
300 pub sort_order: Option<String>,
301 #[serde(default)]
302 pub search: Option<String>,
303 #[serde(default)]
304 pub status: Option<String>,
305 #[serde(flatten)]
306 pub filters: HashMap<String, String>,
307}
308
309fn default_page() -> u32 { 1 }
310fn default_limit() -> u32 { 20 }
311
312impl ListQueryParams {
313 pub fn normalized(mut self) -> Self {
317 let page = backbone_orm::repository::PaginationParams::new(self.page, self.limit);
318 self.page = page.page;
319 self.limit = page.per_page;
320 self
321 }
322}
323
324pub struct ListQuery(pub ListQueryParams);
329
330#[axum::async_trait]
331impl<S: Send + Sync> axum::extract::FromRequestParts<S> for ListQuery {
332 type Rejection = axum::response::Response;
333
334 async fn from_request_parts(
335 parts: &mut axum::http::request::Parts,
336 _state: &S,
337 ) -> Result<Self, Self::Rejection> {
338 use axum::response::IntoResponse;
339 let raw = parts.uri.query().unwrap_or("");
340 match serde_urlencoded::from_str::<ListQueryParams>(raw) {
341 Ok(params) => Ok(ListQuery(params)),
342 Err(e) => {
343 let violation = list_query_violation(raw, &e.to_string());
344 Err((
345 axum::http::StatusCode::BAD_REQUEST,
346 axum::Json(PaginatedApiResponse::<serde_json::Value>::rejected(
347 violation.message.clone(),
348 vec![violation],
349 )),
350 )
351 .into_response())
352 }
353 }
354 }
355}
356
357fn list_query_violation(raw: &str, decode_error: &str) -> crate::violation::Violation {
360 use backbone_orm::QueryParamReason;
361 let pairs: Vec<(String, String)> = serde_urlencoded::from_str(raw).unwrap_or_default();
362 for key in ["page", "limit"] {
363 for (k, v) in pairs.iter().filter(|(k, _)| k == key) {
364 if v.trim().parse::<u32>().is_err() {
365 let e = backbone_orm::QueryParamError::not_a_number(k, v);
366 return query_violation(&e.parameter, e.reason, e.message);
367 }
368 }
369 }
370 for key in ["page", "limit", "sort_by", "sort_order", "search", "status"] {
371 if pairs.iter().filter(|(k, _)| k == key).count() > 1 {
372 return query_violation(
373 key,
374 QueryParamReason::InvalidValue,
375 format!("`{key}` was sent more than once; send it once"),
376 );
377 }
378 }
379 tracing::debug!(target: "backbone::http", error = decode_error, "list query refused");
380 crate::violation::Violation::new("", "invalid_query", "The query string could not be read")
381}
382
383fn query_violation(
386 parameter: &str,
387 reason: backbone_orm::QueryParamReason,
388 message: impl Into<String>,
389) -> crate::violation::Violation {
390 crate::violation::Violation::invalid_query(parameter, reason.as_str(), message)
391}
392
393fn is_query_refusal(violations: &[crate::violation::Violation]) -> bool {
395 !violations.is_empty() && violations.iter().all(|v| v.code == crate::violation::INVALID_QUERY)
396}
397
398fn refusal_sentence(violations: &[crate::violation::Violation]) -> String {
400 violations.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; ")
401}
402
403fn typed_sort_applies(params: &ListQueryParams) -> bool {
406 let sorted = params.filters.keys().any(|k| {
407 let base = k.split('[').next().unwrap_or(k);
408 base.eq_ignore_ascii_case("orderby") || base.eq_ignore_ascii_case("sort")
409 });
410 !sorted && params.sort_by.as_deref().map(str::trim).is_some_and(|f| !f.is_empty())
411}
412
413fn rename_typed_sort(
416 mut violations: Vec<crate::violation::Violation>,
417 params: &ListQueryParams,
418) -> Vec<crate::violation::Violation> {
419 if !typed_sort_applies(params) {
420 return violations;
421 }
422 for v in &mut violations {
423 if v.path == "orderby" {
424 v.path = "sort_by".to_string();
425 v.message = v.message.replace("`orderby`", "`sort_by`");
426 if v.params.contains_key("parameter") {
427 v.params.insert("parameter".into(), "sort_by".into());
428 }
429 }
430 }
431 violations
432}
433
434const RESERVED_QUERY_KEYS: [&str; 3] = ["fields", "include", "with"];
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct HistoryEntry {
452 pub occurred_at: String,
453 pub action: String,
455 pub actor: String,
456 pub changed: serde_json::Value,
457 pub reason: Option<String>,
458 pub correlation_id: Option<String>,
459}
460
461#[async_trait::async_trait]
468pub trait HistoryProvider: Send + Sync {
469 async fn history(
477 &self,
478 table: &str,
479 id: &str,
480 limit: u32,
481 offset: u32,
482 ) -> Result<Option<Vec<HistoryEntry>>, String>;
483}
484
485const AGGREGATE_QUERY_KEYS: [&str; 7] =
491 ["group_by", "sum", "avg", "min", "max", "group_limit", "group_label"];
492
493fn aggregate_spec(params: &ListQueryParams) -> backbone_orm::repository::AggregateSpec {
499 use backbone_orm::repository::{AggregateFn, AggregateSpec};
500
501 let mut reductions = Vec::new();
502 for (key, func) in [
503 ("sum", AggregateFn::Sum),
504 ("avg", AggregateFn::Avg),
505 ("min", AggregateFn::Min),
506 ("max", AggregateFn::Max),
507 ] {
508 if let Some(raw) = params.filters.get(key) {
509 for field in raw.split(',').map(str::trim).filter(|f| !f.is_empty()) {
510 reductions.push((func, field.to_string()));
511 }
512 }
513 }
514
515 AggregateSpec {
516 group_by: params
517 .filters
518 .get("group_by")
519 .map(|g| g.trim().to_string())
520 .filter(|g| !g.is_empty()),
521 reductions,
522 group_limit: params
523 .filters
524 .get("group_limit")
525 .and_then(|l| l.trim().parse::<usize>().ok())
526 .unwrap_or(0),
527 label_field: params
528 .filters
529 .get("group_label")
530 .map(|l| l.trim().to_string())
531 .filter(|l| !l.is_empty()),
532 label_relation: None,
533 }
534}
535
536fn named_secret_at(params: &ListQueryParams, secret_fields: &[&str]) -> Option<(String, String)> {
544 if secret_fields.is_empty() {
545 return None;
546 }
547 let secrets: Vec<String> = secret_fields.iter().map(|f| camel_to_snake_case(f)).collect();
548 const COLUMN_LISTS: [&str; 10] = [
549 "orderby", "sort", "searchfields", "search_fields", "group_by", "sum", "avg", "min", "max", "group_label",
550 ];
551 let mut names: Vec<(String, String)> = Vec::new();
553 let mut keys: Vec<&String> = params.filters.keys().collect();
554 keys.sort();
555 for key in keys {
556 let value = ¶ms.filters[key];
557 match key.find('[') {
558 Some(open) => {
559 names.push((key.clone(), key[..open].to_string()));
560 names.push((key.clone(), key[open + 1..].trim_end_matches(']').to_string()));
561 }
562 None => names.push((key.clone(), key.clone())),
563 }
564 if COLUMN_LISTS.contains(&key.to_ascii_lowercase().as_str()) {
565 names.extend(
566 value
567 .split(',')
568 .map(|v| (key.clone(), v.trim().trim_start_matches('-').to_string())),
569 );
570 }
571 }
572 names.extend(params.sort_by.iter().map(|n| ("sort_by".to_string(), n.clone())));
573 names
574 .into_iter()
575 .find(|(_, n)| secrets.contains(&camel_to_snake_case(n.trim())))
576}
577
578fn secret_named_message(field: &str) -> String {
580 format!("'{field}' is a secret field: it cannot be filtered, sorted, searched or aggregated")
581}
582
583fn secret_violation(parameter: &str, field: &str) -> crate::violation::Violation {
585 query_violation(
586 parameter,
587 backbone_orm::QueryParamReason::InvalidValue,
588 format!("`{parameter}`: {}", secret_named_message(field)),
589 )
590 .with_param("field", field)
591}
592
593fn query_refusals<E: backbone_orm::EntityRepoMeta>(
599 params: &ListQueryParams,
600 paged: bool,
601) -> Vec<crate::violation::Violation> {
602 use backbone_orm::QueryParamReason;
603 let mut refused = Vec::new();
604 if paged {
605 if let Some(message) = pagination_depth_error(params.page, params.limit) {
606 refused.push(query_violation("page", QueryParamReason::TooDeep, format!("`page`: {message}")));
607 }
608 }
609 if let Some(order) = params.sort_order.as_deref().map(str::trim).filter(|o| !o.is_empty()) {
610 if !order.eq_ignore_ascii_case("asc") && !order.eq_ignore_ascii_case("desc") {
611 refused.push(query_violation(
612 "sort_order",
613 QueryParamReason::InvalidValue,
614 format!("`sort_order` must be `asc` or `desc`, not `{}`", clip_value(order)),
615 ));
616 }
617 }
618 if let Some((parameter, field)) = named_secret_at(params, E::secret_fields()) {
619 refused.push(secret_violation(¶meter, &field));
620 }
621 refused.extend(shaping_refusals::<E>(¶ms.filters));
622 refused
623}
624
625fn clip_value(value: &str) -> String {
627 if value.chars().count() <= 40 {
628 value.to_string()
629 } else {
630 format!("{}…", value.chars().take(40).collect::<String>())
631 }
632}
633
634fn shaping_refusals<E: backbone_orm::EntityRepoMeta>(
640 query: &HashMap<String, String>,
641) -> Vec<crate::violation::Violation> {
642 use backbone_orm::QueryParamReason;
643 let mut refused = Vec::new();
644 for name in sparse_fields(query) {
645 if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
646 refused.push(query_violation(
647 "fields",
648 QueryParamReason::InvalidFieldName,
649 format!("`fields`: `{}` is not a field name", clip_value(&name)),
650 ));
651 }
652 }
653 let key = if query.contains_key("include") { "include" } else { "with" };
654 for name in include_relations(query) {
655 if !E::relations().iter().any(|(rel, _, _)| *rel == name) {
656 refused.push(query_violation(
657 key,
658 QueryParamReason::UnknownRelation,
659 format!("`{key}`: `{}` is not a relation of this resource", clip_value(&name)),
660 ));
661 }
662 }
663 refused
664}
665
666fn repository_filters(params: &ListQueryParams) -> HashMap<String, String> {
678 let mut filters = params.filters.clone();
679 for key in RESERVED_QUERY_KEYS {
680 filters.remove(key);
681 }
682 filters.retain(|key, _| !backbone_orm::is_internal_key(key));
685 if let Some(search) = params.search.clone() {
686 filters.insert("search".to_string(), search);
687 }
688 if let Some(status) = params.status.clone() {
689 filters.insert("status".to_string(), status);
690 }
691 let sorted = filters.keys().any(|k| {
695 let base = k.split('[').next().unwrap_or(k);
696 base.eq_ignore_ascii_case("orderby") || base.eq_ignore_ascii_case("sort")
697 });
698 if let Some(field) = params.sort_by.as_deref().map(str::trim).filter(|f| !f.is_empty()) {
699 if !sorted {
700 let dir = match params.sort_order.as_deref().map(str::trim) {
701 Some(d) if d.eq_ignore_ascii_case("desc") => "desc",
702 _ => "asc",
703 };
704 filters.insert("orderby".to_string(), format!("{field}:{dir}"));
705 }
706 }
707 filters
708}
709
710fn aggregate_filters(params: &ListQueryParams) -> HashMap<String, String> {
713 let mut filters = repository_filters(params);
714 for key in AGGREGATE_QUERY_KEYS {
715 filters.remove(key);
716 }
717 filters
718}
719
720fn sparse_fields(query: &HashMap<String, String>) -> Vec<String> {
721 query
722 .get("fields")
723 .map(|s| {
724 s.split(',')
725 .map(str::trim)
726 .filter(|f| !f.is_empty())
727 .map(str::to_string)
728 .collect()
729 })
730 .unwrap_or_default()
731}
732
733fn to_response_value<R: Serialize>(r: R) -> serde_json::Value {
736 serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
737}
738
739fn project_sparse(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
743 if fields.is_empty() {
744 return value;
745 }
746 if let serde_json::Value::Object(map) = &mut value {
747 map.retain(|k, _| k == "id" || fields.iter().any(|f| f == k));
748 }
749 value
750}
751
752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
775pub enum AccessScope {
776 Platform,
778 Company(uuid::Uuid),
780}
781
782impl AccessScope {
783 pub fn company(&self) -> Option<uuid::Uuid> {
789 match self {
790 AccessScope::Platform => None,
791 AccessScope::Company(id) => Some(*id),
792 }
793 }
794}
795
796fn strip_secrets(mut value: serde_json::Value, secret_fields: &[&str]) -> serde_json::Value {
806 if let serde_json::Value::Object(map) = &mut value {
807 for f in secret_fields {
808 map.remove(*f);
809 }
810 }
811 value
812}
813
814fn secure<E: backbone_orm::EntityRepoMeta>(
818 value: serde_json::Value,
819 scope: Option<&AccessScope>,
820) -> serde_json::Value {
821 apply_field_security(
822 strip_secrets(value, E::secret_fields()),
823 scope,
824 E::private_fields(),
825 E::owner_field(),
826 )
827}
828
829pub fn without_secrets<E: backbone_orm::EntityRepoMeta, R: Serialize>(response: R) -> serde_json::Value {
833 secure::<E>(to_response_value(response), None)
834}
835
836fn related_row<E: backbone_orm::EntityRepoMeta>(
840 relation: &str,
841 table: &str,
842 obj: serde_json::Value,
843) -> serde_json::Value {
844 let row = strip_secrets(camelize_keys(obj), E::relation_secret_fields(relation));
845 strip_secrets(row, &backbone_orm::secret_registry::secret_fields_of(table))
847}
848
849fn apply_field_security(
850 mut value: serde_json::Value,
851 scope: Option<&AccessScope>,
852 private_fields: &[&str],
853 owner_field: Option<&str>,
854) -> serde_json::Value {
855 if private_fields.is_empty() {
856 return value;
857 }
858 let can_see_private = match scope {
859 Some(AccessScope::Platform) => true,
860 Some(AccessScope::Company(id)) => owner_field
861 .and_then(|f| value.get(f))
862 .and_then(|v| v.as_str())
863 .and_then(|owner| uuid::Uuid::parse_str(owner).ok())
867 .is_some_and(|owner| owner == *id),
868 None => false,
869 };
870 if !can_see_private {
871 if let serde_json::Value::Object(map) = &mut value {
872 for f in private_fields {
873 map.remove(*f);
874 }
875 }
876 }
877 value
878}
879
880fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
882 query
883 .get("include")
884 .or_else(|| query.get("with"))
885 .map(|s| {
886 s.split(',')
887 .map(str::trim)
888 .filter(|s| !s.is_empty())
889 .map(str::to_string)
890 .collect()
891 })
892 .unwrap_or_default()
893}
894
895fn snake_to_camel(s: &str) -> String {
896 let mut out = String::with_capacity(s.len());
897 let mut upper = false;
898 for c in s.chars() {
899 if c == '_' {
900 upper = true;
901 } else if upper {
902 out.extend(c.to_uppercase());
903 upper = false;
904 } else {
905 out.push(c);
906 }
907 }
908 out
909}
910
911fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
914 match v {
915 serde_json::Value::Object(m) => serde_json::Value::Object(
916 m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
917 ),
918 other => other,
919 }
920}
921
922async fn expand_includes<S, E, C, U>(
932 service: &S,
933 rows: &mut [serde_json::Value],
934 includes: &[String],
935) where
936 S: CrudService<E, C, U>,
937 E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
938 C: Send + Sync + 'static,
939 U: Send + Sync + 'static,
940{
941 if includes.is_empty() || rows.is_empty() {
942 return;
943 }
944 for (rel_name, table, fk_field) in E::relations() {
945 if !includes.iter().any(|i| i == rel_name) {
946 continue;
947 }
948 let mut ids: Vec<String> = rows
949 .iter()
950 .filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
951 .collect();
952 ids.sort();
953 ids.dedup();
954 if ids.is_empty() {
955 continue;
956 }
957 let related = service.fetch_related_json(table, &ids).await;
958 let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
959 for obj in related {
960 if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
961 by_id.insert(id, related_row::<E>(rel_name, table, obj));
962 }
963 }
964 for r in rows.iter_mut() {
965 let related_obj = r
966 .get(fk_field)
967 .and_then(|v| v.as_str())
968 .and_then(|id| by_id.get(id).cloned())
969 .unwrap_or(serde_json::Value::Null);
970 if let serde_json::Value::Object(m) = r {
971 m.insert((*rel_name).to_string(), related_obj);
972 }
973 }
974 }
975}
976
977pub const MAX_PER_PAGE: u32 = 100;
981
982pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
987
988fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
994 let effective_limit = limit.clamp(1, MAX_PER_PAGE);
995 let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
996 if offset > MAX_PAGINATION_OFFSET {
997 Some(format!(
998 "Result set too deep: offset {offset} exceeds the maximum of \
999 {MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
1000 ))
1001 } else {
1002 None
1003 }
1004}
1005
1006pub use crate::service::MAX_BATCH_SIZE;
1012
1013fn batch_size_error(count: usize) -> Option<String> {
1016 if count > MAX_BATCH_SIZE {
1017 Some(format!(
1018 "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
1019 ))
1020 } else {
1021 None
1022 }
1023}
1024
1025fn is_bad_query_error(msg: &str) -> bool {
1034 let m = msg.to_lowercase();
1035 m.contains("does not exist")
1036 || m.contains("invalid input syntax")
1037 || m.contains("42703")
1038 || m.contains("not a column of this entity")
1042 || m.contains("needs a numeric column")
1043}
1044
1045fn refused_list(violations: Vec<crate::violation::Violation>) -> PaginatedApiResponse<serde_json::Value> {
1048 PaginatedApiResponse::rejected(refusal_sentence(&violations), violations)
1049}
1050
1051#[derive(Debug, Serialize, Deserialize, Clone)]
1053#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1054pub struct PaginationResponse {
1055 pub total: u64,
1056 pub page: u32,
1057 pub limit: u32,
1058 pub total_pages: u32,
1059 #[serde(skip_serializing_if = "Option::is_none")]
1063 pub next_cursor: Option<String>,
1064 #[serde(skip_serializing_if = "Option::is_none")]
1066 pub prev_cursor: Option<String>,
1067 #[serde(skip_serializing_if = "Option::is_none")]
1069 pub has_more: Option<bool>,
1070}
1071
1072impl PaginationResponse {
1073 pub fn new(total: u64, page: u32, limit: u32) -> Self {
1074 let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
1075 Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
1076 }
1077
1078 pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
1082 Self {
1083 total: info.total,
1084 page: info.page,
1085 limit: info.per_page,
1086 total_pages: info.total_pages,
1087 next_cursor: info.next_cursor.clone(),
1088 prev_cursor: info.prev_cursor.clone(),
1089 has_more: info.has_more,
1090 }
1091 }
1092}
1093
1094#[derive(Debug, Serialize)]
1096#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1097pub struct PaginatedResponse<T> {
1098 pub data: Vec<T>,
1099 pub meta: PaginationResponse,
1100}
1101
1102#[derive(Debug, Serialize)]
1105#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1106pub struct PaginatedApiResponse<T> {
1107 pub success: bool,
1108 pub data: Vec<T>,
1109 pub meta: PaginationResponse,
1110 #[serde(skip_serializing_if = "Option::is_none")]
1111 pub error: Option<String>,
1112 #[serde(default, skip_serializing_if = "Option::is_none")]
1114 pub violations: Option<Vec<crate::violation::Violation>>,
1115}
1116
1117impl<T> PaginatedApiResponse<T> {
1118 pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
1121 Self {
1122 success: true,
1123 data,
1124 meta: PaginationResponse::from_info(info),
1125 error: None,
1126 violations: None,
1127 }
1128 }
1129
1130 pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
1132 Self {
1133 success: true,
1134 data,
1135 meta: PaginationResponse::new(total, page, limit),
1136 error: None,
1137 violations: None,
1138 }
1139 }
1140
1141 pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
1143 Self {
1144 success: true,
1145 data: resp.data,
1146 meta: resp.meta,
1147 error: None,
1148 violations: None,
1149 }
1150 }
1151
1152 pub fn error(error: impl Into<String>) -> Self {
1154 Self {
1155 success: false,
1156 data: Vec::new(),
1157 meta: PaginationResponse::new(0, 0, 0),
1158 error: Some(error.into()),
1159 violations: None,
1160 }
1161 }
1162
1163 pub fn rejected(error: impl Into<String>, violations: Vec<crate::violation::Violation>) -> Self {
1165 Self {
1166 violations: (!violations.is_empty()).then_some(violations),
1167 ..Self::error(error)
1168 }
1169 }
1170}
1171
1172#[derive(Debug, Serialize, Deserialize)]
1174#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1175pub struct BulkCreateRequest<T> {
1176 pub items: Vec<T>,
1177}
1178
1179#[derive(Debug, Serialize, Deserialize)]
1181#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1182pub struct BulkResponse<T> {
1183 pub items: Vec<T>,
1184 pub total: usize,
1185 pub failed: usize,
1186 pub errors: Vec<String>,
1187}
1188
1189#[derive(Debug, Serialize, Deserialize)]
1191#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1192pub struct UpsertRequest<T> {
1193 pub entity: T,
1194 pub create_if_not_exists: bool,
1195}
1196
1197#[derive(Debug, Serialize, Deserialize)]
1200#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1201pub struct BatchIdsRequest {
1202 pub ids: Vec<String>,
1203}
1204
1205#[derive(Debug, Deserialize)]
1208#[serde(bound = "U: DeserializeOwned")]
1209#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1210pub struct BulkUpdateItem<U> {
1211 pub id: String,
1212 #[serde(flatten)]
1216 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1217 pub data: U,
1218}
1219
1220#[derive(Debug, Deserialize)]
1222#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1223pub struct BulkPatchItem {
1224 pub id: String,
1225 pub patch: HashMap<String, serde_json::Value>,
1226}
1227
1228#[derive(Debug, Deserialize)]
1231#[serde(untagged)]
1232#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1233pub enum BulkPatchRequest {
1234 Shared {
1236 ids: Vec<String>,
1237 patch: HashMap<String, serde_json::Value>,
1238 },
1239 PerItem { items: Vec<BulkPatchItem> },
1241}
1242
1243impl BulkPatchRequest {
1244 fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
1246 match self {
1247 BulkPatchRequest::Shared { ids, patch } => {
1248 ids.into_iter().map(|id| (id, patch.clone())).collect()
1249 }
1250 BulkPatchRequest::PerItem { items } => {
1251 items.into_iter().map(|it| (it.id, it.patch)).collect()
1252 }
1253 }
1254 }
1255}
1256
1257#[derive(Debug, Default, Serialize)]
1259#[serde(rename_all = "camelCase")]
1260#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1261pub struct BulkPreview {
1262 pub will_change: Vec<PreviewChange>,
1264 pub unchanged: Vec<String>,
1266 pub refused: Vec<PreviewRefusal>,
1268 pub total: usize,
1270}
1271
1272#[derive(Debug, Serialize)]
1274#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1275pub struct PreviewChange {
1276 pub id: String,
1278 pub changes: Vec<FieldChange>,
1280}
1281
1282#[derive(Debug, Serialize)]
1284#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1285pub struct FieldChange {
1286 pub field: String,
1288 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1290 pub from: serde_json::Value,
1291 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
1293 pub to: serde_json::Value,
1294}
1295
1296#[derive(Debug, Serialize)]
1298#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1299pub struct PreviewRefusal {
1300 pub id: String,
1302 pub code: String,
1304 pub message: String,
1306}
1307
1308type PreviewOutcome = Result<(serde_json::Value, serde_json::Value), (String, String)>;
1310
1311fn preview_answer(
1318 rows: Vec<(String, PreviewOutcome)>,
1319 patched: &HashMap<String, Vec<String>>,
1320) -> BulkPreview {
1321 let mut out = BulkPreview::default();
1322 for (id, outcome) in rows {
1323 match outcome {
1324 Ok((before, after)) => {
1325 let mut keys = patched.get(&id).cloned().unwrap_or_default();
1326 keys.sort();
1327 let changes: Vec<FieldChange> = keys
1328 .into_iter()
1329 .filter_map(|field| {
1330 let read = |v: &serde_json::Value| {
1331 v.get(snake_to_camel(&field))
1332 .or_else(|| v.get(&field))
1333 .cloned()
1334 .unwrap_or(serde_json::Value::Null)
1335 };
1336 let (from, to) = (read(&before), read(&after));
1337 (from != to).then_some(FieldChange { field, from, to })
1338 })
1339 .collect();
1340 if changes.is_empty() {
1341 out.unchanged.push(id);
1342 } else {
1343 out.will_change.push(PreviewChange { id, changes });
1344 }
1345 }
1346 Err((code, message)) => out.refused.push(PreviewRefusal { id, code, message }),
1347 }
1348 }
1349 out.total = out.will_change.len() + out.unchanged.len() + out.refused.len();
1350 out
1351}
1352
1353#[derive(Debug, Serialize, Deserialize)]
1355#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1356pub struct FilterOptions {
1357 pub filters: HashMap<String, String>,
1358 pub sort_by: Option<String>,
1359 pub sort_order: Option<SortOrder>,
1360}
1361
1362#[derive(Debug, Serialize, Deserialize, Clone, Default)]
1364#[serde(rename_all = "lowercase")]
1365#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1366pub enum SortOrder {
1367 #[default]
1368 Asc,
1369 Desc,
1370}
1371
1372#[derive(Debug, Serialize, Deserialize)]
1374#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1375pub struct ListRequest {
1376 pub page: Option<u32>,
1377 pub limit: Option<u32>,
1378 pub sort_by: Option<String>,
1379 pub sort_order: Option<SortOrder>,
1380 pub filters: Option<HashMap<String, String>>,
1381}
1382
1383impl Default for ListRequest {
1384 fn default() -> Self {
1385 Self {
1386 page: Some(1),
1387 limit: Some(20),
1388 sort_by: None,
1389 sort_order: None,
1390 filters: None,
1391 }
1392 }
1393}
1394
1395#[async_trait::async_trait]
1419pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
1420where
1421 Entity: Send + Sync + 'static,
1424 CreateDto: Send + Sync + 'static,
1425 UpdateDto: Send + Sync + 'static,
1426{
1427 type Error: std::error::Error + Send + Sync;
1429
1430 fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
1433 let _ = err;
1434 None
1435 }
1436
1437 fn status_of(err: &Self::Error) -> Option<axum::http::StatusCode> {
1442 let _ = err;
1443 None
1444 }
1445
1446 fn supports_upsert() -> bool {
1450 true
1451 }
1452
1453 fn entity_name() -> &'static str;
1455
1456 async fn fetch_related_json(
1459 &self,
1460 _table: &str,
1461 _ids: &[String],
1462 ) -> Vec<serde_json::Value> {
1463 Vec::new()
1464 }
1465
1466 async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1468
1469 async fn list_with_info(
1473 &self,
1474 page: u32,
1475 limit: u32,
1476 filters: HashMap<String, String>,
1477 ) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
1478 let (rows, total) = self.list(page, limit, filters).await?;
1479 Ok((
1480 rows,
1481 backbone_orm::repository::PaginationInfo::new(page, limit, total),
1482 ))
1483 }
1484
1485 async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1487
1488 async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1490
1491 async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
1493
1494 async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
1496
1497 async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
1499
1500 async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
1502
1503 async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
1505
1506 async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
1508
1509 async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1511
1512 async fn empty_trash(&self) -> Result<u64, Self::Error>;
1514
1515 async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
1517
1518 async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
1520
1521 async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
1523
1524 async fn count_active(&self) -> Result<u64, Self::Error>;
1526
1527 fn table_name(&self) -> Option<&str> {
1534 None
1535 }
1536
1537 async fn aggregate(
1542 &self,
1543 spec: &backbone_orm::repository::AggregateSpec,
1544 filters: HashMap<String, String>,
1545 ) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
1546
1547 async fn count_active_filtered(
1560 &self,
1561 filters: HashMap<String, String>,
1562 ) -> Result<u64, Self::Error> {
1563 self.list(1, 1, filters).await.map(|(_, total)| total)
1564 }
1565
1566 async fn count_deleted(&self) -> Result<u64, Self::Error>;
1568
1569 async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1578 let mut n = 0;
1579 for id in ids {
1580 if self.soft_delete(&id).await? {
1581 n += 1;
1582 }
1583 }
1584 Ok(n)
1585 }
1586
1587 async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
1589 let mut out = Vec::with_capacity(ids.len());
1590 for id in ids {
1591 if let Some(e) = self.restore(&id).await? {
1592 out.push(e);
1593 }
1594 }
1595 Ok(out)
1596 }
1597
1598 async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
1600 let mut n = 0;
1601 for id in ids {
1602 if self.permanent_delete(&id).await? {
1603 n += 1;
1604 }
1605 }
1606 Ok(n)
1607 }
1608
1609 async fn restore_all(&self) -> Result<u64, Self::Error> {
1614 Ok(0)
1615 }
1616
1617 async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
1619 let mut out = Vec::with_capacity(items.len());
1620 for (id, dto) in items {
1621 if let Some(e) = self.update(&id, dto).await? {
1622 out.push(e);
1623 }
1624 }
1625 Ok(out)
1626 }
1627
1628 async fn bulk_partial_update(
1630 &self,
1631 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1632 ) -> Result<Vec<Entity>, Self::Error> {
1633 let mut out = Vec::with_capacity(items.len());
1634 for (id, fields) in items {
1635 if let Some(e) = self.partial_update(&id, fields).await? {
1636 out.push(e);
1637 }
1638 }
1639 Ok(out)
1640 }
1641
1642 async fn preview_bulk_partial_update(
1647 &self,
1648 items: Vec<(String, HashMap<String, serde_json::Value>)>,
1649 ) -> Result<Option<Vec<BulkPreviewRow<Entity, Self::Error>>>, Self::Error> {
1650 let _ = items;
1651 Ok(None)
1652 }
1653}
1654
1655pub struct BulkPreviewRow<Entity, Err> {
1658 pub id: String,
1660 pub outcome: Result<(Entity, Entity), Err>,
1662}
1663
1664pub struct BackboneCrudHandler<S, E, C, U, R>
1687where
1688 S: CrudService<E, C, U> + 'static,
1689 E: Serialize + Send + Sync + 'static,
1690 C: DeserializeOwned + Send + Sync + 'static,
1691 U: DeserializeOwned + Send + Sync + 'static,
1692 R: From<E> + Serialize + Send + Sync + 'static,
1693{
1694 service: Arc<S>,
1695 _phantom: std::marker::PhantomData<(E, C, U, R)>,
1696}
1697
1698impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
1699where
1700 S: CrudService<E, C, U> + 'static,
1701 E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
1702 C: DeserializeOwned + Send + Sync + 'static,
1703 U: DeserializeOwned + Send + Sync + 'static,
1704 R: From<E> + Serialize + Send + Sync + 'static,
1705{
1706 pub fn new(service: Arc<S>) -> Self {
1707 Self {
1708 service,
1709 _phantom: std::marker::PhantomData,
1710 }
1711 }
1712
1713 pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1741 where
1742 S: Clone,
1743 {
1744 Self::read_routes(service.clone(), base_path)
1745 .merge(Self::write_routes(service, base_path))
1746 }
1747
1748 pub fn routes_guarded(
1751 service: Arc<S>,
1752 base_path: &str,
1753 permissions: crate::permission::CrudPermissions,
1754 ) -> axum::Router<()>
1755 where
1756 S: Clone,
1757 {
1758 Self::read_routes_guarded(service.clone(), base_path, permissions.clone())
1759 .merge(Self::write_routes_guarded(service, base_path, permissions))
1760 }
1761
1762 fn gate(
1764 perms: &Option<crate::permission::CrudPermissions>,
1765 gate: crate::permission::Gate,
1766 route: axum::routing::MethodRouter<()>,
1767 ) -> axum::routing::MethodRouter<()> {
1768 match perms {
1769 Some(p) if p.is_public(gate) => route,
1772 Some(p) => crate::permission::guarded_needs(route, p.needs(gate)),
1773 None => route,
1774 }
1775 }
1776
1777 pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1791 where
1792 S: Clone,
1793 {
1794 Self::read_routes_with(service, base_path, None)
1795 }
1796
1797 pub fn read_routes_guarded(
1804 service: Arc<S>,
1805 base_path: &str,
1806 permissions: crate::permission::CrudPermissions,
1807 ) -> axum::Router<()>
1808 where
1809 S: Clone,
1810 {
1811 Self::read_routes_with(service, base_path, Some(permissions))
1812 }
1813
1814 fn read_routes_with(
1815 service: Arc<S>,
1816 base_path: &str,
1817 perms: Option<crate::permission::CrudPermissions>,
1818 ) -> axum::Router<()>
1819 where
1820 S: Clone,
1821 {
1822 use crate::permission::Gate;
1823 if let Some(table) = service.table_name() {
1826 backbone_orm::secret_registry::register(table, E::secret_fields());
1827 }
1828 use axum::{
1829 extract::Path,
1830 routing::get,
1831 Extension, Router,
1832 };
1833
1834 let handler = Arc::new(Self::new(service));
1835
1836 Router::new()
1837 .route(base_path, Self::gate(&perms, Gate::Read, get({
1839 let h = handler.clone();
1840 move |query: ListQuery, access: Option<Extension<AccessScope>>| async move {
1841 Self::list_handler(h, query, access).await
1842 }
1843 })))
1844 .route(&format!("{}/trash", base_path), Self::gate(&perms, Gate::Read, get({
1846 let h = handler.clone();
1847 move |query: ListQuery, access: Option<Extension<AccessScope>>| async move {
1848 Self::list_deleted_handler(h, query, access).await
1849 }
1850 })))
1851 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Read, get({
1853 let h = handler.clone();
1854 move |path: Path<String>, query: ListQuery, access: Option<Extension<AccessScope>>| async move {
1855 Self::get_handler(h, path, query, access).await
1856 }
1857 })))
1858 .route(&format!("{}/:id/deleted", base_path), Self::gate(&perms, Gate::Read, get({
1860 let h = handler.clone();
1861 move |path: Path<String>, query: ListQuery, access: Option<Extension<AccessScope>>| async move {
1862 Self::get_deleted_handler(h, path, query, access).await
1863 }
1864 })))
1865 .route(&format!("{}/:id/history", base_path), Self::gate(&perms, Gate::Read, get({
1868 let h = handler.clone();
1869 move |path: axum::extract::Path<String>,
1870 query: ListQuery,
1871 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
1872 Self::history_handler(h, path, query, provider).await
1873 }
1874 })))
1875 .route(&format!("{}/aggregate", base_path), Self::gate(&perms, Gate::Read, get({
1877 let h = handler.clone();
1878 move |query: ListQuery| async move {
1879 Self::aggregate_handler(h, query).await
1880 }
1881 })))
1882 .route(&format!("{}/count", base_path), Self::gate(&perms, Gate::Read, get({
1883 let h = handler.clone();
1884 move |query: ListQuery| async move {
1885 Self::count_active_handler(h, query).await
1886 }
1887 })))
1888 .route(&format!("{}/trash/count", base_path), Self::gate(&perms, Gate::Read, get({
1890 let h = handler.clone();
1891 move || async move {
1892 Self::count_deleted_handler(h).await
1893 }
1894 })))
1895 }
1896
1897 pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
1914 where
1915 S: Clone,
1916 {
1917 Self::write_routes_with(service, base_path, None)
1918 }
1919
1920 pub fn write_routes_guarded(
1925 service: Arc<S>,
1926 base_path: &str,
1927 permissions: crate::permission::CrudPermissions,
1928 ) -> axum::Router<()>
1929 where
1930 S: Clone,
1931 {
1932 Self::write_routes_with(service, base_path, Some(permissions))
1933 }
1934
1935 fn write_routes_with(
1936 service: Arc<S>,
1937 base_path: &str,
1938 perms: Option<crate::permission::CrudPermissions>,
1939 ) -> axum::Router<()>
1940 where
1941 S: Clone,
1942 {
1943 use crate::permission::Gate;
1944 if let Some(table) = service.table_name() {
1947 backbone_orm::secret_registry::register(table, E::secret_fields());
1948 }
1949 use axum::{
1950 extract::Path,
1951 routing::{delete, patch, post, put},
1952 Router,
1953 };
1954
1955 let handler = Arc::new(Self::new(service));
1956
1957 Router::new()
1958 .route(base_path, Self::gate(&perms, Gate::Create, post({
1960 let h = handler.clone();
1961 move |body: StrictJsonOrForm<C>| async move {
1962 Self::create_handler(h, body).await
1963 }
1964 })))
1965 .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Create, post({
1967 let h = handler.clone();
1968 move |body: StrictJsonOrForm<Vec<C>>| async move {
1969 Self::bulk_create_handler(h, body).await
1970 }
1971 })))
1972 .route(&format!("{}/upsert", base_path), Self::gate(&perms, Gate::CreateUpdate, post({
1974 let h = handler.clone();
1975 move |body: StrictJsonOrForm<C>| async move {
1976 Self::upsert_handler(h, body).await
1977 }
1978 })))
1979 .route(&format!("{}/delete/bulk", base_path), Self::gate(&perms, Gate::Delete, post({
1981 let h = handler.clone();
1982 move |body: JsonOrForm<BatchIdsRequest>| async move {
1983 Self::bulk_delete_handler(h, body).await
1984 }
1985 })))
1986 .route(&format!("{}/restore/bulk", base_path), Self::gate(&perms, Gate::Restore, post({
1988 let h = handler.clone();
1989 move |body: JsonOrForm<BatchIdsRequest>| async move {
1990 Self::bulk_restore_handler(h, body).await
1991 }
1992 })))
1993 .route(&format!("{}/restore/all", base_path), Self::gate(&perms, Gate::Restore, post({
1995 let h = handler.clone();
1996 move || async move {
1997 Self::restore_all_handler(h).await
1998 }
1999 })))
2000 .route(&format!("{}/trash/bulk", base_path), Self::gate(&perms, Gate::Purge, delete({
2003 let h = handler.clone();
2004 move |body: JsonOrForm<BatchIdsRequest>| async move {
2005 Self::bulk_permanent_delete_handler(h, body).await
2006 }
2007 })))
2008 .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Update, put({
2010 let h = handler.clone();
2011 move |body: JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>| async move {
2012 Self::bulk_update_handler(h, body).await
2013 }
2014 })))
2015 .route(&format!("{}/bulk", base_path), Self::gate(&perms, Gate::Update, patch({
2017 let h = handler.clone();
2018 move |body: JsonOrForm<BulkPatchRequest>| async move {
2019 Self::bulk_patch_handler(h, body).await
2020 }
2021 })))
2022 .route(&format!("{}/bulk/preview", base_path), Self::gate(&perms, Gate::Update, post({
2024 let h = handler.clone();
2025 move |body: JsonOrForm<BulkPatchRequest>| async move {
2026 Self::bulk_patch_preview_handler(h, body).await
2027 }
2028 })))
2029 .route(&format!("{}/empty", base_path), Self::gate(&perms, Gate::Purge, delete({
2031 let h = handler.clone();
2032 move || async move {
2033 Self::empty_trash_handler(h).await
2034 }
2035 })))
2036 .route(&format!("{}/trash/:id", base_path), Self::gate(&perms, Gate::Purge, delete({
2038 let h = handler.clone();
2039 move |path: Path<String>| async move {
2040 Self::permanent_delete_handler(h, path).await
2041 }
2042 })))
2043 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Update, put({
2045 let h = handler.clone();
2046 move |path: Path<String>, body: StrictJsonOrForm<U>| async move {
2047 Self::update_handler(h, path, body).await
2048 }
2049 })))
2050 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Update, patch({
2052 let h = handler.clone();
2053 move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
2054 Self::partial_update_handler(h, path, body).await
2055 }
2056 })))
2057 .route(&format!("{}/:id", base_path), Self::gate(&perms, Gate::Delete, delete({
2059 let h = handler.clone();
2060 move |path: Path<String>| async move {
2061 Self::delete_handler(h, path).await
2062 }
2063 })))
2064 .route(&format!("{}/:id/restore", base_path), Self::gate(&perms, Gate::Restore, post({
2066 let h = handler.clone();
2067 move |path: Path<String>| async move {
2068 Self::restore_handler(h, path).await
2069 }
2070 })))
2071 .layer(axum::middleware::from_fn(correlation_middleware))
2073 }
2074
2075 fn failure<T>(err: &S::Error, fallback: axum::http::StatusCode) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
2083 let violations = S::violations_of(err);
2084 let status = S::status_of(err).unwrap_or(match &violations {
2085 Some(v) if is_query_refusal(v) => axum::http::StatusCode::BAD_REQUEST,
2086 Some(_) => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
2087 None => fallback,
2088 });
2089 error_at(status, violations, err)
2090 }
2091
2092 fn query_failure_parts(
2097 err: &S::Error,
2098 params: &ListQueryParams,
2099 fallback: axum::http::StatusCode,
2100 ) -> (axum::http::StatusCode, String, Vec<crate::violation::Violation>) {
2101 use axum::http::StatusCode;
2102 let violations = S::violations_of(err);
2103 if let Some(v) = violations.as_ref().filter(|v| is_query_refusal(v)) {
2104 let v = rename_typed_sort(v.clone(), params);
2105 return (StatusCode::BAD_REQUEST, refusal_sentence(&v), v);
2106 }
2107 let msg = err.to_string();
2108 if violations.is_none() && is_bad_query_error(&msg) {
2109 tracing::debug!(target: "backbone::http", error = %msg, "list query refused");
2113 let message = "Invalid query parameter or filter";
2114 return (
2115 StatusCode::BAD_REQUEST,
2116 message.to_string(),
2117 vec![crate::violation::Violation::new("", crate::violation::INVALID_QUERY, message)],
2118 );
2119 }
2120 let (status, axum::Json(body)) = Self::failure::<()>(err, fallback);
2121 (status, body.error.unwrap_or_default(), body.violations.unwrap_or_default())
2122 }
2123
2124 fn list_failure(
2126 err: &S::Error,
2127 params: &ListQueryParams,
2128 ) -> (axum::http::StatusCode, axum::Json<PaginatedApiResponse<serde_json::Value>>) {
2129 let (status, error, violations) =
2130 Self::query_failure_parts(err, params, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
2131 (status, axum::Json(PaginatedApiResponse::rejected(error, violations)))
2132 }
2133
2134 fn query_failure<T>(
2136 err: &S::Error,
2137 params: &ListQueryParams,
2138 ) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
2139 let (status, error, violations) =
2140 Self::query_failure_parts(err, params, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
2141 let body = if violations.is_empty() {
2142 ApiResponse::error(error)
2143 } else {
2144 ApiResponse::rejected(error, violations)
2145 };
2146 (status, axum::Json(body))
2147 }
2148
2149 async fn list_handler(
2150 handler: Arc<Self>,
2151 ListQuery(params): ListQuery,
2152 access: Option<axum::Extension<AccessScope>>,
2153 ) -> impl axum::response::IntoResponse {
2154 use axum::{http::StatusCode, Json};
2155
2156 let params = params.normalized();
2157 let refused = query_refusals::<E>(¶ms, true);
2158 if !refused.is_empty() {
2159 return (StatusCode::BAD_REQUEST, Json(refused_list(refused)));
2160 }
2161
2162 let fields = sparse_fields(¶ms.filters);
2165 let includes = include_relations(¶ms.filters);
2166 let scope = access.map(|axum::Extension(s)| s);
2167
2168 let filters = repository_filters(¶ms);
2169
2170 match handler
2171 .service
2172 .list_with_info(params.page, params.limit, filters)
2173 .await
2174 {
2175 Ok((entities, info)) => {
2176 let mut rows: Vec<serde_json::Value> = entities
2179 .into_iter()
2180 .map(|e| {
2181 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
2182 })
2183 .collect();
2184 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
2185 let items: Vec<serde_json::Value> =
2186 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2187 let response = PaginatedApiResponse::ok_with_info(items, &info);
2188 (StatusCode::OK, Json(response))
2189 }
2190 Err(e) => Self::list_failure(&e, ¶ms),
2191 }
2192 }
2193
2194 async fn create_handler(
2195 handler: Arc<Self>,
2196 StrictJsonOrForm(dto): StrictJsonOrForm<C>,
2197 ) -> impl axum::response::IntoResponse {
2198 use axum::{http::StatusCode, Json};
2199
2200 match handler.service.create(dto).await {
2201 Ok(entity) => {
2202 let response = secure::<E>(to_response_value(R::from(entity)), None);
2203 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
2204 }
2205 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2206 }
2207 }
2208
2209 async fn get_handler(
2210 handler: Arc<Self>,
2211 axum::extract::Path(id): axum::extract::Path<String>,
2212 ListQuery(params): ListQuery,
2213 access: Option<axum::Extension<AccessScope>>,
2214 ) -> impl axum::response::IntoResponse {
2215 use axum::{http::StatusCode, Json};
2216
2217 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2218 return refused;
2219 }
2220 let refused = shaping_refusals::<E>(¶ms.filters);
2221 if !refused.is_empty() {
2222 return (
2223 StatusCode::BAD_REQUEST,
2224 Json(ApiResponse::rejected(refusal_sentence(&refused), refused)),
2225 );
2226 }
2227
2228 let fields = sparse_fields(¶ms.filters);
2229 let includes = include_relations(¶ms.filters);
2230 let scope = access.map(|axum::Extension(s)| s);
2231
2232 match handler.service.get_by_id(&id).await {
2233 Ok(Some(entity)) => {
2234 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
2235 let mut rows = [secured];
2236 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
2237 let [secured] = rows;
2238 let value = project_sparse(secured, &fields);
2239 (StatusCode::OK, Json(ApiResponse::ok(value)))
2240 }
2241 Ok(None) => {
2242 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2243 }
2244 Err(e) => {
2245 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2246 }
2247 }
2248 }
2249
2250 async fn update_handler(
2251 handler: Arc<Self>,
2252 axum::extract::Path(id): axum::extract::Path<String>,
2253 StrictJsonOrForm(dto): StrictJsonOrForm<U>,
2254 ) -> impl axum::response::IntoResponse {
2255 use axum::{http::StatusCode, Json};
2256
2257 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2258 return refused;
2259 }
2260
2261 match handler.service.update(&id, dto).await {
2262 Ok(Some(entity)) => {
2263 let response = secure::<E>(to_response_value(R::from(entity)), None);
2264 (StatusCode::OK, Json(ApiResponse::ok(response)))
2265 }
2266 Ok(None) => {
2267 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2268 }
2269 Err(e) => {
2270 Self::failure(&e, StatusCode::BAD_REQUEST)
2271 }
2272 }
2273 }
2274
2275 async fn partial_update_handler(
2276 handler: Arc<Self>,
2277 axum::extract::Path(id): axum::extract::Path<String>,
2278 JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
2279 ) -> impl axum::response::IntoResponse {
2280 use axum::{http::StatusCode, Json};
2281
2282 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2283 return refused;
2284 }
2285
2286 let fields: HashMap<String, serde_json::Value> = fields
2297 .into_iter()
2298 .map(|(k, v)| (camel_to_snake_case(&k), v))
2299 .collect();
2300
2301 match handler.service.partial_update(&id, fields).await {
2302 Ok(Some(entity)) => {
2303 let response = secure::<E>(to_response_value(R::from(entity)), None);
2304 (StatusCode::OK, Json(ApiResponse::ok(response)))
2305 }
2306 Ok(None) => {
2307 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2308 }
2309 Err(e) => {
2310 Self::failure(&e, StatusCode::BAD_REQUEST)
2311 }
2312 }
2313 }
2314
2315 async fn delete_handler(
2316 handler: Arc<Self>,
2317 axum::extract::Path(id): axum::extract::Path<String>,
2318 ) -> impl axum::response::IntoResponse {
2319 use axum::{http::StatusCode, Json};
2320
2321 if let Some(refused) = invalid_id::<()>(&id, "id") {
2322 return refused;
2323 }
2324
2325 match handler.service.soft_delete(&id).await {
2326 Ok(true) => {
2327 (StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
2330 }
2331 Ok(false) => {
2332 (StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
2333 }
2334 Err(e) => {
2335 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2336 }
2337 }
2338 }
2339
2340 async fn bulk_create_handler(
2341 handler: Arc<Self>,
2342 StrictJsonOrForm(items): StrictJsonOrForm<Vec<C>>,
2343 ) -> impl axum::response::IntoResponse {
2344 use axum::{http::StatusCode, Json};
2345
2346 if let Some(err) = batch_size_error(items.len()) {
2347 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2348 }
2349 match handler.service.bulk_create(items).await {
2350 Ok(entities) => {
2351 let result_items: Vec<serde_json::Value> = entities
2352 .into_iter()
2353 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2354 .collect();
2355 let total = result_items.len();
2356 let response = BulkResponse {
2357 items: result_items,
2358 total,
2359 failed: 0,
2360 errors: vec![],
2361 };
2362 (StatusCode::CREATED, Json(ApiResponse::ok(response)))
2363 }
2364 Err(e) => {
2365 Self::failure(&e, StatusCode::BAD_REQUEST)
2366 }
2367 }
2368 }
2369
2370 async fn bulk_delete_handler(
2373 handler: Arc<Self>,
2374 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2375 ) -> impl axum::response::IntoResponse {
2376 use axum::{http::StatusCode, Json};
2377
2378 if let Some(err) = batch_size_error(req.ids.len()) {
2379 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2380 }
2381 if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2382 return refused;
2383 }
2384 match handler.service.bulk_soft_delete(req.ids).await {
2385 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2386 serde_json::json!({ "soft_deleted": count }),
2387 format!("Soft-deleted {count} item(s)"),
2388 ))),
2389 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2390 }
2391 }
2392
2393 async fn bulk_restore_handler(
2394 handler: Arc<Self>,
2395 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2396 ) -> impl axum::response::IntoResponse {
2397 use axum::{http::StatusCode, Json};
2398
2399 if let Some(err) = batch_size_error(req.ids.len()) {
2400 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2401 }
2402 if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2403 return refused;
2404 }
2405 match handler.service.bulk_restore(req.ids).await {
2406 Ok(entities) => {
2407 let items: Vec<serde_json::Value> = entities
2408 .into_iter()
2409 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2410 .collect();
2411 let total = items.len();
2412 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2413 }
2414 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2415 }
2416 }
2417
2418 async fn restore_all_handler(
2419 handler: Arc<Self>,
2420 ) -> impl axum::response::IntoResponse {
2421 use axum::{http::StatusCode, Json};
2422
2423 match handler.service.restore_all().await {
2424 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2425 serde_json::json!({ "restored": count }),
2426 format!("Restored {count} item(s) from trash"),
2427 ))),
2428 Err(e) => Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR),
2429 }
2430 }
2431
2432 async fn bulk_permanent_delete_handler(
2433 handler: Arc<Self>,
2434 JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
2435 ) -> impl axum::response::IntoResponse {
2436 use axum::{http::StatusCode, Json};
2437
2438 if let Some(err) = batch_size_error(req.ids.len()) {
2439 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
2440 }
2441 if let Some(refused) = invalid_ids::<serde_json::Value>(req.ids.iter().map(String::as_str), |i| format!("ids/{i}")) {
2442 return refused;
2443 }
2444 match handler.service.bulk_permanent_delete(req.ids).await {
2445 Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
2446 serde_json::json!({ "permanently_deleted": count }),
2447 format!("Permanently deleted {count} item(s)"),
2448 ))),
2449 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2450 }
2451 }
2452
2453 async fn bulk_update_handler(
2457 handler: Arc<Self>,
2458 JsonOrForm(raw): JsonOrForm<Vec<serde_json::Map<String, serde_json::Value>>>,
2459 ) -> axum::response::Response {
2460 use axum::{http::StatusCode, response::IntoResponse, Json};
2461
2462 if let Some(err) = batch_size_error(raw.len()) {
2463 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)))
2464 .into_response();
2465 }
2466 let mut items: Vec<(String, U)> = Vec::with_capacity(raw.len());
2467 for (index, mut fields) in raw.into_iter().enumerate() {
2468 let id = match fields.remove("id") {
2469 Some(serde_json::Value::String(id)) => id,
2470 _ => {
2471 let violation = crate::violation::Violation::new(
2472 crate::violation::batch_item_path(index, "id"),
2473 "required",
2474 "id is required",
2475 );
2476 return (
2477 StatusCode::UNPROCESSABLE_ENTITY,
2478 Json(ApiResponse::<()>::rejected(format!("item {index}: id is required"), vec![violation])),
2479 )
2480 .into_response();
2481 }
2482 };
2483 if let Some(refused) = invalid_id::<()>(&id, &crate::violation::batch_item_path(index, "id")) {
2484 return refused.into_response();
2485 }
2486 let bytes = match serde_json::to_vec(&fields) {
2487 Ok(bytes) => bytes,
2488 Err(e) => return error_at::<()>(StatusCode::INTERNAL_SERVER_ERROR, None, &e).into_response(),
2489 };
2490 match crate::extractors::decode_json_or_refusal::<U>(&bytes, true) {
2491 Ok(data) => items.push((id, data)),
2492 Err(refusal) => return refusal.at_item(index).into_response(),
2493 }
2494 }
2495 match handler.service.bulk_update(items).await {
2496 Ok(entities) => {
2497 let items: Vec<serde_json::Value> = entities
2498 .into_iter()
2499 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2500 .collect();
2501 let total = items.len();
2502 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2503 .into_response()
2504 }
2505 Err(e) => Self::failure::<()>(&e, StatusCode::BAD_REQUEST).into_response(),
2506 }
2507 }
2508
2509 async fn bulk_patch_handler(
2510 handler: Arc<Self>,
2511 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2512 ) -> impl axum::response::IntoResponse {
2513 use axum::{http::StatusCode, Json};
2514
2515 let items = req.into_items();
2516 if let Some(err) = batch_size_error(items.len()) {
2517 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<serde_json::Value>>::error(err)));
2518 }
2519 if let Some(refused) = invalid_ids::<BulkResponse<serde_json::Value>>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2520 return refused;
2521 }
2522 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2524 .into_iter()
2525 .map(|(id, fields)| {
2526 let fields = fields
2527 .into_iter()
2528 .map(|(k, v)| (camel_to_snake_case(&k), v))
2529 .collect();
2530 (id, fields)
2531 })
2532 .collect();
2533 match handler.service.bulk_partial_update(items).await {
2534 Ok(entities) => {
2535 let items: Vec<serde_json::Value> = entities
2536 .into_iter()
2537 .map(|e| secure::<E>(to_response_value(R::from(e)), None))
2538 .collect();
2539 let total = items.len();
2540 (StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
2541 }
2542 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2543 }
2544 }
2545
2546 async fn bulk_patch_preview_handler(
2552 handler: Arc<Self>,
2553 JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
2554 ) -> impl axum::response::IntoResponse {
2555 use axum::{http::StatusCode, Json};
2556
2557 let items = req.into_items();
2558 if let Some(err) = batch_size_error(items.len()) {
2559 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkPreview>::error(err)));
2560 }
2561 if let Some(refused) = invalid_ids::<BulkPreview>(items.iter().map(|(id, _)| id.as_str()), |i| format!("{i}/id")) {
2562 return refused;
2563 }
2564 let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
2565 .into_iter()
2566 .map(|(id, fields)| {
2567 let fields = fields
2568 .into_iter()
2569 .map(|(k, v)| (camel_to_snake_case(&k), v))
2570 .collect();
2571 (id, fields)
2572 })
2573 .collect();
2574 let patched: HashMap<String, Vec<String>> = items
2575 .iter()
2576 .map(|(id, f)| (id.clone(), f.keys().cloned().collect()))
2577 .collect();
2578 match handler.service.preview_bulk_partial_update(items).await {
2579 Ok(Some(rows)) => {
2580 let rows = rows
2581 .into_iter()
2582 .map(|row| {
2583 let outcome = match row.outcome {
2584 Ok((before, after)) => Ok((
2585 secure::<E>(to_response_value(R::from(before)), None),
2586 secure::<E>(to_response_value(R::from(after)), None),
2587 )),
2588 Err(e) => Err((
2589 S::violations_of(&e)
2590 .and_then(|v| v.into_iter().next().map(|v| v.code))
2591 .unwrap_or_else(|| "refused".to_string()),
2592 e.to_string(),
2593 )),
2594 };
2595 (row.id, outcome)
2596 })
2597 .collect();
2598 (StatusCode::OK, Json(ApiResponse::ok(preview_answer(rows, &patched))))
2599 }
2600 Ok(None) => (
2601 StatusCode::NOT_IMPLEMENTED,
2602 Json(ApiResponse::error("this resource has no preview of a bulk change".to_string())),
2603 ),
2604 Err(e) => Self::failure(&e, StatusCode::BAD_REQUEST),
2605 }
2606 }
2607
2608 async fn upsert_handler(
2609 handler: Arc<Self>,
2610 StrictJsonOrForm(dto): StrictJsonOrForm<C>,
2611 ) -> impl axum::response::IntoResponse {
2612 use axum::{http::StatusCode, Json};
2613
2614 if !S::supports_upsert() {
2617 return (
2618 StatusCode::NOT_IMPLEMENTED,
2619 Json(ApiResponse::<serde_json::Value>::error(crate::service::UPSERT_UNSUPPORTED)),
2620 );
2621 }
2622 match handler.service.upsert(dto).await {
2623 Ok(entity) => {
2624 let response = secure::<E>(to_response_value(R::from(entity)), None);
2625 (StatusCode::OK, Json(ApiResponse::ok(response)))
2626 }
2627 Err(e) => {
2628 Self::failure(&e, StatusCode::BAD_REQUEST)
2629 }
2630 }
2631 }
2632
2633 async fn list_deleted_handler(
2634 handler: Arc<Self>,
2635 ListQuery(params): ListQuery,
2636 access: Option<axum::Extension<AccessScope>>,
2637 ) -> impl axum::response::IntoResponse {
2638 use axum::{http::StatusCode, Json};
2639
2640 let params = params.normalized();
2641 let refused = query_refusals::<E>(¶ms, true);
2642 if !refused.is_empty() {
2643 return (StatusCode::BAD_REQUEST, Json(refused_list(refused)));
2644 }
2645
2646 let fields = sparse_fields(¶ms.filters);
2647 let includes = include_relations(¶ms.filters);
2648 let scope = access.map(|axum::Extension(s)| s);
2649
2650 let filters = repository_filters(¶ms);
2651 match handler.service.list_deleted_filtered(params.page, params.limit, filters).await {
2652 Ok((entities, total)) => {
2653 let mut rows: Vec<serde_json::Value> = entities
2657 .into_iter()
2658 .map(|e| {
2659 secure::<E>(to_response_value(R::from(e)), scope.as_ref())
2660 })
2661 .collect();
2662 expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
2663 let items: Vec<serde_json::Value> =
2664 rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
2665 let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
2666 (StatusCode::OK, Json(response))
2667 }
2668 Err(e) => Self::list_failure(&e, ¶ms),
2669 }
2670 }
2671
2672 async fn restore_handler(
2673 handler: Arc<Self>,
2674 axum::extract::Path(id): axum::extract::Path<String>,
2675 ) -> impl axum::response::IntoResponse {
2676 use axum::{http::StatusCode, Json};
2677
2678 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2679 return refused;
2680 }
2681
2682 match handler.service.restore(&id).await {
2683 Ok(Some(entity)) => {
2684 let response = secure::<E>(to_response_value(R::from(entity)), None);
2685 (StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
2686 }
2687 Ok(None) => {
2688 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
2689 }
2690 Err(e) => {
2691 Self::failure(&e, StatusCode::BAD_REQUEST)
2692 }
2693 }
2694 }
2695
2696 async fn empty_trash_handler(
2697 handler: Arc<Self>,
2698 ) -> impl axum::response::IntoResponse {
2699 use axum::{http::StatusCode, Json};
2700
2701 match handler.service.empty_trash().await {
2702 Ok(count) => {
2703 (StatusCode::OK, Json(ApiResponse::success_with_message(
2704 serde_json::json!({ "deleted_count": count }),
2705 format!("Successfully deleted {} items from trash", count)
2706 )))
2707 }
2708 Err(e) => {
2709 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2710 }
2711 }
2712 }
2713
2714 async fn get_deleted_handler(
2716 handler: Arc<Self>,
2717 axum::extract::Path(id): axum::extract::Path<String>,
2718 ListQuery(params): ListQuery,
2719 access: Option<axum::Extension<AccessScope>>,
2720 ) -> impl axum::response::IntoResponse {
2721 use axum::{http::StatusCode, Json};
2722
2723 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2724 return refused;
2725 }
2726
2727 let fields = sparse_fields(¶ms.filters);
2728 let scope = access.map(|axum::Extension(s)| s);
2729
2730 match handler.service.get_deleted_by_id(&id).await {
2731 Ok(Some(entity)) => {
2732 let secured = secure::<E>(to_response_value(R::from(entity)), scope.as_ref());
2733 let value = project_sparse(secured, &fields);
2734 (StatusCode::OK, Json(ApiResponse::ok(value)))
2735 }
2736 Ok(None) => {
2737 (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
2738 }
2739 Err(e) => {
2740 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2741 }
2742 }
2743 }
2744
2745 async fn permanent_delete_handler(
2747 handler: Arc<Self>,
2748 axum::extract::Path(id): axum::extract::Path<String>,
2749 ) -> axum::response::Response {
2750 use axum::{http::StatusCode, Json, response::IntoResponse};
2751
2752 if let Some(refused) = invalid_id::<R>(&id, "id") {
2753 return refused.into_response();
2754 }
2755
2756 match handler.service.get_deleted_by_id(&id).await {
2758 Ok(Some(_)) => {
2759 match handler.service.permanent_delete(&id).await {
2761 Ok(true) => {
2762 StatusCode::NO_CONTENT.into_response()
2764 }
2765 Ok(false) => {
2766 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
2767 .into_response()
2768 }
2769 Err(e) => {
2770 Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2771 }
2772 }
2773 }
2774 Ok(None) => {
2775 (StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
2776 &format!("{} in trash", S::entity_name()), &id
2777 ))).into_response()
2778 }
2779 Err(e) => {
2780 Self::failure::<R>(&e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
2781 }
2782 }
2783 }
2784
2785 async fn count_active_handler(
2787 handler: Arc<Self>,
2788 ListQuery(params): ListQuery,
2789 ) -> impl axum::response::IntoResponse {
2790 use axum::{http::StatusCode, Json};
2791
2792 let refused = query_refusals::<E>(¶ms, false);
2796 if !refused.is_empty() {
2797 return (
2798 StatusCode::BAD_REQUEST,
2799 Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2800 );
2801 }
2802 match handler.service.count_active_filtered(repository_filters(¶ms)).await {
2803 Ok(count) => {
2804 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2805 }
2806 Err(e) => Self::query_failure(&e, ¶ms),
2809 }
2810 }
2811
2812 async fn aggregate_handler(
2817 handler: Arc<Self>,
2818 ListQuery(params): ListQuery,
2819 ) -> impl axum::response::IntoResponse {
2820 use axum::{http::StatusCode, Json};
2821
2822 let refused = query_refusals::<E>(¶ms, false);
2823 if !refused.is_empty() {
2824 return (
2825 StatusCode::BAD_REQUEST,
2826 Json(ApiResponse::<serde_json::Value>::rejected(refusal_sentence(&refused), refused)),
2827 );
2828 }
2829 let spec = aggregate_spec(¶ms);
2830 match handler.service.aggregate(&spec, aggregate_filters(¶ms)).await {
2831 Ok(result) => {
2832 let render = |g: &backbone_orm::repository::AggregateGroup| {
2833 let mut out = serde_json::Map::new();
2837 out.insert("key".into(), match &g.key {
2838 Some(k) => serde_json::Value::String(k.clone()),
2839 None => serde_json::Value::Null,
2840 });
2841 if g.label.is_some() {
2842 out.insert(
2843 "label".into(),
2844 serde_json::Value::String(g.label.clone().unwrap()),
2845 );
2846 }
2847 out.insert("count".into(), serde_json::json!(g.count));
2848 for (compound, value) in &g.values {
2849 let Some((func, field)) = compound.split_once(':') else { continue };
2850 let slot = out
2851 .entry(func.to_string())
2852 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
2853 if let Some(obj) = slot.as_object_mut() {
2854 obj.insert(field.to_string(), match value {
2855 Some(v) => serde_json::Value::String(v.clone()),
2859 None => serde_json::Value::Null,
2860 });
2861 }
2862 }
2863 serde_json::Value::Object(out)
2864 };
2865
2866 let body = serde_json::json!({
2867 "groups": result.groups.iter().map(render).collect::<Vec<_>>(),
2868 "total": render(&result.total),
2869 "truncated": result.truncated,
2872 });
2873 (StatusCode::OK, Json(ApiResponse::ok(body)))
2874 }
2875 Err(e) => Self::query_failure(&e, ¶ms),
2876 }
2877 }
2878
2879 async fn history_handler(
2890 handler: Arc<Self>,
2891 axum::extract::Path(id): axum::extract::Path<String>,
2892 ListQuery(params): ListQuery,
2893 provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
2894 ) -> impl axum::response::IntoResponse {
2895 use axum::{http::StatusCode, Json};
2896
2897 if let Some(refused) = invalid_id::<serde_json::Value>(&id, "id") {
2898 return refused;
2899 }
2900
2901 let Some(axum::Extension(provider)) = provider else {
2902 return (
2903 StatusCode::NOT_IMPLEMENTED,
2904 Json(ApiResponse::<serde_json::Value>::error(
2905 "history is not configured for this service".to_string(),
2906 )),
2907 );
2908 };
2909
2910 let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
2914 return (
2915 StatusCode::NOT_IMPLEMENTED,
2916 Json(ApiResponse::<serde_json::Value>::error(
2917 "this entity cannot name its table, so its history cannot be keyed".to_string(),
2918 )),
2919 );
2920 };
2921
2922 let limit = params.limit.clamp(1, 200);
2923 let offset = params.page.saturating_sub(1) * limit;
2924
2925 match provider.history(&table, &id, limit, offset).await {
2926 Ok(Some(entries)) => (
2927 StatusCode::OK,
2928 Json(ApiResponse::ok(serde_json::json!({
2929 "audited": true,
2930 "entries": entries,
2931 }))),
2932 ),
2933 Ok(None) => (
2934 StatusCode::OK,
2935 Json(ApiResponse::ok(serde_json::json!({
2936 "audited": false,
2939 "entries": serde_json::Value::Null,
2940 }))),
2941 ),
2942 Err(e) => error_at(StatusCode::INTERNAL_SERVER_ERROR, None, &e),
2943 }
2944 }
2945
2946 async fn count_deleted_handler(
2948 handler: Arc<Self>,
2949 ) -> impl axum::response::IntoResponse {
2950 use axum::{http::StatusCode, Json};
2951
2952 match handler.service.count_deleted().await {
2953 Ok(count) => {
2954 (StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
2955 }
2956 Err(e) => {
2957 Self::failure(&e, StatusCode::INTERNAL_SERVER_ERROR)
2958 }
2959 }
2960 }
2961}
2962
2963pub trait BackboneHttpHandler<T>: Send + Sync {
2988 fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
2990
2991 fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
2993
2994 fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
2996
2997 fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
2999
3000 fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
3002
3003 fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
3005
3006 fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
3008
3009 fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
3011
3012 fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
3014
3015 fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
3017
3018 fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
3020
3021 fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
3023}
3024
3025#[derive(Debug, Serialize, Deserialize)]
3031#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
3032pub struct PaginationRequest {
3033 pub page: u32,
3034 pub limit: u32,
3035 pub sort_by: Option<String>,
3036 pub sort_order: Option<String>,
3037}
3038
3039fn camel_to_snake_case(key: &str) -> String {
3055 let chars: Vec<char> = key.chars().collect();
3056 let mut result = String::with_capacity(key.len() + 2);
3057 for (i, &c) in chars.iter().enumerate() {
3058 if c.is_ascii_uppercase() {
3059 let prev = if i > 0 { chars[i - 1] } else { '\0' };
3060 let next = chars.get(i + 1).copied().unwrap_or('\0');
3061 let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
3065 let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
3066 if (crosses_lower || crosses_acronym)
3067 && !result.is_empty()
3068 && !result.ends_with('_')
3069 {
3070 result.push('_');
3071 }
3072 result.push(c.to_ascii_lowercase());
3073 } else {
3074 result.push(c);
3075 }
3076 }
3077 result
3078}
3079
3080#[cfg(test)]
3081mod tests {
3082 use super::*;
3083
3084 #[test]
3085 fn snake_case_input_passes_through_unchanged() {
3086 assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
3087 assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
3088 assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
3089 }
3090
3091 #[test]
3092 fn single_word_unchanged() {
3093 assert_eq!(camel_to_snake_case("name"), "name");
3094 assert_eq!(camel_to_snake_case("id"), "id");
3095 assert_eq!(camel_to_snake_case(""), "");
3096 }
3097
3098 #[test]
3099 fn camel_case_converts() {
3100 assert_eq!(camel_to_snake_case("isVip"), "is_vip");
3101 assert_eq!(camel_to_snake_case("userId"), "user_id");
3102 assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
3103 assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
3104 }
3105
3106 #[test]
3107 fn pascal_case_converts() {
3108 assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
3109 assert_eq!(camel_to_snake_case("UserId"), "user_id");
3110 }
3111
3112 #[test]
3113 fn acronym_runs_stay_together() {
3114 assert_eq!(camel_to_snake_case("IOError"), "io_error");
3117 assert_eq!(camel_to_snake_case("httpURL"), "http_url");
3118 assert_eq!(camel_to_snake_case("ABC"), "abc");
3119 assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
3120 }
3121
3122 #[test]
3123 fn digits_count_as_lowercase_for_boundary() {
3124 assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
3125 assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
3126 }
3127
3128 #[test]
3129 fn underscores_not_doubled() {
3130 assert_eq!(camel_to_snake_case("_Foo"), "_foo");
3131 assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
3132 }
3133
3134 #[test]
3135 fn shallow_pages_are_allowed() {
3136 assert!(pagination_depth_error(1, 100).is_none());
3138 assert!(pagination_depth_error(101, 100).is_none());
3140 }
3141
3142 #[test]
3143 fn pages_past_the_cap_are_rejected() {
3144 assert!(pagination_depth_error(102, 100).is_some());
3146 assert!(pagination_depth_error(1002, 10).is_some());
3148 }
3149
3150 #[test]
3151 fn oversized_page_size_is_clamped_before_the_check() {
3152 assert!(pagination_depth_error(102, 200).is_some());
3154 assert!(pagination_depth_error(101, 200).is_none());
3156 }
3157
3158 #[test]
3159 fn huge_page_number_does_not_overflow() {
3160 assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
3162 }
3163
3164 #[test]
3167 fn batch_size_within_limit_is_allowed() {
3168 assert!(batch_size_error(0).is_none());
3169 assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
3170 }
3171
3172 #[test]
3173 fn batch_size_over_limit_is_rejected() {
3174 assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
3175 }
3176
3177 #[test]
3178 fn a_bulk_preview_sorts_rows_by_what_the_patch_would_do() {
3179 use serde_json::json;
3180 let mut patched: HashMap<String, Vec<String>> = ["a", "b", "c"]
3181 .iter()
3182 .map(|id| (id.to_string(), vec!["category".to_string()]))
3183 .collect();
3184 patched.insert("d".into(), vec!["category_id".into()]);
3185 let out = preview_answer(
3186 vec![
3187 (
3188 "a".into(),
3189 Ok((
3190 json!({"category": "meal", "updated_at": "1"}),
3191 json!({"category": "travel", "updated_at": "2"}),
3192 )),
3193 ),
3194 ("b".into(), Ok((json!({"category": "travel"}), json!({"category": "travel"})))),
3195 (
3196 "d".into(),
3197 Ok((json!({"categoryId": "x"}), json!({"categoryId": "y"}))),
3198 ),
3199 ("c".into(), Err(("locked".into(), "a posted claim is locked".into()))),
3200 ],
3201 &patched,
3202 );
3203 assert_eq!(out.total, 4);
3204 assert_eq!(out.will_change.len(), 2);
3205 assert_eq!(out.will_change[1].changes[0].field, "category_id", "a camelCase response is read");
3206 let ch = &out.will_change[0].changes;
3207 assert_eq!(ch.len(), 1, "only the patched field is reported, not the timestamp");
3208 assert_eq!((ch[0].from.clone(), ch[0].to.clone()), (json!("meal"), json!("travel")));
3209 assert_eq!(out.unchanged, vec!["b".to_string()]);
3210 assert_eq!(out.refused[0].code, "locked");
3211 }
3212
3213 #[test]
3214 fn bulk_patch_request_parses_shared_shape() {
3215 let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
3216 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
3217 let items = req.into_items();
3218 assert_eq!(items.len(), 2);
3219 assert_eq!(items[0].1.get("status").unwrap(), "void");
3221 assert_eq!(items[1].1.get("status").unwrap(), "void");
3222 assert_eq!(items[0].0, "a");
3223 assert_eq!(items[1].0, "b");
3224 }
3225
3226 #[test]
3227 fn bulk_patch_request_parses_per_item_shape() {
3228 let json = r#"{ "items": [
3229 { "id": "a", "patch": { "status": "void" } },
3230 { "id": "b", "patch": { "note": "late" } }
3231 ] }"#;
3232 let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
3233 let items = req.into_items();
3234 assert_eq!(items.len(), 2);
3235 assert_eq!(items[0].0, "a");
3236 assert_eq!(items[0].1.get("status").unwrap(), "void");
3237 assert_eq!(items[1].0, "b");
3238 assert_eq!(items[1].1.get("note").unwrap(), "late");
3239 }
3240
3241 #[test]
3242 fn batch_ids_request_parses() {
3243 let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
3244 assert_eq!(req.ids, vec!["x", "y", "z"]);
3245 }
3246
3247 fn fields(q: &[(&str, &str)]) -> Vec<String> {
3250 let map: HashMap<String, String> =
3251 q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
3252 sparse_fields(&map)
3253 }
3254
3255 #[test]
3256 fn sparse_fields_parses_comma_list_and_trims() {
3257 assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
3258 }
3259
3260 #[test]
3261 fn sparse_fields_absent_or_empty_is_no_projection() {
3262 assert!(fields(&[]).is_empty());
3263 assert!(fields(&[("fields", "")]).is_empty());
3264 assert!(fields(&[("fields", " , ")]).is_empty());
3265 }
3266
3267 #[test]
3268 fn project_keeps_requested_keys_plus_id() {
3269 let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
3270 let out = project_sparse(v, &["name".into(), "basePrice".into()]);
3271 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
3272 }
3273
3274 #[test]
3275 fn project_always_includes_id_even_if_not_requested() {
3276 let v = serde_json::json!({ "id": "1", "name": "n" });
3277 let out = project_sparse(v, &["name".into()]);
3278 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
3279 }
3280
3281 #[test]
3282 fn project_ignores_unknown_keys() {
3283 let v = serde_json::json!({ "id": "1", "name": "n" });
3284 let out = project_sparse(v, &["name".into(), "nope".into()]);
3285 assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
3286 }
3287
3288 #[test]
3289 fn project_empty_fields_returns_full_object() {
3290 let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
3291 let out = project_sparse(v.clone(), &[]);
3292 assert_eq!(out, v);
3293 }
3294
3295 #[test]
3296 fn project_non_object_returned_unchanged() {
3297 let v = serde_json::json!("scalar");
3298 assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
3299 }
3300
3301 fn owner_a() -> uuid::Uuid {
3306 uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
3307 }
3308 fn owner_b() -> uuid::Uuid {
3309 uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
3310 }
3311
3312 fn row() -> serde_json::Value {
3313 serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
3314 }
3315 const PRIV: &[&str] = &["hppPerUnit"];
3316
3317 struct Secretive;
3319 impl backbone_orm::EntityRepoMeta for Secretive {
3320 fn column_types() -> HashMap<String, String> {
3321 HashMap::new()
3322 }
3323 fn search_fields() -> &'static [&'static str] {
3324 &[]
3325 }
3326 fn secret_fields() -> &'static [&'static str] {
3327 &["tokenHash"]
3328 }
3329 fn private_fields() -> &'static [&'static str] {
3330 PRIV
3331 }
3332 fn owner_field() -> Option<&'static str> {
3333 Some("providerId")
3334 }
3335 fn relation_secret_fields(relation: &str) -> &'static [&'static str] {
3336 match relation {
3337 "user" => &["passwordHash"],
3338 _ => &[],
3339 }
3340 }
3341 fn relations() -> &'static [(&'static str, &'static str, &'static str)] {
3342 &[("user", "users", "userId")]
3343 }
3344 }
3345
3346 fn secret_row() -> serde_json::Value {
3347 let mut r = row();
3348 r["tokenHash"] = serde_json::json!("digest");
3349 r
3350 }
3351
3352 #[test]
3353 fn a_secret_is_served_to_no_caller_not_even_platform_or_the_owner() {
3354 for scope in [None, Some(AccessScope::Platform), Some(AccessScope::Company(owner_a()))] {
3355 let out = secure::<Secretive>(secret_row(), scope.as_ref());
3356 assert!(out.get("tokenHash").is_none(), "{scope:?} was served the secret");
3357 assert!(out.get("name").is_some());
3358 }
3359 let platform = secure::<Secretive>(secret_row(), Some(&AccessScope::Platform));
3360 assert!(platform.get("hppPerUnit").is_some(), "private fields keep their owner/platform rule");
3361 }
3362
3363 #[test]
3364 fn the_typed_sort_pair_reaches_the_repository_as_the_filter_sort() {
3365 let params = |pairs: &[(&str, &str)], by: Option<&str>, order: Option<&str>| ListQueryParams {
3366 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3367 sort_by: by.map(str::to_string),
3368 sort_order: order.map(str::to_string),
3369 ..Default::default()
3370 };
3371 let f = repository_filters(¶ms(&[], Some("priority"), Some("DESC")));
3372 assert_eq!(f.get("orderby").map(String::as_str), Some("priority:desc"));
3373 let f = repository_filters(¶ms(&[], Some("createdAt"), None));
3374 assert_eq!(f.get("orderby").map(String::as_str), Some("createdAt:asc"));
3375 let f = repository_filters(¶ms(&[("orderby[name]", "asc")], Some("priority"), Some("desc")));
3377 assert!(!f.contains_key("orderby"), "{f:?}");
3378 let refused = backbone_orm::filter::parse_filters(
3381 &repository_filters(¶ms(&[], Some("name; drop table x"), None)),
3382 &HashMap::new(),
3383 None,
3384 )
3385 .unwrap_err()
3386 .downcast::<backbone_orm::QueryParamError>()
3387 .expect("a named refusal");
3388 assert_eq!(refused.parameter, "orderby");
3389 let said = rename_typed_sort(
3390 vec![query_violation(&refused.parameter, refused.reason, refused.message)],
3391 ¶ms(&[], Some("name; drop table x"), None),
3392 );
3393 assert_eq!(said[0].path, "sort_by");
3394 assert_eq!(said[0].params["parameter"], "sort_by");
3395 assert!(said[0].message.contains("`sort_by`") && !said[0].message.contains("orderby"), "{}", said[0].message);
3396 let kept = rename_typed_sort(
3398 vec![query_violation("orderby", refused.reason, "`orderby` x")],
3399 ¶ms(&[("orderby", "name")], Some("priority"), None),
3400 );
3401 assert_eq!(kept[0].path, "orderby");
3402 }
3403
3404 fn ask(pairs: &[(&str, &str)]) -> ListQueryParams {
3405 ListQueryParams {
3406 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3407 ..Default::default()
3408 }
3409 }
3410
3411 #[test]
3412 fn a_paging_value_that_is_not_a_number_is_refused_on_its_own_parameter() {
3413 for (raw, parameter) in [("page=abc", "page"), ("limit=-3", "limit"), ("page=2&limit=x1", "limit"), ("page=", "page")] {
3414 assert!(serde_urlencoded::from_str::<ListQueryParams>(raw).is_err(), "{raw}");
3415 let v = list_query_violation(raw, "decode");
3416 assert_eq!((v.path.as_str(), v.code.as_str()), (parameter, "invalid_query"), "{raw}");
3417 assert_eq!(v.params["parameter"], parameter);
3418 assert_eq!(v.params["reason"], "not_a_number");
3419 assert!(v.message.contains(parameter), "{}", v.message);
3420 }
3421 }
3422
3423 #[test]
3424 fn a_typed_parameter_sent_twice_is_refused_by_name() {
3425 let raw = "sort_by=a&sort_by=b";
3426 assert!(serde_urlencoded::from_str::<ListQueryParams>(raw).is_err());
3427 let v = list_query_violation(raw, "decode");
3428 assert_eq!((v.path.as_str(), v.params["reason"].as_str()), ("sort_by", Some("invalid_value")));
3429 }
3430
3431 #[test]
3432 fn the_depth_secret_sort_order_and_shaping_refusals_each_name_their_parameter() {
3433 let at = |params: &ListQueryParams, paged: bool| {
3434 query_refusals::<Secretive>(params, paged)
3435 .into_iter()
3436 .map(|v| (v.path, v.params["reason"].as_str().unwrap_or("").to_string()))
3437 .collect::<Vec<_>>()
3438 };
3439 let deep = ListQueryParams { page: 500, limit: 100, ..Default::default() };
3440 assert_eq!(at(&deep, true), vec![("page".to_string(), "too_deep".to_string())]);
3441 assert!(at(&deep, false).is_empty(), "count and aggregate do not page");
3442 let mut p = ask(&[]);
3443 p.sort_order = Some("sideways".into());
3444 assert_eq!(at(&p, true), vec![("sort_order".to_string(), "invalid_value".to_string())]);
3445 p.sort_order = Some("DESC".into());
3446 assert!(at(&p, true).is_empty());
3447 let mut p = ask(&[]);
3448 p.sort_by = Some("tokenHash".into());
3449 let named = query_refusals::<Secretive>(&p, true);
3450 assert_eq!(named[0].path, "sort_by");
3451 assert!(named[0].message.contains("sort_by") && named[0].message.contains("secret"), "{}", named[0].message);
3452 assert_eq!(at(&ask(&[("token_hash[startwith]", "a")]), true)[0].0, "token_hash[startwith]");
3453 assert_eq!(at(&ask(&[("include", "user,nothing")]), true), vec![("include".to_string(), "unknown_relation".to_string())]);
3454 assert_eq!(at(&ask(&[("with", "nothing")]), true), vec![("with".to_string(), "unknown_relation".to_string())]);
3455 assert!(at(&ask(&[("include", "user")]), true).is_empty());
3456 assert_eq!(at(&ask(&[("fields", "id,na me")]), true), vec![("fields".to_string(), "invalid_field_name".to_string())]);
3457 assert!(at(&ask(&[("fields", "id,someUnknownButWellFormed")]), true).is_empty(), "an unknown field is still ignored");
3458 }
3459
3460 #[test]
3461 fn a_refused_query_parameter_answers_400_not_422() {
3462 use crate::persistence::traits::RepositoryError;
3463 use crate::service::ServiceError;
3464 let named = vec![crate::violation::Violation::invalid_query("page", "not_a_number", "x")];
3465 let broken = vec![crate::violation::Violation::new("name", "max_length", "x")];
3466 assert_eq!(service_error_status(&ServiceError::Repository(RepositoryError::Violations(named.clone()))), axum::http::StatusCode::BAD_REQUEST);
3467 assert_eq!(service_error_status(&ServiceError::Violations(named)), axum::http::StatusCode::BAD_REQUEST);
3468 assert_eq!(service_error_status(&ServiceError::Violations(broken.clone())), axum::http::StatusCode::UNPROCESSABLE_ENTITY);
3469 let mixed = vec![crate::violation::Violation::invalid_query("page", "x", "x"), broken[0].clone()];
3471 assert!(!is_query_refusal(&mixed));
3472 assert!(!is_query_refusal(&[]));
3473 }
3474
3475 #[test]
3476 fn a_named_refusal_from_the_repository_becomes_an_invalid_query_violation() {
3477 use crate::persistence::traits::RepositoryError;
3478 let refused = backbone_orm::QueryParamError::unknown_column("sort_by", "full_name");
3479 let err = RepositoryError::from_source(anyhow::Error::from(refused).context("while listing"));
3480 let RepositoryError::Violations(v) = err else { panic!("{err:?}") };
3481 assert_eq!(v.len(), 1);
3482 assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("sort_by", "invalid_query"));
3483 assert_eq!(v[0].params["parameter"], "sort_by");
3484 assert_eq!(v[0].params["reason"], "unknown_column");
3485 assert!(v[0].message.contains("sort_by") && v[0].message.contains("full_name"));
3486 }
3487
3488 #[test]
3489 fn list_params_normalize_page_zero_and_an_oversized_limit() {
3490 let p = ListQueryParams { page: 0, limit: 1000, ..Default::default() }.normalized();
3491 assert_eq!((p.page, p.limit), (1, MAX_PER_PAGE));
3492 let p = ListQueryParams { page: 3, limit: 0, ..Default::default() }.normalized();
3493 assert_eq!((p.page, p.limit), (3, 1));
3494 }
3495
3496 #[test]
3497 fn a_request_that_names_a_secret_as_a_column_is_caught_in_every_grammar() {
3498 let ask = |pairs: &[(&str, &str)], sort_by: Option<&str>| {
3499 let params = ListQueryParams {
3500 filters: pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
3501 sort_by: sort_by.map(str::to_string),
3502 ..Default::default()
3503 };
3504 named_secret_at(¶ms, <Secretive as backbone_orm::EntityRepoMeta>::secret_fields())
3505 };
3506 for pairs in [
3507 vec![("token_hash", "abc")],
3508 vec![("tokenHash[startwith]", "a")],
3509 vec![("orderby", "name,-token_hash")],
3510 vec![("orderby[token_hash]", "asc")],
3511 vec![("searchFields", "name,tokenHash")],
3512 vec![("min", "token_hash")],
3513 vec![("group_by", "tokenHash")],
3514 ] {
3515 let (parameter, field) = ask(&pairs, None).expect("named");
3516 assert_eq!(camel_to_snake_case(&field), "token_hash", "{pairs:?}");
3517 assert_eq!(parameter, pairs[0].0, "the refusal names the parameter that carried it");
3518 }
3519 assert!(ask(&[], Some("tokenHash")).is_some(), "sort_by");
3520 assert!(ask(&[("name[contain]", "token_hash"), ("orderby", "name")], None).is_none(), "a value is not a column");
3521 }
3522
3523 #[test]
3524 fn a_write_response_keeps_secrets_and_private_fields_from_every_caller() {
3525 let out = secure::<Secretive>(secret_row(), None);
3526 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3527 }
3528
3529 #[test]
3530 fn a_routed_response_dto_loses_the_entitys_secrets() {
3531 let out = without_secrets::<Secretive, _>(secret_row());
3532 assert!(out.get("tokenHash").is_none() && out.get("hppPerUnit").is_none());
3533 assert!(out.get("name").is_some());
3534 }
3535
3536 #[test]
3537 fn an_included_row_loses_the_related_models_secrets() {
3538 let raw = serde_json::json!({ "id": "u1", "email": "a@b.c", "password_hash": "$argon2id$..." });
3539 let out = related_row::<Secretive>("user", "users", raw.clone());
3540 assert!(out.get("passwordHash").is_none(), "{out}");
3541 assert_eq!(out["email"], "a@b.c");
3542 assert!(related_row::<Secretive>("other", "others", raw.clone()).get("passwordHash").is_some(), "only the named relation");
3543 backbone_orm::secret_registry::register("employee_probe.staff", &["passwordHash"]);
3545 assert!(related_row::<Secretive>("staff", "employee_probe.staff", raw).get("passwordHash").is_none(), "registered table");
3546 }
3547
3548 #[test]
3549 fn security_no_private_fields_is_noop() {
3550 let v = row();
3551 assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
3552 }
3553
3554 #[test]
3555 fn security_platform_sees_private() {
3556 let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
3557 assert!(out.get("hppPerUnit").is_some());
3558 }
3559
3560 #[test]
3561 fn security_owner_tenant_sees_private() {
3562 let out = apply_field_security(
3563 row(),
3564 Some(&AccessScope::Company(owner_a())),
3565 PRIV,
3566 Some("providerId"),
3567 );
3568 assert!(out.get("hppPerUnit").is_some());
3569 }
3570
3571 #[test]
3572 fn security_other_tenant_stripped() {
3573 let out = apply_field_security(
3574 row(),
3575 Some(&AccessScope::Company(owner_b())),
3576 PRIV,
3577 Some("providerId"),
3578 );
3579 assert!(out.get("hppPerUnit").is_none());
3580 assert!(out.get("name").is_some());
3581 }
3582
3583 #[test]
3584 fn security_absent_scope_fails_closed() {
3585 let out = apply_field_security(row(), None, PRIV, Some("providerId"));
3586 assert!(out.get("hppPerUnit").is_none());
3587 }
3588
3589 #[test]
3592 fn include_relations_parses_include_and_with() {
3593 let mut q = HashMap::new();
3594 q.insert("include".to_string(), "provider, outlet ".to_string());
3595 assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
3596 let mut q2 = HashMap::new();
3597 q2.insert("with".to_string(), "category".to_string());
3598 assert_eq!(include_relations(&q2), vec!["category"]);
3599 assert!(include_relations(&HashMap::new()).is_empty());
3600 }
3601
3602 #[test]
3603 fn snake_to_camel_converts() {
3604 assert_eq!(snake_to_camel("provider_id"), "providerId");
3605 assert_eq!(snake_to_camel("business_name"), "businessName");
3606 assert_eq!(snake_to_camel("id"), "id");
3607 }
3608
3609 #[test]
3610 fn camelize_keys_top_level_only() {
3611 let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
3612 let out = camelize_keys(v);
3613 assert!(out.get("providerId").is_some());
3614 assert!(out.get("metaData").unwrap().get("created_at").is_some());
3616 }
3617
3618 #[test]
3619 fn security_null_owner_only_platform_sees() {
3620 let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
3621 let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
3622 assert!(tenant.get("hppPerUnit").is_none());
3623 let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
3624 assert!(plat.get("hppPerUnit").is_some());
3625 }
3626
3627 #[test]
3628 fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
3629 let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
3630 let (status, axum::Json(body)) =
3631 write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
3632 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
3633 let json = serde_json::to_value(&body).unwrap();
3634 assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
3635 assert_eq!(json["violations"][0]["path"], "status");
3636 assert_eq!(json["violations"][0]["code"], "field_not_writable");
3637
3638 let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
3640 assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
3641 let json = serde_json::to_value(&body).unwrap();
3642 assert_eq!(json["error"], "boom");
3643 assert_eq!(json["violations"][0]["code"], "invalid_request");
3644 assert_eq!(json["violations"][0]["path"], "");
3645 let (_, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::INTERNAL_SERVER_ERROR);
3647 assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
3648 }
3649}
3650
3651#[cfg(test)]
3652mod internal_key_tests {
3653 use super::*;
3654
3655 fn params_with(filters: &[(&str, &str)]) -> ListQueryParams {
3656 ListQueryParams {
3657 page: 1,
3658 limit: 20,
3659 sort_by: None,
3660 sort_order: None,
3661 search: None,
3662 status: None,
3663 filters: filters
3664 .iter()
3665 .map(|(k, v)| (k.to_string(), v.to_string()))
3666 .collect(),
3667 }
3668 }
3669
3670 #[test]
3671 fn internal_keys_never_reach_the_repository() {
3672 let params = params_with(&[
3673 ("__base_condition", "EXISTS(SELECT 1)"),
3674 ("__base_condition[eq]", "1=1"),
3675 ("__anything", "x"),
3676 ("name", "acme"),
3677 ]);
3678 let filters = repository_filters(¶ms);
3679 assert_eq!(filters.len(), 1, "{filters:?}");
3680 assert_eq!(filters.get("name").map(String::as_str), Some("acme"));
3681 let filters = aggregate_filters(¶ms);
3682 assert!(filters.keys().all(|k| !k.starts_with("__")), "{filters:?}");
3683 }
3684}
3685
3686#[cfg(test)]
3687mod service_error_response_tests {
3688 use super::*;
3689 use crate::service::ServiceError;
3690
3691 #[test]
3692 fn a_refused_field_is_422_with_its_violation_never_500() {
3693 let err = ServiceError::Violations(vec![crate::violation::Violation::new(
3694 "state",
3695 "field_not_writable",
3696 "state cannot be changed by a generic write",
3697 )]);
3698 let (status, body) = service_error_response::<()>(&err);
3699 assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
3700 assert_eq!(body.0.violations.as_ref().map(Vec::len), Some(1));
3701 }
3702
3703 #[test]
3704 fn other_errors_keep_their_own_status() {
3705 use axum::http::StatusCode;
3706 let status = |e: ServiceError| service_error_response::<()>(&e).0;
3707 assert_eq!(
3708 status(ServiceError::Validation("bad".into())),
3709 StatusCode::BAD_REQUEST
3710 );
3711 assert_eq!(status(ServiceError::NotFound), StatusCode::NOT_FOUND);
3712 assert_eq!(
3713 status(ServiceError::AlreadyExists("x".into())),
3714 StatusCode::CONFLICT
3715 );
3716 assert_eq!(
3717 status(ServiceError::Internal("boom".into())),
3718 StatusCode::INTERNAL_SERVER_ERROR
3719 );
3720 }
3721}