rust-db-blueprint 0.1.0

A Rust code generator — reads YAML draft files and generates Axum + SQLx models, migrations, handlers, routes, requests, tests, and seeds
Documentation
use tera::{Tera, Context};
use indexmap::IndexMap;

use crate::tree::Tree;
use crate::models::Statement;

pub struct ControllerGenerator;

impl ControllerGenerator {
    pub fn generate(tree: &Tree) -> IndexMap<String, String> {
        let mut files = IndexMap::new();
        let mut tera = Tera::default();
        tera.add_raw_template("handler", HANDLER_TEMPLATE).unwrap();

        let mut mod_lines = vec!["pub mod prelude;".to_string()];
        let mut prelude_lines = vec![];

        for controller in tree.all_controllers() {
            let mut ctx = Context::new();
            let model_name = controller.name.trim_end_matches("Controller").to_string();
            let handler_mod = crate::generators::model_generator::to_snake_case(&model_name);
            let handler_struct = format!("{}Handlers", &model_name);

            ctx.insert("handler_struct", &handler_struct);
            ctx.insert("model_name", &model_name);
            ctx.insert("model_var", &model_name.to_lowercase());

            let mut method_defs = vec![];
            for (method_name, stmts) in &controller.methods {
                let code = Self::render_handler(tree, method_name, stmts, &model_name);
                method_defs.push(code);
            }
            ctx.insert("methods", &method_defs);

            let rendered = tera.render("handler", &ctx).unwrap();
            let path = format!("src/handlers/{}.rs", handler_mod);
            files.insert(path, rendered);

            mod_lines.push(format!("pub mod {};", handler_mod));
            prelude_lines.push(format!("pub use super::{}::{};", handler_mod, handler_struct));
        }

        files.insert("src/handlers/mod.rs".to_string(), mod_lines.join("\n"));
        files.insert("src/handlers/prelude.rs".to_string(), format!("//! Re-exports\n\n{}\n", prelude_lines.join("\n")));

        files
    }

    fn render_handler(tree: &Tree, name: &str, stmts: &[Statement], model_name: &str) -> String {
        let model_var = model_name.to_lowercase();
        let model_path = format!("crate::models::{}", model_name);
        let request_path = format!("crate::requests::{}", name);

        let has_request = stmts.iter().any(|s| matches!(s, Statement::Validate(_)));

        let mut sig = format!(
            "pub async fn {}(State(pool): State<sqlx::PgPool>",
            name
        );
        if has_request {
            sig.push_str(&format!(
                ", Json(body): Json<crate::requests::{}>",
                capitalize(name)
            ));
        }
        sig.push_str(") -> impl axum::response::IntoResponse");

        let mut body = String::new();
        for stmt in stmts {
            let rendered = match stmt {
                Statement::Query(_) => {
                    format!(
                        "let items = sqlx::query_as::<_, {}>(\"SELECT * FROM {}\")\n            .fetch_all(&pool)\n            .await\n            .unwrap();",
                        model_path, pluralize(&model_name.to_lowercase())
                    )
                }
                Statement::Find(_) => {
                    format!(
                        "let item = sqlx::query_as::<_, {}>(\"SELECT * FROM {} WHERE id = $1\")\n            .bind(id)\n            .fetch_one(&pool)\n            .await\n            .unwrap();",
                        model_path, pluralize(&model_name.to_lowercase())
                    )
                }
                Statement::Save(_) => {
                    format!(
                        "let item = sqlx::query_as::<_, {}>(\n                \"INSERT INTO {} ({}) VALUES ($1) RETURNING *\"\n            )\n            .fetch_one(&pool)\n            .await\n            .unwrap();",
                        model_path, pluralize(&model_name.to_lowercase()), get_insert_columns(tree, model_name)
                    )
                }
                Statement::Delete(_) => {
                    format!(
                        "sqlx::query(\"DELETE FROM {} WHERE id = $1\")\n            .bind(id)\n            .execute(&pool)\n            .await\n            .unwrap();",
                        pluralize(&model_name.to_lowercase())
                    )
                }
                Statement::Validate(ref s) => {
                    if !s.fields.is_empty() {
                        format!("// validated via Json(crate::requests::{})", capitalize(name))
                    } else {
                        String::new()
                    }
                }
                Statement::Render(ref s) => format!("// render: {}", s.view),
                Statement::Redirect(ref s) => format!("// redirect: {}", s.route),
                Statement::Respond(ref s) => {
                    let status = s.status.unwrap_or(200);
                    format!("StatusCode::from_u16({}).unwrap()", status)
                }
                _ => String::new(),
            };
            if !rendered.is_empty() {
                body.push_str(&format!("        {}\n", rendered));
            }
        }
        body.push_str(&format!("        axum::Json(serde_json::json!({{\"ok\": true}}))"));

        format!("{}\n{{\n{}}}", sig, body)
    }
}

fn capitalize(s: &str) -> String {
    let mut c = s.chars();
    match c.next() {
        None => String::new(),
        Some(f) => f.to_uppercase().to_string() + c.as_str(),
    }
}

fn pluralize(s: &str) -> String {
    format!("{}s", s)
}

fn get_insert_columns(_tree: &Tree, model_name: &str) -> String {
    format!("{}, field1, field2", model_name.to_lowercase())
}

const HANDLER_TEMPLATE: &str = r#"use axum::{extract::State, Json};
use axum::http::StatusCode;
use serde_json::Value;
use sqlx::PgPool;

use crate::models::prelude::*;

/// Axum handlers for {{ model_name }} routes.
pub struct {{ handler_struct }};

impl {{ handler_struct }} {
{% for method in methods %}
{{ method }}
{% endfor %}
}
"#;