APISpec: Generate OpenAPI from Go code
July 14, 2026 · View on GitHub
APISpec analyzes your Go source and generates an OpenAPI 3.1 spec (YAML or JSON). It detects routes for popular frameworks (Gin, Echo, Chi, Fiber, Gorilla Mux, net/http), follows the call graph to the real handlers, and infers request/response types from actual code — struct tags, literals, generics, and more.
TL;DR: Point APISpec at your module. Get an OpenAPI spec — plus, optionally, an interactive call-graph diagram and a browser-based config UI.
Table of Contents
- Demo
- Why APISpec
- Quick Start
- The Tools
- Framework Support
- Go Language Support
- How It Works
- Configuration
- Programmatic Usage
- Performance & Limits
- Development
- Documentation
- License
Demo

▶︎ Full 2-minute walkthrough on YouTube — Install → Generate → Explore → Configure → Insight.
Earlier end-to-end demo (e-commerce app): youtu.be/lkKO-a0-ZTU
Why APISpec
- Generated from real code. Routes, parameters, request bodies, and responses are inferred by analyzing the AST and walking the call graph — not from comments or hand-written annotations that drift out of sync.
- Framework-aware. Out-of-the-box detection for Gin, Echo, Chi, Fiber, Gorilla Mux, and
net/http. - Auth-aware. Detects which routes are protected and by what scheme — framework-agnostic, driven by the same config-pattern system. Recognises common JWT/auth libraries with zero config, follows middleware through groups, per-route chains, and handler wrappers, and warns (with a UI picker in
apispecui) when a custom middleware needs a scheme mapping. - Extensible. Framework behavior is described as regex-based patterns in YAML, so adding or tweaking a framework doesn't require touching core logic.
- Type-aware. Resolves aliases and enums to their underlying primitives, maps validator tags (
go-playground/validator) to OpenAPI constraints, and handles generics, arrays ([16]byte,[...]int), pointer dereferencing, and external package types. - Visualizable. Optional HTML call-graph diagram and a separate paginated diagram server for large codebases.
Quick Start
Install
go install github.com/ehabterra/apispec/cmd/apispec@latest
# Make sure your Go bin is on PATH:
export PATH=$HOME/go/bin:$PATH
Other install methods (Homebrew-style scripts, building from source, packaging the binary) are documented in docs/INSTALLATION.md.
Generate an OpenAPI spec
Run from inside your Go module:
# YAML output (framework auto-detected)
apispec --output openapi.yaml
# JSON output
apispec --output openapi.json
# With a custom config and a call-graph diagram
apispec --config apispec.yaml --output openapi.yaml --diagram diagram.html
That's it for most projects. See Configuration for tuning and The Tools for the companion utilities.
The Tools
APISpec ships three binaries that share the same analysis engine.
| Binary | Purpose | Entry point |
|---|---|---|
apispec | Generate an OpenAPI 3.1 spec from a Go module | cmd/apispec |
apispecui | Browser UI: configure APISpec, preview the spec, and explore the call graph at /diagram | cmd/apispecui |
apidiag | Standalone interactive call-graph server (same engine, headless) | cmd/apidiag |
apispec — CLI generator
The main generator. Auto-detects the framework, loads a default config (overridable with --config), and writes an OpenAPI spec.
# Basic
apispec --output openapi.yaml
# Generate metadata for debugging
apispec --output openapi.yaml --write-metadata
# Limit tuning for very large projects
apispec --output openapi.yaml \
--max-nodes 100000 --max-children 1000 --max-recursion-depth 15
# Performance profiling
apispec --output openapi.yaml --cpu-profile --mem-profile
# Skip CGO packages (on by default)
apispec --output openapi.yaml --skip-cgo
Flag reference
| Flag | Shorthand | Description | Default |
|---|---|---|---|
--output | -o | Output path for the OpenAPI spec | openapi.json |
--dir | -d | Directory to parse | . |
--title | -t | API title | Generated API |
--api-version | -v | API version | 1.0.0 |
--description | -D | API description | "" |
--terms | -T | Terms of service URL | "" |
--contact-name | -N | Contact name | Ehab |
--contact-url | -U | Contact URL | https://ehabterra.github.io/ |
--contact-email | -E | Contact email | ehabterra@hotmail.com |
--license-name | -L | License name | "" |
--license-url | -lu | License URL | "" |
--openapi-version | -O | OpenAPI spec version | 3.1.1 |
--config | -c | Path to custom config YAML | "" |
--output-config | -oc | Write the effective config to a YAML file | "" |
--write-metadata | -w | Write metadata.yaml to disk | false |
--split-metadata | -s | Write metadata as multiple files | false |
--diagram | -g | Write call-graph HTML to this path | "" |
--paginated-diagram | -pd | Use paginated rendering for the diagram | false |
--diagram-page-size | -dps | Nodes per page in paginated diagram (50–500) | 100 |
--max-nodes | -mn | Max nodes in the call graph | 50000 |
--max-children | -mc | Max children per node | 500 |
--max-args | -ma | Max arguments per function | 100 |
--max-nested-args | -md | Max depth for nested arguments | 100 |
--max-recursion-depth | -mrd | Max recursion depth (anti-loop) | 10 |
--legacy-tracker | Use the legacy (eager) tracker tree instead of the default lazy tracker | false | |
--skip-cgo | Skip CGO packages | true | |
--include-file | Include files matching pattern (repeatable) | "" | |
--include-package | Include packages matching pattern (repeatable) | "" | |
--include-function | Include functions matching pattern (repeatable) | "" | |
--include-type | Include types matching pattern (repeatable) | "" | |
--exclude-file | Exclude files matching pattern (repeatable) | "" | |
--exclude-package | Exclude packages matching pattern (repeatable) | "" | |
--exclude-function | Exclude functions matching pattern (repeatable) | "" | |
--exclude-type | Exclude types matching pattern (repeatable) | "" | |
--analyze-framework-dependencies | -afd | Walk into framework packages during analysis | true |
--auto-include-framework-packages | -aifp | Auto-include known framework packages | true |
--auto-exclude-tests | -aet | Skip *_test.go files | true |
--auto-exclude-mocks | -aem | Skip mock files | true |
--cpu-profile | Enable CPU profiling | false | |
--mem-profile | Enable memory profiling | false | |
--block-profile | Enable block profiling | false | |
--mutex-profile | Enable mutex profiling | false | |
--trace-profile | Enable trace profiling | false | |
--custom-metrics | Enable custom metrics collection | false | |
--profile-dir | Directory for profiling output | profiles | |
--version | -V | Print version and exit | false |
CLI flags always override values from a config file.
See also: cmd/apispec/README.md.
apispecui — Browser-based config & preview
apispecui is a small local web server that lets you configure APISpec interactively, generate a spec on demand, immediately preview it through embedded Swagger UI, Redoc, or Scalar viewers, and explore the project's call graph at /diagram — the same interactive, paginated visualization that apidiag provides, hosted on the same port and project.
# Build and run
go build -o apispecui ./cmd/apispecui
./apispecui --dir ./my-go-project
# Open http://localhost:8088 — config UI
# Open http://localhost:8088/diagram — call-graph visualization
Endpoints exposed:
| Path | Purpose |
|---|---|
/ | Configuration UI |
/swagger | Swagger UI preview |
/redoc | Redoc preview |
/scalar | Scalar preview |
/diagram | Interactive call-graph / tracker-tree visualization |
/api/spec.json | Last-generated spec (JSON) |
/api/spec.yaml | Last-generated spec (YAML) |
/api/config.yaml | Current effective config |
/api/generate (POST) | Trigger spec generation with the current config |
/api/diagram/* | Paginated diagram API (same surface as apidiag) |
The diagram lazily loads metadata on the first request and re-loads when the project directory is switched via the UI, so a single apispecui process covers both spec preview and graph debugging. The standalone apidiag binary is still shipped for headless use.
Flags: --host (default localhost), --port (default 8088), --dir/-d (project root, default .), --config/-c (initial config), --verbose.
apidiag — Interactive call-graph server (standalone)
The same diagram server, packaged as its own binary. Use it when you want a dedicated graph explorer without the config UI, or to run it on its own host/port. Internally both binaries share internal/diagserver.
go install github.com/ehabterra/apispec/cmd/apidiag@latest
apidiag --dir ./my-go-project --port 8080
# Open http://localhost:8080
Features include package/function/file filtering, multiple export formats (SVG, PNG, PDF, JSON), and a JSON HTTP API for programmatic access.
See cmd/apidiag/README.md for full documentation and a demo video.
Framework Support
| Framework | Routes & methods | Path params | Groups / mounting | Request body | Responses | Auth |
|---|---|---|---|---|---|---|
| Gin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Echo | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Chi | ✅ | ✅ | ✅ (incl. render) | ✅ | ✅ | ✅ |
| Fiber | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Gorilla Mux | ✅ | ✅ (mux.Vars(r)["id"], incl. helper-wrapped & {id:regex} → pattern) | ✅ (PathPrefix, Subrouter) | ✅ | ✅ | ✅ |
net/http | ✅ (HandleFunc, Handle; Go 1.22 method-aware patterns) | ✅ ({id} wildcards + r.PathValue) | basic | ✅ | ✅ | ✅ |
Conditional registration (dynamic routes built at runtime) is generally not supported.
Mixed / multi-framework projects
One binary often serves more than one routing surface — a framework API next
to plain net/http ops endpoints (expvar/pprof-style), a gin API beside a
gorilla/mux admin router, or a half-migrated codebase. APISpec handles this
automatically:
- All recognised frameworks are detected (import scan), not just the first one. The first-seen framework is the primary — its defaults and info apply.
- Every additional framework's patterns are merged in, restricted to its receiver-scoped patterns, so each framework's registrations are documented.
- The stdlib
net/httpsurface is always layered underneath — it never appears ingo.modand its import is universal, so it can't be "detected" as a second framework; instead a receiver-scoped subset of its config is always merged.
Why receiver-scoped only? A scoped pattern (e.g. Handle on
*mux.Router) can never claim another framework's calls, so merging is
safe by construction — inert unless that framework is actually routing.
Unscoped patterns are not merged from secondaries because precedence can't
make them safe: gin's Handle(method, path, h) and mux's Handle(path, h)
would each misparse the other's calls, and net/http's JSON response
catch-all would misread fiber's status-less c.JSON(obj).
What this does and doesn't mean:
| Scenario | Supported? |
|---|---|
Framework API + plain net/http ServeMux endpoints in one binary | ✅ both documented |
| Two frameworks side by side (e.g. gin API + mux admin router) | ✅ both documented, correct verbs |
Raw *http.Request reads (headers, query, PathValue) inside framework handlers | ✅ documented as parameters |
A framework router mounted under a net/http mux (root.Handle("/api/", http.StripPrefix("/api", chiRouter))) | ⚠️ the framework's routes are found, but the /api mount prefix is not composed (routes appear as /users, not /api/users) — tracked in #138 |
Mounts wired through a secondary framework's own Mount-style calls | ⚠️ not traced yet (those patterns are unscoped in their home configs) — also #138 |
A user-supplied --config | never auto-augmented — what you write is exactly what runs |
Go Language Support
APISpec aims for practical coverage of real-world Go services. A quick survey of what's handled:
Supported
- Import and type aliases (resolved to underlying primitives).
- Enum resolution from constants,
enumtags, oroneofvalidator tags. - Assignment & alias tracking:
:=,=, multi-assign, tuple returns, alias chains, latest-wins shadowing. - Composite literals, maps, slices, fixed-size and variable-length arrays (
[16]byte,[5]int,[...]int). - Pointers and automatic dereferencing.
- Selectors and nested field access (
pkg.Type.Field). - Struct fields, embedded fields, tag-based metadata (
json,xml,form,validate, …). - Inline (anonymous) struct types — used as request/response bodies via local
var req struct{...}declarations and as nested struct fields. Captured structurally fromgo/types, so the inline schema shows real properties, honours JSON tags, and resolves named field types to$refs. - Function & method return types resolved from signatures.
- Function literals (anonymous handlers).
- Generics on functions (concrete types mapped at call sites).
- Generic types (parametric structs) — an envelope instantiated with concrete arguments resolves to its own component with the type argument substituted into the parametric field (
Items []T→ array of$ref User,Data T→$ref User), and distinct instantiations of the same generic (Page[User]vsPage[Product]) get distinct schemas rather than collapsing onto a shared placeholder. Covers written instantiations (Page[User]{…}), multi-parameter generics (Pair[User, Product]), nested generics (Envelope[Page[User]]), compiler-inferred instantiations from a generic constructor (NewEnvelope(product)→Envelope[Product]), and a generic type used as a struct field (Wrapper{ Page Page[User] }) — on both request and response bodies, where the same instantiation keys to a single shared component. Seetestdata/generic_structs/. Not yet: payloads whose type argument only exists behind a helper that erases it tointerface{}/any(respondWithSuccess(w, data any)writingAPIResponse[any]{Data: data}) render as a generic object — the argument is genuinelyinterface{}at the encode site; and aliases / defined types over an instantiation (type UserPage = Page[User]) are not expanded. Cross-package type arguments resolve but the component name drops the argument's package. - Interface types and methods (unresolved dynamic values rendered generically).
- Parameter tracing across the call graph; arguments mapped to parameters.
- Method chaining and nested call expressions.
- Conditional response status codes — when a status variable is reassigned across
if/elsebranches with distinct HTTP codes, APISpec emits one response per status, sharing the body schema. - Wrapper/envelope response specialisation — when a handler's payload flows through a shared helper whose field is declared
interface{}/any(e.g.RespondWithSuccess(w, msg, data, code)→NewEnvelope{Data: data}), APISpec recovers the concrete per-route payload type from the call site and emits anallOfof the base envelope$refplus adataoverride, instead of a genericobject. - Interface-typed response bodies — when a handler encodes an interface-typed variable (
var a Animal = Dog{}; json.NewEncoder(w).Encode(a), orvar a Animal; a = Dog{}), the schema documents the concrete type statically assigned to it (Dog) rather than the empty interface. When the handler assigns more than one concrete type on different branches the result is ambiguous, so the interface is kept (honest over wrong). A concrete value returned through a function whose declared return type is the interface (Encode(makeAnimal())wheremakeAnimal() Animal { return Dog{} }) resolves via the callee's return value. A value passed into a helper through an interface parameter — named (writeAnimal(w, v Animal)) orinterface{}/any— resolves to the concrete argument bound at the call site. Embedded-interface handler dispatch (the DI/clean-architectureHandlers{ AuthorHandler }pattern) also resolves to the concrete implementation. Seetestdata/interface_response/. In every case, when the concrete type is genuinely ambiguous (several concrete types on different branches) the interface is kept rather than guessed. - External package types automatically resolved to underlying primitives (with
externalTypesfor custom overrides). go-playground/validatortags mapped to OpenAPI constraints.- CGO packages can be skipped to avoid build errors.
- Dependency-injected route groups.
- Go 1.22
net/http.ServeMuxmethod-aware routing — patterns that carry the verb on the registration (mux.HandleFunc("GET /users/{id}", getUser)) are split into method + path,{id}wildcards become path parameters, andr.PathValue("id")is recognised as a path parameter. ServeMux-only syntax ({path...}trailing wildcards, the{$}end-of-path anchor) is normalised to OpenAPI templating. Seetestdata/servemux/. - Method dispatch in the handler — a single handler registered without a verb (
http.HandleFunc("/users", h)) that branches onr.Method(switch r.Method { case http.MethodGet: … }or anif r.Method == …chain) is split into one operation per HTTP method, with each branch's request body and responses attributed to its own method (by source position) and unique operationIds.http.MethodXxxconstants, plain"GET"literals, and multi-method cases (case http.MethodGet, http.MethodHead:) all resolve. Seetestdata/method_switch/. Not yet: two branches returning the same status code with different bodies (the shared status slot keeps one), and dispatch inside a receiver-method handler. - Handler factories — a route registered as a call that returns the framework's handler type (
g.POST("/users", h.Create())whereCreate() echo.HandlerFunc { return func(c) {…} }), including when the handler is dispatched through an interface whose implementation lives in a different package. - Function-local named types used as request/response bodies (
type Login struct{…}declared inside a handler) — captured from the function body and emitted as real component schemas rather than dangling$refs. - Request bodies bound through a custom wrapper (
util.ReadRequest(c, &dto)→ctx.Bind(dto)) — the concrete type is traced through the wrapper's parameters. - Authentication / security detection — see Security & authentication detection. Protected routes get a per-operation
securityrequirement and the scheme is registered undercomponents.securitySchemes; explicitly-public routes rendersecurity: []. Middleware is followed across router-wideUse, group/subtree closures, per-route chains (chiWith), and handler wrappers (net/http, mux), including look-through into wrapper bodies that call a known auth library.
Partial / not yet supported
- Same path + same status code with different schemas — not yet supported.
- Receiver/parent type tracing is limited;
Decodeon non-body targets may be misclassified (see Request body source disambiguation). divevalidator tag (array-element validation) — planned.
Selected capability highlights
Type alias and enum resolution
type AllowedUserType string
const (
UserTypeAdmin AllowedUserType = "admin"
UserTypeCustomer AllowedUserType = "user"
)
type Permission struct {
AllowedUserTypes []domain.AllowedUserType // → []string in the schema
}
type UserID *int64
type User struct {
ID UserID // → integer / int64
}
Array support
type User struct {
ID [16]byte // string, format: byte, maxLength: 16
Scores [5]int // array, minItems/maxItems: 5
Tags [10]string // array, minItems/maxItems: 10
}
type Config struct {
Values [...]int // array, no size constraint
}
External type resolution
External package types (e.g. uuid.UUID) are resolved to primitives automatically; internal project types are kept as $ref schemas. Pointers to external types resolve to the same primitive schema. Complex external types can be described explicitly via externalTypes in config.
Validator tag support
| Validator tag | OpenAPI mapping |
|---|---|
required | required: true |
omitempty | required: false |
min=N | minimum: N |
max=N | maximum: N |
len=N | minLength: N, maxLength: N |
email | format: email |
url | format: uri |
uuid | format: uuid |
oneof=a b | enum: [a, b] |
alphanum | pattern: "^[a-zA-Z0-9]+$" |
alpha | pattern: "^[a-zA-Z]+$" |
numeric | pattern: "^[0-9]+$" |
containsany=chars | pattern: ".*[chars].*" |
e164 | pattern: "^\\+[1-9]\\d{1,14}$" |
dive | ❌ not yet supported |
Function literals as handlers
router.POST("/users", func(c *gin.Context) {
var user CreateUserRequest
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(201, user)
})
The body and response types are analyzed even for anonymous handlers.
Go 1.22 net/http.ServeMux method-aware routing
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser) // method + wildcard
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /health", health)
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // → path parameter "id"
// ...
}
The HTTP method is parsed off the registration pattern, so GET /users/{id}
becomes a GET operation on /users/{id} with an id path parameter, and
POST /users becomes a POST with its request body inferred as usual. Calls to
r.PathValue("id") inside the handler are recognised as path parameters.
ServeMux-only syntax is normalised to OpenAPI templating: trailing wildcards
{path...} collapse to {path} and the {$} end-of-path anchor is dropped.
See testdata/servemux/ for a worked example.
Inline anonymous struct request / response bodies
func createOrder(w http.ResponseWriter, r *http.Request) {
var req struct {
Items []itemReq `json:"items"`
}
if err := render.DecodeJSON(r.Body, &req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// ...
}
APISpec captures the local struct{...} type structurally from go/types,
emits an inline object schema on the requestBody, and promotes named
field types (itemReq here) to their own components with $ref. Nested
anonymous structs stay inlined. See testdata/anonymous_struct/ for a
worked example covering primitive fields, named-type fields, nested
inline structs, and inline response bodies.
Wrapper / envelope response specialisation
Many services wrap every response in a shared envelope whose payload field
is interface{} — so the concrete type is only knowable at the call site:
type Envelope struct {
Message string `json:"message"`
Data interface{} `json:"data"`
Code int `json:"code"`
}
func NewEnvelope(message string, data interface{}, code int) *Envelope {
return &Envelope{Message: message, Data: data, Code: code}
}
func RespondWithSuccess(w http.ResponseWriter, message string, data interface{}, code int) {
response := NewEnvelope(message, data, code)
_ = json.NewEncoder(w).Encode(response)
}
func listOrders(w http.ResponseWriter, r *http.Request) {
common.RespondWithSuccess(w, "ok", orders.Order{...}, http.StatusOK)
}
APISpec follows the assignment + constructor + parameter chain
(response → NewEnvelope's return literal → the Data field's bound
parameter → the helper's ParamArgMap) to recover the caller-site payload
type, then composes a per-route schema:
allOf:
- $ref: '#/components/schemas/Envelope' # base wrapper (message, code, …)
- type: object
properties:
data:
$ref: '#/components/schemas/Order' # recovered per-route payload
Only genuinely generic fields (interface{}/any) are overridden;
concrete fields like message/code keep rendering from the base schema.
The recovered payload type is always registered as a component, so the
data $ref never dangles. See testdata/wrapped_response/ for a worked
example (composite-literal payloads and a var-declared DTO with a
[]any field passed by value).
Security / authentication detection
APISpec detects auth middleware and marks the routes it protects, framework-agnostically. Detection has two halves, both config-driven:
- Scope (
framework.securityPatterns) — recognises how middleware is applied and how far it reaches:router(chi/echo/gin/muxUse),subtree(group/route closures),route(chiWith, per-route middleware args), andwrapper(a handler wrapped by an auth function, e.g.net/http/mux). - Identity → scheme (
securityMappings) — resolves the which middleware to one or more OpenAPI security requirements, by function name, package, and/or receiver type.
r := chi.NewRouter()
r.Get("/health", health) // open
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(tokenAuth)) // subtree-wide auth
r.Get("/me", me) // → security: [{ bearerAuth: [] }]
})
r.With(authMiddleware).Get("/admin", admin) // per-route chain → protected
Common JWT/auth libraries are recognised with zero config via an import detector (echo-jwt, appleboy/gin-jwt, gofiber/contrib/jwt, golang-jwt validation calls, and more) — the scheme is registered under components.securitySchemes and attached per operation. Explicitly-public routes (skipper / AllowUnauthenticated style middleware) render security: [].
When a custom middleware can't be mapped to a scheme automatically, apispec warns and lists the unresolved middleware; apispecui surfaces the same list with a picker to assign a scheme interactively. To map one yourself:
securityMappings:
- functionNameRegex: ^authMiddleware$
recvTypeRegex: Handler # optional: method-value middleware
schemes:
- { bearerAuth: [] } # entries here are ANDed
# OR alternatives (any one satisfies):
- functionNameRegex: ^New$
pkgRegex: github\.com/golang-jwt/.*
schemesAnyOf:
- [ { bearerAuth: [] } ]
- [ { apiKeyAuth: [] } ]
# Mark a middleware as making routes explicitly public:
- functionNameRegex: ^AllowPublic$
public: true
# Mark a middleware as known non-auth so it's not reported as unresolved
# (logging, CORS, recovery, request-id, …). Emits no scheme, changes no
# security — just silences the warning. Mutually exclusive with schemes, schemesAnyOf, and public:
- functionNameRegex: ^(Logger|Recoverer|RequestID)$
pkgRegex: github\.com/go-chi/chi/v5/middleware
skip: true
Well-known non-auth middleware from the major frameworks' own middleware packages (chi, echo, gin/gin-contrib, fiber, gorilla/handlers) is skipped automatically by import-gated presets, so the unresolved list stays focused on middleware that's genuinely yours to map. In apispecui, each item in the unresolved list also has a one-click Skip button.
See testdata/auth_* for worked fixtures across chi (With), echo groups, gin per-route, fiber groups, mux subrouters, and net/http wrappers.
How It Works
graph TD
A[Go Source Code] --> B[Package Analysis & Type Checking]
B --> C[Framework Detection]
C --> D[Metadata Generation]
D --> E[Call Graph Construction]
E --> F[Tracker Tree with Limits]
G[Config File<br/>--config] -.-> H[Pattern Extraction]
F --> H
D --> H
H --> I[OpenAPI Spec Generation]
I --> J{{Output Format?}}
J -->|JSON| K[openapi.json]
J -->|YAML| L[openapi.yaml]
E -.-> M[Call Graph Diagram<br/>--diagram]
M -.-> N[diagram.html]
H -.-> O[Effective Config Output<br/>--output-config]
O -.-> P[apispec-config.yaml]
D -.-> Q[Metadata Output<br/>--write-metadata]
Q -.-> R[metadata.yaml]
The pipeline, step by step
APISpec turns Go source into an OpenAPI document through a fixed sequence of stages. Each stage below is described by its role (what it does), its purpose (why it exists in the pipeline), and its importance (what it enables, and what breaks without it). Every stage consumes the output of the previous one, so a weakness early on shows up as a missing route or a dangling $ref at the end.
1. Locate the module and select sources
- Role: Resolve the input directory, walk up to the enclosing
go.modto find the module root and import path, then apply include/exclude package and file filters. - Purpose: Fix the analysis boundary (what to read) and the module import path (used to fully-qualify every type name in the output).
- Importance: The module path is the namespace for every schema
$ref; get it wrong and types resolve to the wrong package or dangle. Filters keep large monorepos analyzable by excluding code that can't contain routes.
2. Load and type-check the packages
- Role: Load every in-scope package with
go/packagesrequesting full syntax and type information, so the Go type checker (go/types) runs over the whole set. - Purpose: Give every expression, field, and call a resolved type — the ground truth the rest of the pipeline reads instead of guessing from names.
- Importance: This is why APISpec understands real Go semantics — generics, type aliases, embedded fields, interface implementations, and cross-package types — rather than pattern-matching strings. Packages that fail to type-check are skipped (and reported) so one broken dependency doesn't abort the run.
3. Detect the framework
- Role: Inspect the module's dependencies to identify the web framework in use (Gin, Echo, Chi, Fiber, Gorilla Mux, or plain
net/http). - Purpose: Choose the default pattern set that describes how that framework registers routes, params, bodies, and responses.
- Importance: Every framework expresses the same concept ("GET /users/{id} → handler") with different API calls. Detection picks the config that already knows those idioms, so the common case needs zero hand-written patterns.
4. Load and merge the configuration
- Role: Layer configuration deterministically: framework default →
--configfile → CLI/programmatic overrides → auto-applied security/auth presets (selected from the project's imports, e.g.golang-jwt). Later layers win. - Purpose: Produce a single effective, framework-agnostic config that drives extraction — route/param/body/response patterns plus OpenAPI
info, type mappings, external types, and security schemes. - Importance: The engine itself is generic; all framework- and project-specific knowledge lives in this config. The layering lets defaults "just work" while allowing surgical overrides without forking the engine.
--output-configwrites this merged result so you can see exactly what ran.
5. Generate metadata
- Role: Walk the type-checked ASTs into one normalized, string-interned model: packages, types (fields, JSON tags, declared type parameters), functions, a call graph of caller→callee edges, per-variable assignments, and the structured arguments of every call.
- Purpose: Collapse scattered AST and
go/typesfacts into a single queryable, deterministic, serializable structure that every later stage reads. - Importance: Nothing downstream touches the raw AST again — metadata is the substrate. String-pooling plus sorted iteration at every boundary make the output deterministic (clean release diffs and reliable golden tests), and
--write-metadatadumps this model so a missed route can be debugged.
6. Build the tracker tree
- Role: Starting from each route-registration call site, expand the call graph down to the actual handler and the calls made inside it — through wrappers, groups, mounts, handler factories, and helper functions — bounded by engine-specific limits (see Performance & Limits). The default lazy tree expands subtrees on demand and is bounded by
--max-nodes/--max-children/--max-argsplus an internal per-scope instance cap; the eager tree (--legacy-tracker) materializes them up front and additionally honors--max-recursion-depthand--max-nested-args. - Purpose: Connect a route to the concrete code that actually serves it, following real control flow rather than assuming the handler lives where the route is declared.
- Importance: In real codebases the handler is rarely at the registration site — it's behind middleware, a group closure, a mounted sub-router, or a factory. This traversal is what makes detection work across those styles. The bounds are the safety brake that turns a pathological (deep or cyclic) call graph into a truncation warning instead of a hang or out-of-memory.
7. Extract patterns
- Role: Match the configured framework patterns against the tracker tree to identify each route's method and path, its path/query parameters, its request body, and its responses — then resolve every one to a concrete Go type (dereferencing pointers, unwrapping aliases/enums, applying external-type mappings, and substituting generic type arguments).
- Purpose: Translate raw calls ("this site registers
GET /users/{id}and encodes aPage[User]") into structured, typed route facts. - Importance: This is where source code becomes API semantics. The fidelity of the final schema is decided here: correct path-parameter names, truthful response status codes, and fully-resolved types (including generic envelopes like
Page[User], nestedEnvelope[Page[User]], and inferred instantiations).
8. Map to OpenAPI
- Role: Assemble the OpenAPI 3.1 object from the route facts and resolved types — paths and operations, request/response content, reusable component schemas (promoting named types to
$refs), and security requirements/schemes — while deduplicating and merging (e.g. dropping mount prefixes subsumed by a longer path, pairing status codes to bodies). - Purpose: Convert typed route facts into a single valid, well-formed specification document.
- Importance: This stage produces the deliverable. Schema promotion and
$refhandling, security wiring, and dedup here are what make the spec valid (no dangling references), clean (no duplicate or placeholder schemas), and non-redundant.
9. Serialize the specification
- Role: Marshal the OpenAPI object to YAML or JSON, chosen by the
--outputfile extension. - Purpose: Emit the file that downstream tools consume — Redoc/Swagger UI, client/server code generators, and contract tests.
- Importance: Serialization is deterministic (stable key ordering), so regenerating an unchanged project yields a byte-identical file — the foundation for meaningful diffs and golden-file CI.
10. Emit side outputs and diagnostics (optional but valuable)
- Role: On request, write the interactive call-graph diagram (
--diagram), the effective merged config (--output-config), and/or the metadata dump (--write-metadata); always surface diagnostics — middleware detected but not mapped to a security scheme, path-parameter key mismatches, and packages skipped due to errors. - Purpose: Make the analysis inspectable and its gaps visible instead of silent.
- Importance: This is the debuggability layer. When a route is missed or a type won't resolve, these artifacts are how you find out why — the difference between "it didn't work" and a fixable, located cause.
Configuration
APISpec uses YAML configuration files to describe framework patterns and OpenAPI metadata. For most projects the bundled defaults are enough; provide --config only when you need to extend or override them.
Minimal example (Gin)
info:
title: My API
version: 1.0.0
description: A comprehensive API for user management
framework:
routePatterns:
- callRegex: ^(?i)(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)$
recvTypeRegex: ^github\.com/gin-gonic/gin\.\*(Engine|RouterGroup)$
handlerArgIndex: 1
methodFromCall: true
pathFromArg: true
handlerFromArg: true
requestBodyPatterns:
- callRegex: ^(?i)(BindJSON|ShouldBindJSON|BindXML|BindYAML|BindForm|ShouldBind)$
typeFromArg: true
deref: true
responsePatterns:
- callRegex: ^(?i)(JSON|String|XML|YAML|ProtoBuf|Data|File|Redirect)$
typeArgIndex: 1
statusFromArg: true
typeFromArg: true
paramPatterns:
- callRegex: ^Param$
paramIn: path
- callRegex: ^Query$
paramIn: query
- callRegex: ^GetHeader$
paramIn: header
Custom type mapping
typeMapping:
- goType: time.Time
openapiType: { type: string, format: date-time }
- goType: uuid.UUID
openapiType: { type: string, format: uuid }
- goType: domain.UserStatus
openapiType:
type: string
enum: [active, inactive, pending]
External package types
External types are usually resolved automatically. Use externalTypes only when you need a custom schema:
externalTypes:
- name: github.com/gin-gonic/gin.H
openapiType:
type: object
additionalProperties: true
- name: github.com/your-org/shared.Response
openapiType:
type: object
properties:
code: { type: integer }
message: { type: string }
data: { type: object, additionalProperties: true }
Request body source disambiguation
Generic decoders like json.Decode, json.Unmarshal, and render.DecodeJSON are used both for request bodies and for unrelated decoding (config files, internal payloads). The requestContext block tells APISpec which receivers represent a request context and which method names yield the body. A decoder call is classified as a request-body decoder only when its source argument can be traced — through selectors, idents, assignments, and parameter boundaries — back to a body accessor on a request-context root.
framework:
requestContext:
typeRegexes:
- ^net/http\.\*Request$
- ^github\.com/gin-gonic/gin\.\*Context$
bodyAccessors:
- ^Body$
- ^GetRawData$
When omitted, APISpec falls back to its prior receiver-only matching, so existing configs keep working unchanged.
Security / authentication
Most auth setups are detected with no config (see Security & authentication detection). When you use a custom middleware, map its identity to a scheme with securityMappings, and — if needed — describe how it's applied with framework.securityPatterns:
framework:
securityPatterns:
- callRegex: ^Use$
recvTypeRegex: chi\.Router
scope: router # router | subtree | route | wrapper
middlewareArgIndex: 0
middlewareVariadic: true
securityMappings:
- functionNameRegex: ^authMiddleware$
schemes:
- { bearerAuth: [] }
securitySchemes: # only needed for schemes not auto-registered
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
Programmatic Usage
import (
"os"
"github.com/ehabterra/apispec/generator"
"github.com/ehabterra/apispec/spec"
"gopkg.in/yaml.v3"
)
func main() {
cfg := spec.DefaultGinConfig() // or spec.LoadAPISpecConfig("apispec.yaml")
gen := generator.NewGenerator(cfg)
openapi, err := gen.GenerateFromDirectory("./your-project")
if err != nil {
panic(err)
}
data, _ := yaml.Marshal(openapi)
_ = os.WriteFile("openapi.yaml", data, 0644)
}
Performance & Limits
Analysis engine: lazy (default) vs eager
The tracker tree — the expansion of each route down to its real handler and the calls inside it — can be built by either of two engines. They share the metadata, extraction, and mapping stages, so their output is equivalent (guarded by a parity test over the fixtures); they differ only in how the tree is built and bounded.
- Lazy (default). Expands subtrees on demand, only along the paths a query actually touches. Cost scales with what's reachable from routes, not with the total size of the codebase — so it tends to win on large projects where much of the code never participates in routing, and is comparable on projects where almost everything is reachable. It degrades gracefully on dense or cyclic graphs (a cumulative budget, then leaf stubs) rather than expanding exponentially.
- Eager (
--legacy-tracker). Materializes the whole tree up front. Retained as a comparison/escape hatch; occasionally marginally faster when nearly all code is reachable anyway, but uses more memory (the full tree is held at once).
Choose with --legacy-tracker on the CLI, or the analysis-engine selector in the browser UI. When in doubt, keep the default (lazy).
Limits
APISpec applies safeguards to prevent runaway analysis. Not every knob applies to both engines — the eager engine bounds recursion with explicit depth caps, while the lazy engine replaces those with a cumulative node budget plus an internal per-scope instance cap:
| Parameter | Default | CLI flag | Applies to |
|---|---|---|---|
| Max nodes / tree | 50,000 | --max-nodes | both — eager: nodes per route tree; lazy: cumulative budget of distinct callees materialized across the whole on-demand expansion (then leaf stubs) |
| Max children / node | 500 | --max-children | both |
| Max args / function | 100 | --max-args | both |
| Max nested arg depth | 100 | --max-nested-args | eager only |
| Max recursion depth | 10 | --max-recursion-depth | eager only |
Instead of the recursion-depth / nested-args caps, the lazy engine uses a fixed per-scope instance cap (≈ per handler): it keeps one copy of a shared helper per route so per-route value tracing stays accurate, but cuts the combinatorial copies a call diamond inside a single handler would otherwise create — the role the eager tree's per-ID recursion cap plays. This cap is internal (not a CLI flag); tune the lazy engine through --max-nodes / --max-children / --max-args.
When a limit is reached, APISpec logs a clear warning, e.g.:
Warning: MaxNodesPerTree limit (50000) reached, truncating tree at node example.com/pkg.Function
Warning: MaxChildrenPerNode limit (500) reached for node example.com/pkg.Function, truncating children
Warning: MaxRecursionDepth limit (10) reached for node example.com/pkg.Function
Profiling
apispec -d ./my-project --cpu-profile --mem-profile --custom-metrics
go tool pprof profiles/cpu.prof
go tool pprof profiles/mem.prof
go tool trace profiles/trace.out
Supported: CPU, memory, block, mutex, trace, and custom metrics (--custom-metrics writes metrics.json).
Development
Prerequisites
- Go 1.26+
- Familiarity with Go AST analysis and OpenAPI 3.1
Project layout
apispec/
├── cmd/
│ ├── apispec/ # CLI generator
│ ├── apispecui/ # Browser UI + spec preview
│ └── apidiag/ # Paginated call-graph server
├── generator/ # High-level generator interface
├── internal/
│ ├── core/ # Framework detection & shared logic
│ ├── diagserver/ # Shared call-graph HTTP server (used by apidiag + apispecui)
│ ├── engine/ # Processing engine
│ ├── metadata/ # AST analysis & metadata extraction
│ └── spec/ # OpenAPI generation & mapping
├── pkg/patterns/ # Public pattern helpers
├── spec/ # Public spec package (configs, types)
├── testdata/ # Example projects used in tests
├── scripts/ # Build & utility scripts
└── docs/ # Long-form documentation
Build & test
make build # build all binaries
make test # run all tests
make coverage # tests with coverage
make update-badge # refresh the coverage badge
go test ./internal/spec -v -run "Test.*Comprehensive"
Adding a framework
- Add detection to
internal/core/detector.go. - Add the default config (route/request/response/param patterns) under
internal/spec/. - Register the framework in
cmd/apispec/main.go. - Add a fixture project under
testdata/and a test case.
Contributing
- Fork the repository.
- Create a feature branch:
git checkout -b feature/amazing-feature. - Add tests covering your change.
- Run
make testand (if coverage moves)make update-badge. - Open a pull request.
See CONTRIBUTING.md and CODE_OF_CONDUCT.md for details.
Documentation
- docs/DEBUGGING.md — my route is missing: how to debug (effective config, metadata dump, call-graph diagram, insight reports)
- docs/INSTALLATION.md — installation methods
- docs/RELEASE_WORKFLOW.md — release process
- docs/TRACKER_TREE_USAGE.md — TrackerTree internals
- docs/CYTOGRAPHE_README.md — call-graph visualization
- docs/INTERFACE_RESOLUTION.md — interface resolution notes
- cmd/apispec/README.md — CLI reference
- cmd/apidiag/README.md — diagram server
- internal/metadata/README.md — metadata package
- internal/spec/README.md — spec-generation package
License
Apache License 2.0 — see LICENSE.
