Langage CDL
Crabster Domain Language
{% raw %}
CDL est la façon de décrire un domaine à Crabster. Un seul fichier .cdl
contient tout : les enregistrements à persister, les énumérations qu'ils
utilisent, les références entre eux, et les réglages du service qui les sert.
Statut : v0, implémenté. Tout ce qui est documenté ici s'analyse, se génère, et est exercé en CI via
examples/shop.cdl. Ce que CDL ne supporte pas est refusé avec un message qui le dit — le langage n'accepte jamais ce que le générateur ignorerait en silence.
1. Principes de conception🔗
- Un fichier, une source de vérité. Un
.cdlse versionne, se relit en revue de code, et se régénère de façon déterministe. - Dire le cas courant en n'écrivant rien. Un champ est obligatoire sauf mention contraire ; une liste est paginée et ordonnable ; les DTO sont toujours générés. Les options n'existent que là où elles changent la forme de l'API.
- Refuser plutôt qu'ignorer. Une contrainte que le générateur ne peut pas honorer est une erreur à l'analyse, située et expliquée — pas une omission silencieuse découverte plus tard en production.
- Validation statique avant génération. Une référence vers un enregistrement inexistant, un cycle, un nom qui ne pourrait pas devenir du Rust : tout est détecté avant qu'un seul fichier ne soit écrit.
2. Un exemple complet🔗
C'est examples/shop.cdl, généré et exécuté par la suite de tests.
service shop_api {
database sqlite
port 9000
}
enum OrderStatus { PENDING, PAID, SHIPPED, CANCELLED }
@filterable
record Product {
reference: Text @unique
label: Text @length(..200)
summary: LongText?
price: Decimal
available: Bool
releasedOn: Date?
sku: Uuid?
}
record Customer {
email: Text @unique @matches("^[^@]+@[^@]+$")
fullName: Text @length(2..120)
loyalty: Int? @range(0..1000)
signedUpAt: Timestamp
newsletter: Bool?
}
record Address {
line1: Text @length(..200)
postcode: Text @length(..16)
country: Text
customer: ref Customer @unique // une adresse par client
}
@filterable
record Order {
placedAt: Timestamp
status: OrderStatus
total: Decimal
customer: ref Customer // référence obligatoire
product: ref Product? // référence facultative
}
3. Enregistrements et champs🔗
record Product {
label: Text
}
Un champ s'écrit nom: Type, éventuellement suivi de ? et d'attributs.
Chaque enregistrement reçoit son propre id ; vous ne le déclarez jamais, et un
champ portant ce nom est refusé — de même qu'un champ dont la colonne
s'appellerait table, dont la génération a besoin pour nommer la table en
migration. C'est bien la colonne qui est examinée : table: ref B devient
table_id et passe.
Obligatoire par défaut🔗
Un champ doit contenir une valeur sauf s'il se termine par ?. Le cas
courant est celui qu'on ne devrait pas avoir à écrire.
label: Text // doit être présent
summary: LongText? // peut être absent, et peut être effacé plus tard
La distinction va jusqu'à l'API. Sur une mise à jour, une clé absente laisse la
valeur en place tandis qu'un null explicite l'efface — ce qui n'est possible
que pour un champ que le modèle autorise à être vide.
4. Types🔗
| Type CDL | Type Rust | Type SQL (PostgreSQL) |
|---|---|---|
Text | String | VARCHAR |
LongText | String | TEXT |
Int | i32 | INTEGER |
Long | i64 | BIGINT |
Float | f32 | REAL |
Double | f64 | DOUBLE PRECISION |
Decimal | Decimal | NUMERIC |
Bool | bool | BOOLEAN |
Date | Date | DATE |
Timestamp | DateTimeUtc | TIMESTAMPTZ |
Uuid | Uuid | UUID |
Bytes | Vec<u8> | BYTEA |
Trois de ces types ne se rendent pas de la même façon partout, et c'est la base qui décide :
DecimaldevientNUMERIC(19, 4)sur PostgreSQL etDECIMAL(19, 4)sur MySQL — quinze chiffres avant la virgule, quatre après. Sur SQLite, il n'est pas exact : la valeur revient à travers unf64. Ce n'est pas un choix de Crabster et aucun type de colonne n'y change quoi que ce soit — sqlx refuse délibérémentrust_decimalsur SQLite, et SeaORM lit un flottant puis convertit. SQLite reste ce que la vision en dit, une base de développement et de tests ; un modèle qui stocke de l'argent en production vise PostgreSQL ou MySQL.TimestampdevientDATETIMEsur MySQL, qui n'a pas d'équivalent deTIMESTAMPTZ. SonTIMESTAMPs'arrête en 2038 et se décale selon le fuseau de la session ;DATETIMEcouvre 1000-9999 et stocke ce qu'on lui donne.Bytesdevient unBLOB, stocké hors ligne, plutôt qu'une colonne bornée qui entrerait dans le budget de 65535 octets d'une ligne MySQL.
Un champ peut aussi nommer une énumération déclarée n'importe où dans le
fichier, ou un autre enregistrement via ref.
5. Attributs🔗
Les attributs qualifient ce qui les précède, sur la même ligne. Un attribut de champ écrit sur la ligne du dessous se rattacherait au champ précédent ; il est donc refusé, en nommant le champ qu'il aurait qualifié. Seul l'attribut d'un enregistrement s'écrit au-dessus de sa déclaration — c'est ce qui rend la confusion facile, et c'est pourquoi elle est signalée plutôt que devinée.
record Customer {
name: Text
@unique // refusé : cela rendrait `name` unique, pas `email`
email: Text
}
| Attribut | Sur | Effet |
|---|---|---|
@unique | un champ | Unicité en base ; sur un ref, en fait un un-à-un. Deux refus ne valent que si le projet vise MySQL : sur LongText ou Bytes, qu'il ne sait pas indexer sans longueur de préfixe, et sur un Text borné au-delà de 768 caractères, sa clé d'index s'arrêtant à 3072 octets et utf8mb4 comptant quatre octets par caractère. PostgreSQL et SQLite acceptent les deux |
@length(2..120) | du texte | Borne la longueur. Chaque extrémité peut rester ouverte : @length(..200), @length(3..). Un champ Text sans borne haute reçoit 255, la limite que MySQL appliquait déjà et désormais les trois ; une borne basse de 255 ou plus exige donc une borne haute, car relever le défaut pour l'atteindre transformerait « au moins 255 » en « exactement 255 » |
@range(0..1000) | des nombres | Borne la valeur, même forme ouverte |
@matches("regex") | du texte | La valeur doit correspondre. L'expression est compilée à l'analyse : une expression invalide est refusée ici plutôt que de faire paniquer le serveur livré |
@filterable | un enregistrement | Les listes acceptent des filtres en query string, sur tous les champs sauf le texte long, le binaire et les références — ceux-là n'admettent que specified, s'ils sont facultatifs. Refusé s'il ne reste aucun champ filtrable, ou si l'un d'eux s'appellerait page, size, sort ou order — les paramètres que toute liste lit déjà. Les opérateurs sont détaillés dans le guide |
@versioned | un enregistrement | Verrouillage optimiste. L'enregistrement gagne une colonne version ; ses lectures répondent un ETag, et toute écriture doit envoyer If-Match avec la version qu'elle a lue. Une version périmée reçoit 412, une écriture sans condition 428. Sans lui, deux clients qui modifient la même ligne s'écrasent en silence |
@audited | un enregistrement | L'enregistrement gagne createdAt et updatedAt, écrites par le code au moment de l'écriture — pas par la base, pour que les trois se comportent pareil |
@roles(ADMIN, …) | un enregistrement | Seuls ces rôles atteignent ses endpoints. Voir plus bas |
@reads(…) / @writes(…) | un enregistrement | La même chose, séparée : qui peut lire, et qui peut créer, modifier ou supprimer |
Les colonnes que ces deux attributs ajoutent (version, createdAt,
updatedAt) apparaissent dans les réponses et jamais dans ce qu'un client
envoie : c'est le compte que le projet tient de ce qu'il a fait. Un champ
déclaré sous l'un de ces noms sur un enregistrement qui porte l'attribut est
refusé, en nommant l'attribut.
Les ajouter à un enregistrement qui existe déjà est un changement de modèle
comme un autre : crabster apply en fait des migrations, et remplit les lignes
déjà là — version zéro, et la date du moment pour les deux horodatages.
Un attribut qui ne peut rien signifier sur son champ est refusé plutôt
qu'ignoré : @length sur un Int, @range sur du Text, et sur un ref
tout sauf @unique — la seule qui y ait un sens, celui d'un un-à-un (§6).
Une longueur négative est refusée, et une longueur au-delà de 65535 aussi :
c'est le nombre d'octets qu'une ligne MySQL peut porter, la plus contraignante
des trois bases visées. En utf8mb4 un caractère en coûte jusqu'à quatre, si bien
que le vrai plafond d'un Text visant MySQL est de 16383 caractères — refusé
à la génération, où la base est connue, et non à l'analyse. @length(..0) l'est également, que PostgreSQL rejette en
varchar(0) alors que SQLite l'ignore — les tests générés passeraient et seule
la production protesterait.
Une borne @range doit tenir dans le type du champ : @range(0..3000000000)
sur un Int produit un littéral qu'un i32 ne peut pas contenir. Utilisez
Long.
@range sur un Decimal est refusé aussi, en disant pourquoi : la
bibliothèque de validation n'a pas de contrôle de bornes pour un décimal de
précision arbitraire, donc la contrainte produirait du code qui ne compile pas.
Utilisez Double si l'intervalle compte plus que la précision.
Qui peut atteindre un enregistrement🔗
L'autorisation se déclare sur l'enregistrement, elle ne s'écrit pas dans le
handler. Ce qui protège une route aujourd'hui est une modification de son corps
— claims.require_role("ADMIN") — et une modification de corps est quelque
chose qu'une modification ultérieure peut faire disparaître sans que rien ne le
remarque.
service api {
database postgres
auth jwt
}
roles { ADMIN, MANAGER, USER }
// Une garde unique sur tout l'enregistrement.
@roles(ADMIN)
record Invoice {
total: Decimal
}
// Ou séparée, ce qui est la forme courante : beaucoup regardent, peu modifient.
@reads(USER, MANAGER, ADMIN)
@writes(ADMIN)
record Product {
label: Text
price: Decimal
}
// Et un enregistrement qui ne nomme personne n'est restreint par rien.
record Note {
body: Text
}
Plusieurs rôles signifient n'importe lequel suffit. Un appelant portant
MANAGER atteint la liste de Product ; un appelant n'en portant aucun reçoit
403, et un appelant sans jeton du tout reçoit 401 — la différence compte,
car l'un dit dites qui vous êtes et l'autre vous n'avez pas le droit.
Tout rôle doit être déclaré, et roles { … } est l'endroit. Un rôle qui
n'apparaîtrait que sur un enregistrement ferait de @writes(ADMN) une ressource
que personne ne peut atteindre plutôt qu'un refus — et une garde qui enferme
tout le monde dehors ressemble exactement à une garde voulue. C'est donc refusé,
avec la ligne :
line 5, column 1: `Ledger` is guarded by `ADMN`, and no `roles` block declares
it. Declared: ADMIN, MANAGER.
Une garde a besoin de quelque chose à vérifier. Un modèle qui en déclare une
dans un projet sans réglage auth est refusé avant que rien ne soit écrit : un
rôle se lit dans le jeton d'un appelant, et il n'y a pas de jeton sans module
d'authentification. Lequel importe peu : auth jwt et auth oauth2 portent
tous deux des rôles, et le même enregistrement produit la même garde sous l'un
comme sous l'autre.
Ce qui est généré est un paramètre du handler plutôt qu'une ligne dans son corps — un handler qui ne prend pas sa garde ne compile pas contre la route qui en a besoin. Les tests générés portent un jeton qui a le droit, et un test de plus par enregistrement gardé demande ce qu'il advient d'un appelant qui ne l'a pas.
D'où viennent les rôles eux-mêmes regarde le module d'authentification :
auth jwt les conserve sur le compte, auth oauth2 les lit dans une claim du
jeton du fournisseur — realm_access.roles pour Keycloak, et configurable, car
deux fournisseurs ne s'accordent jamais.
6. Références🔗
Une référence est un champ, pas une déclaration séparée. On l'écrit là où la clé étrangère existe physiquement, et l'inverse est déduit.
record Customer { fullName: Text }
record Order {
customer: ref Customer // cette commande pointe vers un client
}
Cette seule ligne donne à la table order une colonne customer_id, une
contrainte de clé étrangère, et les relations des deux côtés — un Order
appartient à un Customer, un Customer a plusieurs Order. Il n'y a rien à
déclarer sur Customer.
| Écrit | Signification |
|---|---|
customer: ref Customer | Plusieurs commandes par client ; référence obligatoire |
customer: ref Customer? | Idem, mais une commande peut n'en avoir aucune |
customer: ref Customer @unique | Un-à-un : au plus une ligne par client |
Un enregistrement peut se référencer lui-même — c'est ainsi qu'on écrit un arbre :
record Category {
name: Text
parent: ref Category? // facultatif : la racine n'a pas de parent
}
L'auto-référence doit être facultative : la première ligne insérée n'aurait rien vers quoi pointer.
?expand=parent répond la ligne pointée, sur un seul niveau — la catégorie
dépliée porte son propre parentId et non la catégorie qui est derrière. Un
arbre se parcourt en redemandant, pas en demandant le tout d'un coup.
Deux références vers le même enregistrement sont acceptées — from: ref Account et to: ref Account — mais l'inverse n'est alors pas généré : SeaORM l'exprime par un Related, qui ne peut exister qu'une fois par cible. Les deux relations directes restent, et ce sont elles dont une requête a besoin.
Plusieurs services dans un fichier🔗
Plus d'un bloc service décrit plus d'une application, et @service dit à
laquelle un enregistrement appartient :
service orders { database postgres port 8101 }
service billing { database postgres port 8102 }
@service(orders)
record Order { placedAt: Timestamp }
@service(billing)
record Invoice { total: Decimal }
crabster import-cdl génère alors un projet par service, côte à côte, sous
une racine qui porte un docker-compose.yml démarrant l'ensemble d'un coup et
un README.md disant qui répond où. Chaque service est un projet Crabster
ordinaire — il enregistre un modèle de lui-même,
donc crabster record, apply et upgrade y fonctionnent exactement comme sur
un projet généré seul. Générer un service parmi d'autres et le générer seul
produisent les mêmes fichiers, octet pour octet.
Un service qui dit kind gateway est une passerelle devant les autres
plutôt qu'une application :
service edge { kind gateway port 8100 }
Elle n'a ni base ni enregistrements — une passerelle qui possèderait des
enregistrements serait un service — et elle transmet /api/<segment> au service
qui possède ce segment, en-tête Authorization compris. Sa table de routage est
générée depuis le modèle dans son propre config/default.toml, et elle vous
appartient ensuite : une adresse est un fait de déploiement. Un chemin que
personne ne revendique est un 404 de la passerelle elle-même, ce qui est là où
une erreur de routage doit se voir.
Un service qui dit discovery consul s'enregistre dans un catalogue :
service orders { database sqlite port 8101 discovery consul }
service edge { kind gateway port 8100 discovery consul }
@service(orders)
record Order { placedAt: Timestamp }
Le partage est la conception. Quels chemins un service possède vient du modèle et ne change pas : c'est généré dans la table de la passerelle. Où ce service se trouve change à chaque déploiement : la passerelle le demande à Consul — et retombe sur l'adresse générée quand le catalogue n'a rien de passant, car un catalogue en panne ne doit pas emporter le système avec lui.
L'enregistrement est répété plutôt que fait une fois, donc un Consul qui
redémarre retrouve les services en quelques secondes ; le contrôle de santé
qu'il exécute est /health/ready, qui atteint tout ce dont le service dépend.
Un service qui ne joint pas Consul le signale et continue de servir : un service
que rien ne découvre est mauvais, un service qui refuse de démarrer parce qu'un
catalogue est tombé est pire.
Un service qui dit config consul lit une partie de ses réglages dans un
magasin clé-valeur :
service orders { database sqlite port 8101 config consul }
record Order { placedAt: Timestamp }
Chaque clé sous config/<service>/ devient un réglage, le séparateur du magasin
faisant l'imbrication — config/orders/server/port est server.port. La couche
se place sous les fichiers livrés avec le projet et au-dessus de rien : une
valeur posée pour la flotte bat un défaut que personne n'a choisi pour ce
déploiement, et APP__… dans l'environnement la bat à son tour — une machine
peut donc toujours différer sans écrire dans un magasin que tout le monde lit.
C'est lu une fois, au démarrage : un changement atteint un service quand ce service redémarre. Un magasin injoignable n'empêche pas le démarrage — le service se lève sur ses fichiers et son environnement, et le dit.
Un service qui dit session cookie permet à un navigateur de se connecter :
service shop_api {
database postgres
auth oauth2
session cookie
}
roles { ADMIN, USER }
@roles(USER, ADMIN)
record Note { body: Text }
auth oauth2 seul fait de ce projet un serveur de ressources : ce qui arrive est
un jeton au sujet de quelqu'un, et celui qui l'a envoyé a dû l'obtenir
d'abord. Un navigateur ne le peut pas — il n'a nulle part où garder un jeton en
sûreté, et un jeton d'accès à portée de JavaScript est à un script tiers de
devenir celui de quelqu'un d'autre.
Le navigateur reçoit donc un cookie, et ce service garde le jeton. C'est le motif backend-for-frontend, et c'est ce qui lève l'exclusion des flux à session posée en V1 : la session vit ici, pas dans une interface.
Trois routes viennent avec. /auth/login envoie le navigateur chez le
fournisseur en Authorization Code + PKCE ; /auth/callback est là où il
revient, et où le code est échangé ; /auth/logout termine la session de ce
côté-ci plutôt que de demander au navigateur d'oublier. Le cookie est
HttpOnly, SameSite=Lax et Secure hors du profil dev, et il ne porte
qu'un identifiant — les jetons restent dans une table ici, si bien qu'un script
tiers n'a rien à voler.
Le reste du projet ignore tout cela. Une requête portant un cookie de
session se voit poser son en-tête Authorization avant d'atteindre une route :
toute garde @roles et tout handler écrit pour un jeton porteur fonctionnent
avec un cookie, sans changement. Il n'y a qu'un endroit qui décide qui appelle,
et ceci n'en ajoute pas un second.
Ce qui fait refuser un rappel mérite d'être su : celui qui arrive sans le cookie
ayant commencé la connexion, ou avec un state qui n'est pas celui que la
session de ce cookie détient, est refusé — c'est cet appariement qui empêche
qu'un rappel étranger s'achève dans votre navigateur. Et ?next= n'est suivi
que s'il désigne un chemin de ce site, une redirection ouverte étant la façon
dont une page d'hameçonnage emprunte un domaine le temps d'un clic.
Un service qui dit client typescript génère un client typé à côté de
l'API :
service shop_api {
database sqlite
client typescript
}
record Product { label: Text price: Decimal }
Il atterrit dans clients/typescript/, forme un paquet npm publiable, et ne
dépend de rien — fetch est présent dans tous les environnements qu'il vise. Il
est généré depuis le même modèle que le serveur : ses types et les réponses de
l'API s'accordent par construction et non parce que quelqu'un les tient en
phase. Changez le modèle, régénérez, les types suivent.
Il n'est pas produit en lisant le document OpenAPI que ce même modèle engendre. Les deux s'accorderaient de toute façon, et l'un serait une seconde implémentation du premier avec un parse JSON et un générateur de code entre les deux — l'un comme l'autre peuvent se tromper. Ce qui les tient ensemble, c'est que le client généré est compilé, et confronté au serveur qui tourne.
Trois correspondances méritent d'être connues, parce que ce sont celles qu'on
devine mal. Un Decimal est une chaîne — passé par un number JavaScript
il cesserait d'être à précision arbitraire, donc l'API envoie "42.50" entre
guillemets. Un champ facultatif est présent et nul, pas absent : vérifiez la
valeur, pas la clé. Et une référence est un nombre, l'identifiant ; l'objet
référencé n'apparaît sous son nom que si expand l'a demandé.
client rust en génère un en Rust, sous clients/rust/ — un crate à lui, avec
son propre [workspace], si bien qu'il se construit là où il est et qu'un
service voisin en dépend par chemin. C'est ce second usage qui en fait autre
chose que le client TypeScript dans une autre langue : un système généré par
crabster import-cdl est fait de services qui s'appellent, et c'est avec cela
que l'un appelle l'autre. C'est aussi ce qui permet à un test d'atteindre cette
API par HTTP plutôt que par son propre routeur.
Il nomme directement les crates derrière les types — rust_decimal, chrono,
uuid — et seulement ceux que le modèle réclame. Pas le prélude de SeaORM, qui
est la façon dont le serveur les écrit : dépendre d'un ORM pour tenir une date
ferait porter un pilote de base de données à tout appelant de cette API.
client both génère les deux. Un réglage nomme une décision, et vouloir un
client TypeScript pour le navigateur et un client Rust pour le service d'à côté
est une décision comme une autre.
Un enregistrement ne peut pas porter le nom d'un type que déclare un client —
Listing, Problem, Page, Direction, Client — et il est refusé avec la
ligne où il est écrit, plutôt que sous forme de redéfinition dans un fichier que
son auteur n'a pas écrit. Uniquement là où le client est demandé : un nom que
client-rust réclame est libre dans un projet qui ne l'a pas. Order n'en fait
délibérément pas partie, et c'est pourquoi le sens de tri s'appelle
Direction : un enregistrement nommé Order est le premier que déclarent la
moitié des modèles du monde, et un type de client ne prend pas un nom auquel le
modèle a plus droit.
C'est l'ADR-0003 en un réglage : typage de bout en bout et plus de client HTTP écrit à la main, sans que le cœur porte un framework UI à travers ses cycles de rupture.
Un service qui dit telemetry otlp exporte ses spans vers un collecteur :
service edge { kind gateway port 8100 telemetry otlp }
service orders { database sqlite port 8101 telemetry otlp }
@service(orders)
record Order { placedAt: Timestamp }
Tout projet généré journalise déjà un identifiant de requête, et c'est de la
corrélation : quelles lignes de journal appartiennent à une même requête.
Ceci relève de la causalité : quel appel a causé quel autre, et combien de
temps chacun a pris. Les deux voyagent séparément, et c'est voulu. Un
identifiant de requête s'adresse à un humain qui lit des journaux et peut être
n'importe quoi ; le contexte de trace voyage dans traceparent, a la forme
définie par le W3C, et c'est lui qu'un collecteur recoud en une trace.
Un service lit traceparent s'il est là et ouvre une trace à lui s'il ne l'est
pas : la bordure du système ne demande donc aucun cas particulier. Une
passerelle transmet son propre contexte, pas celui de l'appelant : relayer
l'en-tête entrant tel quel ferait du service situé derrière un frère de la
passerelle plutôt qu'un enfant, et une trace qui enregistre trois frères a perdu
ce pour quoi elle existait.
L'échantillonnage est un réglage, et config/prod.toml l'abaisse — tout
exporter est juste pendant qu'on construit un système et faux dès qu'il sert,
car le coût se paie sur le chemin de chaque requête. Une décision déjà prise
en amont est respectée plutôt que reprise : un service qui rejoue le tirage
contredit son voisin sur la même requête, et ce qu'il en reste est une trace
trouée là où le service le plus discret se trouvait. Le taux ne décide que des
traces qui naissent ici.
Un collecteur injoignable coûte un export raté dans un fil de fond et rien sur
le chemin de la requête. Rien ne l'attend, aucun service n'en dépend pour
démarrer. Avec crabster import-cdl sur un système, il en est démarré un à côté
des services, avec une interface sur localhost:16686.
Deux règles vont avec, refusées en le nommant l'une comme l'autre :
- Avec plusieurs services, chaque enregistrement doit dire auquel il
appartient. Avec un seul, « le seul » est la réponse et
@serviceest inutile. - Une référence ne peut pas traverser d'un service à l'autre. Une référence
devient une clé étrangère déclarée dans un
CREATE TABLE, et deux services sont deux bases : aucune clé ne traverse, et aucun ordre de migration n'enjambe les deux. Portez l'identifiant comme un champ ordinaire, et laissez les deux services s'accorder sur ce qu'il signifie. C'est une décision sur un système distribué et non sur une colonne, donc elle vous revient.
Les migrations sont ordonnées pour créer d'abord la table référencée, puisque
la clé étrangère est déclarée dans le CREATE TABLE — seule forme que
toutes les bases acceptent. Un cycle de références est refusé, car aucune
table ne pourrait alors être créée avant les autres ; passez plutôt par un
enregistrement à vous.
Le plusieurs-à-plusieurs n'a pas de syntaxe. Il exige une table de jointure que Crabster ne génère pas, et offrir un mot-clé pour quelque chose qui ne produit rien serait pire que son absence. Modélisez-le par un enregistrement portant deux références.
7. Énumérations🔗
enum OrderStatus { PENDING, PAID, SHIPPED, CANCELLED }
Les valeurs sont utilisées exactement telles qu'écrites, en base comme en
JSON. Un modèle qui déclare PENDING obtient une API qui accepte et renvoie
"PENDING", pas "Pending".
Stockées en texte, ce qui est portable sur toutes les bases et permet d'ajouter une valeur sans migration de schéma.
8. Le bloc service🔗
service shop_api {
database sqlite
port 9000
}
| Réglage | Valeurs |
|---|---|
database | postgres, mysql, sqlite — trois dialectes SQL |
mongodb — des documents plutôt que des tables. Pas un quatrième dialecte : voir plus bas | |
auth | jwt — ce service possède les comptes : il génère une table, un hachage de mot de passe et /auth/login, et signe ses propres jetons |
oauth2 — un fournisseur d'identité possède les comptes : ni table ni login, et un jeton qu'il a émis est validé ici contre les clés qu'il publie | |
search | meilisearch — un endpoint de recherche plein texte par enregistrement. Voir plus bas |
cache | redis — la lecture d'un enregistrement passe par un cache, et une écriture jette ce qu'elle a changé. Les listes et les lectures dépliées ne sont délibérément pas mises en cache : voir plus bas |
messaging | nats — chaque enregistrement de ce service publie ses changements, et ce service peut s'abonner à ceux d'un autre. Un binaire, client Rust pur, rien d'ajouté à l'image. Voir plus bas |
kafka — la même chose contre Kafka : groupes de consommateurs, rétention, une grappe existante. Le client lie une bibliothèque C, donc l'étage de construction gagne CMake et une chaîne C++ | |
kind | gateway — ce service ne persiste rien et relaie /api/<segment> vers celui qui possède ce segment. Un modèle qui en contient un est un système |
discovery | consul — le service s'enregistre, et une passerelle demande au catalogue où sont les autres plutôt que de se fier aux adresses de sa génération |
config | consul — une couche de réglages lue au démarrage dans le magasin clé-valeur, sous les fichiers et au-dessus de rien |
telemetry | otlp — les spans exportées vers un collecteur |
client | rust, typescript — un client typé engendré depuis l'API de ce service |
ui | react, vue, angular — une interface par-dessus ce client, une page par enregistrement. Tire client typescript que vous l'ayez demandé ou non |
session | cookie — une session de navigateur tenue côté serveur, pour un front qui ne doit pas garder de jeton en JavaScript |
deploy | kubernetes — des manifestes Kubernetes dans k8s/ et le même déploiement en chart Helm dans chart/. Rien de spécifique à un fournisseur de cloud : voir k8s/README.md du projet engendré |
port | Le port d'écoute du serveur généré, de 1 à 65535. 0 est refusé : il demande au système un port libre au hasard, ce qu'un fichier de configuration ne veut jamais dire |
Un nom de service est aussi un nom de répertoire. Dans un modèle à plusieurs services, chacun devient un dossier à côté de ceux que la racine écrit pour elle-même — k8s, overlays. Un service portant l'un de ces noms est refusé avant que rien ne soit écrit, en nommant les deux.
Le bloc est facultatif : sans lui, le nom du fichier devient le nom du service,
et les valeurs par défaut sont PostgreSQL sur le port 8080. Les options de la
ligne de commande priment sur ce que dit le fichier — mais une base inconnue
reste refusée, à sa ligne, même si --database allait de toute façon la
remplacer : la faute est dans le fichier.
Les réglages autres que database et port allument un module de génération, et lesquels existent n'est pas quelque chose qu'un parseur peut savoir : un blueprint peut en apporter. Un réglage inconnu n'est donc pas refusé ici mais à la génération, par le crate qui connaît les modules installés — avec sa ligne, sa colonne, et la liste de ce à quoi les modules répondent réellement. --with et --without passent outre, pour essayer un module avant d'écrire le réglage.
Retrouver des enregistrements par leur texte🔗
service shop_api {
database sqlite
search meilisearch
}
record Product {
reference: Text @unique
label: Text @length(2..200)
summary: LongText?
}
Chaque enregistrement gagne GET /api/<chemin>/search?q=…, qui rend la même
page qu'une liste. Un q vide correspond à tout — ce qu'une boîte de recherche
vide devrait montrer — et size est borné à 100 comme celui d'une liste.
Il ignore où les enregistrements sont rangés. Ce qui est indexé est le document que l'API renvoie, et ce qu'une recherche rend est ce même document : cela fonctionne donc sur des tables comme sur des documents sans une ligne de différence, et un enregistrement dans l'index a la forme qu'un appelant connaît déjà.
L'index est écrit à la volée. Une création ou une modification y met l'enregistrement, une suppression l'en retire, sur la requête qui l'a causé. Il n'y a aucune tâche à programmer.
Deux choses méritent d'être sues avant de s'y fier.
L'indexation est au mieux : un échec est journalisé et l'écriture réussit
quand même. Refuser de stocker un enregistrement parce qu'il n'a pas pu être
indexé transformerait une panne de recherche en panne d'écriture, et ce sont les
enregistrements qui comptent. La recherche, elle, ne l'est pas — une recherche
qui n'atteint pas l'index répond 503 plutôt qu'une page vide, car une page
vide se lit « rien ne correspond ».
Et Meilisearch indexe comme une tâche à lui : une recherche faite dans le même souffle qu'une écriture peut être en avance sur elle, d'environ un dixième de seconde. Attendre cette tâche rendrait la phrase exacte et mettrait la latence de l'index sur le chemin de chaque écriture, ce que ce dispositif est fait pour éviter.
Le docker-compose.yml généré en démarre un pour développer, et
APP__SEARCH__ADDRESS et APP__SEARCH__KEY désignent celui de la production.
Prévenir les autres services🔗
service orders {
database sqlite
messaging nats // ou `kafka`
}
Chaque enregistrement de ce service publie ses changements — créé, modifié,
supprimé — sur <service>.<table>.<événement>, en portant le document que l'API
a renvoyé. src/messaging/subjects.rs nomme chacun d'eux en constante : un
publieur ne peut donc pas écorcher un sujet, et un lecteur a la liste.
C'est le canal qui manquait à un système. Une référence ne traverse pas une
frontière de service, donc Invoice porte l'identifiant d'une commande comme un
champ ordinaire et les deux services « se mettent d'accord sur ce qu'il
signifie » — et jusqu'ici rien ne portait cet accord. Une passerelle relaie vers
l'intérieur la requête d'un appelant ; elle ne permet pas à orders de dire à
billing que quelque chose est arrivé.
Publier est au mieux-effort. Un courtier qui a cessé de répondre ne doit pas
faire échouer une écriture déjà réussie : la requête rend son 201 dans les
deux cas. Deux conséquences, écrites dans le src/messaging.rs engendré : NATS
cœur ne garde rien, donc un abonné qui n'était pas connecté ne reçoit pas ce
qu'il a manqué ; et une publication faite pendant que le courtier est injoignable
est perdue si le processus s'arrête avant la reconnexion. Pour des événements qui
doivent y survivre, activez JetStream.
Le courtier n'est délibérément pas une sonde de disponibilité. Un service dont le courtier a disparu répond encore correctement à tout, et le signaler ferait sortir du répartiteur une instance saine à cause d'un canal annexe.
S'abonner est générique. Un service est engendré depuis un modèle restreint
à ses propres enregistrements, pour qu'un service engendré dans un système soit
octet pour octet le même projet que ce service engendré seul. Il n'a donc pas de
type pour l'Invoice d'un autre, et lui en donner un ferait dépendre son contenu
de ses voisins. Déclarez la forme attendue — le document OpenAPI du publieur la
contient — et Messaging::subscribe y désérialise.
Lire deux fois le même enregistrement🔗
service shop_api {
database sqlite
cache redis
}
GET /api/<enregistrement>/{id} regarde d'abord dans Redis et y garde ce qu'il
a dû aller chercher ; une modification ou une suppression jette l'entrée. C'est
tout, et la frontière est précisément l'intérêt.
Une liste n'est pas mise en cache. Elle varie selon la page, la taille, le tri, l'ordre et chaque filtre que le modèle déclare : ses clés sont sans borne — et une écriture sur n'importe quelle ligne peut changer n'importe quelle page de n'importe quel filtre, donc la seule invalidation correcte est de tout jeter. Un cache vidé à chaque écriture est vide quand on le lit.
Une lecture dépliée non plus. ?expand=customer enchâsse un autre
enregistrement, et rien ici ne saurait jeter cette copie quand le client change.
Une valeur périmée de votre propre ligne est bornée par la durée de vie ; une
copie périmée de celle d'un autre, rangée sous votre clé, ne l'est pas.
Il échoue sans gêner. Une lecture qui rate retombe sur la base, une écriture
qui rate est journalisée, et la requête réussit dans les deux cas. Et après un
échec il cesse de demander pendant retry_after_millis — mesuré, parce que sans
cela chaque lecture attendait deux secondes un Redis absent, c'est-à-dire une
panne de cache transformée en panne de latence.
Ce que ça coûte. Un rejet qui échoue laisse une entrée périmée, et rien ne
réessaie. ttl_seconds est ce qui y met fin, et c'est pourquoi c'est un nombre
dans config/default.toml plutôt qu'une constante dans le code : c'est la
réponse à « à quel point une lecture peut-elle être périmée », et seul un
déploiement peut la donner.
Des documents plutôt que des tables🔗
database mongodb est la seule valeur qui ne nomme pas un dialecte SQL, et ce
qu'elle change n'est pas le SQL qu'on écrit :
service shop_api {
database mongodb
}
enum OrderStatus { PENDING, PAID, SHIPPED }
@filterable
@versioned
@audited
record Product {
reference: Text @unique
label: Text @length(2..200)
price: Decimal
status: OrderStatus
}
L'API est la même API. Chaque endpoint, chaque paramètre de requête, chaque
refus et chaque document de problème. Un identifiant y est un nombre lui aussi,
et c'est une décision et non un hasard : celui de MongoDB est un ObjectId,
donc une chaîne, et le laisser passer obligerait chaque lecteur de cette API —
les clients typés, l'interface, le document OpenAPI, les routes d'une passerelle
— à demander où un enregistrement est rangé avant de savoir à quoi ressemble
un identifiant. Une collection counters en distribue par une mise à jour
atomique, et cet aller-retour est le coût entier.
@unique tient, sous forme d'index construit au démarrage plutôt que d'un
CREATE TABLE qu'une collection n'a pas. @versioned tient, la version étant
dans le filtre de la mise à jour : la vérification et l'écriture sont une
seule opération. @filterable tient, ses opérateurs traduits en ceux de BSON.
Ce qui change est ce qu'un magasin de documents n'a pas. Il n'y a pas de migrations : une collection n'a pas de schéma à faire évoluer, donc rien n'est généré pour le faire et rien n'est à tenir en phase. Et une référence est refusée, en la nommant et avec sa ligne :
line 9, column 5: `Order.customer` points at `Customer`, and this model is
stored as documents. A reference becomes a foreign key, declared inside a
`CREATE TABLE` and checked by the database on every write — a document store
has neither.
Ce refus est tout le dessin. Un champ qui ressemble à une référence, qui se
relit comme une référence et que rien ne fait respecter n'échoue pas au moment
où on l'écrit : il échoue bien plus tard, quand les données sont déjà fausses.
Tenez l'identifiant comme un champ ordinaire, et laissez le code qui écrit les
deux les tenir en accord — ce que mongodb décide pour vous de toute façon.
Deux types stockés ne sont pas ceux que le fil transporte, et les deux auraient
échoué en silence. Un Decimal est un Decimal128, si bien que ?sort=price
place 9,50 avant 10,50 et non après. Et Bytes est une chaîne d'octets
plutôt qu'un tableau d'une entrée par octet.
Une base par projet, et pas deux. Il n'existe pas de réglage séparé pour le
développement et pour la production. Le schéma généré n'est pas le même d'une
base à l'autre — un Timestamp est un DATETIME sur MySQL et un timestamptz
ailleurs, un Decimal ne fait pas l'aller-retour sur SQLite — donc développer
sur l'une et déployer sur l'autre, ce serait faire passer ses tests contre un
schéma que la production n'a jamais. Pour développer sans démarrer de serveur,
c'est docker-compose.yml, généré à côté du projet et déjà accordé avec
.env.example.
Écrire deux fois la même chose est refusé plutôt que silencieusement résolu :
un second bloc service, un réglage répété, un attribut posé deux fois sur un
champ. Dans chaque cas le second écrasait le premier sans rien dire.
9. Ce que CDL n'a pas, et pourquoi🔗
- Aucune option pour désactiver la pagination, ni pour choisir le tri. Toute
liste est paginée et ordonnable : elle lit
page,size,sortetordersans qu'aucun attribut ne le demande. Une liste non bornée est un parcours de table à une requête de distance, et une page sans ordre ne veut rien dire — ni l'un ni l'autre ne mérite d'être un choix. Le tri accepte l'id, puis tous les champs sauf le texte long et le binaire, puis les références par leur colonne (customerId) ; une colonne inconnue, ou une direction qui n'est niascnidesc, reçoit un 400 qui énumère ce qui aurait été accepté. Les noms sont ceux que la réponse JSON emploie, pas ceux du modèle. - Aucun interrupteur DTO. Il est toujours généré. Une chose de moins à apprendre, et une branche de moins dans les templates.
- Pas de plusieurs-à-plusieurs, comme ci-dessus.
- Pas de moteur de recherche, pas de description multi-services. Ce sont des phases ultérieures ; voir le plan de travail.
10. Diagnostics🔗
Chaque erreur nomme une ligne, et parle le langage plutôt que la grammaire :
line 2, column 3: `Strng` is not a known type for field `Product.label` —
expected one of Text, LongText, Int, … an enum declared in this file, or `ref`
followed by a record
line 3, column 15: `@uniqe` is not an attribute a field accepts —
expected one of unique, length, range, matches
Un modèle qui s'analyse est aussi un modèle qui peut être généré : les noms qui ne pourraient pas devenir des identifiants Rust, les références vers des enregistrements inexistants et les cycles sont tous détectés avant que quoi que ce soit ne soit écrit.
Les collisions de noms générés🔗
Deux noms différents dans le fichier peuvent converger une fois convertis, et le second écraserait alors le premier. Ces cas sont refusés en nommant le nom généré, pas celui qui a été écrit :
line 2: records `BlogPost` and `blog_post` share a module name —
generation would emit `blog_post` twice, and the second would overwrite the first
Sont vérifiés de cette façon :
- Les modules des enregistrements, y compris ceux que le projet généré
occupe déjà —
mod,page,enums. - Les noms que le module de migration porte déjà. Chaque enregistrement y
devient un
enumdu même nom, à côté dustruct Migrationet de ce que ce fichier importe —Table,ColumnDef,ForeignKey,SchemaManager,DbErret les trois dérives. Un enregistrement qui en prend un masquait l'import en silence, etTable::create()cessait de compiler. La liste est courte et exacte parce que ce module importe par leur nom plutôt que par un glob, tout commedomain/enums.rs. - Les segments d'URL. La mise au pluriel n'est pas injective :
CategoryetCategoriesont tous deux servis sous/api/categories, et deux enregistrements sur un même chemin font paniquer le serveur au démarrage. - Les types Rust. Deux énumérations qui convergent sur un nom, ou une énumération qui converge avec un enregistrement — le module de chaque enregistrement importe les énumérations, ils partagent donc un espace de noms.
- Les trois noms que devient un champ : la colonne (
owner_id, queownerIdatteint aussi), la variante de colonne en migration (X1, quex1etx_1atteignent tous deux) et la clé JSON. - Les relations d'un enregistrement, dont le nom vient du champ à l'aller et de l'enregistrement qui pointe au retour.
- Les variantes d'énumération, et les idens de migration qu'un
enregistrement déclare pour les tables qu'il référence — face à son propre
iden comme entre eux.
PascalCasen'est pas injectif non plus : un enregistrement référençant à la foisX1etX_1ne déclarerait qu'unReferencedX1pour les deux, et la seconde clé étrangère pointerait sur la table de la première.
Trois vérifications ont lieu à la génération et non ici, parce qu'elles
dépendent de la base visée — choisie par le bloc service ou par --database,
que le parseur ne lit ni l'un ni l'autre. Deux portent sur @unique en MySQL et
sont décrites au §5 ; la troisième est celle-ci :
- Les noms de contraintes de clé étrangère. MySQL les exige uniques dans
tout le schéma, pas par table, si bien que
AbC { d: ref T }etAb { cD: ref T }se rejoignent surfk_ab_c_d_id. PostgreSQL les porte par table et SQLite les ignore : le même modèle se génère sans rien dire sur l'une comme sur l'autre.
Une énumération ne peut pas porter un nom qu'un module généré occupe déjà : il s'y retrouverait à la fois déclaré et importé. Deux modules sont concernés, et le message dit lequel :
- Ce que le module de chaque enregistrement déclare, via
DeriveEntityModel—Model,Entity,Column,Relation,PrimaryKey,ActiveModel,ActiveModelBehavior. Un module d'enregistrement importe par leur nom les énumérations qu'il utilise :enum Modelarriverait donc à côté du type de ligne de l'enregistrement. - Ce que
domain/enums.rsimporte à côté des énumérations qu'il déclare —DeriveActiveEnum,EnumIter,StringLen,Serialize,Deserialize.
Les deux listes sont courtes et exactes parce que les templates importent par leur nom plutôt que par un glob. Un glob y mettrait tout le prélude de SeaORM — et l'y remettrait à chaque version de SeaORM.
Une énumération portant le nom d'un type intégré — enum Text — est refusée
elle aussi : elle serait générée, et un champ la nommant obtiendrait le type
intégré, si bien que rien ne pourrait jamais l'atteindre.
La grammaire formelle vit dans crates/crabster-cdl/src/cdl.pest. En cas de
désaccord entre elle et ce document, c'est la grammaire qui fait foi.
{% endraw %}