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 Statement 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 { /secrets/LLM_API_KEY }" } ],
          "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 } ] } ],
  "secrets": [ { "key": "LLM_API_KEY", "value": "sk-...", "description": "Clé d'API pour les appels LLM" } ] }

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é d'API du LLM est déclarée dans secrets et lue dans l'en-tête via { /secrets/LLM_API_KEY }. 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": "{ /secrets/IMAGE_API_KEY }" } ],
      "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 }" } } } ],
  "secrets": [ { "key": "IMAGE_API_KEY", "value": "...", "description": "Clé d'API de génération d'images" } ] }

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": "{ /secrets/IMAGE_API_KEY }" } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "file": { "en-US": { "source": "{ /gen/body/data/0/b64_json }", "encoding": "base64" } } } } ],
  "secrets": [ { "key": "IMAGE_API_KEY", "value": "...", "description": "Clé d'API de génération d'images" } ] }

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": "{ /secrets/MODERATION_API_KEY }" } ],
      "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 }" } } } ] } ],
  "secrets": [ { "key": "MODERATION_API_KEY", "value": "...", "description": "Clé d'API de modération" } ] }

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": "{ /secrets/GEN_API_KEY }" } ],
          "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" } } } ] } ],
  "secrets": [ { "key": "GEN_API_KEY", "value": "...", "description": "Clé d'API de génération" } ] }

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 { /secrets/LLM_API_KEY }" } ],
      "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 } ] } ],
  "secrets": [ { "key": "LLM_API_KEY", "value": "sk-...", "description": "Clé d'API pour les appels LLM" } ] }

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": "{ /secrets/FRAUD_API_KEY }" } ],
          "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": "{ /secrets/CREDIT_API_KEY }" } ], "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 } ] } ],
  "secrets": [ { "key": "FRAUD_API_KEY", "value": "...", "description": "Clé d'API du score de fraude" },
               { "key": "CREDIT_API_KEY", "value": "...", "description": "Clé d'API du score de crédit" } ] }

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 { /secrets/PAYMENT_API_KEY }" } ],
          "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 }" } } } ] } ],
  "secrets": [ { "key": "PAYMENT_API_KEY", "value": "sk-...", "description": "Clé secrète de l'API de paiement" } ] }

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

24. Envoi avec un modèle de courriel retouché dans le studio de contenu (templateBody)

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "name": "tpl",
      "target": { "sys": { "id": "cnt_mail_shipping" } } },
    { "type": "ResourceRead", "resource": "Content", "name": "order",
      "target": { "sys": { "id": "{ /payload/orderId }" } } },
    { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
      "toServiceUser": { "sys": { "id": "{ /order/fields/buyer/en-US/sys/id }" } },
      "subject": "Votre livraison est en route (n° de commande { /order/sys/id })",
      "templateBody": {
        "source": "{ /tpl/fields/html/en-US }",
        "data": { "name": "{ /order/fields/buyerName/en-US }",
                  "tracking": "{ /order/fields/trackingNo/en-US }" } } },
    { "type": "Return", "value": { "sent": true } } ] }

On place le modèle dans un Content (le Field html de cnt_mail_shipping) et l'on n'en remplit que les emplacements. La personne chargée du marketing peut retoucher la formulation dans le studio de contenu sans que le Script change. Le modèle ressemble à ceci.

<p>Bonjour { /name }, la livraison de votre commande est en route.</p>
<p>Numéro de suivi : { /tracking }</p>

source doit être un seul pointeur désignant un Content lu par cette définition. On place le ResourceRead qui lit le modèle avant l'EmailSend : les Statement s'exécutant dans l'ordre où ils sont écrits, désigner un nom qui n'a pas encore été lu donne une valeur vide et fait échouer l'exécution. Les emplacements du modèle ne lisent que data : le modèle ne peut pas lire directement /payload. Un emplacement qui désigne une clé absente de data devient une chaîne vide.

25. Infolettre rédigée par l'administrateur, envoyée aux abonnés (autoEscape)

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "name": "issue",
      "target": { "sys": { "id": "{ /payload/issueId }" } } },
    { "type": "ResourceRead", "resource": "Content", "name": "tpl",
      "target": { "sys": { "id": "cnt_mail_newsletter" } } },
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_subscriber" } },
      "where": { "fields.active": { "eq": true } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "sub",
      "onEach": [
        { "type": "EmailSend", "account": { "sys": { "id": "eml_news" } },
          "to": "{ /sub/fields/email/en-US }",
          "subject": "{ /issue/fields/title/en-US }",
          "autoEscape": false,
          "templateBody": {
            "source": "{ /tpl/fields/html/en-US }",
            "data": { "article": "{ /issue/fields/bodyHtml/en-US }" } } } ] } ] }

Le corps de l'infolettre étant du HTML rédigé par l'administrateur en texte enrichi, laissé tel quel il ferait apparaître les balises comme du texte. Mettre autoEscape à false fait rendre le balisage contenu dans data.

Limitez étroitement la portée du false. Ce réglage s'applique à toutes les valeurs interpolées de cet envoi EmailSend. C'est pourquoi le data de cet exemple ne porte que l'article rédigé par l'administrateur. Mêler dans le même data une valeur venue de l'extérieur, comme le nom d'un abonné, ferait rendre aussi le balisage contenu dans cette valeur. Si une telle valeur est nécessaire, on la place du côté du modèle ou l'on sépare les envois. Le modèle lui-même étant toujours rendu brut, indépendamment d'autoEscape, vérifiez que le modèle et les valeurs placées dans data proviennent uniquement de personnes qui ont le droit d'écrire dans ce Content.

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.

26. 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": "{ /secrets/WEBHOOK_SIGNING_SECRET }",
      "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 } } ],
  "secrets": [ { "key": "WEBHOOK_SIGNING_SECRET", "value": "whsec_9f2c1b7ae4", "description": "Clé de signature des webhooks du prestataire de paiement" } ] }

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. La clé de signature étant déclarée dans secrets, WEEGLOO la chiffre et la stocke en toute sécurité. Dans le Statement, on la lit via { /secrets/WEBHOOK_SIGNING_SECRET }. La valeur d'origine reste toutefois visible lorsqu'on consulte ce Script : restreignez donc les rôles capables de le lire (voir les secrets 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.

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

{ "method": "Post",
  "statements": [
    { "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
      "value": "{ /payload/orderId }{ /payload/amount }{ /secrets/MERCHANT_KEY }" },
    { "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 } } ],
  "secrets": [ { "key": "MERCHANT_KEY", "value": "9f2c1b7ae4", "description": "Clé marchand partagée avec le prestataire de paiement" } ] }

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 déclare la clé secrète dans secrets et on l'écrit dans value sous la forme { /secrets/MERCHANT_KEY }, à la place où ce schéma met la clé. La place de la clé varie d'un schéma à l'autre (au début, à la fin, au milieu), mais en l'écrivant ainsi on peut exprimer n'importe laquelle de 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

28. 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 ou supprime un membre (voir Manipuler 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.

Transfert de fichiers

29. Envoyer la photo d'un produit à un service d'analyse d'images et enregistrer le résultat (multipartBody)

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } }, "name": "product" },
    { "type": "ResourceRead", "resource": "Media",
      "target": { "sys": { "id": "{ /product/fields/photo/en-US/sys/id }" } }, "name": "photo" },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.vision.example/v2/analyze",
          "headers": [ { "key": "x-api-key", "value": "{ /secrets/VISION_API_KEY }" } ],
          "multipartBody": [
            { "name": "image", "url": "{ /photo/fields/file/en-US/url }" },
            { "name": "language", "value": "ko" },
            { "name": "features", "value": "labels" },
            { "name": "features", "value": "text" } ],
          "timeoutMs": 20000, "name": "analysis" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /product/sys/id }" } },
          "fields": { "aiLabels": { "en-US": "{ /analysis/body/labels }" },
                      "aiText":   { "en-US": "{ /analysis/body/text }" } } },
        { "type": "Return", "value": { "ok": true, "labels": "{ /analysis/body/labels }" } } ],
      "catch": [
        { "type": "Return", "value": { "ok": false, "reason": "image analysis failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 502 } ] } ],
  "secrets": [ { "key": "VISION_API_KEY", "value": "...", "description": "Clé d'API d'analyse d'images" } ] }

Lorsqu'un vendeur enregistre un produit, ce flux extrait de la photo du produit des étiquettes et le texte qui y figure, puis les enregistre dans le produit (on l'accroche à Content.Create comme action liée d'un Webhook). On lit le Media via le champ de référence photo du Content produit, puis on écrit l'url de son fichier dans la part fichier de multipartBody. api.vision.example est un service fictif servant d'exemple, et l'on suppose qu'il renvoie labels (tableau de chaînes) et text (chaîne). aiLabels étant un champ Array (éléments de type ShortText), on y écrit le tableau tel quel. Adaptez les chemins { /analysis/body/... } à la forme de réponse du service que vous utilisez réellement.

  • Composition des parts : chaque élément du tableau correspond à une part, et les parts sont envoyées dans l'ordre du tableau. image est une part fichier pour laquelle url est indiqué ; les autres sont des parts texte pour lesquelles value est indiqué. Si l'on utilise plusieurs fois le même nom, comme features, chaque occurrence est envoyée comme une part distincte.
  • Content-Type : la requête entière est toujours envoyée en multipart/form-data ; on ne l'indique donc pas dans headers. Comme la part image omet contentType, elle utilise le Content-Type renvoyé par l'URL du fichier. Le nom de fichier étant lui aussi omis, le dernier segment du chemin de l'URL du fichier devient le nom de fichier.
  • Gestion des échecs : si le fichier dépasse la limite de téléchargement, si le fichier ne peut pas être récupéré depuis l'URL du fichier ou si le délai est dépassé, ce Http échoue. Il échoue aussi lorsque le service cible répond par un statut de 400 ou plus. Comme cet exemple n'indique pas ignoreStatusCode (valeur par défaut false), on passe dans tous les cas à catch, et le Return de catch renvoie le statut 502. Le produit n'est pas modifié. Le traitement propre à chaque type d'échec est présenté dans Les deux façons dont Http échoue.
  • Budget de temps : sur le plan Pro, ce Script se voit allouer environ 60,5 secondes (base de 30 secondes + timeoutMs de 20 secondes + environ 10,5 secondes de téléchargement pour une photo). La méthode de calcul est présentée dans Budget de temps.

Pour une API qui reçoit un seul fichier comme corps entier (sous la forme d'un PUT vers une URL de téléversement), on utilise urlBody au lieu de multipartBody. Les règles des deux champs et l'ordre dans lequel le Content-Type est déterminé sont présentés dans Envoyer un fichier comme corps.

Transformation de fichiers

30. Exemplaire PDF d'un livre numérique portant l'e-mail de l'acheteur (PdfWatermark)

{ "method": "Post",
  "payloadSchema": {
    "type": "object", "required": [ "fields" ],
    "properties": {
      "fields": {
        "type": "object", "required": [ "ebookMediaId", "email", "orderId" ],
        "properties": {
          "ebookMediaId": { "type": "string" },
          "email":   { "type": "string", "minLength": 1, "maxLength": 41, "not": { "pattern": "[^ -~]" } },
          "orderId": { "type": "string", "minLength": 1, "maxLength": 20, "not": { "pattern": "[^A-Za-z0-9-]" } } } } } },
  "statements": [
    { "type": "ResourceRead", "resource": "Media",
      "target": { "sys": { "id": "{ /payload/fields/ebookMediaId }" } }, "name": "ebook" },
    { "type": "ResourceCreate", "resource": "Media", "publish": false,
      "fields": {
        "title": { "en-US": "{ /ebook/fields/title/en-US }" },
        "file": { "en-US": {
          "source": "{ /ebook/fields/file/en-US/url }", "encoding": "url",
          "transformations": [
            { "type": "PdfWatermark", "text": "{ /payload/fields/email } · { /payload/fields/orderId }" } ] } } },
      "name": "copy" },
    { "type": "Return", "value": { "mediaId": "{ /copy/sys/id }" } } ] }

Ce flux crée, une fois le paiement confirmé, l'exemplaire du livre numérique à remettre à l'acheteur. Le payload de la requête fournit l'id du Media du livre numérique original, l'adresse e-mail de l'acheteur et le numéro de commande. Le Media original est laissé intact : on crée un nouveau Media dont chaque page porte en diagonale e-mail · numéro de commande, puis on renvoie son id.

  • Longueur du texte : le texte fixe en dehors des modèles est ·, soit 3 caractères. Comme payloadSchema limite l'adresse e-mail à 41 caractères et le numéro de commande à 20 caractères, le texte apposé ne dépasse pas 64 caractères. Si l'une des deux valeurs contient ne serait-ce qu'un caractère hors de ceux autorisés (saut de ligne compris), la requête est refusée avant l'exécution : le texte ne fait donc pas échouer l'étape de transformation. Les caractères ASCII imprimables qu'accepte l'adresse e-mail figurent tous parmi les caractères autorisés.
  • Couleur : color étant omis, le texte est apposé dans la couleur par défaut #808080 (gris).
  • Publication : comme il s'agit d'un exemplaire destiné à un seul acheteur, publish est mis à false. L'exemplaire n'est pas distribué par CDA/ACDA.
  • Gestion des échecs : si le fichier original n'est pas un PDF ou s'il est chiffré, ResourceCreate échoue avec le statut 422 et aucun nouveau Media n'est créé. La liste complète des cas d'échec est présentée dans Quand la transformation de fichier échoue.
  • Budget de temps : le temps de récupération et de transformation du fichier sort du budget de base de 30 secondes (voir Budget de temps).
  • Vérification de l'achat : cet exemple ne comporte aucune étape qui vérifie le paiement et l'achat. En l'état, tout appelant disposant de la permission d'exécution peut créer un exemplaire à partir de n'importe quel id de Media. En production, lisez d'abord le Content de la commande pour vérifier que l'acheteur et le produit correspondent, puis poursuivez.