OpenAPI
The spec is derived from what you already wrote: handler request and response types, binding tags, validation rules, and route metadata. There is no separate annotation language and no generation step.
Serve the spec
import "github.com/sulv-io/ezz/openapi"
openapi.SetupDocsEndpoints(app)
That registers two public routes: /openapi.json with the document and /docs with a Scalar UI. For different paths, call the underlying function:
openapi.ServeScalar(app, "/api/openapi.json", "/api/reference")
The document is built per request from the routes registered on the app, so call it wherever you like in your setup; late-registered routes still appear.
What ends up in the document
For each route:
| Source | Becomes |
|---|---|
| Method and pattern | The path item and operation, with {id} segments intact |
path tagged fields | Path parameters, always required |
query tagged fields | Query parameters, required when the field validates as required |
header tagged fields | Header parameters |
Request type on POST, PUT, PATCH | A JSON request body schema |
| Response type | The 200 response schema |
validate rules | Schema constraints, see below |
description tag | Parameter and property descriptions |
example tag | Parameter and property examples |
.Tags(...) | Operation tags, and the grouping in the UI |
.Description(...) | Operation description |
Validation rules map onto JSON Schema:
| Rule | Schema |
|---|---|
min / max on strings and arrays | minLength / maxLength |
min / max on numbers | minimum / maximum |
len | minLength and maxLength |
email | format: email |
url | format: uri |
default tag | default |
Document a request type well
type ListUsersRequest struct {
Team string `path:"team" description:"Team slug"`
Query string `query:"q" description:"Free text search" example:"ada"`
Role string `query:"role" description:"Filter by role" example:"admin"`
Page int `query:"page" default:"1" description:"1-based page number"`
Limit int `query:"limit" default:"25" validate:"min=1,max=100" description:"Page size"`
Cursor string `header:"X-Cursor" description:"Opaque pagination cursor"`
}
The generator reads description and example on response struct fields too, so the same tags improve both directions:
type User struct {
ID string `json:"id" description:"Stable user identifier" example:"usr_123"`
Email string `json:"email" description:"Primary email" example:"ada@example.com"`
Admin bool `json:"admin" description:"Whether the user has admin rights"`
}
When no example is given, the generator invents one from the field name and type, so email fields get an email-looking value and created_at gets a timestamp.
Responses that are always documented
Every operation gets 200, 400, and 500, with the standard error body schema. Two more are added conditionally:
401unless the operation's tags includePublic.403when the tags includeAdminorManagement.
Security follows the same rule: a bearerAuth requirement (http, bearer, JWT) is attached to every operation whose tags do not include Public.
Publictag, not by the route's auth rule. A route registered with .Public() but tagged Users is still documented as requiring a bearer token. Add the tag when you want the spec to agree:app.GET("/health", health).Public().Tags("System", "Public")
Metadata that is not emitted yet
.Summary(...), .OperationID(...), .Deprecated(), and .Response(...) are recorded on the route but do not reach the generated document. Operation IDs are generated from the method and path, and summaries are absent. Use .Description(...) for prose that has to appear in the docs, and treat the others as forward-looking.
Set the document title and servers
SetupDocsEndpoints uses a generator with empty info, which falls back to the title "API Documentation" and version "1.0.0". To control that, or to add servers and security schemes, build the generator yourself and serve the result:
gen := openapi.NewGenerator()
gen.Info = openapi.Info{
Title: "Orders API",
Version: "2025-07-01",
Description: "Order management for internal services.",
Contact: &openapi.Contact{Name: "Platform team", Email: "platform@example.com"},
}
gen.Servers = []openapi.Server{
{URL: "https://api.example.com", Description: "Production"},
{URL: "https://staging.api.example.com", Description: "Staging"},
}
openapi.AddSecurityScheme(gen, "apiKey", openapi.SecurityScheme{
Type: "apiKey",
In: "header",
Name: "X-API-Key",
})
app.GET("/openapi.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(openapi.MustJSON(gen.GenerateFromApp(app))))
})).Public()
Then point the UI at your path with openapi.ServeScalar(app, "/openapi.json", "/docs").
Export the spec to a file
Useful for client generation or for checking the spec into review:
//go:build tools
package main
func main() {
app := buildApp() // the same function your server uses
spec := openapi.NewGenerator().GenerateFromApp(app)
os.WriteFile("openapi.json", []byte(openapi.MustJSON(spec)), 0o644)
}
go run -tags tools ./cmd/spec
Keeping the generated file under version control makes an accidental breaking change visible in a diff. It also feeds client generators:
npx openapi-typescript openapi.json -o client/types.ts
Test the spec
func TestSpecCoversUsers(t *testing.T) {
app := buildApp()
spec := openapi.NewGenerator().GenerateFromApp(app)
item, ok := spec.Paths["/api/v1/users/{id}"]
if !ok {
t.Fatal("missing path")
}
if item.Get == nil {
t.Fatal("missing GET operation")
}
if len(item.Get.Parameters) == 0 {
t.Fatal("expected the id path parameter")
}
}
Next steps
- Scalar UI for the reference page served at
/docs. - Request binding for the tags the generator reads.