Skip to content

.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, so outry import --check can tell when requests and code diverge.
api/orders/create.outry
// 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:

api/health.outry
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.toml and environments are unchanged.
  • *.http files keep working in a compatibility mode — see Compatibility with .http.
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.

[doc comment]
[Name:] METHOD target [{ fields }]

GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, or any other upper-case word for custom methods.

  • A path starting with / is appended to the base variable. GET /users is what GET {{base}}/users was. If base is not defined, outry check reports it.
  • An absolute URL https://… or http://… 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 of name (an argument, a parameter or a variable), percent-encoded. This is the same notation as chi, gorilla/mux, Go 1.22 net/http and OpenAPI, so outry import writes router paths as is.
  • ${expr} inserts any expression, percent-encoded.
  • A literal query string is allowed: GET /users?active=true. The query block is appended to it.

The block { must be separated from the target by a space: /orders/{shop} {.

// 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, in outry 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 in outry 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.

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 {
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 appear in field values, expect, save, poll and interpolation.

  • 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.
  • env is 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

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.

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 x is 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 Order or x matches { … } — shape.
  • Header values are strings: compare them as numbers with number(headers.content-length) < 1024.
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")
}
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.

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 3

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:

api/shapes.outry
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.

save order_id = body.id
save first_sku = body.items[0].sku

save 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.

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.

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 401

Environments. 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.

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 run in the CLI, and in the app — until the environment is switched or New run is clicked on the Variables tab.
  • cache: 30m on 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. A 401 from a request that used a cached value does not retry automatically; use fresh where that matters.

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.

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.

api/flows/checkout.outry
// 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 { … } and save 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.

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.

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.

Terminal window
outry run api/orders/create.outry # every request and flow in the file, top to bottom
outry run api/ # every file, sorted by path
outry run CreateOrder # by name
outry run Checkout --env staging
outry run api/orders/create.outry:12 # the request or flow at line 12
outry check api/ # syntax, names, arguments, shapes, cycles
outry check api/ --env prod # + `only` and variables missing in prod
outry fmt [--check] [paths] # rewrite files in the canonical style
outry convert api/ # *.http → *.outry
outry import go . # requests for new routes of a Go service

Running 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.

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 }); expect with several checks has one per line;
  • double quotes; key: value with one space; { email } for email: 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.
Terminal window
outry fmt api/ # rewrite, print changed files
outry fmt --check api/ # CI: exit code 1 if anything is not formatted
  • *.http files are read by the current parser and behave as today; both formats can live in one project. .outry requests cannot call .http ones.
  • {{var}} is not valid in .outry; outry check suggests ${var}, or {var} in a path.
  • outry convert rewrites .http files into .outry next to them (--dry-run prints, --rm deletes the .http afterwards; an existing .outry is 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"]