use crate::actions::CustomAction;
use crate::authz::Action;
use crate::error::CoreError;
use crate::export::{rows_to_csv, EXPORT_CAP};
use crate::filters::parse_query;
use crate::menu::{MenuAction, MenuItem};
use crate::request::ReqCtx;
use crate::response::{ApiBody, ApiResponse};
use crate::storage::{storage, CreateOutcome, QueryOptions};
use crate::ui;
use async_trait::async_trait;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::collections::HashSet;
#[async_trait]
pub trait Resource: Send + Sync {
fn resource_name(&self) -> &'static str;
fn base_path(&self) -> &'static str;
fn table_name(&self) -> &'static str;
fn clone_box(&self) -> Box<dyn Resource>;
fn primary_key(&self) -> &'static str {
"id"
}
fn menu_group(&self) -> Option<&'static str> {
None
}
fn menu(&self) -> &'static str {
self.resource_name()
}
fn allowed_roles(&self) -> Vec<String> {
vec!["admin".to_string()]
}
fn allowed_actions(&self) -> Option<Vec<MenuAction>> {
None
}
fn custom_actions(&self) -> Vec<CustomAction> {
vec![]
}
fn permit_keys(&self) -> Vec<&'static str> {
vec![]
}
fn readonly_keys(&self) -> Vec<&'static str> {
vec!["id", "created_at", "updated_at"]
}
fn soft_delete(&self) -> bool {
self.permit_keys().iter().any(|k| *k == "deleted")
}
fn form_structure(&self) -> Option<Value> {
None
}
fn filterable_fields(&self) -> Vec<crate::filters::FilterField> {
Vec::new()
}
async fn list(&self, ctx: &ReqCtx) -> ApiResponse {
if !self.authorize(ctx, Action::List) {
return CoreError::Unauthorized.into();
}
let opts = parse_query(&ctx.query);
match storage().list(self.table_name(), &opts).await {
Ok(page) => ApiResponse::ok(json!({
"data": page.rows,
"total": page.total,
"page": opts.page,
"per_page": opts.per_page,
})),
Err(e) => CoreError::from(e).into(),
}
}
async fn get(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
if !self.authorize(ctx, Action::Read) {
return CoreError::Unauthorized.into();
}
match storage().get(self.table_name(), self.primary_key(), id).await {
Ok(Some(row)) => ApiResponse::ok(row),
Ok(None) => CoreError::NotFound.into(),
Err(e) => CoreError::from(e).into(),
}
}
async fn create(&self, ctx: &ReqCtx, body: Value) -> ApiResponse {
if !self.authorize(ctx, Action::Create) {
return CoreError::Unauthorized.into();
}
let data = match self.filter_writable(body) {
Ok(d) => d,
Err(resp) => return resp,
};
match storage().create(self.table_name(), data).await {
Ok(CreateOutcome { last_insert_id }) => ApiResponse::created(json!({
"success": true,
"message": format!("{} created successfully", self.resource_name()),
"last_insert_id": last_insert_id,
})),
Err(e) => CoreError::from(e).into(),
}
}
async fn update(&self, ctx: &ReqCtx, id: &str, body: Value) -> ApiResponse {
if !self.authorize(ctx, Action::Update) {
return CoreError::Unauthorized.into();
}
let data = match self.filter_writable(body) {
Ok(d) => d,
Err(resp) => return resp,
};
match storage()
.update(self.table_name(), self.primary_key(), id, data)
.await
{
Ok(n) if n > 0 => ApiResponse::ok(json!({
"success": true,
"message": format!("{} updated successfully", self.resource_name()),
"modified_count": n,
})),
Ok(_) => CoreError::NotFound.into(),
Err(e) => CoreError::from(e).into(),
}
}
async fn delete(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
if !self.authorize(ctx, Action::Delete) {
return CoreError::Unauthorized.into();
}
let soft = self.soft_delete();
match storage()
.delete(self.table_name(), self.primary_key(), id, soft)
.await
{
Ok(n) if n > 0 => ApiResponse::ok(json!({
"success": true,
"message": format!("{} deleted successfully", self.resource_name()),
"soft_delete": soft,
"affected": n,
})),
Ok(_) => CoreError::NotFound.into(),
Err(e) => CoreError::from(e).into(),
}
}
fn form_fields(&self) -> Vec<Value> {
match self.form_structure() {
Some(structure) => ui::fields_from_structure(&structure),
None => ui::default_fields(&self.permit_keys()),
}
}
async fn list_page(&self, ctx: &ReqCtx) -> ApiResponse {
if !self.authorize(ctx, Action::List) {
return crate::auth::login_redirect(ctx);
}
let params: HashMap<String, String> =
serde_urlencoded::from_str(&ctx.query).unwrap_or_default();
if let Some(format) = params.get("download") {
return self.export(ctx, format).await;
}
let mut opts = parse_query(&ctx.query);
let filter_fields = self.filterable_fields();
opts.filters = crate::filters::parse_filters(&ctx.query, &filter_fields);
let current_filters = crate::filters::filter_values(&ctx.query, &filter_fields);
let page = match storage().list(self.table_name(), &opts).await {
Ok(p) => p,
Err(e) => return CoreError::from(e).into(),
};
let headers = ui::derive_headers(&page.rows, self.primary_key());
let mut c = ui::base_context(ctx, self.resource_name());
c.insert("resource_name", self.resource_name());
c.insert("base_path", self.base_path());
c.insert("pk", self.primary_key());
c.insert("headers", &headers);
c.insert("rows", &page.rows);
c.insert("total", &page.total);
c.insert("page", &opts.page);
c.insert("per_page", &opts.per_page);
c.insert("filter_fields", &filter_fields);
c.insert("current_filters", ¤t_filters);
c.insert("has_filters", &(!filter_fields.is_empty()));
c.insert("has_active_filters", &(!opts.filters.is_empty()));
ui::render_with_csrf(ctx, c, "list.html")
}
async fn new_page(&self, ctx: &ReqCtx) -> ApiResponse {
if !self.authorize(ctx, Action::Create) {
return crate::auth::login_redirect(ctx);
}
let mut c = ui::base_context(ctx, self.resource_name());
c.insert("resource_name", self.resource_name());
c.insert("base_path", self.base_path());
c.insert("fields", &self.form_fields());
c.insert("is_edit", &false);
c.insert("record", &json!({}));
ui::render_with_csrf(ctx, c, "form.html")
}
async fn edit_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
if !self.authorize(ctx, Action::Update) {
return crate::auth::login_redirect(ctx);
}
let record = match storage().get(self.table_name(), self.primary_key(), id).await {
Ok(Some(r)) => r,
Ok(None) => return CoreError::NotFound.into(),
Err(e) => return CoreError::from(e).into(),
};
let mut c = ui::base_context(ctx, self.resource_name());
c.insert("resource_name", self.resource_name());
c.insert("base_path", self.base_path());
c.insert("fields", &self.form_fields());
c.insert("is_edit", &true);
c.insert("item_id", &id);
c.insert("record", &record);
ui::render_with_csrf(ctx, c, "form.html")
}
async fn view_page(&self, ctx: &ReqCtx, id: &str) -> ApiResponse {
if !self.authorize(ctx, Action::Read) {
return crate::auth::login_redirect(ctx);
}
let record = match storage().get(self.table_name(), self.primary_key(), id).await {
Ok(Some(r)) => r,
Ok(None) => return CoreError::NotFound.into(),
Err(e) => return CoreError::from(e).into(),
};
let headers = ui::derive_headers(std::slice::from_ref(&record), self.primary_key());
let actions: Vec<Value> = self
.custom_actions()
.iter()
.map(|a| json!({ "name": a.name, "label": a.display_label() }))
.collect();
let mut c = ui::base_context(ctx, self.resource_name());
c.insert("resource_name", self.resource_name());
c.insert("base_path", self.base_path());
c.insert("item_id", &id);
c.insert("headers", &headers);
c.insert("record", &record);
c.insert("actions", &actions);
ui::render_with_csrf(ctx, c, "view.html")
}
async fn create_form(&self, ctx: &ReqCtx, mut form: HashMap<String, String>) -> ApiResponse {
if !self.authorize(ctx, Action::Create) {
return crate::auth::login_redirect(ctx);
}
if let Some(reject) = csrf_guard(ctx, form.remove(crate::csrf::FIELD_NAME)) {
return reject;
}
let body = ui::form_to_json(form);
let resp = self.create(ctx, body).await;
if resp.status < 300 {
ApiResponse::redirect(format!("{}/{}/list", ctx.mount, self.base_path()))
} else {
resp
}
}
async fn update_form(
&self,
ctx: &ReqCtx,
id: &str,
mut form: HashMap<String, String>,
) -> ApiResponse {
if !self.authorize(ctx, Action::Update) {
return crate::auth::login_redirect(ctx);
}
if let Some(reject) = csrf_guard(ctx, form.remove(crate::csrf::FIELD_NAME)) {
return reject;
}
let body = ui::form_to_json(form);
let resp = self.update(ctx, id, body).await;
if resp.status < 300 {
ApiResponse::redirect(format!("{}/{}/view/{}", ctx.mount, self.base_path(), id))
} else {
resp
}
}
async fn delete_form(&self, ctx: &ReqCtx, id: &str, csrf: Option<String>) -> ApiResponse {
if !self.authorize(ctx, Action::Delete) {
return crate::auth::login_redirect(ctx);
}
if let Some(reject) = csrf_guard(ctx, csrf) {
return reject;
}
let resp = self.delete(ctx, id).await;
if resp.status < 300 {
ApiResponse::redirect(format!("{}/{}/list", ctx.mount, self.base_path()))
} else {
resp
}
}
async fn run_action(
&self,
ctx: &ReqCtx,
name: &str,
id: String,
body: Value,
csrf: Option<String>,
) -> ApiResponse {
if !self.authorize(ctx, Action::Custom(name)) {
return CoreError::Unauthorized.into();
}
if let Some(reject) = csrf_guard(ctx, csrf) {
return reject;
}
for action in self.custom_actions() {
if action.name == name {
return (action.handler)(ctx.clone(), id, body).await;
}
}
CoreError::NotFound.into()
}
async fn export(&self, ctx: &ReqCtx, format: &str) -> ApiResponse {
if !self.authorize(ctx, Action::Export) {
return crate::auth::login_redirect(ctx);
}
let opts = QueryOptions {
page: 1,
per_page: EXPORT_CAP,
sort_by: None,
sort_desc: false,
filters: crate::filters::parse_filters(&ctx.query, &self.filterable_fields()),
};
let page = match storage().list(self.table_name(), &opts).await {
Ok(p) => p,
Err(e) => return CoreError::from(e).into(),
};
match format {
"json" => {
let data = serde_json::to_vec_pretty(&page.rows).unwrap_or_default();
ApiResponse::new(
200,
ApiBody::Bytes {
content_type: "application/json".to_string(),
data,
},
)
.with_header(
"Content-Disposition",
format!("attachment; filename=\"{}.json\"", self.base_path()),
)
}
"csv" => {
let headers = ui::derive_headers(&page.rows, self.primary_key());
let data = rows_to_csv(&headers, &page.rows).into_bytes();
ApiResponse::new(
200,
ApiBody::Bytes {
content_type: "text/csv".to_string(),
data,
},
)
.with_header(
"Content-Disposition",
format!("attachment; filename=\"{}.csv\"", self.base_path()),
)
}
other => {
CoreError::BadRequest(format!("unsupported export format: {other}")).into()
}
}
}
fn authorize(&self, ctx: &ReqCtx, action: Action<'_>) -> bool {
crate::authz::authorize(ctx, &self.allowed_roles(), self.base_path(), action)
}
fn filter_writable(&self, body: Value) -> Result<Map<String, Value>, ApiResponse> {
let permitted: HashSet<&str> = self.permit_keys().into_iter().collect();
let readonly: HashSet<&str> = self.readonly_keys().into_iter().collect();
let pk = self.primary_key();
let mut out = Map::new();
if let Value::Object(map) = body {
for (k, v) in map {
if permitted.contains(k.as_str()) && !readonly.contains(k.as_str()) && k != pk {
out.insert(k, v);
}
}
}
if out.is_empty() {
return Err(ApiResponse::error(CoreError::BadRequest(
"No permitted fields in payload".into(),
)));
}
Ok(out)
}
fn generate_menu(&self) -> Option<MenuItem> {
Some(MenuItem {
title: self.menu().to_string(),
path: self.base_path().to_string(),
icon: Some("table".to_string()),
order: Some(10),
children: None,
})
}
}
impl Clone for Box<dyn Resource> {
fn clone(&self) -> Self {
self.clone_box()
}
}
fn csrf_guard(ctx: &ReqCtx, submitted: Option<String>) -> Option<ApiResponse> {
if !crate::auth::is_configured() {
return None;
}
if crate::csrf::verify(ctx, submitted.as_deref()) {
None
} else {
Some(ApiResponse::html(
403,
"<h1>403 Forbidden</h1><p>Your session expired or the request could \
not be verified. Please reload the page and try again.</p>"
.to_string(),
))
}
}