# restbook

Keep your API requests in version-controlled `.http` files and run them from the terminal and CI. restbook understands the format popularised by IDE REST clients and adds what CI needs: environments, request chaining (use the token from `login` in later requests), a small safe assertion language, concurrency, curl/HAR conversion and JUnit/JSON/Markdown reports with meaningful exit codes.

## Features

- **Parser** for `.http` / `.rest`: `###` separators with names, `# @name`, `#` and `//` comments, file variables (`@baseUrl = ...`), raw/JSON/form-urlencoded/multipart bodies, `< ./file` includes, GraphQL (`X-REQUEST-TYPE: GraphQL`), directives `@no-redirect`, `@timeout 5s`, `@skip`, `@tag`, `@after`. Parse errors carry exact line numbers.
- **Variables and environments**: `{{var}}`, `http-client.env.json` with named envs, `$shared` and a merged private env file, `{{$env.TOKEN}}`, `{{$uuid}}`, `{{$timestamp}}`, `{{$randomInt 1 100}}`, `{{$datetime iso8601 +1d}}`.
- **Chaining**: `{{login.response.body.$.token}}`, `{{login.response.headers.Location}}`, with automatic dependency ordering and cycle detection.
- **Assertions**: `?? status == 200`, `?? body.$.items.length > 0`, `?? header content-type contains json`, `?? duration < 500`, `?? body matches /regex/`, `?? body schema ./schemas/user.json`. No embedded scripting.
- **Execution** (httpx): cookie jar per run, redirect policy, TLS verify toggle, proxy, retries, timeouts, concurrency for independent requests (`-j`), selection by name/tag/glob (`--only`), `--env`, dry run with secrets masked.
- **Interop**: `restbook curl file.http name` exports curl; `import-curl` and `import-har` produce `.http`.
- **Output**: coloured terminal view with truncated, highlighted JSON; responses saved to `.restbook/responses/<name>.json`; JUnit XML, JSON and Markdown reports; non-zero exit on failure.

## Install

```bash
cd restbook
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
source .venv/bin/activate     # provides the `restbook` command
```

Requires Python 3.12+.

## Quickstart

```bash
# terminal 1: the bundled demo API
python examples/api/server.py 8184

# terminal 2
cd examples/requests
restbook run auth.http notes.http features.http --env dev -j 4
```

## Usage

### Writing a request file

```http
@contentType = application/json

### Login
# @name login
POST {{baseUrl}}/login
Content-Type: {{contentType}}

{"username": "{{username}}", "password": "{{password}}"}

?? status == 200
?? body.$.token matches /^[0-9a-f]{32}$/

### Who am I
# @name me
GET {{baseUrl}}/me
Authorization: Bearer {{login.response.body.$.token}}

?? status == 200
?? body.$.name == {{username}}
```

Reference: [docs/file-format.md](docs/file-format.md), [docs/assertions.md](docs/assertions.md).

### Running

```console
$ restbook run auth.http --env dev --only login,me
PASS login POST http://127.0.0.1:8184/login  200 2 ms 101 B
  ok   status == 200
  ok   body.$.token matches /^[0-9a-f]{32}$/
  ok   body.$.user.roles.length == 2
  ok   body.$.user.roles[0] == "admin"
  ok   header set-cookie contains session=
{
  "token": "7f036c0481fec956a3aa7d6ada59e5d5",
  "user": {
    "name": "demo",
    "roles": [
      "admin",
      "editor"
    ]
  }
}
PASS me GET http://127.0.0.1:8184/me  200 1 ms 16 B
  ok   status == 200
  ok   body.$.name == demo
{
  "name": "demo"
}

2 passed, 0 failed, 0 skipped in 2 ms
```

`--only` accepts comma-separated names, tags and globs (`--only 'login,users*,crud'`). Requests that a selected request depends on are added automatically.

All three example files, four at a time:

```console
$ restbook run auth.http notes.http features.http --env dev --body-lines 0 -j 4 --no-save | tail -3
  ok   duration < 3000

24 passed, 0 failed, 1 skipped in 433 ms
```

### Failures and exit codes

```console
$ restbook run health.http --no-save
FAIL h GET http://127.0.0.1:8184/health  200 1 ms 16 B
  fail status == 201 (line 4)  (got 200)
  ok   body.$.status == "ok"
...
0 passed, 1 failed, 0 skipped in 1 ms
$ echo $?
1
```

| Exit code | Meaning |
|---|---|
| 0 | every request passed (skipped ones do not count as failures) |
| 1 | at least one failed assertion or request error (connection, timeout, unresolved reference) |
| 2 | usage, parse, environment or dependency-cycle error; nothing was sent |

Parse and dependency errors point at the problem:

```console
$ restbook run bad.http
parse error: bad.http:2: invalid header line 'bad header' (expected 'Name: value'; a blank line must precede the body)
$ restbook run cy.http
error: dependency cycle: a -> b -> a
```

### Dry run (secrets masked)

```console
$ restbook run notes.http --env dev --only create --dry-run
### login
POST http://127.0.0.1:8184/login
Content-Type: application/json

{"username": "demo", "password": "****"}

### create
POST http://127.0.0.1:8184/notes
Authorization: ****
Content-Type: application/json

{
  "title": "Release checklist 990",
  "body": "created at 2026-10-16T16:22:17.602Z, due 2026-10-17T16:22:17.602Z",
  "tags": ["ops", "7fce37dd-fa58-46cd-8231-4796198b7c68"]
}
```

### curl export and import

```console
$ restbook curl notes.http login --env dev --mask
curl -X POST http://127.0.0.1:8184/login -L -H 'Content-Type: application/json' --data-raw '{"username": "demo", "password": "****"}'

$ restbook import-curl "curl -X POST https://api.example.com/v1/items -H 'Content-Type: application/json' -H 'Authorization: Bearer abc' -d '{\"name\":\"x\"}' -L"
### post-v1-items
POST https://api.example.com/v1/items
Content-Type: application/json
Authorization: Bearer abc

{"name":"x"}

$ restbook import-har capture.har -o requests.http
```

Responses to earlier requests cannot be known without sending, so `restbook curl` leaves `{{login.response...}}` placeholders in place.

### Reports

```bash
restbook run notes.http --env dev --junit report.xml --json report.json --markdown report.md
```

Responses are saved as `.restbook/responses/<name>.json` (change with `--out-dir`, disable with `--no-save`), containing the masked request and the full response with parsed JSON body.

### `run` options

| Option | Purpose |
|---|---|
| `--env NAME` | environment from `http-client.env.json` (+ private file) |
| `--var K=V` | override a variable (beats file and env values) |
| `--only LIST` | names, tags or globs |
| `-j N` | run independent requests concurrently |
| `--dry-run` | print resolved requests, send nothing |
| `--insecure`, `--proxy URL` | TLS verification off, proxy |
| `--retries N` | retry transport errors and 502/503/504 with backoff |
| `--timeout S`, `--no-redirect` | defaults; per-request `@timeout` / `@no-redirect` win |
| `--junit F`, `--json F`, `--markdown F` | reports |
| `--body-lines N` | response lines shown (0 hides bodies) |

## Configuration

There is no global config file. Behaviour comes from the command line, the `.http` file and the environment files described in [docs/file-format.md](docs/file-format.md). The example `http-client.private.env.json` contains only the demo API's password; real projects should git-ignore it.

## Architecture

| Module (`src/restbook/`) | Role |
|---|---|
| `parser.py` | `.http` text to `ParsedFile`/`Request`, line-accurate errors |
| `scope.py` | variable resolution, response references, dependency graph, cycle detection, topological order |
| `dynamic.py` | `$uuid`, `$timestamp`, `$randomInt`, `$datetime` |
| `jsonpath.py` | JSONPath subset |
| `assertions.py` | assertion DSL parse and evaluation, JSON Schema check |
| `build.py` | resolved request: bodies, multipart, GraphQL, file includes |
| `envfiles.py`, `masking.py` | environment files; secret masking |
| `runner.py` | selection, httpx execution, retries, concurrency |
| `interop.py` | curl export/import, HAR import, `.http` rendering |
| `report.py` | JUnit, JSON, Markdown, saved responses |
| `cli.py` | click commands |

Design notes with worked examples: [docs/design.md](docs/design.md). Demo API: `examples/api/server.py` (stdlib: login/token, notes CRUD, pagination, slow endpoint, redirect, cookies, form, multipart upload, GraphQL echo).

## Tests

```bash
.venv/bin/pytest -q
```

Covers the parser (including error line numbers), variables, dynamic values, dependency ordering and cycles, JSONPath, assertions and schemas, multipart/file includes, curl/HAR round trips, end-to-end runs against the demo API (sequential and concurrent), JUnit validity and CLI exit codes.

## Limitations

- No scripting (`> {% %}` blocks); use the assertion DSL, or `--var` plus response references.
- JSONPath is a subset: no filters (`[?()]`) or slices.
- Without `-j`, requests run in dependency order and otherwise in file order. With `-j`, unrelated requests may overlap; use `# @after` for order-sensitive sequences.
- Response references are scoped to one file; they cannot cross files.
- Assertions on `duration` measure a single attempt, not retries.
- HTTP/2, client certificates and streaming bodies are not supported.
