Turn typed Go handlers into HTTP endpoints whose request and response structs are the single source of truth for binding, validation and OpenAPI 3 docs.
The struct tags you already write to bind and validate a request are the same tags the OpenAPI generator reads, so the docs are generated from the exact Go types the handler binds — they can never drift from the running code.
Status: pre-1.0 — the API may still change before a tagged
v1.
OpenAPI docs generated straight from the Go types — Swagger UI (left) and Redoc (right). Run it yourself from examples/.
-
Typed handlers —
func(ctx, Request[Header, Param, Query, Body]) (*Response, error); each part binds from a different source,struct{}for the parts you don't use. -
Typed middleware —
WithTypedBeforesees the same parsed request as the handler (parsed once, shared). -
Five adapters, one route set — the same
[]Routeruns unchanged on net/http, gin, Fiber v2, chi and Echo v4. -
OpenAPI 3 generation — a
Registryturns the routes into a validated spec (JSON/YAML/Write) from the same struct tags used for binding. - Pluggable seams — the validator, response envelope and error parser are swappable interfaces; the core ships none and depends on no validation library.
-
Scoped config — bundle validator/envelope/error-parser/body-cap into an immutable
Appand attach it per route withWithApp, instead of process-wide globals. -
Files — multipart uploads (
[]*multipart.FileHeader) bind like any field; downloads stream viaNewResult(bytes).WithFile(...). -
Envelopes & paging — default
{"data": ...}(+meta), with per-route custom or raw responses. -
Safe errors —
HTTPError, per-routeErrorMapper, process-wideErrorParser; unrecognised errors render a generic 500 and never leak internals.
go get github.com/antlss/oapiThe net/http adapter ships with the core. Each other adapter is its own module, so you pull in only what you import:
go get github.com/antlss/oapi/adapter/gin
go get github.com/antlss/oapi/adapter/fiber
go get github.com/antlss/oapi/adapter/chi
go get github.com/antlss/oapi/adapter/echoValidation is opt-in (the core ships no validator). Copy the go-playground/validator
reference in examples/validation, or implement the small Validator
interface yourself.
Let's build a complete, runnable API with OpenAPI generation in 5 simple steps.
Step 1: Initialize your project
mkdir oapi-quickstart && cd oapi-quickstart
go mod init oapi-quickstart
go get github.com/antlss/oapiStep 2: Define your API and Routes (api/api.go)
Create a new directory api and add api.go. This holds your endpoints and OpenAPI registry.
package api
import (
"context"
"net/http"
"github.com/antlss/oapi"
)
// 1. Define your types with binding/validation tags
type CreateProductBody struct {
Name string `json:"name" binding:"required,min=2,max=120" example:"Mechanical Keyboard"`
Price float64 `json:"price" binding:"required,gt=0" example:"49.90"`
Currency string `json:"currency" binding:"required,oneof=USD EUR JPY" example:"USD"`
}
type Product struct {
ID int `json:"id" example:"1001"`
Name string `json:"name" example:"Mechanical Keyboard"`
Price float64 `json:"price" example:"49.90"`
Currency string `json:"currency" example:"USD"`
}
// 2. Create the Route
// Header/Param/Query are unused, so struct{}. Returning *Product wraps it in the default {"data": ...} envelope.
var CreateProduct = oapi.NewRoute(
http.MethodPost, "/products",
func(_ context.Context, req oapi.Request[struct{}, struct{}, struct{}, CreateProductBody]) (*Product, error) {
return &Product{ID: 1001, Name: req.Body.Name, Price: req.Body.Price, Currency: req.Body.Currency}, nil
},
oapi.WithSummary("Create a product"),
oapi.WithTags("catalog"),
oapi.WithSuccessStatus(http.StatusCreated),
)
// 3. Export the Registry (used for both serving and generating docs)
func BuildRegistry() *oapi.Registry {
return oapi.NewRegistry("Catalog API", "v1").
Describe("A tiny example API.").
AddServer("http://localhost:8080", "Local").
Add(CreateProduct)
}Step 3: Create the Generator CLI (cmd/gen/main.go)
Create cmd/gen/main.go. This tiny script writes your OpenAPI spec to disk.
package main
import (
"oapi-quickstart/api"
gendoc "github.com/antlss/oapi/tools/gen_doc"
)
// gendoc.Main parses flags (-out, -format, etc) and writes the files.
func main() {
gendoc.Main(api.BuildRegistry())
}Step 4: Create the Server (main.go)
Create main.go in the root of your project. Notice the //go:generate directive at the top!
//go:generate go run ./cmd/gen -out ./openapi
package main
import (
"log"
"net/http"
"oapi-quickstart/api"
"github.com/antlss/oapi/adapter/nethttp"
)
func main() {
mux := http.NewServeMux()
// Register the routes
nethttp.RegisterAll(mux, api.CreateProduct)
// Serve the raw spec at /openapi.json
mux.HandleFunc("GET /openapi.json", nethttp.SpecHandler(api.BuildRegistry()))
log.Println("Listening on :8080 (Spec at /openapi.json)")
log.Fatal(http.ListenAndServe(":8080", mux))
}Step 5: Generate & Run!
Now you can generate your OpenAPI docs and run the server:
# 1. Generate OpenAPI specs to disk (creates ./openapi/openapi.json and .yaml)
go generate ./...
# 2. Run the server
go run main.goTest it with curl:
curl -X POST http://localhost:8080/products \
-H "Content-Type: application/json" \
-d '{"name": "Mechanical Keyboard", "price": 49.90, "currency": "USD"}'POST /products binds the body, returns 201 with {"data": {...}}, and
/openapi.json serves a spec whose schema — required fields, the oneof enum, the
bounds — comes from the same struct.
Validation is opt-in: install a validator once at startup —
oapi.SetValidator(validation.New())— or thebindingrules are skipped (with a one-time warning).
/openapi.json is the raw spec. To make it browsable, serve a tiny HTML page that
loads that spec into Swagger UI (interactive "Try it out") or Redoc
(read-only reference) from a CDN — no extra Go dependency, no embedded assets:
const swaggerHTML = `<!DOCTYPE html><html><head><meta charset="utf-8">
<title>Catalog API — Swagger UI</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"></head>
<body><div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>window.onload = () => SwaggerUIBundle({url: "/openapi.json", dom_id: "#swagger-ui"})</script>
</body></html>`
const redocHTML = `<!DOCTYPE html><html><head><meta charset="utf-8">
<title>Catalog API — Redoc</title></head>
<body><redoc spec-url="/openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"></script>
</body></html>`
func htmlPage(html string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}
}Mount them next to the spec in main. The UIs are plain static HTML, so there's
nothing oapi-specific here — serve the strings with whatever your framework uses
for an HTML route:
mux.HandleFunc("GET /openapi.json", nethttp.SpecHandler(reg)) // the spec (from above)
mux.HandleFunc("GET /swagger", htmlPage(swaggerHTML)) // interactive UI
mux.HandleFunc("GET /redoc", htmlPage(redocHTML)) // reference docsOpen http://localhost:8080/swagger or /redoc. Both pages only point at
/openapi.json, so they track the Go types automatically — change a struct, the
docs change with it.
examples/docsuiships these same pages with pinned versions + SRI integrity hashes and a landing page at/. Copy that package as-is for production rather than the floating@5/@2tags shown here.
The core is framework-agnostic. Every adapter exposes the same surface — Register,
RegisterAll, SpecHandler — so switching frameworks is just a different
RegisterAll call over the same routes.
| Framework | Adapter package (under github.com/antlss/oapi) |
Notes |
|---|---|---|
| net/http | adapter/nethttp |
Ships with the core, no extra deps (Go 1.22+ method-aware ServeMux). |
| gin | adapter/gin |
Separate module. |
| Fiber v2 | adapter/fiber |
Separate module. |
| chi | adapter/chi |
Separate module (go-chi/chi v5). |
| Echo v4 | adapter/echo |
Separate module. |
Each adapter caps the request body at DefaultMaxRequestBytes (10 MiB; set 0 to
disable), overridable per route via an App's WithMaxRequestBytes.
A Registry collects routes and document metadata, then renders the spec:
reg := oapi.NewRegistry("Catalog API", "v1").
Describe("...").
Contact("API Team", "https://example.com/support", "api@example.com").
License("Apache-2.0", "https://www.apache.org/licenses/LICENSE-2.0").
AddServer("https://api.example.com", "Production").
AddSecurityScheme("bearerAuth", oapi.BearerAuth()).
AddTag("catalog", "Browse and manage products").
Add(routes...)
data, err := reg.JSON() // or reg.YAML()
err = reg.Validate(ctx) // check against the OpenAPI 3 schemaAlso available: TermsOfService, ExternalDocs, Logo/LogoWith, TagGroup, and
UseComponents() (emit shared types as $ref under components/schemas instead of
inlining). A Base document supplies defaults the generated paths overlay (Base /
LoadBaseFile).
Write the spec to disk. Write validates first (unless NoValidate), then
emits JSON and/or YAML, returning the paths it wrote:
written, err := reg.Write(ctx, oapi.GenConfig{Dir: "openapi"})
// -> ["openapi/openapi.json", "openapi/openapi.yaml"], validated before writingGenerate it as a CLI / go generate step. tools/gen_doc is a turnkey main
that parses flags, validates and writes — so your generator command is one line.
Drop a //go:generate directive next to your routes and the spec is rebuilt with
go generate:
//go:generate go run ./cmd/openapi-gen -out ./openapi
package main
import (
gendoc "github.com/antlss/oapi/tools/gen_doc"
"example.com/app/api"
)
func main() { gendoc.Main(api.Registry()) } // flags: -out -format json,yaml -base FILE -no-validateSee it generate, end to end. The examples/ module ships exactly this wiring —
a real cmd/openapi-gen, a //go:generate directive in api/routes.go, and the
committed output under examples/openapi/. Run it yourself:
cd examples
go run ./cmd/openapi-gen -out ./openapi # validates, then writes openapi/openapi.{json,yaml}
go generate ./... # the same, via the //go:generate directiveBecause the output is committed, regenerating and diffing it in review is how spec drift is caught: change a struct, rerun, and the JSON/YAML change with it — or the diff tells you a doc went stale.
Request[Header, Param, Query, Body] — each part binds from a different source;
struct{} means "this endpoint doesn't use it":
| Part | Source | Tag |
|---|---|---|
Header |
request headers | header:"..." |
Param |
path parameters | uri:"..." |
Query |
query string | form:"..." |
Body |
JSON body | json:"..." |
Body |
urlencoded / multipart |
form:"..." (+ []*multipart.FileHeader for files) |
The binding tag carries validation rules that also become OpenAPI constraints
(required; oneof→enum; min/max/gt→bounds; uuid/email/url→formats).
example tags set the docs samples.
-
NewRoute— handler returns*Response, wrapped by the envelope;nil→204 No Content. -
NewRichRoute— handler returns a fully built*Result(paging, headers, status, file download). AddWithResponseType[T]()orWithBinaryResponse(...)so the docs match what it returns. -
NewBodyRoute/NewQueryRoute/NewParamRoute— shortcuts for single-part endpoints, so you skip thestruct{}placeholders.
- Build a
*ResultwithNewDataResult(enveloped),NewListDataResult(+ paging meta) orNewResult(raw); chain.WithStatus,.WithHeader,.WithMeta,.WithPaging,.WithFile. - The envelope is a
ResponseEnvelopeseam (defaultDataEnvelope→{"data": ...}). Override per route withWithEnvelope(KeyedEnvelope{...})/WithRawResponse(), perAppwithWithResponseEnvelope, or globally withSetResponseEnvelope. One definition drives both the wire body and its documented schema.
-
HTTPError— any error withHTTPStatus() intcontrols its own status (and, viaErrorBody, its JSON). Build one withoapi.NewError(...), or the standard field-level 400 withoapi.NewValidationError(...). -
ErrorMapper(per-route,WithErrorMapper) andErrorParser(global,SetErrorParser) own the full wire body;ErrorParseralso documents it. - Resolution order: per-route mapper →
ErrorParser→HTTPError→ aerror-shaped duck typing → generic 500. Unrecognised errors never leak; they're recorded on the carrier for logging middleware.
Validation is a pluggable seam — the core ships no validator and depends on no validation library, so you choose one (or none) and pull in only what you import.
// Install once at startup, before serving. The binding rules now run on every request.
oapi.SetValidator(validation.New())-
The seam. Any type implementing
Validator(Validate(value any, source string) error) works. Install it process-wide withSetValidator, or scope it to a route group with anApp'sWithValidator.SetValidator(nil)disables it explicitly. -
If you skip it. With no validator installed, the
bindingrules are not enforced — requests bind and pass through, and the library logs a one-time warning. Schema generation is unaffected: the docs still show the constraints either way. -
One tag, two jobs.
RuleTag(default"binding") names the tag the validator reads and the generator turns into OpenAPI constraints, so a rule likebinding:"required,oneof=USD EUR"can never validate one thing and document another. -
Reference implementation.
examples/validationis a ready go-playground/validator adapter (validation.New()): one engine per request part, field errors reported by their wire name (json/header/uri/form), translated into the library's field-level400. Copy it, or implement the one-method seam yourself.
Each runnable example installs it at startup (oapi.SetValidator(validation.New())),
so a request that violates a binding rule comes back as a structured 400 you can
see in Swagger UI.
Instead of process-wide globals, bundle config into an immutable App and attach it
per route — two differently configured groups can then serve in one process:
app := oapi.New(
oapi.WithValidator(validation.New()),
oapi.WithResponseEnvelope(oapi.KeyedEnvelope{DataKey: "data", Constants: map[string]any{"success": true}}),
oapi.WithErrorParser(api.AppErrorParser{}),
oapi.WithMaxRequestBytes(5 << 20),
)
r := oapi.NewRoute(method, path, handler, oapi.WithApp(app))New snapshots the current globals and is immutable after. The App scopes both the
wire bytes and the generated docs, so /v1 and /v2 can each have their own
envelope and error shape with no global state. (RuleTag stays process-wide.)
examples/ is a runnable "Catalog API" exercising every capability — all request
parts, JSON/urlencoded/multipart bodies, file upload/download, paging, security,
typed middleware, the full error model and custom envelopes. The same routes mount
on net/http, gin and Fiber under examples/cmd/{nethttp,gin,fiber}.
examples/cmd/customized configures the response/error shapes process-wide via
Set*; examples/cmd/scoped does it per App (two groups, no globals).
Every command serves the spec at /openapi.json plus Swagger UI (/swagger),
Redoc (/redoc) and a landing page (/) — the ready-made pages in
examples/docsui (CDN-loaded, version-pinned, SRI-hashed). Run one and open the
root URL:
cd examples && go run ./cmd/nethttp # then open http://localhost:8081MIT © 2026 Tran Long An

