21 releases (11 breaking)
| new 0.12.0 | Oct 30, 2025 |
|---|---|
| 0.10.0 | Oct 17, 2025 |
| 0.7.1 | Oct 29, 2024 |
#169 in Web programming
792 downloads per month
200KB
5K
SLoC
Hen
Run API requests as files, from the command line.
name = Test Collection File
description = A collection of mock requests for testing this syntax.
$ API_KEY = $(./get_secret.sh)
$ USERNAME = $(echo $USER)
$ API_ORIGIN = https://lorem-api.com/api
$ ROLE = [admin, user, guest]
---
# Load other requests.
<< .fragment.hen
---
Some descriptive title for the prompt.
POST {{ API_ORIGIN }}/echo
* Authorization = {{ API_KEY }}
? query_param_1 = value
~~~ application/json
{
"username" : "{{ USERNAME }}",
"password" : "[[ password ]]",
"role" : "{{ ROLE }}"
}
~~~
^ & status == 200
! sh ./callback.sh
Installation
cargo install hen
Usage
Usage: hen [OPTIONS] [PATH] [SELECTOR]
Arguments:
[PATH]
[SELECTOR]
Options:
--export
--benchmark <BENCHMARK>
--input <KEY=VALUE>
--parallel
--max-concurrency <MAX_CONCURRENCY>
--continue-on-error
-v, --verbose
-h, --help Print help
-V, --version Print version
Execute a Request
To execute a request, use the hen command on a file containing a request:
hen /path/to/collection_directory/collection_file.hen
This will prompt you to select a request from the file to execute.
Specifying a Request
You can specify the nth request directly by providing an index as the second argument:
hen /path/to/collection_directory/collection_file.hen 0
This will bypass the request selection prompt and execute the first request in the file.
Conversely, all requests can be executed with the all selector:
hen /path/to/collection_directory/collection_file.hen all
Selecting a Collection
Alternatively, you can specify a directory of collections. This will prompt you to select a collection file and then a request from that file.
hen /path/to/collection_directory
If the directory contains only one collection file, that file will be selected automatically, bypassing the prompt. Dotfiles (files starting with
.) are ignored by the prompt.
Parallel Execution
Hen executes requests sequentially by default. Pass --parallel to run independent requests concurrently according to the dependency graph built from > requires: declarations.
hen --parallel ./collection.hen all
--max-concurrency <N>throttles the number of simultaneous requests. Omit or set to0to allow as many parallel requests as the graph permits.--continue-on-errorkeeps running any branches that are unaffected by a failure. Without this flag Hen stops after the first failed request.- Outputs are still printed in plan order. Each request buffers its stdout until completion to keep logs deterministic.
Defining an API Request
An API request is defined in a text file with the .hen extension.
At a minimum, a request must have a method and a URL. The method is one of GET, POST, PUT, PATCH, DELETE, HEAD, or OPTIONS. The URL is the endpoint of the request.
[description]
METHOD url
[* header_key = header_value]
[? query_key = query_value]
[~ form_key = form_value]
[~~~ [content_type]
body
~~~]
[> requires: Request Name]
[& response_capture]
[ [^ assertion_condition] || "failure message"]
[! callback]
Headers
Headers are key-value pairs that are sent with the request. Headers are specified with the * character. For example,
* Authorization = abc123
* Content-Type = application/json
Query Parameters
Query parameters are key-value pairs that are appended to the URL. Query parameters are specified with the ? character. For example,
? page = 2
? limit = 10
Multipart Form Data
Multipart form data is used to send files or text fields with a request. Multipart form data is specified with the ~ character. For example,
$ file_1 = $(cat ./test_file.txt)
---
POST https://lorem-api.com/api/echo
# form data can be used to send text data
~ form_text_1 = lorem ipsum.
~ form_text_2 = {{ file_1 }}
# form data can also be used to send files
~ file_1 = @./test_file.txt
Request Body
The request body is the data sent with the request. The body is a multiline block specified with the ~~~ characters. The body can optionally be followed by a content type. For example,
~~~ application/json
{
"key": "value"
}
~~~
User Prompts in Requests
User input can be requested interactively at runtime by using the [[ variable_name ]] syntax. A prompt may be used as a value for a query, header, form, or in the request body or URL. For example,
GET https://example.com/todos/[[ todo_id ]]
? page = [[ page ]]
* Origin = [[ origin ]]
~ file = @[[ file_path ]]
Prompts made in a request will be displayed in the terminal when the request is executed.
Supplying Prompt Values from the CLI
Use the --input flag to pre-fill prompt values and skip interactivity. Each --input expects key=value and may be repeated. Keys correspond to the placeholder name inside [[ ... ]].
hen --input foo=bar --input password=secret ./collection.hen
When a matching prompt is encountered, the provided value is used automatically. Any remaining prompts still fall back to the interactive dialog.
Response Captures
Response captures extract data from the response and store it in a variable for use in subsequent requests, callbacks, and assertions. The response is denoted with the & character, and can exposes the status code, headers, and body of the response.
& status -> $STATUS
& header.content-type -> $CONTENT_TYPE
& body -> $BODY
For JSON bodies, you can extract specific fields.
& body.token -> $TOKEN
& body.data.user.id -> $USER_ID
& body.items[0].name -> $FIRST_ITEM_NAME
Response captures update the callback execution context, making the captured variables available to any callbacks defined later in the same request, or in subsequent requests in the same collection.
Get JWT
POST {{ API_URL }}/jwt
~~~ application/json
{
"username": "foo",
"password": "[[ password ]]"
}
~~~
& body.token -> $TOKEN
! echo $TOKEN | cut -d '.' -f2 | base64 --decode | jq .
Referencing Dependency Responses
When a request declares dependencies with > requires:, it can also read from those responses using the capture syntax. Prefix the capture path with &[Request Name] to target the upstream response:
> requires: Get JWT
GET https://api.example.com/profile
&[Get JWT].body.token -> $TOKEN
&[Get JWT].status.code -> $LOGIN_STATUS
The same accessors (body, header, status) are supported. If a capture references a request that has not been declared as a dependency, Hen raises an error during parsing.
Assertions
Assertions provide lightweight response validation without leaving the request file. Each assertion line starts with ^ followed by a boolean expression. Assertions run after captures resolve and before callbacks execute. If any assertion fails, request execution stops and the failure message is surfaced.
POST https://api.example.com/login
& body.token -> $TOKEN
^ $TOKEN ~= /[A-Za-z0-9_-]+/ || "Token missing or malformed"
^ & status == 200
Available operators are ==, !=, <, <=, >, >=, and ~=. The ~= operator acts as a substring match for quoted strings and as a regular expression when the right-hand side is wrapped in / delimiters.
Assertions can reference the following values:
- Response metadata:
STATUS,STATUS_TEXT,RESPONSE, andDESCRIPTION. - Any response captures defined earlier in the request (e.g.,
$TOKEN). - Numeric and string literals (quoted or unquoted when numeric).
- Response data directly via the capture syntax used elsewhere. For example:
^ & body.token == 'abc'or^ &[Login].header.authorization ~= /Bearer/.
Optionally append || "custom message" to override the default failure text. Messages may interpolate collection or request variables via {{ variable }} before evaluation.
Callbacks
Callbacks are shell commands that are executed after a request is made. Callbacks are defined in the request definition with the ! character. For example,
GET https://lorem-api.com/api/user
# inline shell command
! echo "Request completed."
# a shell script
! sh ./post_request.sh
If a request has multiple callbacks, they are executed in the order they are defined, top to bottom.
GET https://lorem-api.com/api/user
# This is executed first
! echo '1'
# This is executed second
! echo '2'
Callback Execution Context
Callbacks are executed with response data passed as environment variables. The following environment variables are available to callbacks:
STATUS: The HTTP status code of the response.STATUS_TEXT: The canonical reason phrase for the HTTP status code, when available.RESPONSE: The response body of the request.DESCRIPTION: The description of the request.
For example, the following callback will assert that the status code of the response is 200.
#!/bin/bash
# ./post_request.sh
if [ "$STATUS" -eq "200" ]; then
echo "✅ [$DESCRIPTION] Received status 200"
echo $result
else
echo "❌ [$DESCRIPTION] Expected status 200 but got $STATUS"
echo $result
fi
Echo body w. callback
POST https://lorem-api.com/api/health
! sh ./post_request.sh
Defining an API Collection
A file containing multiple requests is called a collection. Collections can be used to group related requests together.
Collections can start with a preamble that contains metadata about the collection. The preamble is separated from the requests by a line containing three dashes ---. The same line is also used to separate requests from each other.
name = Optional Collection Name
description = Optional Collection Description
[VARIABLES]
[GLOBAL HEADERS]
[GLOBAL QUERIES]
[GLOBAL CALLBACKS]
---
[request 1]
---
[request 2]
---
etc.
Global Headers, Queries and Callbacks
Any headers, queries or callbacks defined in the collection preamble become global and are included in all requests in the collection.
In the example below, the Authorization header and page query is included in all requests in the collection. When each request is executed and a response received, the callback echo "Request completed." is executed.
* Authorization = foo
? page = 2
! echo "Request completed."
---
GET https://api.example.com/users
---
GET https://api.example.com/posts
Global callbacks are executed before request-specific callbacks.
User Prompts in Collections
User prompts can be used in a collection preamble. The user is prompted when the collection is loaded: either directly via the CLI or as a prompt in the interactive mode.
$ foo = [[ bar ]]
---
POST https://lorem-api.com/api/echo
~~~ application/json
{ "foo" : "{{ foo }}" }
~~~
Prompt values can be supplied from the CLI using the --input flag, as described in the User Prompts in Requests section.
Global Variables
Global variables are key/value pairs defined in the preamble of a collection file with the $ character. For example,
$ api_origin = https://example.com
$ api_key = abc123
$ username = alice
Variables can be used in the request definition by enclosing the variable name in double curly braces. For example,
GET {{ api_origin }}/todos/2
* Authorization = {{ api_key }}
? username = {{ username }}
Variables can also be set dynamically by running a shell command. For example,
$ api_key = $(./get_secret.sh)
$ username = $(echo $USER)
Or by setting the variable interactively:
$ api_key = [[ api_key ]]
Request-Level Variables
Variables can also be declared inside an individual request. These request-level variables use the same $ name = value syntax and may appear before the HTTP method line or intermixed with other request statements. When both a global variable and a request variable share the same key, the request variable takes precedence for that request only.
$ api_origin = https://lorem-api.com/api
---
Fetch banner image
$ banner_text = Promo%20Time!
$ banner_fill = 444444
GET {{ api_origin }}/image?text={{ banner_text }}&fill={{ banner_fill }}
Request variables fully support shell substitutions ($(...)) and interactive prompts ([[ ... ]]), and are resolved using the same working directory as the collection. This makes it easy to specialize a base request without modifying the collection preamble or other requests.
Array Variables and Map Requests
Variables can also be defined as simple arrays by using square brackets:
$ AUTH_USERNAME = [foo, bar]
When a request references one or more array variables, Hen clones that request and executes it once per value (or per Cartesian combination when multiple arrays are used). Each clone receives the corresponding value injected everywhere the array variable appears—URLs, bodies, assertions, captures, callbacks, and so on. Every generated request is labeled with a bracketed suffix (for example, Echo a request [AUTH_USERNAME=foo]) and is logged, asserted, and benchmarked independently.
Key details:
- Arrays must contain scalar values (no whitespace or nested arrays) and can be declared globally or inside a request. You can still compute them dynamically with
$(...)commands or prompt interpolation. - A request may reference at most two distinct array variables and may expand into up to 128 total combinations. Hen raises a parse error if either limit is exceeded.
- Map requests abort after the first failing iteration. Remaining iterations are skipped and reported as a
MapAbortedfailure, even when--continue-on-erroris set, ensuring side-effectful callbacks do not repeat. - Environment exports produced by each iteration are suffixed with the same bracket label (for example,
TOKEN[AUTH_USERNAME=foo]) to keep values isolated. - Requests cannot depend on a mapped request; declare dependencies on a non-mapped helper if shared setup is required.
This behavior makes it easy to fan a single request across a table of inputs without duplicating boilerplate in the collection file.
Request Dependencies
Requests can depend on other requests in the same collection. Declare dependencies with the > directive placed before the HTTP method (or alongside other request-level declarations):
> requires: Get JWT
GET https://api.example.com/profile
Hen constructs a directed acyclic graph (DAG) from these declarations when it loads the collection. Each dependency must reference another request's description exactly, and any cycles cause an error. When you execute a request, Hen automatically runs its prerequisites first—even if you selected only a single request from the CLI.
All variables captured or exported by a dependency become part of the dependent request's context. That means placeholders like {{ TOKEN }} resolve using values captured upstream, and captures can reference dependency responses via &[Dependency].body... as described earlier. This makes multi-step workflows (login → reuse token → perform action) straightforward to model in one collection file.
Additional Syntax
Comments
Comments are lines that are ignored by the parser. Comments start with the # character. For example,
# This is a comment
GET https://example.com/todos/2
Fragments
Fragments are reusable blocks of text that can be included in multiple requests. Fragments are defined in a separate file and included in a request with the << character. For example,
# .fragment.hen
* Authorization = abc123
GET https://example.com/todos/2
<< .fragment.hen
Fragment paths can be absolute or relative to the collection file.
Fragments can contain multiple requests, headers, query parameters, and request bodies. Fragments can also contain variables and other fragments.
Conditional Guards
Prefix an assertion or fragment with a boolean guard to evaluate it only when the predicate resolves to true:
[ROLE == admin] ^ $STATUS == 200
[ROLE == admin] << admin.assertions.hen
- Guards are parsed with the same expression grammar as assertions and can reference variables, captures, or response metadata (
STATUS,RESPONSE, etc.). - Lowercase bare words are treated as string literals, so
ROLE == admincompares against the literal value"admin". - Guarded assertions are skipped (and reported as such) when the predicate is false.
- Guard prefixes on fragment includes apply to every statement in the fragment, so guarded fragments are only executed when the predicate passes.
Additional Features
Export Requests
Requests can be exported as curl commands. This is useful for debugging or sharing requests with others.
$ API_URL = https://lorem-api.com/api
---
POST {{ API_URL }}/echo
~~~ application/json
{ "foo" : "bar" }
~~~
curl -X POST 'https://lorem-api.com/api/echo' -H 'Content-Type: application/json' -d ' { "foo" : "bar" }'
Exporting happens once all variables and prompts have been resolved and the request is ready to be executed. Callbacks are ignored during export.
Benchmarking
Requests can be benchmarked by specifying the --benchmark flag with the number of iterations to run. This will run the request the specified number of times and output the average time taken to complete the request.
hen /path/to/collection_file.hen --benchmark 10
Benchmarking request: Echo form data
[##################################################] 100.00%
Mean Duration: 399.95937ms
Variance (%): 0.6831308901940525
Notes:
- Callbacks are ignored when benchmarking.
- User prompts will still be executed in benchmarked requests, and so should be avoided, or used in the preamble only.
Examples
Basic Request
GET https://lorem-api.com/api/user/foo
Request with Headers, Query Parameters, and Form Data
POST https://lorem-api.com/api/echo
# Header
* foo = abc123
# Query
? bar = abc123
# Form Data
~ baz = abc123
Request with Body
POST https://lorem-api.com/api/jwt
~~~ application/json
{
"username": "bar",
"password": "qux"
}
~~~
Request with Callback
#!/bin/bash
if [ "$STATUS" -eq "200" ]; then
echo "✅ [$DESCRIPTION] Received status 200"
else
echo "❌ [$DESCRIPTION] Expected status 200 but got $STATUS"
fi
GET https://lorem-api.com/api/user
! sh callback.sh
! echo $JWT_TOKEN | cut -d '.' -f2 | base64 --decode | jq . -> $JWT_PAYLOAD
When using command -> $VARIABLE, surround the arrow with whitespace. The command still runs in the same environment, but its stdout is suppressed; only the sanitized output is saved into the named variable for later requests and callbacks.
Request with Assertions
$ IMAGE_TEXT = https%3A%2F%2Florem-api.com
$ FILL_COLOR = CECECE
GET {{ API_URL }}/image?format=png&fill={{FILL_COLOR}}&text={{IMAGE_TEXT}}
^ STATUS == 200
^ & header.content-type == "image/png"
Request with Dependencies
$ API_URL = https://lorem-api.com/api
---
Get JWT
POST {{ API_URL }}/jwt
~~~ application/json
{
"username": "foo",
"password": "[[ password ]]"
}
~~~
& body.token -> $JWT_TOKEN
^ STATUS == 200
^ $JWT_TOKEN ~= /[A-Za-z0-9_.-]+/
---
Echo Response
> requires: Get JWT
POST {{ API_URL }}/echo
~~~ application/json
{ "foo" : "{{ JWT_TOKEN }}" }
~~~
^ & body.foo == $JWT_TOKEN
Mapped Request
$ REST_ORIGIN = https://lorem-api.com/api
$ USERNAME = [foo, bar]
$ ROLE = [admin, user, guest]
---
Echo a request
POST {{ REST_ORIGIN }}/echo
~~~ application/json
{
"username" : "{{ USERNAME }}",
"role" : "{{ ROLE }}"
}
~~~
^ & status == 200
^ & body.username ~= /bar|foo/
^ & body.role ~= /admin|user|guest/
Dependencies
~15–32MB
~409K SLoC