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.
cd my-serviceoutry 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 codecreated 2 files1 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.outryor.http);~— the request exists, but differs from the code: the differences are listed under it;-— a request points atbase, 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.
Supported routers
Section titled “Supported routers”| 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.
Generated files
Section titled “Generated files”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/@Descriptionfrom swag annotations; - body — the struct the request is decoded into:
json.NewDecoder(r.Body).Decode(&req),c.ShouldBindJSON(&req),render.DecodeJSON, your owndecodeJSON(r, &req), or a validator object with aBindmethod. Fields come fromjsontags,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.FormValueandc.ShouldBindQuery(&q)(fields fromformtags); - form —
c.PostForm,c.DefaultPostForm,r.PostFormValuegive aform { … }body; with a file (c.FormFile("file"),r.FormFile) it becomesmultipart { 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 ownwriteJSON(w, http.StatusOK, v); responses with an error status andgin.H/ maps are skipped. The request getsexpect { body matches Order }, and the struct becomes a shape.
// 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 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 }}- The name (
ListUsers:) comes from the doc comment: a short@Summaryor 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
paramswith anulldefault, soListUsers(page: 2)sends?page=2andListUsers()sends none. - The body is an example with empty values of the right types.
POST,PUTandPATCHwithout a recognized body getbody {}. A route that accepts any method (r.Handle, ginAny) is created asGETwith anany methodcomment.
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.
Middleware
Section titled “Middleware”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:
[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}" }}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.
Matching existing requests
Section titled “Matching existing requests”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.
Differences from the code
Section titled “Differences from the code”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.
What --fix changes
Section titled “What --fix changes”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 Orderis added toexpect;- a header from
[import.middleware]is added toheaders.
Parameter renames, query parameters and other headers are left to you — Outry doesn’t know their values.
Response shapes
Section titled “Response shapes”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:
// dto.Order — internal/dto/order.go:12shape 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.
Checking in CI and fixing
Section titled “Checking in CI and fixing”outry import go . --check # report by file, exit code 1 on errorsoutry import go . --check --format github # the same as GitHub Actions annotationsoutry import go . --fix --dry-run # diff of all fixesoutry 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.
Other routers
Section titled “Other routers”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:
; 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| 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.