Skip to content

pabloos/gota

Repository files navigation

gota gopher mascot

gota

CI codecov License: MIT

gota generates an OpenAPI 3.1 specification from Go source code. It's code-first with real static inference, not annotation-only:

  1. AST inference — routes, path parameters, handler names — everything deducible without programmer help, via go/ast, go/types and golang.org/x/tools.
  2. gota: comments — structured YAML fragments written directly in OpenAPI vocabulary, placed above a handler. The comment is OpenAPI, not an intermediate DSL, which makes it easy for both humans and AI agents to write or edit.

Declared (comment) fields always win; inferred fields fill in the rest.

// gota:
//   summary: Get a user by ID
//   parameters:
//     - name: id
//       in: path
//       required: true
//       schema:
//         type: integer
//   responses:
//     '200':
//       description: The requested user
func GetUser(w http.ResponseWriter, r *http.Request) { ... }

Status

net/http and Chi are supported routers — both plugins run unconditionally against every analyzed package (no flag needed to pick one; a plugin whose framework isn't present just contributes no routes) and share the same net/http-based body inference, since Chi handlers are plain func(w http.ResponseWriter, r *http.Request) with no response-writing idiom of their own.

Chi's Route/Group nesting is followed to arbitrary depth, accumulating the real path prefix (r.Route("/users", func(r chi.Router) { r.Get("/{id}", H) })/users/{id}). Mount is followed for the common same-package, zero-argument constructor function idiom (func productsRouter() chi.Router {...} / mx.Mount("/products", productsRouter())) — a constructor declared in a different package can't be resolved (route extraction only ever sees one package at a time), and is left undetected rather than guessed. A chi-specific regex-constrained param ({id:[0-9]+}) degrades to a bare {id} (no direct OpenAPI equivalent); a bare wildcard segment (/admin/*) has no OpenAPI path-template equivalent at all and that registration is declined entirely.

net/http is the reference router, using Go 1.22+'s enhanced ServeMux patterns ("GET /users/{id}"). A pattern with no method (mux.HandleFunc("/health", HealthCheck)) matches every method per net/http's own ServeMux docs, so gota expands it into one operation per HTTP method it supports (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, TRACE — not CONNECT, which OpenAPI's Path Item Object has no slot for) rather than assuming GET. If that expansion would make two or more operations infer the same operationId (the guaranteed case: the handler is the same for all of them), gota disambiguates by appending the method and path rather than emitting an invalid document.

A method-less pattern registered against an anonymous function that itself dispatches on r.Method — the common pre-Go-1.22 idiom for method routing on one path — is also recognized, not expanded to every method:

mux.Handle("/products", secure(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodPost:
		CreateProduct(w, r)
	case http.MethodGet:
		ListProducts(w, r)
	default:
		methodNotAllowed(w)
	}
})))

splits into one operation per case (or if/else if branch), each correctly bound to its real delegate handler — not the anonymous closure — including that handler's own gota: comment, if any. This is narrow, not a general control-flow analysis: the closure's entire body must be exactly that one switch/if-else chain, and each branch exactly one call. Any extra logic mixed into the dispatch (e.g. a path-substring check alongside the method check) falls outside this and the registration is left undetected, same as before this existed — not a partial or incorrect guess.

A gota: comment can declare schema: {$ref: '#/components/schemas/User'}, and gota generates that component automatically from the matching Go struct — fields, primitive types, omitempty → required/optional, nested structs as their own linked components, slices, maps, time.Time, embedding. The type lookup searches every package under --dir, not just the one containing the comment, so User can live in a different package than the handler that references it.

Handlers with no gota: comment at all also get a best-effort request/response schema, detected from the handler's own encoding/json calls (Decoder.Decode, Encoder.Encode, json.Unmarshal/Marshal, including slices, maps, and basic types like a bare string or int). A map literal wrapping the real payload in an ad-hoc envelope — e.g. json.NewEncoder(w).Encode(map[string]any{"status": "ok", "data": user}), a common way to avoid a named response-envelope struct — gets an inline object schema built from its actual keys (only when every key is a compile-time constant; a dynamic key still produces a response, just a bare {type: object} with no enumerated properties, rather than nothing at all). Response detection tracks the real status code — it walks the body respecting if/else branch boundaries, pairing each Encode/Marshal/http.Error call with whatever w.WriteHeader(<code>) was last called in its own branch (or 200, Go's implicit default, if none was), so a handler with a 404 error branch and a 201 success branch gets both documented as distinct responses, correctly numbered — not everything collapsed into a generic 200.

Detection follows a chain of calls — same package or not, up to four calls deep — to a helper that itself calls Decode/Encode/WriteHeader/http.Error, or delegates further to another helper, treated as if it had all been inlined at the original call site. A parameter reference inside a followed helper resolves back to whatever expression was actually passed at its own call site, transitively through as many levels as it takes — e.g. a shared response library where Success(w, status, data) wraps data in a map-literal envelope and delegates the actual write to JSON(w, status, payload) in the same package, called from a handler in a completely different package, resolves all the way through to the handler's own value. This was tested against two real, representative Go HTTP APIs on GitHub: one (stdlib net/http only) where every handler routed its output through a single same-package respond(w, code, data) helper — before following, every response came out as a generic 200 with no schema; after, error and success responses alike get their real status codes and schemas. The other (13 resource modules) mixed the map-literal-envelope case above with exactly the cross-package, two-level Success/JSON shape — before cross-package/multi-level following, four of its POST endpoints still had no response schema at all despite the map-literal fix; after, all four resolve to the real payload type three frames up.

All of this is a heuristic over common idioms, not a dataflow analysis: following stops after four calls, a helper already earlier in the current chain is declined immediately rather than followed into a cycle, a helper with a variadic parameter or an argument-count mismatch isn't followed at all, a WriteHeader call whose code isn't a compile-time constant is ignored, and if the same status code is produced more than once the last occurrence in source order wins (covers the common "if err != nil {...; return }; ..." shape). A gota: comment always overrides whatever this detects.

Since gota documents every registered route by default (unlike swaggo, which requires an annotation to appear at all), there's also x-gota-skip: true — a real OpenAPI Specification Extension field, not an invented directive — to opt back out. On a handler's own gota: comment it excludes the whole operation from the emitted document:

// gota:
//   x-gota-skip: true
func DebugInfo(w http.ResponseWriter, r *http.Request) { ... }

Placed on a // gota:\n// x-gota-skip: true comment directly above a specific statement inside a handler's body, it excludes just that one detected response (e.g. an unpolished error path) without affecting the rest of the handler's inferred responses — this only applies to response detection, not request body detection.

A schema name declared in more than one analyzed package is a hard error, not a silent "first match wins" — gota has no $ref syntax to say which one was meant, so it refuses to guess.

A handler can be registered from a different package than the one that declares it (mux.HandleFunc("/x", handlers.GetUser)) — gota resolves the gota: comment and best-effort body inference across that boundary the same as if it were local, via a go/types-object index spanning every analyzed package. This only reaches packages within the module being analyzed (--dir), not external dependencies: a handler imported from a third-party module is left unresolved the same way any other unresolvable handler is — the route is still documented, just without a comment or inferred body — rather than gota reading source code outside the project it was pointed at.

A generic type instantiated with exactly one named-struct type argument — the common "response envelope" idiom, Response[User] — gets its own component per instantiation, named <Generic>_<Arg> (Response_User), instead of every instantiation colliding on one Response component with an untyped field. This is inferred automatically from the handler's own Encode/Marshal call, the same as any other best-effort body detection, and the same <Generic>_<Arg> name works in a hand-written gota: comment's $ref too — it's a real, resolvable name, not gota-internal syntax. Scoped deliberately to one type parameter instantiated with a named type: a basic-type argument (Response[string]), a 2+-type-parameter generic, or referencing the bare unparameterized generic name by itself ($ref: '#/components/schemas/Response', with no instantiation specified) all fall back to today's behavior rather than being newly resolved.

Other router plugins — Gin, Echo — are not implemented yet; unlike Chi, they'd also need their own inference.Dialect (see the body inference paragraph above), not just a router plugin.

Usage

go run ./cmd/gota --dir ./path/to/project --out openapi.yaml

Flags:

Flag Default Description
--dir . Directory of the Go project to analyze
--out openapi.yaml Output file (.yaml/.yml or .json)
--title directory name API title
--api-version 0.1.0 API version

Try it against the bundled fixture:

go run ./cmd/gota --dir testdata/nethttp-basic --out /tmp/openapi.yaml

Using gota in CI

Since the spec is derived from the code, the natural place to enforce "the committed spec actually matches the code" is CI — regenerate it and fail the build if that produces a diff, the same drift check you'd use for any other generated file:

name: openapi

on:
  push:
    branches: [main]
  pull_request:

jobs:
  spec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod

      - name: generate openapi.yaml
        run: go run github.com/pabloos/gota/cmd/gota@latest --dir . --out openapi.yaml

      - name: fail if the committed spec is stale
        run: git diff --exit-code openapi.yaml

      - uses: actions/upload-artifact@v4
        with:
          name: openapi
          path: openapi.yaml

go run ./cmd/gota exits non-zero on anything that would make the spec wrong or incomplete — an ambiguous $ref (two packages declaring the same schema name), an HTTP method OpenAPI has no operation slot for, or a structural validation failure (emitter.Validate) — so this job also doubles as a correctness gate, not just a formatting one.

A fresh, validated openapi.yaml artifact in CI is a natural input for whatever comes after doc generation: publish it as a build artifact (as above) for downstream jobs or other repos to consume, diff it against a previous release to catch breaking API changes before they ship, or feed it straight into a client generator — see below.

Example: generating a Go client with oapi-codegen

oapi-codegen reads the spec gota writes and generates a typed Go client from it — a natural next step once the spec exists, and something gota deliberately doesn't do itself. Note that oapi-codegen itself needs a Go 1.25+ toolchain to build — independent of whatever version your own project targets; go generate/go run will fetch that automatically as long as CI has network access.

client/cfg.yaml:

package: client
output: client.gen.go
generate:
  models: true
  client: true

client/generate.go:

package client

//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml ../openapi.yaml

Then, right after gota regenerates openapi.yaml in the CI job above:

      - name: generate Go client from the spec
        run: go generate ./client/...

client.gen.go can be committed like any other generated file (with its own drift check, the same pattern as openapi.yaml above) or uploaded as a build artifact, depending on how your project already handles generated code.

Pipeline

Go source
   │
   ▼
Parsing (go/ast + go/types via golang.org/x/tools/go/packages)
   │
   ├── Extract "gota:" comment blocks  → declared Operation fragments
   └── Router plugin (net/http)        → routes (method, path, handler)
                                             │
                                             ▼
                       Static inference → baseline Operation (params,
                       default response, best-effort body from json calls)
                                             │
                                             ▼
                              Merge (comment wins, inferred fills gaps)
                                             │
                                             ▼
                    Resolve $ref schemas → components.schemas (go/types)
                                             │
                                             ▼
                          Validate (kin-openapi, independent of our model)
                                             │
                                             ▼
                            Emit OpenAPI 3.1 (YAML or JSON)

Validation is a structural sanity check, not a full OpenAPI 3.1 conformance guarantee — see the doc comment on emitter.Validate for the known gap (kin-openapi v0.135.0 models the Schema Object per OpenAPI 3.0 internally). It still catches real mistakes nothing upstream prevents, like a gota: comment declaring a path parameter without required: true.

Testing

go test ./...

Aggregate coverage is tracked by the codecov badge above; the tables below are about what each part's tests actually pin down, not how much.

Core engine — everything router-agnostic:

Feature What's tested Where
gota: comment extraction Full blocks, gofmt-reformatted comments (tab-indent, inserted blank line), no block present, invalid YAML, prose preceding the block internal/extractor/extractor_test.go
Merge semantics Declared fields always win, inferred fields fill gaps, inputs aren't mutated, Deprecated can't be unset once set internal/merger/merger_test.go
Schema $ref resolution Primitives + omitempty→required, nested structs as linked components, slices, time.Time, embedded-field promotion, reference cycles, unknown/ambiguous type names internal/inference/schema_test.go
Generic type instantiation A single-type-parameter generic (Response[T]) instantiated with a named struct resolves to its own component with substituted fields; two distinct instantiations don't collide on one component internal/inference/schema_test.go, internal/inference/body_test.go
Request/response body inference Decode/Unmarshal, Encode/Marshal (single call and "last wins"), slices, maps, basic types, branch-aware status codes, http.Error, non-constant WriteHeader args, x-gota-skip at operation and single-statement granularity, following a chain of same- or cross-package helpers up to four calls deep (both directions, cycle detection, nil-argument and variadic/arity-mismatch non-follow cases), map-literal envelopes (constant keys → inline object, dynamic key → bare object) internal/inference/body_test.go
Path parameter & operation ID inference Turning a {id}-style path template into an OpenAPI parameters entry, handler-name humanization internal/inference/inference_test.go
Document assembly & validation Path/method assembly, duplicate-route errors, unrepresentable-method errors, YAML/JSON marshaling, structural validation catching bad gota: input internal/emitter/emitter_test.go
CLI Flag defaults, --dir resolution to an absolute path, title override, format detection from --out's extension cmd/gota/main_test.go
End-to-end pipeline Full nethttp-basic fixture through generate → emit → marshal, round-tripped; chi-basic fixture (a separate Go module, see below) proving the same pipeline on Chi's nested Route/Mount; nethttp-generics fixture proving two generic instantiations resolve distinctly; nethttp-operationid-collision fixture proving a method-less pattern's 8 expanded operations get distinct operationIds instead of failing validation internal/generate/generate_test.go

Router plugins — everything specific to reading routes out of a given router's API:

Feature net/http Chi
Route extraction (method + path, incl. path params)
Method-less pattern → expands to every HTTP method ✅ (Handle/HandleFunc, no space in the pattern)
Handler resolution: bare identifier
Handler resolution: method value (bound method)
Handler resolution: cross-package reference
Handler resolution: anonymous switch/if-else on r.Method n/a (chi has Method/MethodFunc instead)
Nested path-prefix routing (Route/Group) n/a ✅, arbitrary depth
Mount-ed sub-router n/a same-package, zero-arg constructor function only

Tests: internal/router/nethttp/nethttp_test.go, internal/router/chi/chi_test.go (fixture at internal/router/chi/testdata/routes, its own Go module — chi is a test-only dependency, isolated so it never bumps gota's own go.mod floor; same reasoning for testdata/chi-basic, used by TestRun_ChiBasic). Cross-package resolution itself is plugin-agnostic (internal/astutil, exercised end-to-end in internal/generate/generate_test.go) — any future plugin gets it for free by populating Route.HandlerObj the same way.

Optional local git hooks (.githooks/) mirror the CI checks so failures show up before you even push: pre-commit runs gofmt only (fast, every commit), pre-push runs the same gofmt/vet/build/test steps CI does (slower, every push). They're not enabled by default — turn them on with:

git config core.hooksPath .githooks

Contributing

Contributions are welcome — see CONTRIBUTING.md for dev setup and a list of concrete, well-scoped starting points (a Gin or Echo router plugin + dialect, cross-package Mount resolution). Participation is governed by the Code of Conduct.

License

MIT — see LICENSE.

About

OpenAPI 3.1 generator for Go net/http APIs — real static inference via go/ast and go/types, not annotation parsing

Topics

Resources

Code of conduct

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages