Run .http files as API tests, for local development and CI.
httpsuite executes the requests in your .http files — the same ones you author
and debug in the JetBrains HTTP Client (GoLand, IntelliJ) or the VS Code REST
Client — runs their pre-request and response-handler scripts, checks
client.test assertions and declarative # @expect checks, and reports
pass/fail with the right output for where it's running: coloured and aligned on a
terminal, plain and diffable in CI, or JUnit XML for a dashboard.
It ships as a single static binary with no CGO and no JVM. The JavaScript
for scripts is executed by the pure-Go goja
engine, so the same binary runs unchanged on macOS, Windows, and any Linux
distro — including Alpine/musl and FROM scratch/distroless containers. That
footprint is the main reason to reach for httpsuite over JetBrains' own ijhttp
CLI, which requires a JDK.
Verified compatible. httpsuite's behaviour is checked against the real
JetBrains HTTP Client CLI: a conformance harness runs the same
.http files through both tools and diffs their JUnit output test-by-test. On
the shared .http surface the two agree — and httpsuite is a superset in
several places (see Compatibility).
go install github.com/uradical/httpsuite@latestRequires Go 1.26 or later (see go.mod). Released binaries are static and carry
no toolchain requirement.
httpsuite [--var key=value]... [--env name] [--report file] [--version] [path]
pathis optional and defaults to the current directory.--var key=valueoverrides a{{placeholder}}. Repeatable.--env nameselects an environment fromhttp-client.env.json(see Variables).--report filewrites a JUnit XML report.--versionprints version information and exits.
httpsuite # discover and run in the current directory
httpsuite ./api # run a directory
httpsuite ./api/users.http # run a single file
httpsuite --var token=abc ./api # override {{token}}
httpsuite --env prod ./api # use the "prod" environment
httpsuite --report report.xml ./apiExit code is 0 when everything passes, 1 when any request, assertion, or script test fails, and 2 on a usage or setup error.
When path is a directory (or omitted), httpsuite decides what to run:
- If a
httpsuite.yamlexists in the directory, it is used as the suite definition. - Otherwise every
*.httpfile in the directory is run serially, in sorted order.
When path is a single file, only that file is run.
parallel: true # run groups concurrently (default false)
timeout: 30s # global per-request timeout (default 10s)
groups:
- name: auth
serial: true # opt this group out of parallel execution
files:
- auth/login.http
- auth/refresh.http
- name: users
timeout: 5s # override the timeout for this group
files:
- users/list.http
- users/create.http- Files within a group always run serially, in order.
serial: truekeeps a group off the parallel path even whenparallelis on.timeoutmay be set globally and overridden per group.
Every group runs against one shared session: a single cookie jar, one
client.global, and one response store are threaded through all of its files.
So a login in the first file leaves its session cookie in place for the rest, and
a token stashed with client.global.set(...) — or a named response referenced as
{{login.response.body.$.token}} — is visible to every later file in the group.
setup: files run first, in the same session, for programmatic setup such as
authentication. They are ordinary .http files (still portable to GoLand); their
results are included in the report so a failed setup surfaces.
groups:
- name: account
setup:
- auth/login.http # sets a session cookie / stores a token
files:
- account/profile.http # runs already logged in
- account/update.httpWith data: rows, each row gets its own fresh session, so setup re-runs per row.
Orchestration that would make a .http file non-portable lives here in the YAML
— the .http files stay plain and still run in GoLand. The suite file adds two
things on top of them:
groups:
- name: users
files: [users/get.http] # uses {{id}} and {{name}}
data: # run the file once per row; keys become {{variables}}
- { id: "1", name: "Ada" }
- { id: "2", name: "Bob" }
# dataFile: users/cases.json # …or an external JSON array / CSV table
- name: jobs
files: [jobs/poll.http] # a request named "# @name pollStatus"
retry: # re-run a named request until it passes
- request: pollStatus # matches "# @name pollStatus"
attempts: 20
delay: 2s- Data-driven: each
datarow (ordataFilerecord) runs the group's files once, with the row's key/values available as{{placeholders}}(highest precedence).dataFileaccepts a JSON array of objects or a CSV with a header row. - Retry / until: a named request is re-run until its own assertions pass
(
# @expectorclient.test) orattemptsis reached, waitingdelaybetween tries — so a poll is just a normal request carrying# @expect body.status == "done", with the polling policy kept out of the file.
httpsuite parses the full portable .http syntax:
### Create a user # text after ### names the request
# @name createUser # or name it explicitly
POST {{base}}/users
?notify=true # multi-line URL: indented continuations are appended
&source=cli
Content-Type: application/json
Authorization: Bearer
{{token}} # folded header value (joined with a space)
...commonHeaders # spread an object variable's entries as headers
{ "name": "Ada" }| Feature | Notes |
|---|---|
Request line [METHOD] URL [version] |
Method is optional and defaults to GET; the HTTP version is accepted and ignored. |
### separators |
Trailing text becomes the request name (unless # @name is given). |
| Comments | #, //, and /* … */ block comments. |
| Multi-line URLs | Indented continuation lines are concatenated onto the URL. |
| Folded header values | An indented line continues the previous header's value (RFC 7230, joined with a space). |
| Spread headers | ...objectVar injects an object variable's key/value pairs as headers. |
| Request body | Everything after the blank line. |
| Body from a file | < ./body.json imports the file (path and contents are variable-substituted). |
| Form bodies | application/x-www-form-urlencoded lines split with a leading & are joined (a=1\n&b=2 → a=1&b=2). |
| Metadata tags | # @name, # @no-cookie-jar, # @no-redirect. |
Requests share a cookie jar: a response's Set-Cookie is automatically sent
on later requests (matching domain/path). A standalone file run has its own jar;
within a suite the jar is shared across a whole group, so a login in a setup:
file is carried into the group's requests (see
Shared session). Redirects are followed by default.
Opt out per request:
# @no-cookie-jar # send no stored cookies; store none from this response
# @no-redirect # return a 3xx instead of following it
GET {{base}}/whoami{{placeholder}} tokens in a request's URL, headers, and body are resolved at
execution time. Resolution order, from lowest to highest precedence:
- JetBrains environment files, when
--envis given (see below), including$shared - File-level
@key = value(eager) and@key := value(lazy) declarations - OS environment variables
--var key=valueflags and data-drivendata/dataFilerow values- Values set by scripts via
client.global.set(...)
A plain {{placeholder}} that is still unresolved at execution time fails that
request.
@id = {{$uuid}} # eager: evaluated once, the same value for the whole file
@rid := {{$uuid}} # lazy: re-evaluated on each use (a new value every time)
@url = {{base}}/api # eager values may reference other variablesGenerated fresh on each use, matching the JetBrains HTTP Client:
| Variable | Value |
|---|---|
{{$uuid}} / {{$guid}} |
a random v4 UUID |
{{$timestamp}} |
current Unix time in seconds |
{{$isoTimestamp}} |
current time, ISO-8601 UTC |
{{$randomInt}} / {{$randomInt min max}} |
a random integer (default 0–1000) |
{{$env.NAME}} / {{$processEnv NAME}} |
an OS environment variable |
{{%name}} |
URL-encodes the value of name |
Reference a previous request's response, either with a response-handler script
(the JetBrains way — client.global.set(...), works everywhere), or in place:
# @name login
POST {{base}}/login
###
GET {{base}}/users/{{login.response.body.$.id}}
Authorization: Bearer {{login.response.headers.X-Token}}Supported in-place forms: {{name.response.body.$.json.path}} (with
[index]), {{name.response.headers.Name}}, {{name.response.status}}, and
{{name.response.body}}.
With --env <name>, httpsuite loads variables from http-client.env.json and
http-client.private.env.json sitting next to the .http file. The private
file overrides the public one, and a $shared environment applies to all
environments:
// http-client.env.json
{ "$shared": { "apiVersion": "v2" },
"dev": { "baseUrl": "http://localhost:8080" },
"prod": { "baseUrl": "https://api.example.com" } }
// http-client.private.env.json
{ "dev": { "token": "dev-secret" },
"prod": { "token": "prod-secret" } }httpsuite --env prod ./api # {{baseUrl}}, {{token}}, {{apiVersion}} resolveThese are the same files GoLand uses, so an environment set up there works
unchanged — httpsuite discovers them automatically (the ijhttp CLI instead
requires explicit -v/-p paths). An object-valued env entry can also be used
as a spread header.
httpsuite runs the JetBrains HTTP Client scripting subset. Scripts are plain JavaScript (ES2015+; goja provides the language). Two kinds are supported:
-
Pre-request scripts run before the request is sent, introduced with
<:< {% request.variables.set("nonce", Date.now().toString()) %} POST {{base}}/orders ### or from an external file, resolved relative to the .http file < ./scripts/sign.js GET {{base}}/secure
-
Response-handler scripts run after the response arrives, introduced with
>. They typically register tests:GET {{base}}/users/1 > {% client.test("status is 200", () => { client.assert(response.status === 200, "got " + response.status); }); client.global.set("userName", response.body.name); // reuse in later requests %} ### or from an external file GET {{base}}/users/2 > ./scripts/check-user.js
Script body statements run first; then every client.test(...) block runs in
registration order (matching JetBrains). A failing client.assert marks that
one test failed and the remaining tests still run.
| Object | Highlights |
|---|---|
client |
test(name, fn), assert(cond, msg), log(...), exit(), and client.global (set/get/isEmpty/clear/clearAll, plus global.headers.set/clear to inject headers into later requests). client.global persists across every request in a run. |
response |
status, contentType.{mimeType,charset}, headers.valueOf(name) / valuesOf(name), cookies() / cookiesByName(name), and body — a parsed object for JSON, a DOM Document for XML/HTML, or a raw string otherwise (based on Content-Type). |
request |
Pre-request: method, url/body (getRaw/tryGetSubstituted), headers, environment.get(name), variables.get/set. Response: method, url(), body(), headers. |
crypto† |
crypto.hmac.{sha256,sha384,sha512,sha3}, and a synchronous crypto.subtle (digest, generateKey, importKey, exportKey, sign, verify) for RSA-PSS / ECDSA / HMAC. |
jwt† |
sign(payload, secret, {algorithm}), verify(token, secret), decode(token) — HS/RS/PS/ES 256/384/512. |
| helpers | jsonPath(body, "$.a.b[0]"), xpath(doc, "//tag"), console.log, btoa/atob, URLSearchParams, structuredClone, string2byteArray, DOMParser/XMLSerializer. |
A request fails if there is a network error or timeout, any # @expect
assertion fails, any client.test has a failing client.assert, or a
pre-request or response script throws an uncaught error. A pre-request error
skips the HTTP call.
Not supported: async/await/Promise resolution (scripts must be
synchronous), ES module import/export, and streaming response handlers.
Shell execution (exec/execSync/spawn) is deliberately blocked.
For simple checks you don't need a script — attach # @expect comments to a
request. All assertions on a request are evaluated (evaluation never stops at
the first failure) and a request fails if any fails. A request with no
assertions and no failing tests passes as long as it returns any HTTP response.
# @expect is an httpsuite convenience and is ignored by other .http tools.
# @name createUser
POST {{baseUrl}}/users
Content-Type: application/json
# @expect status == 201
# @expect header Location exists
# @expect body.id number
# @expect body.name == "Ada Lovelace"
# @expect body.roles length > 0
# @expect duration < 500
{ "name": "Ada Lovelace" }# @expect status == 201 # == != < > <= >=
# @expect status 2xx # 2xx 3xx 4xx 5xx range match# @expect header X-Request-Id exists
# @expect header Content-Type == application/json # case-insensitive containsThe response body is parsed as JSON. Paths use dot notation with array indexing,
e.g. body.items[0].id.
# @expect body.id exists
# @expect body.id == 10 # numeric compare: == != < > <= >=
# @expect body.name == "Alan Bradley" # quoted string
# @expect body.active == true # boolean
# @expect body.items[0].name == "Alan" # array indexing
# type checks
# @expect body.count number # integer, no decimal point
# @expect body.price double # number with a decimal point
# @expect body.name string
# @expect body.active boolean
# @expect body.address object
# @expect body.items array
# length of an array or string
# @expect body.items length 3
# @expect body.items length > 0
# dates
# @expect body.created date # ISO 8601 by default
# @expect body.created date "YYYY-MM-DD"
# @expect body.timestamp date "YYYY-MM-DD HH:mm"Date format tokens: YYYY MM DD (date) and HH mm ss (time).
# @expect duration < 500 # milliseconds elapsed for the requestOn a terminal, results are coloured (green pass, red fail, bold summary) with
✓/✗ marks on assertion and test lines, ⚠ for script logs. With no TTY
(CI), the same layout is printed without ANSI codes and with PASS/FAIL/LOG
words instead of glyphs, and --report writes the same results as JUnit XML:
PASS POST https://api.example.com/login 200 89ms
PASS Status is 200
PASS Token extracted
FAIL GET https://api.example.com/profile 401 12ms
PASS Request executed
FAIL Profile returned expected status 200, got 401
LOG checking auth header value "Bearer undefined"
SCRIPT ERROR GET https://api.example.com/orders
TypeError: Cannot read property 'id' of undefined
3 requests 1 passed 2 failed 190ms
The conformance/ harness runs the same .http files through
httpsuite and the real JetBrains ijhttp CLI and diffs their JUnit reports
test-by-test. On the shared surface the two agree (CONFORMANT) — covering
status/body/global state, headers, environments, the full ES scripting API,
cookies, and dynamic variables.
httpsuite is a superset of the ijhttp CLI. These are valid .http
features (from VS Code REST Client / httpyac) that stock ijhttp does not
support, and which httpsuite handles: block comments /* */, folded header
values, spread headers ...var, in-place response references, and lazy @var :=
declarations.
† crypto and jwt match the JetBrains IDE's scripting API but are not defined
in the stock ijhttp CLI, so scripts relying on them run in httpsuite but not
under ijhttp. (xpath, URLSearchParams, structuredClone, and
string2byteArray are provided by httpsuite but not yet verified against
ijhttp.)
Out of scope: non-HTTP protocols (WebSocket, gRPC, MQTT, …), cloud auth helpers
(OAuth/AAD — also absent from the ijhttp CLI), and interactive prompts.
go test ./... # unit tests
go vet ./...
# conformance vs the JetBrains CLI (needs ijhttp on PATH, or pass --ijhttp <path>)
go run ./conformanceBuilding a release binary is CGO-free and cross-compiles to every supported target:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" .