Your Go struct already describes your API. Let it write the docs too.
If you build HTTP APIs in Go, you've lived this: the same endpoint is described in
three different places, by hand, and all three have to agree.
- The handler reads the body, pulls query params, parses headers — binding.
-
The validator checks the body is well-formed —
required,email, ranges. - The OpenAPI doc tells clients what the endpoint expects and returns.
The code already knows all of this. The Go type for the request says exactly
which fields exist, what types they are, which are required. Yet the docs repeat
it in YAML, the validator repeats it in tags, and nothing keeps them in sync. You
rename a field, ship it, and the docs quietly lie until someone files a bug.
Multiply that by a few dozen routes, each wired slightly differently across
gin, chi, or net/http, and "the docs" become a second codebase you maintain
by discipline alone.
oapi removes two of those three places. You write the request and response
as typed Go structs once. Binding, validation, and the OpenAPI 3 document all read
the same struct tags — so the docs are generated from the exact types your
handler binds. They cannot drift, because there is nothing to keep in sync.
⭐ GitHub: github.com/antlss/oapi —
go get github.com/antlss/oapi
The idea in one signature
Every handler has the same shape: a typed request in, a typed response out.
func(ctx context.Context, req oapi.Request[Header, Param, Query, Body]) (*Response, error)
Request[Header, Param, Query, Body] is the whole contract. Each part binds from
a different source, and you use struct{} for the parts an endpoint doesn't need:
Header -> `header:"..."` Param -> `uri:"..."`
Query -> `form:"..."` Body -> `json:"..."` (or `form:"..."` for multipart/urlencoded)
That's it. The handler receives data already parsed, validated, and typed. No
c.ShouldBindJSON(&x), no manual c.Param("id"), no if err != nil boilerplate
in every function.
What the type buys you
1. The type is the entire data model
Define the request once. The struct tags carry three readers at the same time:
type CreateProductBody struct {
Name string `json:"name" binding:"required,min=2,max=120" example:"Mechanical Keyboard"`
SKU string `json:"sku" binding:"required,uuid" example:"5f9c2e3a-1b4d-4c8e-9f0a-2b3c4d5e6f70"`
Price float64 `json:"price" binding:"required,gt=0" example:"49.90"`
Currency string `json:"currency" binding:"required,oneof=USD EUR JPY VND" example:"USD"`
Category string `json:"category" binding:"required,oneof=book electronics food toy" example:"electronics"`
Website string `json:"website" binding:"omitempty,url" example:"https://example.com"`
Tags []string `json:"tags" binding:"omitempty,max=10" example:"new,featured"`
Warehouse Address `json:"warehouse"`
}
-
json:"name"— how the body decodes (binding). -
binding:"required,min=2,max=120"— what makes it valid, and the schema constraints in the docs (required field,minLength/maxLength). -
example:"..."— the sample value clients see in Swagger UI / Redoc.
Nested structs like Warehouse Address are recursed into — their rules and
examples reach the docs too. Rename Name, change min=2 to min=3, add a
field: the binding, the validation, and the published schema all move together,
because they are the same declaration.
2. The docs generate themselves — and stay honest
A Registry collects your routes and turns them into a validated OpenAPI 3
document (JSON or YAML). Because it reads the captured Go types, not a separate
spec file, the document describes exactly what the handler binds.
Here is the struct above, rendered with zero hand-written OpenAPI:
Notice what carried over automatically: category shows its oneof as an
enum, sku is documented as a uuid, name shows its [2..120] characters
bound, website as a url, tags as an array with <= 10 items, and the
request sample uses your real example values instead of bare "string"
placeholders. None of that was written by hand. It was read off the type.
3. Standardized requests and responses — across five frameworks
The same []Route runs unchanged on net/http, gin, Fiber v2,
chi, and Echo v4. The core is framework-agnostic; each adapter is a thin
seam. Pick a framework, or switch later, without rewriting a single handler.
Successful responses share one envelope by default — {"data": ...}, plus
{"meta": ...} for paginated endpoints — so every endpoint in your API answers
in a predictable shape, and clients can rely on it.
4. You still own the parts that are opinions
Standardization shouldn't mean a straitjacket. The things every project does
differently are pluggable seams, and the library ships no policy of its own:
-
Validation is an interface. The core depends on no validation library. Plug
in
go-playground/validator(a ready reference implementation is included), or your own. -
The response envelope is swappable. Keep the default
{"data": ...}, switch to{"success": true, "data": ...}for the whole API, override it per route, or return the raw model with no wrapper at all. -
Error handling is yours. Return an
HTTPError, map domain errors per route with anErrorMapper, or install one process-wideErrorParserthat renders every error in your project's shape — and that shape gets documented too. Anything unrecognized renders a generic 500 and never leaks internals.
Crucially, every one of these seams drives both the bytes on the wire and
the generated docs. Customize the error shape, and the OpenAPI document describes
your custom error shape. No drift, even when you go off the defaults.
A complete API in a few lines
Define a handler over a typed request, declare the route with whatever
documentation metadata you want, and mount it.
func (h *Handler) createProduct() oapi.Route {
return oapi.NewRichRoute(
http.MethodPost, "/products",
func(_ context.Context, req oapi.Request[struct{}, struct{}, struct{}, CreateProductBody]) (*oapi.Result, error) {
p := h.catalog.CreateProduct(req.Body) // req.Body is already bound + validated
return oapi.NewDataResult(p).
WithStatus(http.StatusCreated).
WithHeader("Location", fmt.Sprintf("/products/%d", p.ID)), nil
},
oapi.WithSummary("Create a product"),
oapi.WithTags("catalog"),
oapi.WithSuccessStatus(http.StatusCreated),
oapi.WithResponseType[Product](),
oapi.WithSecurity("bearerAuth", "products:write"),
oapi.WithResponse[struct{}](http.StatusConflict, "Duplicate SKU"),
)
}
Collect your routes into a Registry, add document-level metadata once, and you
have a self-describing API:
reg := oapi.NewRegistry("Catalog API", "v1").
Describe("A demo API exercising typed binding, validation-driven docs, files, paging, security and the full error model.").
AddServer("http://localhost:8080", "Local server").
AddSecurityScheme("bearerAuth", oapi.BearerAuth()).
AddTag("catalog", "Browse, create and manage products").
Add(h.Routes()...)
Then wire it to a framework and serve the spec — here on gin:
oapi.SetValidator(validation.New()) // turn on validation; the core ships none
engine := gin.New()
ginadapter.RegisterAll(engine, h.Routes()...)
engine.GET("/openapi.json", ginadapter.SpecHandler(reg))
// serve Swagger UI / Redoc from the same spec...
engine.Run(":8080")
Or generate the spec to disk for CI, client codegen, or publishing:
reg.Write(context.Background(), oapi.GenConfig{Dir: "./openapi"}) // openapi.json + openapi.yaml
That's the whole loop: write the type, write the handler, get a validated API
and its documentation. The screenshots above are this exact code.
The point
Documentation drifts because it's a copy. The moment your API description lives in
a separate file maintained by hand, it starts aging the second you ship a change.
oapi makes the Go type the single source of truth. Binding reads it, validation
reads it, the OpenAPI document reads it — one declaration, three jobs, no copies to
keep in sync. You keep full control over the parts that are genuinely your call:
how you validate, how you shape responses, how you handle errors. The library just
makes sure that whatever you decide, the docs say the same thing your code does.
Write the struct. Ship the API. The docs are already correct.
oapi is open source (MIT), pre-1.0, and lives on GitHub:
github.com/antlss/oapi — stars, issues and PRs
are very welcome. Install with go get github.com/antlss/oapi. Five adapters:
net/http, gin, Fiber v2, chi, Echo v4. The runnable Catalog API that produced the
screenshots above lives in examples/ — go run ./examples/cmd/gin
and open http://localhost:8080.


Top comments (0)