use std::sync::Arc;
use axum::Router;
use axum::extract::{Path, Query, State};
use axum::routing::{get, post};
use cratefield_core::{Problem, ProblemDef, Scope, TenantConn};
use cratefield_tables::{FieldKind, Filter, TableDef};
use http::{HeaderMap, StatusCode};
use serde_json::{Value, json};
use crate::batch::{Batch, PATH};
use crate::read::{Tables, one, page};
pub const BAD_CURSOR: ProblemDef = ProblemDef {
slug: "bad-cursor",
status: StatusCode::BAD_REQUEST,
title: "Not a cursor for this table",
description: "The `after` parameter is not the `next` value from a previous page of this table.",
};
pub const BAD_KEY: ProblemDef = ProblemDef {
slug: "bad-key",
status: StatusCode::BAD_REQUEST,
title: "Not a key for this table",
description: "The path segment is not a value of the table's primary-key column.",
};
pub const COMPOSITE_KEY: ProblemDef = ProblemDef {
slug: "composite-key",
status: StatusCode::BAD_REQUEST,
title: "A composite key does not fit in one path segment",
description: "The table's primary key is more than one column, so `/{table}/{key}` cannot name a row; name the key in the query at `/{table}/__by?<column>=<value>` instead.",
};
pub const PARTIAL_KEY: ProblemDef = ProblemDef {
slug: "partial-key",
status: StatusCode::BAD_REQUEST,
title: "The key is not complete",
description: "Name every primary-key column of the table once: a row is addressed by its whole key. `after` and `sort` are the page's parameters on this route and cannot name a key column, so a table whose key uses one of those names has no address here.",
};
pub const NOT_A_KEY_COLUMN: ProblemDef = ProblemDef {
slug: "not-a-key-column",
status: StatusCode::BAD_REQUEST,
title: "Not a key column for this table",
description: "A `__by` query names primary-key columns only. To narrow by any other column, read the page and filter it.",
};
pub fn router(tables: Arc<Tables>) -> Router {
Router::new()
.route(PATH, post(batch_route))
.route("/{table}", get(page_route).post(create_route))
.route(
"/{table}/{key}",
get(one_route).put(replace_route).delete(remove_route),
)
.route(
"/{table}/__by",
get(one_by_route)
.put(replace_by_route)
.delete(remove_by_route),
)
.with_state(tables)
}
#[derive(serde::Deserialize)]
struct PageQuery {
#[serde(default)]
after: Option<String>,
#[serde(default)]
sort: Option<String>,
#[serde(flatten)]
filters: std::collections::BTreeMap<String, String>,
}
async fn page_route(
scope: Scope,
conn: TenantConn,
State(tables): State<Arc<Tables>>,
Path(table): Path<String>,
Query(query): Query<PageQuery>,
headers: HeaderMap,
) -> Result<axum::Json<Value>, Problem> {
let declared = tables.declared(&table);
let sort = sort_of(query.sort.as_deref());
let after = match (&query.after, declared) {
(Some(raw), Some(api)) => Some(cursor_from_query(&api.table, raw, sort)?),
_ => None,
};
let filters = match declared {
Some(api) => filters_from_query(&api.table, &query.filters)?,
None => Vec::new(),
};
let body = page(
&tables,
&conn,
conn.tenancy(),
&headers,
&scope,
crate::read::Asked {
table: &table,
after: after.as_ref().map(|value| &value.0),
filters: &filters,
sort,
},
)
.await?;
Ok(axum::Json(body))
}
async fn one_route(
scope: Scope,
conn: TenantConn,
State(tables): State<Arc<Tables>>,
Path((table, key)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<axum::Json<Value>, Problem> {
let api = tables
.declared(&table)
.ok_or_else(|| Problem::new(&crate::read::NO_SUCH_TABLE))?;
let key = key_from_path(&api.table, &key)?;
let body = one(
&tables,
&conn,
conn.tenancy(),
&headers,
&scope,
&table,
&key.0,
)
.await?;
Ok(axum::Json(body))
}
async fn one_by_route(
scope: Scope,
conn: TenantConn,
State(tables): State<Arc<Tables>>,
Path(table): Path<String>,
Query(query): Query<ByQuery>,
headers: HeaderMap,
) -> Result<axum::Json<Value>, Problem> {
let api = tables
.declared(&table)
.ok_or_else(|| Problem::new(&crate::read::NO_SUCH_TABLE))?;
let key = key_from_query(&api.table, &query)?;
let body = one(
&tables,
&conn,
conn.tenancy(),
&headers,
&scope,
&table,
&key.0,
)
.await?;
Ok(axum::Json(body))
}
pub struct Key(pub Value);
pub fn key_from_path(table: &TableDef, segment: &str) -> Result<Key, Problem> {
let [column] = table.primary_key.as_slice() else {
return Err(Problem::new(&COMPOSITE_KEY));
};
let field = table
.fields
.iter()
.find(|field| &field.name == column)
.ok_or_else(|| Problem::new(&crate::access::MISDECLARED))?;
Ok(Key(json!({
column.as_str(): key_value_from_text(field, segment)?
})))
}
fn key_value_from_text(field: &cratefield_tables::FieldDef, raw: &str) -> Result<Value, Problem> {
match &field.kind {
FieldKind::Text { .. }
| FieldKind::Uuid
| FieldKind::Timestamp
| FieldKind::Enum { .. } => Ok(Value::String(raw.to_owned())),
FieldKind::Integer { .. } => raw
.parse::<i64>()
.map(Value::from)
.map_err(|_ignored| Problem::new(&BAD_KEY)),
FieldKind::Real { .. } | FieldKind::Boolean | FieldKind::Json => {
Err(Problem::new(&BAD_KEY))
}
}
}
#[derive(serde::Deserialize)]
struct ByQuery {
#[serde(default)]
#[expect(dead_code)]
after: Option<String>,
#[serde(default)]
#[expect(dead_code)]
sort: Option<String>,
#[serde(flatten)]
key: std::collections::BTreeMap<String, String>,
}
fn key_from_query(table: &TableDef, query: &ByQuery) -> Result<Key, Problem> {
for column in query.key.keys() {
if !table.primary_key.contains(column) {
return Err(Problem::new(&NOT_A_KEY_COLUMN).with_detail(format!(
"`{column}` is not a primary-key column of this table"
)));
}
}
let missing: Vec<&str> = table
.primary_key
.iter()
.map(String::as_str)
.filter(|column| !query.key.contains_key(*column))
.collect();
if !missing.is_empty() {
return Err(Problem::new(&PARTIAL_KEY).with_detail(format!(
"the query does not name `{}`",
missing.join("`, `")
)));
}
let mut object = serde_json::Map::with_capacity(table.primary_key.len());
for column in &table.primary_key {
let field = table
.fields
.iter()
.find(|field| &field.name == column)
.ok_or_else(|| Problem::new(&crate::access::MISDECLARED))?;
let value = key_value_from_text(field, &query.key[column.as_str()])?;
object.insert(column.clone(), value);
}
Ok(Key(Value::Object(object)))
}
async fn create_route(
scope: Scope,
conn: TenantConn,
State(tables): State<Arc<Tables>>,
Path(table): Path<String>,
headers: HeaderMap,
body: axum::Json<Value>,
) -> Result<(StatusCode, axum::Json<Value>), Problem> {
let row = crate::write::create(
&tables,
&conn,
conn.tenancy(),
&headers,
&scope,
&table,
body.0,
)
.await?;
Ok((StatusCode::CREATED, axum::Json(row)))
}
async fn replace_route(
scope: Scope,
conn: TenantConn,
State(tables): State<Arc<Tables>>,
Path((table, key)): Path<(String, String)>,
headers: HeaderMap,
body: axum::Json<Value>,
) -> Result<axum::Json<Value>, Problem> {
let api = tables
.declared(&table)
.ok_or_else(|| Problem::new(&crate::read::NO_SUCH_TABLE))?;
let key = key_from_path(&api.table, &key)?;
let row = crate::write::replace(
&tables,
&conn,
conn.tenancy(),
&headers,
&scope,
&table,
&key.0,
body.0,
)
.await?;
Ok(axum::Json(row))
}
async fn replace_by_route(
scope: Scope,
conn: TenantConn,
State(tables): State<Arc<Tables>>,
Path(table): Path<String>,
Query(query): Query<ByQuery>,
headers: HeaderMap,
body: axum::Json<Value>,
) -> Result<axum::Json<Value>, Problem> {
let api = tables
.declared(&table)
.ok_or_else(|| Problem::new(&crate::read::NO_SUCH_TABLE))?;
let key = key_from_query(&api.table, &query)?;
let row = crate::write::replace(
&tables,
&conn,
conn.tenancy(),
&headers,
&scope,
&table,
&key.0,
body.0,
)
.await?;
Ok(axum::Json(row))
}
async fn remove_route(
scope: Scope,
conn: TenantConn,
State(tables): State<Arc<Tables>>,
Path((table, key)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<StatusCode, Problem> {
let api = tables
.declared(&table)
.ok_or_else(|| Problem::new(&crate::read::NO_SUCH_TABLE))?;
let key = key_from_path(&api.table, &key)?;
crate::write::remove(
&tables,
&conn,
conn.tenancy(),
&headers,
&scope,
&table,
&key.0,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn remove_by_route(
scope: Scope,
conn: TenantConn,
State(tables): State<Arc<Tables>>,
Path(table): Path<String>,
Query(query): Query<ByQuery>,
headers: HeaderMap,
) -> Result<StatusCode, Problem> {
let api = tables
.declared(&table)
.ok_or_else(|| Problem::new(&crate::read::NO_SUCH_TABLE))?;
let key = key_from_query(&api.table, &query)?;
crate::write::remove(
&tables,
&conn,
conn.tenancy(),
&headers,
&scope,
&table,
&key.0,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
fn filters_from_query(
table: &TableDef,
given: &std::collections::BTreeMap<String, String>,
) -> Result<Vec<Filter>, Problem> {
let mut filters = Vec::with_capacity(given.len());
for (column, raw) in given {
let field = table
.fields
.iter()
.find(|field| &field.name == column)
.ok_or_else(|| {
Problem::new(&crate::read::BAD_FILTER)
.with_detail(format!("`{column}` is not a field of this table"))
})?;
filters.push(Filter {
column: column.clone(),
value: value_from_text(field, raw)?,
});
}
Ok(filters)
}
fn value_from_text(field: &cratefield_tables::FieldDef, raw: &str) -> Result<Value, Problem> {
let bad = || {
Problem::new(&crate::read::BAD_FILTER).with_detail(format!(
"`{}` is {} and `{raw}` is not",
field.name,
field.kind.as_str()
))
};
Ok(match &field.kind {
FieldKind::Text { .. }
| FieldKind::Uuid
| FieldKind::Timestamp
| FieldKind::Enum { .. } => Value::String(raw.to_owned()),
FieldKind::Integer { .. } => raw.parse::<i64>().map(Value::from).map_err(|_e| bad())?,
FieldKind::Real { .. } => raw.parse::<f64>().map(Value::from).map_err(|_e| bad())?,
FieldKind::Boolean => match raw {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
_ => return Err(bad()),
},
FieldKind::Json => return Err(bad()),
})
}
pub fn cursor_from_query(
table: &TableDef,
raw: &str,
sort: Option<cratefield_tables::Sort<'_>>,
) -> Result<Key, Problem> {
let value: Value = serde_json::from_str(raw).map_err(|_ignored| {
Problem::new(&BAD_CURSOR).with_detail(
"the cursor is the `next` value from a previous page, sent back as it was given",
)
})?;
let Some(object) = value.as_object() else {
return Err(Problem::new(&BAD_CURSOR)
.with_detail("the cursor names each primary-key column, so it is a JSON object"));
};
let ordered: Vec<&str> = sort
.map(|sort| sort.column)
.into_iter()
.chain(table.primary_key.iter().map(String::as_str))
.collect();
for column in ordered {
let Some(given) = object.get(column).filter(|value| !value.is_null()) else {
return Err(Problem::new(&BAD_CURSOR)
.with_detail(format!("the cursor does not name `{column}`")));
};
let field = table
.fields
.iter()
.find(|field| field.name == column)
.ok_or_else(|| {
Problem::new(&BAD_CURSOR)
.with_detail(format!("`{column}` is not a field of this table"))
})?;
if cratefield_tables::to_sql(field, Some(given)).is_none() {
return Err(Problem::new(&BAD_CURSOR).with_detail(format!(
"`{column}` is {} and the cursor's value is not",
field.kind.as_str()
)));
}
}
Ok(Key(value))
}
async fn batch_route(
scope: Scope,
conn: TenantConn,
State(tables): State<Arc<Tables>>,
headers: HeaderMap,
body: axum::Json<Batch>,
) -> Result<axum::Json<Value>, Problem> {
let answer =
crate::batch::run(&tables, &conn, conn.tenancy(), &headers, &scope, &body.0).await?;
Ok(axum::Json(answer))
}
pub(crate) fn sort_of(raw: Option<&str>) -> Option<cratefield_tables::Sort<'_>> {
raw.map(|raw| {
raw.strip_prefix('-').map_or(
cratefield_tables::Sort {
column: raw,
descending: false,
},
|column| cratefield_tables::Sort {
column,
descending: true,
},
)
})
}