Go library where your types define the OpenAPI 3.1 spec - at compile time, with zero drift.
No annotations. No code generation. No mandatory tooling. Framework-agnostic.
Note
Every other Go OpenAPI solution forces you to maintain two sources of truth - your Go types and your spec. Rename a field, forget to update the YAML, and your API contract silently lies to every client. This library makes that impossible.
goapi-gen is an optional CLI for teams that want a static spec snapshot in CI -
but you never need it to get a fully accurate, live OpenAPI spec.
Three independent packages, zero mandatory dependencies beyond the stdlib:
| Package | Import path | What it does |
|---|---|---|
openapi |
github.com/nopereta/go-api-docs/openapi |
Typed router that auto-generates an OpenAPI 3.1 spec |
ui/scalar |
github.com/nopereta/go-api-docs/ui/scalar |
Scalar UI handler (vendored JS or CDN) |
ui/swagger |
github.com/nopereta/go-api-docs/ui/swagger |
Swagger UI handler (vendored bundle or CDN) |
Every other Go OpenAPI solution has a fundamental problem:
| Approach | The catch |
|---|---|
| swaggo/swag | Comments diverge from code silently. swag init must be re-run. Zero compile-time guarantees. |
| oapi-codegen | You write YAML first. The generated code diverges between regenerations. |
| huma v2 | "Zero deps" but pulls in a framework adapter package. Context-based handlers feel alien. |
| Manual JSON | 100% accurate on day 1, 0% accurate on day 90. Every rename is a lie. |
| go-api-docs | Your Go types are the spec. Generics enforce handler signatures at compile time. SchemaProvider for zero-reflection schemas. Validator for compile-time-safe request validation. goapi-gen for an optional static spec snapshot in CI. No separate YAML. Ever. |
The router introspects your types once at startup via reflect and serves
GET /openapi.json automatically. Nothing to run, nothing to generate.
import "github.com/nopereta/go-api-docs/openapi"
r := openapi.New(openapi.Info{Title: "Tasks API", Version: "1.0.0"})
openapi.POST[CreateTaskInput, Task](r, "/tasks", createTask)
http.Handle("/", r) // GET /openapi.json is served automaticallygoapi-gen walks your Go source files using go/ast + go/types and
produces openapi.json without executing a single line of your code.
It reads the same struct tags, the same generic type parameters, the same
route-option calls - statically, at "compile time".
# install once
go install github.com/nopereta/go-api-docs/cmd/goapi-gen@latest
# generate (run manually or via go generate)
goapi-gen -out openapi.json ./...Add to any .go file and go generate ./... does the rest:
//go:generate goapi-gen -out openapi.json .goapi-gen recognises:
openapi.New(Info{…}, WithServer(…), WithTag(…), WithSecurityScheme(…))GET,GETWithInput,POST,PUT,PATCH,DELETE,Handle- All route options:
Summary,Description,Tags,OperationID,Security,Deprecated,Responses - All struct tags:
path,query,header,json,doc,example,enum,readOnly,writeOnly,format,pattern,min/maxLength,min/max,min/maxItems time.Time→string/date-time, pointer types → nullable, slices → arrays,$refdedup
Tip
Both options produce identical output. Use the reflection approach during
development (zero friction) and optionally add go generate to your CI
pipeline to snapshot the spec as a file.
import "github.com/nopereta/go-api-docs/openapi"
r := openapi.New(
openapi.Info{Title: "Tasks API", Version: "1.0.0"},
openapi.WithServer("https://api.acme.com", "Production"),
openapi.WithSecurityScheme("BearerAuth", openapi.BearerAuth),
openapi.WithTag("tasks", "Task management operations"),
)
// Each struct field drives both HTTP decoding AND the OpenAPI spec.
// Change the type → the spec changes. Delete a field → the spec reflects it.
// There is no second source of truth.
type CreateTaskInput struct {
Title string `json:"title" doc:"Task title" minLength:"1" maxLength:"200"`
Status string `json:"status,omitempty" enum:"open,done"`
Priority *string `json:"priority,omitempty" enum:"low,medium,high"`
}
type Task struct {
ID string `json:"id" readOnly:"true" example:"task_42"`
Title string `json:"title" example:"Write more tests"`
Status string `json:"status" enum:"open,done"`
}
type TaskIDParam struct {
ID string `path:"id" doc:"Task ID" example:"task_42"`
}
type ListInput struct {
Status string `query:"status" enum:"open,done"`
Limit int `query:"limit" min:"1" max:"100"`
}
openapi.GETWithInput[ListInput, []Task](r, "/tasks", listTasks,
openapi.Summary("List tasks"),
openapi.Tags("tasks"),
openapi.Security("BearerAuth"),
openapi.Responses(map[string]openapi.Response{
"401": {Description: "Unauthorized"},
}),
)
openapi.POST[CreateTaskInput, Task](r, "/tasks", createTask,
openapi.Summary("Create a task"),
openapi.Tags("tasks"),
openapi.Security("BearerAuth"),
)
openapi.GETWithInput[TaskIDParam, Task](r, "/tasks/{id}", getTask,
openapi.Summary("Get a task"),
openapi.Tags("tasks"),
openapi.Security("BearerAuth"),
openapi.Responses(map[string]openapi.Response{
"404": {Description: "Task not found"},
}),
)
// GET /openapi.json served automatically. Mount r anywhere.
http.Handle("/", r)import "github.com/nopereta/go-api-docs/ui/scalar"
h, _ := scalar.New(
scalar.WithSpecURL("/openapi.json"),
scalar.WithTheme(scalar.ThemeDefault),
scalar.With(scalar.DarkMode, scalar.DisableAgent),
scalar.WithBranding(scalar.Branding{Title: "Acme Corp", Subtitle: "Platform API"}),
)
http.Handle("/", h)import "github.com/nopereta/go-api-docs/ui/swagger"
h, _ := swagger.New(
swagger.WithSpecURL("/openapi.json"),
swagger.WithDarkMode(),
swagger.WithPersistAuthorization(),
swagger.WithTryItOutEnabled(),
)
http.Handle("/", h)| Tag | Location | Notes |
|---|---|---|
path:"name" |
URL segment | Always required in spec |
query:"name" |
Query string | Optional; add required:"true" to mark required |
header:"name" |
HTTP header | Optional; add required:"true" to mark required |
| (no tag) | JSON body | Decoded for POST/PUT/PATCH |
doc:"…" |
any | Description in spec |
example:"…" |
any | Example value |
enum:"a,b,c" |
string | Restricted values - validated in spec |
readOnly:"true" |
any | Read-only in response schema |
writeOnly:"true" |
any | Write-only in request schema |
required:"false" |
non-pointer body field | Mark body field as optional |
required:"true" |
query/header | Mark param as required in spec |
format:"uuid" |
string | Override schema format |
pattern:"^[a-z]+$" |
string | Regexp constraint |
minLength:"1" |
string | Minimum length |
maxLength:"255" |
string | Maximum length |
min:"0" |
number | Minimum value |
max:"100" |
number | Maximum value |
minItems:"1" |
array | Minimum item count |
maxItems:"50" |
array | Maximum item count |
Mix path params, query params and body fields in the same struct - they're dispatched automatically:
type UpdateInput struct {
ID string `path:"id" doc:"Task ID"`
Title *string `json:"title,omitempty" maxLength:"200"`
Status *string `json:"status,omitempty" enum:"open,in_progress,done"`
}
openapi.PATCH[UpdateInput, Task](r, "/tasks/{id}", updateTask)For cross-field or business-rule validation, implement Validate() on the input type - it is called
automatically after decode (see Compile-time escape hatches).
openapi.GET[Out](r, path, fn, opts...) // no params
openapi.GETWithInput[In, Out](r, path, fn, opts...) // with path/query/header params
openapi.POST[In, Out](r, path, fn, opts...)
openapi.PUT[In, Out](r, path, fn, opts...)
openapi.PATCH[In, Out](r, path, fn, opts...)
openapi.DELETE[In](r, path, fn, opts...) // returns 204
openapi.Handle[In, Out](r, method, path, fn, opts...) // escape hatchopenapi.Summary("Get a task")
openapi.Description("Returns a single task by its ID.")
openapi.Tags("tasks")
openapi.OperationID("getTask")
openapi.Security("BearerAuth")
openapi.Deprecated()
// Document additional response codes beyond the auto-generated success response:
openapi.Responses(map[string]openapi.Response{
"404": {Description: "Task not found"},
"422": {Description: "Validation error"},
})openapi.New(info,
openapi.WithServer("https://api.acme.com", "Production"),
openapi.WithSecurityScheme("BearerAuth", openapi.BearerAuth), // JWT Bearer
openapi.WithSecurityScheme("ApiKey", openapi.APIKeyHeader("X-API-Key")),
openapi.WithSecurityScheme("Basic", openapi.BasicAuth),
openapi.WithTag("tasks", "Task management operations"),
openapi.WithTag("billing", "Subscription and payment endpoints"),
openapi.WithPathValueFn(chi.URLParam), // only needed for httprouter-style routers
)By default the schema is derived automatically via reflection. If you want
full compile-time control over a type's schema - or want zero reflection for
that type - implement SchemaProvider:
func (Task) OpenAPISchema() openapi.Schema {
return openapi.Schema{
Type: "object",
Properties: map[string]openapi.Schema{
"id": {Type: "string", ReadOnly: true, Example: "task_42"},
"title": {Type: "string"},
"status": {Type: "string", Enum: []any{"open", "done"}},
},
Required: []string{"id", "title", "status"},
}
}The reflection path is bypassed entirely for opted-in types. The schema is guaranteed by the
compiler - rename a field and forget to update OpenAPISchema, the method still compiles,
but your IDE/linter will track the change. This is the closest Go gets to a true compile-time spec.
Implement Validate() error on any input struct and it will be called automatically after all
path/query/header params and the JSON body have been decoded. A non-nil error returns 400 Bad Request.
func (in *CreateTaskInput) Validate() error {
if in.Title == "" {
return errors.New("title is required")
}
if len(in.Title) > 200 {
return errors.New("title exceeds 200 characters")
}
return nil
}This is a compile-time contract: adding Validate() to a type automatically enables it -
no registration, no wiring, no annotations needed.
func getTask(_ *http.Request, in *TaskIDParam) (*Task, error) {
task, ok := db.Find(in.ID)
if !ok {
return nil, openapi.ErrNotFound("task not found") // → 404
}
return task, nil
}Helpers: ErrNotFound (404), ErrBadRequest (400), ErrUnauthorized (401),
Err(status, code, message) for anything else.
openapi.BearerAuth // HTTP Bearer JWT
openapi.BasicAuth // HTTP Basic
openapi.APIKeyHeader("X-API-Key") // API key in header
openapi.APIKeyQuery("api_key") // API key in query stringThe handler serves the HTML page and the vendored @scalar/api-reference JS bundle from memory.
Point at a CDN instead with WithJSPath.
h, err := scalar.New(
scalar.WithSources(
scalar.Source{URL: "/openapi.json", Title: "v2", Default: true},
scalar.Source{URL: "/openapi.v1.json", Title: "v1"},
),
scalar.WithTheme(scalar.ThemePurple),
scalar.WithBranding(scalar.Branding{
LogoURL: "/logo.svg",
Title: "Acme Corp",
Subtitle: "Platform API",
FaviconURL: "/favicon.svg",
}),
scalar.WithEnvBadge("staging"),
scalar.WithBaseServerURL("https://staging.api.acme.com"),
scalar.With(scalar.DisableAgent, scalar.DisableMCP, scalar.DarkMode),
scalar.WithCustomTheme(
scalar.NewCustomTheme().
Accent("#2563eb").
Background("#0f172a").
Font("'Inter', sans-serif"),
),
)
// Serves:
// GET / → HTML page (Cache-Control: no-cache)
// GET /scalar.js → vendored JS bundle (Cache-Control: immutable)
http.Handle("/", h)Available themes: ThemeDefault, ThemeAlternate, ThemeMoon, ThemePurple,
ThemeSolarized, ThemeBluePlanet, ThemeDeepSpace, ThemeSaturn, ThemeKepler,
ThemeMars, ThemeNone.
Update the vendored bundle:
make vendor-js # uses pinned version (1.52.6)
make vendor-js VERSION=1.53.0 # pin a specific versionh, err := swagger.New(
swagger.WithSpecURL("/openapi.json"),
swagger.WithDarkMode(),
swagger.WithBranding(swagger.Branding{Title: "Acme"}),
swagger.WithEnvBadge("dev"),
swagger.WithPersistAuthorization(),
swagger.WithDisplayRequestDuration(),
swagger.WithTryItOutEnabled(),
swagger.WithFilter(""),
swagger.WithDocExpansion(swagger.DocExpansionList),
)
// Serves:
// GET / → HTML page (Cache-Control: no-cache)
// GET /swagger-ui-bundle.js → vendored JS (Cache-Control: immutable)
// GET /swagger-ui-standalone-preset.js → vendored JS (Cache-Control: immutable)
// GET /swagger-ui.css → vendored CSS (Cache-Control: immutable)
http.Handle("/", h)Update the vendored bundles:
make vendor-swagger-ui # uses pinned version (5.18.2)
make vendor-swagger-ui VERSION=5.19.0openapi.Router implements http.Handler and mounts into any framework that
can wrap a standard handler - no code changes needed.
// chi
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Mount("/", api) // chi middleware wraps the whole thing
// gorilla/mux
r := mux.NewRouter()
r.PathPrefix("/").Handler(api)
// gin
g := gin.Default()
g.Any("/*path", gin.WrapH(api)) // gin.WrapH converts http.Handler
// echo
e := echo.New()
e.Any("/*", echo.WrapHandler(api)) // echo.WrapHandler does the same
// fiber (requires github.com/gofiber/adaptor/v2)
app := fiber.New()
app.All("/*", adaptor.HTTPHandler(api))
// httprouter - needs WithPathValueFn because its handler signature differs
type paramsKey struct{}
api := openapi.New(info, openapi.WithPathValueFn(func (r *http.Request, name string) string {
ps, _ := r.Context().Value(paramsKey{}).(httprouter.Params)
return ps.ByName(name)
}))
router := httprouter.New()
adapt := func (h http.Handler) httprouter.Handle {
return func (w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), paramsKey{}, ps)))
}
}
router.GET("/tasks", adapt(api))
router.POST("/tasks", adapt(api))
router.GET("/tasks/:id", adapt(api))
router.NotFound = api // /openapi.json + anything elseBecause openapi.Router uses its own stdlib mux internally, path params are always set via
r.PathValue() - no WithPathValueFn needed for the mount-at-root pattern. Each framework's
middleware (auth, CORS, rate-limit, …) wraps the whole thing transparently.
See COMPARISON.md for full details.
| Path | Stack | What it shows |
|---|---|---|
examples/ui/scalar/basic |
scalar | Petstore proxied via localhost |
examples/ui/scalar/cdn |
scalar | Load Scalar from jsDelivr CDN |
examples/ui/scalar/full |
scalar | Pink theme, branding, env badge, live Tasks API |
examples/ui/scalar/multi-spec |
scalar | v1 + v2 dropdown, Swagger 2.0 + OpenAPI 3.1 |
examples/ui/scalar/openpotato |
scalar | Two live public APIs, reverse-proxy |
examples/ui/scalar/swagger2 |
scalar | Scalar rendering a Swagger 2.0 spec |
examples/ui/swagger/basic |
swagger | Petstore via Swagger UI |
examples/ui/swagger/full |
swagger | Branding, dark mode, env badge, live Tasks API |
examples/ui/swagger/openapi |
openapi + swagger | Spec auto-generated from Go types, Tasks API |
examples/openapi/stdhttp |
openapi only | Plain net/http - zero framework, also shows //go:generate goapi-gen |
examples/openapi/chi |
openapi + chi | Typed router mounted inside chi |
examples/openapi/gorilla |
openapi + gorilla/mux | Typed router mounted inside gorilla/mux |
examples/openapi/gin |
openapi + gin | Typed router wrapped with gin.WrapH |
examples/openapi/echo |
openapi + echo | Typed router wrapped with echo.WrapHandler |
examples/openapi/fiber |
openapi + fiber | Typed router wrapped with adaptor.HTTPHandler |
examples/openapi/httprouter |
openapi + httprouter | WithPathValueFn + context adapter |
Each example is a self-contained Go module:
cd examples/openapi/stdhttp && go run . && open http://localhost:9097
cd examples/openapi/chi && go run . && open http://localhost:9091
cd examples/openapi/gorilla && go run . && open http://localhost:9092
cd examples/openapi/gin && go run . && open http://localhost:9093
cd examples/openapi/echo && go run . && open http://localhost:9094
cd examples/openapi/fiber && go run . && open http://localhost:9095
cd examples/openapi/httprouter && go run . && open http://localhost:9096
cd examples/ui/swagger/openapi && go run . && open http://localhost:9083