use std::sync::Arc;
use axum::{
Json,
extract::{Path, Query, State},
response::{IntoResponse, Response},
routing::{get, post},
};
use cratefield_core::{
Action, Audience, Clock, Column, IdGen, ModuleContext, Outcome, Problem, Scope, Surface,
SystemClock, UlidIdGen, View, require_admin,
};
use http::HeaderMap;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::store;
use crate::{Collections, Settings};
pub(crate) struct ModuleState {
pub ctx: Arc<ModuleContext>,
pub settings: Settings,
}
pub(crate) fn router(ctx: Arc<ModuleContext>, settings: Settings) -> axum::Router {
let state = Arc::new(ModuleState { ctx, settings });
axum::Router::new()
.route("/{collection}", get(read_collection))
.route("/{collection}/{slug}", get(read_item))
.route("/admin/save", post(admin_save))
.route("/admin/publish", post(admin_publish))
.route("/admin/unpublish", post(admin_unpublish))
.route("/admin/delete", post(admin_delete))
.route("/admin/list", get(admin_list))
.with_state(state)
}
fn internal(scope: &Scope) -> Problem {
Problem::internal().instance(&scope.request_id)
}
fn db_of(state: &ModuleState) -> Option<Arc<dyn cratefield_core::Database>> {
state.ctx.ports.db.clone()
}
fn check_collection(state: &ModuleState, scope: &Scope, collection: &str) -> Result<(), Problem> {
match &state.settings.collections {
Collections::Any => Ok(()),
Collections::List(allowed) if allowed.iter().any(|c| c == collection) => Ok(()),
Collections::List(_) => Err(Problem::not_found().instance(&scope.request_id)),
}
}
fn now_iso() -> String {
use time::format_description::well_known::Rfc3339;
SystemClock
.now()
.replace_nanosecond(0)
.expect("truncation stays in range")
.format(&Rfc3339)
.unwrap_or_default()
}
async fn read_item(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Path((collection, slug)): Path<(String, String)>,
) -> Result<Response, Problem> {
check_collection(&state, &scope, &collection)?;
let Some(db) = db_of(&state) else {
return Err(internal(&scope));
};
let revision = store::published_revision(&*db, &collection, &slug)
.await
.map_err(|_| internal(&scope))?;
match revision {
Some(rev) => Ok(Json(revision_json(&rev)).into_response()),
None => Err(Problem::not_found().instance(&scope.request_id)),
}
}
async fn read_collection(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Path(collection): Path<String>,
) -> Result<Response, Problem> {
check_collection(&state, &scope, &collection)?;
let Some(db) = db_of(&state) else {
return Err(internal(&scope));
};
let items = store::published_in_collection(&*db, &collection)
.await
.map_err(|_| internal(&scope))?;
let list: Vec<Value> = items.iter().map(revision_json).collect();
Ok(Json(json!({ "collection": collection, "items": list })).into_response())
}
fn revision_json(rev: &store::Revision) -> Value {
json!({
"collection": rev.collection,
"slug": rev.slug,
"version": rev.version,
"title": rev.title,
"body": rev.body,
"data": parse_data(&rev.data),
"publishedAt": rev.created_at,
})
}
fn item_json(item: &store::Item) -> Value {
json!({
"collection": item.collection,
"slug": item.slug,
"title": item.title,
"body": item.body,
"data": parse_data(&item.data),
"status": item.status,
"version": item.version,
"updatedAt": item.updated_at,
})
}
fn parse_data(text: &str) -> Value {
serde_json::from_str(text).unwrap_or_else(|_| json!({}))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct SaveBody {
pub collection: String,
pub slug: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub data: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct ItemRef {
pub collection: String,
pub slug: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct ListQuery {
pub collection: String,
}
fn admin_gate(state: &ModuleState, headers: &HeaderMap, scope: &Scope) -> Result<(), Problem> {
require_admin(&*state.ctx.config, headers).map_err(|p| p.instance(&scope.request_id))
}
async fn admin_save(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
Json(body): Json<SaveBody>,
) -> Result<Response, Problem> {
admin_gate(&state, &headers, &scope)?;
check_collection(&state, &scope, &body.collection)?;
let slug = body.slug.trim();
if slug.is_empty() {
return Err(
Problem::validation_failed("slug must not be empty").instance(&scope.request_id)
);
}
let data = match &body.data {
None => "{}".to_owned(),
Some(Value::Object(_)) => body
.data
.as_ref()
.map(ToString::to_string)
.unwrap_or_default(),
Some(_) => {
return Err(Problem::validation_failed("data must be a JSON object")
.instance(&scope.request_id));
}
};
let Some(db) = db_of(&state) else {
return Err(internal(&scope));
};
store::save_item(
&*db,
&body.collection,
slug,
&body.title,
&body.body,
&data,
&now_iso(),
)
.await
.map_err(|_| internal(&scope))?;
let item = store::find_item(&*db, &body.collection, slug)
.await
.map_err(|_| internal(&scope))?
.ok_or_else(|| internal(&scope))?;
Ok(Json(item_json(&item)).into_response())
}
async fn admin_publish(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
Json(body): Json<ItemRef>,
) -> Result<Response, Problem> {
admin_gate(&state, &headers, &scope)?;
check_collection(&state, &scope, &body.collection)?;
let Some(db) = db_of(&state) else {
return Err(internal(&scope));
};
let item = store::find_item(&*db, &body.collection, &body.slug)
.await
.map_err(|_| internal(&scope))?
.ok_or_else(|| Problem::not_found().instance(&scope.request_id))?;
let version = store::publish_item(&*db, &item, &UlidIdGen.ulid(), &now_iso())
.await
.map_err(|_| internal(&scope))?;
Ok(Json(json!({ "ok": true, "version": version })).into_response())
}
async fn admin_unpublish(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
Json(body): Json<ItemRef>,
) -> Result<Response, Problem> {
admin_gate(&state, &headers, &scope)?;
check_collection(&state, &scope, &body.collection)?;
let Some(db) = db_of(&state) else {
return Err(internal(&scope));
};
store::unpublish_item(&*db, &body.collection, &body.slug, &now_iso())
.await
.map_err(|_| internal(&scope))?;
Ok(Json(json!({ "ok": true })).into_response())
}
async fn admin_delete(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
Json(body): Json<ItemRef>,
) -> Result<Response, Problem> {
admin_gate(&state, &headers, &scope)?;
check_collection(&state, &scope, &body.collection)?;
let Some(db) = db_of(&state) else {
return Err(internal(&scope));
};
store::delete_item(&*db, &body.collection, &body.slug)
.await
.map_err(|_| internal(&scope))?;
Ok(Json(json!({ "ok": true })).into_response())
}
async fn admin_list(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, Problem> {
admin_gate(&state, &headers, &scope)?;
check_collection(&state, &scope, &query.collection)?;
let Some(db) = db_of(&state) else {
return Err(internal(&scope));
};
let items = store::list_items(&*db, &query.collection)
.await
.map_err(|_| internal(&scope))?;
let list: Vec<Value> = items.iter().map(item_json).collect();
Ok(Json(json!({ "collection": query.collection, "items": list })).into_response())
}
pub(crate) fn surface(_settings: &Settings) -> Surface {
Surface::new()
.action(
Action::post("save", "/admin/save")
.audience(Audience::Admin)
.input::<SaveBody>()
.accepted("Saved."),
)
.action(
Action::post("publish", "/admin/publish")
.audience(Audience::Admin)
.input::<ItemRef>()
.outcome(Outcome::Json),
)
.action(
Action::post("unpublish", "/admin/unpublish")
.audience(Audience::Admin)
.input::<ItemRef>()
.outcome(Outcome::Json),
)
.action(
Action::post("delete", "/admin/delete")
.audience(Audience::Admin)
.input::<ItemRef>()
.outcome(Outcome::Json),
)
.action(
Action::get("list", "/admin/list")
.audience(Audience::Admin)
.input::<ListQuery>()
.outcome(Outcome::Json),
)
.view(View::form("save"))
.view(View::table(
"list",
[
("slug", "Slug"),
("title", "Title"),
("status", "Status"),
("version", "Version"),
("updated_at", "Updated"),
]
.into_iter()
.map(|(key, label)| Column::new(key, label))
.collect(),
))
}