APISpec: Generate OpenAPI from Go code

July 14, 2026 · View on GitHub

Coverage Go Version License GitHub Actions Tests Lint Go Reference GitHub release

APISpec Logo

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

apispecui — generate an OpenAPI 3.1 spec from Go source, no annotations

Watch the full apispecui walkthrough on YouTube

▶︎ 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.

BinaryPurposeEntry point
apispecGenerate an OpenAPI 3.1 spec from a Go modulecmd/apispec
apispecuiBrowser UI: configure APISpec, preview the spec, and explore the call graph at /diagramcmd/apispecui
apidiagStandalone 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

FlagShorthandDescriptionDefault
--output-oOutput path for the OpenAPI specopenapi.json
--dir-dDirectory to parse.
--title-tAPI titleGenerated API
--api-version-vAPI version1.0.0
--description-DAPI description""
--terms-TTerms of service URL""
--contact-name-NContact nameEhab
--contact-url-UContact URLhttps://ehabterra.github.io/
--contact-email-EContact emailehabterra@hotmail.com
--license-name-LLicense name""
--license-url-luLicense URL""
--openapi-version-OOpenAPI spec version3.1.1
--config-cPath to custom config YAML""
--output-config-ocWrite the effective config to a YAML file""
--write-metadata-wWrite metadata.yaml to diskfalse
--split-metadata-sWrite metadata as multiple filesfalse
--diagram-gWrite call-graph HTML to this path""
--paginated-diagram-pdUse paginated rendering for the diagramfalse
--diagram-page-size-dpsNodes per page in paginated diagram (50–500)100
--max-nodes-mnMax nodes in the call graph50000
--max-children-mcMax children per node500
--max-args-maMax arguments per function100
--max-nested-args-mdMax depth for nested arguments100
--max-recursion-depth-mrdMax recursion depth (anti-loop)10
--legacy-trackerUse the legacy (eager) tracker tree instead of the default lazy trackerfalse
--skip-cgoSkip CGO packagestrue
--include-fileInclude files matching pattern (repeatable)""
--include-packageInclude packages matching pattern (repeatable)""
--include-functionInclude functions matching pattern (repeatable)""
--include-typeInclude types matching pattern (repeatable)""
--exclude-fileExclude files matching pattern (repeatable)""
--exclude-packageExclude packages matching pattern (repeatable)""
--exclude-functionExclude functions matching pattern (repeatable)""
--exclude-typeExclude types matching pattern (repeatable)""
--analyze-framework-dependencies-afdWalk into framework packages during analysistrue
--auto-include-framework-packages-aifpAuto-include known framework packagestrue
--auto-exclude-tests-aetSkip *_test.go filestrue
--auto-exclude-mocks-aemSkip mock filestrue
--cpu-profileEnable CPU profilingfalse
--mem-profileEnable memory profilingfalse
--block-profileEnable block profilingfalse
--mutex-profileEnable mutex profilingfalse
--trace-profileEnable trace profilingfalse
--custom-metricsEnable custom metrics collectionfalse
--profile-dirDirectory for profiling outputprofiles
--version-VPrint version and exitfalse

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:

PathPurpose
/Configuration UI
/swaggerSwagger UI preview
/redocRedoc preview
/scalarScalar preview
/diagramInteractive call-graph / tracker-tree visualization
/api/spec.jsonLast-generated spec (JSON)
/api/spec.yamlLast-generated spec (YAML)
/api/config.yamlCurrent 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

FrameworkRoutes & methodsPath paramsGroups / mountingRequest bodyResponsesAuth
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/http surface is always layered underneath — it never appears in go.mod and 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:

ScenarioSupported?
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 --confignever 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, enum tags, or oneof validator 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 from go/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] vs Page[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. See testdata/generic_structs/. Not yet: payloads whose type argument only exists behind a helper that erases it to interface{}/any (respondWithSuccess(w, data any) writing APIResponse[any]{Data: data}) render as a generic object — the argument is genuinely interface{} 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/else branches 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 an allOf of the base envelope $ref plus a data override, instead of a generic object.
  • Interface-typed response bodies — when a handler encodes an interface-typed variable (var a Animal = Dog{}; json.NewEncoder(w).Encode(a), or var 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()) where makeAnimal() 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)) or interface{}/any — resolves to the concrete argument bound at the call site. Embedded-interface handler dispatch (the DI/clean-architecture Handlers{ AuthorHandler } pattern) also resolves to the concrete implementation. See testdata/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 externalTypes for custom overrides).
  • go-playground/validator tags mapped to OpenAPI constraints.
  • CGO packages can be skipped to avoid build errors.
  • Dependency-injected route groups.
  • Go 1.22 net/http.ServeMux method-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, and r.PathValue("id") is recognised as a path parameter. ServeMux-only syntax ({path...} trailing wildcards, the {$} end-of-path anchor) is normalised to OpenAPI templating. See testdata/servemux/.
  • Method dispatch in the handler — a single handler registered without a verb (http.HandleFunc("/users", h)) that branches on r.Method (switch r.Method { case http.MethodGet: … } or an if 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.MethodXxx constants, plain "GET" literals, and multi-method cases (case http.MethodGet, http.MethodHead:) all resolve. See testdata/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()) where Create() 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 security requirement and the scheme is registered under components.securitySchemes; explicitly-public routes render security: []. Middleware is followed across router-wide Use, group/subtree closures, per-route chains (chi With), 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; Decode on non-body targets may be misclassified (see Request body source disambiguation).
  • dive validator 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 tagOpenAPI mapping
requiredrequired: true
omitemptyrequired: false
min=Nminimum: N
max=Nmaximum: N
len=NminLength: N, maxLength: N
emailformat: email
urlformat: uri
uuidformat: uuid
oneof=a benum: [a, b]
alphanumpattern: "^[a-zA-Z0-9]+$"
alphapattern: "^[a-zA-Z]+$"
numericpattern: "^[0-9]+$"
containsany=charspattern: ".*[chars].*"
e164pattern: "^\\+[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 (responseNewEnvelope'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/mux Use), subtree (group/route closures), route (chi With, per-route middleware args), and wrapper (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.mod to 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/packages requesting 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 → --config file → 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-config writes 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/types facts 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-metadata dumps 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-args plus an internal per-scope instance cap; the eager tree (--legacy-tracker) materializes them up front and additionally honors --max-recursion-depth and --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 a Page[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], nested Envelope[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 $ref handling, 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 --output file 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:

ParameterDefaultCLI flagApplies to
Max nodes / tree50,000--max-nodesboth — eager: nodes per route tree; lazy: cumulative budget of distinct callees materialized across the whole on-demand expansion (then leaf stubs)
Max children / node500--max-childrenboth
Max args / function100--max-argsboth
Max nested arg depth100--max-nested-argseager only
Max recursion depth10--max-recursion-deptheager 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

  1. Add detection to internal/core/detector.go.
  2. Add the default config (route/request/response/param patterns) under internal/spec/.
  3. Register the framework in cmd/apispec/main.go.
  4. Add a fixture project under testdata/ and a test case.

Contributing

  1. Fork the repository.
  2. Create a feature branch: git checkout -b feature/amazing-feature.
  3. Add tests covering your change.
  4. Run make test and (if coverage moves) make update-badge.
  5. Open a pull request.

See CONTRIBUTING.md and CODE_OF_CONDUCT.md for details.

Documentation

License

Apache License 2.0 — see LICENSE.