use crate::extractors::JsonOrForm;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ApiResponse<T> {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub violations: Option<Vec<crate::violation::Violation>>,
}
impl<T> ApiResponse<T> {
pub fn rejected(error: impl Into<String>, violations: Vec<crate::violation::Violation>) -> Self {
Self {
success: false,
data: None,
message: None,
error: Some(error.into()),
violations: (!violations.is_empty()).then_some(violations),
}
}
pub fn success(data: T, message: Option<String>) -> Self {
Self {
success: true,
data: Some(data),
message,
error: None,
violations: None,
}
}
pub fn ok(data: T) -> Self {
Self {
success: true,
data: Some(data),
message: None,
error: None,
violations: None,
}
}
pub fn success_with_message(data: T, message: impl Into<String>) -> Self {
Self {
success: true,
data: Some(data),
message: Some(message.into()),
error: None,
violations: None,
}
}
pub fn error(error: impl Into<String>) -> Self {
Self {
success: false,
data: None,
message: None,
error: Some(error.into()),
violations: None,
}
}
pub fn not_found(entity: &str, id: &str) -> Self {
Self {
success: false,
data: None,
message: None,
error: Some(format!("{} with id '{}' not found", entity, id)),
violations: None,
}
}
}
fn write_error<T>(
violations: Option<Vec<crate::violation::Violation>>,
err: &impl std::fmt::Display,
fallback: axum::http::StatusCode,
) -> (axum::http::StatusCode, axum::Json<ApiResponse<T>>) {
match violations {
Some(v) => (axum::http::StatusCode::UNPROCESSABLE_ENTITY, axum::Json(ApiResponse::rejected(err.to_string(), v))),
None => (fallback, axum::Json(ApiResponse::error(err.to_string()))),
}
}
#[derive(Debug, Deserialize, Default, Clone)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ListQueryParams {
#[serde(default = "default_page")]
pub page: u32,
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub sort_by: Option<String>,
#[serde(default)]
pub sort_order: Option<String>,
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(flatten)]
pub filters: HashMap<String, String>,
}
fn default_page() -> u32 { 1 }
fn default_limit() -> u32 { 20 }
const RESERVED_QUERY_KEYS: [&str; 3] = ["fields", "include", "with"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub occurred_at: String,
pub action: String,
pub actor: String,
pub changed: serde_json::Value,
pub reason: Option<String>,
pub correlation_id: Option<String>,
}
#[async_trait::async_trait]
pub trait HistoryProvider: Send + Sync {
async fn history(
&self,
table: &str,
id: &str,
limit: u32,
offset: u32,
) -> Result<Option<Vec<HistoryEntry>>, String>;
}
const AGGREGATE_QUERY_KEYS: [&str; 7] =
["group_by", "sum", "avg", "min", "max", "group_limit", "group_label"];
fn aggregate_spec(params: &ListQueryParams) -> backbone_orm::repository::AggregateSpec {
use backbone_orm::repository::{AggregateFn, AggregateSpec};
let mut reductions = Vec::new();
for (key, func) in [
("sum", AggregateFn::Sum),
("avg", AggregateFn::Avg),
("min", AggregateFn::Min),
("max", AggregateFn::Max),
] {
if let Some(raw) = params.filters.get(key) {
for field in raw.split(',').map(str::trim).filter(|f| !f.is_empty()) {
reductions.push((func, field.to_string()));
}
}
}
AggregateSpec {
group_by: params
.filters
.get("group_by")
.map(|g| g.trim().to_string())
.filter(|g| !g.is_empty()),
reductions,
group_limit: params
.filters
.get("group_limit")
.and_then(|l| l.trim().parse::<usize>().ok())
.unwrap_or(0),
label_field: params
.filters
.get("group_label")
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty()),
label_relation: None,
}
}
fn repository_filters(params: &ListQueryParams) -> HashMap<String, String> {
let mut filters = params.filters.clone();
for key in RESERVED_QUERY_KEYS {
filters.remove(key);
}
if let Some(search) = params.search.clone() {
filters.insert("search".to_string(), search);
}
if let Some(status) = params.status.clone() {
filters.insert("status".to_string(), status);
}
filters
}
fn aggregate_filters(params: &ListQueryParams) -> HashMap<String, String> {
let mut filters = repository_filters(params);
for key in AGGREGATE_QUERY_KEYS {
filters.remove(key);
}
filters
}
fn sparse_fields(query: &HashMap<String, String>) -> Vec<String> {
query
.get("fields")
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|f| !f.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn to_response_value<R: Serialize>(r: R) -> serde_json::Value {
serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
}
fn project_sparse(mut value: serde_json::Value, fields: &[String]) -> serde_json::Value {
if fields.is_empty() {
return value;
}
if let serde_json::Value::Object(map) = &mut value {
map.retain(|k, _| k == "id" || fields.iter().any(|f| f == k));
}
value
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessScope {
Platform,
Company(uuid::Uuid),
}
impl AccessScope {
pub fn company(&self) -> Option<uuid::Uuid> {
match self {
AccessScope::Platform => None,
AccessScope::Company(id) => Some(*id),
}
}
}
fn apply_field_security(
mut value: serde_json::Value,
scope: Option<&AccessScope>,
private_fields: &[&str],
owner_field: Option<&str>,
) -> serde_json::Value {
if private_fields.is_empty() {
return value;
}
let can_see_private = match scope {
Some(AccessScope::Platform) => true,
Some(AccessScope::Company(id)) => owner_field
.and_then(|f| value.get(f))
.and_then(|v| v.as_str())
.and_then(|owner| uuid::Uuid::parse_str(owner).ok())
.is_some_and(|owner| owner == *id),
None => false,
};
if !can_see_private {
if let serde_json::Value::Object(map) = &mut value {
for f in private_fields {
map.remove(*f);
}
}
}
value
}
fn include_relations(query: &HashMap<String, String>) -> Vec<String> {
query
.get("include")
.or_else(|| query.get("with"))
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn snake_to_camel(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut upper = false;
for c in s.chars() {
if c == '_' {
upper = true;
} else if upper {
out.extend(c.to_uppercase());
upper = false;
} else {
out.push(c);
}
}
out
}
fn camelize_keys(v: serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(m) => serde_json::Value::Object(
m.into_iter().map(|(k, val)| (snake_to_camel(&k), val)).collect(),
),
other => other,
}
}
async fn expand_includes<S, E, C, U>(
service: &S,
rows: &mut [serde_json::Value],
includes: &[String],
) where
S: CrudService<E, C, U>,
E: backbone_orm::EntityRepoMeta + Send + Sync + 'static,
C: Send + Sync + 'static,
U: Send + Sync + 'static,
{
if includes.is_empty() || rows.is_empty() {
return;
}
for (rel_name, table, fk_field) in E::relations() {
if !includes.iter().any(|i| i == rel_name) {
continue;
}
let mut ids: Vec<String> = rows
.iter()
.filter_map(|r| r.get(fk_field).and_then(|v| v.as_str()).map(str::to_string))
.collect();
ids.sort();
ids.dedup();
if ids.is_empty() {
continue;
}
let related = service.fetch_related_json(table, &ids).await;
let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
for obj in related {
if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(str::to_string) {
by_id.insert(id, camelize_keys(obj));
}
}
for r in rows.iter_mut() {
let related_obj = r
.get(fk_field)
.and_then(|v| v.as_str())
.and_then(|id| by_id.get(id).cloned())
.unwrap_or(serde_json::Value::Null);
if let serde_json::Value::Object(m) = r {
m.insert((*rel_name).to_string(), related_obj);
}
}
}
}
pub const MAX_PER_PAGE: u32 = 100;
pub const MAX_PAGINATION_OFFSET: u32 = 10_000;
fn pagination_depth_error(page: u32, limit: u32) -> Option<String> {
let effective_limit = limit.clamp(1, MAX_PER_PAGE);
let offset = page.max(1).saturating_sub(1).saturating_mul(effective_limit);
if offset > MAX_PAGINATION_OFFSET {
Some(format!(
"Result set too deep: offset {offset} exceeds the maximum of \
{MAX_PAGINATION_OFFSET}. Please add filters to narrow your search."
))
} else {
None
}
}
pub use crate::service::MAX_BATCH_SIZE;
fn batch_size_error(count: usize) -> Option<String> {
if count > MAX_BATCH_SIZE {
Some(format!(
"Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
))
} else {
None
}
}
fn is_bad_query_error(msg: &str) -> bool {
let m = msg.to_lowercase();
m.contains("does not exist")
|| m.contains("invalid input syntax")
|| m.contains("42703")
|| m.contains("not a column of this entity")
|| m.contains("needs a numeric column")
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct PaginationResponse {
pub total: u64,
pub page: u32,
pub limit: u32,
pub total_pages: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prev_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_more: Option<bool>,
}
impl PaginationResponse {
pub fn new(total: u64, page: u32, limit: u32) -> Self {
let total_pages = if limit == 0 { 0 } else { ((total as f64) / (limit as f64)).ceil() as u32 };
Self { total, page, limit, total_pages, next_cursor: None, prev_cursor: None, has_more: None }
}
pub fn from_info(info: &backbone_orm::repository::PaginationInfo) -> Self {
Self {
total: info.total,
page: info.page,
limit: info.per_page,
total_pages: info.total_pages,
next_cursor: info.next_cursor.clone(),
prev_cursor: info.prev_cursor.clone(),
has_more: info.has_more,
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct PaginatedResponse<T> {
pub data: Vec<T>,
pub meta: PaginationResponse,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct PaginatedApiResponse<T> {
pub success: bool,
pub data: Vec<T>,
pub meta: PaginationResponse,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl<T> PaginatedApiResponse<T> {
pub fn ok_with_info(data: Vec<T>, info: &backbone_orm::repository::PaginationInfo) -> Self {
Self {
success: true,
data,
meta: PaginationResponse::from_info(info),
error: None,
}
}
pub fn ok(data: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
Self {
success: true,
data,
meta: PaginationResponse::new(total, page, limit),
error: None,
}
}
pub fn from_paginated(resp: PaginatedResponse<T>) -> Self {
Self {
success: true,
data: resp.data,
meta: resp.meta,
error: None,
}
}
pub fn error(error: impl Into<String>) -> Self {
Self {
success: false,
data: Vec::new(),
meta: PaginationResponse::new(0, 0, 0),
error: Some(error.into()),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BulkCreateRequest<T> {
pub items: Vec<T>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BulkResponse<T> {
pub items: Vec<T>,
pub total: usize,
pub failed: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpsertRequest<T> {
pub entity: T,
pub create_if_not_exists: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BatchIdsRequest {
pub ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(bound = "U: DeserializeOwned")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BulkUpdateItem<U> {
pub id: String,
#[serde(flatten)]
#[cfg_attr(feature = "openapi", schema(value_type = Object))]
pub data: U,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BulkPatchItem {
pub id: String,
pub patch: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum BulkPatchRequest {
Shared {
ids: Vec<String>,
patch: HashMap<String, serde_json::Value>,
},
PerItem { items: Vec<BulkPatchItem> },
}
impl BulkPatchRequest {
fn into_items(self) -> Vec<(String, HashMap<String, serde_json::Value>)> {
match self {
BulkPatchRequest::Shared { ids, patch } => {
ids.into_iter().map(|id| (id, patch.clone())).collect()
}
BulkPatchRequest::PerItem { items } => {
items.into_iter().map(|it| (it.id, it.patch)).collect()
}
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct FilterOptions {
pub filters: HashMap<String, String>,
pub sort_by: Option<String>,
pub sort_order: Option<SortOrder>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub enum SortOrder {
#[default]
Asc,
Desc,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ListRequest {
pub page: Option<u32>,
pub limit: Option<u32>,
pub sort_by: Option<String>,
pub sort_order: Option<SortOrder>,
pub filters: Option<HashMap<String, String>>,
}
impl Default for ListRequest {
fn default() -> Self {
Self {
page: Some(1),
limit: Some(20),
sort_by: None,
sort_order: None,
filters: None,
}
}
}
#[async_trait::async_trait]
pub trait CrudService<Entity, CreateDto, UpdateDto>: Send + Sync
where
Entity: Send + Sync + 'static,
CreateDto: Send + Sync + 'static,
UpdateDto: Send + Sync + 'static,
{
type Error: std::error::Error + Send + Sync;
fn violations_of(err: &Self::Error) -> Option<Vec<crate::violation::Violation>> {
let _ = err;
None
}
fn entity_name() -> &'static str;
async fn fetch_related_json(
&self,
_table: &str,
_ids: &[String],
) -> Vec<serde_json::Value> {
Vec::new()
}
async fn list(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
async fn list_with_info(
&self,
page: u32,
limit: u32,
filters: HashMap<String, String>,
) -> Result<(Vec<Entity>, backbone_orm::repository::PaginationInfo), Self::Error> {
let (rows, total) = self.list(page, limit, filters).await?;
Ok((
rows,
backbone_orm::repository::PaginationInfo::new(page, limit, total),
))
}
async fn create(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
async fn get_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
async fn update(&self, id: &str, dto: UpdateDto) -> Result<Option<Entity>, Self::Error>;
async fn partial_update(&self, id: &str, fields: HashMap<String, serde_json::Value>) -> Result<Option<Entity>, Self::Error>;
async fn soft_delete(&self, id: &str) -> Result<bool, Self::Error>;
async fn bulk_create(&self, items: Vec<CreateDto>) -> Result<Vec<Entity>, Self::Error>;
async fn upsert(&self, dto: CreateDto) -> Result<Entity, Self::Error>;
async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<Entity>, u64), Self::Error>;
async fn restore(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
async fn empty_trash(&self) -> Result<u64, Self::Error>;
async fn get_deleted_by_id(&self, id: &str) -> Result<Option<Entity>, Self::Error>;
async fn permanent_delete(&self, id: &str) -> Result<bool, Self::Error>;
async fn list_deleted_filtered(&self, page: u32, limit: u32, filters: HashMap<String, String>) -> Result<(Vec<Entity>, u64), Self::Error>;
async fn count_active(&self) -> Result<u64, Self::Error>;
fn table_name(&self) -> Option<&str> {
None
}
async fn aggregate(
&self,
spec: &backbone_orm::repository::AggregateSpec,
filters: HashMap<String, String>,
) -> Result<backbone_orm::repository::AggregateResult, Self::Error>;
async fn count_active_filtered(
&self,
filters: HashMap<String, String>,
) -> Result<u64, Self::Error> {
self.list(1, 1, filters).await.map(|(_, total)| total)
}
async fn count_deleted(&self) -> Result<u64, Self::Error>;
async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
let mut n = 0;
for id in ids {
if self.soft_delete(&id).await? {
n += 1;
}
}
Ok(n)
}
async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<Entity>, Self::Error> {
let mut out = Vec::with_capacity(ids.len());
for id in ids {
if let Some(e) = self.restore(&id).await? {
out.push(e);
}
}
Ok(out)
}
async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, Self::Error> {
let mut n = 0;
for id in ids {
if self.permanent_delete(&id).await? {
n += 1;
}
}
Ok(n)
}
async fn restore_all(&self) -> Result<u64, Self::Error> {
Ok(0)
}
async fn bulk_update(&self, items: Vec<(String, UpdateDto)>) -> Result<Vec<Entity>, Self::Error> {
let mut out = Vec::with_capacity(items.len());
for (id, dto) in items {
if let Some(e) = self.update(&id, dto).await? {
out.push(e);
}
}
Ok(out)
}
async fn bulk_partial_update(
&self,
items: Vec<(String, HashMap<String, serde_json::Value>)>,
) -> Result<Vec<Entity>, Self::Error> {
let mut out = Vec::with_capacity(items.len());
for (id, fields) in items {
if let Some(e) = self.partial_update(&id, fields).await? {
out.push(e);
}
}
Ok(out)
}
}
pub struct BackboneCrudHandler<S, E, C, U, R>
where
S: CrudService<E, C, U> + 'static,
E: Serialize + Send + Sync + 'static,
C: DeserializeOwned + Send + Sync + 'static,
U: DeserializeOwned + Send + Sync + 'static,
R: From<E> + Serialize + Send + Sync + 'static,
{
service: Arc<S>,
_phantom: std::marker::PhantomData<(E, C, U, R)>,
}
impl<S, E, C, U, R> BackboneCrudHandler<S, E, C, U, R>
where
S: CrudService<E, C, U> + 'static,
E: Serialize + Send + Sync + Clone + backbone_orm::EntityRepoMeta + 'static,
C: DeserializeOwned + Send + Sync + 'static,
U: DeserializeOwned + Send + Sync + 'static,
R: From<E> + Serialize + Send + Sync + 'static,
{
pub fn new(service: Arc<S>) -> Self {
Self {
service,
_phantom: std::marker::PhantomData,
}
}
pub fn routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
where
S: Clone,
{
Self::read_routes(service.clone(), base_path)
.merge(Self::write_routes(service, base_path))
}
pub fn read_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
where
S: Clone,
{
use axum::{
extract::{Path, Query},
routing::get,
Extension, Router,
};
let handler = Arc::new(Self::new(service));
Router::new()
.route(base_path, get({
let h = handler.clone();
move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
Self::list_handler(h, query, access).await
}
}))
.route(&format!("{}/trash", base_path), get({
let h = handler.clone();
move |query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
Self::list_deleted_handler(h, query, access).await
}
}))
.route(&format!("{}/:id", base_path), get({
let h = handler.clone();
move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
Self::get_handler(h, path, query, access).await
}
}))
.route(&format!("{}/:id/deleted", base_path), get({
let h = handler.clone();
move |path: Path<String>, query: Query<ListQueryParams>, access: Option<Extension<AccessScope>>| async move {
Self::get_deleted_handler(h, path, query, access).await
}
}))
.route(&format!("{}/:id/history", base_path), get({
let h = handler.clone();
move |path: axum::extract::Path<String>,
query: axum::extract::Query<ListQueryParams>,
provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>| async move {
Self::history_handler(h, path, query, provider).await
}
}))
.route(&format!("{}/aggregate", base_path), get({
let h = handler.clone();
move |query: axum::extract::Query<ListQueryParams>| async move {
Self::aggregate_handler(h, query).await
}
}))
.route(&format!("{}/count", base_path), get({
let h = handler.clone();
move |query: axum::extract::Query<ListQueryParams>| async move {
Self::count_active_handler(h, query).await
}
}))
.route(&format!("{}/trash/count", base_path), get({
let h = handler.clone();
move || async move {
Self::count_deleted_handler(h).await
}
}))
}
pub fn write_routes(service: Arc<S>, base_path: &str) -> axum::Router<()>
where
S: Clone,
{
use axum::{
extract::Path,
routing::{delete, patch, post, put},
Router,
};
let handler = Arc::new(Self::new(service));
Router::new()
.route(base_path, post({
let h = handler.clone();
move |body: JsonOrForm<C>| async move {
Self::create_handler(h, body).await
}
}))
.route(&format!("{}/bulk", base_path), post({
let h = handler.clone();
move |body: JsonOrForm<Vec<C>>| async move {
Self::bulk_create_handler(h, body).await
}
}))
.route(&format!("{}/upsert", base_path), post({
let h = handler.clone();
move |body: JsonOrForm<C>| async move {
Self::upsert_handler(h, body).await
}
}))
.route(&format!("{}/delete/bulk", base_path), post({
let h = handler.clone();
move |body: JsonOrForm<BatchIdsRequest>| async move {
Self::bulk_delete_handler(h, body).await
}
}))
.route(&format!("{}/restore/bulk", base_path), post({
let h = handler.clone();
move |body: JsonOrForm<BatchIdsRequest>| async move {
Self::bulk_restore_handler(h, body).await
}
}))
.route(&format!("{}/restore/all", base_path), post({
let h = handler.clone();
move || async move {
Self::restore_all_handler(h).await
}
}))
.route(&format!("{}/trash/bulk", base_path), delete({
let h = handler.clone();
move |body: JsonOrForm<BatchIdsRequest>| async move {
Self::bulk_permanent_delete_handler(h, body).await
}
}))
.route(&format!("{}/bulk", base_path), put({
let h = handler.clone();
move |body: JsonOrForm<Vec<BulkUpdateItem<U>>>| async move {
Self::bulk_update_handler(h, body).await
}
}))
.route(&format!("{}/bulk", base_path), patch({
let h = handler.clone();
move |body: JsonOrForm<BulkPatchRequest>| async move {
Self::bulk_patch_handler(h, body).await
}
}))
.route(&format!("{}/empty", base_path), delete({
let h = handler.clone();
move || async move {
Self::empty_trash_handler(h).await
}
}))
.route(&format!("{}/trash/:id", base_path), delete({
let h = handler.clone();
move |path: Path<String>| async move {
Self::permanent_delete_handler(h, path).await
}
}))
.route(&format!("{}/:id", base_path), put({
let h = handler.clone();
move |path: Path<String>, body: JsonOrForm<U>| async move {
Self::update_handler(h, path, body).await
}
}))
.route(&format!("{}/:id", base_path), patch({
let h = handler.clone();
move |path: Path<String>, body: JsonOrForm<HashMap<String, serde_json::Value>>| async move {
Self::partial_update_handler(h, path, body).await
}
}))
.route(&format!("{}/:id", base_path), delete({
let h = handler.clone();
move |path: Path<String>| async move {
Self::delete_handler(h, path).await
}
}))
.route(&format!("{}/:id/restore", base_path), post({
let h = handler.clone();
move |path: Path<String>| async move {
Self::restore_handler(h, path).await
}
}))
}
async fn list_handler(
handler: Arc<Self>,
axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
access: Option<axum::Extension<AccessScope>>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
if let Some(err) = pagination_depth_error(params.page, params.limit) {
return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
}
let fields = sparse_fields(¶ms.filters);
let includes = include_relations(¶ms.filters);
let scope = access.map(|axum::Extension(s)| s);
let filters = repository_filters(¶ms);
match handler
.service
.list_with_info(params.page, params.limit, filters)
.await
{
Ok((entities, info)) => {
let mut rows: Vec<serde_json::Value> = entities
.into_iter()
.map(|e| {
apply_field_security(
to_response_value(R::from(e)),
scope.as_ref(),
E::private_fields(),
E::owner_field(),
)
})
.collect();
expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
let items: Vec<serde_json::Value> =
rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
let response = PaginatedApiResponse::ok_with_info(items, &info);
(StatusCode::OK, Json(response))
}
Err(e) => {
let msg = e.to_string();
if is_bad_query_error(&msg) {
(StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
format!("Invalid query parameter or filter: {msg}"),
)))
} else {
(StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
}
}
}
}
async fn create_handler(
handler: Arc<Self>,
JsonOrForm(dto): JsonOrForm<C>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.create(dto).await {
Ok(entity) => {
let response: R = entity.into();
(StatusCode::CREATED, Json(ApiResponse::ok(response)))
}
Err(e) if S::violations_of(&e).is_some() => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
Err(e) => {
let error_str = e.to_string();
if error_str.contains("conflict") || error_str.contains("already exists") {
(StatusCode::CONFLICT, Json(ApiResponse::<R>::error(error_str)))
} else {
(StatusCode::BAD_REQUEST, Json(ApiResponse::<R>::error(error_str)))
}
}
}
}
async fn get_handler(
handler: Arc<Self>,
axum::extract::Path(id): axum::extract::Path<String>,
axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
access: Option<axum::Extension<AccessScope>>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
let fields = sparse_fields(¶ms.filters);
let includes = include_relations(¶ms.filters);
let scope = access.map(|axum::Extension(s)| s);
match handler.service.get_by_id(&id).await {
Ok(Some(entity)) => {
let secured = apply_field_security(
to_response_value(R::from(entity)),
scope.as_ref(),
E::private_fields(),
E::owner_field(),
);
let mut rows = [secured];
expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
let [secured] = rows;
let value = project_sparse(secured, &fields);
(StatusCode::OK, Json(ApiResponse::ok(value)))
}
Ok(None) => {
(StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(S::entity_name(), &id)))
}
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
}
}
}
async fn update_handler(
handler: Arc<Self>,
axum::extract::Path(id): axum::extract::Path<String>,
JsonOrForm(dto): JsonOrForm<U>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.update(&id, dto).await {
Ok(Some(entity)) => {
let response: R = entity.into();
(StatusCode::OK, Json(ApiResponse::ok(response)))
}
Ok(None) => {
(StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
}
Err(e) => {
write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
}
}
}
async fn partial_update_handler(
handler: Arc<Self>,
axum::extract::Path(id): axum::extract::Path<String>,
JsonOrForm(fields): JsonOrForm<HashMap<String, serde_json::Value>>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
let fields: HashMap<String, serde_json::Value> = fields
.into_iter()
.map(|(k, v)| (camel_to_snake_case(&k), v))
.collect();
match handler.service.partial_update(&id, fields).await {
Ok(Some(entity)) => {
let response: R = entity.into();
(StatusCode::OK, Json(ApiResponse::ok(response)))
}
Ok(None) => {
(StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
}
Err(e) => {
write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
}
}
}
async fn delete_handler(
handler: Arc<Self>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.soft_delete(&id).await {
Ok(true) => {
(StatusCode::OK, Json(ApiResponse::<()>::success_with_message((), "Entity deleted successfully")))
}
Ok(false) => {
(StatusCode::NOT_FOUND, Json(ApiResponse::<()>::not_found(S::entity_name(), &id)))
}
Err(e) => {
write_error(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
async fn bulk_create_handler(
handler: Arc<Self>,
JsonOrForm(items): JsonOrForm<Vec<C>>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.bulk_create(items).await {
Ok(entities) => {
let result_items: Vec<R> = entities.into_iter().map(R::from).collect();
let total = result_items.len();
let response = BulkResponse {
items: result_items,
total,
failed: 0,
errors: vec![],
};
(StatusCode::CREATED, Json(ApiResponse::ok(response)))
}
Err(e) => {
write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
}
}
}
async fn bulk_delete_handler(
handler: Arc<Self>,
JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
if let Some(err) = batch_size_error(req.ids.len()) {
return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
}
match handler.service.bulk_soft_delete(req.ids).await {
Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
serde_json::json!({ "soft_deleted": count }),
format!("Soft-deleted {count} item(s)"),
))),
Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
}
}
async fn bulk_restore_handler(
handler: Arc<Self>,
JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
if let Some(err) = batch_size_error(req.ids.len()) {
return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(err)));
}
match handler.service.bulk_restore(req.ids).await {
Ok(entities) => {
let items: Vec<R> = entities.into_iter().map(R::from).collect();
let total = items.len();
(StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
}
Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
}
}
async fn restore_all_handler(
handler: Arc<Self>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.restore_all().await {
Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
serde_json::json!({ "restored": count }),
format!("Restored {count} item(s) from trash"),
))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
}
}
async fn bulk_permanent_delete_handler(
handler: Arc<Self>,
JsonOrForm(req): JsonOrForm<BatchIdsRequest>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
if let Some(err) = batch_size_error(req.ids.len()) {
return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error(err)));
}
match handler.service.bulk_permanent_delete(req.ids).await {
Ok(count) => (StatusCode::OK, Json(ApiResponse::success_with_message(
serde_json::json!({ "permanently_deleted": count }),
format!("Permanently deleted {count} item(s)"),
))),
Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
}
}
async fn bulk_update_handler(
handler: Arc<Self>,
JsonOrForm(items): JsonOrForm<Vec<BulkUpdateItem<U>>>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
if let Some(err) = batch_size_error(items.len()) {
return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(err)));
}
let items: Vec<(String, U)> = items.into_iter().map(|it| (it.id, it.data)).collect();
match handler.service.bulk_update(items).await {
Ok(entities) => {
let items: Vec<R> = entities.into_iter().map(R::from).collect();
let total = items.len();
(StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
}
Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
}
}
async fn bulk_patch_handler(
handler: Arc<Self>,
JsonOrForm(req): JsonOrForm<BulkPatchRequest>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
let items = req.into_items();
if let Some(err) = batch_size_error(items.len()) {
return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BulkResponse<R>>::error(err)));
}
let items: Vec<(String, HashMap<String, serde_json::Value>)> = items
.into_iter()
.map(|(id, fields)| {
let fields = fields
.into_iter()
.map(|(k, v)| (camel_to_snake_case(&k), v))
.collect();
(id, fields)
})
.collect();
match handler.service.bulk_partial_update(items).await {
Ok(entities) => {
let items: Vec<R> = entities.into_iter().map(R::from).collect();
let total = items.len();
(StatusCode::OK, Json(ApiResponse::ok(BulkResponse { items, total, failed: 0, errors: vec![] })))
}
Err(e) => write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST),
}
}
async fn upsert_handler(
handler: Arc<Self>,
JsonOrForm(dto): JsonOrForm<C>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.upsert(dto).await {
Ok(entity) => {
let response: R = entity.into();
(StatusCode::OK, Json(ApiResponse::ok(response)))
}
Err(e) => {
write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
}
}
}
async fn list_deleted_handler(
handler: Arc<Self>,
axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
access: Option<axum::Extension<AccessScope>>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
if let Some(err) = pagination_depth_error(params.page, params.limit) {
return (StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(err)));
}
let fields = sparse_fields(¶ms.filters);
let includes = include_relations(¶ms.filters);
let scope = access.map(|axum::Extension(s)| s);
match handler.service.list_deleted(params.page, params.limit).await {
Ok((entities, total)) => {
let mut rows: Vec<serde_json::Value> = entities
.into_iter()
.map(|e| {
apply_field_security(
to_response_value(R::from(e)),
scope.as_ref(),
E::private_fields(),
E::owner_field(),
)
})
.collect();
expand_includes::<S, E, C, U>(&*handler.service, &mut rows, &includes).await;
let items: Vec<serde_json::Value> =
rows.into_iter().map(|r| project_sparse(r, &fields)).collect();
let response = PaginatedApiResponse::ok(items, total, params.page, params.limit);
(StatusCode::OK, Json(response))
}
Err(e) => {
let msg = e.to_string();
if is_bad_query_error(&msg) {
(StatusCode::BAD_REQUEST, Json(PaginatedApiResponse::<serde_json::Value>::error(
format!("Invalid query parameter or filter: {msg}"),
)))
} else {
(StatusCode::INTERNAL_SERVER_ERROR, Json(PaginatedApiResponse::<serde_json::Value>::error(msg)))
}
}
}
}
async fn restore_handler(
handler: Arc<Self>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.restore(&id).await {
Ok(Some(entity)) => {
let response: R = entity.into();
(StatusCode::OK, Json(ApiResponse::success_with_message(response, "Entity restored successfully")))
}
Ok(None) => {
(StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(S::entity_name(), &id)))
}
Err(e) => {
write_error(S::violations_of(&e), &e, StatusCode::BAD_REQUEST)
}
}
}
async fn empty_trash_handler(
handler: Arc<Self>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.empty_trash().await {
Ok(count) => {
(StatusCode::OK, Json(ApiResponse::success_with_message(
serde_json::json!({ "deleted_count": count }),
format!("Successfully deleted {} items from trash", count)
)))
}
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
}
}
}
async fn get_deleted_handler(
handler: Arc<Self>,
axum::extract::Path(id): axum::extract::Path<String>,
axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
access: Option<axum::Extension<AccessScope>>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
let fields = sparse_fields(¶ms.filters);
let scope = access.map(|axum::Extension(s)| s);
match handler.service.get_deleted_by_id(&id).await {
Ok(Some(entity)) => {
let secured = apply_field_security(
to_response_value(R::from(entity)),
scope.as_ref(),
E::private_fields(),
E::owner_field(),
);
let value = project_sparse(secured, &fields);
(StatusCode::OK, Json(ApiResponse::ok(value)))
}
Ok(None) => {
(StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found(&format!("Deleted {}", S::entity_name()), &id)))
}
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
}
}
}
async fn permanent_delete_handler(
handler: Arc<Self>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> axum::response::Response {
use axum::{http::StatusCode, Json, response::IntoResponse};
match handler.service.get_deleted_by_id(&id).await {
Ok(Some(_)) => {
match handler.service.permanent_delete(&id).await {
Ok(true) => {
StatusCode::NO_CONTENT.into_response()
}
Ok(false) => {
(StatusCode::NOT_FOUND, Json(ApiResponse::<R>::error(
format!("Failed to permanently delete {}", S::entity_name())
))).into_response()
}
Err(e) => {
write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
}
}
}
Ok(None) => {
(StatusCode::NOT_FOUND, Json(ApiResponse::<R>::not_found(
&format!("{} in trash", S::entity_name()), &id
))).into_response()
}
Err(e) => {
write_error::<R>(S::violations_of(&e), &e, StatusCode::INTERNAL_SERVER_ERROR).into_response()
}
}
}
async fn count_active_handler(
handler: Arc<Self>,
axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.count_active_filtered(repository_filters(¶ms)).await {
Ok(count) => {
(StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
}
Err(e) => {
let msg = e.to_string();
let code = if is_bad_query_error(&msg) {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(code, Json(ApiResponse::<serde_json::Value>::error(msg)))
}
}
}
async fn aggregate_handler(
handler: Arc<Self>,
axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
let spec = aggregate_spec(¶ms);
match handler.service.aggregate(&spec, aggregate_filters(¶ms)).await {
Ok(result) => {
let render = |g: &backbone_orm::repository::AggregateGroup| {
let mut out = serde_json::Map::new();
out.insert("key".into(), match &g.key {
Some(k) => serde_json::Value::String(k.clone()),
None => serde_json::Value::Null,
});
if g.label.is_some() {
out.insert(
"label".into(),
serde_json::Value::String(g.label.clone().unwrap()),
);
}
out.insert("count".into(), serde_json::json!(g.count));
for (compound, value) in &g.values {
let Some((func, field)) = compound.split_once(':') else { continue };
let slot = out
.entry(func.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let Some(obj) = slot.as_object_mut() {
obj.insert(field.to_string(), match value {
Some(v) => serde_json::Value::String(v.clone()),
None => serde_json::Value::Null,
});
}
}
serde_json::Value::Object(out)
};
let body = serde_json::json!({
"groups": result.groups.iter().map(render).collect::<Vec<_>>(),
"total": render(&result.total),
"truncated": result.truncated,
});
(StatusCode::OK, Json(ApiResponse::ok(body)))
}
Err(e) => {
let msg = e.to_string();
let code = if is_bad_query_error(&msg) {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(code, Json(ApiResponse::<serde_json::Value>::error(msg)))
}
}
}
async fn history_handler(
handler: Arc<Self>,
axum::extract::Path(id): axum::extract::Path<String>,
axum::extract::Query(params): axum::extract::Query<ListQueryParams>,
provider: Option<axum::Extension<std::sync::Arc<dyn HistoryProvider>>>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
let Some(axum::Extension(provider)) = provider else {
return (
StatusCode::NOT_IMPLEMENTED,
Json(ApiResponse::<serde_json::Value>::error(
"history is not configured for this service".to_string(),
)),
);
};
let Some(table) = handler.service.table_name().map(|t| t.to_string()) else {
return (
StatusCode::NOT_IMPLEMENTED,
Json(ApiResponse::<serde_json::Value>::error(
"this entity cannot name its table, so its history cannot be keyed".to_string(),
)),
);
};
let limit = params.limit.clamp(1, 200);
let offset = params.page.saturating_sub(1) * limit;
match provider.history(&table, &id, limit, offset).await {
Ok(Some(entries)) => (
StatusCode::OK,
Json(ApiResponse::ok(serde_json::json!({
"audited": true,
"entries": entries,
}))),
),
Ok(None) => (
StatusCode::OK,
Json(ApiResponse::ok(serde_json::json!({
"audited": false,
"entries": serde_json::Value::Null,
}))),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<serde_json::Value>::error(e)),
),
}
}
async fn count_deleted_handler(
handler: Arc<Self>,
) -> impl axum::response::IntoResponse {
use axum::{http::StatusCode, Json};
match handler.service.count_deleted().await {
Ok(count) => {
(StatusCode::OK, Json(ApiResponse::ok(serde_json::json!({ "count": count }))))
}
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string())))
}
}
}
}
pub trait BackboneHttpHandler<T>: Send + Sync {
fn list(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
fn create(&self, request: T) -> anyhow::Result<ApiResponse<T>>;
fn get_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
fn update(&self, id: &uuid::Uuid, request: T) -> anyhow::Result<ApiResponse<T>>;
fn partial_update(&self, id: &uuid::Uuid, fields: HashMap<String, serde_json::Value>) -> anyhow::Result<ApiResponse<T>>;
fn soft_delete(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<()>>;
fn bulk_create(&self, request: BulkCreateRequest<T>) -> anyhow::Result<ApiResponse<BulkResponse<T>>>;
fn upsert(&self, request: UpsertRequest<T>) -> anyhow::Result<ApiResponse<T>>;
fn list_deleted(&self, request: ListRequest) -> anyhow::Result<ApiResponse<Vec<T>>>;
fn restore(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
fn empty_trash(&self) -> anyhow::Result<ApiResponse<()>>;
fn get_deleted_by_id(&self, id: &uuid::Uuid) -> anyhow::Result<ApiResponse<T>>;
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct PaginationRequest {
pub page: u32,
pub limit: u32,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
}
fn camel_to_snake_case(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
let mut result = String::with_capacity(key.len() + 2);
for (i, &c) in chars.iter().enumerate() {
if c.is_ascii_uppercase() {
let prev = if i > 0 { chars[i - 1] } else { '\0' };
let next = chars.get(i + 1).copied().unwrap_or('\0');
let crosses_lower = prev.is_ascii_lowercase() || prev.is_ascii_digit();
let crosses_acronym = prev.is_ascii_uppercase() && next.is_ascii_lowercase();
if (crosses_lower || crosses_acronym)
&& !result.is_empty()
&& !result.ends_with('_')
{
result.push('_');
}
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snake_case_input_passes_through_unchanged() {
assert_eq!(camel_to_snake_case("is_vip"), "is_vip");
assert_eq!(camel_to_snake_case("flag_reason"), "flag_reason");
assert_eq!(camel_to_snake_case("loyalty_tier_v2"), "loyalty_tier_v2");
}
#[test]
fn single_word_unchanged() {
assert_eq!(camel_to_snake_case("name"), "name");
assert_eq!(camel_to_snake_case("id"), "id");
assert_eq!(camel_to_snake_case(""), "");
}
#[test]
fn camel_case_converts() {
assert_eq!(camel_to_snake_case("isVip"), "is_vip");
assert_eq!(camel_to_snake_case("userId"), "user_id");
assert_eq!(camel_to_snake_case("customerSegment"), "customer_segment");
assert_eq!(camel_to_snake_case("blacklistReason"), "blacklist_reason");
}
#[test]
fn pascal_case_converts() {
assert_eq!(camel_to_snake_case("IsVip"), "is_vip");
assert_eq!(camel_to_snake_case("UserId"), "user_id");
}
#[test]
fn acronym_runs_stay_together() {
assert_eq!(camel_to_snake_case("IOError"), "io_error");
assert_eq!(camel_to_snake_case("httpURL"), "http_url");
assert_eq!(camel_to_snake_case("ABC"), "abc");
assert_eq!(camel_to_snake_case("XMLHttpRequest"), "xml_http_request");
}
#[test]
fn digits_count_as_lowercase_for_boundary() {
assert_eq!(camel_to_snake_case("v2Field"), "v2_field");
assert_eq!(camel_to_snake_case("field2Name"), "field2_name");
}
#[test]
fn underscores_not_doubled() {
assert_eq!(camel_to_snake_case("_Foo"), "_foo");
assert_eq!(camel_to_snake_case("foo_Bar"), "foo_bar");
}
#[test]
fn shallow_pages_are_allowed() {
assert!(pagination_depth_error(1, 100).is_none());
assert!(pagination_depth_error(101, 100).is_none());
}
#[test]
fn pages_past_the_cap_are_rejected() {
assert!(pagination_depth_error(102, 100).is_some());
assert!(pagination_depth_error(1002, 10).is_some());
}
#[test]
fn oversized_page_size_is_clamped_before_the_check() {
assert!(pagination_depth_error(102, 200).is_some());
assert!(pagination_depth_error(101, 200).is_none());
}
#[test]
fn huge_page_number_does_not_overflow() {
assert!(pagination_depth_error(u32::MAX, u32::MAX).is_some());
}
#[test]
fn batch_size_within_limit_is_allowed() {
assert!(batch_size_error(0).is_none());
assert!(batch_size_error(MAX_BATCH_SIZE).is_none());
}
#[test]
fn batch_size_over_limit_is_rejected() {
assert!(batch_size_error(MAX_BATCH_SIZE + 1).is_some());
}
#[test]
fn bulk_patch_request_parses_shared_shape() {
let json = r#"{ "ids": ["a", "b"], "patch": { "status": "void" } }"#;
let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
let items = req.into_items();
assert_eq!(items.len(), 2);
assert_eq!(items[0].1.get("status").unwrap(), "void");
assert_eq!(items[1].1.get("status").unwrap(), "void");
assert_eq!(items[0].0, "a");
assert_eq!(items[1].0, "b");
}
#[test]
fn bulk_patch_request_parses_per_item_shape() {
let json = r#"{ "items": [
{ "id": "a", "patch": { "status": "void" } },
{ "id": "b", "patch": { "note": "late" } }
] }"#;
let req: BulkPatchRequest = serde_json::from_str(json).unwrap();
let items = req.into_items();
assert_eq!(items.len(), 2);
assert_eq!(items[0].0, "a");
assert_eq!(items[0].1.get("status").unwrap(), "void");
assert_eq!(items[1].0, "b");
assert_eq!(items[1].1.get("note").unwrap(), "late");
}
#[test]
fn batch_ids_request_parses() {
let req: BatchIdsRequest = serde_json::from_str(r#"{ "ids": ["x", "y", "z"] }"#).unwrap();
assert_eq!(req.ids, vec!["x", "y", "z"]);
}
fn fields(q: &[(&str, &str)]) -> Vec<String> {
let map: HashMap<String, String> =
q.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
sparse_fields(&map)
}
#[test]
fn sparse_fields_parses_comma_list_and_trims() {
assert_eq!(fields(&[("fields", "id, name ,basePrice")]), vec!["id", "name", "basePrice"]);
}
#[test]
fn sparse_fields_absent_or_empty_is_no_projection() {
assert!(fields(&[]).is_empty());
assert!(fields(&[("fields", "")]).is_empty());
assert!(fields(&[("fields", " , ")]).is_empty());
}
#[test]
fn project_keeps_requested_keys_plus_id() {
let v = serde_json::json!({ "id": "1", "name": "n", "basePrice": 10, "hppPerUnit": 5 });
let out = project_sparse(v, &["name".into(), "basePrice".into()]);
assert_eq!(out, serde_json::json!({ "id": "1", "name": "n", "basePrice": 10 }));
}
#[test]
fn project_always_includes_id_even_if_not_requested() {
let v = serde_json::json!({ "id": "1", "name": "n" });
let out = project_sparse(v, &["name".into()]);
assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
}
#[test]
fn project_ignores_unknown_keys() {
let v = serde_json::json!({ "id": "1", "name": "n" });
let out = project_sparse(v, &["name".into(), "nope".into()]);
assert_eq!(out, serde_json::json!({ "id": "1", "name": "n" }));
}
#[test]
fn project_empty_fields_returns_full_object() {
let v = serde_json::json!({ "id": "1", "name": "n", "x": 2 });
let out = project_sparse(v.clone(), &[]);
assert_eq!(out, v);
}
#[test]
fn project_non_object_returned_unchanged() {
let v = serde_json::json!("scalar");
assert_eq!(project_sparse(v.clone(), &["name".into()]), v);
}
fn owner_a() -> uuid::Uuid {
uuid::Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
}
fn owner_b() -> uuid::Uuid {
uuid::Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap()
}
fn row() -> serde_json::Value {
serde_json::json!({ "id": "1", "providerId": owner_a().to_string(), "name": "n", "hppPerUnit": 5 })
}
const PRIV: &[&str] = &["hppPerUnit"];
#[test]
fn security_no_private_fields_is_noop() {
let v = row();
assert_eq!(apply_field_security(v.clone(), None, &[], Some("providerId")), v);
}
#[test]
fn security_platform_sees_private() {
let out = apply_field_security(row(), Some(&AccessScope::Platform), PRIV, Some("providerId"));
assert!(out.get("hppPerUnit").is_some());
}
#[test]
fn security_owner_tenant_sees_private() {
let out = apply_field_security(
row(),
Some(&AccessScope::Company(owner_a())),
PRIV,
Some("providerId"),
);
assert!(out.get("hppPerUnit").is_some());
}
#[test]
fn security_other_tenant_stripped() {
let out = apply_field_security(
row(),
Some(&AccessScope::Company(owner_b())),
PRIV,
Some("providerId"),
);
assert!(out.get("hppPerUnit").is_none());
assert!(out.get("name").is_some());
}
#[test]
fn security_absent_scope_fails_closed() {
let out = apply_field_security(row(), None, PRIV, Some("providerId"));
assert!(out.get("hppPerUnit").is_none());
}
#[test]
fn include_relations_parses_include_and_with() {
let mut q = HashMap::new();
q.insert("include".to_string(), "provider, outlet ".to_string());
assert_eq!(include_relations(&q), vec!["provider", "outlet"]);
let mut q2 = HashMap::new();
q2.insert("with".to_string(), "category".to_string());
assert_eq!(include_relations(&q2), vec!["category"]);
assert!(include_relations(&HashMap::new()).is_empty());
}
#[test]
fn snake_to_camel_converts() {
assert_eq!(snake_to_camel("provider_id"), "providerId");
assert_eq!(snake_to_camel("business_name"), "businessName");
assert_eq!(snake_to_camel("id"), "id");
}
#[test]
fn camelize_keys_top_level_only() {
let v = serde_json::json!({ "provider_id": "1", "meta_data": { "created_at": "x" } });
let out = camelize_keys(v);
assert!(out.get("providerId").is_some());
assert!(out.get("metaData").unwrap().get("created_at").is_some());
}
#[test]
fn security_null_owner_only_platform_sees() {
let v = serde_json::json!({ "id": "1", "providerId": null, "hppPerUnit": 5 });
let tenant = apply_field_security(v.clone(), Some(&AccessScope::Company(owner_a())), PRIV, Some("providerId"));
assert!(tenant.get("hppPerUnit").is_none());
let plat = apply_field_security(v, Some(&AccessScope::Platform), PRIV, Some("providerId"));
assert!(plat.get("hppPerUnit").is_some());
}
#[test]
fn a_write_refused_for_named_reasons_answers_422_with_its_violations() {
let v = vec![crate::violation::Violation::new("status", "field_not_writable", "`status` changes only through its verbs")];
let (status, axum::Json(body)) =
write_error::<()>(Some(v), &"validation failed: `status` changes only through its verbs", axum::http::StatusCode::BAD_REQUEST);
assert_eq!(status, axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let json = serde_json::to_value(&body).unwrap();
assert_eq!(json["error"], "validation failed: `status` changes only through its verbs");
assert_eq!(json["violations"][0]["path"], "status");
assert_eq!(json["violations"][0]["code"], "field_not_writable");
let (status, axum::Json(body)) = write_error::<()>(None, &"boom", axum::http::StatusCode::BAD_REQUEST);
assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
assert!(serde_json::to_value(&body).unwrap().get("violations").is_none());
}
}