Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 132 additions & 12 deletions rapina-cli/src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ use std::fs;
use std::path::Path;

use super::templates;
use super::templates::DatabaseType;

/// Execute the `new` command to create a new Rapina project.
///
/// `template` is `None` for the default starter and `Some("crud")` / `Some("auth")`
/// for the optional starter templates.
pub fn execute(name: &str, template: Option<&str>, no_ai: bool) -> Result<(), String> {
/// `db_type` specifies the database to configure for the project.
pub fn execute(
name: &str,
template: Option<&str>,
db_type: Option<&DatabaseType>,
no_ai: bool,
) -> Result<(), String> {
validate_project_name(name)?;

let project_path = Path::new(name);
Expand All @@ -24,25 +31,33 @@ pub fn execute(name: &str, template: Option<&str>, no_ai: bool) -> Result<(), St
"Creating new Rapina project:".bright_cyan(),
name.bold()
);
if let Some(ref db) = db_type {
println!(
" {} Database: {}",
"📦".bright_cyan(),
db.to_string().bold()
);
}
println!();

let src_path = project_path.join("src");
fs::create_dir_all(&src_path).map_err(|e| format!("Failed to create directory: {}", e))?;

match template {
None | Some("rest-api") => templates::rest_api::generate(name, project_path, &src_path)?,
Some("crud") => templates::crud::generate(name, project_path, &src_path)?,
Some("auth") => templates::auth::generate(name, project_path, &src_path)?,
Some(other) => {
return Err(format!(
"Unknown template '{}'. Available: rest-api, crud, auth",
other
));
None | Some("rest-api") => {
templates::rest_api::generate(name, project_path, &src_path, db_type)?
}
Some("crud") => {
// Clap validation ensures --db is present for crud template
// Safe to unwrap because it has been validated in clap
templates::crud::generate(name, project_path, &src_path, db_type.unwrap())?
}
Some("auth") => templates::auth::generate(name, project_path, &src_path, db_type)?,
_ => unreachable!(),
}

// Create README.md
let readme = generate_readme(name);
let readme = generate_readme(name, db_type);
fs::write(project_path.join("README.md"), readme)
.map_err(|e| format!("Failed to write README.md: {}", e))?;
println!(" {} Created {}", "✓".green(), "README.md".cyan());
Expand Down Expand Up @@ -72,6 +87,10 @@ pub fn execute(name: &str, template: Option<&str>, no_ai: bool) -> Result<(), St
println!();
println!(" {}:", "Next steps".bright_yellow());
println!(" cd {}", name.cyan());
if db_type.is_some() {
println!(" # Configure your database URL in .env or source");
println!(" export DATABASE_URL=\"your-database-url\"");
}
println!(" rapina dev");
println!();

Expand All @@ -80,9 +99,76 @@ pub fn execute(name: &str, template: Option<&str>, no_ai: bool) -> Result<(), St

// ── README ───────────────────────────────────────────────────────────────────

fn generate_readme(name: &str) -> String {
fn generate_readme(name: &str, db_type: Option<&DatabaseType>) -> String {
let db_section = if let Some(db) = db_type {
match db {
DatabaseType::Sqlite => {
r#"
## Database

This project uses **SQLite** for data persistence. The database file is created automatically at `app.db`.

To configure a different SQLite database or adjust connection pool settings, edit `src/main.rs`:

```rust
.with_database(DatabaseConfig::new("sqlite://app.db?mode=rwc"))
```

Run migrations:
```bash
rapina migrate new create_your_table
```
"#
}
DatabaseType::Postgres => {
r#"
## Database

This project is configured for **PostgreSQL**. Set the `DATABASE_URL` environment variable before running:

```bash
export DATABASE_URL="postgres://user:password@localhost:5432/dbname"
```

Or create a `.env` file:
```env
DATABASE_URL=postgres://user:password@localhost:5432/dbname
```

Run migrations:
```bash
rapina migrate new create_your_table
```
"#
}
DatabaseType::Mysql => {
r#"
## Database

This project is configured for **MySQL**. Set the `DATABASE_URL` environment variable before running:

```bash
export DATABASE_URL="mysql://user:password@localhost:3306/dbname"
```

Or create a `.env` file:
```env
DATABASE_URL=mysql://user:password@localhost:3306/dbname
```

Run migrations:
```bash
rapina migrate new create_your_table
```
"#
}
}
} else {
""
};

format!(
"# {name}\n\nA web application built with Rapina.\n\n## Getting started\n\n```bash\nrapina dev\n```\n\n## Routes\n\n- `GET /` — Hello world\n- `GET /__rapina/health` — Health check (built-in)\n"
"# {name}\n\nA web application built with Rapina.\n\n## Getting started\n\n```bash\nrapina dev\n```\n\n## Routes\n\n- `GET /` — Hello world\n- `GET /__rapina/health` — Health check (built-in)\n{db_section}"
)
}

Expand Down Expand Up @@ -471,4 +557,38 @@ mod tests {
assert!(content.contains("DocumentedError"));
assert!(content.contains("rapina add resource"));
}

#[test]
fn test_generate_readme_without_db() {
let content = generate_readme("test-app", None);
assert!(content.contains("# test-app"));
assert!(content.contains("rapina dev"));
assert!(!content.contains("## Database"));
}

#[test]
fn test_generate_readme_with_sqlite() {
let content = generate_readme("test-app", Some(&DatabaseType::Sqlite));
assert!(content.contains("## Database"));
assert!(content.contains("**SQLite**"));
assert!(content.contains("app.db"));
}

#[test]
fn test_generate_readme_with_postgres() {
let content = generate_readme("test-app", Some(&DatabaseType::Postgres));
assert!(content.contains("## Database"));
assert!(content.contains("**PostgreSQL**"));
assert!(content.contains("DATABASE_URL"));
assert!(content.contains("postgres://"));
}

#[test]
fn test_generate_readme_with_mysql() {
let content = generate_readme("test-app", Some(&DatabaseType::Mysql));
assert!(content.contains("## Database"));
assert!(content.contains("**MySQL**"));
assert!(content.contains("DATABASE_URL"));
assert!(content.contains("mysql://"));
}
}
103 changes: 71 additions & 32 deletions rapina-cli/src/commands/templates/auth.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
use std::path::Path;

use super::{generate_cargo_toml, generate_gitignore, write_file};

pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(), String> {
use super::{
DatabaseType, generate_cargo_toml, generate_database_config, generate_db_import,
generate_env_content, generate_gitignore, generate_gitignore_extras, generate_rapina_dep,
generate_with_database_line, write_file,
};

pub fn generate(
name: &str,
project_path: &Path,
src_path: &Path,
db_type: Option<&DatabaseType>,
) -> Result<(), String> {
let version = env!("CARGO_PKG_VERSION");
let rapina_dep = format!("\"{}\"", version);
let rapina_dep = generate_rapina_dep(version, db_type);

write_file(
&project_path.join("Cargo.toml"),
Expand All @@ -13,7 +22,7 @@ pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(),
)?;
write_file(
&src_path.join("main.rs"),
&generate_main_rs(),
&generate_main_rs(db_type),
"src/main.rs",
)?;
write_file(
Expand All @@ -23,31 +32,41 @@ pub fn generate(name: &str, project_path: &Path, src_path: &Path) -> Result<(),
)?;
write_file(
&project_path.join(".gitignore"),
&generate_gitignore(&[".env"]),
&generate_gitignore(&generate_gitignore_extras(db_type)),
".gitignore",
)?;
write_file(
&project_path.join(".env.example"),
&generate_env_example(),
".env.example",
&project_path.join(".env"),
&generate_auth_env_content(db_type),
".env",
)?;

Ok(())
}

fn generate_main_rs() -> String {
r#"mod auth;
fn generate_auth_env_content(db_type: Option<&DatabaseType>) -> String {
const AUTH_VARS: &str = "# Authentication Configuration\n# Replace JWT_SECRET with a long, random string (at least 32 characters).\nJWT_SECRET=change-me-to-a-long-random-secret-change-me\nJWT_EXPIRATION=3600";
generate_env_content(db_type, Some(AUTH_VARS))
}

fn generate_main_rs(db_type: Option<&DatabaseType>) -> String {
let db_import = generate_db_import(db_type);
let db_setup = generate_database_config(db_type);
let with_database_line = generate_with_database_line(db_type);

format!(
r#"mod auth;

use rapina::prelude::*;
use rapina::middleware::RequestLogMiddleware;

{}
#[get("/me")]
async fn me(user: CurrentUser) -> Json<serde_json::Value> {
Json(serde_json::json!({ "id": user.id }))
}
async fn me(user: CurrentUser) -> Json<serde_json::Value> {{
Json(serde_json::json!({{ "id": user.id }}))
}}

#[tokio::main]
async fn main() -> std::io::Result<()> {
async fn main() -> std::io::Result<()> {{
load_dotenv();

let auth_config = AuthConfig::from_env().expect("JWT_SECRET is required");
Expand All @@ -57,6 +76,7 @@ async fn main() -> std::io::Result<()> {
.post("/auth/login", auth::login)
.get("/me", me);

{}
Rapina::new()
.with_tracing(TracingConfig::new())
.middleware(RequestLogMiddleware::new())
Expand All @@ -65,12 +85,13 @@ async fn main() -> std::io::Result<()> {
.public_route("POST", "/auth/register")
.public_route("POST", "/auth/login")
.state(auth_config)
.router(router)
{} .router(router)
.listen("127.0.0.1:3000")
.await
}
"#
.to_string()
}}
"#,
db_import, db_setup, with_database_line
)
}

fn generate_auth_rs() -> String {
Expand Down Expand Up @@ -99,8 +120,8 @@ pub async fn register(body: Json<RegisterRequest>) -> Result<Json<TokenResponse>
#[public]
#[post("/auth/login")]
pub async fn login(
body: Json<LoginRequest>,
auth: State<AuthConfig>,
body: Json<LoginRequest>,
) -> Result<Json<TokenResponse>> {
// TODO: validate credentials against database
if body.username == "admin" && body.password == "password" {
Expand All @@ -114,20 +135,13 @@ pub async fn login(
.to_string()
}

fn generate_env_example() -> String {
r#"JWT_SECRET=change-me-to-a-long-random-secret
JWT_EXPIRATION=3600
"#
.to_string()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_generate_main_rs_has_auth_routes() {
let content = generate_main_rs();
let content = generate_main_rs(None);
assert!(content.contains(".post(\"/auth/register\", auth::register)"));
assert!(content.contains(".post(\"/auth/login\", auth::login)"));
assert!(content.contains(".get(\"/me\", me)"));
Expand All @@ -137,7 +151,7 @@ mod tests {

#[test]
fn test_generate_main_rs_marks_public_routes() {
let content = generate_main_rs();
let content = generate_main_rs(None);
assert!(content.contains("with_health_check(true)"));
assert!(content.contains("public_route(\"POST\", \"/auth/register\")"));
assert!(content.contains("public_route(\"POST\", \"/auth/login\")"));
Expand All @@ -155,10 +169,19 @@ mod tests {
}

#[test]
fn test_generate_env_example() {
let content = generate_env_example();
fn test_generate_env_file() {
let content = generate_auth_env_content(None);
assert!(content.contains("JWT_SECRET="));
assert!(content.contains("JWT_EXPIRATION="));
assert!(content.contains("Replace"));
}

#[test]
fn test_generate_env_file_with_database() {
let content = generate_auth_env_content(Some(&DatabaseType::Sqlite));
assert!(content.contains("JWT_SECRET="));
assert!(content.contains("DATABASE_URL="));
assert!(content.contains("Replace"));
}

#[test]
Expand All @@ -168,4 +191,20 @@ mod tests {
assert!(content.contains("Cargo.lock"));
assert!(content.contains(".env"));
}

#[test]
fn test_generate_main_rs_without_database() {
let content = generate_main_rs(None);
assert!(!content.contains("with_database"));
assert!(!content.contains("db_config"));
assert!(!content.contains("DatabaseConfig"));
}

#[test]
fn test_generate_main_rs_with_database() {
let content = generate_main_rs(Some(&DatabaseType::Sqlite));
assert!(content.contains("with_database(db_config)"));
assert!(content.contains("let db_config"));
assert!(content.contains("use rapina::database::DatabaseConfig"));
}
}
Loading
Loading