Cookbook (Ejemplos prácticos)

Muestra diversos escenarios como ScriptDefinition completos. Para el fundamento de la sintaxis, consulte el Catálogo de Statement y las Expresiones de valor; para la ejecución y las restricciones, consulte Semántica de ejecución, restricciones y seguridad. En todos los ejemplos, el valor escrito en fields es un mapa de locales ({ "<locale>": valor }), y el locale de los ejemplos se ha unificado en en-US. Todos los ejemplos se ejecutan en línea en la ruta que atiende la petición de invocación y devuelven el resultado en el cuerpo de la respuesta de esa invocación. En los ejemplos que tienen una llamada externa (Http, EmailSend), el timeoutMs de ese Statement se suma al presupuesto de tiempo de ejecución (en Http, × (1 + retry)) y, si ese Statement está dentro de una iteración (Loop, ResourceForEach), se multiplica por el límite de iteraciones (presupuesto de tiempo).

Índice

CRUD básico

1. Crear y 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. Actualizar con un valor calculado (recuento de vistas +1)

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

3. Reunir y devolver mi 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 }" } } ] }

Con createdBy: ":self" recorre «solo lo mío» y reúne los elementos con SetVar para devolverlos. Como ResourceForEach no vincula el resultado del recorrido como colección, para devolverlo como lista se reúne así de forma manual. En el presupuesto de tiempo, el recorrido se contabiliza como el tiempo declarado por onEach multiplicado por el número de elementos procesados. Aquí onEach no tiene ninguna llamada externa, así que el tiempo declarado es 0 y el límite efectivo es el presupuesto básico de 30 segundos; si se pone una llamada externa en onEach, esa multiplicación entra tal cual en el presupuesto y, al alcanzar el máximo de 180 segundos, la ejecución se detiene ahí (presupuesto de tiempo). Cuando basta con leer la lista y devolverla, es preferible llamar directamente a la API de listado de CDA/CMA desde el frontend en lugar de recorrer con un Script.

4. Leer uno, aplicar un guard y aprobar

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

Al vincular un único elemento a un nombre con ResourceRead, se referencia directamente como { /order/fields/... }. Si no existe, la lectura da error (se puede envolver en Try).

Búsqueda y upsert

5. slug upsert (find-then-upsert)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
      "where": { "fields.slug": { "eq": "{ /payload/fields/slug }" } }, "name": "found" },
    { "type": "If", "condition": { "!!": "{ /found/sys/id }" },
      "then": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /found/sys/id }" } },
          "fields": { "body": { "en-US": "{ /payload/fields/body }" } } },
        { "type": "Return", "value": { "id": "{ /found/sys/id }", "op": "updated" } } ],
      "else": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
          "fields": { "slug": { "en-US": "{ /payload/fields/slug }" }, "body": { "en-US": "{ /payload/fields/body }" } }, "name": "created" },
        { "type": "Return", "value": { "id": "{ /created/sys/id }", "op": "created" }, "statusCode": 201 } ] } ] }

ResourceFind vincula directamente la primera coincidencia (o null si no hay ninguna), y { "!!": "{ /found/sys/id }" } bifurca según si existe.

6. Clave de campo dinámica y 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 la clave del campo como la clave del bucket de locale son referencias { /ptr }. Se usa para colocar una traducción en un bucket de locale concreto.

API externa

7. Guard de crédito, cobro por adelantado (CAS), llamada al LLM, reembolso en caso de fallo (ejemplo destacado)

{ "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, reintentar" }, "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": "Clave de API para llamar al LLM" } ] }

Primero, un guard comprueba si el saldo es suficiente y luego descuenta antes de la llamada externa. El descuento aplica un bloqueo optimista (CAS) sobre el sys.version de la wallet. Si otra ejecución cambió la wallet entre la lectura del saldo y el descuento, aborta por discrepancia de versión y catch devuelve 409. Como no se ha hecho ninguna llamada externa, las solicitudes concurrentes no se descuentan por duplicado. Solo después de confirmar el descuento se llama al LLM y, si esa llamada falla, catch vuelve a sumar el importe descontado (cost) para emitir un reembolso (compensación) y luego devuelve 502. El orden consiste en confirmar el cargo antes de la llamada externa irreversible y compensar solo en caso de fallo. La clave de API del LLM se declara en secrets y se lee en la cabecera con { /secrets/LLM_API_KEY }. Para conocer los límites de la compensación, consulte Sin transacciones y compensación en Semántica de ejecución.

8. Convertir una imagen (URL) en Media y adjuntarla a 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": "Clave de API de generación de imágenes" } ] }

Se crea el Media con un name y ResourceCreate (Content) coloca { /img/sys/id } en el campo de referencia. Media usa el mismo modelo de fields que Content. file es la instrucción de ingesta { source, encoding }. La ingesta de archivo no declara ningún tiempo, así que sale del presupuesto básico de 30 segundos, y tampoco se incluye en el límite de llamadas externas por definición (restricciones estáticas).

9. Imagen base64 a 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": "Clave de API de generación de imágenes" } ] }

10. Publicación o eliminación condicional tras la moderación

{ "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": "Clave de API de moderación" } ] }

11. try/catch: fallback en caso de fallo externo

{ "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": "Generación fallida" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ],
  "secrets": [ { "key": "GEN_API_KEY", "value": "...", "description": "Clave de API de generación" } ] }

12. Rellenar con IA el resumen y las etiquetas de una entrada

{ "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": "Clave de API para llamar al LLM" } ] }

Cuando se crea una entrada, el modelo rellena su resumen y sus etiquetas (se engancha a Content.Create como acción vinculada de un Webhook). Aquí la respuesta llega en dos capas. El responseType de Http es Json por defecto, así que el sobre de la respuesta de la API ya es un objeto, pero la respuesta que ha producido el modelo está dentro, en choices/0/message/content, como cadena. Por eso hay que quitar una capa más con ParseJson antes de poder sacar los valores como { /ai/summary } y { /ai/tags }. Las etiquetas se escriben tal cual, como array, en un campo Array (elementos de tipo ShortText).

Aunque se fije el contrato con salida estructurada (response_format), llega algo que no es JSON cuando la respuesta se corta por el límite de longitud o el modelo rechaza la petición. Por eso el parseo va envuelto en Try, que convierte el fallo de parseo en un 502. El mensaje de fallo lleva el texto que se intentó parsear, así que se puede ver qué llegó. Si el sobre de una API ya no es JSON, dé a Http un responseType: "Text" y pase { /resp/body } directamente (véase Http y ParseJson).

Paralelo

13. Combinar dos llamadas externas paralelas en un 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. Revisión de registro: puntuaciones en paralelo seguidas de una decisión basada en 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": "Clave de API de puntuación de fraude" },
               { "key": "CREDIT_API_KEY", "value": "...", "description": "Clave de API de puntuación crediticia" } ] }

También se puede insertar { /ptr } en la ruta de la URL. Los resultados de las ramas se referencian tras la unión. Hay dos llamadas externas. El número de llamadas externas que puede contener una definición es un límite por plan (véase Planes de precios), así que compruebe que está dentro de ese límite.

Bucles y agregación

En el presupuesto de tiempo, Loop y ResourceForEach se contabilizan como el tiempo declarado por el body (onEach) multiplicado por el límite de iteraciones. En los ejemplos de esta sección el body no tiene llamadas externas, así que el tiempo declarado es 0 y el límite efectivo es el presupuesto básico de 30 segundos. Ese es también el punto en el que la iteración topa realmente (presupuesto de tiempo, Loop).

15. N Content a partir de una entrada de array (Loop over)

{ "method": "Post",
  "statements": [
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "item", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_item" } },
          "fields": { "name": { "en-US": "{ /item/name }" }, "qty": { "en-US": "{ /item/qty }" } } } ] } ] }

16. counted loop (for): sembrar 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" } } } ] } ] }

for abarca de from a to, ambos incluidos (literales enteros, step por defecto 1). name vincula el contador actual a { /i }.

17. Eliminación en cascada (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 ResourceForEach pagina internamente las coincidencias y elimina cada elemento, borra todos los comentarios que cumplen la condición (hasta el límite de la plataforma) sin paginación manual y luego borra el propio post. Como onEach no declara ningún tiempo, el presupuesto de tiempo de ejecución de esta definición es de 30 segundos.

18. Acumulación en bucle: suma con 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. Procesar en lote todos los elementos que cumplen una condición

{ "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 ResourceForEach pagina internamente las coincidencias, no hace falta un bucle con cursor (Loop while + acumulación con SetVar). Encuentra todos los draft que cumplen la condición y publica cada uno. Si son tantos que es difícil recorrerlos por completo, fije con limit el máximo a procesar de una vez y deje el where con una condición de «no procesado» para continuar el procesamiento reejecutando.

20. Recopilar ids por lotes desde una lista de correos electrónicos (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 }" } } } ] }

Una lectura (ResourceFind) no es una llamada externa, por lo que se permite dentro del body de un Loop. La presencia y la ausencia se acumulan por separado con merge.

Saga y concurrencia

21. Saga de pago (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": "Clave secreta de la API de pago" } ] }

Tras la reserva (draft): si el pago tiene éxito, se confirma, se publica y se devuelve 201; si falla, catch elimina la reserva (compensación) y devuelve 402; finally registra siempre. La compensación basada en eliminación produce un nuevo sys.id, por lo que entra dentro de la limitación en la que las referencias se rompen (véase Sin transacciones y compensación en Semántica de ejecución).

22. CAS con bloqueo optimista

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

Se lee el stock para obtener un sys.version reciente y luego se descuenta con esa versión (version). Si otra ejecución cambió el valor entre la lectura y la escritura, aborta por discrepancia de versión y catch devuelve 409. El guard de falta de stock queda fuera de Try (una salida anticipada normal).

Email

23. Correo de notificación al 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": "Tu envío ha comenzado",
          "body": "<p>El envío del producto que pediste ha comenzado.</p>" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "notified": { "en-US": true } } } ] } ] }

Recorre los pedidos a los que aún no se ha enviado la notificación (aquellos cuyo fields.notified no es true), envía un correo al comprador de cada pedido y marca notified justo después. Como EmailSend es de un destinatario por correo, el envío múltiple se hace así, elemento a elemento, con ResourceForEach (onEach puede contener llamadas externas). Al darlo con toServiceUser, la dirección del miembro no entra en el espacio de variables del Script y se resuelve justo antes del envío. Como el where se deja como «no procesado» y al final de onEach se marca como completado, aunque se corte a mitad, al reejecutar continúa desde los pedidos restantes (si el marcado falla justo después de un efecto secundario con éxito, ese registro puede duplicarse en la siguiente ejecución; at-least-once).

24. Enviar con una plantilla de correo que se edita en el estudio de contenidos (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": "Tu pedido ya está en camino (número de 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 } } ] }

La plantilla se guarda en un Content (el Field html de cnt_mail_shipping) y solo se rellenan sus huecos. Aunque la persona de marketing retoque el texto en el estudio de contenidos, el Script se queda igual. La plantilla tiene esta forma.

<p>{ /name }, el envío de tu pedido ya ha comenzado.</p>
<p>Número de seguimiento: { /tracking }</p>

source tiene que ser un único puntero que señale a un Content leído por esta misma definición. El ResourceRead que lee la plantilla se coloca antes del EmailSend. Como los Statement se ejecutan en el orden en que están escritos, si se señala un nombre que todavía no se ha leído, el valor queda vacío y la ejecución falla. Los huecos de la plantilla solo leen data, así que la plantilla no puede leer /payload directamente. Un hueco que señale una clave que no está en data queda como texto vacío.

25. Enviar a los suscriptores una newsletter redactada por una persona administradora (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 }" } } } ] } ] }

El cuerpo de la newsletter es HTML que la persona administradora ha escrito en texto enriquecido, así que, si se deja tal cual, las etiquetas se ven como texto. Al dejar autoEscape en false, el marcado contenido en data se renderiza.

Acote bien el alcance de ese false. Este ajuste afecta a todos los valores interpolados de ese EmailSend. Por eso el data de este ejemplo solo lleva article, que lo ha escrito la persona administradora. Si en ese mismo data se mezcla un valor que viene de fuera, como el nombre del suscriptor, el marcado contenido en ese valor también se renderiza. Si hace falta un valor así, se pone en la plantilla o se separa el envío. La plantilla en sí también se renderiza siempre en crudo, con independencia de autoEscape, así que compruebe que tanto la plantilla como los valores que entran en data salen únicamente de manos con permiso de escritura sobre ese Content.

Verificación de firma

Cuando una pasarela de pago (PG, MoR) envía un webhook, adjunta una firma al cuerpo. Quien lo recibe debe comprobar, antes de hacer cualquier otra cosa, que esa firma se reproduce con la clave secreta que él tiene. Los dos ejemplos de abajo son los dos métodos que se dan en la práctica: uno genera un código con la clave secreta (keyed) y el otro concatena los campos y la clave secreta y calcula un digest.

26. Verificar la firma de un webhook (desempaquetar la cabecera envuelta, replay window)

{ "method": "Post",
  "statements": [
    { "type": "Regex", "name": "sig", "mode": "Capture",
      "pattern": "^t=(\\d+),v1=([0-9a-f]{64})$", "value": "{ /headers/x-provider-signature }" },
    { "type": "If", "condition": { "==": [ "{ /sig }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "malformed signature header" }, "isError": true, "statusCode": 400 } ] },
 
    { "type": "Signature", "name": "verified", "algorithm": "SHA256",
      "secret": "{ /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": "Clave de firma de webhooks de la pasarela de pago" } ] }

Como el proveedor envía la marca de tiempo y el código juntos en una sola cabecera (t=1492774577,v1=<hex de 64 caracteres>), no se puede construir el mensaje que se va a firmar antes de desempaquetar la cabecera. De ahí este orden.

  1. El Capture de Regex desempaqueta la cabecera y la separa en { /sig/1 } (la marca de tiempo) y { /sig/2 } (el código). El índice 0 es la coincidencia completa y desde 1 están los grupos de captura. Si el formato no encaja, { /sig } es null y ahí mismo se responde con un 400.
  2. Signature toma como mensaje "<marca de tiempo>.<cuerpo original>", genera el código y lo compara con { /sig/2 }. La clave está en tomar el mensaje del original previo al parseo ({ /rawPayload }). Al volver a convertir en cadena el /payload parseado, los espacios en blanco y la notación de los números quedan normalizados y ya no se recuperan los bytes que firmó la contraparte. Poniendo los dos punteros seguidos en la cadena se concatenan tal cual, así que no hace falta ningún operador.
  3. Si { /verified } es false, es un 401. Que la firma sea incorrecta y que no venga la cabecera son una misma cosa, false (no se le indica a quien envía cuál de las dos falla).
  4. Aunque la firma sea correcta, se rechaza una petición vieja. { /now/seconds } es el instante en que arrancó esta ejecución, así que se mira si su diferencia con la marca de tiempo que lleva la firma supera la replay window (aquí, 300 segundos). La marca de tiempo ha llegado como cadena desde la cabecera, pero la operación aritmética la convierte en número.
  5. Solo después de pasar hasta aquí se busca el pedido y se cambia su estado.

Como no hay ninguna llamada externa, no se declara ningún tiempo, así que termina dentro del presupuesto básico de 30 segundos y el proveedor recibe la respuesta en el momento. Como la clave de firma se ha declarado en secrets, WEEGLOO la cifra y la almacena de forma segura. En el Statement se lee con { /secrets/WEBHOOK_SIGNING_SECRET }. No obstante, al consultar este Script se ve el valor original, así que mantenga acotados los roles que pueden leer este Script (secrets del modelo de seguridad).

Hay dos formas de permitir que el proveedor llame a esta ventanilla, y lo que las separa es si ese proveedor puede enviar cabeceras personalizadas.

  • Si puede enviarlas, se emite un token que solo contiene el permiso Execute de ese Script, se le pide que lo ponga en la cabecera Authorization y que llame a /execute. El rol que se acota a un único Script se trata en el permiso script de SpaceRole, y el token en Space Access Token. Esta es la opción por defecto.
  • Si no puede enviarlas (un proveedor que solo permite registrar la URL de callback y no tiene ninguna opción para añadir cabeceras), se activa el anonymousCallEnabled de ese Script y se registra como callback la dirección /execute/anonymous. En ese caso la ejecución pasa a ser con la identidad del autor, así que el updatedBy del pedido que modifica este Script también es el autor y, al no haber autenticación, la verificación de firma de arriba es la única autenticación de esta ventanilla. Las condiciones y las reglas de guardado se tratan en Llamada anónima.

La definición anterior cumple tal cual las condiciones de un Script anónimo: no usa el filtro createdBy: ":self" y las peticiones que no pasan la firma ni la replay window se cortan con Return antes de tocar nada.

27. Verificar una firma hash sin clave

{ "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": "Clave de comercio compartida con la pasarela de pago" } ] }

Es el método que, en lugar de HMAC, "concatena unos campos determinados y la clave secreta y calcula su SHA256". Hash no tiene campo secret. La clave secreta se declara en secrets y se escribe como { /secrets/MERCHANT_KEY } dentro de value, en la posición en la que ese esquema coloca la clave. Cada esquema pone la clave delante, detrás o en medio, y escribiéndola así se puede expresar cualquier posición.

El encoding se ajusta a la notación de la contraparte (Hex, HexUpper, Base64, Base64Url). A diferencia de Signature, el resultado es una cadena, así que la comparación hay que hacerla uno mismo, y esa comparación es una comparación de igualdad normal. Como el límite de value es de 128 caracteres, para los esquemas que calculan sobre todo el cuerpo se usa Signature.

Consulta de miembros

28. Buscar un miembro por correo y darle un cupón con correo de 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": "Se ha emitido tu cupón",
      "body": "<p>Le hemos entregado a { /member/nickname } el cupón { /coupon/fields/code/en-US }.</p>" },
    { "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }

Busca a un miembro con un solo correo electrónico y usa su sys.id como propietario del cupón y como destinatario del mensaje. Las reglas al leer el directorio de miembros son estas.

  • Como sys.email se almacena cifrado, solo acepta operadores de coincidencia exacta (eq, ne, in, nin). Con otro operador, como prefix, no se obtienen 0 resultados en silencio: la ejecución falla.
  • Si no hay coincidencia, ResourceFind vincula null, así que la existencia se bifurca igual que al buscar un Content.
  • Los campos de un miembro, a diferencia de los de Content y Media, no son un mapa de locales. Se referencian tal cual, como en { /member/nickname }.
  • Al enviar el correo no se extrae la dirección: se pasa el sys.id en toServiceUser. Como el motor resuelve la dirección justo antes del envío, la dirección del miembro no entra en el espacio de variables del Script.
  • Para guardar esta definición, el settings del SpaceRole del autor debe tener SETTING_SERVICE_LOGIN. Los Statement que crean o eliminan miembros no se guardan con ningún rol (Manejar el directorio de miembros).

EmailSend suma su timeoutMs (10 segundos si no se declara) al presupuesto de tiempo de ejecución y cuenta como una sola llamada en el límite de llamadas externas por definición.

Transferencia de archivos

29. Enviar la foto de un producto a un servicio de análisis de imágenes y registrar el 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": "Clave de API de análisis de imágenes" } ] }

Es un flujo en el que, cuando un vendedor registra un producto, se extraen de la foto del producto las etiquetas y el texto que aparece en ella, y se registran en el producto (se engancha a Content.Create como acción vinculada de un Webhook). Se lee el Media a través del campo de referencia photo del Content del producto, y la url de ese archivo se escribe en la parte de archivo de multipartBody. api.vision.example es un servicio ficticio para el ejemplo, y se ha supuesto que este servicio devuelve labels (un array de cadenas) y text (una cadena). aiLabels es un campo Array (elementos de tipo ShortText), así que el array se escribe tal cual. Corrija las rutas { /analysis/body/... } según la forma de la respuesta del servicio que use realmente.

  • Composición de las partes: cada elemento del array es una parte, y las partes se transmiten en el orden del array. image es una parte de archivo que indica url, y las demás son partes de texto que indican value. Si se usa el mismo nombre varias veces, como features, cada uno se transmite como una parte independiente.
  • Content-Type: la petición completa se transmite siempre como multipart/form-data, así que no se indica en headers. La parte image omite contentType, así que usa el Content-Type con el que respondió la URL del archivo. También omite el nombre de archivo, así que el último segmento de la ruta de la URL del archivo pasa a ser el nombre de archivo.
  • Gestión de fallos: ese Http falla si el archivo supera el límite de descarga, si no se puede obtener el archivo de su URL o si se agota el tiempo. También falla si el servicio de destino responde con un status 400 o superior. Este ejemplo no indica ignoreStatusCode (valor por defecto false), así que en cualquiera de los casos pasa a catch, y el Return de catch devuelve el status 502. El producto no se modifica. La forma de gestionar cada tipo de fallo se trata en Las dos maneras en que falla Http.
  • Presupuesto de tiempo: con el plan Pro, a este Script se le conceden unos 60,5 segundos (30 segundos básicos + 20 segundos de timeoutMs + unos 10,5 segundos de tiempo de descarga de una foto). El método de cálculo se trata en Presupuesto de tiempo.

Si es una API que envía un único archivo como cuerpo completo (del tipo que hace PUT a una URL de subida), se usa urlBody en lugar de multipartBody. Las reglas de los dos campos y el orden en que se determina el Content-Type se tratan en Enviar un archivo como cuerpo.

Transformación de archivos

30. Copia en PDF de un libro electrónico con el correo del 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 }" } } ] }

Es un flujo que, una vez confirmado el pago, crea la copia del libro electrónico que se entregará al comprador. El payload de la petición recibe el id del Media del libro electrónico original, el correo electrónico del comprador y el número de pedido. El Media original no se modifica: se crea un Media nuevo con correo electrónico · número de pedido estampado en diagonal en todas las páginas y se devuelve su id.

  • Longitud del texto: el texto fijo fuera de las plantillas es ·, de 3 caracteres. Como payloadSchema limita el correo electrónico a 41 caracteres como máximo y el número de pedido a 20 como máximo, el texto estampado no supera los 64 caracteres. Si cualquiera de los dos valores contiene aunque sea un carácter fuera de los permitidos (incluidos los saltos de línea), la petición se rechaza antes de ejecutarse, así que el texto no provoca ningún fallo en la fase de transformación. Todos los caracteres imprimibles ASCII que acepta el correo electrónico están incluidos en los caracteres admitidos.
  • Color: como se ha omitido color, se estampa con el valor por defecto #808080 (gris).
  • Publicación: como es una copia para un solo comprador, publish se ha dejado en false. La copia no se entrega por CDA/ACDA.
  • Gestión de fallos: si el archivo original no es un PDF o está cifrado, ResourceCreate falla con status 422 y no se crea el Media nuevo. La lista completa de casos de fallo se trata en Casos en los que falla la transformación de archivos.
  • Presupuesto de tiempo: el tiempo que se tarda en descargar y transformar el archivo sale del presupuesto básico de 30 segundos (Presupuesto de tiempo).
  • Confirmación de la compra: este ejemplo no tiene ningún paso que confirme el pago ni la compra. Tal como está, cualquier llamante con permiso de ejecución puede crear una copia a partir de cualquier id de Media. Para usarlo en la práctica, lea primero el Content del pedido y compruebe que el comprador y el producto coinciden antes de continuar.