Your Go struct already describes your API. Let it write the docs too.
Source: Dev.to
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)
Enter fullscreen mode
Exit fullscreen mode
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)
Enter fullscreen mode
Exit fullscreen mode
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
- 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"`
}
Enter fullscreen mode
Exit fullscreen mode
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.
- 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.
- 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.
- 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 an ErrorMapper, or install one process-wide ErrorParser that 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"),
)
}
Enter fullscreen mode
Exit fullscreen mode
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()...)
Enter fullscreen mode
Exit fullscreen mode
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")
Enter fullscreen mode
Exit fullscreen mode
Or generate the spec to disk for CI, client codegen, or publishing:
reg.Write(context.Background(), oapi.GenConfig{Dir: "./openapi"}) // openapi.json + openapi.yaml
Enter fullscreen mode
Exit fullscreen mode
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.*

