Generators (rws gen)
rws gen model, route, migration and test — code for a project in the conventional layout, written against the exact API of the rws release you run, never overwriting a file.
Since 18.29.0 rws gen writes the four things an app gets more of — a
model, a resource's routes, a migration, a test — into a project in the
conventional layout. What it writes is
pinned to the API of the rws release you run (CI generates into every
starter and runs the
generated tests), and it never overwrites a file: running a generator twice
changes nothing the second time, and a file that is in the way is a conflict,
shown as a diff. There is no rws new — new projects start from a starter.
rws gen model Post title:String views:i64 published:bool 'note:Option<String>'
rws gen route posts # the model's fields; or: rws gen route posts title:String
rws gen test posts
rws gen migration add_index_to_posts
cargo testUse the rws binary of the release the project depends on
(cargo install rust-web-server --version <x.y.z>, or any of the
installation packages): its output names
that release in each file's header and is written against its API.
The same generators are tools of the dev MCP endpoint
(gen_model, gen_route, gen_migration, gen_test), and a library
module, rust_web_server::generator.
What each generator writes
| Command | Creates | Adds a line to |
|---|---|---|
rws gen model <Name> field:type… |
src/models/<name>.rs, migrations/NNNN_create_<table>.sql and .down.sql |
src/models/mod.rs (pub mod post;, pub use post::Post;; created when missing), src/lib.rs (pub mod models;), Cargo.toml (the model-<db> feature) |
rws gen route <resource> [field:type…] |
src/routes/<resource>.rs |
src/routes/mod.rs, app() in src/lib.rs (the routes are registered) |
rws gen migration <name> |
migrations/NNNN_<name>.sql and .down.sql |
— |
rws gen test <resource> |
tests/<resource>.rs |
— |
The paths are those of rws.app.toml ([paths] models, routes,
migrations, tests, lib), so a project that keeps its routes elsewhere
gets them there. A line is added only where it can be placed safely — beside
the other mod lines, inside the existing register(…) call of app(), in a
one-line rust-web-server = { … } dependency; otherwise the plan ends with a
note: saying what to add by hand.
Models
rws gen model Post title:String views:i64 published:bool 'note:Option<String>'
// src/models/post.rs
//! `Post`: the `posts` table (migrations/0001_create_posts.sql).
//!
//! Generated by `rws gen model Post title:String views:i64 published:bool note:Option<String>` (rws 18.29.0).
use rust_web_server::Model;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Model)]
#[table(name = "posts")]
pub struct Post {
#[primary_key(auto_increment)]
pub id: i64,
pub title: String,
pub views: i64,
pub published: bool,
pub note: Option<String>,
}
#[cfg(test)]
mod tests {
// maps_to_the_posts_table: the table, columns and primary key;
// round_trips_through_sqlite (SQLite only): the migrations applied to an
// in-memory database, then save, find_by_id, delete_by_id.
}- The name may be
Post,BlogPostorblog_post: the module is snake_case (blog_post.rs), the type CamelCase, the table the snake_case plural (blog_posts). Every model getsid: i64, auto-increment. - Field types are a fixed list:
i64,i32,f64,bool,String, andOption<T>of those (a nullable column). Anything else is refused with that list. - The database is
--db sqlite|postgres|mysql, else themodel-*feature the project'sCargo.tomlalready enables, else SQLite; the feature is added when missing. The migration's column types follow it:
| Rust | SQLite | PostgreSQL | MySQL |
|---|---|---|---|
id |
INTEGER PRIMARY KEY AUTOINCREMENT |
BIGSERIAL PRIMARY KEY |
BIGINT AUTO_INCREMENT PRIMARY KEY |
i64 |
INTEGER |
BIGINT |
BIGINT |
i32 |
INTEGER |
INTEGER |
INT |
f64 |
REAL |
DOUBLE PRECISION |
DOUBLE |
bool |
INTEGER |
BOOLEAN |
BOOLEAN |
String |
TEXT |
TEXT |
TEXT |
Columns are NOT NULL unless the field is an Option.
- The migration is numbered after the highest
NNNN_inmigrations/; the down file drops the table. - To list the model in the dev MCP endpoint's
modelstool, add.model::<models::Post>()to yourDevTools(the plan's note says so).
Routes
rws gen route posts title:String views:i64 [--methods list,show,create,update,remove] [--prefix /api]writes typed handlers — #[get("/posts")] list, #[get("/posts/{id}")] show,
#[post("/posts")] create (201), #[put("/posts/{id}")] update,
#[delete("/posts/{id}")] remove — a Post and a NewPost (the request
body) type, and
/// Add these routes to the app.
pub fn register(app: AppWithState<crate::Store>) -> AppWithState<crate::Store> {
app.route(list).route(show).route(create).route(update).route(remove)
}The app type and its state come from pub fn app(…) -> AppWithState<S> (or
AsyncAppWithState<S>) in src/lib.rs, and app()'s existing
routes::…::register(…) call is wrapped:
routes::posts::register(routes::todos::register(App::with_state(…))). A
project whose lib.rs has no such app() — the mcp starter builds an
McpServer — is refused.
Without fields, the routes take the fields of src/models/<singular>.rs when
there is one, else name: String. The handlers keep the items in memory, in
the module (static STORE): they work and are tested as they are; replace the
store with the app's state or the model's repository when the data must outlast
the process. Unknown ids answer 404, ids that aren't numbers 400, bodies that
aren't JSON of the right shape 400 — problem details, as every rws error.
Migrations
rws gen migration add_index_to_postswrites migrations/0002_add_index_to_posts.sql and .down.sql, each holding
a statement that changes nothing (SELECT 1;) under a comment, so the files
apply as they are until you replace it. DbPool::migrate splits a file at
semicolons — keep them out of comments (migrations).
Tests
rws gen test posts [--app-expr 'app(Store::memory())']reads the routes src/routes/posts.rs declares (#[get("…")] and the like)
and the fields of its NewPost, and writes tests/posts.rs: a round trip
through every route there (create, show, list, update, remove, then 404),
and the errors (404 for an unknown id, 400 for a non-numeric id and for a
body that isn't JSON) — through TestClient, no socket. The test builds the
app with app(); when app takes arguments, with what the existing tests in
tests/ pass to TestClient::new, or --app-expr.
Conflicts, dry runs, JSON
$ rws gen model Post title:String # after the one above
conflict src/models/post.rs
unchanged src/models/mod.rs
…
--- a/src/models/post.rs
+++ b/src/models/post.rs
@@ -11,9 +11,6 @@
#[primary_key(auto_increment)]
pub id: i64,
pub title: String,
- pub views: i64,
…
rws gen: not written — a file above exists with other content (the diff is what the generator would write). Move it aside or merge by hand.- A file the generator creates is never replaced. If it exists with exactly
what the generator would write, it is
unchanged; with anything else it is aconflict, the diff goes from the file to what would be written, and nothing of the plan is written. Re-running the same command is safe. --dry-runprints the plan with every diff and writes nothing.--jsonprints the plan for agents:{"command":"rws gen model Post title:String","status":"written","written":true,"changes":[{"path":"src/models/post.rs","action":"create","diff":"--- /dev/null\n+++ b/src/models/post.rs\n…"}],"notes":[…]}—statusiswritten,planned(dry run),unchangedorconflict; errors are{"status":"usage"|"refused","error":"…"}.--dir <project>runs against another directory (default: the working directory, which must hold the project'sCargo.toml).
Exit codes: 0 written (or nothing to do), 1 a conflict or a project the
generator can't work with (no app(), no route module for gen test), 2 a
usage error (a bad name or type, an unknown option).
Names are identifiers — letters, digits and single _, starting with a letter,
at most 64 characters, not a Rust keyword — so no generated path leaves the
project; a resource whose type would clash with a name the generated code
imports (paths → Path, values → Value) is refused.
From Rust
use rust_web_server::generator::{plan, Field, Generate};
let request = Generate::Model {
name: "Post".into(),
fields: vec![Field::parse("title:String")?],
db: None,
};
let plan = plan(".", &request)?; // reads only
print!("{}", plan.render(true)); // one line per file, then the diffs
if !plan.has_conflicts() {
plan.apply(".")?; // writes; refuses if a file changed since
}Plan has the changes (FileChange { path, kind, content, original, diff },
kind one of Create, Edit, Unchanged, Conflict), the notes, and
to_json(written); generator::run(args, out, err) is the command line.