Cookbook (Praxisbeispiele)

Verschiedene Szenarien werden als vollständige ScriptDefinition gezeigt. Die Syntaxgrundlage finden Sie im Statement-Katalog und unter Wertausdrücke; zu Ausführung und Einschränkungen siehe Ausführungssemantik, Einschränkungen und Sicherheit. In allen Beispielen ist der fields-Wert beim Schreiben eine Locale-Map ({ "<locale>": Wert }), und die Beispiel-Locale ist einheitlich en-US. Alle Beispiele laufen inline auf dem Weg, der die aufrufende Anfrage verarbeitet, und geben das Ergebnis als Antwort-Body dieses Aufrufs zurück. Bei einem Beispiel mit einem externen Aufruf (Http, EmailSend) wird das timeoutMs dieses Statements zum Zeitbudget der Ausführung addiert (bei Http × (1 + retry)), und steht dieses Statement innerhalb einer Iteration (Loop, ResourceForEach), wird es mit der Iterationsobergrenze multipliziert (siehe Zeitbudget).

Inhaltsverzeichnis

Grundlegendes CRUD

1. Content erstellen und veröffentlichen

{ "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. Update mit berechnetem Wert (Aufrufzähler +1)

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

3. Meine Bestellliste sammeln und zurückgeben

{ "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 }" } } ] }

Mit createdBy: ":self" wird „nur das Eigene" durchlaufen, und die Elemente werden mit SetVar gesammelt und zurückgegeben. Da ResourceForEach das Iterationsergebnis nicht als Collection bindet, sammelt man sie so selbst, um eine Liste zurückzugeben. Die Iteration wird im Zeitbudget als die von onEach deklarierte Zeit multipliziert mit der Anzahl der verarbeiteten Elemente gerechnet. Hier enthält onEach keinen externen Aufruf, die deklarierte Zeit ist also 0, sodass das Grundbudget von 30 Sekunden die tatsächliche Grenze ist; legen Sie einen externen Aufruf in onEach, geht diese Multiplikation unverändert in das Budget ein, und bei Erreichen der Obergrenze von 180 Sekunden wird dort abgebrochen (siehe Zeitbudget). Wenn es genügt, eine Liste zu lesen und zurückzugeben, ist es besser, statt einer Script-Iteration im Frontend die CDA/CMA-Listen-API direkt aufzurufen.

4. Einzeln lesen, guard, dann genehmigen

{ "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 } } ] }

Wenn Sie mit ResourceRead einen einzelnen Datensatz an einen Namen binden, können Sie ihn direkt über { /order/fields/... } referenzieren. Existiert er nicht, führt der Lesevorgang zu einem Fehler (Sie können ihn mit Try umschließen).

Nachschlagen und upsert

5. slug upsert (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 bindet die erste Übereinstimmung direkt (oder null, falls keine vorhanden ist), und { "!!": "{ /found/sys/id }" } verzweigt danach, ob sie existiert.

6. Dynamischer Feld-Schlüssel und dynamischer Locale-patch

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

Sowohl der Feld-Schlüssel als auch der Locale-Bucket-Schlüssel sind { /ptr }-Referenzen. Verwenden Sie dies, wenn Sie eine Übersetzung in einen bestimmten Locale-Bucket einfügen möchten.

Externe API

7. Credit-guard, Vorab-Abzug (CAS), LLM-Aufruf, Rückerstattung bei Fehler (repräsentatives Beispiel)

{ "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, erneut versuchen" }, "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 } ] } ] }

Mit einem guard wird zunächst geprüft, ob das Guthaben ausreicht, und dann vor dem externen Aufruf abgezogen. Der Abzug setzt eine optimistische Sperre (CAS) auf die sys.version des wallet. Hat eine andere Ausführung das wallet zwischen dem Lesen des Guthabens und dem Abzug verändert, wird wegen einer Versionsabweichung abgebrochen und catch gibt 409 zurück. Da noch kein externer Aufruf erfolgt ist, werden gleichzeitige Anfragen nicht doppelt belastet. Erst nachdem der Abzug festgeschrieben ist, wird das LLM aufgerufen; schlägt dieser Aufruf fehl, addiert catch den abgezogenen Betrag (cost) wieder hinzu, um eine Rückerstattung (Kompensation) vorzunehmen, und gibt dann 502 zurück. Die Reihenfolge lautet: die Abbuchung vor dem unumkehrbaren externen Aufruf festschreiben und nur im Fehlerfall kompensieren. Der geheime Schlüssel wird in einem secret:true-Header abgelegt. Zu den Grenzen der Kompensation siehe Keine Transaktionen und Kompensation in der Ausführungssemantik.

8. Bild (URL) als Media erstellen und an Content anhängen

{ "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 }" } } } ] }

Das Media wird mit einem name erstellt, und ResourceCreate (Content) steckt { /img/sys/id } in das Referenzfeld. Media verwendet dasselbe fields-Modell wie Content. file ist die Ingestion-Anweisung { source, encoding }. Der Datei-Ingest deklariert keine Zeit und geht daher vom Grundbudget von 30 Sekunden ab; auf das Limit für externe Aufrufe pro Definition wird er ebenfalls nicht angerechnet (siehe Statische Einschränkungen).

9. base64-Bild zu 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. Bedingtes publish oder Löschen nach Moderation

{ "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 bei externem Fehler

{ "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": "Generierung fehlgeschlagen" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ] }

12. KI-Zusammenfassung und Tags in einen Beitrag füllen

{ "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 } ] } ] }

Sobald ein Beitrag angelegt ist, füllt das Modell dessen Zusammenfassung und Tags (als verknüpfte Aktion eines Webhook an Content.Create gehängt). Die Antwort kommt hier in zwei Schichten. responseType von Http ist standardmäßig Json, der Antwort-Umschlag der API ist also schon ein Objekt, aber die Antwort, die das Modell erzeugt hat, steckt darin bei choices/0/message/content als String. Deshalb schälen Sie mit ParseJson eine Schicht weiter, bevor Sie Werte als { /ai/summary } und { /ai/tags } herausholen können. Die Tags gehen als das Array, das sie sind, in ein Array-Feld (Elemente vom Typ ShortText).

Auch mit einem Vertrag über strukturierte Ausgabe (response_format) kommt etwas an, das kein JSON ist, wenn die Antwort an einer Längengrenze abgeschnitten wird oder das Modell die Anfrage verweigert. Darum ist das Parsen in Try gefasst, was einen Parse-Fehlschlag in ein 502 verwandelt. Die Fehlermeldung führt den Text mit, den zu parsen versucht wurde, sodass Sie sehen, was zurückkam. Bei einer API, deren Umschlag von vornherein kein JSON ist, geben Sie Http ein responseType: "Text" und übergeben { /resp/body } direkt (siehe Http und ParseJson).

Parallel

13. Zwei parallele externe Aufrufe zu Content zusammenführen

{ "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. Registrierungsprüfung: parallele Scores, dann and-Entscheidung

{ "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 } ] } ] }

Sie können { /ptr } auch in den URL-Pfad einfügen. Branch-Ergebnisse werden nach dem Join referenziert. Es gibt zwei externe Aufrufe. Die Anzahl externer Aufrufe, die eine Definition enthalten kann, ist ein tarifabhängiges Limit (siehe Tarife); prüfen Sie daher, ob Sie innerhalb dieses Limits liegen.

Schleifen und Aggregation

Loop und ResourceForEach werden im Zeitbudget als die vom body (onEach) deklarierte Zeit multipliziert mit der Iterationsobergrenze gerechnet. In den Beispielen dieses Abschnitts enthält der body keinen externen Aufruf, die deklarierte Zeit ist also 0, sodass das Grundbudget von 30 Sekunden die tatsächliche Grenze ist. Genau hier stößt eine Iteration in der Praxis auch an ihre Grenze (siehe Zeitbudget und Loop).

15. N Content aus einem Array-Input (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): Slots seeden

{ "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 läuft von from bis to, inklusive (Ganzzahl-Literale, step ist standardmäßig 1). name bindet den aktuellen Zähler an { /i }.

17. Cascade-Löschung (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 }" } } } ] }

Da ResourceForEach die Treffer intern paginiert und jedes Element löscht, werden ohne manuelle Paginierung alle passenden Kommentare (bis zur Plattformobergrenze) gelöscht und danach der Beitrag selbst. Da onEach keine Zeit deklariert, beträgt das Zeitbudget der Ausführung dieser Definition 30 Sekunden.

18. Schleifenakkumulation: SetVar-Summe

{ "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. Alle passenden Elemente in einem Rutsch verarbeiten

{ "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 }" } } } ] } ] }

Da ResourceForEach die Treffer intern paginiert, ist keine cursor-Schleife (Loop while + SetVar-Akkumulation) nötig. Es findet alle passenden Entwürfe und veröffentlicht jeden. Ist die Anzahl so groß, dass ein vollständiger Durchlauf schwierig ist, legen Sie mit limit eine Obergrenze für einen Durchlauf fest und setzen where auf die Bedingung „unbearbeitet", um bei einer erneuten Ausführung fortzufahren.

20. id-Batch-Sammlung aus einer E-Mail-Liste (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 }" } } } ] }

Ein Lesevorgang (ResourceFind) ist kein externer Aufruf und daher innerhalb eines Loop-body erlaubt. Vorhandensein und Fehlen werden jeweils mit merge akkumuliert.

Saga und Nebenläufigkeit

21. Zahlungs-Saga (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 }" } } } ] } ] }

Nach der Reservierung (draft): bei erfolgreicher Zahlung Bestätigung, publish und 201; bei einem Fehler löscht catch die Reservierung (Kompensation) und gibt 402 zurück; finally protokolliert immer. Da die löschbasierte Kompensation eine neue sys.id erzeugt, fällt sie unter die Einschränkung, bei der Referenzen brechen (siehe Keine Transaktionen und Kompensation in der Ausführungssemantik).

22. Optimistische Sperre (CAS)

{ "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, erneut versuchen" }, "isError": true, "statusCode": 409 } ] } ] }

Lesen Sie den Bestand, um eine frische sys.version zu erhalten, und ziehen Sie dann mit dieser Version ab (version). Hat eine andere Ausführung den Wert zwischen dem Lesen und dem Schreiben verändert, wird wegen einer Versionsabweichung abgebrochen und catch gibt 409 zurück. Der guard für unzureichenden Bestand liegt außerhalb von Try (normaler vorzeitiger Ausstieg).

E-Mail

23. Benachrichtigungs-E-Mail an den Käufer jeder Bestellung (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": "Der Versand hat begonnen",
          "body": "<p>Der Versand des von Ihnen bestellten Artikels hat begonnen.</p>" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "notified": { "en-US": true } } } ] } ] }

Es durchläuft die Bestellungen, für die noch keine Benachrichtigung gesendet wurde (bei denen fields.notified nicht true ist), sendet dem Käufer jeder Bestellung eine E-Mail und markiert unmittelbar danach notified. Da EmailSend pro E-Mail nur einen Empfänger hat, sendet man für den Mehrfachversand so mit ResourceForEach je Element (onEach kann externe Aufrufe enthalten). Übergeben mit toServiceUser, gelangt die Adresse des Mitglieds nicht in den Variablenraum des Script und wird erst unmittelbar vor dem Senden aufgelöst. Da where auf „unbearbeitet" gesetzt und am Ende von onEach die Fertigstellung markiert wird, wird bei einem Abbruch mittendrin nach einer erneuten Ausführung ab den verbliebenen Bestellungen fortgesetzt (schlägt die Markierung unmittelbar nach dem erfolgreichen Seiteneffekt fehl, kann dieser Fall in der nächsten Ausführung doppelt vorkommen; at-least-once).

Signaturprüfung

Ein Zahlungsdienstleister (PG, MoR) hängt beim Senden eines Webhooks eine Signatur an den Body. Die empfangende Seite muss, bevor sie irgendetwas tut, prüfen, ob sich diese Signatur mit dem eigenen geheimen Schlüssel reproduzieren lässt. Die beiden Beispiele unten sind die zwei Verfahren, die in der Praxis auseinandergehen. Das eine erzeugt einen Code mit dem geheimen Schlüssel (keyed), das andere hängt Felder und den geheimen Schlüssel aneinander und berechnet daraus einen Digest.

24. Webhook-Signatur prüfen (verpackten Header entpacken, Replay-Window)

{ "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 } } ] }

Da der Anbieter Zeitstempel und Code gemeinsam in einem Header sendet (t=1492774577,v1=<64 Zeichen hex>), lässt sich die zu signierende Nachricht nicht bilden, bevor der Header entpackt ist. Daher ergibt sich diese Reihenfolge.

  1. Das Capture von Regex entpackt den Header und teilt ihn in { /sig/1 } (Zeitstempel) und { /sig/2 } (Code). Index 0 ist der gesamte Treffer, ab 1 folgen die Capture-Gruppen. Passt das Format nicht, ist { /sig } gleich null, und es wird an dieser Stelle mit 400 zurückgeschickt.
  2. Signature nimmt "<Zeitstempel>.<Original-Body>" als Nachricht, erzeugt den Code und vergleicht ihn mit { /sig/2 }. Entscheidend ist, die Nachricht als Original vor dem Parsen ({ /rawPayload }) zu nehmen. Macht man aus dem geparsten /payload wieder einen String, werden Leerzeichen und Zahlennotation normalisiert, und man kommt nicht zu den Bytes zurück, die die Gegenseite signiert hat. Setzt man zwei Pointer nebeneinander in einen String, werden sie unmittelbar aneinandergehängt, daher braucht es keinen Operator.
  3. Ist { /verified } gleich false, folgt 401. Eine falsche Signatur und ein fehlender Header sind beides dasselbe false (der sendenden Seite wird nicht mitgeteilt, welches von beidem falsch war).
  4. Auch bei passender Signatur wird eine alte Anfrage abgewiesen. { /now/seconds } ist der Zeitpunkt, zu dem diese Ausführung begonnen hat, daher wird geprüft, ob der Abstand zum Zeitstempel in der Signatur das Replay-Window (hier 300 Sekunden) überschreitet. Der Zeitstempel kam als String aus dem Header, wird aber von der arithmetischen Operation in eine Zahl umgewandelt.
  5. Erst nachdem all das bestanden ist, wird die Bestellung gesucht und ihr Status geändert.

Da es keinen externen Aufruf gibt, ist auch keine Zeit deklariert; die Ausführung endet somit innerhalb des Grundbudgets von 30 Sekunden, und der Anbieter erhält die Antwort an dieser Stelle. Das secret für die Prüfung wird anders als secret: true in einem Http-Header nicht verschlüsselt gespeichert, halten Sie daher die Rollen, die dieses Script lesen können, eng (siehe Secret-Header im Sicherheitsmodell).

Es gibt zwei Wege, dem Anbieter das Aufrufen dieser Anlaufstelle zu ermöglichen, und die Weggabelung ist, ob dieser Anbieter benutzerdefinierte Header senden kann.

  • Kann er es, geben Sie ein Token aus, das nur die Execute-Berechtigung auf dieses Script enthält, lassen es in den Authorization-Header setzen und /execute aufrufen. Eine Rolle, die auf ein einzelnes bestimmtes Script eingeengt ist, wird unter script-Berechtigung der SpaceRole behandelt, das Token unter Space Access Token. Das ist der Standardweg.
  • Kann er es nicht (ein Anbieter, bei dem sich nur eine Callback-URL registrieren lässt und es keine Einstellung für zusätzliche Header gibt), schalten Sie anonymousCallEnabled dieses Script ein und registrieren die Adresse /execute/anonymous als Callback. Die Ausführung erfolgt dann unter der Identität des Autors, daher ist auch das updatedBy der Bestellung, die dieses Script ändert, der Autor, und da es keine Authentifizierung gibt, ist die obige Signaturprüfung die einzige Authentifizierung dieser Anlaufstelle. Die Bedingungen und die Speicherregeln werden unter Anonymer Aufruf behandelt.

Die obige Definition erfüllt unverändert die Bedingungen eines anonymen Script. Sie verwendet keinen createdBy: ":self"-Filter, und eine Anfrage, die die Signatur und das Replay-Window nicht besteht, wird mit Return abgebrochen, bevor irgendetwas angetastet wird.

25. Signaturprüfung per Hash ohne Schlüssel

{ "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 } } ] }

Das ist statt HMAC das Verfahren „festgelegte Felder und den geheimen Schlüssel aneinanderhängen und SHA256 berechnen". Hash hat kein secret-Feld; den geheimen Schlüssel (9f2c1b7ae4) schreiben Sie direkt in value, an die Stelle, an die ihn dieses Schema setzt. Da der Schlüssel je Schema vorne, hinten oder in der Mitte steht, drückt dieses Vorgehen alle Positionen aus.

encoding richten Sie nach der Notation der Gegenseite (Hex, HexUpper, Base64, Base64Url). Anders als bei Signature ist das Ergebnis ein String, daher müssen Sie den Vergleich selbst durchführen, und dieser Vergleich ist ein normaler Gleichheitsvergleich. Da die Obergrenze von value 128 Zeichen ist, verwenden Sie für ein Schema, das über den gesamten Body rechnet, Signature.

Mitglieder nachschlagen

26. Mitglied per E-Mail finden, Coupon und Benachrichtigungsmail

{ "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": "Ein Coupon wurde ausgestellt",
      "body": "<p>Hallo { /member/nickname }, wir haben Ihnen den Coupon { /coupon/fields/code/en-US } geschenkt.</p>" },
    { "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }

Über eine einzelne E-Mail-Adresse wird ein Mitglied gefunden und dessen sys.id als Eigentümer des Coupons und als Empfänger der E-Mail verwendet. Beim Lesen des Mitgliederverzeichnisses gelten diese Regeln.

  • sys.email wird verschlüsselt gespeichert und nimmt daher nur Operatoren der exakten Übereinstimmung an (eq, ne, in, nin). Geben Sie einen anderen Operator wie prefix, gibt es nicht stillschweigend 0 Treffer, sondern die Ausführung schlägt fehl.
  • Gibt es keinen Treffer, bindet ResourceFind null, daher verzweigen Sie über das Vorhandensein in derselben Form wie beim Suchen von Content.
  • Die Felder eines Mitglieds sind anders als bei Content und Media keine Locale-Map. Referenzieren Sie sie unmittelbar, etwa als { /member/nickname }.
  • Beim Senden der E-Mail holen Sie die Adresse nicht heraus, sondern übergeben die sys.id an toServiceUser. Da die Engine die Adresse unmittelbar vor dem Senden auflöst, gelangt die Adresse des Mitglieds nicht in den Variablenraum des Script.
  • Um diese Definition zu speichern, müssen die settings der SpaceRole des Autors SETTING_SERVICE_LOGIN enthalten. Ein Statement, das ein Mitglied erstellt, ändert oder löscht, lässt sich mit keiner Rolle speichern (siehe Mitgliederverzeichnis lesen).

EmailSend addiert sein timeoutMs (ohne Deklaration 10 Sekunden) zum Zeitbudget der Ausführung und zählt einmal auf das Limit für externe Aufrufe pro Definition.