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

Импорт роутов из Go

Outry читает исходники Go-сервиса, находит зарегистрированные роуты и создаёт запрос .outry для каждого роута, у которого его ещё нет. Существующие запросы сравниваются с кодом, чтобы коллекция не разъезжалась с сервисом: outry import go --check роняет CI, когда это случилось, а --fix обновляет запросы. Существующие файлы меняются только с --fix.

Окно терминала
cd my-service
outry 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 code
created 2 files
1 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.
api/users/post.outry
// Отправляет приветственное письмо.
// chi internal/http/users.go:42 → h.CreateUser
//
// Headers: X-Tenant-ID
// Body: dto.CreateUser
// name string required ФИО
// email string required
// address.city string
CreateUser: POST /users {
handler: h.CreateUser
body { name: "", email: "", address: { city: "" } }
}
api/users/get.outry
// ListUsers returns a page of users.
// chi internal/http/users.go:22 → h.ListUsers
//
// Query:
// page string
// limit string
ListUsers: 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.

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, и запросы к роутам за ним получают их сразу:

api/env.toml
[import.middleware]
AuthRequired = { headers = { Authorization = "Bearer ${Login().body.token}" } }
api/orders/get.outry
// 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-параметры и заголовки, без правок. Метод и путь — то, по чему они найдены.

Правка меняет только своё место, а затронутый запрос печатается заново, как это делает outry fmt; остальной файл остаётся как был.

  • новый метод или путь; параметры пути сохраняют имена из запроса ({user_id} остаётся, даже если в коде {id}) — на них ссылаются вызовы и переменные;
  • недостающее тело или обязательное поле добавляется с пустым значением нужного типа, лишнее поле удаляется;
  • литерал не того типа приводится, когда это возможно ("25" → 25, 1 → "1"), иначе заменяется пустым значением;
  • в expect добавляется body matches Order;
  • в headers добавляется заголовок из [import.middleware].

Переименование параметров, query-параметры и остальные заголовки остаются вам — их значений Outry не знает.

Структура, которой отвечает обработчик, становится shape, а созданный запрос проверяет ответ через body matches. Shape пишутся в shapes.outry проекта (или в файл, где shape уже есть), по одному на тип Go, с его местом в комментарии:

api/shapes.outry
// dto.Order — internal/dto/order.go:12
shape 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, как и новые запросы.

Окно терминала
outry import go . --check # отчёт по файлам, код выхода 1 при ошибках
outry import go . --check --format github # то же аннотациями GitHub Actions
outry 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:

echo.scm
; 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")) @route
Окно терминала
outry 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 <путь> ограничивает запрос файлами, импортирующими пакет с таким префиксом.