Cookbook (Kumpulan Contoh Praktis)
Berbagai skenario ditampilkan sebagai ScriptDefinition yang lengkap. Untuk dasar sintaksisnya, lihat Katalog Statement dan Ekspresi nilai; untuk eksekusi dan batasan, lihat Semantik eksekusi, batasan, dan keamanan. Pada setiap contoh, nilai fields untuk penulisan berupa peta locale ({ "<locale>": nilai }), dan locale contoh diseragamkan menjadi en-US. Setiap contoh dijalankan secara inline pada jalur yang menangani permintaan panggilan, lalu mengembalikan hasilnya sebagai body respons panggilan itu. Pada contoh yang memiliki panggilan eksternal (Http·EmailSend), timeoutMs statement tersebut ditambahkan ke anggaran waktu eksekusi (untuk Http, × (1 + retry)), dan jika statement itu berada di dalam perulangan (Loop·ResourceForEach), nilainya dikalikan sebanyak batas atas iterasi (Anggaran waktu).
Daftar Isi
- CRUD Dasar: 1. Membuat dan mempublikasikan Content · 2. Update dengan nilai terkomputasi · 3. Kumpulkan daftar pesanan saya lalu kembalikan · 4. Baca satu, guard, lalu setujui
- Pencarian dan upsert: 5. slug upsert · 6. Kunci field dinamis dan patch locale
- API Eksternal: 7. Potong kredit di muka (CAS), panggilan LLM, refund · 8. URL gambar menjadi Media · 9. Gambar base64 menjadi Media · 10. Penanganan bersyarat setelah moderasi · 11. try/catch fallback · 12. Ringkasan dan tag dengan AI
- Paralel: 13. Menggabungkan setelah panggilan paralel · 14. Peninjauan pendaftaran
- Perulangan dan agregasi: 15. Membuat N dari input array · 16. penyemaian counted loop · 17. Penghapusan cascade · 18. Jumlah akumulasi loop · 19. Memproses seluruh item yang memenuhi kondisi secara massal · 20. Mengumpulkan id secara batch
- Saga dan konkurensi: 21. Saga pembayaran · 22. Kunci optimistis CAS
- Email: 23. Email notifikasi kepada pembeli tiap pesanan
- Verifikasi tanda tangan: 24. Verifikasi tanda tangan webhook · 25. Verifikasi tanda tangan hash tanpa kunci
- Pencarian anggota: 26. Menemukan anggota lewat email lalu mengirim kupon dan email notifikasi
CRUD Dasar
1. Membuat dan mempublikasikan 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. Update dengan nilai terkomputasi (jumlah tampilan +1)
{ "method": "Post",
"statements": [
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
"fields": { "viewCount": { "en-US": { "$+": [ "{ /payload/fields/viewCount }", 1 ] } } } } ] }3. Kumpulkan daftar pesanan saya lalu kembalikan
{ "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 }" } } ] }Dengan createdBy: ":self" ia menelusuri "hanya milik saya" dan mengumpulkan tiap item dengan SetVar lalu mengembalikannya. Karena ResourceForEach tidak mengikat hasil penelusuran sebagai koleksi, untuk mengembalikannya sebagai daftar Anda mengumpulkannya secara langsung seperti ini. Pada anggaran waktu, penelusuran dihitung sebagai waktu yang dideklarasikan onEach dikalikan jumlah item yang diproses. Di sini onEach tidak memuat panggilan eksternal sehingga waktu deklarasinya 0 dan anggaran dasar 30 detik menjadi batas yang sesungguhnya; jika Anda meletakkan panggilan eksternal pada onEach, perkalian itu masuk apa adanya ke anggaran, dan eksekusi terhenti begitu mencapai batas atas 180 detik (Anggaran waktu). Ketika Anda cukup membaca daftar lalu mengembalikannya, lebih baik memanggil langsung API daftar CDA/CMA dari frontend alih-alih menelusuri dengan Script.
4. Baca satu, guard, lalu setujui
{ "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 } } ] }Dengan mengikat satu item ke sebuah nama menggunakan ResourceRead, Anda dapat merujuknya langsung sebagai { /order/fields/... }. Jika tidak ada, pembacaan menghasilkan kesalahan (Anda bisa membungkusnya dengan Try).
Pencarian dan 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 mengikat langsung kecocokan pertama (atau null jika tidak ada), dan { "!!": "{ /found/sys/id }" } bercabang berdasarkan ada tidaknya.
6. Kunci field dinamis dan patch locale dinamis
{ "method": "Patch",
"statements": [
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
"fields": { "{ /payload/fields/fieldKey }": { "{ /payload/fields/locale }": "{ /payload/fields/value }" } } } ] }Baik kunci field maupun kunci bucket locale sama-sama merupakan referensi { /ptr }. Gunakan ini saat Anda ingin menaruh terjemahan ke dalam bucket locale tertentu.
API Eksternal
7. Guard kredit, potong di muka (CAS), panggilan LLM, refund saat gagal (contoh utama)
{ "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, coba lagi" }, "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 } ] } ] }Guard memeriksa apakah saldo mencukupi terlebih dahulu, lalu memotong sebelum panggilan eksternal. Pemotongan itu memasang kunci optimistis (CAS) pada sys.version milik wallet. Jika eksekusi lain mengubah wallet di antara pembacaan saldo dan pemotongan, eksekusi di-abort karena versi tidak cocok dan catch mengembalikan 409. Karena tidak ada panggilan eksternal yang dilakukan, permintaan bersamaan tidak terkena pemotongan ganda. Hanya setelah pemotongan dipastikan, LLM baru dipanggil, dan jika panggilan itu gagal, catch menambahkan kembali jumlah yang dipotong (cost) untuk melakukan refund (kompensasi), lalu mengembalikan 502. Urutannya adalah memastikan penagihan sebelum panggilan eksternal yang tidak dapat dibatalkan, lalu melakukan kompensasi hanya saat gagal. Kunci rahasia diletakkan pada header secret:true. Untuk batasan kompensasi, lihat Tanpa transaksi dan kompensasi pada Semantik eksekusi.
8. Menjadikan gambar (URL) sebagai Media lalu melampirkannya ke 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 }" } } } ] }Buat Media dengan sebuah name, dan ResourceCreate (Content) menaruh { /img/sys/id } ke dalam field referensi. Media memakai model fields yang sama dengan Content. file adalah instruksi ingest { source, encoding }. Penyerapan file tidak mendeklarasikan waktu sehingga diambil dari anggaran dasar 30 detik, dan tidak termasuk dalam batas panggilan eksternal per definisi (Batasan statis).
9. Gambar base64 menjadi 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. Publikasi atau penghapusan bersyarat setelah moderasi
{ "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 saat kegagalan eksternal
{ "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": "Pembuatan gagal" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ] }12. Mengisi ringkasan dan tag artikel dengan AI
{ "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 } ] } ] }Begitu artikel dibuat, model mengisi ringkasan dan tag-nya (dipasang ke Content.Create sebagai aksi terhubung sebuah Webhook). Responsnya di sini datang dalam dua lapis. responseType pada Http default-nya Json, jadi envelope respons dari API sudah berupa objek, tetapi jawaban yang dibuat model ada di dalamnya, di choices/0/message/content, sebagai string. Karena itu satu lapis lagi dibuka dengan ParseJson sebelum nilainya bisa diambil sebagai { /ai/summary } dan { /ai/tags }. Tag ditulis apa adanya sebagai array ke field Array (elemen ShortText).
Meski kontrak dipasang dengan keluaran terstruktur (response_format), yang datang bukan JSON ketika respons terpotong oleh batas panjang atau model menolak permintaan. Karena itu parsing dibungkus Try, yang mengubah kegagalan parsing menjadi 502. Pesan kegagalan memuat teks yang coba di-parsing, sehingga terlihat apa yang kembali. Untuk API yang envelope-nya saja sudah bukan JSON, beri Http sebuah responseType: "Text" lalu serahkan { /resp/body } langsung (lihat Http dan ParseJson).
Paralel
13. Menggabungkan 2 panggilan eksternal paralel menjadi 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. Peninjauan pendaftaran: skor paralel, lalu keputusan berbasis 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 } ] } ] }Anda juga dapat menyisipkan { /ptr } ke dalam jalur URL. Hasil branch dirujuk setelah join. Panggilan eksternalnya ada 2. Karena jumlah panggilan eksternal yang dapat dimuat satu definisi merupakan batas per paket (lihat Paket Harga), pastikan jumlahnya berada dalam batas itu.
Perulangan dan agregasi
Pada anggaran waktu, Loop dan ResourceForEach dihitung sebagai waktu yang dideklarasikan body (onEach) dikalikan batas atas iterasi. Contoh-contoh pada bagian ini tidak memuat panggilan eksternal di body-nya sehingga waktu deklarasinya 0, dan karena itu anggaran dasar 30 detik menjadi batas yang sesungguhnya. Di titik inilah perulangan benar-benar terhenti (Anggaran waktu, Loop).
15. N Content dari input array (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): menyemai slot
{ "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 mencakup from hingga to, inklusif (literal integer, step default-nya 1). name mengikat penghitung saat ini ke { /i }.
17. Penghapusan 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 memaginasi kecocokan secara internal dan menghapus tiap item, sehingga tanpa pagination manual ia menghapus semua komentar yang memenuhi kondisi (hingga batas atas platform) lalu menghapus postingannya sendiri. Karena onEach tidak mendeklarasikan waktu, anggaran waktu eksekusi definisi ini adalah 30 detik.
18. Akumulasi loop: penjumlahan 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. Memproses seluruh item yang memenuhi kondisi secara massal
{ "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 }" } } } ] } ] }Karena ResourceForEach memaginasi kecocokan secara internal, cursor loop (Loop while + akumulasi SetVar) tidak diperlukan. Ia menemukan semua draft yang memenuhi kondisi lalu mempublikasikan tiap item. Jika jumlahnya sangat banyak sehingga sulit diselesaikan seluruhnya, tetapkan batas atas yang diproses sekaligus dengan limit, dan jadikan where sebagai kondisi "belum diproses" agar dapat dilanjutkan lewat eksekusi ulang.
20. Mengumpulkan id secara batch dari daftar email (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 }" } } } ] }Pembacaan (ResourceFind) bukan panggilan eksternal, sehingga diizinkan di dalam body Loop. Keberadaan dan ketiadaan masing-masing diakumulasikan dengan merge.
Saga dan konkurensi
21. Saga pembayaran (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 }" } } } ] } ] }Setelah memesan (draft): saat pembayaran berhasil, ia mengonfirmasi, mempublikasikan, dan mengembalikan 201; saat gagal, catch menghapus pemesanan (kompensasi) dan mengembalikan 402; finally selalu mencatat log. Kompensasi berbasis penghapusan menghasilkan sys.id yang baru, sehingga termasuk dalam batasan di mana referensi menjadi rusak (lihat Tanpa transaksi dan kompensasi pada Semantik eksekusi).
22. Kunci optimistis 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, coba lagi" }, "isError": true, "statusCode": 409 } ] } ] }Baca stok untuk memperoleh sys.version terbaru, lalu potong dengan versi itu (version). Jika eksekusi lain mengubah nilai di antara pembacaan dan penulisan, eksekusi di-abort karena versi tidak cocok dan catch mengembalikan 409. Guard kehabisan stok berada di luar Try (pengembalian dini yang normal).
23. Email notifikasi kepada pembeli tiap pesanan (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": "Pengiriman telah dimulai",
"body": "<p>Pengiriman produk yang Anda pesan telah dimulai.</p>" },
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
"fields": { "notified": { "en-US": true } } } ] } ] }Ia menelusuri pesanan yang belum dikirimi notifikasi (fields.notified yang bukan true), mengirim email kepada pembeli tiap pesanan, lalu segera menandai notified. Karena EmailSend hanya menerima 1 penerima per email, pengiriman banyak email dilakukan per item dengan ResourceForEach seperti ini (onEach dapat memuat panggilan eksternal). Jika diberikan lewat toServiceUser, alamat anggota tidak masuk ke ruang variabel Script melainkan di-resolve tepat sebelum pengiriman. Karena where dijadikan "belum diproses" dan penyelesaian ditandai di akhir onEach, meski terputus di tengah, eksekusi ulang akan melanjutkan dari pesanan yang tersisa (jika penandaan gagal tepat setelah efek samping berhasil, item itu dapat terkirim ganda pada eksekusi berikutnya; at-least-once).
Verifikasi tanda tangan
Penyedia pembayaran (PG·MoR) melampirkan tanda tangan pada body ketika mengirim webhook. Pihak penerima harus memastikan tanda tangan itu dapat direproduksi dengan kunci rahasia yang dimilikinya sebelum melakukan apa pun. Dua contoh di bawah adalah dua cara yang memang benar-benar berbeda. Yang satu membuat kode memakai kunci rahasia (keyed), yang lain menghitung digest dengan menyambung field-field dan kunci rahasia.
24. Verifikasi tanda tangan webhook (menguraikan header terbungkus, 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 } } ] }Karena penyedia mengirim timestamp dan kode bersama dalam satu header (t=1492774577,v1=<hex 64 karakter>), Anda tidak dapat menyusun pesan yang ditandatangani sebelum menguraikan header itu. Karena itu urutannya ditetapkan seperti ini.
CapturepadaRegexmenguraikan header menjadi{ /sig/1 }(timestamp) dan{ /sig/2 }(kode). Indeks0adalah keseluruhan kecocokan dan mulai dari1adalah grup tangkapan. Jika formatnya tidak sesuai,{ /sig }bernilainullsehingga permintaan dikembalikan di tempat dengan400.Signaturemenjadikan"<timestamp>.<body asli>"sebagai pesan, membuat kode, lalu membandingkannya dengan{ /sig/2 }. Intinya adalah mengambil pesan dari teks asli sebelum parsing ({ /rawPayload }). Jika/payloadyang sudah di-parsing dijadikan string kembali, spasi dan notasi angka akan dinormalkan sehingga tidak kembali menjadi byte yang ditandatangani pihak lain. Menempatkan dua pointer bersebelahan di dalam satu string akan langsung menyambungkannya, sehingga tidak perlu operator.- Jika
{ /verified }bernilaifalse, hasilnya401. Tanda tangan yang salah dan header yang tidak ada keduanya menjadi satu, yaitufalse(kita tidak memberi tahu pengirim bagian mana yang salah). - Meski tanda tangannya benar, permintaan yang sudah lama tetap ditolak.
{ /now/seconds }adalah waktu ketika eksekusi ini dimulai, jadi kita melihat apakah selisihnya dengan timestamp yang dibawa tanda tangan melampaui replay window (di sini 300 detik). Timestamp datang dari header sebagai string, tetapi operasi aritmetika mengubahnya menjadi angka. - Baru setelah lolos sampai sini, pesanan dicari dan statusnya diubah.
Karena tidak ada panggilan eksternal, tidak ada waktu yang dideklarasikan, sehingga eksekusinya selesai dalam anggaran dasar 30 detik dan penyedia menerima responsnya di tempat. secret yang dipakai untuk verifikasi, berbeda dengan secret: true pada header Http, tidak disimpan terenkripsi, jadi persempit role yang dapat membaca Script ini (header secret pada Model keamanan).
Ada dua cara agar penyedia dapat memanggil pintu ini, dan penentunya adalah apakah penyedia itu dapat mengirim header kustom.
- Jika bisa mengirim, terbitkan token yang hanya memuat izin Execute untuk Script tersebut, minta penyedia menyertakannya pada header
Authorization, lalu memanggil/execute. Role yang dipersempit ke satu Script tertentu dibahas di izin script pada SpaceRole, dan tokennya di Space Access Token. Cara ini adalah yang utama. - Jika tidak bisa mengirim (penyedia yang hanya dapat mendaftarkan URL callback dan tidak memiliki pengaturan untuk melampirkan header), aktifkan
anonymousCallEnabledpada Script tersebut lalu daftarkan alamat/execute/anonymoussebagai callback. Saat itu eksekusinya memakai identitas penulis, sehinggaupdatedBydari pesanan yang diubah Script ini juga menjadi penulis, dan karena tidak ada autentikasi, verifikasi tanda tangan di atas menjadi satu-satunya autentikasi pintu ini. Syarat dan aturan penyimpanannya dibahas di Panggilan anonim.
Definisi di atas apa adanya sudah memenuhi syarat Script anonim. Ia tidak memakai filter createdBy: ":self", dan permintaan yang tidak lolos tanda tangan serta replay window diputus dengan Return sebelum menyentuh apa pun.
25. Verifikasi tanda tangan hash tanpa kunci
{ "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 } } ] }Ini cara "menyambung field-field yang sudah ditentukan dengan kunci rahasia lalu menghitung SHA256", bukan HMAC. Hash tidak memiliki field secret; kunci rahasianya (9f2c1b7ae4) ditulis langsung di dalam value pada posisi yang ditetapkan skema itu. Karena setiap skema menempatkan kunci di depan, di belakang, atau di tengah, cara ini dapat menyatakan semua posisi tersebut.
encoding disesuaikan dengan notasi pihak lain (Hex·HexUpper·Base64·Base64Url). Berbeda dengan Signature, hasilnya berupa string sehingga Anda harus membandingkannya sendiri, dan perbandingan itu adalah perbandingan kesetaraan biasa. Karena batas atas value adalah 128 karakter, untuk skema yang menghitung atas seluruh body gunakan Signature.
Pencarian anggota
26. Menemukan anggota lewat email lalu mengirim kupon dan email notifikasi
{ "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": "Kupon Anda telah diterbitkan",
"body": "<p>Kupon { /coupon/fields/code/en-US } telah diberikan kepada { /member/nickname }.</p>" },
{ "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }Ia menemukan anggota hanya dengan satu email, lalu memakai sys.id-nya sebagai pemilik kupon dan penerima email. Aturan ketika membaca direktori anggota adalah sebagai berikut.
- Karena
sys.emaildisimpan terenkripsi, ia hanya menerima operator keluarga kecocokan persis (eq·ne·in·nin). Jika Anda memberi operator lain sepertiprefix, yang terjadi bukan 0 hasil secara senyap melainkan eksekusi yang gagal. - Jika tidak ada kecocokan,
ResourceFindmengikatnull, sehingga percabangan keberadaannya berbentuk sama seperti ketika mencari Content. - Berbeda dengan Content dan Media, field anggota bukan peta locale. Rujuk apa adanya seperti
{ /member/nickname }. - Ketika mengirim email, jangan mengeluarkan alamatnya, melainkan berikan
sys.idpadatoServiceUser. Engine me-resolve alamat tepat sebelum pengiriman sehingga alamat anggota tidak masuk ke ruang variabel Script. - Untuk menyimpan definisi ini,
settingspada SpaceRole penulis harus memuatSETTING_SERVICE_LOGIN. Statement yang membuat, mengubah, atau menghapus anggota tidak dapat disimpan dengan role apa pun (Membaca direktori anggota).
EmailSend menambahkan timeoutMs (atau 10 detik jika tidak dideklarasikan) ke anggaran waktu eksekusi, dan dihitung sebagai satu terhadap batas panggilan eksternal per definisi.
Dokumen terkait
- Katalog Statement: field dan hasil dari statement yang dipakai dalam contoh.
- Ekspresi nilai: referensi, JsonLogic, dan aturan peta locale.
- Semantik eksekusi, batasan, dan keamanan: urutan eksekusi, kompensasi, penguncian optimistis, dan batasan.
- Ikhtisar Script: struktur tingkat atas dan waktu yang diberikan untuk satu eksekusi.
