use std::{fs, path::Path};
pub fn generate(name: &str, root: &Path) -> anyhow::Result<()> {
for dir in &[
"src/app/controllers",
"src/app/models",
"src/app/validators",
"src/app/middleware",
"src/app/events",
"src/app/services",
"src/app/policies",
"src/config",
"src/routes",
"database/migrations",
"tests/common",
] {
fs::create_dir_all(root.join(dir))?;
}
let files: Vec<(&str, String)> = vec![
("Cargo.toml", cargo_toml(name)),
(".env.example", ENV_EXAMPLE.into()),
(".gitignore", GITIGNORE.into()),
("Dockerfile", dockerfile(name)),
(".dockerignore", DOCKERIGNORE.into()),
("docker-compose.yml", DOCKER_COMPOSE.into()),
("src/main.rs", MAIN_RS.into()),
("src/lib.rs", LIB_RS.into()),
("src/state.rs", STATE_RS.into()),
("src/routes/mod.rs", ROUTES_MOD_RS.into()),
("src/routes/auth.rs", ROUTES_AUTH_RS.into()),
("src/routes/api.rs", ROUTES_API_RS.into()),
("src/config/mod.rs", CONFIG_MOD_RS.into()),
("src/config/app.rs", CONFIG_APP_RS.into()),
("src/config/auth.rs", CONFIG_AUTH_RS.into()),
("src/config/database.rs", CONFIG_DB_RS.into()),
("src/app/mod.rs", APP_MOD_RS.into()),
("src/app/middleware/mod.rs", MIDDLEWARE_MOD_RS.into()),
("src/app/middleware/auth.rs", MIDDLEWARE_AUTH_RS.into()),
("src/app/middleware/logger.rs", MIDDLEWARE_LOGGER_RS.into()),
("src/app/middleware/cors.rs", MIDDLEWARE_CORS_RS.into()),
("src/app/controllers/mod.rs", CONTROLLERS_MOD_RS.into()),
(
"src/app/controllers/auth_controller.rs",
AUTH_CONTROLLER_RS.into(),
),
(
"src/app/controllers/user_controller.rs",
USER_CONTROLLER_RS.into(),
),
("src/app/models/mod.rs", MODELS_MOD_RS.into()),
("src/app/models/user.rs", USER_MODEL_RS.into()),
("src/app/validators/mod.rs", VALIDATORS_MOD_RS.into()),
(
"src/app/validators/auth_requests.rs",
AUTH_REQUESTS_RS.into(),
),
(
"src/app/validators/admin_requests.rs",
ADMIN_REQUESTS_RS.into(),
),
("src/app/policies/mod.rs", POLICIES_MOD_RS.into()),
("src/app/policies/user_policy.rs", USER_POLICY_RS.into()),
("src/app/events/mod.rs", EVENTS_MOD_RS.into()),
("src/app/events/auth_events.rs", AUTH_EVENTS_RS.into()),
("src/app/services/mod.rs", SERVICES_MOD_RS.into()),
("src/app/services/auth_service.rs", AUTH_SERVICE_RS.into()),
("database/migrations/001_users.sql", MIGRATION_USERS.into()),
(
"database/migrations/002_tokens.sql",
MIGRATION_TOKENS.into(),
),
(
"database/migrations/003_password_resets.sql",
MIGRATION_PASSWORD_RESETS.into(),
),
("tests/common/mod.rs", TESTS_COMMON_RS.into()),
("tests/auth.rs", TESTS_AUTH_RS.into()),
];
for (rel, content) in &files {
fs::write(root.join(rel), content)?;
println!(" create {rel}");
}
Ok(())
}
fn cargo_toml(name: &str) -> String {
let pkg_name = std::path::Path::new(name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(name);
format!(
r#"[package]
name = "{pkg_name}"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "{pkg_name}"
path = "src/main.rs"
[dependencies]
axum = "0.8"
tokio = {{ version = "1", features = ["full"] }}
tower-http = {{ version = "0.6", features = ["trace", "cors"] }}
tower = "0.5"
serde = {{ version = "1", features = ["derive"] }}
serde_json = "1"
sqlx = {{ version = "0.8", default-features = false, features = [
"macros", "runtime-tokio-native-tls", "chrono", "postgres",
] }}
chrono = {{ version = "0.4", features = ["serde"] }}
uuid = {{ version = "1", features = ["v4"] }}
dotenvy = "0.15"
anyhow = "1"
thiserror = "2"
rok-auth = {{ version = "0.1", features = ["axum", "magic-link"] }}
rok-encrypt = {{ version = "0.1" }}
rok-orm = {{ version = "0.1", features = ["postgres", "axum"] }}
rok-validate = {{ version = "0.1", features = ["axum"] }}
rok-config = {{ version = "0.1" }}
[dev-dependencies]
rok-testing = {{ version = "0.1" }}
tokio = {{ version = "1" }}
"#
)
}
const ENV_EXAMPLE: &str = r#"APP_NAME=rok-api
LISTEN_ADDR=0.0.0.0:3000
DATABASE_URL=postgres://postgres:postgres@localhost/rok_api_dev
JWT_SECRET=change-me-in-production
"#;
const GITIGNORE: &str = "/target\n.env\n.env.local\n";
fn dockerfile(name: &str) -> String {
let bin_name = std::path::Path::new(name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(name);
format!(
r#"FROM rust:1.85-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml ./
RUN mkdir src && echo 'fn main() {{}}' > src/main.rs
RUN cargo build --release 2>/dev/null || true
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
RUN useradd -m -u 1001 appuser
WORKDIR /app
COPY --from=builder /app/target/release/{bin_name} ./
COPY --from=builder /app/database/migrations ./migrations
RUN chown -R appuser:appuser /app
USER appuser
ENV LISTEN_ADDR=0.0.0.0:3000
EXPOSE 3000
ENTRYPOINT ["./{bin_name}"]
"#
)
}
const DOCKERIGNORE: &str = "target/\n.git/\n*.md\n.gitignore\n.dockerignore\ndocker-compose*.yml\n";
const DOCKER_COMPOSE: &str = r#"name: rok-api
services:
app:
build: .
container_name: rok-api
restart: unless-stopped
env_file: .env
ports:
- "3000:3000"
environment:
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/rok_api_dev"
networks:
- infra
networks:
infra:
external: true
"#;
const MAIN_RS: &str = r#"use axum::{middleware, Router};
use sqlx::postgres::PgPoolOptions;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
mod app;
mod config;
mod routes;
mod state;
use config::{AppConfig, AuthConfig, DatabaseConfig};
use routes::{api_router, auth_router};
use state::AppState;
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let app_cfg = AppConfig::load();
let auth_cfg = AuthConfig::load();
let db_cfg = DatabaseConfig::load();
let pool = PgPoolOptions::new()
.max_connections(db_cfg.max_connections)
.connect(&db_cfg.url)
.await
.expect("failed to connect to database");
let state = AppState::new(pool, auth_cfg.jwt_secret);
let app = Router::new()
.merge(auth_router())
.merge(api_router())
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&app_cfg.listen_addr)
.await
.expect("failed to bind");
println!("{} listening on {}", app_cfg.name, app_cfg.listen_addr);
axum::serve(listener, app).await.expect("server error");
}
"#;
const LIB_RS: &str = r#"pub mod app;
pub mod config;
pub mod routes;
pub mod state;
"#;
const STATE_RS: &str = r#"use std::sync::Arc;
use rok_auth::{Auth, AuthConfig};
use rok_auth::axum::{HasAuth, HasPool};
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub auth: Auth,
}
impl AppState {
pub fn new(pool: PgPool, jwt_secret: String) -> Self {
let auth = Auth::new(AuthConfig {
secret: jwt_secret,
..Default::default()
});
Self { pool, auth }
}
}
impl HasPool for AppState {
fn pool(&self) -> &PgPool {
&self.pool
}
}
impl HasAuth for AppState {
fn auth_handle(&self) -> Arc<Auth> {
Arc::new(self.auth.clone())
}
}
"#;
const ROUTES_MOD_RS: &str = r#"pub mod api;
pub mod auth;
pub use api::api_router;
pub use auth::auth_router;
"#;
const ROUTES_AUTH_RS: &str = r#"use axum::{
routing::{get, post},
Router,
};
use crate::app::controllers::auth_controller::AuthController;
use crate::state::AppState;
pub fn auth_router() -> Router<AppState> {
Router::new()
.route("/auth/register", post(AuthController::register))
.route("/auth/login", post(AuthController::login))
.route("/auth/logout", post(AuthController::logout))
.route("/auth/me", get(AuthController::me))
.route("/auth/forgot-password", post(AuthController::forgot_password))
.route("/auth/reset-password", post(AuthController::reset_password))
.route("/auth/magic-link", post(AuthController::magic_link))
.route(
"/auth/magic-link/callback",
get(AuthController::magic_link_callback),
)
}
"#;
const ROUTES_API_RS: &str = r#"use axum::{
routing::{delete, get, post, put},
Router,
};
use crate::app::controllers::user_controller::UserController;
use crate::state::AppState;
pub fn api_router() -> Router<AppState> {
Router::new()
.route("/api/v1/users", get(UserController::index))
.route("/api/v1/users", post(UserController::store))
.route("/api/v1/users/:id", get(UserController::show))
.route("/api/v1/users/:id", put(UserController::update))
.route("/api/v1/users/:id", delete(UserController::destroy))
}
"#;
const CONFIG_MOD_RS: &str = r#"pub mod app;
pub mod auth;
pub mod database;
pub use app::AppConfig;
pub use auth::AuthConfig;
pub use database::DatabaseConfig;
"#;
const CONFIG_APP_RS: &str = r#"use rok_config::Config;
#[derive(Config, Debug)]
pub struct AppConfig {
#[env("APP_NAME", default = "rok-api")]
pub name: String,
#[env("LISTEN_ADDR", default = "0.0.0.0:3000")]
pub listen_addr: String,
}
"#;
const CONFIG_AUTH_RS: &str = r#"use rok_config::Config;
#[derive(Config, Debug)]
pub struct AuthConfig {
#[env("JWT_SECRET")]
pub jwt_secret: String,
}
"#;
const CONFIG_DB_RS: &str = r#"use rok_config::Config;
#[derive(Config, Debug)]
pub struct DatabaseConfig {
#[env("DATABASE_URL")]
pub url: String,
#[env("DB_MAX_CONNECTIONS", default = 10)]
pub max_connections: u32,
}
"#;
const APP_MOD_RS: &str = r#"pub mod controllers;
pub mod events;
pub mod middleware;
pub mod models;
pub mod policies;
pub mod services;
pub mod validators;
"#;
const MIDDLEWARE_MOD_RS: &str = "pub mod auth;\npub mod cors;\npub mod logger;\n";
const MIDDLEWARE_AUTH_RS: &str = r#"use rok_auth::Auth;
use rok_auth::axum::AuthLayer;
pub fn layer(auth: Auth) -> AuthLayer {
AuthLayer::new(auth)
}
"#;
const MIDDLEWARE_LOGGER_RS: &str = r#"use tower_http::trace::TraceLayer;
pub fn layer(
) -> TraceLayer<tower_http::classify::SharedClassifier<tower_http::classify::ServerErrorsAsFailures>>
{
TraceLayer::new_for_http()
}
"#;
const MIDDLEWARE_CORS_RS: &str = r#"use tower_http::cors::CorsLayer;
pub fn layer() -> CorsLayer {
CorsLayer::permissive()
}
"#;
const CONTROLLERS_MOD_RS: &str = "pub mod auth_controller;\npub mod user_controller;\n";
const AUTH_CONTROLLER_RS: &str = r#"use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use rok_auth::axum::Ctx;
use rok_auth::{password, Claims, MagicLink, MagicLinkConfig, PasswordReset};
use rok_encrypt::Encrypter;
use rok_orm::{Model, PgModel};
use rok_validate::Valid;
use serde_json::json;
use std::collections::HashMap;
use crate::app::models::User;
use crate::app::validators::auth_requests::*;
use crate::state::AppState;
pub struct AuthController;
impl AuthController {
pub async fn register(
State(state): State<AppState>,
Valid(body): Valid<RegisterRequest>,
) -> impl IntoResponse {
let exists = User::filter("email", body.email.as_str())
.first()
.await;
match exists {
Ok(Some(_)) => {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "Email already registered" })),
);
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
Ok(None) => {}
}
let hash = match password::hash(&body.password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
let user = match User::create_returning(
&state.pool,
&[("email", body.email.as_str().into()), ("password_hash", hash.into())],
)
.await
{
Ok(u) => u,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
let claims = Claims::new(user.id.to_string(), vec!["user"]);
let token = match state.auth.sign(&claims) {
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
(
StatusCode::CREATED,
Json(json!({
"token": token,
"user": { "id": user.id, "email": user.email }
})),
)
}
pub async fn login(
State(state): State<AppState>,
Valid(body): Valid<LoginRequest>,
) -> impl IntoResponse {
let user = match User::filter("email", body.email.as_str())
.first()
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Invalid credentials" })),
);
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
match password::verify(&body.password, &user.password_hash) {
Ok(true) => {}
Ok(false) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Invalid credentials" })),
);
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
}
let claims = Claims::new(user.id.to_string(), vec!["user"]);
let token = match state.auth.sign(&claims) {
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
(
StatusCode::OK,
Json(json!({
"token": token,
"user": { "id": user.id, "email": user.email }
})),
)
}
pub async fn me(ctx: Ctx) -> impl IntoResponse {
let claims = match ctx.require_auth() {
Ok(c) => c,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
);
}
};
let id: i64 = match claims.sub.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "Invalid token subject" })),
);
}
};
let user = match User::find_by_pk(ctx.db(), id).await {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": "User not found" })),
);
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
(
StatusCode::OK,
Json(json!({
"user": {
"id": user.id,
"email": user.email,
"created_at": user.created_at,
}
})),
)
}
pub async fn logout(_ctx: Ctx) -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "message": "Logged out" })))
}
pub async fn forgot_password(
State(state): State<AppState>,
Valid(body): Valid<ForgotPasswordRequest>,
) -> impl IntoResponse {
let user = User::filter("email", body.email.as_str())
.first()
.await;
if let Ok(Some(_)) = user {
let _ = PasswordReset::issue(&state.pool, &body.email).await;
}
(
StatusCode::OK,
Json(json!({
"message": "If the email exists, a reset link has been sent"
})),
)
}
pub async fn reset_password(
State(state): State<AppState>,
Valid(body): Valid<ResetPasswordRequest>,
) -> impl IntoResponse {
let email = match PasswordReset::verify(&state.pool, &body.token).await {
Ok(Some(email)) => email,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "Invalid or expired reset token" })),
);
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
let hash = match password::hash(&body.password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
let result = User::update_where(
&state.pool,
User::query().where_eq("email", email.as_str()),
&[("password_hash", hash.into())],
)
.await;
match result {
Ok(_) => (
StatusCode::OK,
Json(json!({ "message": "Password updated" })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
pub async fn magic_link(
State(state): State<AppState>,
Valid(body): Valid<MagicLinkRequest>,
) -> impl IntoResponse {
let encrypter = Encrypter::from_config(
rok_encrypt::EncryptConfig::new(&state.auth.config().secret),
);
let config = MagicLinkConfig::default();
let token = MagicLink::issue(&encrypter, &body.email, &config);
(StatusCode::OK, Json(json!({ "token": token })))
}
pub async fn magic_link_callback(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let token = match params.get("token") {
Some(t) => t,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "Missing token query param" })),
);
}
};
let encrypter = Encrypter::from_config(
rok_encrypt::EncryptConfig::new(&state.auth.config().secret),
);
let email = match MagicLink::verify(&state.pool, &encrypter, token).await {
Ok(e) => e,
Err(e) => {
let msg = match &e {
rok_auth::AuthError::TokenExpired => "Magic link expired",
rok_auth::AuthError::InvalidToken => "Invalid magic link",
_ => "Verification failed",
};
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": msg })),
);
}
};
let user = match User::filter("email", email.as_str()).first().await {
Ok(Some(u)) => u,
Ok(None) => {
let hash = match password::hash(&uuid::Uuid::new_v4().to_string()) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
match User::create_returning(
&state.pool,
&[
("email", email.as_str().into()),
("password_hash", hash.into()),
],
)
.await
{
Ok(u) => u,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
}
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
let claims = Claims::new(user.id.to_string(), vec!["user"]);
let access_token = match state.auth.sign(&claims) {
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
(
StatusCode::OK,
Json(json!({
"token": access_token,
"user": { "id": user.id, "email": user.email }
})),
)
}
}
"#;
const USER_CONTROLLER_RS: &str = r#"use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use rok_auth::axum::Ctx;
use rok_orm::PgModel;
use rok_validate::Valid;
use serde_json::json;
use crate::app::models::User;
use crate::app::validators::admin_requests::*;
use crate::state::AppState;
pub struct UserController;
impl UserController {
pub async fn index(ctx: Ctx, State(state): State<AppState>) -> impl IntoResponse {
if let Err(_) = ctx.require_auth() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
);
}
match User::all(&state.pool).await {
Ok(users) => {
let data: Vec<_> = users
.into_iter()
.map(|u| {
json!({
"id": u.id,
"email": u.email,
"created_at": u.created_at,
})
})
.collect();
(StatusCode::OK, Json(json!({ "data": data })))
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
pub async fn show(
ctx: Ctx,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> impl IntoResponse {
if let Err(_) = ctx.require_auth() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
);
}
match User::find_by_pk(&state.pool, id).await {
Ok(Some(user)) => (
StatusCode::OK,
Json(json!({
"user": {
"id": user.id,
"email": user.email,
"created_at": user.created_at,
}
})),
),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "User not found" })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
pub async fn store(
State(state): State<AppState>,
Valid(body): Valid<CreateUserRequest>,
) -> impl IntoResponse {
let hash = match rok_auth::password::hash(&body.password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
);
}
};
match User::create_returning(
&state.pool,
&[
("email", body.email.as_str().into()),
("password_hash", hash.into()),
],
)
.await
{
Ok(user) => (
StatusCode::CREATED,
Json(json!({
"user": { "id": user.id, "email": user.email }
})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
Valid(body): Valid<UpdateUserRequest>,
) -> impl IntoResponse {
let mut data: Vec<(&str, rok_orm::SqlValue)> = Vec::new();
if let Some(email) = &body.email {
data.push(("email", email.as_str().into()));
}
match User::update_by_pk(&state.pool, id, &data).await {
Ok(count) if count > 0 => (
StatusCode::OK,
Json(json!({ "message": "User updated" })),
),
Ok(_) => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "User not found" })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
pub async fn destroy(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> impl IntoResponse {
match User::delete_by_pk(&state.pool, id).await {
Ok(count) if count > 0 => (
StatusCode::OK,
Json(json!({ "message": "User deleted" })),
),
Ok(_) => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "User not found" })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
}
"#;
const MODELS_MOD_RS: &str = "pub mod user;\npub use user::User;\n";
const USER_MODEL_RS: &str = r#"use rok_orm::Model;
use serde::Serialize;
#[derive(Debug, Clone, Model, Serialize, sqlx::FromRow)]
pub struct User {
pub id: i64,
pub email: String,
#[serde(skip)]
pub password_hash: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
"#;
const VALIDATORS_MOD_RS: &str =
"pub mod admin_requests;\npub mod auth_requests;\n";
const AUTH_REQUESTS_RS: &str = r#"use rok_validate::Validate;
use serde::Deserialize;
#[derive(Deserialize, Validate)]
pub struct RegisterRequest {
#[validate(required, email)]
pub email: String,
#[validate(required, min = 8, max = 128)]
pub password: String,
#[validate(required, same = "password")]
pub password_confirmation: String,
}
#[derive(Deserialize, Validate)]
pub struct LoginRequest {
#[validate(required, email)]
pub email: String,
#[validate(required, min = 8)]
pub password: String,
}
#[derive(Deserialize, Validate)]
pub struct ForgotPasswordRequest {
#[validate(required, email)]
pub email: String,
}
#[derive(Deserialize, Validate)]
pub struct ResetPasswordRequest {
#[validate(required)]
pub token: String,
#[validate(required, min = 8, max = 128)]
pub password: String,
#[validate(required, same = "password")]
pub password_confirmation: String,
}
#[derive(Deserialize, Validate)]
pub struct MagicLinkRequest {
#[validate(required, email)]
pub email: String,
}
"#;
const ADMIN_REQUESTS_RS: &str = r#"use rok_validate::Validate;
use serde::Deserialize;
#[derive(Deserialize, Validate)]
pub struct CreateUserRequest {
#[validate(required, email)]
pub email: String,
#[validate(required, min = 8, max = 128)]
pub password: String,
}
#[derive(Deserialize, Validate)]
pub struct UpdateUserRequest {
#[validate(email)]
pub email: Option<String>,
}
"#;
const POLICIES_MOD_RS: &str = "pub mod user_policy;\n";
const USER_POLICY_RS: &str = r#"use rok_auth::Claims;
pub struct UserPolicy;
impl UserPolicy {
pub fn can_view_all(claims: &Claims) -> bool {
claims.has_role("admin")
}
pub fn can_view(claims: &Claims, target_user_id: i64) -> bool {
claims.has_role("admin") || claims.sub == target_user_id.to_string()
}
pub fn can_update(claims: &Claims, target_user_id: i64) -> bool {
claims.has_role("admin") || claims.sub == target_user_id.to_string()
}
pub fn can_delete(claims: &Claims) -> bool {
claims.has_role("admin")
}
}
"#;
const EVENTS_MOD_RS: &str = "pub mod auth_events;\n";
const AUTH_EVENTS_RS: &str = r#"use chrono::{DateTime, Utc};
pub struct UserRegistered {
pub user_id: i64,
pub email: String,
pub at: DateTime<Utc>,
}
pub struct UserLoggedIn {
pub user_id: i64,
pub at: DateTime<Utc>,
}
"#;
const SERVICES_MOD_RS: &str = "pub mod auth_service;\n";
const AUTH_SERVICE_RS: &str = r#"use rok_auth::{password, Auth, AuthError, Claims, TokenPair};
use rok_orm::PgModel;
use sqlx::PgPool;
use crate::app::models::User;
pub struct AuthService;
impl AuthService {
pub async fn attempt(
auth: &Auth,
pool: &PgPool,
email: &str,
password: &str,
) -> Result<TokenPair, AuthError> {
let user = User::filter("email", email)
.first()
.await
.map_err(|e: sqlx::Error| AuthError::Internal(e.to_string()))?
.ok_or(AuthError::InvalidCredentials)?;
let valid = password::verify(password, &user.password_hash)
.map_err(|e| AuthError::HashError(e.to_string()))?;
if !valid {
return Err(AuthError::InvalidCredentials);
}
let access_token = auth.sign(&Claims::new(user.id.to_string(), vec!["user"]))?;
let refresh_token = auth.sign_refresh(&user.id.to_string())?;
Ok(TokenPair {
access_token,
refresh_token,
})
}
}
"#;
const MIGRATION_USERS: &str = r#"CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"#;
const MIGRATION_TOKENS: &str = r#"CREATE TABLE IF NOT EXISTS personal_access_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
name TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_tokens_user ON personal_access_tokens(user_id);
"#;
const MIGRATION_PASSWORD_RESETS: &str = r#"CREATE TABLE IF NOT EXISTS password_resets (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_password_resets_email ON password_resets(email);
"#;
const TESTS_COMMON_RS: &str = r#"use axum::Router;
use rok_testing::TestClient;
pub struct TestApp {
pub client: TestClient,
}
impl TestApp {
pub async fn boot() -> Self {
let _ = dotenvy::from_filename(".env.test");
TestApp {
client: TestClient::new(Router::new()),
}
}
}
"#;
const TESTS_AUTH_RS: &str = r#"mod common;
#[tokio::test]
#[ignore = "requires running database"]
async fn register_and_login() {
let _app = common::TestApp::boot().await;
// TODO: implement auth integration test
}
"#;