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 · 24. Modelo de e-mail editado no estúdio de conteúdo · 25. Newsletter escrita pelo administrador
- Verificação de assinatura: 26. Verificar a assinatura de um webhook · 27. Verificar uma assinatura de hash sem chave
- Consulta de membros: 28. Encontrar o membro pelo e-mail, dar cupom e enviar aviso
- Envio de arquivos: 29. Enviar a foto do produto a um serviço de análise de imagens e registrar o resultado
- Transformação de arquivos: 30. Cópia em PDF de um e-book com o e-mail do comprador estampado
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 { /secrets/LLM_API_KEY }" } ],
"body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 15000, "name": "resp" },
{ "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
"fields": { "text": { "en-US": "{ /resp/body/choices/0/message/content }" } }, "name": "out" },
{ "type": "Return", "value": { "ok": true, "id": "{ /out/sys/id }", "remaining": "{ /charged/fields/balance/en-US }" } } ],
"catch": [
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
"fields": { "balance": { "en-US": { "$+": [ "{ /charged/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } } },
{ "type": "Return", "value": { "ok": false, "reason": "generation failed, refunded" }, "isError": true, "statusCode": 502 } ] } ],
"secrets": [ { "key": "LLM_API_KEY", "value": "sk-...", "description": "Chave de API para chamar a LLM" } ] }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 de API da LLM é declarada em secrets e lida no cabeçalho como { /secrets/LLM_API_KEY }. 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": "{ /secrets/IMAGE_API_KEY }" } ],
"body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
{ "type": "ResourceCreate", "resource": "Media",
"fields": { "title": { "en-US": "{ /payload/fields/prompt }" },
"file": { "en-US": { "source": "{ /gen/body/data/0/url }", "encoding": "url" } } }, "name": "img" },
{ "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_artwork" } },
"fields": { "prompt": { "en-US": "{ /payload/fields/prompt }" }, "image": { "en-US": "{ /img/sys/id }" } } } ],
"secrets": [ { "key": "IMAGE_API_KEY", "value": "...", "description": "Chave da API de geração de imagens" } ] }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": "{ /secrets/IMAGE_API_KEY }" } ],
"body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
{ "type": "ResourceCreate", "resource": "Media",
"fields": { "file": { "en-US": { "source": "{ /gen/body/data/0/b64_json }", "encoding": "base64" } } } } ],
"secrets": [ { "key": "IMAGE_API_KEY", "value": "...", "description": "Chave da API de geração de imagens" } ] }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": "{ /secrets/MODERATION_API_KEY }" } ],
"body": { "text": "{ /payload/fields/body }" }, "name": "mod" },
{ "type": "If", "condition": { "==": [ "{ /mod/body/flagged }", true ] },
"then": [ { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ],
"else": [ { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] } ],
"secrets": [ { "key": "MODERATION_API_KEY", "value": "...", "description": "Chave da API de moderação" } ] }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": "{ /secrets/GEN_API_KEY }" } ],
"body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 8000, "name": "resp" },
{ "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
"fields": { "text": { "en-US": "{ /resp/body/text }" }, "source": { "en-US": "primary" } } } ],
"catch": [
{ "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
"fields": { "text": { "en-US": "Falha na geração" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ],
"secrets": [ { "key": "GEN_API_KEY", "value": "...", "description": "Chave da API de geração" } ] }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 { /secrets/LLM_API_KEY }" } ],
"body": { "prompt": "{ /payload/fields/body }", "response_format": { "type": "json_object" } },
"timeoutMs": 15000, "name": "resp" },
{ "type": "Try",
"body": [
{ "type": "ParseJson", "name": "ai", "value": "{ /resp/body/choices/0/message/content }" },
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
"fields": { "summary": { "en-US": "{ /ai/summary }" },
"tags": { "en-US": "{ /ai/tags }" } } },
{ "type": "Return", "value": { "ok": true, "tags": "{ /ai/tags }" } } ],
"catch": [
{ "type": "Return", "value": { "ok": false, "reason": "model did not return JSON" }, "isError": true, "statusCode": 502 } ] } ],
"secrets": [ { "key": "LLM_API_KEY", "value": "sk-...", "description": "Chave de API para chamar a LLM" } ] }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": "{ /secrets/FRAUD_API_KEY }" } ],
"body": { "email": "{ /payload/fields/email }" }, "name": "fraud" } ],
[ { "type": "Http", "method": "GET", "url": "https://api.credit.com/v1/{ /payload/fields/userId }/score",
"headers": [ { "key": "x-api-key", "value": "{ /secrets/CREDIT_API_KEY }" } ], "name": "credit" } ] ] },
{ "type": "If",
"condition": { "and": [ { "<": [ "{ /fraud/body/risk }", 0.5 ] }, { ">=": [ "{ /credit/body/score }", 700 ] } ] },
"then": [
{ "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
"fields": { "email": { "en-US": "{ /payload/fields/email }" }, "status": { "en-US": "approved" } } },
{ "type": "Return", "value": { "decision": "approved" }, "statusCode": 201 } ],
"else": [ { "type": "Return", "value": { "decision": "manual-review" }, "statusCode": 202 } ] } ],
"secrets": [ { "key": "FRAUD_API_KEY", "value": "...", "description": "Chave da API de pontuação de fraude" },
{ "key": "CREDIT_API_KEY", "value": "...", "description": "Chave da API de pontuação de crédito" } ] }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 { /secrets/PAYMENT_API_KEY }" } ],
"body": { "amount": "{ /payload/fields/amount }", "ref": "{ /order/sys/id }" }, "timeoutMs": 10000, "name": "pay" },
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
"fields": { "status": { "en-US": "paid" }, "txId": { "en-US": "{ /pay/body/transactionId }" } }, "publish": true },
{ "type": "Return", "value": { "orderId": "{ /order/sys/id }", "status": "paid" }, "statusCode": 201 } ],
"catch": [
{ "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } } },
{ "type": "Return", "value": { "reason": "payment failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 402 } ],
"finally": [
{ "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_paylog" } },
"fields": { "orderRef": { "en-US": "{ /order/sys/id }" }, "amount": { "en-US": "{ /payload/fields/amount }" } } } ] } ],
"secrets": [ { "key": "PAYMENT_API_KEY", "value": "sk-...", "description": "Chave secreta da API de pagamentos" } ] }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).
24. Enviar com um modelo de e-mail editado no estúdio de conteúdo (templateBody)
{ "method": "Post",
"statements": [
{ "type": "ResourceRead", "resource": "Content", "name": "tpl",
"target": { "sys": { "id": "cnt_mail_shipping" } } },
{ "type": "ResourceRead", "resource": "Content", "name": "order",
"target": { "sys": { "id": "{ /payload/orderId }" } } },
{ "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
"toServiceUser": { "sys": { "id": "{ /order/fields/buyer/en-US/sys/id }" } },
"subject": "Sua entrega foi iniciada (número do pedido { /order/sys/id })",
"templateBody": {
"source": "{ /tpl/fields/html/en-US }",
"data": { "name": "{ /order/fields/buyerName/en-US }",
"tracking": "{ /order/fields/trackingNo/en-US }" } } },
{ "type": "Return", "value": { "sent": true } } ] }O modelo fica em um Content (o Field html de cnt_mail_shipping) e só as lacunas dele são preenchidas. Mesmo que a pessoa de marketing mude o texto no estúdio de conteúdo, o Script permanece como está. O modelo tem este formato.
<p>{ /name }, a entrega do produto que você pediu foi iniciada.</p>
<p>Número de rastreio: { /tracking }</p>O source precisa ser um único ponteiro que aponta para um Content lido por esta definição. Coloque antes do EmailSend o ResourceRead que lê o modelo. Como os Statement são executados na ordem em que foram escritos, apontar para um nome ainda não lido deixa o valor vazio e a execução falha. As lacunas do modelo leem apenas data, portanto o modelo não pode ler /payload diretamente. Uma lacuna que aponta para uma chave ausente em data vira texto vazio.
25. Enviar aos assinantes a newsletter escrita pelo administrador (autoEscape)
{ "method": "Post",
"statements": [
{ "type": "ResourceRead", "resource": "Content", "name": "issue",
"target": { "sys": { "id": "{ /payload/issueId }" } } },
{ "type": "ResourceRead", "resource": "Content", "name": "tpl",
"target": { "sys": { "id": "cnt_mail_newsletter" } } },
{ "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_subscriber" } },
"where": { "fields.active": { "eq": true } }, "order": "sys.createdAt,sys.id",
"from": "Current", "advanced": false, "name": "sub",
"onEach": [
{ "type": "EmailSend", "account": { "sys": { "id": "eml_news" } },
"to": "{ /sub/fields/email/en-US }",
"subject": "{ /issue/fields/title/en-US }",
"autoEscape": false,
"templateBody": {
"source": "{ /tpl/fields/html/en-US }",
"data": { "article": "{ /issue/fields/bodyHtml/en-US }" } } } ] } ] }O corpo da newsletter é um HTML que o administrador escreveu em rich text; deixado como está, as tags apareceriam como texto. Com autoEscape em false, o markup contido em data é renderizado.
Mantenha estreito o alcance do false. Essa configuração vale para todos os valores interpolados daquele EmailSend. Por isso, o data deste exemplo traz apenas o article escrito pelo administrador. Se você misturar no mesmo data um valor vindo de fora, como o nome do assinante, o markup contido nesse valor também será renderizado. Se precisar de um valor assim, coloque-o no lado do modelo ou separe os envios. O próprio modelo também é sempre renderizado cru, independentemente de autoEscape, portanto confirme que tanto o modelo quanto os valores que entram em data só vêm das mãos de quem tem permissão de escrita naquele Content.
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.
26. 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": "{ /secrets/WEBHOOK_SIGNING_SECRET }",
"value": "{ /sig/1 }.{ /rawPayload }", "expected": "{ /sig/2 }" },
{ "type": "If", "condition": { "!": "{ /verified }" },
"then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
{ "type": "If", "condition": { ">=": [ { "-": [ "{ /now/seconds }", "{ /sig/1 }" ] }, 300 ] },
"then": [ { "type": "Return", "value": { "ok": false, "reason": "timestamp outside the replay window" }, "isError": true, "statusCode": 401 } ] },
{ "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
"where": { "fields.orderId": { "eq": "{ /payload/data/orderId }" } }, "name": "order" },
{ "type": "If", "condition": { "==": [ "{ /order }", null ] },
"then": [ { "type": "Return", "value": { "ok": false, "reason": "unknown order" }, "isError": true, "statusCode": 404 } ] },
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
"fields": { "status": { "en-US": "paid" }, "paidAt": { "en-US": "{ /now/iso }" } }, "publish": true },
{ "type": "Return", "value": { "ok": true } } ],
"secrets": [ { "key": "WEBHOOK_SIGNING_SECRET", "value": "whsec_9f2c1b7ae4", "description": "Chave de assinatura de webhook da processadora de pagamentos" } ] }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. Como a chave de assinatura foi declarada em secrets, a WEEGLOO a criptografa e a armazena com segurança. No Statement, ela é lida como { /secrets/WEBHOOK_SIGNING_SECRET }. No entanto, ao consultar este Script, o valor original fica visível, portanto mantenha estreito o conjunto de papéis que podem ler este Script (veja secrets 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.
27. Verificar uma assinatura de hash sem chave
{ "method": "Post",
"statements": [
{ "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
"value": "{ /payload/orderId }{ /payload/amount }{ /secrets/MERCHANT_KEY }" },
{ "type": "If", "condition": { "!=": [ "{ /expectedSign }", "{ /payload/signature }" ] },
"then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/orderRef }" } },
"fields": { "status": { "en-US": "paid" } }, "publish": true },
{ "type": "Return", "value": { "ok": true } } ],
"secrets": [ { "key": "MERCHANT_KEY", "value": "9f2c1b7ae4", "description": "Chave de lojista compartilhada com a processadora de pagamentos" } ] }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 é declarada em secrets e escrita dentro de value como { /secrets/MERCHANT_KEY }, na posição em que aquele esquema coloca a chave. Cada esquema põe a chave no início, no fim ou no meio, e escrevendo assim é possível expressar qualquer uma dessas 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
28. 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 ou exclui um membro é salvo com papel algum (veja Trabalhar com o 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.
Envio de arquivos
29. Enviar a foto do produto a um serviço de análise de imagens e registrar o resultado (multipartBody)
{ "method": "Post",
"statements": [
{ "type": "ResourceRead", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } }, "name": "product" },
{ "type": "ResourceRead", "resource": "Media",
"target": { "sys": { "id": "{ /product/fields/photo/en-US/sys/id }" } }, "name": "photo" },
{ "type": "Try",
"body": [
{ "type": "Http", "method": "POST", "url": "https://api.vision.example/v2/analyze",
"headers": [ { "key": "x-api-key", "value": "{ /secrets/VISION_API_KEY }" } ],
"multipartBody": [
{ "name": "image", "url": "{ /photo/fields/file/en-US/url }" },
{ "name": "language", "value": "ko" },
{ "name": "features", "value": "labels" },
{ "name": "features", "value": "text" } ],
"timeoutMs": 20000, "name": "analysis" },
{ "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /product/sys/id }" } },
"fields": { "aiLabels": { "en-US": "{ /analysis/body/labels }" },
"aiText": { "en-US": "{ /analysis/body/text }" } } },
{ "type": "Return", "value": { "ok": true, "labels": "{ /analysis/body/labels }" } } ],
"catch": [
{ "type": "Return", "value": { "ok": false, "reason": "image analysis failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 502 } ] } ],
"secrets": [ { "key": "VISION_API_KEY", "value": "...", "description": "Chave da API de análise de imagens" } ] }Quando um vendedor cadastra um produto, as etiquetas e o texto presente na foto do produto são extraídos e registrados no produto (é pendurado em Content.Create como ação vinculada de um Webhook). O Media é lido pelo campo de referência photo do Content do produto, e a url desse arquivo é escrita na part de arquivo de multipartBody. api.vision.example é um serviço fictício usado como exemplo, e supomos que ele devolve labels (um array de strings) e text (uma string). Como aiLabels é um campo Array (elementos de ShortText), o array é escrito do jeito que está. Ajuste o caminho { /analysis/body/... } ao formato de resposta do serviço que você de fato usa.
- Composição das parts: cada elemento do array é uma part, e as parts são enviadas na ordem do array.
imageé uma part de arquivo comurlespecificada, e as demais são parts de texto comvalueespecificado. Se você usar o mesmo nome várias vezes, comofeatures, cada um é enviado como uma part separada. Content-Type: a requisição inteira é sempre enviada comomultipart/form-data, portanto não o especifique emheaders. Como a partimageomitecontentType, ela usa oContent-Typecom que a URL do arquivo respondeu. Como o nome do arquivo também foi omitido, o último segmento do caminho da URL do arquivo passa a ser o nome do arquivo.- Tratamento de falhas: se o arquivo ultrapassar o limite de download, se não for possível receber o arquivo da URL do arquivo ou se o tempo se esgotar, aquele
Httpfalha. Ele também falha se o serviço de destino responder com um status 400 ou superior. Como este exemplo não especificaignoreStatusCode(padrãofalse), em qualquer um dos casos a execução passa para ocatch, e oReturndocatchretorna o status502. O produto não é alterado. O tratamento de cada tipo de falha é abordado em As duas formas de falha do Http. - Orçamento de tempo: no plano Pro, este Script tem cerca de 60,5 segundos permitidos (30 segundos base + 20 segundos de
timeoutMs+ cerca de 10,5 segundos de tempo de download de uma foto). O método de cálculo é abordado em Orçamento de tempo.
Se a API recebe um único arquivo como o corpo inteiro (no formato de um PUT em uma URL de upload), use urlBody em vez de multipartBody. As regras dos dois campos e a ordem em que o Content-Type é definido são abordadas em Enviar um arquivo como corpo.
Transformação de arquivos
30. Cópia em PDF de um e-book com o e-mail do comprador estampado (PdfWatermark)
{ "method": "Post",
"payloadSchema": {
"type": "object", "required": [ "fields" ],
"properties": {
"fields": {
"type": "object", "required": [ "ebookMediaId", "email", "orderId" ],
"properties": {
"ebookMediaId": { "type": "string" },
"email": { "type": "string", "minLength": 1, "maxLength": 41, "not": { "pattern": "[^ -~]" } },
"orderId": { "type": "string", "minLength": 1, "maxLength": 20, "not": { "pattern": "[^A-Za-z0-9-]" } } } } } },
"statements": [
{ "type": "ResourceRead", "resource": "Media",
"target": { "sys": { "id": "{ /payload/fields/ebookMediaId }" } }, "name": "ebook" },
{ "type": "ResourceCreate", "resource": "Media", "publish": false,
"fields": {
"title": { "en-US": "{ /ebook/fields/title/en-US }" },
"file": { "en-US": {
"source": "{ /ebook/fields/file/en-US/url }", "encoding": "url",
"transformations": [
{ "type": "PdfWatermark", "text": "{ /payload/fields/email } · { /payload/fields/orderId }" } ] } } },
"name": "copy" },
{ "type": "Return", "value": { "mediaId": "{ /copy/sys/id }" } } ] }É o fluxo que cria a cópia do e-book a ser entregue ao comprador depois que o pagamento é confirmado. Pelo payload da requisição, recebe o id do Media do e-book original, o e-mail do comprador e o número do pedido. Mantém o Media original como está, cria um novo Media com e-mail · número do pedido estampado na diagonal em todas as páginas e retorna o id dele.
- Comprimento do texto: o texto fixo fora do template é
·, com 3 caracteres. Como opayloadSchemalimitou o e-mail a 41 caracteres e o número do pedido a 20 caracteres, o texto estampado não passa de 64 caracteres. Em qualquer um dos dois valores, basta um único caractere fora dos permitidos (incluindo a quebra de linha) para que a requisição seja rejeitada antes da execução; por isso, a etapa de transformação não falha por causa do texto. Todos os caracteres ASCII imprimíveis que o e-mail aceita estão entre os caracteres permitidos. - Cor: como
colorfoi omitido, o texto é estampado na cor padrão#808080(cinza). - Publicação: como é uma cópia para um único comprador,
publishfoi definido comofalse. A cópia não é entregue pela CDA/ACDA. - Tratamento de falhas: se o arquivo original não for um PDF ou estiver criptografado, o
ResourceCreatefalha com status 422, e nenhum Media novo é criado. A lista completa dos casos de falha é abordada em Quando a transformação de arquivos falha. - Orçamento de tempo: o tempo para receber e transformar o arquivo sai do orçamento base de 30 segundos (Orçamento de tempo).
- Confirmação da compra: este exemplo não tem nenhuma etapa que confirme o pagamento e a compra. Se ficar assim, qualquer chamador com permissão de execução pode criar uma cópia a partir de qualquer id de Media. No uso real, leia primeiro o Content do pedido, confirme se o comprador e o produto correspondem e só então prossiga.
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.
