use super::{write_file, Scaffold, ScaffoldArgs, ScaffoldResult};
use anyhow::Result;
use heck::{ToSnakeCase, ToUpperCamelCase};
pub struct CrudApiScaffold;
impl Scaffold for CrudApiScaffold {
fn name(&self) -> &'static str {
"crud-api"
}
fn description(&self) -> &'static str {
"JSON API CRUD: pagination, sorting, filtering, resource wrapper"
}
fn generate(&self, args: &ScaffoldArgs) -> Result<ScaffoldResult> {
let mut r = ScaffoldResult::default();
let model = args.name_or("Resource");
let pascal = model.to_upper_camel_case();
let snake = model.to_snake_case();
let d = args.dry_run;
write_file(
&mut r,
&format!("src/app/controllers/{snake}_controller.rs"),
&controller_template(&pascal, &snake),
d,
)?;
write_file(
&mut r,
&format!("src/app/requests/{snake}_query.rs"),
&query_template(&pascal),
d,
)?;
let mig_dir = if std::path::Path::new("database/migrations").exists() {
"database/migrations"
} else {
"migrations"
};
write_file(
&mut r,
&format!("{mig_dir}/create_{snake}s_table.sql"),
&migration_template(&snake),
d,
)?;
write_file(
&mut r,
&format!("tests/{snake}_api_test.rs"),
&test_template(&pascal, &snake),
d,
)?;
r.warnings
.push("Register API routes in src/routes/mod.rs".to_string());
r.warnings
.push("Add Accept: application/json header to all requests".into());
Ok(r)
}
}
fn controller_template(pascal: &str, snake: &str) -> String {
format!(
r#"use axum::{{extract::{{Path, Query, State}}, response::IntoResponse, Json}};
use rok_auth::axum::{{Ctx, Response}};
use rok_validate::Valid;
use serde::{{Deserialize, Serialize}};
use crate::app::requests::{snake}_query::{pascal}Query;
#[derive(Serialize)]
pub struct PaginatedResponse<T> {{
pub data: Vec<T>,
pub meta: PaginationMeta,
}}
#[derive(Serialize)]
pub struct PaginationMeta {{
pub total: i64,
pub per_page: i64,
pub current_page: i64,
pub last_page: i64,
}}
pub async fn index(State(pool): State<sqlx::PgPool>, Query(q): Query<{pascal}Query>) -> impl IntoResponse {{
let page = q.page.unwrap_or(1).max(1);
let per_page = q.per_page.unwrap_or(15).clamp(1, 100);
let offset = (page - 1) * per_page;
// TODO: query DB with sort/filter/pagination
Response::json(serde_json::json!({{
"data": [],
"meta": {{ "total": 0, "per_page": per_page, "current_page": page, "last_page": 1 }}
}}))
}}
pub async fn show(State(pool): State<sqlx::PgPool>, Path(id): Path<i64>) -> impl IntoResponse {{
// TODO: fetch by id
Response::json(serde_json::json!({{ "data": {{ "id": id }} }}))
}}
pub async fn store(State(pool): State<sqlx::PgPool>, Json(body): Json<serde_json::Value>) -> impl IntoResponse {{
// TODO: validate + insert
Response::created(serde_json::json!({{ "data": body }}))
}}
pub async fn update(State(pool): State<sqlx::PgPool>, Path(id): Path<i64>, Json(body): Json<serde_json::Value>) -> impl IntoResponse {{
// TODO: validate + update
Response::json(serde_json::json!({{ "data": {{ "id": id }} }}))
}}
pub async fn destroy(State(pool): State<sqlx::PgPool>, Path(id): Path<i64>) -> impl IntoResponse {{
// TODO: delete
Response::no_content()
}}
"#
)
}
fn query_template(pascal: &str) -> String {
format!(
r#"use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct {pascal}Query {{
pub page: Option<i64>,
pub per_page: Option<i64>,
pub sort: Option<String>, // e.g. "name" or "-created_at"
pub filter: Option<serde_json::Value>, // ?filter[field]=value
}}
"#
)
}
fn migration_template(snake: &str) -> String {
format!(
r#"CREATE TABLE {snake}s (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON {snake}s (created_at DESC);
"#
)
}
fn test_template(_pascal: &str, snake: &str) -> String {
format!(
r#"#[cfg(test)]
mod tests {{
#[tokio::test]
async fn test_{snake}_pagination() {{
// TODO: GET /{snake}s?page=1&per_page=5, verify meta fields
}}
#[tokio::test]
async fn test_{snake}_sorting() {{
// TODO: GET /{snake}s?sort=-created_at, verify descending order
}}
#[tokio::test]
async fn test_{snake}_filtering() {{
// TODO: GET /{snake}s?filter[name]=foo, verify filtered results
}}
}}
"#
)
}