rok-cli 0.3.2

Developer CLI for rok-based Axum applications
use super::{write_file, Scaffold, ScaffoldArgs, ScaffoldResult};
use anyhow::Result;

pub struct MultiTenantScaffold;

impl Scaffold for MultiTenantScaffold {
    fn name(&self) -> &'static str {
        "multi-tenant"
    }
    fn description(&self) -> &'static str {
        "Multi-tenancy: tenant context middleware, scoped routes, isolation strategy"
    }

    fn generate(&self, args: &ScaffoldArgs) -> Result<ScaffoldResult> {
        let mut r = ScaffoldResult::default();
        let d = args.dry_run;
        write_file(
            &mut r,
            "src/app/middleware/tenant_context.rs",
            MIDDLEWARE,
            d,
        )?;
        write_file(&mut r, "src/app/extractors/tenant.rs", EXTRACTOR, d)?;
        write_file(&mut r, "migrations/create_tenants_table.sql", MIGRATION, d)?;
        r.warnings
            .push("Add TenantContext layer to your Router".into());
        r.warnings
            .push("Choose isolation strategy: shared schema or schema-per-tenant".into());
        Ok(r)
    }
}

const MIDDLEWARE: &str = r#"use axum::{extract::Request, middleware::Next, response::Response};
use rok_problem::Problem;

pub async fn tenant_context(req: Request, next: Next) -> Result<Response, Problem> {
    // TODO: extract tenant from subdomain (req.uri().host()) or X-Tenant-ID header
    // Store in request extensions for downstream extractors
    Ok(next.run(req).await)
}
"#;

const EXTRACTOR: &str = r#"use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use rok_problem::Problem;

pub struct TenantId(pub i64);

#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for TenantId {
    type Rejection = Problem;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Problem> {
        parts.extensions.get::<TenantId>()
            .cloned()
            .ok_or_else(|| Problem::unauthorized("Tenant context not found"))
    }
}
"#;

const MIGRATION: &str = r#"CREATE TABLE tenants (
    id           BIGSERIAL PRIMARY KEY,
    name         TEXT NOT NULL,
    slug         TEXT NOT NULL UNIQUE,
    plan         TEXT NOT NULL DEFAULT 'free',
    suspended_at TIMESTAMPTZ,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE tenant_users (
    tenant_id  BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id    BIGINT NOT NULL,
    role       TEXT NOT NULL DEFAULT 'member',
    PRIMARY KEY (tenant_id, user_id)
);
"#;