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

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

E-mail

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.

  1. O Capture de Regex desempacota o cabeçalho e o divide em { /sig/1 } (o timestamp) e { /sig/2 } (o código). O índice 0 é a correspondência inteira, e de 1 em diante vêm os grupos de captura. Se o formato não coincidir, { /sig } é null e a requisição é devolvida ali mesmo com 400.
  2. O Signature toma "<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 /payload parseado 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.
  3. Se { /verified } for false, é 401. Assinatura errada e cabeçalho ausente são a mesma coisa, ambos false (não se informa a quem enviou qual dos dois estava errado).
  4. 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.
  5. 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 Authorization e 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 anonymousCallEnabled daquele Script e registre o endereço /execute/anonymous como callback. Nesse caso, a execução passa a ser com a identidade do autor, portanto o updatedBy do 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, como prefix, a execução falha, em vez de devolver silenciosamente 0 resultado.
  • Se não houver correspondência, o ResourceFind vincula null, 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.id em toServiceUser. 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 settings do SpaceRole do autor precisa ter SETTING_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 com url especificada, e as demais são parts de texto com value especificado. Se você usar o mesmo nome várias vezes, como features, cada um é enviado como uma part separada.
  • Content-Type: a requisição inteira é sempre enviada como multipart/form-data, portanto não o especifique em headers. Como a part image omite contentType, ela usa o Content-Type com 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 Http falha. Ele também falha se o serviço de destino responder com um status 400 ou superior. Como este exemplo não especifica ignoreStatusCode (padrão false), em qualquer um dos casos a execução passa para o catch, e o Return do catch retorna o status 502. 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 o payloadSchema limitou 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 color foi omitido, o texto é estampado na cor padrão #808080 (cinza).
  • Publicação: como é uma cópia para um único comprador, publish foi definido como false. 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 ResourceCreate falha 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.