.outry format
- Readable without docs. C-like syntax: the body is JSON, checks look like JS, Java or C# expressions. Nothing Go-specific.
- Grows with the task. The smallest file is one line; blocks are added only when needed.
- Declarative. A request may call other requests, but there are no
ifs, loops or user functions. Files stay readable in code review. - Close to the code. Paths are written like router paths (
/orders/{id}), response shapes can come from the server’s own types, sooutry import --checkcan tell when requests and code diverge.
At a glance
Section titled “At a glance”// Creates an order for the current user.CreateOrder: POST /orders/{shop} { only: [dev, staging] query { page: 1 }
headers { Authorization: "Bearer ${Login().body.token}" Idempotency-Key: uuid() }
body { customer: CreateUser(name: "Bob").body.id, items: [{ sku: "A-1", qty: 2 }], }
expect { status == 201 body.items.length == 1 headers.Location.startsWith("/orders/") body matches Order GetOrder(id: body.id).body.status == "new" }
save order_id = body.id}The minimal file:
GET /health- Extension
.outry, UTF-8, LF or CRLF line endings. - A file holds any number of requests, flows, shapes and
lets, in any order. - Project discovery,
env.tomland environments are unchanged. *.httpfiles keep working in a compatibility mode — see Compatibility with .http.
Lexical rules
Section titled “Lexical rules”| Element | Syntax |
|---|---|
| Comments | // to end of line and /* block */ |
| Identifiers | Unicode letters, digits and _, not starting with a digit: order_id, CreateOrder |
| Strings | "..." or '...' — the same thing. Escapes \" \' \\ \n \t \r \u{1F600}. Interpolation ${expr}; \$ for a literal $ |
| Multi-line strings | """..."""; the common leading indentation is removed, interpolation works |
| Numbers | JSON numbers: 42, -1.5, 1e3 |
| Literals | true, false, null |
| Durations | 500ms, 1s, 2m, 1h — used in timeout, cache, poll |
| Regular expressions | /^\d+$/i — only on the right of matches |
Inside { } and [ ] elements are separated by commas or new lines; a trailing comma is allowed.
Reserved words: let, shape, flow, fresh, save, expect, poll, every, for, matches,
in, typeof, true, false, null, plus the names of built-in functions.
They cannot be used as variable or request names.
Requests
Section titled “Requests”[doc comment][Name:] METHOD target [{ fields }]Method
Section titled “Method”GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, or any other upper-case word for custom
methods.
Target
Section titled “Target”- A path starting with
/is appended to thebasevariable.GET /usersis whatGET {{base}}/userswas. Ifbaseis not defined,outry checkreports it. - An absolute URL
https://…orhttp://…is used as is. - A string
"…"for anything else, e.g. a URL built from variables:GET "${auth_url}/token".
The target is a single token without spaces. Inside it:
{name}is a path parameter: the value ofname(an argument, a parameter or a variable), percent-encoded. This is the same notation as chi, gorilla/mux, Go 1.22net/httpand OpenAPI, sooutry importwrites router paths as is.${expr}inserts any expression, percent-encoded.- A literal query string is allowed:
GET /users?active=true. Thequeryblock is appended to it.
The block { must be separated from the target by a space: /orders/{shop} {.
Name and description
Section titled “Name and description”// Creates an order for the current user.// Prices come from the catalog.CreateOrder: POST /orders { body { sku: "A-1", qty: 2 }}- The name goes before the method, followed by
:right after it:CreateOrder: POST …. It starts with an upper-case letter and is unique in the project. It is used to call the request, inoutry run, in the app’s tree and in reports. - The doc comment — the run of
//lines right above — is the description, shown in hover, in the app and inoutry run -v. - A request without a name is still sent by
outry run, but it can’t be called by other requests. The only request of a file without a name and a comment is named after the file (create-order.outry→CreateOrder).
Files written before names existed name a request by the first line of its comment
(// Create order → CreateOrder). That still works; outry fmt rewrites such requests to
CreateOrder: POST … and keeps the rest of the comment as the description.
Fields
Section titled “Fields”All fields are optional; each may appear once. outry fmt sorts them in the order of this table.
| Field | Meaning |
|---|---|
handler: users.GetUser |
The server handler this request is for; written by outry import — see Keeping in sync with code |
params { … } |
Parameters of the request when it is called |
only: [dev, staging] |
Environments the request may be sent in — also when called by another request |
confirm: true |
Ask before sending (the app shows a dialog, the CLI asks in a TTY and refuses otherwise unless --yes) |
timeout: 10s |
Request timeout. Default: 30s |
redirects: false |
Do not follow redirects. Default: follow up to 10 |
cache: 30m |
Keep the response of a call between runs — see Caching |
query { … } |
Query parameters |
headers { … } |
Request headers |
body … / form { … } / multipart { … } |
The body, at most one of them |
poll … |
Repeat the request until a condition holds — see Polling |
expect { … } |
Checks of the response |
save name = expr |
Save a value; may repeat |
query { page: 1 tags: ["new", "gift"] // tags=new&tags=gift q: search // value of the variable `search` debug: null // omitted}Keys are identifiers or strings ("page[size]": 10). Values are expressions; arrays repeat the key,
null omits it.
Headers
Section titled “Headers”headers { Authorization: "Bearer ${token}" X-Request-Id: uuid() "X-Weird Header": "ok"}Keys are header names: letters, digits and -, or a string. Values are expressions converted to
strings.
| Form | Sent as | Default Content-Type |
|---|---|---|
body { … } / body [ … ] |
JSON | application/json |
body "text" / body """…""" |
The string as is | text/plain; charset=utf-8 |
body file("./payload.json") |
File contents, path relative to the .outry file |
By extension, else application/octet-stream |
form { … } |
application/x-www-form-urlencoded |
same |
multipart { … } |
multipart/form-data; file(…) values become file parts |
same, with a boundary |
A Content-Type in headers always wins.
The JSON body is JSON5 with expressions:
body { "name": "Viktor", // plain JSON pasted from anywhere works role: "admin", // keys without quotes manager: manager_id, // a variable email, // shorthand for email: email tags: ["a", "b"], greeting: "Hi, ${name}!", // interpolation}In value position an identifier is a variable; true, false and null are literals.
Expressions
Section titled “Expressions”Expressions appear in field values, expect, save, poll and interpolation.
Values and names
Section titled “Values and names”- Literals: strings, numbers,
true,false,null, arrays[…], objects{…}. - An identifier is resolved in this order: call arguments → parameter defaults →
file
lets → the variable chain (--var, saved values,OUTRY_*,env.toml, keychain). vars["name-with-dashes"]reaches variables whose names are not identifiers.envis the name of the current environment.
After the response arrives (in expect, save, poll) these are also defined:
| Name | Value |
|---|---|
status |
Status code, a number |
headers |
Response headers; names are case-insensitive: headers.content-type, headers["X-Id"] |
body |
Parsed JSON, or the text if the body is not JSON |
duration |
Time to the full response, milliseconds |
cookies |
Cookies of the run’s jar for this request’s URL: cookies.session — see Cookies |
Access
Section titled “Access”a.b, a["b"], a[0], a[-1] (from the end). Reading a missing field or index gives null
instead of an error, so body.user.address.city == null is safe and body.id != null means “exists”.
After . a name may contain dashes, as header names do: headers.content-type. Write subtraction with
spaces: body.total - 1.
Operators
Section titled “Operators”From highest to lowest precedence:
| Operators | Meaning |
|---|---|
!x, -x, typeof x |
not, negation, type name |
* / % |
arithmetic |
+ - |
arithmetic; + on strings concatenates |
< <= > >= |
numbers, or strings alphabetically |
== != |
deep equality, no type coercion: "1" != 1 |
in, matches |
membership, pattern |
&& |
and |
|| |
or |
typeof xis one of"string","number","boolean","null","array","object".x in [1, 2, 3]— element of an array;"id" in body— key of an object;"ab" in "cab"— substring.x matches /re/— regular expression;x matches Orderorx matches { … }— shape.- Header values are strings: compare them as numbers with
number(headers.content-length) < 1024.
Methods
Section titled “Methods”| On | Methods |
|---|---|
| strings | .length, .startsWith(s), .endsWith(s), .contains(s), .lower(), .upper(), .trim(), .split(sep) |
| arrays | .length, .contains(x), .first, .last, .any(x => …), .all(x => …), .map(x => …), .filter(x => …) |
| objects | .keys, .values, .has(key) |
The arrow x => expr is allowed only as an argument of these methods:
expect { body.items.all(i => i.qty > 0) body.items.map(i => i.sku).contains("A-1")}Built-in functions
Section titled “Built-in functions”| Function | Result |
|---|---|
uuid() |
Random UUID v4 |
now() |
Unix time, seconds; now() + 3600 is an hour later |
nowIso() |
Current time, RFC 3339 |
randomInt(min, max) |
Random integer, both ends included |
randomString(n) |
n random letters and digits |
number(x), string(x) |
Conversion; number("abc") is an error |
json(x) |
x as compact JSON text |
base64(x), unbase64(x) |
Base64 encoding |
file(path) |
A file, for body and multipart |
schema(path) |
A JSON Schema, for matches |
Each call returns a new value: two uuid()s are different.
Checks
Section titled “Checks”expect { status == 201 body.total > 0 body.items.length == 1 headers.content-type.contains("json") duration < 500}Every line is a separate check and must be a boolean. All checks run, even after a failure. A failed check prints the expression and the values of its sides:
✗ body.total > 0 — body.total is 0✗ body.items.length == 1 — body.items.length is 3Shapes
Section titled “Shapes”matches checks the structure of a value:
expect { body matches { id: string, total: number, status: "new" | "paid", items: [{ sku: string, qty: integer }], note?: string, // optional deleted_at: string | null, }}| Shape | Matches |
|---|---|
string, number, integer, boolean, null, any |
A value of that type |
"paid", 42, true |
Exactly that value |
A | B |
Either |
[T] |
An array whose every element matches T |
{ a: T, b?: U } |
An object with field a, optional b; extra fields are allowed |
Name |
A named shape |
schema("./order.schema.json") |
A JSON Schema |
A failure names the first mismatching path: body.items[2].qty: expected integer, got "2".
schema() takes a path relative to the .outry file where it is written (for a named shape — where the
shape is declared). Supported JSON Schema keywords (draft 7 / 2020-12, plus OpenAPI 3.0 nullable):
type, enum, const, properties, required, additionalProperties, items, prefixItems,
minItems / maxItems, minLength / maxLength, pattern, minimum / maximum and their
exclusive forms, allOf, anyOf, oneOf, not, and $ref inside the same file. Other keywords are
ignored. outry check reports a missing or invalid schema file.
shape Order = schema("./schemas/order.json")Named shapes are declared at the top level and are visible in the whole project:
shape Order { id: string, total: number, items: [Item] }
shape Item { sku: string, qty: integer }Planned: outry import go generates shapes from the Go structs a handler returns (json tags,
omitempty → optional) into api/shapes.outry, and outry import go --check fails when they no longer
match the code — see Keeping in sync with code.
Saving values
Section titled “Saving values”save order_id = body.idsave first_sku = body.items[0].skusave runs after a successful response; the value is available to later requests as a variable and
survives between runs, as > save does today. Saving null (a missing field) is an error.
Parameters
Section titled “Parameters”A request can be called with arguments. Its parameters are:
- path parameters — every
{name}in the target; - declared parameters — in
params, with or without a default.
Login: POST /auth/login { params { email: "dev@example.com" password // no default }
body { email, password } expect { status == 200 }}For each parameter the value is: the call argument → the default → the variable chain. A parameter without a value anywhere is an error listing all missing names, like missing variables today.
Calling requests
Section titled “Calling requests”A request is called like a function, with named arguments only:
headers { Authorization: "Bearer ${Login(email: "admin@example.com").body.token}" }body { customer: CreateUser(name: "Bob").body.id }expect { GetOrder(id: body.id).body.status == "new" }The result is the callee’s response: status, headers, body, duration.
Where. Calls are expressions and may appear anywhere expressions do: query, headers, body,
expect, save, poll and flows. They run when the expression is evaluated: fields before the
request is sent are evaluated in source order, expect after the response.
Names. Request names are unique in the project, no imports needed. When two folders have a
Create, call them by folder: users.Create(), orders.Create() (path from the project root, /
→ .). outry check reports ambiguous and unknown names.
Failures. If the callee fails — network error, a failed expect, a missing variable — the caller
fails too, with the call chain:
✗ CreateOrder → Login: status == 200 — status is 401Environments. A callee’s only applies: a request limited to [dev] cannot be called in prod,
directly or indirectly. outry check --env prod finds such calls without sending anything.
Cycles. A calling B calling A is reported by outry check, whatever the arguments.
Side effects. A callee’s saves are applied as if it ran on its own.
Caching
Section titled “Caching”Within one run a call with the same request and the same arguments is sent once; later calls get
the same response. Ten requests calling Login() log in once.
fresh Login()always sends a new request.- A run is one
outry runin the CLI, and in the app — until the environment is switched or New run is clicked on the Variables tab. cache: 30mon the callee keeps its response between runs (in the same state file as saved values, outside the repo): a login token survives restarts until it expires. A401from a request that used a cached value does not retry automatically; usefreshwhere that matters.
Cookies
Section titled “Cookies”Each run has a cookie jar, like a browser: Set-Cookie from a response is stored and sent with later
requests to the same domain and path, including requests made by calls. A session
login works without saving anything by hand. The jar lives as long as the call cache: one outry run,
or in the app until the environment is switched.
After the response, cookies holds the jar’s cookies for the request’s URL:
expect { cookies.session != null}A Cookie header in headers is sent as written, in addition to the jar’s cookies.
Polling
Section titled “Polling”WaitUntilPaid: GET /orders/{id} { poll body.status == "paid" every 1s for 30s expect { body.paid_at != null }}The request is repeated every every until the condition is true, then expect and save run on the
last response. If the condition is still false after for, the request fails with the last values.
A flow is a named scenario: steps run top to bottom.
// Checkout// A user buys one item and pays.flow Checkout { user = CreateUser(name: "Bob") order = CreateOrder(shop: "main", customer: user.body.id) Pay(order: order.body.id)
expect { GetOrder(id: order.body.id).body.status == "paid" }
save last_order = order.body.id}A step is one of:
name = expr— a local binding, visible in later steps of this flow;- a call
Name(…)— a request or another flow; expect { … }andsave name = expr.
The flow stops at the first failed step. Flows take params like requests and may call other flows;
a flow’s call result is null. The app shows a trace of every request a flow (or a request) called,
with their responses and timings.
File-level let
Section titled “File-level let”let shop = "main"let admin = { email: "admin@example.com", role: "admin" }A let is visible in its file only and sits between parameters and the variable chain in name
resolution. Use env.toml for values shared across files.
Keeping in sync with code
Section titled “Keeping in sync with code”outry import writes the handler into each generated request:
GetUser: GET /users/{id} { handler: users.GetUser expect { body matches User }}Requests are matched to routes by handler, so a request is still recognised after its path or
method changed in code. Requests without handler are matched by method and path, with parameters
compared as “any value” (/users/{id} ~ /users/{user_id}).
outry import go --check then compares every matched request with its handler — method and path,
body fields and their types, query parameters, headers, the response shape — and fails when they
drift apart; --fix corrects what can be corrected without guessing, --prune deletes files of
removed routes. The app shows the same in the Routes tab. See
Import routes from Go.
Running
Section titled “Running”outry run api/orders/create.outry # every request and flow in the file, top to bottomoutry run api/ # every file, sorted by pathoutry run CreateOrder # by nameoutry run Checkout --env stagingoutry run api/orders/create.outry:12 # the request or flow at line 12outry check api/ # syntax, names, arguments, shapes, cyclesoutry check api/ --env prod # + `only` and variables missing in prodoutry fmt [--check] [paths] # rewrite files in the canonical styleoutry convert api/ # *.http → *.outryoutry import go . # requests for new routes of a Go serviceRunning a file or a directory runs every request in it, including ones that mostly exist to be
called (Login, CreateUser); the call cache makes sure a shared Login() is still sent
once. To run a scenario only, run the flow by name.
Formatting
Section titled “Formatting”outry fmt produces one canonical form, like gofmt:
- two-space indentation; one field per line; a blank line between top-level items (consecutive
lets and shapes keep their grouping); - fields in the order of the fields table, with a blank line around multi-line fields;
- a block with one element that fits stays on one line (
expect { status == 200 });expectwith several checks has one per line; - double quotes;
key: valuewith one space;{ email }foremail: email; durations in the largest whole unit (10000ms→10s); - objects, arrays and argument lists that fit in 80 columns are kept on one line, otherwise one element per line with a trailing comma;
"""…"""strings are re-indented to the surrounding block;- comments are preserved: above an element, at the end of its line, or at the end of a block. An item that can’t be printed with every comment in place (e.g. a comment inside a shape) is left as it is.
outry fmt api/ # rewrite, print changed filesoutry fmt --check api/ # CI: exit code 1 if anything is not formattedCompatibility with .http
Section titled “Compatibility with .http”*.httpfiles are read by the current parser and behave as today; both formats can live in one project..outryrequests cannot call.httpones.{{var}}is not valid in.outry;outry checksuggests${var}, or{var}in a path.outry convertrewrites.httpfiles into.outrynext to them (--dry-runprints,--rmdeletes the.httpafterwards; an existing.outryis never overwritten):
.http |
.outry |
|---|---|
POST {{base}}/users/{{id}} |
POST /users/{id} |
Header: Bearer {{token}} |
Header: "Bearer ${token}"; a value that is only {{token}} → token |
| JSON body | body { … }: "{{x}}" → x, "a {{x}}" → "a ${x}", {{x}} outside quotes → number(x) |
| other body | "…", or """…""" if multi-line |
> save x = body.id |
save x = body.id |
> assert status == 200 |
status == 200 in expect |
> assert body.id exists |
body.id != null |
> assert body.tags contains "a" |
body.tags.contains("a") |
> assert headers.content-length < 1000 |
number(headers.content-length) < 1000 |
# comment above the request |
Name: from the comment’s first line if it is up to four words (the rest stays as a // comment), otherwise from the file name |
{{$uuid}}, {{$timestamp}}, {{$randomInt 1 10}} |
uuid(), now(), randomInt(1, 9) (the upper bound is inclusive in .outry) |
{{x-y}} |
vars["x-y"] |