API Docs

OpenAPI

Generate an OpenAPI 3 document from your handler types and struct tags.

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:

SourceBecomes
Method and patternThe path item and operation, with {id} segments intact
path tagged fieldsPath parameters, always required
query tagged fieldsQuery parameters, required when the field validates as required
header tagged fieldsHeader parameters
Request type on POST, PUT, PATCHA JSON request body schema
Response typeThe 200 response schema
validate rulesSchema constraints, see below
description tagParameter and property descriptions
example tagParameter and property examples
.Tags(...)Operation tags, and the grouping in the UI
.Description(...)Operation description

Validation rules map onto JSON Schema:

RuleSchema
min / max on strings and arraysminLength / maxLength
min / max on numbersminimum / maximum
lenminLength and maxLength
emailformat: email
urlformat: uri
default tagdefault

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:

  • 401 unless the operation's tags include Public.
  • 403 when the tags include Admin or Management.

Security follows the same rule: a bearerAuth requirement (http, bearer, JWT) is attached to every operation whose tags do not include Public.

The security marker is driven by the 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)
}
Terminal
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:

Terminal
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

Copyright © 2026