Skip to content

Guide

OpenAPI

Zinc describes your API as an OpenAPI 3.1 spec from the types you already write, and serves it with a docs page. No comments to maintain, and the rules it states are the rules it enforces.

Zinc describes your API as an OpenAPI 3.1 spec: every route, its parameters, request body and responses. Every app serves it at /openapi.json, with a browsable reference page at /docs. Use it to generate client code, test against it, or check a change doesn’t break your API’s shape. Coming from Huma or Fuego? See how they compare.

The spec comes from your code, not from comments. A typed handler already says what it takes and returns, so it needs nothing extra:

package main
import (
"log"
"net/http"
"github.com/0mjs/zinc"
"github.com/0mjs/zinc/middleware/apidocs"
)
type Pet struct {
ID int64 `json:"id" doc:"The pet's ID." example:"7"`
Name string `json:"name" validate:"required,min=1,max=40"`
Kind string `json:"kind" validate:"oneof=cat dog"`
}
type CreatePet struct {
Store string `path:"store"`
Name string `json:"name" validate:"required,min=1,max=40"`
Kind string `json:"kind" validate:"oneof=cat dog"`
}
func main() {
app := zinc.New(zinc.Config{
OpenAPI: zinc.OpenAPIConfig{Title: "Pet Store", Version: "1.0.0"},
})
app.Post("/stores/{store}/pets", zinc.Typed(func(c *zinc.Context, in CreatePet) (Pet, error) {
return Pet{ID: 7, Name: in.Name, Kind: in.Kind}, nil
})).Status(http.StatusCreated).Summary("Add a pet").Tags("pets")
app.Get("/docs", apidocs.New()).Hidden()
log.Fatal(app.Listen(":8080"))
}

Open http://localhost:8080/docs for the docs page, or fetch the spec itself:

Terminal window
curl http://localhost:8080/openapi.json
"/stores/{store}/pets": {
"post": {
"tags": ["pets"],
"summary": "Add a pet",
"parameters": [
{ "name": "store", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreatePetBody" } } }
},
"responses": {
"201": { "description": "Created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Pet" } } } },
"400": { "description": "Bad Request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"422": { "description": "Unprocessable Entity", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"500": { "description": "Internal Server Error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
}

The spec is public by default. For a private API, protect it or turn it off.

From Becomes
path:"store" fields Path parameters, with the field’s type. Every {name} in the route is listed, as a string if no field binds it.
query, header and cookie fields Query, header and cookie parameters. validate:"required" makes one required, and default:"20" documents the value binding fills in when the request leaves it out.
json fields The JSON request body. Fields bound from the path, query or headers are left out of it. A field is required with validate:"required", or when its rules reject a missing value. The same schema is listed as application/xml when the input has xml tags, and under each configured decoder’s media type.
form fields A form body, or multipart/form-data when a field holds a file. A field tagged both json and form is in both bodies, since binding reads it from either. A file field’s media:"image/png" tag documents the part’s content type, and binding rejects other types.
The output type The success response. zinc.NoContent gives 204 with no body. Fields without omitempty are required, since they’re always sent. The output types document their media type: text/plain, text/html, application/octet-stream, or a redirect with Location.
.Status(201) The success status.
A route with input A 400 response, and a 422 when the input has validation rules or enums, or the app has its own Validator.
Every route A 500 response.

Routes are listed in the order you register them. GET and HEAD routes never get a request body in the spec.

A handler that isn’t typed is still in the spec, with its method, path and path parameters. Tell Zinc its input and output types to describe the rest:

app.Put("/pets/{id}", func(c *zinc.Context) error {
var in CreatePet
if err := c.Bind().All(&in); err != nil {
return err
}
return c.JSON(update(in)) // update: your code
}).Input(CreatePet{}).Output(Pet{})

Input and Output take a value of the type. Zinc doesn’t check that the handler really uses them, so keep them in step with the code. On a typed handler they panic: its types are already known.

Without Output, a .Response or .Produces for a success status, or .Status, Zinc can’t know what the handler sends, so it doesn’t guess. The spec lists a default response with a body of any type:

"default": {
"description": "The handler's response. Route.Output or Route.Response describes it.",
"content": { "*/*": {} }
}

Every route has these methods. Each returns the route, so they chain:

Method What it does
.Name("createPet") Names the route, and sets the operation ID
.Summary("Add a pet") One line shown in docs pages
.Description("…") Longer text; Markdown works
.Tags("pets") Groups the route in docs pages
.Deprecated() Marks the route deprecated; it still serves requests
.Hidden() Leaves the route out of the spec
.Errors(404, 409) Adds error statuses the handler answers by returning an error, such as zinc.NotFound(...)
.Example(201, "a cat", Pet{...}) Adds a named example of a response; see Add examples
.RequestExample("a cat", CreatePet{...}) Adds a named example of the request body
.Response(409, Conflict{}) Adds a response the handler writes itself; pass nil for one with no body
.Produces(200, "text/csv") The media types a status is sent as, when Zinc can’t tell: a plain handler, or a Bytes, File or Stream output
.Consumes("text/csv") The media types the request body is accepted as, in place of the ones Zinc infers
.Security("bearer") Names the security schemes that protect the route; with no names, marks it public

Unset fields stay out of the spec. Zinc doesn’t invent summaries or operation IDs.

Use Errors for a status your handler reaches by returning an error: the error handler writes that response, so the spec describes it with the error handler’s body. Use Response only when the handler writes the body itself:

app.Get("/pets/{id}", zinc.Typed(func(c *zinc.Context, in PetID) (Pet, error) {
pet, ok := pets.Find(in.ID) // pets: your store
if !ok {
return Pet{}, zinc.NotFound("no such pet")
}
return pet, nil
})).Errors(http.StatusNotFound)

Give a response or the request body named examples, written as Go values. Zinc encodes each as clients would receive it and puts it beside the schema:

app.Post("/pets", zinc.Typed(createPet)).
Status(http.StatusCreated).
RequestExample("a cat", CreatePet{Name: "Tom"}).
Example(http.StatusCreated, "a cat", Pet{ID: "7", Name: "Tom"}).
Errors(http.StatusConflict).
Example(http.StatusConflict, "name taken", zinc.NewError(http.StatusConflict, "a pet named Tom exists"))
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Error" },
"examples": {
"name taken": {
"value": { "error": { "status": 409, "message": "a pet named Tom exists" } }
}
}
}
}
}

An error example is an *HTTPError, shown as your error handler writes it. A request example leaves out fields bound from the path, query, headers or cookies, and nil fields.

Each example is checked when the spec is built. A value of the wrong type is an error that names the route:

zinc: GET /: example "x" for 200: value is a main.petRecord; the response is a main.Pet

Tags and Security on a group apply to every route registered in it afterwards, and to its child groups. Like Use, call them before the group’s first route:

cfg := zinc.OpenAPIConfig{
Title: "Pet Store",
Version: "1.0.0",
SecuritySchemes: map[string]zinc.OpenAPISecurityScheme{
"bearer": {Type: "http", Scheme: "bearer", BearerFormat: "JWT"},
},
}
app := zinc.New(zinc.Config{OpenAPI: cfg})
admin := app.Group("/admin", requireToken).Tags("admin").Security("bearer") // requireToken: your auth middleware
admin.Delete("/pets/{id}", zinc.Typed(deletePet))

Security only documents how the route is protected. Enforcing it is your middleware’s job, here requireToken. A scheme name that isn’t in SecuritySchemes is an error when the spec is built.

Call Means
.Security("key", "bearer") Either scheme is enough
.SecurityAll("key", "bearer") Both are needed, together
.Security("oauth:pets:read") Scheme oauth, with scope pets:read
.Security() Public, overriding the group’s or the default

A secured route documents a 401, and a 403 when it needs a scope, since its auth answers with them. Set NoAuthResponses in OpenAPIConfig to leave them out.

Hidden on a group leaves its routes, and its child groups’ routes, out of the spec:

internal := app.Group("/internal").Hidden()

An OAuth 2 scheme lists its flows:

"oauth": {Type: "oauth2", Flows: &zinc.OpenAPIOAuthFlows{
AuthorizationCode: &zinc.OpenAPIOAuthFlow{
AuthorizationURL: "https://id.example.com/authorize",
TokenURL: "https://id.example.com/token",
Scopes: map[string]string{"pets:read": "Read pets"},
},
}},

A scheme missing something its type needs, such as an oauth2 scheme without flows, is also an error, since the spec would be invalid.

Middleware is a plain function, so the spec can’t see the credentials it checks or the errors it answers. Describe them with Document, beside the Use or group it describes. Zinc’s middleware with something to add comes with a Doc function:

app := zinc.New()
app.Use(timeout.New(timeout.Config{Timeout: 5 * time.Second}))
app.Document(timeout.Doc())
api := app.Group("/api", csrf.New()).Document(csrf.Doc())
api.Post("/pets", zinc.Typed(createPet)).Status(201)

POST /api/pets now requires the CSRF token, and lists 403 and 503 beside its own responses. The token is a security scheme, so a generated client sets it once, not on every call:

"security": [
{
"csrf": []
}
]
"securitySchemes": {
"csrf": {
"type": "apiKey",
"in": "header",
"name": "X-CSRF-Token",
"description": "The CSRF token: the value of the cookie a safe request set."
}
}
responses: 201, 400, 403, 422, 500, 503

On a route with its own security, the token joins each requirement: a route that takes an API key or a bearer token needs the token with either.

App.Document covers every route, Group.Document the group’s routes, and Route.Document one route. For your own middleware, write a zinc.MiddlewareDoc:

api.Document(zinc.MiddlewareDoc{
Methods: []string{http.MethodPost, http.MethodPut, http.MethodDelete},
Headers: []zinc.HeaderDoc{{
Name: "Idempotency-Key",
Description: "Makes a retry safe.",
Required: true,
}},
Errors: []int{http.StatusConflict},
})
Field Means
Methods The methods it applies to. Empty means every method.
Security Credentials it checks, by scheme name: added to the spec’s schemes and required with the route’s own security. No 401 is added; list what it answers in Errors
Headers Other request headers it reads, which callers pass on each call. A header the route’s input already binds is listed once, from the input.
Errors Statuses it answers by returning an error, described with your error handler’s body
Middleware Doc() adds
CSRF On POST, PUT, PATCH and DELETE: the X-CSRF-Token header as the csrf security scheme, 400 and 403
Timeout 503
Limiter 429
Body Limit 413

Every app serves its spec at /openapi.json, and a browsable reference page for it at /docs, for GET and HEAD. Describe the API with Config.OpenAPI:

app := zinc.New(zinc.Config{
OpenAPI: zinc.OpenAPIConfig{Title: "Pet Store", Version: "1.0.0"},
})

The spec is built on the first request and kept; routes you register later are picked up on the next one. Neither endpoint is a route: a route of your own at the same path wins, and so does a mount or static directory at exactly that path, and neither is listed in the spec. A mount above them, such as app.Static("/", "./public"), doesn’t hide them: /openapi.json and /docs still answer GET and HEAD, and the mount serves everything else. Routes() lists both, with Builtin set. Middleware added with app.Use runs for them like any request.

The page at /docs loads Scalar from a CDN, pinned to one version with an integrity hash. To use another renderer or your own copy of the files, turn the page off and add API Docs.

To Do
Serve the spec at another path zinc.Config{OpenAPIPath: "/api/openapi.json"}; the page follows it
Serve the page at another path zinc.Config{DocsPath: "/reference"}
Serve no page zinc.Config{DocsPath: "-"}
Serve no spec, and so no page zinc.Config{OpenAPIPath: "-"}
Protect it with middleware app.OpenAPI("/openapi.json", cfg, requireAPIKey) (requireAPIKey: your middleware). It replaces the default spec.
Serve several specs Call app.OpenAPI once for each path and config
OpenAPIConfig field Default Meaning
Title Your main module’s name The API’s name
Version Your main module’s version, or 0.0.0 Your API’s version, not Zinc’s
Description none Markdown
TermsOfService none A URL
Contact none &zinc.OpenAPIContact{Name, URL, Email}
License none &zinc.OpenAPILicense{Name: "MIT", Identifier: "MIT"}; an SPDX Identifier or a URL, not both
ExternalDocs none &zinc.OpenAPIExternalDocs{URL: "https://example.com/docs"}
Servers none Base URLs, such as {URL: "https://api.example.com"}
Tags none Tag descriptions, in the order docs pages list them. Tags that routes use but Tags leaves out follow, in the order they’re used
SecuritySchemes none The schemes Security refers to, by name
Security none Schemes for every route that doesn’t set its own
NoAuthResponses false Leaves out the 401 and 403 that secured routes get
Schemas none Schemas for types from other packages; see below

A config the spec can’t be valid with, such as a license without a name, panics in zinc.New and app.OpenAPI, and is an error from app.OpenAPISpec.

To show the spec as a page, add API Docs.

For what no option covers, edit the spec as JSON. Extensions adds x- members to its root, Route.Operation edits one operation, and Mutate edits the whole spec last:

app.Get("/pets", listPets).Operation(func(op map[string]any) { // listPets: your handler
op["x-rate-limit"] = 100
})
app.OpenAPI("/openapi.json", zinc.OpenAPIConfig{
Title: "Pet Store", Version: "1.0.0",
Extensions: map[string]any{"x-logo": map[string]any{"url": "https://example.com/logo.png"}},
Mutate: func(spec map[string]any) error {
spec["info"].(map[string]any)["x-audience"] = "partners"
return nil
},
})

Zinc keeps its own order and adds new members after it. The result is checked again, so a hook can’t leave the spec broken:

zinc: the spec after its hooks: #/paths/~1pets/get/responses/200: $ref #/components/responses/Pets doesn't resolve

app.Validate() builds every spec the app serves and checks the routes, without a request. Call it at startup or in a test, so a mistake fails there:

if err := app.Validate(); err != nil {
log.Fatal(err)
}
zinc: paths /pets/{id} and /pets/{name} differ only in parameter names, which OpenAPI doesn't allow; give the parameters the same names
POST /pets: main.Created.Location has header:"Location" but is sent in the JSON body, so no header is sent; add json:"-" to send it as a header

It also reports openapi tag words it doesn’t know, and validate rules the validator doesn’t enforce on types given to .Input and .Output.

app.OpenAPISpec(cfg) returns the spec as JSON without serving anything. Build it in a test to keep a copy in your repository, and a change to the API’s shape shows up in the diff:

func TestOpenAPISpec(t *testing.T) {
spec, err := newApp().OpenAPISpec(specConfig) // newApp, specConfig: your code
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile("openapi.json", spec, 0o644); err != nil {
t.Fatal(err)
}
}

The output is the same for the same routes, so the file only changes when the API does.

Go Schema
string, bool string, boolean
integers integer with format int32 or int64. 8- and 16-bit types, and uint32, have their range as minimum and maximum; unsigned types have minimum: 0; uint, uint64 and uintptr have no format, since int64 can’t hold their largest values
float32, float64 number with format float or double
[]T, [N]T array; in a response, a slice can also be null
[]byte a base64 string
map[string]T object with additionalProperties; in a response, it can also be null
*T T, or null
time.Time a date-time string
json.Number number
uuid.UUID (google or gofrs) a uuid string
*big.Int, netip.Addr, net.IP, decimal.Decimal integer for big.Int; the others are strings
other types with MarshalText a string
named structs a shared schema under components/schemas, referenced with $ref. A generic one is named after its arguments: Page[User] is PageUser

Struct fields follow encoding/json: json tag names, -, ,string, and embedded structs. Fields promoted from an embedded pointer aren’t required in a response, since a nil pointer leaves them out. More tags add detail:

Tag Adds
validate:"required,min=1,max=40" Required fields, lengths, ranges, email, uuid and url formats, and oneof choices: the rules the validator enforces. With omitempty, the zero value is allowed by the rules after it, as the validator allows it, unless the field is a pointer: one that isn’t nil is checked whatever it points to.
doc:"The pet's ID." A description
example:"7" An example value
enum:"s,m,l" The values the field takes, or its elements for a slice. Zinc checks them on input, whatever the validator
pattern:"^[a-z0-9-]+$" A regular expression a string must match. Zinc checks it on input, whatever the validator

The request body of the first example, CreatePetBody, becomes:

"CreatePetBody": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 40 },
"kind": { "type": "string", "enum": ["cat", "dog"] }
},
"required": ["name", "kind"]
}

kind is required although it has no required rule: oneof=cat dog rejects the empty string a missing field binds as, so a request without it gets a 422. Add omitempty to make it optional.

Go can’t list a type’s constants at run time, so a named type lists its values with an Enum method. It becomes one shared schema, which every field of the type refers to:

type Kind string
const (
Cat Kind = "cat"
Dog Kind = "dog"
)
func (Kind) Enum() []any { return []any{Cat, Dog} }
"Kind": { "type": "string", "enum": ["cat", "dog"] }

When a type’s JSON doesn’t match its Go shape, such as one with its own MarshalJSON, describe it yourself with zinc.SchemaProvider:

type Date struct{ time.Time }
func (Date) OpenAPISchema() map[string]any {
return map[string]any{"type": "string", "format": "date"}
}

Mark fields read-only, write-only or deprecated

Section titled “Mark fields read-only, write-only or deprecated”

The openapi tag adds a field’s role to the schema: readonly for a field the server sets, such as an ID, writeonly for one clients send but never get back, such as a password, and deprecated. Separate several with commas:

type Pet struct {
ID string `json:"id" openapi:"readonly"`
Name string `json:"name"`
Password string `json:"password,omitempty" openapi:"writeonly"`
Nick string `json:"nick,omitempty" openapi:"deprecated"`
}
"id": { "type": "string", "readOnly": true },
"password": { "type": "string", "writeOnly": true },
"nick": { "type": "string", "deprecated": true }

The tag describes the field; it doesn’t filter it. Binding still fills a readonly field from the body, and a response still sends a writeonly one. Use separate input and output types for a field that must never cross.

A component is named after its Go type, starting with a capital, so createPet is CreatePet. A struct’s input variant adds Input, and a request body without its parameter fields Body, unless the name already ends with it: createPetInput stays CreatePetInput.

To choose another name, add an OpenAPIName method (zinc.SchemaNamer). It works for enum types as well as structs:

type petRecord struct{ ID string `json:"id"` }
func (petRecord) OpenAPIName() string { return "PetSummary" }
type Status string
func (Status) Enum() []any { return []any{"active", "disabled"} }
func (Status) OpenAPIName() string { return "PetStatus" }

If two types claim one name, the later one gets its package name in front, such as AdminCreatePet.

A type from another module can’t gain a method. Give it a schema in OpenAPIConfig.Schemas instead:

cfg := zinc.OpenAPIConfig{
Schemas: map[reflect.Type]map[string]any{
reflect.TypeFor[money.Amount](): {"type": "string", "format": "decimal"}, // money: another module
},
}

An entry there wins over everything else, including the types Zinc knows, such as uuid.UUID.

The spec claims the rules that are enforced

Section titled “The spec claims the rules that are enforced”

validate tags become schema keywords, such as minLength and format: email, only for rules something enforces: Zinc’s built-in rules, or the rules your Validator lists with RuleSet. A validator without RuleSet makes no claims. A field whose rules reject a missing value, such as oneof=cat dog or min=1 without omitempty, is listed as required, because a request without it gets a 422. 422 is documented only on routes that can send it.

With Zinc’s default error handler, the 400, 422 and 500 responses describe its error body, the Error schema. With zinc.ProblemErrors, they describe RFC 9457 problem details, the Problem schema, as application/problem+json. With your own ErrorHandler, Zinc can’t know what you send, so those responses list the status alone.

encoding/json writes a nil slice or map as null, so a response schema allows null for them, and a generated client may type such a field as a pointer, such as *[]string. A field with omitempty or omitzero is left out instead of sent as null, so its schema doesn’t allow null.

Register zinc.Typed(...) directly. A typed handler called from inside a plain one isn’t recognized; add .Input and .Output to that route instead.

Zinc’s router can tell /files/{path} from /files/{path...}, and /pets/{id} on GET from /pets/{name} on POST. OpenAPI can’t: the first pair is one operation, and paths that differ only in parameter names aren’t allowed. Building the spec returns an error naming both routes:

zinc: paths /pets/{id} and /pets/{name} differ only in parameter names, which OpenAPI doesn't allow; give the parameters the same names

Rename the parameters to match, or leave one route out of the spec with .Hidden().

With Zinc’s default error handler, the spec’s Error schema is Zinc’s error body. Your own type named Error gets its package name in front, such as MainError.

header:"ETag" is ETag in the spec, and header:"X-CSRF-Token" is X-CSRF-Token. On the wire, net/http sends its canonical form, such as Etag; header names aren’t case-sensitive, and binding matches any spelling.

A pattern counts an empty value as left out

Section titled “A pattern counts an empty value as left out”

Like enum, pattern skips a zero value, which is what a field left out binds as. Use validate:"required" to refuse an empty one. Write the pattern in the syntax Go’s regexp and JSON Schema share, and anchor it with ^ and $: like JSON Schema’s pattern, it matches anywhere in the value. A pattern that doesn’t compile, or one on a field that isn’t a string, panics when the typed route is registered.

Hidden routes, mounts and static files, and methods OpenAPI 3.1 has no field for, such as PURGE.

  • API Docs: show the spec as a page with Scalar, Swagger UI, Stoplight Elements or ReDoc.
  • Typed Handlers: write handlers whose types describe themselves.
  • Binding: the tags the spec reads, and the validation rules.