Cookbook (Exemplos práticos)
Cada cenário é mostrado como um ScriptDefinition completo. Para a base da sintaxe, consulte o Catálogo de statements e Expressões de valor; para a execução e as restrições, consulte Semântica de execução, restrições e segurança. Em todos os exemplos, o valor de fields na escrita é um mapa de locale ({ "<locale>": valor }), e o locale dos exemplos foi padronizado em en-US. Todos os exemplos são executados inline, no caminho que processa a requisição da chamada, e devolvem o resultado no corpo da resposta da chamada. Nos exemplos que têm uma chamada externa (Http·EmailSend), o timeoutMs daquele statement é somado ao orçamento de tempo de execução (em Http, × (1 + retry)) e, se aquele statement estiver dentro de uma iteração (Loop·ResourceForEach), é multiplicado pelo limite máximo de iterações (veja Orçamento de tempo).
Índice
- CRUD básico: 1. Criar e publicar Content · 2. Atualizar com valor calculado · 3. Reunir e retornar minha lista de pedidos · 4. Ler um item, aplicar guard e aprovar
- Consulta e upsert: 5. upsert por slug · 6. Chave de campo dinâmica e patch de locale
- API externa: 7. Débito antecipado de crédito (CAS), chamada de LLM, reembolso · 8. URL de imagem para Media · 9. Imagem base64 para Media · 10. Tratamento condicional após moderação · 11. fallback com try/catch · 12. Resumo e tags com IA
- Paralelo: 13. Mesclar após chamadas paralelas · 14. Análise de cadastro
- Loops e agregação: 15. Criar N a partir de um array · 16. Semente de loop contado · 17. Exclusão em cascata · 18. Acumulação em loop: soma · 19. Processar em lote todos os itens que atendem à condição · 20. Coletar ids em lote
- Saga e concorrência: 21. Saga de pagamento · 22. CAS de bloqueio otimista
- E-mail: 23. E-mail de notificação ao comprador de cada pedido
- Verificação de assinatura: 24. Verificar a assinatura de um webhook · 25. Verificar uma assinatura de hash sem chave
- Consulta de membros: 26. Encontrar o membro pelo e-mail, dar cupom e enviar aviso
CRUD básico
1. Criar e publicar 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. Atualizar com valor calculado (contagem de visualizações +1)
{ "method": "Post",
"statements": [
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
"fields": { "viewCount": { "en-US": { "$+": [ "{ /payload/fields/viewCount }", 1 ] } } } } ] }3. Reunir e retornar minha lista de pedidos
{ "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 }" } } ] }Com createdBy: ":self", percorre "apenas os meus" e reúne os itens com SetVar para retornar. Como o ResourceForEach não vincula o resultado do percurso como uma coleção, para devolver uma lista você reúne você mesmo desta forma. No orçamento de tempo, o percurso entra como o tempo declarado por onEach multiplicado pela quantidade de itens processados. Aqui não há chamada externa em onEach, portanto o tempo declarado é 0 e o orçamento base de 30 segundos é o limite efetivo; se você colocar uma chamada externa em onEach, essa multiplicação entra tal como está no orçamento e, ao alcançar o limite de 180 segundos, a execução é interrompida ali (veja Orçamento de tempo). Quando basta ler uma lista e devolvê-la, é melhor chamar diretamente a API de listagem da CDA/CMA no frontend, em vez de percorrer com um Script.
4. Ler um item, aplicar guard e aprovar
{ "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 } } ] }Ao vincular um único item a um nome com ResourceRead, você o referencia diretamente como { /order/fields/... }. Se não existir, a leitura gera um erro (é possível envolvê-la com Try).
Consulta e upsert
5. upsert por 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 } ] } ] }O ResourceFind vincula diretamente a primeira correspondência (ou null, se não houver nenhuma), e { "!!": "{ /found/sys/id }" } faz a ramificação conforme a existência.
6. Chave de campo dinâmica e patch de locale dinâmico
{ "method": "Patch",
"statements": [
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
"fields": { "{ /payload/fields/fieldKey }": { "{ /payload/fields/locale }": "{ /payload/fields/value }" } } } ] }Tanto a chave de campo quanto a chave de bucket de locale são referências { /ptr }. Use isso quando quiser inserir uma tradução em um bucket de locale específico.
API externa
7. Guard de crédito, débito antecipado (CAS), chamada de LLM, reembolso em caso de falha (exemplo principal)
{ "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, tente novamente" }, "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 } ] } ] }Com um guard, verifica-se se o saldo é suficiente e, em seguida, debita-se antes da chamada externa. O débito aplica um bloqueio otimista (CAS) sobre o sys.version da wallet. Se outra execução alterou a wallet entre a leitura do saldo e o débito, ele aborta por incompatibilidade de versão e o catch retorna 409. Como nenhuma chamada externa foi feita, requisições concorrentes não são debitadas duas vezes. Somente depois que o débito é confirmado é que a LLM é chamada e, se essa chamada falhar, o catch soma de volta o valor debitado (cost) para fazer um reembolso (compensação) e, então, retorna 502. A ordem é confirmar a cobrança antes da chamada externa irreversível e compensar somente em caso de falha. A chave secreta fica em um cabeçalho secret:true. Para os limites da compensação, consulte Sem transações e compensação na Semântica de execução.
8. Criar Media a partir de uma imagem (URL) e anexar ao 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 }" } } } ] }Crie o Media com um name, e o ResourceCreate (Content) insere { /img/sys/id } no campo de referência. O Media usa o mesmo modelo de fields que o Content. file é a instrução de ingestão { source, encoding }. A ingestão de arquivo não tem tempo declarado, portanto sai do orçamento base de 30 segundos, e também não entra no limite de chamadas externas por definição (veja Restrições estáticas).
9. Imagem base64 para 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. Publicação ou exclusão condicional após moderação
{ "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 em caso de falha externa
{ "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": "Falha na geração" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ] }12. Preencher resumo e tags de um post com IA
{ "method": "Post",
"statements": [
{ "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
"headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
"body": { "prompt": "{ /payload/fields/body }", "response_format": { "type": "json_object" } },
"timeoutMs": 15000, "name": "resp" },
{ "type": "Try",
"body": [
{ "type": "ParseJson", "name": "ai", "value": "{ /resp/body/choices/0/message/content }" },
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
"fields": { "summary": { "en-US": "{ /ai/summary }" },
"tags": { "en-US": "{ /ai/tags }" } } },
{ "type": "Return", "value": { "ok": true, "tags": "{ /ai/tags }" } } ],
"catch": [
{ "type": "Return", "value": { "ok": false, "reason": "model did not return JSON" }, "isError": true, "statusCode": 502 } ] } ] }Quando um post é criado, o modelo preenche o resumo e as tags dele (é pendurado em Content.Create como ação vinculada de um Webhook). Aqui a resposta vem em duas camadas. O responseType do Http é Json por padrão, então o envelope de resposta da API já é um objeto, mas a resposta que o modelo produziu está dentro dele, em choices/0/message/content, como string. Por isso é preciso descascar mais uma camada com ParseJson antes de conseguir tirar os valores como { /ai/summary } e { /ai/tags }. As tags são escritas como array, do jeito que estão, em um campo Array (elementos de ShortText).
Mesmo firmando o contrato com saída estruturada (response_format), chega algo que não é JSON quando a resposta é cortada pelo limite de tamanho ou o modelo recusa o pedido. Por isso o parse fica envolvido em Try, que transforma a falha de parse em um 502. A mensagem de falha leva o texto que se tentou parsear, então dá para ver o que voltou. Se o envelope de uma API já não é JSON, dê ao Http um responseType: "Text" e passe { /resp/body } direto (veja Http e ParseJson).
Paralelo
13. Mesclar duas chamadas externas paralelas em 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. Análise de cadastro: pontuações em paralelo e decisão com 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 } ] } ] }Você também pode inserir { /ptr } no caminho da URL. Os resultados das branches são referenciados após o join. Há duas chamadas externas. Como o número de chamadas externas que uma definição pode conter é um limite por plano (veja Planos), confirme se você está dentro desse limite.
Loops e agregação
No orçamento de tempo, Loop e ResourceForEach entram como o tempo declarado pelo body (onEach) multiplicado pelo limite máximo de iterações. Nos exemplos desta seção não há chamada externa no body, portanto o tempo declarado é 0 e o orçamento base de 30 segundos é o limite efetivo. É também nesse ponto que uma iteração de fato é barrada (veja Orçamento de tempo e Loop).
15. N Content a partir de um array de entrada (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. Loop contado (for): semear 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" } } } ] } ] }O for vai de from até to, inclusive (literais inteiros, step padrão 1). O name vincula o contador atual a { /i }.
17. Exclusão em cascata (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 }" } } } ] }Como o ResourceForEach pagina as correspondências internamente e exclui cada item, sem paginação manual ele apaga todos os comentários que atendem à condição (até o limite máximo da plataforma) e depois apaga o próprio post. Como onEach não tem tempo declarado, o orçamento de tempo de execução desta definição é de 30 segundos.
18. Acumulação em loop: soma com 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. Processar em lote todos os itens que atendem à condição
{ "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 }" } } } ] } ] }Como o ResourceForEach pagina as correspondências internamente, não é preciso um loop com cursor (Loop while + acumulação de SetVar). Encontra todos os rascunhos que atendem à condição e publica cada item. Se a quantidade for muito grande e concluir tudo for difícil, defina com limit um limite máximo a processar de uma vez e deixe where com a condição "não processado" para continuar o processamento em uma reexecução.
20. Coletar ids em lote a partir de uma lista de 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 }" } } } ] }Uma leitura (ResourceFind) não é uma chamada externa, portanto é permitida dentro do corpo de um Loop. A presença e a ausência são acumuladas, cada uma, com merge.
Saga e concorrência
21. Saga de pagamento (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 }" } } } ] } ] }Após a reserva (draft): em caso de pagamento bem-sucedido, ele confirma, publica e retorna 201; em caso de falha, o catch exclui a reserva (compensação) e retorna 402; o finally sempre registra em log. A compensação por exclusão gera um novo sys.id, portanto está dentro da limitação em que as referências se quebram (veja Sem transações e compensação na Semântica de execução).
22. CAS de bloqueio otimista
{ "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, tente novamente" }, "isError": true, "statusCode": 409 } ] } ] }Leia o estoque para obter um sys.version atualizado e, então, debite com essa versão (version). Se outra execução alterou o valor entre a leitura e a escrita, ele aborta por incompatibilidade de versão e o catch retorna 409. O guard de falta de estoque fica fora do Try (um retorno antecipado normal).
23. E-mail de notificação ao comprador de cada pedido (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": "Sua entrega começou",
"body": "<p>A entrega do produto que você pediu começou.</p>" },
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
"fields": { "notified": { "en-US": true } } } ] } ] }Percorre os pedidos que ainda não foram notificados (aqueles em que fields.notified não é true), envia um e-mail ao comprador de cada pedido e marca notified logo em seguida. Como o EmailSend tem 1 destinatário por e-mail, o envio em lote é feito assim, item a item, com ResourceForEach (onEach pode conter chamadas externas). Ao fornecer toServiceUser, o endereço do membro não entra no espaço de variáveis do Script e é resolvido imediatamente antes do envio. Como where foi deixado como "não processado" e a conclusão é marcada ao fim de onEach, mesmo que seja interrompido no meio, uma reexecução continua a partir dos pedidos restantes (se a marcação falhar logo após o sucesso do efeito colateral, aquele item pode ser duplicado na próxima execução; at-least-once).
Verificação de assinatura
As processadoras de pagamentos (PG·MoR) anexam uma assinatura ao corpo quando enviam um webhook. Quem recebe precisa, antes de fazer qualquer coisa, conferir se essa assinatura é reproduzível com a chave secreta que ele tem. Os dois exemplos abaixo são os dois métodos que de fato se dividem na prática: um gera um código com a chave secreta (keyed), e o outro concatena campos com a chave secreta e calcula um digest.
24. Verificar a assinatura de um webhook (desempacotar o cabeçalho, janela de replay)
{ "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 } } ] }Como o provedor envia o timestamp e o código juntos em um único cabeçalho (t=1492774577,v1=<64 caracteres hex>), não é possível montar a mensagem a assinar antes de desempacotar o cabeçalho. Por isso a ordem fica assim.
- O
CapturedeRegexdesempacota o cabeçalho e o divide em{ /sig/1 }(o timestamp) e{ /sig/2 }(o código). O índice0é a correspondência inteira, e de1em diante vêm os grupos de captura. Se o formato não coincidir,{ /sig }énulle a requisição é devolvida ali mesmo com400. - O
Signaturetoma"<timestamp>.<corpo original>"como mensagem, gera o código e o compara com{ /sig/2 }. O ponto central é tomar a mensagem a partir do original, antes do parse ({ /rawPayload }). Transformar de novo em string o/payloadparseado normaliza espaços e notação numérica, e não devolve os bytes que a outra parte assinou. Colocar os dois ponteiros lado a lado em uma string já os concatena, portanto nenhum operador é necessário. - Se
{ /verified }forfalse, é401. Assinatura errada e cabeçalho ausente são a mesma coisa, ambosfalse(não se informa a quem enviou qual dos dois estava errado). - Mesmo com a assinatura correta, uma requisição antiga é recusada. Como
{ /now/seconds }é o instante em que esta execução começou, verifica-se se a diferença em relação ao timestamp que acompanha a assinatura passa da janela de replay (aqui, 300 segundos). O timestamp veio do cabeçalho como string, mas a operação aritmética o converte em número. - Só depois de passar por tudo isso é que o pedido é encontrado e tem seu status alterado.
Como não há chamada externa, não há tempo declarado, portanto tudo termina dentro do orçamento base de 30 segundos e o provedor recebe a resposta ali mesmo. O secret usado na verificação, diferente do secret: true de um cabeçalho Http, não é armazenado de forma criptografada, portanto mantenha estreito o conjunto de papéis que podem ler este Script (veja cabeçalhos secret no modelo de segurança).
Há duas formas de permitir que o provedor chame este canal, e o que decide entre elas é se esse provedor consegue enviar cabeçalhos personalizados.
- Se conseguir, emita um token que contenha apenas a permissão Execute daquele Script, peça que ele o coloque no cabeçalho
Authorizatione chame/execute. O papel que restringe a um único Script é abordado em permissão script do SpaceRole, e o token, em Space Access Token. Esta é a forma padrão. - Se não conseguir (um provedor no qual só é possível registrar a URL de callback, sem configuração para anexar cabeçalhos), ative o
anonymousCallEnableddaquele Script e registre o endereço/execute/anonymouscomo callback. Nesse caso, a execução passa a ser com a identidade do autor, portanto oupdatedBydo pedido que este Script altera também é o autor e, como não há autenticação, a verificação de assinatura acima passa a ser a única autenticação deste canal. As condições e as regras de salvamento são abordadas em Chamada anônima.
A definição acima já satisfaz as condições de um Script anônimo: não usa o filtro createdBy: ":self" e interrompe com Return, antes de tocar em qualquer coisa, as requisições que não passam pela assinatura e pela janela de replay.
25. Verificar uma assinatura de hash sem chave
{ "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 } } ] }Em vez de HMAC, é o método de "concatenar campos determinados com a chave secreta e calcular SHA256". O Hash não tem campo secret: a chave secreta (9f2c1b7ae4) é escrita diretamente dentro de value, na posição em que aquele esquema a coloca. Como cada esquema põe a chave no início, no fim ou no meio, este formato é o que expressa todas as posições.
O encoding acompanha a notação da outra parte (Hex·HexUpper·Base64·Base64Url). Diferente de Signature, o resultado é uma string, portanto você mesmo precisa fazer a comparação, e essa comparação é uma comparação de igualdade comum. Como o limite máximo de value é 128 caracteres, para esquemas que calculam sobre o corpo inteiro use Signature.
Consulta de membros
26. Encontrar o membro pelo e-mail, dar cupom e enviar aviso
{ "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": "Seu cupom foi emitido",
"body": "<p>Demos a { /member/nickname } o cupom { /coupon/fields/code/en-US }.</p>" },
{ "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }Encontra um membro por um único e-mail e usa o sys.id dele como proprietário do cupom e como destinatário do e-mail. As regras para ler o diretório de membros são estas.
- Como
sys.emailé armazenado criptografado, ele aceita apenas operadores da família de igualdade exata (eq·ne·in·nin). Se você passar outro operador, comoprefix, a execução falha, em vez de devolver silenciosamente 0 resultado. - Se não houver correspondência, o
ResourceFindvinculanull, portanto a ramificação por existência tem o mesmo formato de quando você busca um Content. - Diferente de Content e Media, os campos do membro não são mapas de locale. Referencie-os diretamente, como em
{ /member/nickname }. - Ao enviar o e-mail, não extraia o endereço: passe o
sys.idemtoServiceUser. Como o motor resolve o endereço imediatamente antes do envio, o endereço do membro não entra no espaço de variáveis do Script. - Para salvar esta definição, o
settingsdo SpaceRole do autor precisa terSETTING_SERVICE_LOGIN. Nenhum statement que cria, altera ou exclui um membro é salvo com papel algum (veja Leitura do diretório de membros).
O EmailSend soma timeoutMs (10 segundos se não houver declaração) ao orçamento de tempo de execução e é contado como um no limite de chamadas externas por definição.
Documentos relacionados
- Catálogo de statements: os campos e resultados dos statements usados nos exemplos.
- Expressões de valor: referências, JsonLogic e regras de mapa de locale.
- Semântica de execução, restrições e segurança: ordem de execução, compensação, bloqueio otimista e restrições.
- Visão geral do Script: a estrutura de nível superior e o tempo concedido a uma execução.
