Guide du développeur
{% raw %} Comment construire une application avec Crabster, de l'installation au déploiement. Vous n'avez besoin d'aucune autre page pour aller jusqu'au bout ; celles qui sont citées approfondissent, elles ne complètent pas.
Ce que Crabster fait, et ne fait pas. Il écrit un projet Rust complet à partir d'un modèle de domaine, et vous le rend : le code généré vous appartient, il n'y a pas de bibliothèque de support à mettre à jour, et vous pouvez cesser d'utiliser Crabster sans rien perdre. Ce qu'il ne fait pas, c'est vivre dans votre projet.
1. Installer🔗
cargo install --path chemin/vers/crabster/crates/crabster-cli --locked
crabster --version
Trois commandes, et c'est tout ce qu'il y a :
| Commande | Ce qu'elle fait |
|---|---|
crabster new <nom> | Un projet sans modèle : serveur, configuration, santé, métriques, Dockerfile, CI. Lancé sans argument sur un terminal, il demande — y compris si vous voulez une application ou un système de plusieurs |
crabster import-cdl <fichier> | Un projet complet à partir d'un modèle de domaine |
crabster record <Nom> | Ajoute un enregistrement à un projet existant |
crabster introspect <url> | Écrit un .cdl depuis une base qui existe déjà |
2. Cinq minutes🔗
Rien à installer d'autre : le projet par défaut vise SQLite, qui est un fichier.
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
Dans un autre 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":"Premier billet","body":null,
"publishedAt":"2026-01-01T10:00:00Z","authorId":1}'
curl 'localhost:8123/api/posts?title=Premier%20billet'
Et http://localhost:8123/swagger-ui dans un navigateur.
crabster record a écrit votre modèle dans .crabster/model.cdl. À partir de
là, vous pouvez continuer avec la commande ou éditer ce fichier — les deux
mènent au même endroit.
Ou un système, si une seule application n'est pas la forme🔗
Lancez crabster new sans argument sur un terminal : la deuxième question est
si vous voulez une application ou plusieurs. Répondez plusieurs et il demande
une passerelle, les services, s'ils s'enregistrent dans un catalogue et s'ils
exportent des traces — puis il écrit le modèle et génère un projet par service,
ainsi qu'une racine qui démarre l'ensemble :
crabster new shop --architecture microservices \
--gateway edge:9400 --service orders:sqlite:9401 --service billing:sqlite:9402 \
--discovery consul --telemetry otlp
Les flags et les questions sont la même chose : le système que vous avez
obtenu en répondant est celui que ces flags produisent, et tous deux sont
celui que crabster import-cdl produit à partir du .cdl laissé dans la
racine. Il n'y a qu'un générateur, et le wizard est une façon d'écrire son
entrée.
Ce qui en sort ne contient aucun enregistrement — un système se crée avant d'en
avoir. Ajoutez-les avec crabster record dans un service, exactement comme
ci-dessus, puis lancez crabster upgrade à la racine : il réassemble le modèle
depuis ce que les services contiennent désormais et reconstruit la table de
routage de la passerelle à partir de lui. Sans cette étape la passerelle ne
route rien, car ce qu'elle route est ce que le modèle dit qu'un service possède,
et elle a été générée quand personne ne possédait rien. La commande le dit en
terminant.
3. Modéliser🔗
Un modèle est un fichier .cdl. Il tient en trois formes : record, enum,
et un bloc service facultatif.
service blog {
database postgres
port 8080
auth jwt // facultatif : ajoute comptes et 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
Les règles qui comptent🔗
Un champ est obligatoire sauf s'il finit par ?. Le cas courant est celui
qu'on ne devrait pas avoir à écrire. La distinction va jusqu'à l'API : sur un
PATCH, une clé absente laisse la valeur en place, un null explicite l'efface
— et n'est accepté que sur un champ que le modèle autorise à être vide.
id est à Crabster. Vous ne le déclarez jamais, et un champ de ce nom est
refusé.
Une référence s'écrit là où la clé étrangère existe. author: ref Author
sur Post donne à la table une colonne author_id, une contrainte, et les
relations des deux côtés. Rien à déclarer sur Author.
| Écrit | Signification |
|---|---|
author: ref Author | Plusieurs billets par auteur, référence obligatoire |
author: ref Author? | Idem, mais un billet peut n'en avoir aucun |
author: ref Author @unique | Un-à-un : au plus un billet par auteur |
Les cycles sont refusés. A → B → A n'a pas d'ordre de migration, et une
clé étrangère se déclare dans le CREATE TABLE.
Un attribut qualifie ce qui le précède, sur la même ligne. Écrit au-dessus
d'un champ, il qualifierait le champ précédent : c'est refusé, en nommant
lequel. Seul l'attribut d'un enregistrement (@filterable) s'écrit au-dessus.
Types🔗
| CDL | Rust | Notes |
|---|---|---|
Text | String | VARCHAR. Sans borne haute : 255 |
LongText | String | TEXT, non borné |
Int Long | i32 i64 | |
Float Double | f32 f64 | |
Decimal | Decimal | (19, 4). Inexact sur SQLite — voir plus bas |
Bool | bool | |
Date Timestamp | Date DateTimeUtc | Timestamp devient DATETIME sur MySQL |
Uuid | Uuid | |
Bytes | Vec<u8> | BLOB ; en JSON, un tableau d'entiers |
Decimalsur SQLite n'est pas exact. La valeur revient à travers unf64: sqlx refuse délibérémentrust_decimalsur SQLite. Aucun type de colonne n'y change rien. Un modèle qui stocke de l'argent en production vise PostgreSQL ou MySQL.
Attributs🔗
| Attribut | Sur | Effet |
|---|---|---|
@unique | un champ | Unicité en base ; sur un ref, un-à-un |
@length(2..120) | du texte | Bornes ouvertes possibles : @length(..200) |
@range(0..1000) | des nombres | Même forme |
@matches("regex") | du texte | Compilée à l'analyse : une regex invalide est refusée ici |
@filterable | un enregistrement | Les listes acceptent des filtres exacts en query string |
Un attribut qui ne peut rien signifier est refusé plutôt qu'ignoré : @length
sur un Int, @range sur du Text, @range sur un Decimal.
Le bloc service🔗
| Réglage | Valeurs | Défaut |
|---|---|---|
database | postgres, mysql, sqlite | postgres |
port | 1–65535 | 8080 |
auth | jwt | absent |
Les options de la ligne de commande priment. Une base par projet : il n'y a pas de réglage séparé pour le développement et la production, parce que le schéma n'est pas le même d'une base à l'autre.
Le détail complet du langage : Langage CDL.
4. Ce que vous obtenez🔗
Un projet issu d'un modèle. crabster new en donne le tiers supérieur : pas de
src/domain, src/dto, src/api ni src/migration, qui viennent des
enregistrements.
Cargo.toml Dockerfile .dockerignore
README.md .env.example .gitignore
.spectral.yaml .github/workflows/ci.yml
docker-compose.yml (PostgreSQL et MySQL seulement — SQLite est un fichier)
config/ default.toml, dev.toml, prod.toml
.crabster/ ce que Crabster a enregistré — à committer
src/
main.rs démarrage, routeur, couches HTTP
config.rs configuration en couches
health.rs /health, /health/live, /health/ready
http.rs timeout, concurrence, taille de corps, CORS
observability.rs journaux, métriques, identifiant de corrélation
openapi.rs le document OpenAPI
error.rs le contrat d'erreur
state.rs ce que chaque handler reçoit
testing.rs d'où les tests tirent leur base
domain/ une entité SeaORM par enregistrement
dto/ ce que l'API accepte et renvoie
api/ un module d'endpoints par enregistrement
migration/ une migration par enregistrement, dans l'ordre
Les endpoints🔗
Cinq par enregistrement :
| Méthode | Chemin | Répond |
|---|---|---|
GET | /api/<records>?page=0&size=20 | Une page, avec totalItems et totalPages |
GET | /api/<records>/{id} | Un enregistrement, ou 404 |
POST | /api/<records> | 201 et l'enregistrement créé, avec Location |
PATCH | /api/<records>/{id} | L'enregistrement modifié |
DELETE | /api/<records>/{id} | 204, ou 409 si quelque chose le référence |
PATCH et non PUT : le corps est un ensemble de changements, et une clé
omise garde sa valeur.
Tri sur toute liste, sans rien déclarer : ?sort=<champ>&order=asc|desc,
sur une liste blanche par enregistrement. Un champ refusé répond 400 en listant
ceux qui sont acceptés.
Filtres avec @filterable. Le nom seul reste une égalité exacte —
?title=Premier — et le reste se demande en ?<champ>.<opérateur>= :
| Opérateur | Sur | Exemple |
|---|---|---|
(le nom seul), equals | tout champ filtrable | ?status=PAID |
notEquals | idem | ?status.notEquals=CANCELLED |
contains | du texte court | ?label.contains=basse |
greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual | nombres, décimaux, dates, horodatages | ?price.lessThan=100 |
specified | tout champ facultatif | ?summary.specified=false |
Plusieurs à la fois sont combinés en ET : une ligne doit satisfaire chacun.
Le texte long, le binaire et les références n'ont pas de filtre de valeur —
seulement specified, et seulement s'ils sont facultatifs. Pas de comparaison
sur du texte : l'ordre des chaînes dépend de la collation, et les trois bases
ne s'accordent pas dessus.
Et un paramètre que l'endpoint ne lit pas est refusé, avec un 400 qui liste ceux qu'il accepte. Un filtre mal orthographié ne s'applique pas : sans ce refus, il répondait la table entière, ce qui ressemble à un résultat.
Objets liés : une référence est un identifiant, ?expand= la remplace par
l'enregistrement lui-même.
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":"Chaise","price":"49.90"}}
Une requête par référence pour toute la page, pas une par ligne. La clé est
absente quand il n'y a rien à mettre dedans — personne n'a demandé, ou la
référence est vide ; customerId à côté dit lequel des deux. Un nom que
l'enregistrement ne référence pas est refusé en listant ceux qui existent.
Plus, sur tout projet : /health, /health/live, /health/ready, /metrics,
/api-docs/openapi.json, /swagger-ui, /problems.
Écrire sans écraser le voisin🔗
Un enregistrement marqué @versioned refuse une écriture qui ne dit pas ce
qu'elle a lu :
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"
| Ce que le client envoie | Réponse |
|---|---|
If-Match avec la version courante | 200, et un nouvel ETag |
If-Match avec une version dépassée | 412, /problems/stale-version |
| Rien du tout | 428, /problems/version-required |
Le PATCH et le DELETE sont concernés. La vérification et l'écriture sont
une seule instruction SQL (WHERE id = ? AND version = ?), donc rien ne
peut se glisser entre les deux.
Sans @versioned, deux clients qui modifient la même ligne s'écrasent en
silence : le dernier gagne, le premier n'en saura jamais rien. C'est le seul
endroit où le code généré était faux plutôt qu'incomplet.
@audited ajoute createdAt et updatedAt, écrites par le code. Les deux
attributs peuvent être ajoutés plus tard : crabster apply en fait des
migrations.
Quand ça refuse🔗
Toute défaillance répond en RFC 9457,
servi en application/problem+json :
{
"type": "/problems/validation",
"title": "The submitted data is invalid",
"status": 422,
"detail": "…",
"errors": {"email": [{"code": "email"}]}
}
type est le champ sur lequel brancher, et celui que le statut ne peut pas
remplacer : deux 409 différents ont deux type différents. C'est une URI que
le projet sert — GET /problems liste tous les genres, GET /problems/unique-conflict en explique un.
| Statut | Quand |
|---|---|
400 | Corps illisible, ou paramètre de requête invalide |
404 | Pas d'enregistrement, ou pas d'endpoint |
405 | Ce chemin existe sous une autre méthode |
409 | Valeur unique prise, ou référence qui ne tient pas |
413 415 | Corps trop grand, ou pas envoyé en JSON |
422 | Le JSON a été lu et le modèle le refuse |
422et non400pour un corps refusé :400dit « je n'ai pas pu lire ceci »,422dit « je l'ai lu et je n'en veux pas ». Envoyer quelqu'un vérifier sa sérialisation quand le vrai grief est un champ, c'est le genre d'aide qui coûte un après-midi.
5. Faire évoluer le projet🔗
crabster record Comment \
--field "text: LongText" \
--field "post: ref Post"
La commande ne fusionne rien. Chaque fichier généré a été empreint à l'écriture :
- un fichier qui correspond encore à son empreinte est à Crabster, et peut bouger ;
- un fichier qui n'y correspond plus a été édité à la main : la commande s'arrête, le nomme, et n'écrit rien.
C'est un message à lire, pas un obstacle à contourner avec --force — qui
écrase vraiment.
Les migrations déjà appliquées gardent leur numéro. Un enregistrement ne peut
qu'être ajouté : pour en retirer un, éditez .crabster/model.cdl et
régénérez ailleurs, ou faites la migration à la main.
Pour changer un enregistrement existant — un champ de plus, un champ de
moins — c'est crabster apply, juste en dessous. N'éditez pas
.crabster/model.cdl : c'est la copie de ce que Crabster a généré la dernière
fois, et donc le seul témoin de ce que votre base contient déjà.
Faire évoluer le modèle🔗
Éditez le modèle que le projet enregistre, et lancez crabster apply :
crabster apply --path mon-api --dry-run # lit .crabster/model.cdl
crabster apply --path mon-api
Si vous gardez votre .cdl ailleurs dans le dépôt, nommez-le :
crabster apply shop.cdl --path mon-api.
La commande compare ce modèle à .crabster/applied.cdl — le modèle que les
migrations ont réellement construit — et la différence devient des migrations
neuves, jamais une réécriture des anciennes. Ces deux fichiers ne bougent
pas ensemble : model.cdl change dès que vous l'éditez, applied.cdl seulement
quand une migration est écrite pour la différence. C'est ce décalage qui permet
de répondre à la question qu'aucun fichier seul ne peut trancher : ce modèle
déclare-t-il quelque chose dont la base n'a jamais entendu parler ?
Tant qu'il en reste, crabster record et crabster upgrade refusent, en
nommant le champ :
this project's model declares things its database has never been told about:
`Customer.tier`, which no migration creates
Ce n'est pas de la rigidité : régénérer le code depuis un modèle que la base
n'a jamais vu donne un projet qui compile et échoue à sa première requête. Le reste du code est régénéré exactement
comme sur crabster upgrade : réécrit là où vous n'avez rien touché, fusionné
là où vous avez écrit.
Migrations written, to be applied next time the project starts:
src/migration/n0001_customer_add_nickname.rs
Les migrations neuves sont dans la bande n ; la bande m est celle des
créations de table. Elles s'appliquent au démarrage suivant, comme les autres.
| Ce que vous changez | Ce qui se passe |
|---|---|
| Un champ facultatif de plus | ADD COLUMN |
| Un champ obligatoire de plus | il faut dire quoi mettre dans les lignes existantes : --default "Customer.nickname=inconnu" |
@unique ajouté | un index unique |
| Un champ de moins | DROP COLUMN, et seulement avec --force : la colonne part avec ses valeurs |
| Un enregistrement de moins | DROP TABLE, --force aussi |
| Un enregistrement de plus | sa migration de création, comme crabster record |
| Rendre un champ facultatif ou obligatoire | PostgreSQL et MySQL modifient la colonne ; SQLite reconstruit la table |
Une borne @length plus large | sur PostgreSQL et MySQL, la colonne s'élargit avec elle ; sur SQLite rien n'est écrit |
Une borne @length plus étroite | pareil, et seulement avec --force : une valeur déjà plus longue que la nouvelle borne ne peut pas être gardée |
| Une référence facultative de plus | PostgreSQL et MySQL ajoutent la colonne et sa clé étrangère ; SQLite reconstruit la table, une clé étrangère y faisant partie de sa définition |
Un @unique retiré | sur SQLite seulement, en reconstruisant la table : les deux autres ont écrit la contrainte sous un nom qu'elles ont inventé, et qui n'est pas celui du modèle |
Int → Long, Float → Double, Text → LongText | la colonne est réénoncée au type plus large ; sur SQLite rien n'est écrit, les trois paires y partageant une même affinité |
La borne est la largeur de la colonne elle-même, pas seulement un contrôle de requête — ce qui écrit dans la base directement y est tenu aussi. Sauf sur SQLite, qui enregistre une largeur et n'en impose aucune : là, la borne n'appartient qu'au contrôle de requête, et la régénération le réécrit toute seule.
Sur SQLite, un changement de table devient une reconstruction. Son ALTER TABLE ajoute une colonne, en supprime une, et renomme — le fait qu'une colonne
puisse être vide, qu'elle soit unique, et les clés étrangères que la table
déclare sont tous fixés par le CREATE TABLE et ne peuvent plus être modifiés
ensuite. Crabster fait donc ce que SQLite documente : il construit la table que
votre modèle décrit à côté de l'ancienne, y recopie les lignes, supprime
l'ancienne et prend son nom. Une migration pour l'enregistrement, quoi qu'il ait
changé d'autre. Chaque ligne garde son id.
C'est aussi pourquoi un projet SQLite généré applique ses migrations sur une
connexion à lui, clés étrangères désactivées (voir main.rs). Activées,
SQLite réécrit les clauses REFERENCES de toutes les tables qui pointent sur
celle qu'on remplace, et elles la suivraient dans le néant — et PRAGMA foreign_keys est sans effet dans une transaction, or une migration s'exécute
dans une transaction. La connexion est fermée dès les migrations terminées ; le
pool qui sert les requêtes a les clés étrangères activées, comme toujours.
Et ce qui est refusé, en le nommant : changer le type d'un champ vers autre
chose que les trois élargissements ci-dessus, retirer un @unique sur
PostgreSQL ou MySQL, retirer une référence, et en ajouter une obligatoire — les lignes
déjà dans la table ne pointent sur rien, et aucun --default ne sauve ce cas-là :
la valeur devrait nommer une ligne de la cible qui existe, ce que le modèle ne
peut pas promettre. Ajoutez-la facultative, remplissez-la, puis rendez-la
obligatoire. Dans ces cas Crabster ne génère rien : écrivez la migration
vous-même, dans un fichier à elle, pour la base que vous utilisez vraiment.
C'est délibéré. Un ALTER juste sur deux bases sur trois est pire que pas
d'ALTER du tout : la troisième ne l'apprend qu'en production.
Ce qui vous appartient déjà🔗
Certains endroits sont à vous et survivent à la régénération octet pour
octet — ils ne sont même pas passés à rustfmt :
// Yours: kept as-is when Crabster regenerates this file.
// crabster:begin dependencies
// crabster:end dependencies
Il y en a dans Cargo.toml ([dependencies]), à la fin de src/main.rs, dans
src/api/mod.rs (des routes qui ne viennent d'aucun enregistrement) et dans
config/default.toml. Écrire ailleurs marche aussi — cela fera simplement
s'arrêter crabster record en nommant le fichier, ce qui est le comportement
que vous voulez.
Committer .crabster/🔗
project.toml enregistre ce que la ligne de commande a décidé (nom, base,
port, modules), model.cdl le modèle verbatim, applied.cdl le modèle que les
migrations ont construit, files.toml les empreintes, et snapshot/ une copie
de chaque fichier tel qu'il a été écrit. Sans eux,
crabster record ne peut plus distinguer votre travail du sien, et
crabster upgrade ne peut plus fusionner.
L'instantané double le nombre de fichiers du dépôt — 42 fichiers et 201 Ko pour
examples/shop.cdl, soit autant que le projet lui-même. C'est le prix de la
fusion, et il est visible : chaque record et chaque upgrade produit un diff
sur le projet et un diff sur sa copie.
Partir d'une base que vous avez déjà🔗
crabster introspect "postgres://user:pass@localhost/shop" --service shop_api --out shop.cdl
crabster import-cdl shop.cdl --path shop
C'est la seule commande qui se connecte à une base. Tout le reste calcule depuis le modèle, et c'est le projet généré qui exécute une migration — ici le schéma est l'entrée, et personne d'autre que la base ne le détient. PostgreSQL, MySQL et SQLite.
Ce qui en sort est un point de départ, et le dit — dans un commentaire en tête du fichier plutôt que dans une documentation que vous ne lirez peut-être pas. Un schéma retient ce qu'une base fait respecter ; un modèle en dit plus :
| Ce qui ne revient pas | Pourquoi |
|---|---|
enum et ses variantes | Une énumération est stockée en texte : les variantes sont dans les lignes |
@filterable | Les champs par lesquels une liste se restreint sont une décision d'API, dont une table ne garde aucune trace |
@matches, @range | Vérifiés à l'entrée, avant que quoi que ce soit soit stocké |
Le bloc service | Un port, un module d'authentification et un nom de base décrivent un déploiement |
La borne inférieure d'un @length | Une colonne borne la longueur maximale, jamais la minimale |
Et une chose revient que vous n'avez peut-être pas écrite : un champ Text sans
borne en reçoit une quand la colonne est bâtie, donc @length(..255) peut
apparaître là où le modèle ne disait rien. Un schéma ne distingue pas un défaut
d'une décision.
Ce qui revient, c'est tout ce que le schéma porte : chaque enregistrement,
chaque champ avec son type et son caractère facultatif, chaque référence — lue
dans les clés étrangères, si bien que customer_id devient
customer: ref Customer —, chaque unicité sur une colonne, ainsi que
@versioned et @audited, reconnus aux colonnes qu'ils ajoutent.
Relisez, ajoutez ce qui manque, et gardez-le : à partir de là c'est le modèle, et
crabster record, apply et upgrade travaillent dessus.
Franchir une version🔗
Quand une nouvelle version de Crabster fait bouger les templates, un projet déjà généré n'est pas figé :
crabster upgrade --path mon-api --dry-run # dit ce qui bougerait
crabster upgrade --path mon-api # le fait
La commande régénère le projet à partir de ce que .crabster/ a enregistré —
même modèle, même base, mêmes modules — et décide fichier par fichier, à
l'empreinte :
| Ce que dit l'empreinte | Ce qui se passe |
|---|---|
| Le fichier est exactement celui que Crabster avait écrit | il est réécrit avec la nouvelle version |
| Vous l'avez édité, et les deux modifications ne se marchent pas dessus | elles sont fusionnées : votre travail et le changement de template sont tous les deux dans le fichier |
| Vous l'avez édité sur la ligne même que les templates changent | il n'est pas touché ; la fusion, marqueurs de conflit compris, est déposée dans .crabster/incoming/<le même chemin> |
| Le fichier n'est plus produit du tout | il est supprimé — sauf si vous l'aviez édité, auquel cas il reste |
| C'est une migration déjà écrite | il n'est jamais touché, quoi qu'il arrive, et ce que les templates auraient écrit va dans .crabster/incoming/ |
La fusion est une vraie fusion à 3 voies, comme celle de git. L'ancêtre commun,
c'est .crabster/snapshot/ : une copie de chaque fichier tel que Crabster
l'a écrit. Sans elle il n'y a pas de fusion possible — ce que le générateur a
produit un jour n'est pas reconstituable après coup, les templates étant compilés
dans le binaire qui l'a écrit. Elle se committe, comme le reste de .crabster/.
Les zones protégées sont réinjectées comme sur crabster record : ce que vous
avez écrit dans [dependencies] traverse la version sans compter comme une
édition du fichier.
Sur un conflit, le fichier déposé porte les marqueurs habituels : ours est
votre version, original ce que Crabster avait écrit là, theirs ce que les
nouveaux templates écrivent. Tranchez, recopiez le fichier, puis supprimez
.crabster/incoming/ — elle est vidée à chaque upgrade de toute façon.
Rien de ce que vous avez écrit n'est jamais écrasé : sur un conflit, Crabster laisse votre fichier tel quel plutôt que d'y planter des marqueurs.
--force réécrit vos fichiers avec la version des templates, sans fusionner.
Ce n'est pas la façon de s'en servir.
Une fusion propre peut quand même produire du code qui ne compile pas — c'est
vrai de git aussi. Relisez, et lancez cargo test.
Faire évoluer un système entier🔗
Lancez-la à la racine d'un système et elle prend le système :
crabster upgrade --path shop # la racine, la passerelle, et chaque service
Aucun flag ne le dit. Une racine enregistre un modèle qui déclare plusieurs services, ce qu'un projet seul ne fait jamais : le répertoire dit donc déjà lequel des deux il est.
Ce qu'elle fait, et qu'une évolution projet par projet ne peut pas faire, c'est
réassembler le modèle depuis les services. Chaque service possède ses
propres enregistrements — un service généré parmi d'autres est un projet
Crabster ordinaire, et .crabster/model.cdl est la façon dont tout projet
ordinaire enregistre ce qu'il contient — donc le modèle du système n'est pas une
seconde copie à tenir en phase. Il est relu depuis les parties à chaque fois, et
la table de routage de la passerelle est reconstruite à partir du résultat.
C'est ce qui porte jusqu'à la passerelle un enregistrement ajouté dans un service. Rien d'autre ne le fait : ce qu'une passerelle route est ce que le modèle dit qu'un service possède, et un système est créé avant que ses services ne possèdent quoi que ce soit.
crabster new shop --architecture microservices \
--gateway edge --service orders:sqlite --service billing:sqlite
crabster record Order --field "placedAt: Timestamp" --path shop/orders
crabster upgrade --path shop # `/api/orders` atteint désormais `orders`
Chaque partie passe par la même fusion que ci-dessus : un fichier que vous avez édité est donc fusionné ici aussi. Deux services déclarant une même énumération avec des valeurs différentes sont refusés en la nommant — un nom doit désigner une seule chose à l'échelle d'un système.
Les migrations ne bougent jamais. SeaORM décide « appliquée » ou « en attente » au nom seul du module de migration : réécrire le contenu de
src/migration/m0002_customer.rssans changer son nom changerait ce que votre projet attend sans changer votre base, et rien ne le dirait — la panne arriverait à la première requête. Crabster ne les régénère donc pas du tout : un fichier écrit une fois n'est ni réécrit, ni supprimé, ni comparé, ni signalé, et--forcene l'ouvre pas. Un module déclare les siens dans sonmodule.toml(write_once = ["src/migration/*_*.rs"]), et un blueprint peut en déclarer d'autres.Conséquence à connaître : changer le template d'une migration n'atteint que les projets qui ne l'ont pas encore. C'est voulu — pour les autres, la seule façon correcte de changer le schéma est une migration de plus.
Deux points de méthode : faites-le sur un arbre git propre, pour que
git diff vous montre exactement ce qui a bougé ; et si le projet a été généré
avec un blueprint, repassez --blueprint — .crabster/project.toml
n'enregistre pas son chemin, qui est celui d'une machine.
Un projet généré avant que
.crabster/snapshot/existe n'a pas d'ancêtre à fusionner. Il franchit quand même la version : ses fichiers édités sont simplement laissés tels quels, avec la nouvelle version à côté. Le premierupgradeécrit la copie, donc le suivant fusionne.
6. Configuration🔗
En couches, chacune écrasant la précédente :
config/default.toml— les valeurs par défaut, committéesconfig/{profil}.toml—APP_PROFILEchoisit- les variables
APP__*—APP__SERVER__PORT=9000
Un
APP_PROFILEabsent vautprod, et c'est délibéré : un conteneur déployé sans la variable ne doit pas démarrer lié à la boucle locale..env.examplesélectionnedev, etcp .env.example .envest la première ligne du README généré.
| Clé | Ce que c'est |
|---|---|
server.host server.port | Adresse d'écoute |
logging.level | Écrasé par RUST_LOG |
http.timeout_seconds | Au-delà, la requête répond 408 |
http.max_concurrent_requests | Combien peuvent tourner en même temps |
http.max_body_bytes | Ce qu'une requête peut faire allouer |
http.cors_allowed_origins | Vide = aucune. Il n'y a pas de joker |
auth.* | Durées de vie des jetons (voir §8) |
Les secrets vont dans l'environnement, jamais dans les fichiers TOML.
DATABASE_URL est lue depuis l'environnement ou .env.
Une clé que rien ne déclare arrête le serveur🔗
APP__SERVERR__PORT=9000 laissait jusqu'ici le serveur sur le port qu'il avait
déjà, sans rien dire. Il refuse maintenant de démarrer :
Error: failed to load configuration
Caused by:
unknown field `serverr`, expected one of `profile`, `server`, `logging`, `http`
Ignorer est la mauvaise réponse pour une configuration, et pour la raison qui
fait d'un filtre inconnu un 400 plutôt qu'un haussement d'épaules : un réglage
avalé en silence ressort plus tard en service que personne ne joint, loin de la
faute de frappe qui l'a causé. La règle vaut pour toutes les sources — les
fichiers, l'environnement, et le magasin clé-valeur si config consul est
actif — et pour les sections qu'ajoutent les modules comme pour celles du cœur.
Vos propres réglages🔗
Une clé ajoutée à config/default.toml doit être déclarée dans src/config.rs
aussi, et cela demande les deux régions :
pub struct Settings {
// …
// crabster:begin settings
pub mine: MineSettings, // le champ
// crabster:end settings
}
// crabster:begin types
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MineSettings { // le type qu'il nomme
pub retries: u32,
}
// crabster:end types
avec la section correspondante dans la région en fin de config/default.toml :
[mine]
retries = 3
Les deux régions survivent à la régénération — crabster record et apply
gardent ce qu'elles contiennent. Écrit ailleurs, le même code marque le fichier
comme édité et bloque le crabster record suivant.
Et votre section est contrôlée comme les autres : retrys = 3 répond
unknown field `retrys`, expected `retries` for key `mine` .
L'éditeur connaît les clés, lui aussi🔗
Chaque config/*.toml s'ouvre sur une ligne que votre éditeur lit :
#:schema ./schema.json
config/schema.json est engendré à côté, dérivé du Settings de
src/config.rs plutôt qu'écrit à côté de lui — une seule énonciation de la
forme, donc les deux ne peuvent pas se séparer. Taplo lit le pointeur, et VS
Code comme les IDE JetBrains embarquent Taplo : les clés se complètent à la
frappe, leurs commentaires de documentation s'affichent en infobulle, et une
clé mal orthographiée est soulignée avant que quoi que ce soit ne tourne.
Deux manques assumés, que le serveur rattrape de toute façon au démarrage :
- Rien n'est
required.dev.tomletprod.tomlne restituent que les réglages qu'un profil change ; exiger l'ensemble soulignerait chaque clé qu'ils laissent à bon droit àdefault.toml. - Une section que le schéma ignore est admise. Les réglages que vous
ajoutez dans les régions vivent dans
src/config.rs, et le schéma est dérivé du fichier tel qu'engendré — il ne peut donc pas les décrire, et les signaler serait une erreur. En revanche une section connue du schéma n'admet aucune clé que le serveur ne lit pas.
7. Tests🔗
cargo test
Trois tests par enregistrement : un cycle de vie complet, une liste paginée et ordonnée, et ce que l'endpoint refuse. Ils passent par le routeur complet, pas par les handlers.
Ils tournent contre la base que le projet vise. Pour SQLite, une base en mémoire par test : rien à installer. Pour PostgreSQL et MySQL, un conteneur que la suite démarre elle-même — Docker requis — avec une base créée par test. Si vous avez déjà un serveur :
TEST_DATABASE_URL=postgres://user:pass@localhost:5432/postgres cargo test
Couverture, sans configuration :
cargo install cargo-llvm-cov
cargo llvm-cov --html
8. Comptes et sessions🔗
Ajoutez auth jwt au bloc service, ou essayez-le sans toucher au modèle :
crabster import-cdl blog.cdl --with auth-jwt --path ~/essai
Cinq endpoints apparaissent, dans src/auth/ :
| Méthode | Chemin | Répond |
|---|---|---|
POST | /auth/register | 201 et le compte. Mot de passe ≥ 12 caractères |
POST | /auth/login | Un jeton d'accès, un de rafraîchissement, et la durée du premier |
POST | /auth/refresh | Une nouvelle session, rôles relus depuis le compte |
GET | /auth/me | Le compte de cette session |
GET | /auth/users | Tous les comptes. Rôle ADMIN requis |
Protéger une route à vous🔗
Nommez l'extracteur dans le handler. C'est cela qui protège la route — il n'y a pas de second endroit où le déclarer :
use crate::auth::extract::Authenticated;
async fn mine(Authenticated(claims): Authenticated) -> AppResult<Json<Thing>> {
claims.require_role("ADMIN")?;
// ...
}
Les endpoints CRUD générés ne sont pas protégés d'office : les protéger tous changerait le sens de chaque endpoint de votre modèle.
La clé de signature🔗
.env.example livre un placeholder pour qu'une copie de travail démarre sans
préparation. Cette valeur exacte est refusée hors du profil dev, donc un
déploiement ne peut pas tourner avec la clé qui est dans votre dépôt :
openssl rand -base64 48 # puis APP__AUTH__SECRET=…
Une clé de moins de 32 octets est refusée partout. Les deux refus sont au démarrage, pas au premier login.
Sans état, donc irrévocable. Rien n'est stocké côté serveur : un jeton d'accès reste valide jusqu'à son expiration quoi qu'il arrive au compte. D'où quinze minutes pour l'accès et trente jours pour le rafraîchissement — et les rôles relus depuis le compte à chaque rafraîchissement.
Les rôles🔗
Une liste séparée par des virgules sur le compte, USER à la création. Il n'y
a pas d'endpoint qui attribue un rôle, volontairement : changez la colonne.
9. Observabilité et contrat d'API🔗
| Chemin | Répond |
|---|---|
/health/live | 200 tant que le processus sert. Ne joint rien |
/health/ready | 200 quand tout ce dont le service dépend répond, 503 sinon — en nommant quoi |
/metrics | Compteurs, latences, requêtes en vol, au format Prometheus |
/api-docs/openapi.json | Le document OpenAPI 3.1 |
/swagger-ui | Une console dessus |
La distinction santé compte : une politique de redémarrage lit la vivacité (redémarrer un processus sain parce qu'une base est tombée transforme une panne en deux), un répartiteur de charge lit la disponibilité.
Les métriques sont étiquetées par la route matchée — /api/posts/{id},
jamais /api/posts/1 — donc le nombre de séries est borné par la taille de
votre projet, pas par son trafic.
Chaque requête porte un x-request-id, forgé si absent, inscrit dans chaque
ligne de journal de cette requête et renvoyé sur la réponse.
Le document est généré depuis les handlers eux-mêmes ; il ne peut pas dériver du code. Pour le vérifier :
curl -s localhost:8123/api-docs/openapi.json > openapi.json
npx @stoplight/spectral-cli lint openapi.json # utilise .spectral.yaml
Une règle y est coupée, info-contact : qui répond de votre API est la seule
chose que Crabster ne peut pas savoir. Mettez un contact dans le bloc
info(...) de src/openapi.rs et rallumez-la.
10. Déployer🔗
docker build -t blog .
docker run --rm -p 8123:8123 -e DATABASE_URL=… blog
Quatre étages, dont deux pour le cache de dépendances. L'image finale contient
le binaire et son config/, rien d'autre — pas de shell, pas de gestionnaire de
paquets — et tourne en nonroot.
APP_PROFILE n'y est pas défini, donc prod : le serveur écoute sur toutes les
interfaces, ce qu'un conteneur doit faire pour être joignable.
Avec la base, en local — PostgreSQL et MySQL seulement : un projet SQLite
n'a pas de docker-compose.yml, puisqu'il n'y a pas de serveur à démarrer.
docker compose up -d --wait # la base seule
docker compose --profile app up # les deux
Le profil est ce qui garde la première commande utile : pendant que vous
travaillez, vous voulez le serveur depuis cargo run.
Pour un projet SQLite, donnez au conteneur un répertoire où garder son fichier :
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 fait tourner le formateur, Clippy en -D warnings,
les tests contre la base visée, un audit de dépendances et une construction de
l'image. Aucun secret, aucun réglage de dépôt. L'image est construite et non
poussée — le workflow dit en commentaire quoi ajouter. Le README généré porte
l'équivalent GitLab.
11. Étendre🔗
Un blueprint change ce qui est généré sans toucher au cœur de Crabster :
crabster import-cdl blog.cdl --blueprint ./mon-blueprint
Quatre choses possibles — remplacer un template, en ajouter un, ajouter un
module, écrire dans un fichier qu'un autre module possède. C'est le sujet
d'Écrire un blueprint, et il y a un exemple
complet dans examples/blueprints/gitlab.
12. Quand ça coince🔗
| Ce que vous voyez | Ce que c'est |
|---|---|
failed to bind …: Address already in use | Le port du bloc service est pris. APP__SERVER__PORT=8123 cargo run |
| Le serveur démarre et rien ne répond | Pas de .env → profil prod, écoute sur 0.0.0.0. Avec .env → dev, sur 127.0.0.1 |
DATABASE_URL is not set | cp .env.example .env |
configuration file "config/default" not found | Le binaire lit config/ relativement au répertoire courant. Lancez-le depuis le projet, ou copiez config/ à côté de lui — c'est ce que fait l'image |
crabster record nomme un fichier et s'arrête | Vous l'avez édité. Lisez le diff avant de penser à --force |
… was generated by another version | Le projet vient d'autres templates. crabster upgrade le fait franchir la version, sans toucher aux fichiers que vous avez édités |
| Les tests demandent Docker | Projet PostgreSQL ou MySQL. Démarrez Docker, ou TEST_DATABASE_URL=… |
`X` is not a setting any installed module answers to | Réglage service inconnu. Le message liste ceux qui existent |
Un Decimal revient faux | SQLite. Voir §3 |
13. Référence rapide🔗
crabster new [<nom>] [--database postgres|mysql|sqlite] [--port N] [--no-input]
[--path DIR] [--blueprint DIR] [--with M] [--without M] [--check]
crabster new [<nom>] --architecture microservices
[--gateway NOM[:PORT]] [--service NOM[:BASE[:PORT]]]…
[--discovery consul] [--telemetry otlp] [--path DIR]
crabster import-cdl <fichier.cdl> [--database …] [--port N]
[--path DIR] [--blueprint DIR] [--with M] [--without M] [--check]
crabster record <Nom> --field "nom: Type" [--field …] [--filterable]
[--path DIR] [--blueprint DIR] [--force] [--check]
crabster introspect <URL> [--out FICHIER] [--service NOM]
--check lance cargo check avant d'annoncer un succès : sûr, et lent d'une
minute la première fois.
Pour aller plus loin🔗
| Page | Ce qu'elle apporte |
|---|---|
| Langage CDL | Le langage en entier, et ce qu'il refuse |
| Écrire un blueprint | Étendre la génération |
| Architecture technique | Pourquoi chaque choix a été fait |
| Vision et objectifs | Ce que Crabster essaie d'être |
| Périmètre V2 | Ce qui vient après |
| {% endraw %} |