Skip to content

HTTP provider

The HTTP provider is the heart of Tales. It executes HTTP(S) requests (including ConnectRPC JSON-over-HTTP), with structured request building, declarative assertions, and a small set of well-defined body modes.

step "http" "<name>" {
request {
method = "POST" // optional, defaults to "GET"
url = "..." // required
headers = { ... } // optional
query = { ... } // optional
timeout = "30s" // optional, per-step override
body { ... } // optional, exactly one body mode
auth { ... } // optional
}
expect { ... } // optional but recommended
capture { ... } // optional
}

The provider sets sensible defaults: GET if no method is given, 30s request timeout, no body, no auth.

request {
method = "GET"
url = "${config.base_url}/users/${result.create_user.id}"
headers = {
Accept = "application/json"
X-Trace-Id = generate("trace_id")
}
query = {
page = 1
sort = "created_at"
}
}

Query params are merged into the URL. Header values are evaluated like any expression, env vars, captures, generators, vars, all valid.

A request may carry exactly one of json, form, raw, or multipart. The parser rejects steps that combine them.

Evaluated value → serialised as JSON. Content-Type: application/json is set automatically.

request {
method = "POST"
url = "${config.base_url}/users"
body {
json = {
password = "Sup3rS3cret!"
preferences = {
notifications = true
}
}
}
}
request {
method = "GET"
url = "${config.base_url}/basic-auth"
auth {
basic {
username = "admin"
password = env("ADMIN_PASSWORD")
}
}
}

The provider sets the Authorization: Basic <base64> header and masks the password in reports. Combining auth.basic with a headers.Authorization value is rejected with a clear error.

expect {
status = 200
headers = {
Content-Type = contains("application/json")
}
json = {
id = is_string()
role = optional("ROLE_UNSPECIFIED")
quota = optional(any())
}
body = "..." // optional, raw body string match
strict = true // optional, fail on extra JSON keys not in `expect`
}
FieldWhat it asserts
statusExact match against the response status code.
headersMap of name = expected, values may be matchers (contains, etc.).
jsonStructural match against the JSON body. Matchers apply field-by-field.
bodyRaw body string match.
strictWhen true, extra JSON fields not listed in expect.json fail the step.

Full matcher reference: Matchers.

After a step runs, the response is bound to the response namespace:

capture {
id = response.json.id
trace_id = response.headers["X-Trace-Id"][0]
status = response.status_code
}

See Captures & result chaining for full semantics.

FieldTypeNotes
response.statusnumberHTTP status code.
response.jsondynamicDecoded JSON (or null when the body is not JSON).
response.bodystringRaw response body.
response.headersmap of list(string)Every value for every header, in wire order, keyed by the canonical MIME name. Single-valued headers come back as a one-element list.
response.cookiesmap of cookie objectParsed Set-Cookie cookies, keyed by cookie name.

Each cookie object exposes: name, value, raw, path, domain, expires (RFC3339 string or ""), max_age, secure, http_only, same_site ("lax" | "strict" | "none" | ""). Duplicate cookie names use the last-seen value (browser overwrite semantics).

capture {
session = response.cookies.ia_session.value
theme = response.cookies.theme.value
set_cookies = response.headers["Set-Cookie"] # list, both values preserved
ct = response.headers["Content-Type"][0] # single-valued, index 0 reads the string
}

Cookie names that are not HCL identifiers (e.g. ia-session) are accessible via index syntax: response.cookies["ia-session"].value.

When the runner renders a response into a report, Cookie/Set-Cookie headers, any header whose name contains signature (case-insensitive), and the value/raw fields of every parsed cookie are replaced with ***. Captures see the unmasked values, masking only applies to report output.

SourceEffect
request.timeoutPer-step override. Bounds this single HTTP call.
--timeout on tales testGlobal cap on the whole suite.
Provider default30s per request when neither is set.

A timeout fires context.DeadlineExceeded through the HTTP client and the step fails with a clear error. Retries inside a retry block honour the same deadline.

ConnectRPC services that accept JSON over HTTP are just HTTP requests. No special configuration, use the JSON body mode and the right Content-Type:

step "http" "create_user_connect" {
request {
method = "POST"
url = "${config.base_url}/users.v1.UserService/CreateUser"
headers = { Content-Type = "application/json" }
body {
json = {
}
}
}
expect {
status = 200
json = {
user = {
id = is_string()
role = optional("ROLE_UNSPECIFIED") // proto field, defaults absent
}
}
}
}

ConnectRPC tends to omit fields equal to their zero value. Use optional(<default>) to express “either the field equals the default, or it is absent”.

HTTP steps appear with provider: "http" in JSONL, JUnit, and visual HTML reports. The request shape carries method, url, headers, query, and the chosen body mode. The response shape carries status_code, headers, json (parsed when possible), and body (raw).

Secrets injected via auth.basic.password are masked; values inlined into headers are not, capture them from a previous step’s response if you need them masked.