Cookbook (Exemples pratiques)

Divers scénarios sont présentés sous forme de ScriptDefinition complets. Pour le fondement de la syntaxe, voir le Catalogue des statements et les Expressions de valeur ; pour l'exécution et les contraintes, voir Sémantique d'exécution, contraintes et sécurité. Dans tous les exemples, la valeur d'écriture de fields est un mappage de locale ({ "<locale>": valeur }), et la locale des exemples a été unifiée sur en-US. Tous les exemples s'exécutent en ligne, sur le chemin qui traite la requête d'appel, et renvoient leur résultat dans le corps de la réponse de l'appel. Dans un exemple comportant un appel externe (Http, EmailSend), le timeoutMs de ce statement s'ajoute au budget de temps d'exécution (× (1 + retry) pour Http) et, si ce statement se trouve dans une itération (Loop, ResourceForEach), il est multiplié par le plafond d'itérations (voir Budget de temps).

Table des matières

CRUD de base

1. Créer et publier un Content

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
      "fields": { "title": { "en-US": "{ /payload/fields/title }" }, "body": { "en-US": "{ /payload/fields/body }" } },
      "publish": true, "name": "post" },
    { "type": "Return", "value": { "id": "{ /post/sys/id }" }, "statusCode": 201 } ] }

2. Mise à jour avec une valeur calculée (compteur de vues +1)

{ "method": "Post",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "viewCount": { "en-US": { "$+": [ "{ /payload/fields/viewCount }", 1 ] } } } } ] }

3. Rassembler et renvoyer ma liste de commandes

{ "method": "Get",
  "statements": [
    { "type": "SetVar", "var": "orders", "value": [] },
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "createdBy": ":self" }, "order": "-sys.createdAt", "from": "Current", "advanced": false,
      "limit": 20, "name": "order",
      "onEach": [
        { "type": "SetVar", "var": "orders", "value": { "$merge": [ "{ /vars/orders }", [ "{ /order }" ] ] } } ] },
    { "type": "Return", "value": { "orders": "{ /vars/orders }" } } ] }

Avec createdBy: ":self", on ne parcourt que « les miens » et on rassemble les éléments avec SetVar pour les renvoyer. Comme ResourceForEach ne lie pas le résultat du parcours sous forme de collection, il faut ainsi les rassembler soi-même pour les renvoyer en liste. Dans le budget de temps, un parcours compte pour le temps déclaré par onEach multiplié par le nombre d'éléments traités. Ici, onEach ne contient aucun appel externe : son temps déclaré vaut 0 et le budget de base de 30 secondes constitue la limite effective. Si l'on place un appel externe dans onEach, cette multiplication entre telle quelle dans le budget et, une fois le plafond de 180 secondes atteint, l'exécution s'interrompt là (voir Budget de temps). Lorsqu'il suffit de lire une liste et de la renvoyer, mieux vaut appeler directement l'API de liste CDA/CMA depuis le frontend plutôt que de parcourir avec un Script.

4. Lire un élément, appliquer un guard, puis approuver

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "target": { "sys": { "id": "{ /payload/fields/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "!=": [ "{ /order/fields/status/en-US }", "pending" ] },
      "then": [ { "type": "Return", "value": { "reason": "not pending" }, "isError": true, "statusCode": 409 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "approved" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

En liant un élément unique à un nom avec ResourceRead, on le référence directement via { /order/fields/... }. S'il n'existe pas, la lecture génère une erreur (on peut l'envelopper dans un Try).

Recherche et upsert

5. upsert par slug (find-then-upsert)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
      "where": { "fields.slug": { "eq": "{ /payload/fields/slug }" } }, "name": "found" },
    { "type": "If", "condition": { "!!": "{ /found/sys/id }" },
      "then": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /found/sys/id }" } },
          "fields": { "body": { "en-US": "{ /payload/fields/body }" } } },
        { "type": "Return", "value": { "id": "{ /found/sys/id }", "op": "updated" } } ],
      "else": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
          "fields": { "slug": { "en-US": "{ /payload/fields/slug }" }, "body": { "en-US": "{ /payload/fields/body }" } }, "name": "created" },
        { "type": "Return", "value": { "id": "{ /created/sys/id }", "op": "created" }, "statusCode": 201 } ] } ] }

ResourceFind lie directement la première correspondance (ou null s'il n'y en a pas), et { "!!": "{ /found/sys/id }" } fait un branchement selon qu'elle existe ou non.

6. Clé de champ dynamique et patch de locale dynamique

{ "method": "Patch",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "{ /payload/fields/fieldKey }": { "{ /payload/fields/locale }": "{ /payload/fields/value }" } } } ] }

La clé de champ et la clé de bucket de locale sont toutes deux des références { /ptr }. On s'en sert pour placer une traduction dans un bucket de locale précis.

API externe

7. Guard de crédits, prélèvement anticipé (CAS), appel LLM, remboursement en cas d'échec (exemple phare)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_wallet" } },
      "where": { "createdBy": ":self" }, "name": "wallet" },
    { "type": "If", "condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" }, "isError": true, "statusCode": 402 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "version": "{ /wallet/sys/version }",
          "fields": { "balance": { "en-US": { "$-": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } },
          "name": "charged" } ],
      "catch": [ { "type": "Return", "value": { "ok": false, "reason": "version conflict, réessayer" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
          "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
          "body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 15000, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/choices/0/message/content }" } }, "name": "out" },
        { "type": "Return", "value": { "ok": true, "id": "{ /out/sys/id }", "remaining": "{ /charged/fields/balance/en-US }" } } ],
      "catch": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "fields": { "balance": { "en-US": { "$+": [ "{ /charged/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } } },
        { "type": "Return", "value": { "ok": false, "reason": "generation failed, refunded" }, "isError": true, "statusCode": 502 } ] } ] }

Un guard vérifie d'abord si le solde est suffisant, puis prélève avant l'appel externe. Le prélèvement pose un verrou optimiste (CAS) sur le sys.version du wallet. Si une autre exécution a modifié le wallet entre la lecture du solde et le prélèvement, l'opération est abandonnée pour cause de conflit de version et catch renvoie 409. Comme aucun appel externe n'a été effectué, les requêtes concurrentes ne font pas l'objet d'un double prélèvement. Ce n'est qu'une fois le prélèvement confirmé que le LLM est appelé, et si cet appel échoue, catch rajoute le montant prélevé (cost) pour effectuer un remboursement (compensation), puis renvoie 502. L'ordre consiste à confirmer la facturation avant l'appel externe irréversible et à ne compenser qu'en cas d'échec. La clé secrète est placée dans un en-tête secret:true. Pour les limites de la compensation, voir L'absence de transaction et la compensation dans la Sémantique d'exécution.

8. Transformer une image (URL) en Media et l'attacher à un Content

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "title": { "en-US": "{ /payload/fields/prompt }" },
                  "file":  { "en-US": { "source": "{ /gen/body/data/0/url }", "encoding": "url" } } }, "name": "img" },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_artwork" } },
      "fields": { "prompt": { "en-US": "{ /payload/fields/prompt }" }, "image": { "en-US": "{ /img/sys/id }" } } } ] }

On crée le Media avec un name, et ResourceCreate (Content) place { /img/sys/id } dans le champ de référence. Media utilise le même modèle de fields que Content. file est la directive d'ingestion { source, encoding }. L'ingestion de fichier ne déclare aucun temps : elle sort donc du budget de base de 30 secondes, et elle n'entre pas non plus dans le plafond d'appels externes par définition (voir Contraintes statiques).

9. Image base64 vers Media

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "file": { "en-US": { "source": "{ /gen/body/data/0/b64_json }", "encoding": "base64" } } } } ] }

10. Publication ou suppression conditionnelle après modération

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.mod.com/check",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "text": "{ /payload/fields/body }" }, "name": "mod" },
    { "type": "If", "condition": { "==": [ "{ /mod/body/flagged }", true ] },
      "then": [ { "type": "ResourceDelete",  "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ],
      "else": [ { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] } ] }

11. try/catch : fallback en cas d'échec externe

{ "method": "Post",
  "statements": [
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://primary.api/gen",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
          "body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 8000, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/text }" }, "source": { "en-US": "primary" } } } ],
      "catch": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "Échec de la génération" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ] }

12. Remplir le résumé et les tags d'un article avec l'IA

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
      "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/body }", "response_format": { "type": "json_object" } },
      "timeoutMs": 15000, "name": "resp" },
 
    { "type": "Try",
      "body": [
        { "type": "ParseJson", "name": "ai", "value": "{ /resp/body/choices/0/message/content }" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
          "fields": { "summary": { "en-US": "{ /ai/summary }" },
                      "tags":    { "en-US": "{ /ai/tags }" } } },
        { "type": "Return", "value": { "ok": true, "tags": "{ /ai/tags }" } } ],
      "catch": [
        { "type": "Return", "value": { "ok": false, "reason": "model did not return JSON" }, "isError": true, "statusCode": 502 } ] } ] }

Dès qu'un article est créé, le modèle remplit son résumé et ses tags (on l'accroche à Content.Create comme action liée d'un Webhook). Ici, la réponse arrive en deux couches. Le responseType de Http vaut Json par défaut, donc l'enveloppe de réponse de l'API est déjà un objet, mais la réponse produite par le modèle se trouve à l'intérieur, dans choices/0/message/content, sous forme de chaîne. C'est pourquoi on retire une couche de plus avec ParseJson avant de pouvoir sortir les valeurs sous la forme { /ai/summary } et { /ai/tags }. Les tags s'écrivent tels quels, en tableau, dans un champ Array (éléments de type ShortText).

Même avec un contrat de sortie structurée (response_format), on reçoit autre chose que du JSON lorsque la réponse est tronquée par une limite de longueur ou que le modèle refuse la demande. Le parsing est donc enveloppé dans Try, qui transforme un échec de parsing en 502. Le message d'échec emporte le texte qu'il a tenté de parser, ce qui permet de voir ce qui est revenu. Pour une API dont l'enveloppe n'est déjà pas du JSON, donnez à Http un responseType: "Text" et passez { /resp/body } directement (voir Http et ParseJson).

Parallèle

13. Fusionner deux appels externes parallèles dans un Content

{ "method": "Post",
  "statements": [
    { "type": "Parallel", "branches": [
      [ { "type": "Http", "method": "GET", "url": "https://api.a.com/x", "name": "a" } ],
      [ { "type": "Http", "method": "GET", "url": "https://api.b.com/y", "name": "b" } ] ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_merged" } },
      "fields": { "left": { "en-US": "{ /a/body/value }" }, "right": { "en-US": "{ /b/body/value }" } } } ] }

14. Examen d'inscription : scores en parallèle, puis décision par and

{ "method": "Post",
  "statements": [
    { "type": "Parallel", "branches": [
      [ { "type": "Http", "method": "POST", "url": "https://api.fraud.com/score",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
          "body": { "email": "{ /payload/fields/email }" }, "name": "fraud" } ],
      [ { "type": "Http", "method": "GET", "url": "https://api.credit.com/v1/{ /payload/fields/userId }/score",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ], "name": "credit" } ] ] },
    { "type": "If",
      "condition": { "and": [ { "<": [ "{ /fraud/body/risk }", 0.5 ] }, { ">=": [ "{ /credit/body/score }", 700 ] } ] },
      "then": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "fields": { "email": { "en-US": "{ /payload/fields/email }" }, "status": { "en-US": "approved" } } },
        { "type": "Return", "value": { "decision": "approved" }, "statusCode": 201 } ],
      "else": [ { "type": "Return", "value": { "decision": "manual-review" }, "statusCode": 202 } ] } ] }

On peut aussi insérer { /ptr } dans le chemin de l'URL. Les résultats des branches se référencent après la jointure. Il y a deux appels externes. Le nombre d'appels externes qu'une définition peut contenir étant une limite propre au plan (voir Tarifs), vérifiez que vous restez dans cette limite.

Boucles et agrégation

Dans le budget de temps, Loop et ResourceForEach comptent pour le temps déclaré par leur body (onEach) multiplié par le plafond d'itérations. Les exemples de cette section n'ont aucun appel externe dans leur body : leur temps déclaré vaut 0, si bien que le budget de base de 30 secondes constitue la limite effective. C'est aussi là que l'itération vient réellement buter sur cette limite (voir Budget de temps et Loop).

15. N Content à partir d'un tableau en entrée (Loop over)

{ "method": "Post",
  "statements": [
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "item", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_item" } },
          "fields": { "name": { "en-US": "{ /item/name }" }, "qty": { "en-US": "{ /item/qty }" } } } ] } ] }

16. counted loop (for) : amorcer des slots

{ "method": "Post",
  "statements": [
    { "type": "Loop", "for": { "from": 1, "to": 5 }, "name": "i", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_slot" } },
          "fields": { "index": { "en-US": "{ /i }" }, "status": { "en-US": "open" } } } ] } ] }

for va de from à to inclus (littéraux entiers, step par défaut 1). name lie le compteur courant à { /i }.

17. Suppression en cascade (ForEach, Delete)

{ "method": "Delete",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_comment" } },
      "where": { "fields.postId": { "eq": "{ /payload/sys/id }" } }, "from": "Current", "advanced": false, "name": "comment",
      "onEach": [ { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /comment/sys/id }" } } } ] },
    { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] }

ResourceForEach pagine les correspondances en interne et supprime chaque élément : sans pagination manuelle, il supprime donc tous les commentaires correspondant à la condition (jusqu'au plafond de la plateforme), puis supprime l'article lui-même. Comme onEach ne déclare aucun temps, le budget de temps d'exécution de cette définition est de 30 secondes.

18. Accumulation en boucle : somme avec SetVar

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "total", "value": 0 },
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "row", "maxIterations": 100,
      "body": [ { "type": "SetVar", "var": "total", "value": { "$+": [ "{ /vars/total }", "{ /row/qty }" ] } } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_summary" } },
      "fields": { "totalQty": { "en-US": "{ /vars/total }" } } } ] }

19. Traiter en masse tous les éléments correspondant à une condition

{ "method": "Post",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
      "where": { "fields.status": { "eq": "draft" } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "post",
      "onEach": [
        { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /post/sys/id }" } } } ] } ] }

ResourceForEach pagine les correspondances en interne : plus besoin d'une boucle à cursor (Loop while + accumulation SetVar). Il trouve tous les brouillons correspondant à la condition et publie chaque élément. Si le nombre est très élevé et qu'aller au bout est difficile, on fixe avec limit un plafond à traiter en une fois, et on laisse where sur une condition « non traité » pour poursuivre le traitement lors d'une réexécution.

20. Collecte groupée d'id à partir d'une liste d'e-mails (merge)

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "ids",     "value": [] },
    { "type": "SetVar", "var": "missing", "value": [] },
    { "type": "Loop", "over": "{ /payload/fields/emails }", "name": "email", "maxIterations": 100,
      "body": [
        { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "where": { "fields.email": { "eq": "{ /email }" } }, "name": "acc" },
        { "type": "If", "condition": { "!!": "{ /acc/sys/id }" },
          "then": [ { "type": "SetVar", "var": "ids",     "value": { "$merge": [ "{ /vars/ids }",     [ "{ /acc/sys/id }" ] ] } } ],
          "else": [ { "type": "SetVar", "var": "missing", "value": { "$merge": [ "{ /vars/missing }", [ "{ /email }" ] ] } } ] } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_campaign" } },
      "fields": { "recipients": { "en-US": "{ /vars/ids }" }, "unresolved": { "en-US": "{ /vars/missing }" } } } ] }

Une lecture (ResourceFind) n'est pas un appel externe : elle est donc autorisée dans le corps d'une Loop. La présence et l'absence sont chacune accumulées avec merge.

Saga et concurrence

21. Saga de paiement (Try/catch/finally)

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "fields": { "sku": { "en-US": "{ /payload/fields/sku }" }, "status": { "en-US": "reserved" } },
      "publish": false, "name": "order" },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.pay.com/charge",
          "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
          "body": { "amount": "{ /payload/fields/amount }", "ref": "{ /order/sys/id }" }, "timeoutMs": 10000, "name": "pay" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "status": { "en-US": "paid" }, "txId": { "en-US": "{ /pay/body/transactionId }" } }, "publish": true },
        { "type": "Return", "value": { "orderId": "{ /order/sys/id }", "status": "paid" }, "statusCode": 201 } ],
      "catch": [
        { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } } },
        { "type": "Return", "value": { "reason": "payment failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 402 } ],
      "finally": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_paylog" } },
          "fields": { "orderRef": { "en-US": "{ /order/sys/id }" }, "amount": { "en-US": "{ /payload/fields/amount }" } } } ] } ] }

Après la réservation (draft) : en cas de succès du paiement, confirmation, publication et 201 ; en cas d'échec, catch supprime la réservation (compensation) et renvoie 402 ; finally journalise toujours. La compensation par suppression produit un nouveau sys.id, elle relève donc de la limite où les références sont rompues (voir L'absence de transaction et la compensation dans la Sémantique d'exécution).

22. CAS à verrouillage optimiste

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_stock" } },
      "where": { "fields.sku": { "eq": "{ /payload/fields/sku }" } }, "name": "stock" },
    { "type": "If", "condition": { "<": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] },
      "then": [ { "type": "Return", "value": { "reason": "out of stock" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /stock/sys/id }" } },
          "version": "{ /stock/sys/version }",
          "fields": { "qty": { "en-US": { "$-": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] } } } },
        { "type": "Return", "value": { "ok": true } } ],
      "catch": [
        { "type": "Return", "value": { "reason": "version conflict, réessayer" }, "isError": true, "statusCode": 409 } ] } ] }

On lit le stock pour obtenir un sys.version à jour, puis on effectue le prélèvement avec cette version (version). Si une autre exécution a modifié la valeur entre la lecture et l'écriture, l'opération est abandonnée pour cause de conflit de version et catch renvoie 409. Le guard de rupture de stock est placé hors du Try (sortie anticipée normale).

E-mail

23. Courriel de notification à l'acheteur de chaque commande (ForEach + EmailSend)

{ "method": "Post",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.notified": { "ne": true } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "order",
      "onEach": [
        { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
          "toServiceUser": { "sys": { "id": "{ /order/fields/buyer/en-US/sys/id }" } },
          "subject": "Votre livraison a commencé",
          "body": "<p>La livraison de l'article que vous avez commandé a commencé.</p>" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "notified": { "en-US": true } } } ] } ] }

On parcourt les commandes dont la notification n'a pas encore été envoyée (celles dont fields.notified n'est pas true), on envoie un courriel à l'acheteur de chaque commande, puis on marque aussitôt notified. EmailSend n'ayant qu'un seul destinataire par envoi, l'envoi en nombre se fait ainsi, un par élément, avec ResourceForEach (onEach peut contenir des appels externes). Fourni via toServiceUser, l'adresse du membre n'entre pas dans l'espace de variables du Script et est résolue juste avant l'envoi. Comme on laisse where sur « non traité » et qu'on marque l'achèvement à la fin de onEach, même en cas d'interruption une réexécution reprend à partir des commandes restantes (si le marquage échoue juste après le succès de l'effet de bord, cette commande peut être dupliquée à l'exécution suivante ; at-least-once).

Vérification de signature

Les prestataires de paiement (PG, MoR) joignent une signature au corps lorsqu'ils envoient un webhook. Avant de faire quoi que ce soit, celui qui la reçoit doit vérifier que cette signature se reproduit avec la clé secrète qu'il détient. Les deux exemples ci-dessous correspondent aux deux méthodes que l'on rencontre réellement : l'une produit un code avec la clé secrète (keyed), l'autre calcule un condensé en concaténant des champs et la clé secrète.

24. Vérifier la signature d'un webhook (déballer l'en-tête emballé, fenêtre de rejeu)

{ "method": "Post",
  "statements": [
    { "type": "Regex", "name": "sig", "mode": "Capture",
      "pattern": "^t=(\\d+),v1=([0-9a-f]{64})$", "value": "{ /headers/x-provider-signature }" },
    { "type": "If", "condition": { "==": [ "{ /sig }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "malformed signature header" }, "isError": true, "statusCode": 400 } ] },
 
    { "type": "Signature", "name": "verified", "algorithm": "SHA256",
      "secret": "whsec_9f2c1b7ae4",
      "value": "{ /sig/1 }.{ /rawPayload }", "expected": "{ /sig/2 }" },
    { "type": "If", "condition": { "!": "{ /verified }" },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "If", "condition": { ">=": [ { "-": [ "{ /now/seconds }", "{ /sig/1 }" ] }, 300 ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "timestamp outside the replay window" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.orderId": { "eq": "{ /payload/data/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "==": [ "{ /order }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "unknown order" }, "isError": true, "statusCode": 404 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "paid" }, "paidAt": { "en-US": "{ /now/iso }" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

Le fournisseur envoyant l'horodatage et le code ensemble dans un seul en-tête (t=1492774577,v1=<hex de 64 caractères>), on ne peut pas constituer le message à signer avant d'avoir déballé cet en-tête. L'ordre est donc fixé ainsi.

  1. Le Capture de Regex déballe l'en-tête et le sépare en { /sig/1 } (l'horodatage) et { /sig/2 } (le code). L'index 0 est la correspondance entière et les groupes de capture commencent à 1. Si le format ne correspond pas, { /sig } vaut null et l'on renvoie un 400 sur place.
  2. Signature prend pour message "<horodatage>.<corps d'origine>", en produit le code et le compare à { /sig/2 }. L'essentiel est de prendre pour message le texte d'origine avant parsing ({ /rawPayload }). Refaire une chaîne à partir du /payload parsé normalise les espaces et la notation des nombres, si bien qu'on ne retrouve pas les octets signés par le correspondant. Placer les deux pointers côte à côte dans une chaîne les concatène tels quels : aucun opérateur n'est nécessaire.
  3. Si { /verified } vaut false, c'est un 401. Une signature erronée et un en-tête absent ne font qu'un, tous deux false (on n'indique pas à l'expéditeur laquelle des deux est en cause).
  4. Même avec une signature correcte, une requête ancienne est rejetée. { /now/seconds } étant l'instant où cette exécution a démarré, on regarde si son écart avec l'horodatage porté par la signature dépasse la fenêtre de rejeu (replay window ; ici 300 secondes). L'horodatage est arrivé de l'en-tête sous forme de chaîne, mais l'opération arithmétique le convertit en nombre.
  5. Ce n'est qu'après avoir franchi tout cela que l'on cherche la commande et que l'on change son statut.

Faute d'appel externe, il n'y a aucun temps déclaré : l'exécution se termine donc dans le budget de base de 30 secondes, et le fournisseur reçoit la réponse sur place. Contrairement au secret: true d'un en-tête Http, le secret utilisé pour la vérification n'est pas stocké chiffré : restreignez donc les rôles capables de lire ce Script (voir les en-têtes secret du modèle de sécurité).

Il y a deux façons de permettre au fournisseur d'appeler ce guichet, et le critère est de savoir si ce fournisseur peut envoyer des en-têtes personnalisés.

  • S'il peut en envoyer, on émet un jeton ne portant que la permission Execute de ce Script, on le lui fait placer dans l'en-tête Authorization et appeler /execute. Le rôle qui restreint à un seul Script est traité dans la permission script de SpaceRole, et le jeton dans Space Access Token. C'est la voie par défaut.
  • S'il ne peut pas en envoyer (un fournisseur chez qui l'on ne peut enregistrer qu'une URL de callback, sans réglage pour y joindre des en-têtes), on active le anonymousCallEnabled de ce Script et on enregistre l'adresse /execute/anonymous comme callback. L'exécution se fait alors sous l'identité de l'auteur : le updatedBy de la commande que ce Script modifie devient donc l'auteur et, faute d'authentification, la vérification de signature ci-dessus est la seule authentification de ce guichet. Les conditions et les règles d'enregistrement sont traitées dans Appel anonyme.

La définition ci-dessus satisfait telle quelle les conditions d'un Script anonyme : elle n'utilise pas le filtre createdBy: ":self", et toute requête qui ne franchit ni la signature ni la fenêtre de rejeu est coupée par Return avant de toucher à quoi que ce soit.

25. Vérifier une signature par hachage sans clé

{ "method": "Post",
  "statements": [
    { "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
      "value": "{ /payload/orderId }{ /payload/amount }9f2c1b7ae4" },
    { "type": "If", "condition": { "!=": [ "{ /expectedSign }", "{ /payload/signature }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/orderRef }" } },
      "fields": { "status": { "en-US": "paid" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

C'est la méthode qui, au lieu d'un HMAC, « concatène des champs déterminés et une clé secrète puis calcule un SHA256 ». Hash n'a pas de champ secret : on écrit la clé secrète (9f2c1b7ae4) directement dans value, à la place où ce schéma la met. La place de la clé variant d'un schéma à l'autre (au début, à la fin, au milieu), cette façon de faire exprime toutes ces places.

encoding s'aligne sur la notation du correspondant (Hex, HexUpper, Base64, Base64Url). Contrairement à Signature, le résultat est une chaîne : la comparaison est donc à faire soi-même, et c'est une égalité ordinaire. La limite de value étant de 128 caractères, pour un schéma qui calcule sur le corps entier on utilise Signature.

Recherche de membre

26. Trouver un membre par e-mail, lui offrir un coupon et l'en informer par courriel

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "ServiceUser",
      "where": { "sys.email": { "eq": "{ /payload/fields/email }" } }, "name": "member" },
    { "type": "If", "condition": { "==": [ "{ /member }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "member not found" }, "isError": true, "statusCode": 404 } ] },
 
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_coupon" } },
      "fields": { "code": { "en-US": "WELCOME-{ /member/sys/id }" },
                  "owner": { "en-US": "{ /member/sys/id }" } }, "publish": false, "name": "coupon" },
 
    { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
      "toServiceUser": { "sys": { "id": "{ /member/sys/id }" } },
      "subject": "Un coupon vous a été attribué",
      "body": "<p>Nous avons offert le coupon { /coupon/fields/code/en-US } à { /member/nickname }.</p>" },
    { "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }

On trouve un membre avec une seule adresse e-mail, puis on utilise son sys.id comme propriétaire du coupon et comme destinataire du courriel. Les règles de lecture de l'annuaire des membres sont les suivantes.

  • sys.email étant stocké chiffré, il n'accepte que les opérateurs de la famille de l'égalité exacte (eq, ne, in, nin). Donner un autre opérateur, comme prefix, ne renvoie pas silencieusement 0 élément : l'exécution échoue.
  • En l'absence de correspondance, ResourceFind lie null : on branche donc sur l'existence de la même façon que pour la recherche d'un Content.
  • Contrairement à Content et Media, les champs d'un membre ne sont pas des mappages de locale. On les référence tels quels, comme { /member/nickname }.
  • Pour envoyer le courriel, on ne sort pas l'adresse : on passe le sys.id à toServiceUser. Le moteur résolvant l'adresse juste avant l'envoi, l'adresse du membre n'entre pas dans l'espace de variables du Script.
  • Pour enregistrer cette définition, le settings du SpaceRole de l'auteur doit contenir SETTING_SERVICE_LOGIN. Aucun rôle ne permet d'enregistrer un statement qui crée, modifie ou supprime un membre (voir Lecture de l'annuaire des membres).

EmailSend ajoute son timeoutMs (10 secondes à défaut de déclaration) au budget de temps d'exécution et compte pour un dans le plafond d'appels externes par définition.