rws-proxy 18.29.0

The proxy and operations layer of rust-web-server: reverse proxy, config-driven routes, load balancing, L4 and WebSocket proxies, the admin API, cluster sync, fleet pull, WAF, LLM and A2A gateways, FastCGI, traffic insights. (internal: use the rust-web-server crate).
Documentation
//! The text `rws gen` writes. Every file names the command and the rws
//! release that made it, so the code matches that release's API.

use super::names::{BaseType, Field};
use super::scan::AppKind;
use super::{Db, RouteAction};

const VERSION: &str = env!("CARGO_PKG_VERSION");

/// The SQL type of a column on `db` (`NOT NULL` unless optional).
fn sql_type(field: &Field, db: Db) -> String {
    let ty = match (field.ty.base, db) {
        (BaseType::I64, Db::Sqlite) | (BaseType::I32, Db::Sqlite) => "INTEGER",
        (BaseType::I64, _) => "BIGINT",
        (BaseType::I32, Db::Postgres) => "INTEGER",
        (BaseType::I32, Db::Mysql) => "INT",
        (BaseType::F64, Db::Sqlite) => "REAL",
        (BaseType::F64, Db::Postgres) => "DOUBLE PRECISION",
        (BaseType::F64, Db::Mysql) => "DOUBLE",
        (BaseType::Bool, Db::Sqlite) => "INTEGER",
        (BaseType::Bool, _) => "BOOLEAN",
        (BaseType::String, _) => "TEXT",
    };
    if field.ty.optional {
        ty.to_string()
    } else {
        format!("{ty} NOT NULL")
    }
}

fn id_column(db: Db) -> &'static str {
    match db {
        Db::Sqlite => "INTEGER PRIMARY KEY AUTOINCREMENT",
        Db::Postgres => "BIGSERIAL PRIMARY KEY",
        Db::Mysql => "BIGINT AUTO_INCREMENT PRIMARY KEY",
    }
}

/// `migrations/NNNN_create_<table>.sql`.
pub fn create_table_up(command: &str, table: &str, fields: &[Field], db: Db) -> String {
    let width = fields.iter().map(|f| f.name.len()).max().unwrap_or(0).max(2);
    let mut columns = vec![format!("    {:width$} {}", "id", id_column(db))];
    columns.extend(fields.iter().map(|f| format!("    {:width$} {}", f.name, sql_type(f, db))));
    format!(
        "-- The `{table}` table ({db}), generated by `{command}` (rws {VERSION}).\nCREATE TABLE {table} (\n{}\n);\n",
        columns.join(",\n"),
        db = db.label(),
    )
}

/// `migrations/NNNN_create_<table>.down.sql`.
pub fn create_table_down(table: &str) -> String {
    format!("DROP TABLE {table};\n")
}

/// `migrations/NNNN_<name>.sql` for `rws gen migration`: a statement that
/// changes nothing, so the file applies as it is, until it is replaced.
pub fn empty_up(command: &str, stem: &str) -> String {
    format!(
        "-- {stem}: generated by `{command}` (rws {VERSION}).\n-- Replace the statement below with the change. DbPool::migrate runs this\n-- file once, in a transaction, and records it in _schema_migrations\n-- (statements are split at semicolons, so keep them out of comments).\nSELECT 1;\n"
    )
}

/// `migrations/NNNN_<name>.down.sql`: undoes [`empty_up`].
pub fn empty_down(stem: &str) -> String {
    format!("-- Undo {stem}.sql here (DbPool::rollback_last runs this file).\nSELECT 1;\n")
}

/// A sample value of a field's type, as Rust (`round` 0 or 1 picks one of
/// two).
fn rust_sample(field: &Field, round: usize) -> String {
    let value = match (field.ty.base, round) {
        (BaseType::I64 | BaseType::I32, 0) => "1".to_string(),
        (BaseType::I64 | BaseType::I32, _) => "2".to_string(),
        (BaseType::F64, 0) => "1.5".to_string(),
        (BaseType::F64, _) => "2.5".to_string(),
        (BaseType::Bool, 0) => "true".to_string(),
        (BaseType::Bool, _) => "false".to_string(),
        (BaseType::String, 0) => format!("\"sample {}\".to_string()", field.name),
        (BaseType::String, _) => format!("\"changed {}\".to_string()", field.name),
    };
    if field.ty.optional {
        format!("Some({value})")
    } else {
        value
    }
}

/// The same sample as JSON, for `json!(…)`.
fn json_sample(field: &Field, round: usize) -> String {
    match (field.ty.base, round) {
        (BaseType::String, 0) => format!("\"sample {}\"", field.name),
        (BaseType::String, _) => format!("\"changed {}\"", field.name),
        _ => rust_sample(&Field { name: field.name.clone(), ty: super::names::FieldType { base: field.ty.base, optional: false } }, round),
    }
}

/// What the model module needs to know.
pub struct ModelText<'a> {
    pub command: &'a str,
    pub type_name: &'a str,
    pub table: &'a str,
    pub fields: &'a [Field],
    pub migration: &'a str,
    /// `Some(dir)` adds a round trip through SQLite in memory, migrating
    /// from `dir` (relative to the crate root).
    pub sqlite_test: Option<&'a str>,
}

/// `src/models/<name>.rs`.
pub fn model(m: &ModelText<'_>) -> String {
    let ModelText { command, type_name, table, fields, migration, .. } = m;
    let mut out = format!(
        "//! `{type_name}`: the `{table}` table ({migration}).\n//!\n//! Generated by `{command}` (rws {VERSION}).\n\nuse rust_web_server::Model;\nuse serde::{{Deserialize, Serialize}};\n\n#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Model)]\n#[table(name = \"{table}\")]\npub struct {type_name} {{\n    #[primary_key(auto_increment)]\n    pub id: i64,\n"
    );
    for f in fields.iter() {
        out.push_str(&format!("    pub {}: {},\n", f.name, f.ty));
    }
    out.push_str("}\n\n#[cfg(test)]\nmod tests {\n");
    out.push_str(&format!("    use super::{type_name};\n    use rust_web_server::model::Model;\n\n"));
    let columns: Vec<String> = std::iter::once("\"id\"".to_string()).chain(fields.iter().map(|f| format!("\"{}\"", f.name))).collect();
    out.push_str(&format!(
        "    #[test]\n    fn maps_to_the_{table}_table() {{\n        assert_eq!(\"{table}\", {type_name}::table_name());\n        assert_eq!(&[{}], {type_name}::column_names());\n        assert_eq!(\"id\", {type_name}::primary_key_name());\n        assert!({type_name}::primary_key_auto_increment());\n    }}\n",
        columns.join(", ")
    ));
    if let Some(dir) = m.sqlite_test {
        let values: Vec<String> = fields.iter().map(|f| format!("{}: {}", f.name, rust_sample(f, 0))).collect();
        out.push_str(&format!(
            "\n    /// The migrations in `{dir}/` create the table this type maps to.\n    #[tokio::test]\n    async fn round_trips_through_sqlite() {{\n        use rust_web_server::model::{{DbPool, Repository}};\n\n        let pool = DbPool::memory().await.unwrap();\n        pool.migrate(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{dir}\")).await.unwrap();\n        let repository = {type_name}::repository(&pool);\n        let saved = repository.save(&{type_name} {{ id: 0, {} }}).await.unwrap();\n        assert!(saved.id > 0);\n        assert_eq!(Some(saved.clone()), repository.find_by_id(saved.id).await.unwrap());\n        repository.delete_by_id(saved.id).await.unwrap();\n        assert_eq!(None, repository.find_by_id(saved.id).await.unwrap());\n    }}\n",
            values.join(", ")
        ));
    }
    out.push_str("}\n");
    out
}

/// A new `src/models/mod.rs`.
pub fn models_mod(module: &str, type_name: &str) -> String {
    format!(
        "//! The `#[derive(Model)]` types, one module per table. Register each with\n//! the dev MCP endpoint (`DevTools::model::<T>()`) so its `models` tool\n//! lists it.\n\npub mod {module};\n\npub use {module}::{type_name};\n"
    )
}

/// A new `src/routes/mod.rs`.
pub fn routes_mod(module: &str) -> String {
    format!("//! The routes, one module per resource: its handlers and a `register`\n//! that adds them to the app.\n\npub mod {module};\n")
}

/// What the route module needs to know.
pub struct RouteText<'a> {
    pub command: &'a str,
    pub resource: &'a str,
    /// The path the routes are under (`/posts`, `/api/posts`).
    pub base: &'a str,
    pub type_name: &'a str,
    pub singular: &'a str,
    pub fields: &'a [Field],
    pub actions: &'a [RouteAction],
    pub kind: AppKind,
    pub state: &'a str,
}

/// `src/routes/<resource>.rs`.
pub fn route(r: &RouteText<'_>) -> String {
    let RouteText { command, resource, base, type_name, singular, fields, actions, kind, state } = r;
    let has = |a: RouteAction| actions.contains(&a);
    let needs_body = has(RouteAction::Create) || has(RouteAction::Update);
    let needs_path = has(RouteAction::Show) || has(RouteAction::Update) || has(RouteAction::Remove);
    let needs_error = needs_path;
    let what: Vec<&str> = actions.iter().map(|a| a.verb()).collect();
    let new_type = format!("New{type_name}");
    let mut out = format!(
        "//! `{base}`: {} {resource}.\n//!\n//! Generated by `{command}` (rws {VERSION}).\n//!\n//! The {resource} are kept in memory, in this module: replace `STORE` with\n//! the app's state or a model's repository when they must outlast the\n//! process.\n\n",
        join_words(&what)
    );
    out.push_str("use std::collections::BTreeMap;\nuse std::sync::{Mutex, MutexGuard};\n\n");
    let (app_import, app_type) = match kind {
        AppKind::Sync => ("rust_web_server::state::AppWithState", "AppWithState"),
        AppKind::Async => ("rust_web_server::async_state::AsyncAppWithState", "AsyncAppWithState"),
    };
    let mut uses = vec![app_import.to_string(), format!("rust_web_server::handler::{}", if needs_path { "{Json, Path}" } else { "Json" })];
    if needs_error {
        uses.push("rust_web_server::error::AppError".to_string());
    }
    if has(RouteAction::Create) {
        uses.push("rust_web_server::response::{StatusCodeReasonPhrase, STATUS_CODE_REASON_PHRASE}".to_string());
    }
    let mut macros: Vec<&str> = actions.iter().map(|a| a.macro_name()).collect();
    macros.sort_unstable();
    macros.dedup();
    uses.push(match macros.as_slice() {
        [one] => format!("rust_web_server::{one}"),
        _ => format!("rust_web_server::{{{}}}", macros.join(", ")),
    });
    uses.sort();
    for line in &uses {
        out.push_str(&format!("use {line};\n"));
    }
    out.push_str("use serde::{Deserialize, Serialize};\n");
    if has(RouteAction::Remove) {
        out.push_str("use serde_json::{json, Value};\n");
    }
    out.push_str(&format!("\n/// A {}.\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct {type_name} {{\n    pub id: i64,\n", singular.replace('_', " ")));
    for f in fields.iter() {
        out.push_str(&format!("    pub {}: {},\n", f.name, f.ty));
    }
    out.push_str("}\n");
    if needs_body {
        let bodies: Vec<String> = [(RouteAction::Create, "POST", base.to_string()), (RouteAction::Update, "PUT", format!("{base}/{{id}}"))]
            .into_iter()
            .filter(|(a, _, _)| has(*a))
            .map(|(_, m, p)| format!("`{m} {p}`"))
            .collect();
        out.push_str(&format!("\n/// The body of {}.\n#[derive(Clone, Debug, Deserialize)]\npub struct {new_type} {{\n", bodies.join(" and ")));
        for f in fields.iter() {
            out.push_str(&format!("    pub {}: {},\n", f.name, f.ty));
        }
        out.push_str("}\n");
    }
    out.push_str(&format!(
        "\nstatic STORE: Mutex<BTreeMap<i64, {type_name}>> = Mutex::new(BTreeMap::new());\n\n/// The {resource}, locked (a handler that panicked doesn't lock them out).\nfn store() -> MutexGuard<'static, BTreeMap<i64, {type_name}>> {{\n    STORE.lock().unwrap_or_else(|poisoned| poisoned.into_inner())\n}}\n"
    ));
    if needs_error {
        out.push_str(&format!(
            "\nfn not_found(id: i64) -> AppError {{\n    AppError::NotFound(format!(\"no {} {{id}}\"))\n}}\n",
            singular.replace('_', " ")
        ));
    }
    let item = "item";
    for action in actions.iter() {
        out.push('\n');
        match action {
            RouteAction::List => out.push_str(&format!(
                "#[get(\"{base}\")]\npub fn list() -> Json<Vec<{type_name}>> {{\n    Json(store().values().cloned().collect())\n}}\n"
            )),
            RouteAction::Show => out.push_str(&format!(
                "/// `{{id}}` that isn't a number is answered `400` by `Path`.\n#[get(\"{base}/{{id}}\")]\npub fn show(Path(id): Path<i64>) -> Result<Json<{type_name}>, AppError> {{\n    store().get(&id).cloned().map(Json).ok_or_else(|| not_found(id))\n}}\n"
            )),
            RouteAction::Create => {
                let assigns: Vec<String> = fields.iter().map(|f| format!("{0}: new.{0}", f.name)).collect();
                let init = if assigns.is_empty() { "id".to_string() } else { format!("id, {}", assigns.join(", ")) };
                out.push_str(&format!(
                    "/// A body that isn't a `{new_type}` is answered `400` by `Json`.\n#[post(\"{base}\")]\npub fn create(Json(new): Json<{new_type}>) -> (&'static StatusCodeReasonPhrase, Json<{type_name}>) {{\n    let mut all = store();\n    let id = all.keys().next_back().map_or(1, |last| last + 1);\n    let {item} = {type_name} {{ {init} }};\n    all.insert(id, {item}.clone());\n    (STATUS_CODE_REASON_PHRASE.n201_created, Json({item}))\n}}\n"
                ));
            }
            RouteAction::Update => {
                let mut assigns = String::new();
                for f in fields.iter() {
                    assigns.push_str(&format!("    {item}.{0} = new.{0};\n", f.name));
                }
                let new_binding = if fields.is_empty() { "_new" } else { "new" };
                out.push_str(&format!(
                    "#[put(\"{base}/{{id}}\")]\npub fn update(Path(id): Path<i64>, Json({new_binding}): Json<{new_type}>) -> Result<Json<{type_name}>, AppError> {{\n    let mut all = store();\n    let {item} = all.get_mut(&id).ok_or_else(|| not_found(id))?;\n{assigns}    Ok(Json({item}.clone()))\n}}\n"
                ));
            }
            RouteAction::Remove => out.push_str(&format!(
                "#[delete(\"{base}/{{id}}\")]\npub fn remove(Path(id): Path<i64>) -> Result<Json<Value>, AppError> {{\n    store().remove(&id).map(|_| Json(json!({{ \"deleted\": id }}))).ok_or_else(|| not_found(id))\n}}\n"
            )),
        }
    }
    let chain: String = actions.iter().map(|a| format!(".route({})", a.handler())).collect();
    out.push_str(&format!(
        "\n/// Add these routes to the app.\npub fn register(app: {app_type}<{state}>) -> {app_type}<{state}> {{\n    app{chain}\n}}\n"
    ));
    out
}

fn join_words(words: &[&str]) -> String {
    match words {
        [] => String::new(),
        [one] => one.to_string(),
        [init @ .., last] => format!("{} and {last}", init.join(", ")),
    }
}

/// What the test file needs to know.
pub struct TestText<'a> {
    pub command: &'a str,
    pub crate_name: &'a str,
    pub resource: &'a str,
    pub base: &'a str,
    pub app_expr: &'a str,
    pub actions: &'a [RouteAction],
    /// The body fields (`New<Type>`'s), when `create`/`update` exist.
    pub fields: &'a [Field],
}

/// `tests/<resource>.rs`.
pub fn test(t: &TestText<'_>) -> String {
    let TestText { command, crate_name, resource, base, app_expr, actions, fields } = t;
    let has = |a: RouteAction| actions.contains(&a);
    let body = |round: usize| -> String {
        let pairs: Vec<String> = fields.iter().map(|f| format!("\"{}\": {}", f.name, json_sample(f, round))).collect();
        format!("json!({{ {} }})", pairs.join(", "))
    };
    let mut out = format!(
        "//! `{base}` through `TestClient`: no socket, no server.\n//!\n//! Generated by `{command}` (rws {VERSION}).\n\nuse rust_web_server::test_client::{{TestClient, TestResponse}};\nuse serde_json::{{json, Value}};\nuse {crate_name}::*;\n\nfn json_of(response: &TestResponse) -> Value {{\n    serde_json::from_slice(response.body_bytes()).unwrap_or(Value::Null)\n}}\n"
    );
    // The round trip, as far as the routes go.
    if has(RouteAction::Create) {
        out.push_str(&format!(
            "\n#[test]\nfn {resource}_round_trip() {{\n    let client = TestClient::new({app_expr});\n    let created = client.post(\"{base}\").body_json(&{}.to_string()).send();\n    assert_eq!(201, created.status(), \"{{}}\", json_of(&created));\n    let item = json_of(&created);\n    let id = item[\"id\"].as_i64().expect(\"the created item has an id\");\n",
            body(0)
        ));
        for f in fields.iter() {
            out.push_str(&format!("    assert_eq!(json!({}), item[\"{}\"]);\n", json_sample(f, 0), f.name));
        }
        if has(RouteAction::Show) {
            out.push_str(&format!(
                "\n    let shown = client.get(&format!(\"{base}/{{id}}\")).send();\n    assert_eq!(200, shown.status());\n    assert_eq!(item, json_of(&shown));\n"
            ));
        }
        if has(RouteAction::List) {
            out.push_str(&format!(
                "\n    let listed = json_of(&client.get(\"{base}\").send());\n    assert!(listed.as_array().expect(\"a list\").iter().any(|i| i[\"id\"] == id), \"{{listed}}\");\n"
            ));
        }
        if has(RouteAction::Update) {
            out.push_str(&format!(
                "\n    let updated = client.put(&format!(\"{base}/{{id}}\")).body_json(&{}.to_string()).send();\n    assert_eq!(200, updated.status(), \"{{}}\", json_of(&updated));\n",
                body(1)
            ));
            for f in fields.iter() {
                out.push_str(&format!("    assert_eq!(json!({}), json_of(&updated)[\"{}\"]);\n", json_sample(f, 1), f.name));
            }
        }
        if has(RouteAction::Remove) {
            out.push_str(&format!(
                "\n    assert_eq!(200, client.delete(&format!(\"{base}/{{id}}\")).send().status());\n    assert_eq!(404, client.delete(&format!(\"{base}/{{id}}\")).send().status());\n"
            ));
            if has(RouteAction::Show) {
                out.push_str(&format!("    assert_eq!(404, client.get(&format!(\"{base}/{{id}}\")).send().status());\n"));
            }
        }
        out.push_str("}\n");
    } else if has(RouteAction::List) {
        out.push_str(&format!(
            "\n#[test]\nfn {resource}_are_listed() {{\n    let client = TestClient::new({app_expr});\n    let listed = client.get(\"{base}\").send();\n    assert_eq!(200, listed.status());\n    assert!(json_of(&listed).is_array());\n}}\n"
        ));
    }
    // The errors.
    let id_route = [(RouteAction::Show, "get"), (RouteAction::Update, "put"), (RouteAction::Remove, "delete")].into_iter().find(|(a, _)| has(*a));
    if id_route.is_some() || has(RouteAction::Create) {
        out.push_str(&format!("\n#[test]\nfn {resource}_errors_are_answered() {{\n    let client = TestClient::new({app_expr});\n"));
        if let Some((action, method)) = id_route {
            let with_body = if action == RouteAction::Update { format!(".body_json(&{}.to_string())", body(0)) } else { String::new() };
            out.push_str(&format!(
                "    assert_eq!(404, client.{method}(\"{base}/999999999\"){with_body}.send().status(), \"an id that was never created\");\n    assert_eq!(400, client.{method}(\"{base}/not-a-number\"){with_body}.send().status(), \"the id must be a number\");\n"
            ));
        }
        if has(RouteAction::Create) {
            out.push_str(&format!("    assert_eq!(400, client.post(\"{base}\").body_json(\"not json\").send().status());\n"));
        }
        out.push_str("}\n");
    }
    out
}