Skip to Content
Mélodium 0.11.0 is now available!
DocsExemples07. API CRUD SQL

API CRUD SQL

Source: tutorial/07_sql_crud_api See in Playground

Une petite API de “notes” adossée à PostgreSQL : POST /notes stocke le corps de la requête comme texte brut, GET /notes liste toutes les notes stockées. Elle combine un modèle SqlPool partagé entre les requêtes avec un modèle HttpServer dans le même programme.

Note

Nécessite une base de données PostgreSQL accessible. Pointez db_url vers une instance Postgres pour l’essayer, et donnez son mot de passe dans MELODIUM_SECRET_DB_PASSWORD.

Exécution

cd tutorial/07_sql_crud_api MELODIUM_SECRET_DB_PASSWORD=... melodium run Compo.toml --db_url postgresql://user@localhost/notes_db
curl -X POST http://127.0.0.1:8080/notes -d "buy milk" curl http://127.0.0.1:8080/notes
1) buy milk

Optionnel : ajoutez --api-report et un jeton d’API (MELODIUM_API_TOKEN) pour voir la trace complète de cette exécution sur Cadence.CI .

Fonctionnement

main instancie deux modèles, db (un SqlPool) et server (un HttpServer), et les transmet aux traitements de route via des paramètres de configuration de modèle :

model AppDb(const db_url: string, const db_password: Secret<string>) : SqlPool { url = db_url password = |wrap<Secret<string>>(db_password) min_connections = 1 max_connections = 5 } treatment main( const db_url: string = "postgresql://postgres@localhost/notes_db", const db_password: Secret<string> = "env:MELODIUM_SECRET_DB_PASSWORD", const port: u16 = 8080 ) model db: AppDb(db_url=db_url, db_password=db_password) model server: HttpServer(host=|from_ipv4(|localhost_ipv4()), port=port) { startup() connect[sql_pool=db]() startup.trigger -> connect.trigger connected[sql_pool=db]() createTable[db=db]() connected.trigger -> createTable.trigger start[http_server=server]() logReady: logInfoMessage(label="server", message="notes API ready") createTable.done -> start.trigger createTable.done -> logReady.trigger createNote[db=db, http_server=server]() listNotes[db=db, http_server=server]() }

Le mot de passe de la base est un secret : db_password est un Secret<string> dont la valeur par défaut est le localisateur env:MELODIUM_SECRET_DB_PASSWORD, car les programmes n’ont accès qu’aux variables d’environnement dont le nom commence par MELODIUM_SECRET_. SqlPool ne le lit qu’au moment de se connecter et le place lui-même dans l’URL de connexion : db_url ne contient donc aucun identifiant, et le mot de passe n’apparaît dans aucun paramètre, journal ni rapport. Un autre localisateur peut être donné à la place, comme --db_password file:/run/secrets/db_password.

Le flux de données relie la mise en place de la base au démarrage du serveur :

connect est déclenché une seule fois au démarrage, et le traitement source connected démarre une piste une fois le pool réellement prêt : createTable, et tout ce qui en dépend, y compris le démarrage du serveur HTTP, ne s’exécute qu’ensuite, si bien qu’aucune requête ne peut arriver avant la création de la table.

Écriture : execute avec une seule liaison

POST /notes réduit le corps à un unique Block<string> avec trigger.last (le même idiome consistant à réduire un flux d’un seul élément en un bloc que celui utilisé pour calculer un total à partir d’un flux de nombres), l’enveloppe dans une Map, et le transmet comme paramètre de liaison à execute :

treatment insertNote[db: SqlPool]() input data: Stream<byte> output data: Stream<byte> { decoded: decode() Self.data -> decoded.data textBlock: trigger<string>() decoded.text -> textBlock.stream bindMap: blockMapEntry<string>(key="text") textBlock.last -> bindMap.value doInsert: execute[sql_pool=db](sql="INSERT INTO notes (text) VALUES (?)", bindings=["text"]) bindMap.map -> doInsert.bind insertFailed: logErrorMessage(label="sql", message="insert failed") insertError: logError(label="sql") doInsert.failed -> insertFailed.trigger doInsert.error -> insertError.message confirm: emit<string>(value="created\n") asBytes: stream<string>() encoded: encode() doInsert.completed -> confirm.trigger,emit -> asBytes.block,stream -> encoded.text,data -> Self.data }

Le SQL de execute utilise le symbole de liaison par défaut ? ; pour une connexion PostgreSQL, il est automatiquement réécrit en $1, $2, etc. avant d’atteindre le pilote, si bien que INSERT INTO notes (text) VALUES (?) n’a jamais besoin d’être écrit à la main sous la forme $1.

Lecture : fetch transmis ligne par ligne

GET /notes transmet sa réponse ligne par ligne : la sortie data de fetch émet chaque ligne dès qu’elle arrive depuis la base, et chaque ligne devient une ligne "id) text\n" écrite directement dans connection.data. La réponse HTTP grossit au fur et à mesure que les lignes arrivent, rien n’est mis en tampon côté client :

treatment listRows[db: SqlPool]() input trigger: Block<void> output lines: Stream<byte> { emitBind: emit<Map>(value=|mmap([])) rows: fetch[sql_pool=db](sql="SELECT id::text AS id, text FROM notes ORDER BY id", bindings=[]) Self.trigger -> emitBind.trigger,emit -> rows.bind rowErrors: logErrors(label="sql") rows.errors -> rowErrors.messages id: mapGet<string>(key="id") text: mapGet<string>(key="text") rows.data -> id.map rows.data -> text.map idOr: unwrapOr<string>(default="?") textOr: unwrapOr<string>(default="") id.value -> idOr.option text.value -> textOr.option line: format(format="{id}) {text}\n") idOr.value -> asEntry.value asEntry: entry(key="id") asEntry.map -> withText.base textOr.value -> withText.value withText: insert(key="text") withText.map -> line.entries line.formatted -> encoded.text encoded: encode() encoded.data -> Self.lines }

SELECT id::text AS id, text FROM notes convertit id en texte directement en SQL, plutôt que de deviner quel type entier natif (i32 ? i64 ?) le pilote Postgres associe à SERIAL : get<string> correspond alors toujours. Quand le type Mélodium exact renvoyé par un pilote est incertain, il est souvent plus simple de le forcer en string directement dans la requête que de deviner et de se tromper silencieusement, puisque get<T> renvoie none en cas d’incompatibilité de type plutôt qu’une erreur.

Les deux routes déclenchent leur réponse depuis connection.started plutôt que depuis un déclencheur dérivé du corps, puisque GET /notes n’a lui non plus aucun corps de requête.

Dépendances

[dependencies] std = "0.11.0" # flux de base, journalisation, structures de données http = "0.11.0" # client et serveur HTTP net = "0.11.0" # utilitaires d'adresses IP sql = "0.11.0" # pools de connexions, fetch, execute encoding = "0.11.0" # encodage / décodage UTF-8