Developer guide
{% raw %} How to build an application with Crabster, from installing it to deploying what it made. You need no other page to get all the way through; the ones referenced go deeper, they do not fill gaps.
What Crabster does, and does not. It writes a complete Rust project from a domain model and hands it to you: the generated code is yours, there is no support library to keep up to date, and you can stop using Crabster without losing anything. What it does not do is live inside your project.
1. Install🔗
cargo install --path path/to/crabster/crates/crabster-cli --locked
crabster --version
Three commands, and that is all there is:
| Command | What it does |
|---|---|
crabster new <name> | A project with no model: server, configuration, health, metrics, Dockerfile, CI. Run it with no arguments on a terminal and it asks — including whether you want one application or a system of several |
crabster import-cdl <file> | A full project from a domain model |
crabster record <Name> | Adds a record to an existing project |
crabster introspect <url> | Writes a .cdl from a database that already exists |
2. Five minutes🔗
Nothing else to install: the default target is SQLite, which is a file.
crabster new blog --database sqlite --port 8123
cd blog
crabster record Author \
--field "name: Text @length(2..80)" \
--field "email: Text @unique"
crabster record Post --filterable \
--field "title: Text @length(2..200)" \
--field "body: LongText?" \
--field "publishedAt: Timestamp" \
--field "author: ref Author"
cp .env.example .env
cargo run
In another terminal:
curl -X POST localhost:8123/api/authors -H 'content-type: application/json' \
-d '{"name":"Ada","email":"ada@example.com"}'
curl -X POST localhost:8123/api/posts -H 'content-type: application/json' \
-d '{"title":"First post","body":null,
"publishedAt":"2026-01-01T10:00:00Z","authorId":1}'
curl 'localhost:8123/api/posts?title=First%20post'
And http://localhost:8123/swagger-ui in a browser.
crabster record wrote your model into .crabster/model.cdl. From here you can
carry on with the command or edit that file — both lead to the same place.
Or a system, if one application is not the shape🔗
Run crabster new with no arguments on a terminal and the second question is
whether you want one application or several. Answer several and it asks for a
gateway, the services, whether they register with a catalogue and whether they
export spans — then writes the model and generates one project per service, plus
a root that starts all of them:
crabster new shop --architecture microservices \
--gateway edge:9400 --service orders:sqlite:9401 --service billing:sqlite:9402 \
--discovery consul --telemetry otlp
The flags and the questions are the same thing: a system you answered for is
the system those flags make, and both are the system crabster import-cdl
makes from the .cdl left in the root. There is one generator, and the wizard
is a way of writing its input.
What comes out has no records — a system is created before it has any. Add them
with crabster record inside a service, exactly as above, then run
crabster upgrade at the root: it assembles the model again from what the
services now hold and rebuilds the gateway's routing table from it. Without that
step the gateway routes nothing, because what it routes is what the model says a
service owns and it was generated when nothing owned anything. The command says
so when it finishes.
3. Modelling🔗
A model is a .cdl file. It has three shapes: record, enum, and an optional
service block.
service blog {
database postgres
port 8080
auth jwt // optional: adds accounts and sessions
}
enum Status { DRAFT, PUBLISHED, ARCHIVED }
record Author {
name: Text @length(2..80)
email: Text @unique @matches("^.+@.+$")
}
@filterable
record Post {
title: Text @length(2..200)
body: LongText?
status: Status
publishedAt: Timestamp
author: ref Author
}
crabster import-cdl blog.cdl --path ~/blog
The rules that matter🔗
A field is mandatory unless it ends in ?. The common case is the one you
should not have to write. The distinction reaches the API: on a PATCH, an
absent key keeps its value and an explicit null clears it — and null is only
accepted on a field the model allows to be empty.
id belongs to Crabster. You never declare it, and a field by that name is
refused.
A reference is written where the foreign key physically is. author: ref Author on Post gives the table an author_id column, a constraint, and the
relations on both sides. Nothing to declare on Author.
| Written | Means |
|---|---|
author: ref Author | Many posts per author, reference required |
author: ref Author? | The same, but a post may have none |
author: ref Author @unique | One-to-one: at most one post per author |
Cycles are refused. A → B → A has no migration order, and a foreign key is
declared inside CREATE TABLE.
An attribute qualifies what precedes it, on the same line. Written above a
field it would qualify the previous one: that is refused, and the message names
which. Only a record's attribute (@filterable) goes above.
Types🔗
| CDL | Rust | Notes |
|---|---|---|
Text | String | VARCHAR. With no upper bound: 255 |
LongText | String | TEXT, unbounded |
Int Long | i32 i64 | |
Float Double | f32 f64 | |
Decimal | Decimal | (19, 4). Not exact on SQLite — see below |
Bool | bool | |
Date Timestamp | Date DateTimeUtc | Timestamp becomes DATETIME on MySQL |
Uuid | Uuid | |
Bytes | Vec<u8> | BLOB; an array of integers in JSON |
Decimalis not exact on SQLite. The value comes back through anf64: sqlx deliberately does not supportrust_decimalthere, and no column type changes that. A model storing money in production targets PostgreSQL or MySQL.
Attributes🔗
| Attribute | On | Effect |
|---|---|---|
@unique | a field | Uniqueness in the database; on a ref, one-to-one |
@length(2..120) | text | Either end may stay open: @length(..200) |
@range(0..1000) | numbers | Same shape |
@matches("regex") | text | Compiled while parsing: an invalid regex is refused here |
@filterable | a record | Lists accept exact filters from the query string |
An attribute that could not mean anything is refused rather than ignored:
@length on an Int, @range on Text, @range on a Decimal.
The service block🔗
| Setting | Values | Default |
|---|---|---|
database | postgres, mysql, sqlite | postgres |
port | 1–65535 | 8080 |
auth | jwt | absent |
Command-line flags win. One database per project: there is no separate setting for development and production, because the schema is not the same from one database to the next.
The language in full: CDL language.
4. What you get🔗
A project made from a model. crabster new gives you the top third of it: no
src/domain, src/dto, src/api or src/migration, which come from records.
Cargo.toml Dockerfile .dockerignore
README.md .env.example .gitignore
.spectral.yaml .github/workflows/ci.yml
docker-compose.yml (PostgreSQL and MySQL only — SQLite is a file)
config/ default.toml, dev.toml, prod.toml
.crabster/ what Crabster recorded — commit it
src/
main.rs startup, router, HTTP layers
config.rs layered configuration
health.rs /health, /health/live, /health/ready
http.rs timeout, concurrency, body size, CORS
observability.rs logs, metrics, correlation id
openapi.rs the OpenAPI document
error.rs the error contract
state.rs what every handler is given
testing.rs where the tests get a database
domain/ one SeaORM entity per record
dto/ what the API accepts and returns
api/ one endpoint module per record
migration/ one migration per record, in order
The endpoints🔗
Five per record:
| Method | Path | Answers |
|---|---|---|
GET | /api/<records>?page=0&size=20 | One page, with totalItems and totalPages |
GET | /api/<records>/{id} | One record, or 404 |
POST | /api/<records> | 201 and the created record, with Location |
PATCH | /api/<records>/{id} | The updated record |
DELETE | /api/<records>/{id} | 204, or 409 if something still references it |
PATCH rather than PUT: the body is a set of changes, and a key you leave out
keeps its value.
Ordering on every list, with nothing to declare:
?sort=<field>&order=asc|desc, over a per-record whitelist. A field that is not
accepted answers 400 and lists the ones that are.
Filters with @filterable. The bare name is still an exact match —
?title=First — and the rest are asked for as ?<field>.<operator>=:
| Operator | On | Example |
|---|---|---|
(the bare name), equals | any filterable field | ?status=PAID |
notEquals | the same | ?status.notEquals=CANCELLED |
contains | short text | ?label.contains=table |
greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual | numbers, decimals, dates, timestamps | ?price.lessThan=100 |
specified | any optional field | ?summary.specified=false |
Several at once are combined with AND: a row has to satisfy every one.
Long text, binary and references have no value filter — only specified, and
only when they are optional. No comparison on text: string order depends on
the collation, and the three databases do not agree on it.
And a parameter the endpoint does not read is refused, with a 400 listing the ones it takes. A misspelt filter does not apply: without that refusal it answered the whole table, which looks like a result.
Related objects: a reference is an identifier, and ?expand= replaces it
with the record itself.
curl "localhost:9000/api/orders?expand=customer,product"
{"id":1,"customerId":1,"productId":1,
"customer":{"id":1,"email":"a@b.com","fullName":"Ada L"},
"product":{"id":1,"label":"Chair","price":"49.90"}}
One query per reference for the whole page, not one per row. The key is left
out when there is nothing to put in it — nobody asked, or the reference is
empty; customerId beside it says which. A name the record does not reference
is refused, listing the ones it has.
Plus, on every project: /health, /health/live, /health/ready, /metrics,
/api-docs/openapi.json, /swagger-ui, /problems.
Writing without overwriting somebody🔗
A record marked @versioned turns down a write that does not say what it read:
curl -i localhost:9000/api/orders/1 # ETag: "3"
curl -X PATCH localhost:9000/api/orders/1 \
-H 'If-Match: "3"' -H 'content-type: application/json' \
-d '{"status":"PAID"}' # 200, ETag: "4"
| What the client sends | Answer |
|---|---|
If-Match with the current version | 200, and a new ETag |
If-Match with a version since passed | 412, /problems/stale-version |
| Nothing at all | 428, /problems/version-required |
PATCH and DELETE are both covered. The check and the write are one SQL
statement (WHERE id = ? AND version = ?), so nothing can slip between them.
Without @versioned, two clients changing the same row overwrite one another
in silence: the last one wins and the first never finds out. It is the one
place where generated code was wrong rather than merely incomplete.
@audited adds createdAt and updatedAt, written by the code. Both
attributes can be added later: crabster apply turns them into migrations.
When it refuses🔗
Every failure answers RFC 9457 problem
details, as application/problem+json:
{
"type": "/problems/validation",
"title": "The submitted data is invalid",
"status": 422,
"detail": "…",
"errors": {"email": [{"code": "email"}]}
}
type is the field to branch on, and the one the status cannot replace: two
different 409s carry two different types. It is a URI the project serves —
GET /problems lists every kind, GET /problems/unique-conflict explains one.
| Status | When |
|---|---|
400 | Unreadable body, or an invalid query parameter |
404 | No such record, or no such endpoint |
405 | That path exists under another method |
409 | A unique value is taken, or a reference does not hold |
413 415 | Body too large, or not sent as JSON |
422 | The JSON was read and the model refuses it |
422and not400for a refused body:400says "I could not read this",422says "I read it and will not have it". Sending someone to check their serialisation when the real complaint is one field is the kind of help that costs an afternoon.
5. Growing the project🔗
crabster record Comment \
--field "text: LongText" \
--field "post: ref Post"
The command merges nothing. Every generated file was stamped as it was written:
- a file that still matches its stamp is Crabster's, and may move;
- a file that no longer does was edited by hand, and the command stops, names it, and writes nothing.
That is a message to read, not an obstacle to get past with --force — which
really does overwrite.
Migrations already applied keep their numbers. A record can only be added:
to remove one, edit .crabster/model.cdl and generate elsewhere, or write the
migration yourself.
To change a record that already exists — one field more, one field less —
use crabster apply, just below. Do not edit .crabster/model.cdl: it is the
copy of what Crabster last generated, and so the only record of what your
database already holds.
Growing the model🔗
Edit the model the project records, and run crabster apply:
crabster apply --path my-api --dry-run # reads .crabster/model.cdl
crabster apply --path my-api
If you keep your .cdl elsewhere in the repository, name it:
crabster apply shop.cdl --path my-api.
The command compares that model with .crabster/applied.cdl — the model the
migrations have actually built — and the difference becomes new
migrations, never a rewrite of the old ones. The two files do not move
together: model.cdl changes the moment you edit it, applied.cdl only when a
migration is written for the difference. That gap is what answers the question
no single file can: does this model declare anything the database has never
been told about?
While anything is owed, crabster record and crabster upgrade refuse,
naming the field:
this project's model declares things its database has never been told about:
`Customer.tier`, which no migration creates
That is not stiffness: regenerating the code from a model the database has
never seen gives a project that compiles and fails on its first request. The rest of the code is regenerated exactly as on
crabster upgrade: rewritten where you touched nothing, merged where you wrote.
Migrations written, to be applied next time the project starts:
src/migration/n0001_customer_add_nickname.rs
New migrations live in the n band; the m band is table creations. They are
applied on the next start, like every other.
| What you change | What happens |
|---|---|
| One more optional field | ADD COLUMN |
| One more mandatory field | you have to say what goes in the rows that exist: --default "Customer.nickname=unknown" |
@unique added | a unique index |
| One fewer field | DROP COLUMN, and only with --force: the column goes with its values |
| One fewer record | DROP TABLE, --force too |
| One more record | its creation migration, as crabster record writes |
| Making a field optional or mandatory | PostgreSQL and MySQL alter the column; SQLite rebuilds the table |
A wider @length bound | on PostgreSQL and MySQL, the column is widened to match; on SQLite nothing is written |
A narrower @length bound | the same, and only with --force: a value already longer than the new bound cannot be kept |
| One more optional reference | PostgreSQL and MySQL add the column and its foreign key; SQLite rebuilds the table, a foreign key being part of its definition there |
@unique taken away | SQLite only, by rebuilding the table: the other two wrote the constraint under a name they invented, and neither the model's |
Int → Long, Float → Double, Text → LongText | the column is restated at the wider type; on SQLite nothing is written, all three pairs sharing one affinity there |
The bound is the column's own width, not only a request check — something writing to the database directly is held to it too. Except on SQLite, which records a width and enforces none of it: there the bound is the request check's alone, and regeneration rewrites that on its own.
On SQLite, a change to a table becomes a rebuild. Its ALTER TABLE adds a
column, drops one, and renames — whether a column may be empty, whether it is
unique, and the foreign keys a table declares are all settled by CREATE TABLE
and cannot be altered after. So Crabster does what SQLite documents: it builds
the table your model describes beside the old one, copies the rows across, drops
the old one and takes its name. One migration for the record, whatever else
changed about it. Every row keeps its id.
That is also why a generated SQLite project runs its migrations on a
connection of its own, with foreign keys off (see main.rs). With them on,
SQLite rewrites the REFERENCES clauses of every table pointing at the one
being replaced, so they would follow it out of existence — and PRAGMA foreign_keys is a no-op inside a transaction, which is where a migration runs.
The connection is closed as soon as the migrations are done; the pool that
serves requests has foreign keys on, as it always did.
And what is refused, by name: changing a field's type to anything but the
three widenings above, removing a @unique on PostgreSQL or MySQL,
removing a reference, and adding a mandatory one — the rows already in the
table point at nothing, and no --default rescues that one, because the value
would have to name a row of the target that exists and the model cannot promise
it does. Add it optional, fill it in, then make it mandatory. For those Crabster
generates nothing: write the migration yourself, in a file of its own, for the
database you actually run.
That is deliberate. An ALTER that is right on two databases out of three is
worse than no ALTER at all: the third only finds out in production.
What is already yours🔗
Some places belong to you and survive regeneration byte for byte — they are
not even run through rustfmt:
// Yours: kept as-is when Crabster regenerates this file.
// crabster:begin dependencies
// crabster:end dependencies
There are regions in Cargo.toml ([dependencies]), at the end of
src/main.rs, in src/api/mod.rs (routes that come from no record) and in
config/default.toml. Writing elsewhere works too — it will simply make
crabster record stop and name the file, which is the behaviour you want.
Commit .crabster/🔗
project.toml records what the command line decided (name, database, port,
modules), model.cdl the model verbatim, applied.cdl the model the
migrations built, files.toml the stamps, and snapshot/ a copy of every file
as it was written. Without them,
crabster record can no longer tell your work from its own, and
crabster upgrade can no longer merge.
The snapshot doubles the file count of the repository — 42 files and 201 KB for
examples/shop.cdl, as much again as the project itself. That is what merging
costs, and it is visible: every record and every upgrade produces a diff on
the project and a diff on its copy.
Starting from a database you already have🔗
crabster introspect "postgres://user:pass@localhost/shop" --service shop_api --out shop.cdl
crabster import-cdl shop.cdl --path shop
This is the one command that connects to a database. Everything else computes from the model, and the generated project is what runs a migration — here the schema is the input, and nothing but the database has it. PostgreSQL, MySQL and SQLite.
What comes out is a starting point and says so, in a comment at the top of the file rather than in a document you might not read. A schema holds what a database enforces; a model holds more:
| What does not come back | Why |
|---|---|
enum and its variants | An enumeration is stored as text, so the variants are in the rows |
@filterable | Which fields a list may be narrowed by is an API decision, and a table holds no trace of it |
@matches, @range | Checked on the way in, before anything is stored |
The service block | A port, an authentication module and a database name describe a deployment |
The lower half of @length | A column bounds how long a value may be and never how short |
And one thing comes back that you may not have written: a Text field with no
bound is given one when the column is built, so @length(..255) may appear
where the model said nothing. A schema cannot tell a default from a decision.
What does come back is everything the schema carries: every record, every
field with its type and whether it may be absent, every reference — read from
the foreign keys, so customer_id becomes customer: ref Customer — every
single-column uniqueness, and @versioned and @audited, recognised by the
columns they add.
Read it, add what is missing, and keep it: from there on it is the model, and
crabster record, apply and upgrade all work on it.
Crossing a version🔗
When a new Crabster moves the templates, a project already generated is not stuck on the old ones:
crabster upgrade --path my-api --dry-run # says what would move
crabster upgrade --path my-api # moves it
The command regenerates the project from what .crabster/ recorded — same
model, same database, same modules — and decides file by file, from the
stamps:
| What the stamp says | What happens |
|---|---|
| The file is exactly what Crabster wrote | it is rewritten with the new version |
| You edited it, and the two changes do not overlap | they are merged: your work and the templates' change are both in the file |
| You edited the very line the templates change | it is not touched; the merge, conflict markers and all, is left in .crabster/incoming/<the same path> |
| The file is no longer produced at all | it is deleted — unless you had edited it, in which case it stays |
| It is a migration already written | it is never touched, whatever happens, and what the templates would have written goes to .crabster/incoming/ |
The merge is a real three-way merge, the same kind git does. The common
ancestor is .crabster/snapshot/: a copy of every file as Crabster wrote
it. Without it no merge is possible — what the generator produced on a given
day cannot be recovered afterwards, its templates being compiled into the binary
that wrote it. Commit it, like the rest of .crabster/.
Protected regions are re-injected as on crabster record: what you wrote in
[dependencies] crosses the version without counting as an edit of the file.
On a conflict, the file left beside yours carries the usual markers: ours is
your version, original is what Crabster had written there, theirs is what
the new templates write. Settle them, copy the file back, then delete
.crabster/incoming/ — it is emptied on every upgrade anyway.
Nothing you wrote is ever overwritten: on a conflict Crabster leaves your file exactly as it is rather than planting markers in it.
--force rewrites your files with the templates' version, without merging. It
is not the way to use this.
A clean merge can still produce code that does not compile — that is true of git
too. Read it, and run cargo test.
Migrations never move. SeaORM decides applied-or-pending by the migration's name alone: rewriting the contents of
src/migration/m0002_customer.rswithout changing its name would change what your project expects without changing your database, and nothing would say so — the failure would arrive at the first request. Crabster therefore does not regenerate them at all: a file written once is never rewritten, deleted, compared or reported, and--forcedoes not open it. A module declares its own in itsmodule.toml(write_once = ["src/migration/*_*.rs"]), and a blueprint can declare more.One consequence worth knowing: changing a migration's template reaches only the projects that do not have it yet. That is deliberate — for the others, the one correct way to change the schema is another migration.
Two points of method: do it on a clean git tree, so git diff shows you exactly
what moved; and if the project was generated through a blueprint, pass
--blueprint again — .crabster/project.toml does not record its path, which
belongs to one machine.
A project generated before
.crabster/snapshot/existed has no ancestor to merge from. It still crosses the version: its edited files are simply left alone, with the new version beside them. The firstupgradewrites the copy, so the next one merges.
6. Configuration🔗
Layered, each source overriding the previous one:
config/default.toml— committed defaultsconfig/{profile}.toml—APP_PROFILEpicks oneAPP__*environment variables —APP__SERVER__PORT=9000
An unset
APP_PROFILEmeansprod, deliberately: a container deployed without the variable must not come up bound to the loopback address..env.exampleselectsdev, andcp .env.example .envis the first line of the generated README.
| Key | What it is |
|---|---|
server.host server.port | Where it listens |
logging.level | Overridden by RUST_LOG |
http.timeout_seconds | Past it, the request answers 408 |
http.max_concurrent_requests | How many may run at once |
http.max_body_bytes | What one request can make the process allocate |
http.cors_allowed_origins | Empty means none. There is no wildcard |
auth.* | Token lifetimes (see §8) |
Secrets go in the environment, never in the TOML files. DATABASE_URL is read
from the environment or .env.
A key nothing declares stops the server🔗
APP__SERVERR__PORT=9000 used to leave the server on the port it already had,
and say nothing. Now it refuses to start:
Error: failed to load configuration
Caused by:
unknown field `serverr`, expected one of `profile`, `server`, `logging`, `http`
Ignoring is the wrong answer for configuration, for the reason an unknown
query-string filter is a 400 rather than a shrug: a setting silently dropped
surfaces later as a service nobody can reach, far from the typo that caused it.
It holds for every source — the files, the environment, and the key-value store
if config consul is on — and for the sections modules add as well as the core
ones.
Settings of your own🔗
A key you add to config/default.toml has to be declared in src/config.rs
too, and that takes both regions:
pub struct Settings {
// …
// crabster:begin settings
pub mine: MineSettings, // the field
// crabster:end settings
}
// crabster:begin types
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MineSettings { // the type it names
pub retries: u32,
}
// crabster:end types
with the matching section inside the region at the end of config/default.toml:
[mine]
retries = 3
Both regions survive regeneration — crabster record and apply keep what is
inside them. Written anywhere else, the same code marks the file as edited and
stops the next crabster record instead.
And your section is checked like the rest: retrys = 3 answers
unknown field `retrys`, expected `retries` for key `mine` .
The editor knows the keys too🔗
Each config/*.toml opens with a line your editor reads:
#:schema ./schema.json
config/schema.json is generated beside them, derived from Settings in
src/config.rs rather than written next to it — one statement of the shape, so
the two cannot part company. Taplo reads the pointer, and VS Code and the
JetBrains IDEs both ship Taplo: keys are completed as you type them, their doc
comments show in the tooltip, and a misspelt one is underlined before anything
runs.
Two deliberate gaps, both of which the server still catches at startup:
- Nothing is
required.dev.tomlandprod.tomlrestate only the settings a profile changes; requiring the full set would underline every key they correctly leave todefault.toml. - A section the schema does not know is allowed. Settings you add in the
regions live in
src/config.rs, and the schema is derived from the file as generated — so it cannot describe them, and flagging them would be wrong. A section inside the schema, though, admits no key the server does not read.
7. Tests🔗
cargo test
Three tests per record: a full lifecycle, a paged and ordered list, and what the endpoint refuses. They go through the whole router, not the handlers.
They run against the database the project targets. For SQLite, an in-memory database per test: nothing to install. For PostgreSQL and MySQL, a container the suite starts itself — Docker required — with a database created per test. If you already have a server:
TEST_DATABASE_URL=postgres://user:pass@localhost:5432/postgres cargo test
Coverage, with no configuration:
cargo install cargo-llvm-cov
cargo llvm-cov --html
8. Accounts and sessions🔗
Add auth jwt to the service block, or try it without touching the model:
crabster import-cdl blog.cdl --with auth-jwt --path ~/try
Five endpoints appear, under src/auth/:
| Method | Path | Answers |
|---|---|---|
POST | /auth/register | 201 and the account. Password ≥ 12 characters |
POST | /auth/login | An access token, a refresh token, and how long the first lasts |
POST | /auth/refresh | A new session, roles read again from the account |
GET | /auth/me | The account this session belongs to |
GET | /auth/users | Every account. Requires the ADMIN role |
Protecting a route of your own🔗
Name the extractor in the handler. That is what protects the route — there is no second place where it is declared:
use crate::auth::extract::Authenticated;
async fn mine(Authenticated(claims): Authenticated) -> AppResult<Json<Thing>> {
claims.require_role("ADMIN")?;
// ...
}
The generated CRUD endpoints are not protected by default: protecting them all would change the meaning of every endpoint in your model.
The signing key🔗
.env.example ships a placeholder so a working copy runs with no setup. That
exact value is refused outside the dev profile, so a deployment cannot run
with the key that is in your repository:
openssl rand -base64 48 # then APP__AUTH__SECRET=…
A key shorter than 32 bytes is refused everywhere. Both refusals happen at startup, not at the first login.
Stateless, therefore irrevocable. Nothing is stored on the server side: an access token stays valid until it expires whatever happens to the account. Hence fifteen minutes for access and thirty days for refresh — with roles read from the account on every refresh.
Roles🔗
A comma-separated list on the account, USER on creation. There is no
endpoint that grants a role, on purpose: change the column.
9. Observability and the API contract🔗
| Path | Answers |
|---|---|
/health/live | 200 as long as the process is serving. Reaches nothing |
/health/ready | 200 when everything it depends on answers, 503 otherwise — naming which |
/metrics | Counts, latencies, in-flight requests, in Prometheus format |
/api-docs/openapi.json | The OpenAPI 3.1 document |
/swagger-ui | A console over it |
The health split matters: a restart policy reads liveness (restarting a healthy process because a database went down turns one outage into two), a load balancer reads readiness.
Metrics are labelled by the matched route — /api/posts/{id}, never
/api/posts/1 — so the number of series is bounded by the size of your project
rather than by its traffic.
Every request carries an x-request-id, minted if absent, written into every
log line of that request and returned on the response.
The document is generated from the handlers themselves; it cannot drift from the code. To check it:
curl -s localhost:8123/api-docs/openapi.json > openapi.json
npx @stoplight/spectral-cli lint openapi.json # uses .spectral.yaml
One rule is off there, info-contact: who answers for your API is the one thing
Crabster cannot know. Put a contact in the info(...) block of
src/openapi.rs and turn it back on.
10. Deploying🔗
docker build -t blog .
docker run --rm -p 8123:8123 -e DATABASE_URL=… blog
Four stages, two of them for dependency caching. The final image holds the
binary and its config/ and nothing else — no shell, no package manager — and
runs as nonroot.
APP_PROFILE is unset in it, so prod: the server binds every interface, which
is what a container has to do to be reachable.
With the database, locally — PostgreSQL and MySQL only: a SQLite project
has no docker-compose.yml, there being no server to start.
docker compose up -d --wait # the database alone
docker compose --profile app up # both
The profile is what keeps the first command useful: while you are working you
want the server from cargo run.
For a SQLite project, give the container somewhere to keep its file:
mkdir -p data
docker run --rm -p 8123:8123 -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \
-e DATABASE_URL='sqlite:///data/blog.db?mode=rwc' blog
.github/workflows/ci.yml runs the formatter, Clippy at -D warnings, the
tests against the database you target, a dependency audit and a build of the
image. No secret, no repository setting. The image is built and not pushed —
the workflow says in a comment what to add. The generated README carries the
GitLab equivalent.
11. Extending🔗
A blueprint changes what is generated without touching Crabster's core:
crabster import-cdl blog.cdl --blueprint ./my-blueprint
Four things are possible — replace a template, add one, add a module, write into
a file another module owns. That is the subject of
Writing a blueprint, and there is a complete
example in examples/blueprints/gitlab.
12. When it goes wrong🔗
| What you see | What it is |
|---|---|
failed to bind …: Address already in use | The port from the service block is taken. APP__SERVER__PORT=8123 cargo run |
| The server starts and nothing answers | No .env → prod profile, listening on 0.0.0.0. With .env → dev, on 127.0.0.1 |
DATABASE_URL is not set | cp .env.example .env |
configuration file "config/default" not found | The binary reads config/ relative to the working directory. Run it from the project, or put config/ beside it — which is what the image does |
crabster record names a file and stops | You edited it. Read the diff before reaching for --force |
… was generated by another version | The project came from other templates. crabster upgrade crosses the version for it, leaving the files you edited alone |
| The tests want Docker | A PostgreSQL or MySQL project. Start Docker, or set TEST_DATABASE_URL |
`X` is not a setting any installed module answers to | An unknown service setting. The message lists the ones that exist |
A Decimal comes back wrong | SQLite. See §3 |
13. Quick reference🔗
crabster new [<name>] [--database postgres|mysql|sqlite] [--port N] [--no-input]
[--path DIR] [--blueprint DIR] [--with M] [--without M] [--check]
crabster new [<name>] --architecture microservices
[--gateway NAME[:PORT]] [--service NAME[:DB[:PORT]]]…
[--discovery consul] [--telemetry otlp] [--path DIR]
crabster import-cdl <file.cdl> [--database …] [--port N]
[--path DIR] [--blueprint DIR] [--with M] [--without M] [--check]
crabster record <Name> --field "name: Type" [--field …] [--filterable]
[--path DIR] [--blueprint DIR] [--force] [--check]
crabster introspect <URL> [--out FILE] [--service NAME]
--check runs cargo check before reporting success: safe, and a minute slower
the first time.
Further🔗
| Page | What it adds |
|---|---|
| CDL language | The language in full, and what it refuses |
| Writing a blueprint | Extending generation |
| Technical architecture | Why each choice was made |
| Vision and goals | What Crabster is trying to be |
| V2 scope | What comes next |
| {% endraw %} |