//! 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
}