Webhook - Call a script

🟡 Intermediate

variable script webhook

This example shows how to expose a webhook endpoint that delegates the request to a run-script action and lets the script build the HTTP response.

  • Create a user with an API key assigned to the /internal/data-access group.

  • Define a webhook endpoint declaring its HTTP verbs in methods, each with an optional linkedAction.

  • Read the request with webhook.parseArgs(trigger.parameters) (.method(), .body(), .json(), …).

  • Build the HTTP response with request.respond(code, content, contentType).

git checkout origin/osp-web-configuration .
git checkout origin/osp-scripts-configuration .
git checkout origin/example-webhook-call-script .

Configuration structure

@startuml
skinparam backgroundColor transparent

package "root/webhook/script-endpoint" {
    [endpoint.webhook] as endpoint
}

package "root/action" {
    [action.ospp\n(RUN_SCRIPT)] as action
}

package "root/script" {
    [handle-request.js] as script
}

external -[#blue]-> endpoint : <size:11><color:blue>**HTTP POST**
endpoint -[#dodgerblue]-> action : <size:11><color:dodgerblue>**linkedAction**
action -[#dodgerblue]-> script : <size:11><color:dodgerblue>**runs**
script -[#green]-> endpoint : <size:11><color:green>**request.respond(code, content, contentType)**
endpoint -[#green]> external : <size:11><color:green>**HTTP response**

@enduml

How it works

  • The endpoint declares a single POST verb in its methods list. A request using a verb that is not declared is rejected with a 405 Method Not Allowed.

  • The script parses the request with const request = webhook.parseArgs(trigger.parameters), then reads it through request.method(), request.headers() / request.header(name), request.body() / request.json() (POST / PUT) and request.query() (GET / DELETE).

  • The script returns the HTTP response with request.respond(code, content, contentType): code sets the HTTP status code (default 200), content sets the response body, and contentType sets its Content-Type. Use request.respond(code) for a status-only response with no body.

  • The caller must hold WRITE access on both the endpoint and the linked action.

See webhooks for the full reference.

Steps

  1. Setup a user with an API key

    To trigger the webhook, the user must be member of the group /internal/data-access.

    modules/keycloak/keycloak-1/users.keycloak
    {
      "users": [
        {
          "enabled": true,
          "groups": [
            "/internal/data-access"
          ],
          "username": "example",
          "email": "example@localhost",
          "firstName": "example",
          "lastName": "example",
          "credentials": [
            {
              "initial": true,
              "temporary": true,
              "type": "password",
              "value": "mysuperpassword"
            }
          ],
          "attributes": {
            "authorizedKeys": [],
            "apiKey": "simple-api-key"
          }
        }
      ]
    }
    

    Warning

    When defining an API key for a user, it may take up to 1 minute for it to be applied.

  2. Declare the script that handles the request

    The script parses the request with webhook.parseArgs(trigger.parameters) and builds the response with request.respond(code, content, contentType).

    root/script/detached.scripts
    {
        "moduleId": "modules.scripts.scripts-1",
        "sourceFile": "root/script/handle-request.js"
    }
    
    root/script/handle-request.js
    // Linked script executed by the webhook endpoint "root.webhook.script-endpoint".
    //
    // When a script is triggered by a webhook, the incoming HTTP request is carried by the script
    // parameters. Parse them with "webhook.parseArgs(trigger.parameters)" to obtain a request
    // object that exposes the request and the response builder:
    //   * request.method()               the HTTP method (POST, GET, PUT or DELETE),
    //   * request.headers()              all request headers as a key/value object,
    //   * request.header(name)           a single header value,
    //   * request.body()                 the raw request body (POST / PUT),
    //   * request.json()                 the request body parsed as JSON,
    //   * request.query()                the query parameters (GET / DELETE),
    //   * request.respond(code)                       sets an HTTP status code with no body,
    //   * request.respond(code, content, contentType) sets the HTTP response returned to the caller.
    //
    // respond() does not stop the script: the last call wins and the response is read once the script ends.
    // The script is wrapped in a function so "return" can be used to short-circuit after responding.
    
    const request = webhook.parseArgs(trigger.parameters);
    
    (function () {
        const contentType = request.header("Content-Type") || request.header("content-type") || "unknown";
    
        let payload;
        try {
            payload = JSON.parse(request.body());
        } catch (parseError) {
            // The script controls the HTTP status code returned to the caller and produces the final body.
            request.respond(400, JSON.stringify({ error: "The request body is not valid JSON." }), "application/json");
            return;
        }
    
        const temperature = Number(payload.temperature);
        if (!Number.isFinite(temperature)) {
            request.respond(422, JSON.stringify({ error: "Field 'temperature' is required and must be a number." }), "application/json");
            return;
        }
    
        const status = temperature > 30 ? "alert" : "ok";
        log.info("Webhook script computed status [{}] for temperature [{}].", status, temperature);
    
        request.respond(201, JSON.stringify({
            status: status,
            temperature: temperature,
            receivedContentType: contentType
        }), "application/json");
    })();
    
  3. Declare the run-script action

    The action is of type RUN_SCRIPT and is issued by the osp-web module that exposes the webhook.

    root/action/action.ospp
    {
        "moduleId": [
            "modules.web.web-1"
        ],
        "type": "RUN_SCRIPT"
    }
    
  4. Create the webhook endpoint

    The endpoint declares a single POST verb in its methods list, whose linkedAction references the action. The request is read by the script through the webhook controller, so the action arguments list is left empty.

    root/webhook/script-endpoint/endpoint.webhook
    {
        "moduleId": "modules.web.web-1",
        "path": "/call-script",
        "methods": [
            {
                "verb": "POST",
                "linkedAction": {
                    "actionId": "root.action",
                    "parameters": {
                        "scriptId": {
                            "type": "CONSTANT",
                            "value": "root.script"
                        },
                        "arguments": {
                            "type": "LIST",
                            "values": []
                        }
                    },
                    "expiration": {
                        "value": 5,
                        "unit": "SECONDS"
                    }
                }
            }
        ]
    }
    
  5. Send an HTTP POST request

    Using an HTTP client of your choice, send a POST request to the endpoint. The script status code and content are returned to the caller.

    curl -X POST http://stack-1.onsphere.local:5000/osp/webhooks/call-script \
      -H "Accept: application/json" \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer simple-api-key" \
      -d '{"temperature": 35}'
    

    The script answers with the status code 201 and the following body:

    {
      "status": "alert",
      "temperature": 35,
      "receivedContentType": "application/json"
    }
    

    Sending a body without a temperature field makes the script answer with a 422 status code, and a body that is not valid JSON makes it answer with 400.