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.
Nécessite une base de données PostgreSQL accessible. Pointez db_url vers une instance Postgres pour l’essayer.
Exécution
cd tutorial/07_sql_crud_api
melodium run Compo.toml --db_url postgresql://user@localhost/notes_dbcurl -X POST http://127.0.0.1:8080/notes -d "buy milk"
curl http://127.0.0.1:8080/notes1) buy milkOptionnel : 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) : SqlPool {
url = db_url
min_connections = 1
max_connections = 5
}
treatment main(
const db_url: string = "postgresql://postgres@localhost/notes_db",
const port: u16 = 8080
)
model db: AppDb(db_url=db_url)
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 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.10.3" # flux de base, journalisation, structures de données
http = "0.10.3" # client et serveur HTTP
net = "0.10.3" # utilitaires d'adresses IP
sql = "0.10.3" # pools de connexions, fetch, execute
encoding = "0.10.3" # encodage / décodage UTF-8