The openapi package generates a valid OpenAPI 3.1 document for a lagodev
app using only the standard library and reflection — no codegen, no
struct registration, no annotations file. Describe operations with Go
types, render the spec, serve a Swagger-UI page, and (via
lago gen:client) generate a fully typed Go HTTP client from the result.
There are three layers:
| Layer | What it does | Entry points |
|---|---|---|
| Struct → Schema | Reflect a Go type into JSON Schema, honouring json/validate tags |
SchemaOf[T], SchemaFor |
| Spec builder | Declare operations; bodies become reusable $ref components |
New, Spec.Operation, FromApp |
| Serve | Render JSON, expose handlers, ship a docs page | Spec.JSON, Spec.Handler, Spec.DocsHandler |
FromApp harvests every route registered on a *web.App and returns a
serveable *Spec with one bare operation per route (method, path, path
params, a default 200):
import (
"github.com/devituz/lagodev/openapi"
"github.com/devituz/lagodev/web"
)
func Register(app *web.App) {
app.Group("/api/v1", func(g *web.Router) {
g.Resource("posts", controllers.NewPostController(app.DB()))
})
spec := openapi.FromApp(app, "Blog API", "1.0.0")
spec.AddServer("https://api.example.com", "production")
// Serve the document through a web.Handler — Spec.Map() returns the
// document as a plain map the framework renders as JSON.
app.Get("/openapi.json", func(c *web.Context) (any, error) {
return spec.Map()
})
}The Spec.Handler() / Spec.DocsHandler() helpers return http.Handler
values — mount those on a standalone http.ServeMux (see
Serving the spec). On a *web.App, return
spec.Map() from a web.Handler as above, or render spec.JSON() to a
file at build time.
FromApp reads route metadata only — web does not carry request and
response types — so each operation starts as a skeleton. Refine
individual operations afterwards (a repeated method+path replaces the
prior operation). To harvest without building a full spec, use
openapi.Harvest(app) []RouteInfo (or HarvestRouter([]web.Route) for a
sub-group); each RouteInfo carries Method, Path (normalized to brace
form) and PathParams.
New(title, version) returns an empty *Spec; Operation declares one
operation. Request and response bodies are described by Go types and
registered once under components/schemas, referenced elsewhere by
$ref.
type CreateUser struct {
Name string `json:"name" validate:"required,max=64"`
Email string `json:"email" validate:"required,email"`
Role string `json:"role" validate:"required,in=admin|user"`
}
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
spec := openapi.New("Users API", "1.0.0")
spec.AddServer("https://api.example.com", "production")
spec.Operation(http.MethodPost, "/users", openapi.OperationConfig{
Summary: "Create a user",
Tags: []string{"users"},
OperationID: "createUser",
RequestBody: openapi.BodyOf[CreateUser](),
Responses: map[int]openapi.Response{
201: openapi.JSONResponse[User]("created"),
422: openapi.TextResponse("validation failed"),
},
})
spec.Operation(http.MethodGet, "/users/{id}", openapi.OperationConfig{
Summary: "List or fetch a user",
Tags: []string{"users"},
OperationID: "getUser",
Query: []openapi.Param{
{Name: "expand", Description: "comma-separated relations"},
},
Responses: map[int]openapi.Response{
200: openapi.JSONResponse[User]("the user"),
404: openapi.TextResponse("not found"),
},
})OperationConfig fields:
| Field | Purpose |
|---|---|
Summary |
Short operation title |
Description |
Longer prose |
Tags |
Grouping labels (drives Spec.Tags() and Swagger-UI sections) |
OperationID |
Stable id — becomes the Go method name in the generated client |
PathParams |
[]Param docs for path placeholders |
Query |
[]Param query parameters |
RequestBody |
*Body (see below) |
Responses |
map[int]Response keyed by HTTP status |
Path parameters are auto-derived from {id} / :id segments and merged
with explicit PathParams docs — any placeholder you do not document is
added as a required string param. Both brace and colon forms are
accepted and normalized to brace form. An empty Responses map defaults
to a single 200 OK so the operation stays valid.
| Constructor | Result |
|---|---|
openapi.BodyOf[T]() |
Request body schema from type T (application/json, required) |
openapi.RawBody(schema) |
Request body from an explicit *Schema |
openapi.JSONResponse[T](desc) |
JSON response whose body is T |
openapi.ListResponse[T](desc) |
JSON response whose body is []T |
openapi.TextResponse(desc) |
Bodyless response (e.g. 204, error statuses) |
Body and Response are plain structs — set MediaType to override the
default application/json, or Required/Description as needed.
SchemaOf[T]() (or SchemaFor(reflect.Type)) reflects a type into JSON
Schema. It honours json tags (names, omitempty, -), recurses
through nested structs, slices, maps and pointers, flattens embedded
structs the way encoding/json promotes their fields, and emits
time.Time as {type: string, format: date-time}. Pointer and
omitempty fields become nullable using the OpenAPI 3.1 type-union idiom
(["string", "null"]).
validate tags map onto schema keywords:
| Validate rule | Schema keyword |
|---|---|
required |
adds the field to the object's required |
email / url / uuid / datetime |
format |
alpha / alphanumeric / regex=… |
pattern |
min / max / len |
minLength/maxLength (strings), minItems/maxItems (arrays), or minimum/maximum (numbers) |
gte / lte |
minimum / maximum |
gt / lt |
minimum/maximum + exclusive… |
in=admin|user |
enum (numeric when the field is numeric) |
Whether min/max constrains length or magnitude follows the field's
type, matching the validation package's own behaviour. A required
field that is also omitempty is not added to required (it can be
absent on the wire).
When operations register types through a *Spec, named structs become
$ref components automatically — SchemaOf/SchemaFor build inline
schemas only. Use spec.Registry() to pre-register or inspect:
name := openapi.Register[User](spec.Registry()) // "User"
all := spec.Registry().Schemas() // map[string]*openapi.Schemaspec := openapi.New("Users API", "1.0.0")
// ... declare operations ...
mux := http.NewServeMux()
mux.Handle("/openapi.json", spec.Handler())
mux.Handle("/docs", spec.DocsHandler("/openapi.json"))Spec.Handler()renders the document once, caches the bytes, and serves them asapplication/json. OnlyGET/HEADare answered (405otherwise).Spec.DocsHandler(specURL)serves a self-contained Swagger-UI page pointing atspecURL(defaults to/openapi.jsonwhen empty). Assets load from a public CDN; for offline use, host them yourself and adaptopenapi.SwaggerHTML(title, specURL).
On a *web.App (which routes through web.Handler, not http.Handler),
serve the document by returning spec.Map() from a handler instead, as
shown in the quick start.
Other render outputs:
b, err := spec.JSON() // indented []byte, valid OpenAPI 3.1
m, err := spec.Map() // map[string]any (JSON round-trip) for post-processing
tags := spec.Tags() // distinct operation tags, sortedWrite the document to disk to feed the client generator:
b, _ := spec.JSON()
_ = os.WriteFile("openapi.json", b, 0o644)lago gen:client reads an OpenAPI 3.x JSON document (anything the
openapi package emits, or any compliant spec) and writes a single
client.go: a Client struct, one method per operation, and a Go struct
for every component schema.
lago gen:client --spec openapi.json --dir client --package client| Flag | Default | Purpose |
|---|---|---|
--spec |
— (required) | Path to the OpenAPI JSON document |
--dir |
client |
Output directory (<dir>/client.go) |
--package |
derived from --dir |
Package name |
--base-url |
first server URL in the spec |
Baked-in DefaultBaseURL |
--force |
false |
Overwrite an existing client.go |
artisan gen:client … is the interchangeable alias.
DefaultBaseURLconstant from the spec's first server (or--base-url).- A
Client{BaseURL string; HTTPClient *http.Client}withNew(baseURL string) *Client(http.DefaultClientby default). - An
APIError{StatusCode int; Body string}returned for every non-2xx response. - One method per operation. The method name comes from
OperationID(sanitized + PascalCased); without one it falls back to verb + path segments (GetUsersId). Each method takesctx context.Contextfirst, then typed path params, query params, and a typedbodywhen the operation has a request body. The return iserror, or(<RespType>, error)when the lowest 2xx response carries a JSON body. - A struct per
components/schemasentry, fields tagged with the JSON wire name (,omitemptyunless the schema marks the property required).
For the two operations declared above, the generated client exposes:
client := client.New("") // uses DefaultBaseURL
u, err := client.CreateUser(ctx, client.CreateUser{
Name: "Ada",
Email: "ada@example.com",
Role: "admin",
})
u, err := client.GetUser(ctx, "42", "profile") // id, expandBodies are JSON-encoded with Content-Type: application/json; 2xx JSON
responses are decoded into the typed return; everything else comes back as
*APIError. The file carries a // Code generated … DO NOT EDIT. header —
regenerate, don't hand-edit.
# 1. emit the spec from your running app (or a small main that builds it)
go run ./cmd/genspec > openapi.json
# 2. generate the client into ./client
lago gen:client --spec openapi.json --dir client --package client --force
# 3. import and call it
go build ./client- Spec hosting. Render once at startup —
Spec.Handler()already caches the JSON, so it is cheap to keep mounted. If you don't want the doc public, gate/openapi.jsonand/docsbehind the same auth middleware as the rest of the API, or only register them when an env flag is set. - Swagger-UI assets.
DocsHandlerpulls Swagger-UI from jsDelivr. For air-gapped or strict-CSP deployments, vendor the assets and build your own page fromSwaggerHTMLas a template. OperationIDis contract. It becomes the client method name. Set it deliberately and keep it stable across versions — renaming it is a breaking change for every generated client downstream.- Spec is the source of truth for codegen.
gen:clientconsumes a static JSON file, so wire spec generation into CI: build the spec, writeopenapi.json, rungen:client --force, and fail the build on a dirty diff to catch drift between the API and its published client. - Reflection limits. Schemas come from struct tags only. Computed
fields, custom
MarshalJSON, andinterface{}values reflect as their literal Go shape (open{}for interfaces) — model wire DTOs explicitly rather than reusing ORM models with hidden columns.