use std::collections::HashMap;
use std::sync::Arc;
use serde::Serialize;
use serde_json::Value;
use crate::database::DbPool;
use crate::database::repository::{is_safe_identifier, sql_literal};
use crate::foundation::{DbError, DbResult};
#[derive(Debug, Clone)]
pub struct TableEndpoint {
pub table: String,
pub columns: Vec<String>,
pub orderable: Option<Vec<String>>,
pub max_page_size: u64,
pub default_page_size: u64,
}
impl TableEndpoint {
pub fn new(table: &str, columns: &[&str]) -> DbResult<Self> {
if !is_safe_identifier(table) {
return Err(DbError::Config(format!(
"data-api table name must be a safe identifier: '{table}'"
)));
}
let columns: Vec<String> = columns.iter().map(|c| c.to_string()).collect();
if columns.is_empty() || columns.iter().any(|c| !is_safe_identifier(c)) {
return Err(DbError::Config(
"data-api columns must be non-empty safe identifiers".to_string(),
));
}
Ok(Self {
table: table.to_string(),
columns,
orderable: None,
max_page_size: 100,
default_page_size: 20,
})
}
pub fn with_orderable(mut self, orderable: &[&str]) -> DbResult<Self> {
let orderable: Vec<String> = orderable.iter().map(|c| c.to_string()).collect();
if orderable.is_empty()
|| orderable
.iter()
.any(|c| !is_safe_identifier(c) || !self.columns.contains(c))
{
return Err(DbError::Config(
"data-api orderable columns must be safe identifiers within the column whitelist"
.to_string(),
));
}
self.orderable = Some(orderable);
Ok(self)
}
pub fn with_page_limits(mut self, max_page_size: u64, default_page_size: u64) -> Self {
self.max_page_size = max_page_size.max(1);
self.default_page_size = default_page_size.max(1).min(self.max_page_size);
self
}
fn orderable_columns(&self) -> &[String] {
self.orderable.as_deref().unwrap_or(&self.columns)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterOp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
Contains,
}
impl FilterOp {
fn as_sql(self) -> &'static str {
match self {
FilterOp::Eq => "=",
FilterOp::Ne => "!=",
FilterOp::Lt => "<",
FilterOp::Le => "<=",
FilterOp::Gt => ">",
FilterOp::Ge => ">=",
FilterOp::Contains => "LIKE",
}
}
}
#[derive(Debug, Clone)]
pub struct Filter {
pub column: String,
pub op: FilterOp,
pub value: Value,
}
impl Filter {
pub fn eq(column: &str, value: impl Into<Value>) -> Self {
Self {
column: column.to_string(),
op: FilterOp::Eq,
value: value.into(),
}
}
pub fn contains(column: &str, value: impl Into<Value>) -> Self {
Self {
column: column.to_string(),
op: FilterOp::Contains,
value: value.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderDirection {
Asc,
Desc,
}
#[derive(Debug, Clone, Default)]
pub struct ListRequest {
pub page: u64,
pub page_size: u64,
pub filters: Vec<Filter>,
pub order: Option<(String, OrderDirection)>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ListResponse {
pub items: Vec<Value>,
pub page: u64,
pub page_size: u64,
pub total: u64,
}
pub struct DataApiGateway {
pool: Arc<DbPool>,
role: String,
endpoints: HashMap<String, TableEndpoint>,
}
impl DataApiGateway {
pub fn new(pool: Arc<DbPool>) -> Self {
Self {
pool,
role: "admin".to_string(),
endpoints: HashMap::new(),
}
}
pub fn with_role(mut self, role: &str) -> Self {
self.role = role.to_string();
self
}
pub fn register(mut self, name: &str, endpoint: TableEndpoint) -> DbResult<Self> {
if !is_safe_identifier(name) {
return Err(DbError::Config(format!(
"data-api endpoint name must be a safe identifier: '{name}'"
)));
}
self.endpoints.insert(name.to_string(), endpoint);
Ok(self)
}
pub fn endpoint_names(&self) -> Vec<&str> {
self.endpoints.keys().map(|s| s.as_str()).collect()
}
pub fn manifest(&self) -> Value {
let endpoints: Vec<Value> = self
.endpoints
.iter()
.map(|(name, ep)| {
serde_json::json!({
"name": name,
"table": ep.table,
"columns": ep.columns,
"orderable": ep.orderable_columns(),
"max_page_size": ep.max_page_size,
"default_page_size": ep.default_page_size,
})
})
.collect();
serde_json::json!({ "role": self.role, "endpoints": endpoints })
}
fn endpoint(&self, name: &str) -> DbResult<&TableEndpoint> {
self.endpoints
.get(name)
.ok_or_else(|| DbError::Config(format!("unknown data-api endpoint: '{name}'")))
}
fn project(&self, ep: &TableEndpoint, rows: Vec<Value>) -> Vec<Value> {
rows.into_iter()
.map(|row| match row {
Value::Object(map) => Value::Object(
map.into_iter()
.filter(|(key, _)| ep.columns.contains(key))
.collect(),
),
other => other,
})
.collect()
}
pub async fn get(&self, name: &str, id: i64) -> DbResult<Option<Value>> {
let ep = self.endpoint(name)?;
let sql = format!(
"SELECT {} FROM {} WHERE id = {}",
ep.columns.join(", "),
ep.table,
id
);
let rows = self.pool.query_rows(&sql, &self.role).await?;
Ok(self.project(ep, rows).into_iter().next())
}
pub async fn list(&self, name: &str, req: &ListRequest) -> DbResult<ListResponse> {
let ep = self.endpoint(name)?;
if req.page == 0 {
return Err(DbError::Config("data-api page starts at 1".to_string()));
}
let page_size = if req.page_size == 0 {
ep.default_page_size
} else {
req.page_size.min(ep.max_page_size)
};
let mut where_clauses: Vec<String> = Vec::new();
for filter in &req.filters {
if !ep.columns.contains(&filter.column) {
return Err(DbError::Permission(format!(
"data-api column '{}' is not exposed for endpoint '{}'",
filter.column, name
)));
}
if filter.op == FilterOp::Contains {
let text = match &filter.value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
where_clauses.push(format!(
"instr({}, {}) > 0",
filter.column,
sql_literal(&Value::String(text))?
));
continue;
}
let literal = sql_literal(&filter.value)?;
where_clauses.push(format!(
"{} {} {}",
filter.column,
filter.op.as_sql(),
literal
));
}
let where_sql = if where_clauses.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_clauses.join(" AND "))
};
let order_sql = match &req.order {
Some((column, direction)) => {
let allowed = ep.orderable_columns().contains(column);
if !allowed {
return Err(DbError::Permission(format!(
"data-api column '{}' is not orderable for endpoint '{}'",
column, name
)));
}
let dir = match direction {
OrderDirection::Asc => "ASC",
OrderDirection::Desc => "DESC",
};
format!(" ORDER BY {column} {dir}")
}
None => String::new(),
};
let count_sql = format!("SELECT {} FROM {}{}", ep.columns[0], ep.table, where_sql);
let total = self.pool.query_rows(&count_sql, &self.role).await?.len() as u64;
let offset = (req.page - 1) * page_size;
let sql = format!(
"SELECT {} FROM {}{}{} LIMIT {} OFFSET {}",
ep.columns.join(", "),
ep.table,
where_sql,
order_sql,
page_size,
offset
);
let items = self.project(ep, self.pool.query_rows(&sql, &self.role).await?);
Ok(ListResponse {
items,
page: req.page,
page_size,
total,
})
}
}