Skip to content

Import routes from Go

Outry can read your Go service’s source code, find the registered routes and create a .outry request for every route that doesn’t have one yet. Existing requests are compared with the code, so the collection doesn’t drift from the service: outry import go --check fails CI when it does, and --fix updates the requests. Existing files change only with --fix.

Terminal window
cd my-service
outry init # once: api/env.toml with `base`
outry import go . # scan the whole repository
+ 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)
  • + — a new file was created for the route;
  • = — a request for this route already exists (under any name, in .outry or .http);
  • ~ — the request exists, but differs from the code: the differences are listed under it;
  • - — a request points at base, but no such route was found in the code.

Add --dry-run to see the plan without writing anything. In the desktop app the same thing is the sync button next to Requests, or the Routes tab.

Router What is recognized
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") with nested groups, r.Group("/v1").GET(…), middleware in r.Group("/v1", mw), r.GET("/x", mw, h) and r.Use(mw)
net/http mux.HandleFunc("GET /items/{id}", h) (Go 1.22 patterns), http.HandleFunc("/path", h)

A router is only looked for in files that import it. Routes registered in other functions and packages get their prefixes too: r.Mount("/admin", admin.Routes()), users.Register(v1), users.Register(r.Group("/users")) — that’s why it’s best to scan the whole repository, not just cmd/server. vendor/, testdata/ and *_test.go are skipped.

Paths may be string literals, concatenations and package constants (apiPrefix + "/users"). A path computed at runtime can’t be resolved; it is reported as a warning.

In the desktop app the Routes tab shows the same information for every route; click a new route to preview the file before creating it.

Path parameters keep the router notation: {id}, {id:[0-9]+}, {path...}, :id and *path all turn into {id} / {path} — parameters of the request.

Outry also reads the handler to show what the request expects:

  • description — the handler’s doc comment, or @Summary / @Description from swag annotations;
  • body — the struct the request is decoded into: json.NewDecoder(r.Body).Decode(&req), c.ShouldBindJSON(&req), render.DecodeJSON, your own decodeJSON(r, &req), or a validator object with a Bind method. Fields come from json tags, binding:"required" / validate:"required" marks required ones, nested structs are listed with dots (user.email), and a comment after a field is kept;
  • query — r.URL.Query().Get("page"), c.Query, c.DefaultQuery, c.QueryParam, r.FormValue and c.ShouldBindQuery(&q) (fields from form tags);
  • form — c.PostForm, c.DefaultPostForm, r.PostFormValue give a form { … } body; with a file (c.FormFile("file"), r.FormFile) it becomes multipart { file: file("") };
  • headers — r.Header.Get("X-Tenant-ID"), c.GetHeader;
  • response — the value of the first successful json.NewEncoder(w).Encode(v), c.JSON(200, v), render.JSON(w, r, v) or your own writeJSON(w, http.StatusOK, v); responses with an error status and gin.H / maps are skipped. The request gets expect { body matches Order }, and the struct becomes a shape.
api/users/post.outry
// Sends a welcome email.
// chi internal/http/users.go:42 → h.CreateUser
//
// Headers: X-Tenant-ID
// Body: dto.CreateUser
// name string required full name
// 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
}
}
  • The name (ListUsers:) comes from the doc comment: a short @Summary or first line is the name itself; a Go-style comment (ListUsers returns …) gives the name of the function, the comment goes to the description. Without a comment the name is the handler’s (h.GetUser → GetUser).
  • handler: links the request to the code, so the request is recognised after its path changes.
  • Query parameters become params with a null default, so ListUsers(page: 2) sends ?page=2 and ListUsers() sends none.
  • The body is an example with empty values of the right types. POST, PUT and PATCH without a recognized body get body {}. A route that accepts any method (r.Handle, gin Any) is created as GET with an any method comment.

File names follow the path: static segments become folders, trailing parameters go into the name.

Route File
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

If the name is taken by a file for a different route (.outry or .http), a suffix is added: users/get-2.outry.

Outry collects the middleware each route goes through: r.Use(…) above it in the same function and block, the group’s r.Group("/v1", auth) (gin), r.With(auth).Get(…) and r.Group(func(r chi.Router) { r.Use(auth); … }) (chi), and the middleware of a router passed to another function (routes.Register(authed)). The generated file lists them under the source line; middleware that every route has (logging, recovery) is left out.

Tell Outry which headers a middleware needs in env.toml, and requests for routes behind it get them right away:

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

The key is the middleware’s name as written in the code: AuthRequired matches middleware.AuthRequired(), mw.AuthRequired and AuthRequired; mw.RequireRole matches any mw.RequireRole(…). A header value is a .outry string, so ${…} is an expression — here a call of the Login request, which is sent once and cached. An existing request without the header gets a warning with a fix.

A request counts as the request for a route when its handler: names the route’s handler. Requests without handler match by method and path, ignoring the query string: in .outry paths starting with / (appended to base), in .http URLs starting with {{base}}. Parameters match any name, so /users/{user_id} covers GET /users/{id}, and so does a concrete value — /users/42 — unless the code has a static /users/42 route of its own. Rename and reorganize the generated files freely — the next import still recognizes them.

Use --base <NAME> if the .http files call the service address something other than base.

Every matched request is checked against its handler. Each difference has the line in the request file and the place in the Go code; errors fail --check, warnings don’t. Differences marked fix are corrected by --fix.

Difference Example
Method changed (request found by handler:) method changed in code: PUT → PATCH error, fix
Path changed (request found by handler:) path changed in code: /users/{id} → /v2/users/{id} error, fix
Path parameter renamed path parameter renamed in code: {id} → {user_id} warning
The handler reads a body, the request sends none handler reads a dto.CreateUser body, the request sends none error, fix
A required field is missing from body required field `email` (string) is missing from body error, fix
A field in body is not in the struct body field `nmae` is not in dto.CreateUser error, fix
A literal of the wrong type body field `age` is a string, code expects int error, fix
The handler reads a query parameter the request doesn’t send handler reads query parameter `page`, the request doesn't send it warning (error if required)
The handler reads a header the request doesn’t send handler reads header `X-Tenant-ID`, the request doesn't send it warning
The route is behind a middleware from env.toml, the request doesn’t send its header route is behind `middleware.AuthRequired()`, the request doesn't send header `Authorization` warning, fix
The response isn’t checked handler responds with Order, the request doesn't check `body matches Order` warning, fix

Only a literal object body { … } is compared with the struct: nested objects and arrays by their json names (address.city, items[].sku), types only for literal values — named types are resolved (type Status string expects a string). A body taken from a variable, a file, a form or a string is left alone.

A request that expects a client error — status == 400, status >= 400, status in [401, 403] — is a negative test: its body, query and headers are wrong on purpose, so only its method and path are checked.

Requests in .http files are compared in a limited way: the body only if it is valid JSON, plus query parameters and headers, without fixes. Their method and path are what they were matched by.

A fix changes only the spot it is about, and the request it touches is reprinted the way outry fmt does; the rest of the file stays as it was.

  • a new method or path; path parameters keep the request’s names ({user_id} stays even if the code says {id}), because calls and variables refer to them;
  • a missing body or required field is added with an empty value of the right type, an unknown field is removed;
  • a literal of the wrong type is converted when it can be ("25" → 25, 1 → "1"), otherwise replaced with an empty value;
  • body matches Order is added to expect;
  • a header from [import.middleware] is added to headers.

Parameter renames, query parameters and other headers are left to you — Outry doesn’t know their values.

A struct the handler responds with becomes a shape, and the generated request checks the response with body matches. Shapes go to shapes.outry in the project (or the file that already has shapes), one per Go type, with its location in a comment:

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 tags give the names, omitempty makes a field optional, a pointer without it may be null, nested structs become shapes of their own, time.Time is a string, maps and interfaces are {} and any.

The next imports compare the shapes with the code. A shape may be stricter than the code — literals instead of string (status: "new" | "paid"), integer instead of number, no null — that is not a difference. A field that is not in the struct, or a type that contradicts it, is an error; a field missing from the shape is a warning. --fix rewrites the shape by the code and keeps your compatible fields as they are. Shapes for new types are added by outry import go like new requests.

Terminal window
outry import go . --check # report by file, exit code 1 on errors
outry import go . --check --format github # the same as GitHub Actions annotations
outry import go . --fix --dry-run # diff of all fixes
outry import go . --fix # apply them (and create new files)
outry import go . --prune # delete files whose routes are all gone

--check writes nothing. It fails on new routes without a request, requests whose route is gone, response types without a shape and on differences that are errors:

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`

With --format github each line becomes an annotation on the .outry file — or on the Go line, for a new route — so it shows up in the pull request. --format json (or --json) prints the whole plan: existing[].changes with severity, go, fixable and the diff of each fix, shape_changes, new_shapes, stale and prunable.

--prune deletes only whole files where every request points at a route that’s gone and there is nothing else (flows, shapes, let). Other stale requests are listed for you to remove by hand.

Routes are found by tree-sitter queries — the built-in ones live in crates/outry-core/queries. To support another router, write your own query and pass it with --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
Terminal window
outry import go . --query echo.scm
Capture Meaning
@route The call that registers a route
@path Its path
@method Method: Get, GET, "GET" or http.MethodGet. Without it the method comes from a "GET /path" pattern, otherwise any method
@handler Handler, written into handler: and the comment
@receiver The router the method is called on — used to find its group prefix
@group + @group.path A prefix. With @group.var it applies to that variable (v1 := r.Group("/v1")), with @group.body to everything inside the block (r.Route)
@mount + @mount.func Routes of the function with this name get the prefix of @receiver plus @mount.path; @mount.pkg narrows it to a package
@middleware Middleware of a @route or @group, any number: (argument_list . (_) @path (_)* @middleware (_) @handler .)
@use + @middleware Middleware for the routes of @receiver: as a statement (r.Use(mw)) for routes below it in the same function and block, inside a call chain (r.With(mw).Get) for that call only

Captures starting with _ are free for predicates. A ; outry: import <path> line limits the query to files importing a package with that prefix.