Импорт роутов из Go
Outry читает исходники Go-сервиса, находит зарегистрированные роуты и создаёт запрос .outry для
каждого роута, у которого его ещё нет. Существующие запросы сравниваются с кодом, чтобы коллекция не
разъезжалась с сервисом: outry import go --check роняет CI, когда это случилось, а --fix обновляет
запросы. Существующие файлы меняются только с --fix.
cd my-serviceoutry init # один раз: api/env.toml с `base`outry import go . # сканировать весь репозиторий+ api/users/get.outry GET /users internal/http/routes.go:24+ api/users/get-by-id.outry GET /users/{id} internal/http/routes.go:25= api/users/create.outry POST /users internal/http/routes.go:26~ api/users/update.outry PUT /users/{id} internal/http/routes.go:27 api/users/update.outry:4:9 error body field `age` is a string, code expects int- api/legacy/get.outry GET /legacy (no such route in code)
14 Go files, 4 routes: 2 new, 2 existing (1 changed), 1 not in codecreated 2 files1 differences can be fixed with --fix (preview: --fix --dry-run)+— для роута создан новый файл;=— запрос для этого роута уже есть (под любым именем, в.outryили.http);~— запрос есть, но расходится с кодом: расхождения перечислены под ним;-— запрос указывает наbase, но такого роута в коде нет.
С --dry-run план показывается без записи. В приложении то же самое — кнопка синхронизации рядом с
Requests или вкладка Routes.
Поддерживаемые роутеры
Заголовок раздела «Поддерживаемые роутеры»| Роутер | Что распознаётся |
|---|---|
| chi | r.Get/Post/…, r.Method("GET", …), r.Handle, r.Route("/prefix", func(r chi.Router) {…}), r.Mount("/prefix", sub()), r.Mount("/prefix", subRouter), r.With(mw).Get(…), r.Use(mw), r.Group(func(r chi.Router) {…}) |
| gin | r.GET/POST/…, r.Any, r.Handle("GET", …), v1 := r.Group("/v1") и вложенные группы, r.Group("/v1").GET(…), middleware в r.Group("/v1", mw), r.GET("/x", mw, h) и r.Use(mw) |
net/http |
mux.HandleFunc("GET /items/{id}", h) (шаблоны Go 1.22), http.HandleFunc("/path", h) |
Роутер ищется только в файлах, которые его импортируют. Роуты из других функций и пакетов тоже получают
префиксы: r.Mount("/admin", admin.Routes()), users.Register(v1), users.Register(r.Group("/users")) —
поэтому лучше сканировать весь репозиторий, а не только cmd/server. vendor/, testdata/ и
*_test.go пропускаются.
Путь может быть строкой, конкатенацией и константой пакета (apiPrefix + "/users"). Путь, который
вычисляется во время работы, не определить — о нём будет предупреждение.
В приложении вкладка Routes показывает то же самое для каждого роута; нажмите на новый роут, чтобы посмотреть файл до создания.
Что создаётся
Заголовок раздела «Что создаётся»Параметры пути остаются в записи роутера: {id}, {id:[0-9]+}, {path...}, :id и *path
превращаются в {id} / {path} — параметры запроса.
Outry также читает обработчик и показывает, что запрос ожидает:
- описание — doc-комментарий обработчика или
@Summary/@Descriptionиз аннотаций swag; - тело — структура, в которую декодируется запрос:
json.NewDecoder(r.Body).Decode(&req),c.ShouldBindJSON(&req),render.DecodeJSON, свояdecodeJSON(r, &req)или объект-валидатор с методомBind. Поля берутся из теговjson,binding:"required"/validate:"required"отмечают обязательные, вложенные структуры показываются через точку (user.email), комментарий после поля сохраняется; - query —
r.URL.Query().Get("page"),c.Query,c.DefaultQuery,c.QueryParam,r.FormValueиc.ShouldBindQuery(&q)(поля из теговform); - форма —
c.PostForm,c.DefaultPostForm,r.PostFormValueдают телоform { … }, а с файлом (c.FormFile("file"),r.FormFile) —multipart { file: file("") }; - заголовки —
r.Header.Get("X-Tenant-ID"),c.GetHeader; - ответ — значение первого успешного
json.NewEncoder(w).Encode(v),c.JSON(200, v),render.JSON(w, r, v)или своегоwriteJSON(w, http.StatusOK, v); ответы с кодом ошибки иgin.H/ map пропускаются. Запрос получаетexpect { body matches Order }, а структура становится shape.
// Отправляет приветственное письмо.// chi internal/http/users.go:42 → h.CreateUser//// Headers: X-Tenant-ID// Body: dto.CreateUser// name string required ФИО// email string required// address.city stringCreateUser: POST /users { handler: h.CreateUser body { name: "", email: "", address: { city: "" } }}// ListUsers returns a page of users.// chi internal/http/users.go:22 → h.ListUsers//// Query:// page string// limit stringListUsers: GET /users { handler: h.ListUsers
params { page: null limit: null }
query { page limit }}- Имя (
ListUsers:) — из doc-комментария: короткий@Summaryили первая строка и есть имя; комментарий в стиле Go (ListUsers returns …) даёт имя функции, а сам уходит в описание. Без комментария имя берётся из обработчика (h.GetUser→GetUser). handler:связывает запрос с кодом: запрос узнаётся и после того, как поменялся путь.- Query-параметры становятся
paramsсо значениемnullпо умолчанию, так чтоListUsers(page: 2)отправит?page=2, аListUsers()— ничего. - Тело — пример с пустыми значениями нужных типов.
POST,PUTиPATCH, у которых тело не распознано, получаютbody {}. Роут на любой метод (r.Handle,Anyв gin) создаётся какGETс комментариемany method.
Имена файлов повторяют путь: статические сегменты — папки, параметры в конце — в имени.
| Роут | Файл |
|---|---|
GET /users |
users/get.outry |
POST /users |
users/post.outry |
GET /users/{id} |
users/get-by-id.outry |
GET /users/{id}/posts |
users/posts/get.outry |
GET / |
root/get.outry |
Если имя занято файлом другого роута (.outry или .http), добавляется суффикс: users/get-2.outry.
Middleware
Заголовок раздела «Middleware»Outry собирает middleware, через которые проходит роут: r.Use(…) выше в той же функции и том же
блоке, группу r.Group("/v1", auth) (gin), r.With(auth).Get(…) и
r.Group(func(r chi.Router) { r.Use(auth); … }) (chi), а также middleware роутера, переданного в другую
функцию (routes.Register(authed)). В созданном файле они перечислены под строкой с источником;
middleware, которые есть у всех роутов (логирование, recovery), не пишутся.
Какие заголовки нужны middleware, задаётся в env.toml, и запросы к
роутам за ним получают их сразу:
[import.middleware]AuthRequired = { headers = { Authorization = "Bearer ${Login().body.token}" } }// gin internal/router/router.go:31 → h.Orders.List// middleware: middleware.AuthRequired()ListOrders: GET /orders { handler: h.Orders.List headers { Authorization: "Bearer ${Login().body.token}" }}Ключ — имя middleware, как в коде: AuthRequired подходит к middleware.AuthRequired(),
mw.AuthRequired и AuthRequired; mw.RequireRole — к любому mw.RequireRole(…). Значение
заголовка — строка .outry, так что ${…} — выражение, здесь вызов
запроса Login, который отправляется один раз и кешируется. Существующий запрос без заголовка получает
предупреждение с правкой.
Сопоставление с существующими запросами
Заголовок раздела «Сопоставление с существующими запросами»Запрос считается запросом для роута, если его handler: называет обработчик роута. Запросы без
handler сопоставляются по методу и пути (query-строка не учитывается): в .outry — пути, начинающиеся
с / (они дописываются к base), в .http — URL, начинающиеся с {{base}}. Параметры совпадают с
любым именем, так что /users/{user_id} подходит для GET /users/{id}, как и конкретное значение —
/users/42, если в коде нет отдельного статического роута /users/42. Созданные файлы можно свободно
переименовывать и раскладывать по папкам — следующий импорт их узнает.
Если адрес сервиса в .http-файлах называется не base, укажите --base <ИМЯ>.
Расхождения с кодом
Заголовок раздела «Расхождения с кодом»Каждый найденный запрос сверяется с обработчиком. У каждого расхождения есть строка в файле запроса
и место в Go-коде; ошибки (error) роняют --check, предупреждения (warning) — нет.
Расхождения с пометкой fix исправляет --fix.
| Расхождение | Пример | |
|---|---|---|
Сменился метод (запрос найден по handler:) |
method changed in code: PUT → PATCH |
error, fix |
Сменился путь (запрос найден по handler:) |
path changed in code: /users/{id} → /v2/users/{id} |
error, fix |
| Переименован параметр пути | path parameter renamed in code: {id} → {user_id} |
warning |
| Обработчик читает тело, а запрос его не шлёт | handler reads a dto.CreateUser body, the request sends none |
error, fix |
Обязательного поля нет в body |
required field `email` (string) is missing from body |
error, fix |
Поля из body нет в структуре |
body field `nmae` is not in dto.CreateUser |
error, fix |
| Литерал не того типа | body field `age` is a string, code expects int |
error, fix |
| Обработчик читает query-параметр, которого запрос не шлёт | handler reads query parameter `page`, the request doesn't send it |
warning (error, если required) |
| Обработчик читает заголовок, которого запрос не шлёт | handler reads header `X-Tenant-ID`, the request doesn't send it |
warning |
Роут за middleware из env.toml, а запрос не шлёт его заголовок |
route is behind `middleware.AuthRequired()`, the request doesn't send header `Authorization` |
warning, fix |
| Ответ не проверяется | handler responds with Order, the request doesn't check `body matches Order` |
warning, fix |
Со структурой сравнивается только литерал-объект body { … }: вложенные объекты и массивы — по именам
из json (address.city, items[].sku), типы — только у литералов; именованные типы раскрываются
(type Status string ждёт строку). Тело из переменной, файла, формы или строки не проверяется.
Запрос, который ждёт ошибку клиента — status == 400, status >= 400, status in [401, 403], —
негативный тест: тело, query и заголовки в нём неправильные нарочно, поэтому проверяются только метод
и путь.
Запросы в .http сравниваются ограниченно: тело — только если это валидный JSON, плюс query-параметры
и заголовки, без правок. Метод и путь — то, по чему они найдены.
Что меняет --fix
Заголовок раздела «Что меняет --fix»Правка меняет только своё место, а затронутый запрос печатается заново, как это делает outry fmt;
остальной файл остаётся как был.
- новый метод или путь; параметры пути сохраняют имена из запроса (
{user_id}остаётся, даже если в коде{id}) — на них ссылаются вызовы и переменные; - недостающее тело или обязательное поле добавляется с пустым значением нужного типа, лишнее поле удаляется;
- литерал не того типа приводится, когда это возможно (
"25"→25,1→"1"), иначе заменяется пустым значением; - в
expectдобавляетсяbody matches Order; - в
headersдобавляется заголовок из[import.middleware].
Переименование параметров, query-параметры и остальные заголовки остаются вам — их значений Outry не знает.
Shape ответов
Заголовок раздела «Shape ответов»Структура, которой отвечает обработчик, становится shape,
а созданный запрос проверяет ответ через body matches. Shape пишутся в shapes.outry проекта (или в
файл, где shape уже есть), по одному на тип Go, с его местом в комментарии:
// dto.Order — internal/dto/order.go:12shape Order { id: integer, total: number, note?: string, parent: Order | null, customer: Customer, items: [Item], created: string,}Имена — из тегов json, omitempty делает поле необязательным, указатель без него может быть null,
вложенные структуры становятся отдельными shape, time.Time — строка, map и интерфейсы — {} и any.
Следующие импорты сравнивают shape с кодом. Shape может быть строже кода — литералы вместо string
(status: "new" | "paid"), integer вместо number, без null — это не расхождение. Поле, которого
нет в структуре, или тип, который ей противоречит, — ошибка; поле, которого нет в shape, —
предупреждение. --fix переписывает shape по коду, оставляя ваши совместимые поля как есть. Shape для
новых типов добавляет outry import go, как и новые запросы.
Проверка в CI и исправление
Заголовок раздела «Проверка в CI и исправление»outry import go . --check # отчёт по файлам, код выхода 1 при ошибкахoutry import go . --check --format github # то же аннотациями GitHub Actionsoutry import go . --fix --dry-run # diff всех правокoutry import go . --fix # применить (и создать новые файлы)outry import go . --prune # удалить файлы, все роуты которых пропали--check ничего не пишет. Он падает на новых роутах без запроса, запросах, чьего роута больше нет,
типах ответа без shape и расхождениях-ошибках:
api/users/update.outry 4:9 error body field `age` is a string, code expects int ← internal/http/routes.go:27
internal/http/routes.go 24:1 error route GET /users has no request (`outry import go` creates api/users/get.outry)
2 routes checked: 2 errors, 0 warnings; 1 fixable with `outry import go --fix`С --format github каждая строка становится аннотацией на файле .outry — или на строке Go, для
нового роута, — и видна в pull request. --format json (или --json) выводит весь план:
existing[].changes с severity, go, fixable и diff каждой правки, shape_changes,
new_shapes, stale и prunable.
--prune удаляет только файлы целиком, где все запросы указывают на пропавшие роуты и больше ничего
нет (сценариев, shape, let). Остальные устаревшие запросы перечислены — их удалите вручную.
Другие роутеры
Заголовок раздела «Другие роутеры»Роуты находятся запросами tree-sitter —
встроенные лежат в
crates/outry-core/queries.
Чтобы поддержать другой роутер, напишите свой запрос и передайте его через --query:
; outry: import github.com/labstack/echo(call_expression function: (selector_expression operand: (_) @receiver field: (field_identifier) @method) arguments: (argument_list . (_) @path (_) @handler .) (#any-of? @method "GET" "POST" "PUT" "PATCH" "DELETE")) @routeoutry import go . --query echo.scm| Захват | Значение |
|---|---|
@route |
Вызов, регистрирующий роут |
@path |
Его путь |
@method |
Метод: Get, GET, "GET" или http.MethodGet. Без него метод берётся из шаблона "GET /path", иначе — любой |
@handler |
Обработчик, пишется в handler: и в комментарий |
@receiver |
Роутер, на котором вызван метод, — по нему ищется префикс группы |
@group + @group.path |
Префикс. С @group.var — для этой переменной (v1 := r.Group("/v1")), с @group.body — для всего внутри блока (r.Route) |
@mount + @mount.func |
Роуты функции с этим именем получают префикс @receiver плюс @mount.path; @mount.pkg сужает поиск до пакета |
@middleware |
Middleware @route или @group, сколько угодно: (argument_list . (_) @path (_)* @middleware (_) @handler .) |
@use + @middleware |
Middleware для роутов @receiver: отдельной инструкцией (r.Use(mw)) — для роутов ниже в той же функции и том же блоке, в цепочке вызовов (r.With(mw).Get) — только для этого вызова |
Захваты, начинающиеся с _, — для предикатов. Строка ; outry: import <путь> ограничивает запрос
файлами, импортирующими пакет с таким префиксом.