Формат .outry
- Читается без документации. C-подобный синтаксис: тело — это JSON, проверки похожи на выражения из JS, Java или C#. Ничего специфичного для Go.
- Растёт вместе с задачей. Минимальный файл — одна строка, блоки добавляются только когда нужны.
- Декларативный. Запрос может вызывать другие запросы, но в нём нет
if, циклов и своих функций. Файлы остаются читаемыми на ревью. - Близко к коду. Пути пишутся как в роутере (
/orders/{id}), форма ответа может браться из типов самого сервера, поэтомуoutry import --checkвидит, когда запросы и код разошлись.
Коротко
Заголовок раздела «Коротко»// 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}Минимальный файл:
GET /health- Расширение
.outry, UTF-8, переводы строк LF или CRLF. - В файле может быть сколько угодно запросов, сценариев, форм и
let, в любом порядке. - Поиск проекта,
env.tomlи окружения не меняются. - Файлы
*.httpпродолжают работать в режиме совместимости — см. Совместимость с .http.
Лексика
Заголовок раздела «Лексика»| Элемент | Синтаксис |
|---|---|
| Комментарии | // до конца строки и /* блок */ |
| Идентификаторы | Буквы Unicode, цифры и _, не с цифры: order_id, CreateOrder |
| Строки | "..." или '...' — одно и то же. Экранирование \" \' \\ \n \t \r \u{1F600}. Подстановка ${expr}; \$ — буквальный $ |
| Многострочные строки | """..."""; общий отступ слева убирается, подстановка работает |
| Числа | Числа JSON: 42, -1.5, 1e3 |
| Литералы | true, false, null |
| Длительности | 500ms, 1s, 2m, 1h — в timeout, cache, poll |
| Регулярные выражения | /^\d+$/i — только справа от matches |
Внутри { } и [ ] элементы разделяются запятыми или переводами строк; висячая запятая разрешена.
Зарезервированные слова: let, shape, flow, fresh, save, expect, poll, every, for,
matches, in, typeof, true, false, null и имена встроенных функций.
Их нельзя использовать как имена переменных и запросов.
Запросы
Заголовок раздела «Запросы»[doc-комментарий][Имя:] МЕТОД адрес [{ поля }]GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS или любое другое слово заглавными буквами
для нестандартных методов.
- Путь с
/дописывается к переменнойbase.GET /users— это бывшийGET {{base}}/users. Еслиbaseне задана, об этом скажетoutry check. - Абсолютный URL
https://…илиhttp://…используется как есть. - Строка
"…"— для всего остального, например URL из переменных:GET "${auth_url}/token".
Адрес — один токен без пробелов. Внутри него:
{name}— path-параметр: значениеname(аргумент, параметр или переменная), закодированное для URL. Это та же запись, что в chi, gorilla/mux,net/httpиз Go 1.22 и OpenAPI, поэтомуoutry importпишет пути роутера как есть.${expr}вставляет любое выражение, закодированное для URL.- Можно писать query-строку:
GET /users?active=true. Блокqueryдописывается к ней.
Блок { отделяется от адреса пробелом: /orders/{shop} {.
Имя и описание
Заголовок раздела «Имя и описание»// Creates an order for the current user.// Prices come from the catalog.CreateOrder: POST /orders { body { sku: "A-1", qty: 2 }}- Имя пишется перед методом, сразу за ним
:—CreateOrder: POST …. Оно начинается с заглавной буквы и уникально в проекте. По нему запрос вызывают, запускают черезoutry run, показывают в дереве приложения и в отчётах. - Doc-комментарий — подряд идущие строки
//прямо над запросом — это описание; оно видно в подсказке при наведении, в приложении и вoutry run -v. - Запрос без имени
outry runтоже отправит, но вызвать его из других запросов нельзя. Единственный запрос файла без имени и без комментария называется по файлу (create-order.outry→CreateOrder).
В файлах, написанных до появления имён, имя запроса — первая строка комментария
(// Create order → CreateOrder). Это по-прежнему работает; outry fmt переписывает такие запросы
в CreateOrder: POST …, а остаток комментария оставляет описанием.
Все поля необязательны, каждое встречается не больше одного раза. outry fmt сортирует их в порядке
этой таблицы.
| Поле | Значение |
|---|---|
handler: users.GetUser |
Хендлер сервера, к которому относится запрос; пишет outry import — см. Синхронизация с кодом |
params { … } |
Параметры запроса при вызове |
only: [dev, staging] |
Окружения, в которые запрос можно отправить — в том числе при вызове из другого запроса |
confirm: true |
Спросить перед отправкой (приложение покажет диалог, CLI спросит в терминале, а без терминала откажется без --yes) |
timeout: 10s |
Таймаут запроса. По умолчанию 30s |
redirects: false |
Не следовать редиректам. По умолчанию — до 10 |
cache: 30m |
Хранить ответ вызова между запусками — см. Кеширование |
query { … } |
Query-параметры |
headers { … } |
Заголовки запроса |
body … / form { … } / multipart { … } |
Тело, не больше одного из них |
poll … |
Повторять запрос, пока не выполнится условие — см. Опрос |
expect { … } |
Проверки ответа |
save name = expr |
Сохранить значение; можно несколько раз |
query { page: 1 tags: ["new", "gift"] // tags=new&tags=gift q: search // значение переменной `search` debug: null // не отправляется}Ключи — идентификаторы или строки ("page[size]": 10). Значения — выражения; массив повторяет ключ,
null его убирает.
Заголовки
Заголовок раздела «Заголовки»headers { Authorization: "Bearer ${token}" X-Request-Id: uuid() "X-Weird Header": "ok"}Ключи — имена заголовков: буквы, цифры и - или строка. Значения — выражения, приводятся к строке.
| Форма | Отправляется как | Content-Type по умолчанию |
|---|---|---|
body { … } / body [ … ] |
JSON | application/json |
body "text" / body """…""" |
Строка как есть | text/plain; charset=utf-8 |
body file("./payload.json") |
Содержимое файла, путь от файла .outry |
По расширению, иначе application/octet-stream |
form { … } |
application/x-www-form-urlencoded |
он же |
multipart { … } |
multipart/form-data; значения file(…) становятся файлами |
он же, с boundary |
Content-Type из headers всегда главнее.
JSON-тело — это JSON5 с выражениями:
body { "name": "Viktor", // обычный JSON откуда угодно работает role: "admin", // ключи без кавычек manager: manager_id, // переменная email, // сокращение для email: email tags: ["a", "b"], greeting: "Hi, ${name}!", // подстановка}На месте значения идентификатор — это переменная; true, false и null — литералы.
Выражения
Заголовок раздела «Выражения»Выражения встречаются в значениях полей, expect, save, poll и подстановках.
Значения и имена
Заголовок раздела «Значения и имена»- Литералы: строки, числа,
true,false,null, массивы[…], объекты{…}. - Идентификатор ищется по порядку: аргументы вызова → значения параметров по умолчанию →
letфайла → цепочка переменных (--var, сохранённые значения,OUTRY_*,env.toml, связка ключей). vars["name-with-dashes"]— переменные, имена которых не идентификаторы.env— имя текущего окружения.
После ответа (в expect, save, poll) доступны ещё:
| Имя | Значение |
|---|---|
status |
Код ответа, число |
headers |
Заголовки ответа; регистр имени не важен: headers.content-type, headers["X-Id"] |
body |
Разобранный JSON или текст, если тело не JSON |
duration |
Время до получения всего ответа, мс |
cookies |
Cookies прогона для адреса этого запроса: cookies.session — см. Cookies |
a.b, a["b"], a[0], a[-1] (с конца). Чтение отсутствующего поля или индекса даёт null, а не
ошибку, поэтому body.user.address.city == null безопасно, а body.id != null значит «есть».
После . имя может содержать дефисы, как имена заголовков: headers.content-type. Вычитание пишите
с пробелами: body.total - 1.
Операторы
Заголовок раздела «Операторы»По убыванию приоритета:
| Операторы | Значение |
|---|---|
!x, -x, typeof x |
не, минус, имя типа |
* / % |
арифметика |
+ - |
арифметика; + для строк склеивает |
< <= > >= |
числа или строки по алфавиту |
== != |
глубокое сравнение, без приведения типов: "1" != 1 |
in, matches |
вхождение, шаблон |
&& |
и |
|| |
или |
typeof x— одно из"string","number","boolean","null","array","object".x in [1, 2, 3]— элемент массива;"id" in body— ключ объекта;"ab" in "cab"— подстрока.x matches /re/— регулярное выражение;x matches Orderилиx matches { … }— форма.- Значения заголовков — строки: сравнивать как числа через
number(headers.content-length) < 1024.
| Для | Методы |
|---|---|
| строк | .length, .startsWith(s), .endsWith(s), .contains(s), .lower(), .upper(), .trim(), .split(sep) |
| массивов | .length, .contains(x), .first, .last, .any(x => …), .all(x => …), .map(x => …), .filter(x => …) |
| объектов | .keys, .values, .has(key) |
Стрелка x => expr разрешена только как аргумент этих методов:
expect { body.items.all(i => i.qty > 0) body.items.map(i => i.sku).contains("A-1")}Встроенные функции
Заголовок раздела «Встроенные функции»| Функция | Результат |
|---|---|
uuid() |
Случайный UUID v4 |
now() |
Unix-время в секундах; now() + 3600 — через час |
nowIso() |
Текущее время, RFC 3339 |
randomInt(min, max) |
Случайное целое, оба конца включительно |
randomString(n) |
n случайных букв и цифр |
number(x), string(x) |
Преобразование; number("abc") — ошибка |
json(x) |
x в виде компактного JSON |
base64(x), unbase64(x) |
Base64 |
file(path) |
Файл, для body и multipart |
schema(path) |
JSON Schema, для matches |
Каждый вызов даёт новое значение: два uuid() различаются.
Проверки
Заголовок раздела «Проверки»expect { status == 201 body.total > 0 body.items.length == 1 headers.content-type.contains("json") duration < 500}Каждая строка — отдельная проверка и должна давать boolean. Выполняются все проверки, даже после провала. Проваленная проверка показывает выражение и значения его частей:
✗ body.total > 0 — body.total is 0✗ body.items.length == 1 — body.items.length is 3matches проверяет структуру значения:
expect { body matches { id: string, total: number, status: "new" | "paid", items: [{ sku: string, qty: integer }], note?: string, // необязательное deleted_at: string | null, }}| Форма | Подходит |
|---|---|
string, number, integer, boolean, null, any |
Значение этого типа |
"paid", 42, true |
Ровно это значение |
A | B |
Любое из двух |
[T] |
Массив, каждый элемент которого подходит под T |
{ a: T, b?: U } |
Объект с полем a и необязательным b; лишние поля разрешены |
Name |
Именованная форма |
schema("./order.schema.json") |
JSON Schema |
Ошибка называет первый неподходящий путь: body.items[2].qty: expected integer, got "2".
Путь в schema() считается от файла .outry, где он написан (для именованной формы — от файла, где
она объявлена). Поддерживаются ключи JSON Schema (draft 7 / 2020-12 и nullable из OpenAPI 3.0):
type, enum, const, properties, required, additionalProperties, items, prefixItems,
minItems / maxItems, minLength / maxLength, pattern, minimum / maximum и их exclusive-формы,
allOf, anyOf, oneOf, not и $ref внутри того же файла. Остальные ключи не проверяются.
outry check сообщает об отсутствующем или неверном файле схемы.
shape Order = schema("./schemas/order.json")Именованные формы объявляются на верхнем уровне и видны во всём проекте:
shape Order { id: string, total: number, items: [Item] }
shape Item { sku: string, qty: integer }Запланировано: outry import go генерирует формы из Go-структур, которые возвращает хендлер (теги json,
omitempty → необязательное поле), в api/shapes.outry, а outry import go --check падает, когда
они перестают совпадать с кодом — см. Синхронизация с кодом.
Сохранение значений
Заголовок раздела «Сохранение значений»save order_id = body.idsave first_sku = body.items[0].skusave выполняется после успешного ответа; значение доступно следующим запросам как переменная и
сохраняется между запусками, как сегодняшний > save. Сохранить null (отсутствующее поле) — ошибка.
Параметры
Заголовок раздела «Параметры»Запрос можно вызвать с аргументами. Его параметры:
- path-параметры — все
{name}в адресе; - объявленные параметры — в
params, со значением по умолчанию или без.
Login: POST /auth/login { params { email: "dev@example.com" password // без значения по умолчанию }
body { email, password } expect { status == 200 }}Значение параметра: аргумент вызова → значение по умолчанию → цепочка переменных. Если значения нет нигде — ошибка со списком всех недостающих имён, как сейчас с переменными.
Вызов запросов
Заголовок раздела «Вызов запросов»Запрос вызывается как функция, только с именованными аргументами:
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" }Результат — ответ вызванного запроса: status, headers, body, duration.
Где. Вызов — это выражение, он допустим везде, где выражения: query, headers, body, expect,
save, poll и сценарии. Выполняется, когда вычисляется выражение: поля до отправки — в порядке
в файле, expect — после ответа.
Имена. Имена запросов уникальны в проекте, импорты не нужны. Если в двух папках есть Create, их
вызывают с папкой: users.Create(), orders.Create() (путь от корня проекта, / → .).
Неоднозначные и неизвестные имена находит outry check.
Ошибки. Если вызванный запрос упал — сеть, проваленный expect, нет переменной, — падает и
вызывающий, с цепочкой вызовов:
✗ CreateOrder → Login: status == 200 — status is 401Окружения. only вызванного запроса действует: запрос с [dev] нельзя вызвать в prod ни
напрямую, ни косвенно. outry check --env prod находит такие вызовы, ничего не отправляя.
Циклы. A вызывает B, B вызывает A — ошибка outry check, независимо от аргументов.
Побочные эффекты. save вызванного запроса применяются так же, как при его отдельном запуске.
Кеширование
Заголовок раздела «Кеширование»В пределах одного прогона вызов с тем же запросом и теми же аргументами отправляется один раз,
следующие вызовы получают тот же ответ. Десять запросов, вызывающих Login(), логинятся один раз.
fresh Login()всегда отправляет новый запрос.- Прогон — это один
outry runв CLI, а в приложении — пока не переключили окружение или не нажали New run на вкладке Variables. cache: 30mу вызываемого запроса хранит его ответ между прогонами (в том же файле состояния, что и сохранённые значения, вне репозитория): токен логина переживает перезапуск, пока не истечёт.401у запроса, который использовал закешированное значение, сам не повторяется; где это важно, используйтеfresh.
Cookies
Заголовок раздела «Cookies»У каждого прогона своё хранилище cookies, как в браузере: Set-Cookie из ответа запоминается и
отправляется со следующими запросами на тот же домен и путь, в том числе из вызовов.
Логин по сессии работает без ручного сохранения. Хранилище живёт столько же, сколько кеш вызовов:
один outry run, а в приложении — пока не переключили окружение.
После ответа cookies — cookies хранилища для адреса запроса:
expect { cookies.session != null}Заголовок Cookie в headers отправляется как написан, вместе с cookies хранилища.
WaitUntilPaid: GET /orders/{id} { poll body.status == "paid" every 1s for 30s expect { body.paid_at != null }}Запрос повторяется раз в every, пока условие не станет истинным, затем expect и save
выполняются на последнем ответе. Если за for условие так и не выполнилось, запрос падает с
последними значениями.
Сценарии
Заголовок раздела «Сценарии»Сценарий (flow) — именованная последовательность шагов сверху вниз.
// 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}Шаг — одно из:
name = expr— локальное имя, видно следующим шагам этого сценария;- вызов
Name(…)— запроса или другого сценария; expect { … }иsave name = expr.
Сценарий останавливается на первом упавшем шаге. Сценарии принимают params, как запросы, и могут
вызывать другие сценарии; результат вызова сценария — null. Приложение показывает трассу всех
запросов, которые вызвал сценарий (или запрос), с ответами и временем.
let в файле
Заголовок раздела «let в файле»let shop = "main"let admin = { email: "admin@example.com", role: "admin" }let виден только в своём файле и при поиске имени стоит между параметрами и цепочкой переменных.
Общие для нескольких файлов значения — в env.toml.
Синхронизация с кодом
Заголовок раздела «Синхронизация с кодом»outry import записывает обработчик в каждый созданный запрос:
GetUser: GET /users/{id} { handler: users.GetUser expect { body matches User }}Запросы сопоставляются с роутами по handler, поэтому запрос узнаётся и после смены пути или метода
в коде. Запросы без handler сопоставляются по методу и пути, параметры сравниваются как «любое
значение» (/users/{id} ~ /users/{user_id}).
Затем outry import go --check сравнивает каждый найденный запрос с его обработчиком — метод и путь,
поля тела и их типы, query-параметры, заголовки, shape ответа — и падает, когда они разошлись;
--fix исправляет то, что исправляется без догадок, --prune удаляет файлы пропавших роутов.
В приложении то же самое — во вкладке Routes. Подробнее —
Импорт роутов из Go.
outry run api/orders/create.outry # все запросы и сценарии файла сверху внизoutry run api/ # все файлы, по путиoutry run CreateOrder # по имениoutry run Checkout --env stagingoutry run api/orders/create.outry:12 # запрос или сценарий на строке 12outry check api/ # синтаксис, имена, аргументы, формы, циклыoutry check api/ --env prod # + `only` и переменные, которых нет в prodoutry fmt [--check] [paths] # привести файлы к каноническому видуoutry convert api/ # *.http → *.outryoutry import go . # запросы для новых роутов Go-сервисаЗапуск файла или каталога выполняет все запросы в нём, в том числе те, что нужны в основном для
вызова (Login, CreateUser); кеш вызовов следит, чтобы общий Login() всё равно
отправился один раз. Чтобы выполнить только сценарий, запустите flow по имени.
Форматирование
Заголовок раздела «Форматирование»outry fmt даёт один канонический вид, как gofmt:
- отступ два пробела; одно поле на строку; пустая строка между элементами верхнего уровня (идущие
подряд
letи формы сохраняют группировку); - поля в порядке таблицы полей, многострочные поля отделены пустыми строками;
- блок из одного элемента, который помещается, остаётся в одну строку (
expect { status == 200 });expectс несколькими проверками — по одной на строку; - двойные кавычки;
key: valueс одним пробелом;{ email }вместоemail: email; длительности — в самой крупной целой единице (10000ms→10s); - объекты, массивы и списки аргументов, которые помещаются в 80 символов, остаются в одну строку, иначе — по элементу на строку с висячей запятой;
- строки
"""…"""получают отступ окружающего блока; - комментарии сохраняются: над элементом, в конце его строки или в конце блока. Элемент, который нельзя напечатать, не сдвинув комментарий (например, комментарий внутри формы), остаётся как был.
outry fmt api/ # переписать, напечатать изменённые файлыoutry fmt --check api/ # CI: код выхода 1, если что-то не отформатированоСовместимость с .http
Заголовок раздела «Совместимость с .http»- Файлы
*.httpчитает текущий парсер, они работают как сейчас; оба формата могут жить в одном проекте. Запросы.outryне могут вызывать запросы.http. {{var}}в.outryнедопустим;outry checkподскажет${var}или{var}в пути.outry convertпереписывает.httpв.outryрядом (--dry-runпечатает,--rmпотом удаляет.http; существующий.outryне перезаписывается):
.http |
.outry |
|---|---|
POST {{base}}/users/{{id}} |
POST /users/{id} |
Header: Bearer {{token}} |
Header: "Bearer ${token}"; значение из одного {{token}} → token |
| JSON-тело | body { … }: "{{x}}" → x, "a {{x}}" → "a ${x}", {{x}} без кавычек → number(x) |
| другое тело | "…", или """…""", если в несколько строк |
> save x = body.id |
save x = body.id |
> assert status == 200 |
status == 200 в 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 |
# комментарий над запросом |
Имя: из первой строки комментария, если в ней до четырёх слов (остальное остаётся комментарием //), иначе из имени файла |
{{$uuid}}, {{$timestamp}}, {{$randomInt 1 10}} |
uuid(), now(), randomInt(1, 9) (в .outry верхняя граница включается) |
{{x-y}} |
vars["x-y"] |