Перейти к содержимому

Формат .outry

  • Читается без документации. C-подобный синтаксис: тело — это JSON, проверки похожи на выражения из JS, Java или C#. Ничего специфичного для Go.
  • Растёт вместе с задачей. Минимальный файл — одна строка, блоки добавляются только когда нужны.
  • Декларативный. Запрос может вызывать другие запросы, но в нём нет if, циклов и своих функций. Файлы остаются читаемыми на ревью.
  • Близко к коду. Пути пишутся как в роутере (/orders/{id}), форма ответа может браться из типов самого сервера, поэтому outry import --check видит, когда запросы и код разошлись.
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
}

Минимальный файл:

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

matches проверяет структуру значения:

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")

Именованные формы объявляются на верхнем уровне и видны во всём проекте:

api/shapes.outry
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.id
save first_sku = body.items[0].sku

save выполняется после успешного ответа; значение доступно следующим запросам как переменная и сохраняется между запусками, как сегодняшний > 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, как в браузере: 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) — именованная последовательность шагов сверху вниз.

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
}

Шаг — одно из:

  • name = expr — локальное имя, видно следующим шагам этого сценария;
  • вызов Name(…) — запроса или другого сценария;
  • expect { … } и save name = expr.

Сценарий останавливается на первом упавшем шаге. Сценарии принимают params, как запросы, и могут вызывать другие сценарии; результат вызова сценария — null. Приложение показывает трассу всех запросов, которые вызвал сценарий (или запрос), с ответами и временем.

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 staging
outry run api/orders/create.outry:12 # запрос или сценарий на строке 12
outry check api/ # синтаксис, имена, аргументы, формы, циклы
outry check api/ --env prod # + `only` и переменные, которых нет в prod
outry fmt [--check] [paths] # привести файлы к каноническому виду
outry convert api/ # *.http → *.outry
outry 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 читает текущий парсер, они работают как сейчас; оба формата могут жить в одном проекте. Запросы .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"]