From a60287b78ba712bdfeab5f6d2109c132ac7a23ce Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 20 Sep 2026 23:30:16 +0400 Subject: [PATCH] docs: document http() and Lua json in scripts --- docs/configuration.md | 101 ++++++++++++++++++++++++++++ docs/examples.md | 151 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 5258d0f..93c52ad 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -458,6 +458,8 @@ end > **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). +> **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts). + **YAML example:** ```yaml @@ -523,6 +525,8 @@ function transform(req) { > **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). +> **Note:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts). + **YAML example:** ```yaml @@ -554,3 +558,100 @@ js: ```sh SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }' ``` + +## HTTP Requests in Scripts + +Lua and JavaScript scripts can send their own HTTP requests and use the response in the main request, for example to fetch a fresh token or CSRF value from another endpoint. + +**Function:** + +``` +http(url, opts) +``` + +`url` must be an absolute `http` or `https` URL. `opts` is optional. + +**Options:** + +| Option | Type | Default | Description | +| -------------- | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------ | +| `method` | string | `GET` | HTTP method, sent as given | +| `headers` | table/object | - | Request headers, same shape as `req.headers` | +| `params` | table/object | - | Query parameters, added to any already in the URL | +| `cookies` | table/object | - | Cookies, sent as a single `Cookie` header | +| `body` | string | `""` | Request body | +| `timeout` | duration string (`"500ms"`) | `30s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed | +| `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) | +| `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none | + +Unknown options and values of the wrong type raise an error. + +**Response:** + +| Field / Method | Description | +| -------------- | ------------------------------------------------------------------ | +| `status` | Status code (number) | +| `headers` | Response headers, each key holding an array of values | +| `body` | Response body (string) | +| `header(name)` | First value of a header, case-insensitive; `nil`/`null` if missing | +| `cookie(name)` | Value of a cookie from `Set-Cookie`; `nil`/`null` if missing | +| `json()` | JavaScript only: parses the body as JSON | + +In Lua, call the helpers as `res:header("name")` or `res.header("name")`. + +**Behavior:** + +- **Runs on every request:** the requests are sent each time `transform` runs, so every main request triggers them. Script globals persist across requests within a worker, so a script can cache a value itself (e.g. `if not token then token = login() end`). +- **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to. +- **Status codes:** non-2xx responses are returned normally; check `status` in the script. +- **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript). +- **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts. +- **Dry run:** script requests are still sent in dry-run mode. +- **Not measured:** script requests are not included in the results. + +**Lua JSON:** + +Lua has no built-in JSON support, so Lua scripts get a global `json` table: + +- `json.decode(string)`: arrays become 1-indexed tables and `null` becomes `nil` (so `null` array items leave holes). +- `json.encode(value)`: a table with keys exactly `1..n` becomes an array, any other table becomes an object, and an empty table becomes `{}`. + +JavaScript scripts use the built-in `JSON` object. + +**Lua example:** + +```lua +function transform(req) + local res = http("https://api.example.com/login", { + method = "POST", + headers = { ["Content-Type"] = "application/json" }, + body = json.encode({ user = "test", password = "secret" }), + timeout = "2s", + }) + if res.status ~= 200 then + error("login failed: " .. res.status) + end + + req.headers["Authorization"] = "Bearer " .. json.decode(res.body).token + return req +end +``` + +**JavaScript example:** + +```javascript +function transform(req) { + const res = http("https://api.example.com/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user: "test", password: "secret" }), + timeout: "2s", + }); + if (res.status !== 200) { + throw new Error("login failed: " + res.status); + } + + req.headers["Authorization"] = "Bearer " + res.json().token; + return req; +} +``` diff --git a/docs/examples.md b/docs/examples.md index 79029ba..9374240 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1087,3 +1087,154 @@ js: "@./scripts/body.js" ``` + +**Fetch a value from another endpoint before each request:** + +Scripts can send their own requests with `http`. Here each request first fetches a CSRF token and sends it in a header and cookie. See [HTTP Requests in Scripts](configuration.md#http-requests-in-scripts) for all options. + +```sh +sarin -U http://example.com/api/orders -r 1000 -c 10 \ + -M POST \ + -lua 'function transform(req) + local res = http("http://example.com/csrf") + req.headers["X-CSRF-Token"] = json.decode(res.body).token + req.cookies["session"] = res:cookie("session") + return req + end' +``` + +
+YAML equivalent + +```yaml +url: http://example.com/api/orders +requests: 1000 +concurrency: 10 +method: POST +lua: | + function transform(req) + local res = http("http://example.com/csrf") + req.headers["X-CSRF-Token"] = json.decode(res.body).token + req.cookies["session"] = res:cookie("session") + return req + end +``` + +
+ +**Log in once per worker and reuse the token (JavaScript):** + +```yaml +url: http://example.com/api/profile +requests: 1000 +concurrency: 10 +js: | + let token; + + function login() { + const res = http("http://example.com/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user: "test", password: "secret" }), + }); + if (res.status !== 200) throw new Error("login failed: " + res.status); + return res.json().token; + } + + function transform(req) { + if (!token) token = login(); + req.headers["Authorization"] = "Bearer " + token; + return req; + } +``` + +**Chain two calls, then use the result in the URL path (Lua):** + +```yaml +url: http://example.com/api/placeholder +requests: 500 +concurrency: 10 +lua: | + function transform(req) + local token = json.decode(http("http://example.com/login", { + method = "POST", + body = json.encode({ user = "test", password = "secret" }), + headers = { ["Content-Type"] = "application/json" }, + }).body).token + + local orders = json.decode(http("http://example.com/api/orders", { + headers = { Authorization = "Bearer " .. token }, + }).body) + + req.path = "/api/orders/" .. orders[1].id + req.headers["Authorization"] = "Bearer " .. token + return req + end +``` + +**Refresh a token only when it expires (JavaScript):** + +```yaml +url: http://example.com/api/profile +requests: 5000 +concurrency: 20 +js: | + let token; + let expiresAt = 0; + + function transform(req) { + if (Date.now() >= expiresAt) { + const res = http("http://example.com/login", { method: "POST", body: "{}" }); + token = res.json().token; + expiresAt = Date.now() + 60000; // refresh once a minute per worker + } + req.headers["Authorization"] = "Bearer " + token; + return req; + } +``` + +**Follow redirects and use a slower timeout for a login endpoint:** + +```sh +sarin -U http://example.com/api/me -r 200 -c 5 \ + -lua 'function transform(req) + local res = http("http://example.com/login", { + method = "POST", + maxRedirects = 5, + timeout = "10s", + }) + req.cookies["session"] = res:cookie("session") + return req + end' +``` + +**Keep sending the main request even when the extra call fails:** + +A failed `http` call raises an error, which skips the main request and records the failure in the results. Catch it to decide yourself: + +```sh +sarin -U http://example.com/api/items -r 1000 -c 10 \ + -lua 'function transform(req) + local ok, res = pcall(http, "http://example.com/api/flags") + if ok and res.status == 200 then + req.headers["X-Flags"] = res.body + else + req.headers["X-Flags"] = "default" + end + return req + end' +``` + +The JavaScript equivalent: + +```sh +sarin -U http://example.com/api/items -r 1000 -c 10 \ + -js 'function transform(req) { + try { + req.headers["X-Flags"] = http("http://example.com/api/flags").body; + } catch (e) { + req.headers["X-Flags"] = "default"; + } + return req; + }' +```