use std::{fs, path::Path};
use heck::{ToSnakeCase, ToUpperCamelCase};
pub fn controller(name: &str, resource: bool) -> anyhow::Result<()> {
let camel = name.to_upper_camel_case();
let base = camel.strip_suffix("Controller").unwrap_or(&camel);
let pascal = base.to_upper_camel_case();
let snake = base.to_snake_case();
let path = format!("src/app/controllers/{snake}_controller.rs");
if resource {
write_file(&path, &resource_controller_template(&pascal, &snake))?;
} else {
write_file(&path, &controller_template(&pascal, &snake))?;
}
println!("Created {path}");
println!("Register it in src/app/controllers/mod.rs:");
println!(" pub mod {snake}_controller;");
Ok(())
}
fn controller_template(pascal: &str, snake: &str) -> String {
format!(
r#"use axum::{{extract::Path}};
use rok_auth::axum::Ctx;
use rok_auth_macros::controller;
use rok_core::api::ApiResponse;
#[controller]
pub struct {pascal}Controller;
impl {pascal}Controller {{
/// `GET /{snake}s`
pub async fn index(ctx: Ctx) -> ApiResponse {{
ApiResponse::ok(serde_json::json!([]))
}}
/// `POST /{snake}s`
pub async fn store(ctx: Ctx) -> ApiResponse {{
ApiResponse::created(serde_json::json!({{ "message": "created" }}))
}}
/// `GET /{snake}s/:id`
pub async fn show(ctx: Ctx, Path(id): Path<i64>) -> ApiResponse {{
ApiResponse::ok(serde_json::json!({{ "id": id }}))
}}
/// `PUT /{snake}s/:id`
pub async fn update(ctx: Ctx, Path(id): Path<i64>) -> ApiResponse {{
ApiResponse::ok(serde_json::json!({{ "id": id, "message": "updated" }}))
}}
/// `DELETE /{snake}s/:id`
pub async fn destroy(ctx: Ctx, Path(id): Path<i64>) -> ApiResponse {{
ApiResponse::no_content()
}}
}}
"#
)
}
fn resource_controller_template(pascal: &str, snake: &str) -> String {
format!(
r#"use axum::{{
extract::{{Path, State}},
Json,
}};
use rok_auth::axum::Ctx;
use rok_auth_macros::controller;
use rok_core::api::ApiResponse;
use crate::app::models::{pascal};
use crate::app::resources::{snake}_resource::{pascal}Resource;
use crate::app::validators::{{create_{snake}_request::Create{pascal}Request, update_{snake}_request::Update{pascal}Request}};
use crate::state::AppState;
#[controller]
pub struct {pascal}Controller;
impl {pascal}Controller {{
pub async fn index(ctx: Ctx, State(state): State<AppState>) -> ApiResponse {{
let items = {pascal}::all(&state.pool).await?;
let data: Vec<_> = items.into_iter().map({pascal}Resource::from).collect();
ApiResponse::ok(data)
}}
pub async fn store(
ctx: Ctx,
State(state): State<AppState>,
Json(body): Json<Create{pascal}Request>,
) -> ApiResponse {{
let item = {pascal}::create(&state.pool, body).await?;
ApiResponse::created({pascal}Resource::from(item))
}}
pub async fn show(
ctx: Ctx,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> ApiResponse {{
let item = {pascal}::find_or_fail(&state.pool, id).await?;
ApiResponse::ok({pascal}Resource::from(item))
}}
pub async fn update(
ctx: Ctx,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<Update{pascal}Request>,
) -> ApiResponse {{
let item = {pascal}::update(&state.pool, id, body).await?;
ApiResponse::ok({pascal}Resource::from(item))
}}
pub async fn destroy(
ctx: Ctx,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> ApiResponse {{
{pascal}::destroy(&state.pool, id).await?;
ApiResponse::no_content()
}}
}}
"#
)
}
#[allow(dead_code)]
pub fn model(name: &str, id_type: Option<&str>) -> anyhow::Result<()> {
model_full(name, id_type, false, false, false, false)
}
pub fn model_full(
name: &str,
id_type: Option<&str>,
with_migration: bool,
with_controller: bool,
with_resource: bool,
with_factory: bool,
) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/models/{snake}.rs");
write_file(&path, &model_template(&pascal, &snake, id_type))?;
println!("Created {path}");
println!(" Register in src/app/models/mod.rs: pub mod {snake};");
if with_migration {
migration(&format!("create_{snake}s_table"))?;
}
if with_controller {
controller(name, false)?;
}
if with_resource {
resource(name, false)?;
}
if with_factory {
make_factory(name)?;
}
Ok(())
}
pub fn model_all(name: &str, id_type: Option<&str>) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let model_path = format!("src/app/models/{snake}.rs");
write_file(&model_path, &model_template(&pascal, &snake, id_type))?;
println!("Created {model_path}");
migration(&format!("create_{snake}s_table"))?;
controller(name, true)?;
resource(name, false)?;
make_request_pair(name)?;
policy(name, false)?;
make_service(name, false)?;
test(name, false)?;
println!();
println!("Routes hint — add to your router:");
println!(" Route::resource(\"/{snake}s\", {pascal}Controller)");
println!();
println!("Register modules:");
println!(" src/app/models/mod.rs → pub mod {snake};");
println!(" src/app/controllers/mod.rs → pub mod {snake}_controller;");
println!(" src/app/resources/mod.rs → pub mod {snake}_resource;");
println!(" src/app/requests/mod.rs → pub mod create_{snake}_request;");
println!(" src/app/requests/mod.rs → pub mod update_{snake}_request;");
println!(" src/app/policies/mod.rs → pub mod {snake};");
println!(" src/app/services/mod.rs → pub mod {snake}_service;");
Ok(())
}
pub fn make_factory(name: &str) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/database/factories/{snake}_factory.rs");
write_file(&path, &factory_template(&pascal))?;
println!("Created {path}");
Ok(())
}
fn factory_template(pascal: &str) -> String {
format!(
r#"use rok_orm::factory::Factory;
pub struct {pascal}Factory;
impl Factory for {pascal}Factory {{
type Model = super::{pascal};
fn definition() -> Vec<(&'static str, rok_orm::SqlValue)> {{
vec![
// ("name", "Default Name".into()),
]
}}
}}
"#
)
}
fn model_template(pascal: &str, snake: &str, id_type: Option<&str>) -> String {
let (use_line, id_rust_type, id_attr) = match id_type {
Some("ulid") => (
"use rok_core::crypto::ids::Ulid;\n",
"Ulid",
"#[rok_orm(id = \"ulid\")]\n",
),
Some("cuid2") => (
"use rok_core::crypto::ids::Cuid2;\n",
"Cuid2",
"#[rok_orm(id = \"cuid2\")]\n",
),
Some("uuid_v7") => (
"use rok_core::crypto::ids::UuidV7;\n",
"UuidV7",
"#[rok_orm(id = \"uuid_v7\")]\n",
),
Some("snowflake") => (
"use rok_core::crypto::ids::Snowflake;\n",
"Snowflake",
"#[rok_orm(id = \"snowflake\")]\n",
),
Some("nanoid") => (
"use rok_core::crypto::ids::NanoId;\n",
"NanoId",
"#[rok_orm(id = \"nanoid\")]\n",
),
_ => ("", "i64", ""),
};
let migration_hint = match id_type {
Some("ulid") => "// Migration: t.ulid_pk();".to_string(),
Some("cuid2") => "// Migration: t.cuid2_pk();".to_string(),
Some("uuid_v7") => "// Migration: t.uuid_v7_pk();".to_string(),
Some("snowflake") => "// Migration: t.snowflake_pk();".to_string(),
Some("nanoid") => "// Migration: t.nanoid_pk();".to_string(),
_ => "// Migration: t.id();".to_string(),
};
let _ = snake; format!(
r#"use rok_orm::Model;
use serde::{{Deserialize, Serialize}};
{use_line}
#[derive(Debug, Clone, Model, Serialize, Deserialize, sqlx::FromRow)]
{id_attr}pub struct {pascal} {{
pub id: {id_rust_type},
{migration_hint}
// TODO: add fields
}}
"#
)
}
fn migration_dir() -> &'static str {
if Path::new("database/migrations").exists() {
"database/migrations"
} else {
"migrations"
}
}
pub fn migration(name: &str) -> anyhow::Result<()> {
use chrono::Local;
let snake = name.to_snake_case();
let pascal = snake.to_upper_camel_case();
let ts = Local::now().format("%Y_%m_%d_%H%M%S").to_string();
let dir = migration_dir();
fs::create_dir_all(dir)?;
let filename = format!("{dir}/{ts}_{snake}.rs");
write_file(&filename, &migration_template(&pascal, &snake, &ts))?;
println!("Created {filename}");
println!("Register in your migration runner:");
println!(" .migration({pascal})");
Ok(())
}
fn migration_template(pascal: &str, snake: &str, timestamp: &str) -> String {
format!(
r#"use rok_orm::{{Migration, SchemaExecutor}};
use async_trait::async_trait;
pub struct {pascal};
#[async_trait]
impl Migration for {pascal} {{
fn name(&self) -> &str {{
"{timestamp}_{snake}"
}}
async fn up(&self, schema: &SchemaExecutor) -> anyhow::Result<()> {{
schema.create("{snake}", |t| {{
t.id();
// TODO: add columns
// t.string("name").not_null();
t.timestamps();
}}).await
}}
async fn down(&self, schema: &SchemaExecutor) -> anyhow::Result<()> {{
schema.drop_table_if_exists("{snake}").await
}}
}}
"#
)
}
pub fn seeder(name: &str) -> anyhow::Result<()> {
let camel = name.to_upper_camel_case();
let base = camel.strip_suffix("Seeder").unwrap_or(&camel);
let pascal = base.to_upper_camel_case();
let snake = base.to_snake_case();
let path = format!("src/database/seeders/{snake}_seeder.rs");
fs::create_dir_all("src/database/seeders")?;
write_file(&path, &seeder_template(&pascal))?;
println!("Created {path}");
Ok(())
}
fn seeder_template(pascal: &str) -> String {
format!(
r#"use sqlx::PgPool;
pub struct {pascal}Seeder;
impl {pascal}Seeder {{
pub async fn run(pool: &PgPool) -> Result<(), sqlx::Error> {{
// TODO: insert seed data
Ok(())
}}
}}
"#
)
}
pub fn validator(name: &str) -> anyhow::Result<()> {
let camel = name.to_upper_camel_case();
let base = camel.strip_suffix("Request").unwrap_or(&camel);
let pascal = base.to_upper_camel_case();
let snake = base.to_snake_case();
let path = format!("src/app/validators/{snake}_request.rs");
write_file(&path, &validator_template(&pascal))?;
println!("Created {path}");
println!("Register it in src/app/validators/mod.rs:");
println!(" pub mod {snake}_request;");
Ok(())
}
fn validator_template(pascal: &str) -> String {
format!(
r#"use rok_validate::Validate;
use serde::Deserialize;
#[derive(Deserialize, Validate)]
pub struct {pascal}Request {{
// TODO: add validated fields
// #[validate(required, min = 1, max = 255)]
// pub name: String,
}}
"#
)
}
pub fn request(name: &str) -> anyhow::Result<()> {
let camel = name.to_upper_camel_case();
let base = camel.strip_suffix("Request").unwrap_or(&camel);
let pascal = base.to_upper_camel_case();
let snake = base.to_snake_case();
let path = format!("src/app/requests/{snake}_request.rs");
write_file(&path, &request_template(&pascal))?;
println!("Created {path}");
println!("Register it in src/app/requests/mod.rs:");
println!(" pub mod {snake}_request;");
Ok(())
}
pub fn make_request_pair(name: &str) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let create_path = format!("src/app/requests/create_{snake}_request.rs");
write_file(&create_path, &request_template(&format!("Create{pascal}")))?;
println!("Created {create_path}");
let update_path = format!("src/app/requests/update_{snake}_request.rs");
write_file(&update_path, &request_template(&format!("Update{pascal}")))?;
println!("Created {update_path}");
Ok(())
}
fn request_template(pascal: &str) -> String {
format!(
r#"use rok_validate::{{FormRequest, Validate}};
use serde::Deserialize;
#[derive(Deserialize, Validate, FormRequest)]
pub struct {pascal}Request {{
// TODO: add validated fields
// #[validate(required, bail, min = 1, max = 255)]
// pub name: String,
// #[validate(sometimes, required, email)]
// pub email: Option<String>,
}}
"#
)
}
pub fn make_service(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/services/{snake}_service.rs");
write_file_guarded(&path, &service_template(&pascal, &snake), force)?;
println!("Created {path}");
println!("Register in src/app/services/mod.rs:");
println!(" pub mod {snake}_service;");
Ok(())
}
fn service_template(pascal: &str, snake: &str) -> String {
format!(
r#"use sqlx::PgPool;
use crate::app::models::{snake}::{pascal};
pub struct {pascal}Service<'a> {{
pool: &'a PgPool,
}}
impl<'a> {pascal}Service<'a> {{
pub fn new(pool: &'a PgPool) -> Self {{
Self {{ pool }}
}}
pub async fn all(&self) -> Result<Vec<{pascal}>, sqlx::Error> {{
{pascal}::all(self.pool).await
}}
pub async fn find(&self, id: i64) -> Result<{pascal}, sqlx::Error> {{
{pascal}::find_or_fail(self.pool, id).await
}}
}}
"#
)
}
pub fn locale(lang: &str, force: bool) -> anyhow::Result<()> {
if lang == "en" {
anyhow::bail!("Cannot generate a template from itself. Use a language other than 'en'.");
}
let lang = lang.to_lowercase();
let out_path = format!("locales/{lang}.json");
if !force && std::path::Path::new(&out_path).exists() {
anyhow::bail!("{out_path} already exists. Pass --force to overwrite.");
}
let template_map: serde_json::Map<String, serde_json::Value> =
if let Ok(content) = std::fs::read_to_string("locales/en.json") {
serde_json::from_str(&content).unwrap_or_else(|_| default_locale_template())
} else {
default_locale_template()
};
let blank = blank_locale_map(&template_map);
let json = serde_json::to_string_pretty(&blank)?;
fs::create_dir_all("locales")?;
fs::write(&out_path, json + "\n")?;
println!("Created {out_path}");
println!("Translate the values in {out_path} then add I18nLayer::with_dir(\"locales\", \"{lang}\") to your router.");
Ok(())
}
fn blank_locale_map(
template: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
use serde_json::Value;
let mut out = serde_json::Map::new();
for (k, v) in template {
let blank_val = match v {
Value::String(_) => Value::String(String::new()),
Value::Object(inner) => {
let mut obj = serde_json::Map::new();
for (ik, iv) in inner {
obj.insert(
ik.clone(),
if iv.is_string() {
Value::String(String::new())
} else {
iv.clone()
},
);
}
Value::Object(obj)
}
other => other.clone(),
};
out.insert(k.clone(), blank_val);
}
out
}
fn default_locale_template() -> serde_json::Map<String, serde_json::Value> {
let v: serde_json::Value = serde_json::json!({
"validation.required": "The {field} field is required.",
"validation.email": "The {field} field must be a valid email address.",
"validation.url": "The {field} field must be a valid URL.",
"validation.numeric": "The {field} field must be a number.",
"validation.min_length": "The {field} field must be at least {min} character(s).",
"validation.max_length": "The {field} field may not be greater than {max} character(s).",
"validation.min": "The {field} field must be at least {min}.",
"validation.max": "The {field} field may not be greater than {max}.",
"validation.same": "The {field} field confirmation does not match.",
"validation.in": "The {field} field must be one of: {options}."
});
v.as_object().cloned().unwrap_or_default()
}
pub fn observer(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/observers/{snake}.rs");
write_file_guarded(&path, &observer_template(&pascal), force)?;
println!("Created {path}");
println!("Register with: rok_orm::observe::<Model, {pascal}>();");
Ok(())
}
fn observer_template(pascal: &str) -> String {
format!(
r#"use rok_orm::{{Observer, OrmResult}};
pub struct {pascal};
impl<M: rok_orm::Model> Observer<M> for {pascal} {{
fn creating(&self, _model: &M) -> OrmResult<()> {{ Ok(()) }}
fn created(&self, _model: &M) {{}}
fn updating(&self, _model: &M) -> OrmResult<()> {{ Ok(()) }}
fn updated(&self, _model: &M) {{}}
fn deleting(&self, _model: &M) -> OrmResult<()> {{ Ok(()) }}
fn deleted(&self, _model: &M) {{}}
}}
"#
)
}
pub fn resource(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/resources/{snake}_resource.rs");
write_file_guarded(&path, &resource_template(&pascal), force)?;
println!("Created {path}");
println!("Register in src/app/resources/mod.rs:");
println!(" pub mod {snake}_resource;");
Ok(())
}
fn resource_template(pascal: &str) -> String {
format!(
r#"use serde_json::json;
pub struct {pascal}Resource;
impl {pascal}Resource {{
pub fn transform(model: &impl serde::Serialize) -> serde_json::Value {{
// TODO: map model fields to the response shape
json!({{ "id": 0 }})
}}
}}
"#
)
}
pub fn scope(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/scopes/{snake}.rs");
write_file_guarded(&path, &scope_template(&pascal), force)?;
println!("Created {path}");
Ok(())
}
fn scope_template(pascal: &str) -> String {
format!(
r#"use rok_orm::{{Model, QueryBuilder}};
pub struct {pascal};
impl {pascal} {{
pub fn apply<M: Model>(builder: QueryBuilder<M>) -> QueryBuilder<M> {{
// TODO: add scope conditions
// builder.where_eq("active", true)
builder
}}
}}
"#
)
}
pub fn policy(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/policies/{snake}.rs");
write_file_guarded(&path, &policy_template(&pascal), force)?;
println!("Created {path}");
println!("Register in src/app/policies/mod.rs:");
println!(" pub mod {snake};");
Ok(())
}
fn policy_template(pascal: &str) -> String {
format!(
r#"pub struct {pascal};
impl {pascal} {{
pub fn view(&self, _user_id: i64) -> bool {{ true }}
pub fn create(&self, _user_id: i64) -> bool {{ true }}
pub fn update(&self, _user_id: i64) -> bool {{ true }}
pub fn delete(&self, _user_id: i64) -> bool {{ true }}
}}
"#
)
}
pub fn job(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/jobs/{snake}.rs");
write_file_guarded(&path, &job_template(&pascal), force)?;
println!("Created {path}");
println!("Dispatch with: rok_queue::dispatch({pascal} {{ ... }}).await?;");
Ok(())
}
fn job_template(pascal: &str) -> String {
format!(
r#"use rok_queue::{{Job, JobContext, JobError, Runnable}};
use serde::{{Deserialize, Serialize}};
#[derive(Serialize, Deserialize, Job)]
#[job(queue = "default", max_attempts = 3)]
pub struct {pascal} {{
// TODO: add job payload fields
pub id: i64,
}}
impl Runnable for {pascal} {{
async fn handle(&self, _ctx: &JobContext) -> Result<(), JobError> {{
// TODO: implement job logic
Ok(())
}}
}}
"#
)
}
pub fn event(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/events/{snake}.rs");
write_file_guarded(&path, &event_template(&pascal), force)?;
println!("Created {path}");
println!("Emit with: rok_events::emit({pascal} {{ ... }});");
Ok(())
}
fn event_template(pascal: &str) -> String {
format!(
r#"use rok_events::Event;
use serde::{{Deserialize, Serialize}};
#[derive(Serialize, Deserialize, Event)]
pub struct {pascal} {{
// TODO: add event fields
}}
"#
)
}
pub fn listener(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/listeners/{snake}.rs");
write_file_guarded(&path, &listener_template(&pascal), force)?;
println!("Created {path}");
println!("Register with: rok_events::listen::<EventType, {pascal}>();");
Ok(())
}
fn listener_template(pascal: &str) -> String {
format!(
r#"pub struct {pascal};
impl {pascal} {{
pub async fn handle<E: rok_events::Event>(&self, _event: &E) {{
// TODO: implement listener logic
}}
}}
"#
)
}
pub fn notification(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let path = format!("src/app/notifications/{snake}.rs");
write_file_guarded(&path, ¬ification_template(&pascal), force)?;
println!("Created {path}");
Ok(())
}
fn notification_template(pascal: &str) -> String {
format!(
r#"use serde::{{Deserialize, Serialize}};
#[derive(Serialize, Deserialize)]
pub struct {pascal} {{
// TODO: add notification fields
}}
impl {pascal} {{
pub fn channels(&self) -> Vec<&'static str> {{
vec!["mail", "database"]
}}
}}
"#
)
}
pub fn test(name: &str, force: bool) -> anyhow::Result<()> {
let pascal = name.to_upper_camel_case();
let snake = name.to_snake_case();
let crud_path = format!("tests/{snake}_test.rs");
if !force && Path::new(&crud_path).exists() {
println!("skip {crud_path} already exists — use --force to create a second file");
return Ok(());
}
let path = format!("tests/{snake}.rs");
write_file_guarded(&path, &test_template(&pascal, &snake), force)?;
println!("Created {path}");
Ok(())
}
fn test_template(pascal: &str, snake: &str) -> String {
let _ = pascal;
format!(
r#"#[cfg(test)]
mod tests {{
#[tokio::test]
async fn {snake}_test() {{
// TODO: implement test
}}
}}
"#
)
}
pub fn auth(guard: &str, features: &str, model: &str, force: bool) -> anyhow::Result<()> {
let model_pascal = model.to_upper_camel_case();
let model_snake = model.to_snake_case();
let is_jwt = guard == "jwt";
let has_email_verify = features.contains("email-verify");
let has_password_reset = features.contains("password-reset");
let has_totp = features.contains("totp");
let password_reset_imports = if has_password_reset {
"use crate::app::validators::forgot_password_request::ForgotPasswordRequest;\nuse crate::app::validators::reset_password_request::ResetPasswordRequest;"
} else {
""
};
let features_doc = if has_email_verify && has_password_reset {
"email-verify, password-reset"
} else if has_email_verify {
"email-verify"
} else if has_password_reset {
"password-reset"
} else {
""
};
{
let path = format!("src/app/models/{model_snake}.rs");
write_file_guarded(&path, &auth_model_template(&model_pascal, &model_snake, is_jwt, has_email_verify), force)?;
println!("Created {path}");
}
{
use chrono::Local;
let ts = Local::now().format("%Y_%m_%d_%H%M%S").to_string();
let dir = migration_dir();
fs::create_dir_all(dir)?;
let filename = format!("{dir}/{ts}_create_{model_snake}s_table.rs");
write_file_guarded(&filename, &auth_migration_template(&model_pascal, &model_snake, &ts, is_jwt, has_email_verify), force)?;
println!("Created {filename}");
}
{
let path = "src/app/controllers/auth_controller.rs".to_string();
write_file_guarded(&path, &auth_controller_template(is_jwt, has_email_verify, has_password_reset, has_totp, features_doc, password_reset_imports), force)?;
println!("Created {path}");
}
{
let path = "src/app/validators/login_request.rs".to_string();
write_file_guarded(&path, &login_validator_template(), force)?;
println!("Created {path}");
}
{
let path = "src/app/validators/register_request.rs".to_string();
write_file_guarded(&path, ®ister_validator_template(), force)?;
println!("Created {path}");
}
if has_password_reset {
let path = "src/app/validators/forgot_password_request.rs".to_string();
write_file_guarded(&path, &forgot_password_validator_template(), force)?;
println!("Created {path}");
}
if has_password_reset {
let path = "src/app/validators/reset_password_request.rs".to_string();
write_file_guarded(&path, &reset_password_validator_template(), force)?;
println!("Created {path}");
}
{
fs::create_dir_all("src/database/factories")?;
let path = format!("src/database/factories/{model_snake}_factory.rs");
write_file_guarded(&path, &auth_factory_template(&model_pascal), force)?;
println!("Created {path}");
}
{
fs::create_dir_all("src/database/seeders")?;
let path = format!("src/database/seeders/{model_snake}_seeder.rs");
write_file_guarded(&path, &auth_seeder_template(&model_pascal), force)?;
println!("Created {path}");
}
println!();
println!("Next steps:");
println!(" 1. Register auth routes:");
println!(" use crate::app::controllers::auth_controller::AuthController;");
println!(" .nest(\"/auth\", AuthController::routes())");
if is_jwt {
println!(" 2. Add JWT_SECRET to your .env file");
}
if has_email_verify {
println!(" 3. Configure mail for email verification");
}
println!(" 4. Register {}Seeder in your seeder runner", model_pascal);
println!(" 5. Run: cargo run -- db:migrate && cargo run -- db:seed");
Ok(())
}
fn auth_model_template(pascal: &str, _snake: &str, is_jwt: bool, has_email_verify: bool) -> String {
let id_field = if is_jwt {
" pub id: i64,\n // Migration: t.id();".to_string()
} else {
" pub id: i64,\n // Migration: t.id();".to_string()
};
let email_verify_fields = if has_email_verify {
r#" pub email_verified_at: Option<chrono::DateTime<chrono::Utc>>,"#.to_string()
} else {
String::new()
};
format!(
r#"use rok_orm::Model;
use serde::{{Deserialize, Serialize}};
#[derive(Debug, Clone, Model, Serialize, Deserialize, sqlx::FromRow)]
pub struct {pascal} {{
{id_field}
pub name: String,
pub email: String,
pub password: String,
{email_verify_fields}
pub remember_token: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}}
"#,
id_field = id_field,
email_verify_fields = email_verify_fields
)
}
fn auth_migration_template(pascal: &str, snake: &str, timestamp: &str, _is_jwt: bool, has_email_verify: bool) -> String {
let _ = pascal;
let email_verify_col = if has_email_verify {
" t.timestamp_nullable(\"email_verified_at\");"
} else {
""
};
format!(
r#"use rok_orm::{{Migration, SchemaExecutor}};
use async_trait::async_trait;
pub struct Create{pascal}sTable;
#[async_trait]
impl Migration for Create{pascal}sTable {{
fn name(&self) -> &str {{
"{timestamp}_create_{snake}s_table"
}}
async fn up(&self, schema: &SchemaExecutor) -> anyhow::Result<()> {{
schema.create("{snake}s", |t| {{
t.id();
t.string("name").not_null();
t.string("email").unique().not_null();
t.string("password").not_null();
{email_verify_col}
t.string_nullable("remember_token");
t.timestamps();
}}).await
}}
async fn down(&self, schema: &SchemaExecutor) -> anyhow::Result<()> {{
schema.drop_table_if_exists("{snake}s").await
}}
}}
"#,
email_verify_col = email_verify_col
)
}
fn auth_controller_template(
is_jwt: bool,
has_email_verify: bool,
has_password_reset: bool,
_has_totp: bool,
_features_doc: &str,
password_reset_imports: &str,
) -> String {
let auth_header = if is_jwt {
r#"use rok_auth::jwt::{JwtToken, TokenResponse};
use rok_auth::Hasher;"#.to_string()
} else {
r#"use rok_auth::session::SessionUser;
use rok_auth::Hasher;"#.to_string()
};
let login_return = if is_jwt {
r#" let token = JwtToken::from_user(&user).map_err(|e| Response::internal_error(e.to_string()))?;
Response::json(serde_json::json!({ "token": token.access_token(), "user": user }))"#.to_string()
} else {
r#" SessionUser::login(&ctx, &user).map_err(|e| Response::internal_error(e.to_string()))?;
Response::json(serde_json::json!({ "user": user }))"#.to_string()
};
let logout_handler = if is_jwt {
r#"
/// Invalidate the current token.
pub async fn logout(ctx: Ctx) -> impl IntoResponse {
JwtToken::from_ctx(&ctx)?.revoke().await;
Response::no_content()
}
"#.to_string()
} else {
r#"
/// End the current session.
pub async fn logout(ctx: Ctx) -> impl IntoResponse {
SessionUser::logout(&ctx);
Response::no_content()
}
"#.to_string()
};
let email_verify_handler = if has_email_verify {
r#"
/// Send email verification link.
pub async fn send_verify_email(ctx: Ctx) -> impl IntoResponse {
let user = ctx.user()?;
// TODO: dispatch SendEmailVerification notification
Response::json(serde_json::json!({ "message": "Verification email sent" }))
}
/// Verify email with signed URL token.
pub async fn verify_email(ctx: Ctx, Path(token): Path<String>) -> impl IntoResponse {
// TODO: validate signed token, mark email_verified_at
Response::json(serde_json::json!({ "message": "Email verified" }))
}
"#.to_string()
} else {
String::new()
};
let password_reset_handler = if has_password_reset {
r#"
/// Send password reset link.
pub async fn forgot_password(
Valid(Json(req)): Valid<Json<ForgotPasswordRequest>>,
) -> impl IntoResponse {
// TODO: dispatch SendPasswordReset notification
Response::json(serde_json::json!({ "message": "Reset link sent if email exists" }))
}
/// Reset password with signed token.
pub async fn reset_password(
Valid(Json(req)): Valid<Json<ResetPasswordRequest>>,
) -> impl IntoResponse {
// TODO: validate token, update password
Response::json(serde_json::json!({ "message": "Password updated" }))
}
"#.to_string()
} else {
String::new()
};
format!(
r#"use axum::{{extract::Path, response::IntoResponse, Json}};
use rok_auth::axum::{{Ctx, Response}};
use rok_auth_macros::controller;
use rok_validate::Valid;
use serde::Deserialize;
{auth_header}
use crate::app::models::User;
use crate::app::validators::login_request::LoginRequest;
use crate::app::validators::register_request::RegisterRequest;
{password_reset_imports}
#[controller]
pub struct AuthController;
impl AuthController {{
/// Register a new user.
pub async fn register(
Valid(Json(req)): Valid<Json<RegisterRequest>>,
) -> impl IntoResponse {{
let hash = Hasher::make(&req.password);
let user = User::create(&rok_orm::pool(), serde_json::json!({{
"name": req.name,
"email": req.email,
"password": hash,
}}))
.await
.map_err(|e| Response::internal_error(e.to_string()))?;
Response::json(serde_json::json!({{ "message": "User registered", "user": user }}))
}}
/// Authenticate and return a token / session.
pub async fn login(
Valid(Json(req)): Valid<Json<LoginRequest>>,
) -> impl IntoResponse {{
let user = User::find_by("email", &req.email)
.await
.map_err(|_| Response::unauthorized("Invalid credentials"))?
.ok_or_else(|| Response::unauthorized("Invalid credentials"))?;
if !Hasher::check(&req.password, &user.password) {{
return Err(Response::unauthorized("Invalid credentials"));
}}
{login_return}
}}
{logout_handler}
{email_verify_handler}
{password_reset_handler}
/// Get the current authenticated user.
pub async fn me(ctx: Ctx) -> impl IntoResponse {{
let user = ctx.user::<User>()?;
Response::json(serde_json::json!({{ "user": user }}))
}}
}}
"#,
auth_header = auth_header,
login_return = login_return,
logout_handler = logout_handler,
email_verify_handler = email_verify_handler,
password_reset_handler = password_reset_handler,
)
}
fn login_validator_template() -> String {
r#"use rok_validate::Validate;
use serde::Deserialize;
#[derive(Deserialize, Validate)]
pub struct LoginRequest {
#[validate(required, email)]
pub email: String,
#[validate(required, min = 8)]
pub password: String,
}
"#
.to_string()
}
fn register_validator_template() -> String {
r#"use rok_validate::Validate;
use serde::Deserialize;
#[derive(Deserialize, Validate)]
pub struct RegisterRequest {
#[validate(required, min = 2, max = 255)]
pub name: String,
#[validate(required, email)]
pub email: String,
#[validate(required, min = 8, confirmed)]
pub password: String,
#[validate(required, same = "password")]
pub password_confirmation: String,
}
"#
.to_string()
}
fn forgot_password_validator_template() -> String {
r#"use rok_validate::Validate;
use serde::Deserialize;
#[derive(Deserialize, Validate)]
pub struct ForgotPasswordRequest {
#[validate(required, email)]
pub email: String,
}
"#
.to_string()
}
fn reset_password_validator_template() -> String {
r#"use rok_validate::Validate;
use serde::Deserialize;
#[derive(Deserialize, Validate)]
pub struct ResetPasswordRequest {
#[validate(required)]
pub token: String,
#[validate(required, email)]
pub email: String,
#[validate(required, min = 8, confirmed)]
pub password: String,
#[validate(required, same = "password")]
pub password_confirmation: String,
}
"#
.to_string()
}
fn auth_factory_template(pascal: &str) -> String {
format!(
r#"use rok_orm::factory::Factory;
use rok_core::crypto::hasher::Hasher;
pub struct {pascal}Factory;
impl Factory for {pascal}Factory {{
type Model = super::{pascal};
fn definition() -> Vec<(&'static str, rok_orm::SqlValue)> {{
vec![
("name", "Test User".into()),
("email", "test@example.com".into()),
("password", Hasher::make("password").into()),
]
}}
}}
"#
)
}
fn auth_seeder_template(pascal: &str) -> String {
format!(
r#"use sqlx::PgPool;
use {pascal}Factory;
pub struct {pascal}Seeder;
impl {pascal}Seeder {{
pub async fn run(pool: &PgPool) -> Result<(), sqlx::Error> {{
{pascal}Factory::create(pool, None).await?;
println!("Created default {pascal}");
Ok(())
}}
}}
"#
)
}
fn write_file(path: &str, content: &str) -> anyhow::Result<()> {
write_file_guarded(path, content, false)
}
fn write_file_guarded(path: &str, content: &str, force: bool) -> anyhow::Result<()> {
let p = Path::new(path);
if !force && p.exists() {
anyhow::bail!("{path} already exists. Pass --force to overwrite.");
}
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
fs::write(p, content)?;
Ok(())
}