rok-cli 0.3.6

Developer CLI for rok-based Axum applications
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
    }}
}}
"#
    )
}