@aiscenblue/squab
v0.3.16
Published
An API workbench for the terminal — organise requests, send them, read the response, run the collection.
Maintainers
Readme
__
,'o `. squab
=< | an api workbench you reach over ssh
`.___,'
'' ''A squab is a young pigeon. Pigeons carry messages; this one carries your requests. Organise them, send them, read the response, run the whole collection with assertions — in a terminal, over SSH.
See it running: https://aiscenblue.github.io/squab
Install
bunx @aiscenblue/squab
npm i -g @aiscenblue/squabOr go install github.com/aiscenblue/squab@latest.
Run
squab # the workbench, on your own collections
squab serve # over SSH: every public key gets a workspace
squab run example # headless, exits non-zero if an expectation failsWhy
Collection runners, environment sharing and enough request history to be useful are all paid features elsewhere. They are also not hard problems — a runner is a for-loop with assertions, and an environment is a map. This is that, in a terminal, over SSH.
The workbench
squab local · default
────────────────────────────────────────────────────────────────────────
COLLECTIONS ────────│ REQUEST · example / get ──────────────────────────
example │ 1 ### example · get
● GET get │ 2 # @expect status 200
POST post json │ 3 GET {{base}}/get?from=squab
GET fails │ 4 Accept: application/json
│────────────────────────────────────────────────────
│ RESPONSE · 200 OK · 128ms · 688 B ─────────────────
│ jq ▸ .headers.Accept
│ ["application/json"]
────────────────────────────────────────────────────────────────────────
✓ all 1 variables resolve ^p find · ^s send · ^r run · ^e vars · ?| key | what it does |
|---|---|
| ctrl+p or / | fuzzy-find any request in any collection |
| s or ctrl+s | save the buffer and send it |
| ctrl+r | run the collection with its expectations |
| i in the runner | jump straight into the request that failed, ready to edit |
| r in the runner | rerun only what failed |
| ctrl+e | variables, with the scope each value comes from |
| e | edit the jq filter over the response |
| H | response headers |
| shift+J / shift+K | move between panes — the red bar shows which one has focus |
| i | start editing the request; esc stops |
| tab | move between panes |
| ? | keys |
The request pane has two modes, like vim. It starts in normal mode, where letters
are commands; press i to type, esc to stop. The pane title shows INSERT
while you are editing.
Most terminals treat ctrl+s as flow control and freeze the screen with it, which
is why s sends too. Run stty -ixon if you would rather have ctrl+s back.
Requests are .http files
A collection is a text file. It diffs, it reviews, and you can paste into it from a curl command or a docs page.
### get an order
# @expect status 200
# @expect jq .status == "paid"
GET {{base}}/v1/orders/42?expand=items
Authorization: Bearer {{token}}
# @off X-Debug: 1
### create one
# @expect status 201
POST {{base}}/v1/orders
Content-Type: application/json
{"sku": "A-19", "qty": 2}### separates requests and names them. # @off keeps a header without
sending it, and # @follow chases redirects for that one request.
Testing
A test is a # @expect line above the request it belongs to. There is no
separate test file and no scripting language — the assertions live with the
request they check, and travel with it in git.
The four checks
### the four kinds
# @expect status 200
# @expect jq .json.sku == "A-19"
# @expect header content-type json
# @expect contains "qty"
POST {{base}}/post
Content-Type: application/json
{"sku": "A-19", "qty": 2}| check | asserts |
|---|---|
| status <code> | the response code, exactly |
| jq <filter> == <value> | a value inside a JSON body |
| header <name> <text> | that response header contains that text, case-insensitively |
| contains <text> | the raw body contains that text |
A request may carry as many as you like, and every one is evaluated — a run reports all the failures, not just the first.
Writing jq assertions
The filter is real jq, so nesting, indexing and pipes all work:
# @expect jq .json.user.id == 7
# @expect jq .json.user.roles[0] == "admin"
# @expect jq (.json.user.roles | length) == 2
# @expect jq .items[-1].sku == "B-04"
# @expect jq [.items[].qty] == [2,1]The right-hand side is JSON, which decides how you write it:
| value | written as |
|---|---|
| string | "paid" — with the quotes |
| number | 1999 — without |
| boolean | true |
| null | null |
| array | [2,1] |
| object | {"a":1} |
.status == paid without quotes will not match a string, which is the usual
first mistake.
Reading a failure
✗ one that fails so you can see the message 200 OK · 205ms
.json.sku = "A-19", expected "B-99"Failures report the actual value, so you rarely have to go and look at the body yourself. Each kind explains itself in its own terms:
expected status 200, got 409 Conflict
.json.user.id = 7, expected 8
header content-type = text/html; charset=utf-8, expected to contain "json"
body does not contain "already_cancelled"A request that could not be sent at all says so instead of pretending to be an assertion failure:
✗ unreachable could not resolve api.example.invalid
✗ missing variable {{token}} is not set — nothing was sentChaining one request into the next
A named request publishes its response into the runtime scope, so later requests in the same run can use it:
### login
# @expect status 200
POST {{base}}/login
{"user": "ada", "password": "{{password}}"}
### orders, using the session login returned
# @expect status 200
GET {{base}}/orders
X-Prev-Status: {{login.status}}{{login.body}} and {{login.status}} come from the request named login.
Cookies persist across a run too, so a login that sets a session cookie is
followed by requests that carry it.
Values you do not have to invent
### create an order with a fresh trace id
# @expect status 201
POST {{base}}/orders
X-Request-Id: {{$uuid}}
{"idempotency_key": "{{$uuid}}", "at": {{$timestamp}}}Each use generates a new value, so the header and the body get different UUIDs. The full set:
{{$uuid}} {{$guid}} {{$timestamp}} {{$epoch}}
{{$isoTimestamp}} {{$randomInt}} {{$randomBoolean}} {{$randomAlphaNumeric}}
{{$randomWord}} {{$randomEmail}} {{$randomFullName}} {{$randomFirstName}}
{{$randomLastName}} {{$randomIP}} {{$randomPort}} {{$randomHexColor}}A name that is not on this list stays unresolved and is reported, so a typo like
{{$randomNmae}} fails loudly instead of being sent as literal text.
Running them
| where | how |
|---|---|
| the whole collection | ctrl+r in the workbench |
| one request, with its checks | s — the status bar shows ✓ 2 of 2 asserts |
| a failure you want to fix | i on the red row jumps into that request |
| only what failed | r in the runner |
| headless | squab run <collection> |
squab run exits non-zero when anything fails, which is what makes it usable in
a pipeline:
squab run smoke -env staging
squab run smoke -reporter junit -reporter-export results.xml
squab run smoke -i "login" -i "orders" # a subset
squab run smoke -env-var base=http://localhost:8080
squab run smoke -bail # stop at the first failureKeeping tests honest in review
Deleting an assertion makes a suite pass by checking less, and that is invisible
in a green build. squab diff names it:
~ get an order
- expect jq .status == "paid"
! weaker than HEAD: get an orderWhat this deliberately does not do
There is no scripting: you cannot compute a value, loop, or branch inside an assertion. Postman gives you a JavaScript sandbox for that.
It is a trade rather than an omission. # @expect jq .status == "paid" is
readable by someone who does not write code, it diffs cleanly in a pull request,
and squab diff can tell a reviewer when you removed it. A block of JavaScript
buys power and costs all three.
Fix a failure without leaving the runner
A run leaves you looking at a list of results. Move to a red row and press i:
squab closes the runner, selects that request, drops you into the editor with the
reason it failed still on screen, and s sends it again. r reruns only the
failures.
That loop — run, land on the break, fix, resend — is the reason to have a runner and an editor in the same program.
Review what changed in a pull request
Because a collection is text in your repo, squab can tell a reviewer what actually changed about your API tests — not what changed in a JSON blob:
squab diff # working tree against HEAD
squab diff main # against a branch
squab diff main..HEAD # between two revisionsorders.http
- cancel an order
- request POST {{base}}/v1/orders/42/cancel
~ get an order
~ url {{base}}/v1/orders/42 → {{base}}/v1/orders/{{id}}
+ header X-Trace {{$uuid}}
- expect jq .status == "paid"
+ list orders
! weaker than HEAD: cancel an order, get an order
3 change(s) since HEAD · 2 weakenedThe last line is the point. Dropping an assertion or deleting a request makes a suite pass by checking less, and that is invisible in a green build. squab names it.
Variables have scopes, and the scope is visible
Precedence runs runtime > secret > env > global, and the variable table shows
which layer won and how many others are shadowed — most variable bugs are a
value arriving from a layer you forgot about.
Secrets live in secrets.json at mode 0600, outside the collections directory,
and render masked. If that file is ever found readable by anyone else — copied
from a backup, checked out with loose permissions — squab tightens it on load
rather than reading tokens off a world-readable file. A value of the form $(command) is resolved by shelling out
at send time and never written to disk:
token = $(op read op://api/token)Over SSH
There is no hosted instance — this repository is source, not a server. You run the server, then SSH into the machine running it. On one machine that is:
# terminal 1 — run the server
squab serve -addr localhost:23234
# terminal 2 — connect to it
ssh -p 23234 localhostssh takes a host and a port, never a URL. To reach it from elsewhere, bind a
public interface (-addr :23234), open that port, and connect with
ssh -p 23234 you@your-host.
You need an SSH key for the client to offer — the server accepts any key but
authenticates with keys only, so a machine with no key in ~/.ssh will be
turned away. ssh-keygen -t ed25519 if you have none.
The public key is the account. A key that has not been seen before gets a workspace derived from its fingerprint, seeded with a working example collection — no password, no signup, no session store. Workspace directories are named by a hash of the fingerprint rather than the fingerprint itself, so a directory listing on the host does not enumerate anyone's keys.
In CI
run exits non-zero when an expectation fails:
squab run smoke -env stagingIt writes JUnit or JSON for whatever picks up your test results:
squab run smoke -reporter junit -reporter-export results.xml
squab run smoke -reporter json -reporter-export results.jsonOther flags that matter in a pipeline: -i "one request" to run a subset,
-env-var key=value to override a variable without editing a file, -bail to
stop at the first failure, -timeout-request, -delay-request and -insecure.
Colour and motion
Colour comes from CharmTone, but it is only spent where it encodes something: the method, the status class, pass or fail, the scope of a variable. Chrome and keybinds stay neutral. The palette is declared per colour profile, so a 16-colour terminal degrades on purpose rather than by accident, and light and dark are chosen from the terminal's actual background colour.
Motion is Harmonica springs on anything that changes position, plus an ambient layer — a drifting gradient masthead, breathing focus rules, a shimmer while a response streams in. Ambient motion redraws constantly, which is worth thinking about over a network:
squab --no-ambient # keep the springs, drop the drift
squab --still # no animation at allLayout
~/.squab/
collections/*.http the requests — commit these
environments/*.json per-environment variables
globals.json variables shared across environments
secrets.json masked values, mode 0600 — do not commit theseBuilt with
Bubble Tea · Lip Gloss · Bubbles · Wish · Harmonica · gojq
Charm's v2 modules live under charm.land, not github.com/charmbracelet:
charm.land/bubbletea/v2 charm.land/lipgloss/v2
charm.land/bubbles/v2 charm.land/wish/v2Build
go build ./...
go test ./...