crabster

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 :

CommandeCe 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.

ÉcritSignification
author: ref AuthorPlusieurs billets par auteur, référence obligatoire
author: ref Author?Idem, mais un billet peut n'en avoir aucun
author: ref Author @uniqueUn-à-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🔗

CDLRustNotes
TextStringVARCHAR. Sans borne haute : 255
LongTextStringTEXT, non borné
Int Longi32 i64
Float Doublef32 f64
DecimalDecimal(19, 4). Inexact sur SQLite — voir plus bas
Boolbool
Date TimestampDate DateTimeUtcTimestamp devient DATETIME sur MySQL
UuidUuid
BytesVec<u8>BLOB ; en JSON, un tableau d'entiers

Decimal sur SQLite n'est pas exact. La valeur revient à travers un f64 : sqlx refuse délibérément rust_decimal sur SQLite. Aucun type de colonne n'y change rien. Un modèle qui stocke de l'argent en production vise PostgreSQL ou MySQL.

Attributs🔗

AttributSurEffet
@uniqueun champUnicité en base ; sur un ref, un-à-un
@length(2..120)du texteBornes ouvertes possibles : @length(..200)
@range(0..1000)des nombresMême forme
@matches("regex")du texteCompilée à l'analyse : une regex invalide est refusée ici
@filterableun enregistrementLes 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églageValeursDéfaut
databasepostgres, mysql, sqlitepostgres
port1–655358080
authjwtabsent

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éthodeCheminRépond
GET/api/<records>?page=0&size=20Une 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érateurSurExemple
(le nom seul), equalstout champ filtrable?status=PAID
notEqualsidem?status.notEquals=CANCELLED
containsdu texte court?label.contains=basse
greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqualnombres, décimaux, dates, horodatages?price.lessThan=100
specifiedtout 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 envoieRéponse
If-Match avec la version courante200, et un nouvel ETag
If-Match avec une version dépassée412, /problems/stale-version
Rien du tout428, /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.

StatutQuand
400Corps illisible, ou paramètre de requête invalide
404Pas d'enregistrement, ou pas d'endpoint
405Ce chemin existe sous une autre méthode
409Valeur unique prise, ou référence qui ne tient pas
413 415Corps trop grand, ou pas envoyé en JSON
422Le JSON a été lu et le modèle le refuse

422 et non 400 pour un corps refusé : 400 dit « je n'ai pas pu lire ceci », 422 dit « 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 :

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 changezCe qui se passe
Un champ facultatif de plusADD COLUMN
Un champ obligatoire de plusil faut dire quoi mettre dans les lignes existantes : --default "Customer.nickname=inconnu"
@unique ajoutéun index unique
Un champ de moinsDROP COLUMN, et seulement avec --force : la colonne part avec ses valeurs
Un enregistrement de moinsDROP TABLE, --force aussi
Un enregistrement de plussa migration de création, comme crabster record
Rendre un champ facultatif ou obligatoirePostgreSQL et MySQL modifient la colonne ; SQLite reconstruit la table
Une borne @length plus largesur PostgreSQL et MySQL, la colonne s'élargit avec elle ; sur SQLite rien n'est écrit
Une borne @length plus étroitepareil, 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 plusPostgreSQL 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 → LongTextla 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 pasPourquoi
enum et ses variantesUne énumération est stockée en texte : les variantes sont dans les lignes
@filterableLes champs par lesquels une liste se restreint sont une décision d'API, dont une table ne garde aucune trace
@matches, @rangeVérifiés à l'entrée, avant que quoi que ce soit soit stocké
Le bloc serviceUn port, un module d'authentification et un nom de base décrivent un déploiement
La borne inférieure d'un @lengthUne 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'empreinteCe qui se passe
Le fichier est exactement celui que Crabster avait écritil est réécrit avec la nouvelle version
Vous l'avez édité, et les deux modifications ne se marchent pas dessuselles 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 changentil 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 toutil est supprimé — sauf si vous l'aviez édité, auquel cas il reste
C'est une migration déjà écriteil 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.rs sans 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 --force ne l'ouvre pas. Un module déclare les siens dans son module.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 premier upgrade écrit la copie, donc le suivant fusionne.


6. Configuration🔗

En couches, chacune écrasant la précédente :

  1. config/default.toml — les valeurs par défaut, committées
  2. config/{profil}.toml — APP_PROFILE choisit
  3. les variables APP__* — APP__SERVER__PORT=9000

Un APP_PROFILE absent vaut prod, et c'est délibéré : un conteneur déployé sans la variable ne doit pas démarrer lié à la boucle locale. .env.example sélectionne dev, et cp .env.example .env est la première ligne du README généré.

CléCe que c'est
server.host server.portAdresse d'écoute
logging.levelÉcrasé par RUST_LOG
http.timeout_secondsAu-delà, la requête répond 408
http.max_concurrent_requestsCombien peuvent tourner en même temps
http.max_body_bytesCe qu'une requête peut faire allouer
http.cors_allowed_originsVide = 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 :


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éthodeCheminRépond
POST/auth/register201 et le compte. Mot de passe ≥ 12 caractères
POST/auth/loginUn jeton d'accès, un de rafraîchissement, et la durée du premier
POST/auth/refreshUne nouvelle session, rôles relus depuis le compte
GET/auth/meLe compte de cette session
GET/auth/usersTous 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🔗

CheminRépond
/health/live200 tant que le processus sert. Ne joint rien
/health/ready200 quand tout ce dont le service dépend répond, 503 sinon — en nommant quoi
/metricsCompteurs, latences, requêtes en vol, au format Prometheus
/api-docs/openapi.jsonLe document OpenAPI 3.1
/swagger-uiUne 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 voyezCe que c'est
failed to bind …: Address already in useLe port du bloc service est pris. APP__SERVER__PORT=8123 cargo run
Le serveur démarre et rien ne répondPas de .env → profil prod, écoute sur 0.0.0.0. Avec .env → dev, sur 127.0.0.1
DATABASE_URL is not setcp .env.example .env
configuration file "config/default" not foundLe 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êteVous l'avez édité. Lisez le diff avant de penser à --force
… was generated by another versionLe projet vient d'autres templates. crabster upgrade le fait franchir la version, sans toucher aux fichiers que vous avez édités
Les tests demandent DockerProjet PostgreSQL ou MySQL. Démarrez Docker, ou TEST_DATABASE_URL=…
`X` is not a setting any installed module answers toRéglage service inconnu. Le message liste ceux qui existent
Un Decimal revient fauxSQLite. 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🔗

PageCe qu'elle apporte
Langage CDLLe langage en entier, et ce qu'il refuse
Écrire un blueprintÉtendre la génération
Architecture techniquePourquoi chaque choix a été fait
Vision et objectifsCe que Crabster essaie d'être
Périmètre V2Ce qui vient après
{% endraw %}