Generate an OpenAPI 3.1 document from your Go types. Keep your router, your middleware, and your HTTP handlers.
Experimental: The public API can change. Feedback from real services is welcome.
specout adds API documentation to a Go service that already works. You describe each route with Go types and field tags, and specout builds the document from them.
Your handlers do not change. They still receive http.ResponseWriter and
*http.Request, and they still read, validate, and answer each request. You do
not need comment annotations or a code generation step.
specout only describes your API. It does not decode or validate requests, so your code must enforce what the tags say.
specout requires Go 1.27 or later.
go get github.com/happytoolin/specout@latestThis service has one route. Save it as main.go in your Go module:
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/happytoolin/specout"
)
// GreetRequest describes the request parameters.
type GreetRequest struct {
Name string `path:"name" jsonschema:"minLength=1,maxLength=40"`
Language string `query:"language,omitempty" jsonschema:"enum=en|es,default=en"`
}
// Greeting is the response body.
type Greeting struct {
Message string `json:"message"`
}
// Problem is the error body.
type Problem struct {
Error string `json:"error"`
}
// greet is a normal net/http handler. It reads and checks its own input.
func greet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
name := r.PathValue("name")
switch r.URL.Query().Get("language") {
case "", "en":
json.NewEncoder(w).Encode(Greeting{Message: "Hello, " + name})
case "es":
json.NewEncoder(w).Encode(Greeting{Message: "Hola, " + name})
default:
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(Problem{Error: "language must be en or es"})
}
}
func main() {
doc := specout.New(specout.Config{Title: "Hello API", Version: "1.0.0"})
mux := http.NewServeMux()
routes := specout.Std(doc, mux)
routes.Get("/hello/{name}", specout.Handler[GreetRequest, Greeting]{
HandlerFunc: greet,
Summary: "Say hello",
Responses: []specout.Response{{Status: http.StatusBadRequest, Type: Problem{}}},
})
mux.Handle("GET /openapi.json", doc)
log.Fatal(http.ListenAndServe("localhost:8080", mux))
}Start the service:
go run .Call the route:
curl 'http://localhost:8080/hello/Ada?language=es'{"message":"Hola, Ada"}Get the API document:
curl http://localhost:8080/openapi.jsonThe document includes this operation, and a schema for each of the two response types:
{
"summary": "Say hello",
"operationId": "getHelloName",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": { "type": "string", "maxLength": 40, "minLength": 1 }
},
{
"name": "language",
"in": "query",
"required": false,
"schema": { "type": "string", "enum": ["en", "es"], "default": "en" }
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/Greeting" } }
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/Problem" } }
}
}
}
}specout documents the API that you already have. Huma and Fuego are frameworks that also decode and validate requests for you. swag reads comment annotations.
Two features are specific to specout. It finds routes that have no documentation, and it compares the statuses in your HTTP tests with the document.
| specout | Huma | Fuego | swag | |
|---|---|---|---|---|
| Handlers | ||||
Plain net/http handlers |
Yes | No, it uses its own handler signature | Yes, with GetStd |
Yes |
| Describe the API with | Go types and tags | Go types and tags | Go types and route options | Comments |
| Build step | None | None | None | swag init |
| Decodes and validates requests | No, your handlers do | Yes | Yes | No |
| OpenAPI | ||||
| Document version | 3.1.0 | 3.1.0, and also 3.0.3 | 3.1.0 | 2.0, or 3.1.0 with --v3.1 on v2 RC |
| Cookie parameters | Yes | Yes | Yes | — |
| Discriminated unions | Yes, from Go types | Yes, with a handwritten schema | — | Yes, with @Discriminator on v2 RC |
| Callbacks | Yes, from Go types | Yes, with a handwritten object | — | — |
| Checks | ||||
| Find routes with no documentation | Yes | — | — | — |
| Compare test statuses with the document | Yes | — | — | — |
Router support:
| Router | specout | Huma | Fuego |
|---|---|---|---|
http.ServeMux |
Yes | Yes | Yes |
| chi v5 | Yes | Yes | — |
| gorilla/mux | Yes | Yes | Yes |
| Echo v5 | Yes | Yes | — |
| Echo v4 | Yes | Yes | Yes |
| Gin | Yes | Yes | Yes |
| Fiber v3 | Yes | Yes | — |
| Fiber v2 | No, Fiber deprecated it | Yes | — |
| httprouter, BunRouter, Flow | — | Yes | — |
swag reads comments, so it works with any router.
A dash means that we did not find the feature in the tool's documentation or source. We checked Huma v2.40.0, Fuego v0.21.0, and swag v1.16.6 and v2.0.0-rc6 on 2026-10-30. These tools change, so check their documentation before you choose.
Choose specout to add documentation and keep your current request handling code. Choose Huma or Fuego if you want the framework to decode and validate requests.
| Area | Support |
|---|---|
| Output | OpenAPI 3.1 and JSON Schema 2020-12, as JSON in a stable order. |
| Parameters | Path, query, header, and cookie. |
| Request bodies | JSON, binary files, multipart uploads, and custom media types. |
| Responses | Status codes, headers, media types, shared errors, links, and responses without a body. |
| Schemas | Nested and recursive types, collections, optional fields, nullable values, enums, formats, value limits, and discriminated unions. |
| Authentication | Bearer tokens, HTTP Basic, API keys, OAuth 2.0, OpenID Connect, and mutual TLS. Your middleware checks the credentials. |
| Route details | Summaries, descriptions, tags, operation IDs, deprecation, examples, and security for each route. |
| Requests your API sends | Webhooks and callbacks, with schemas from Go types. |
| Checks | Find routes without documentation, and compare response statuses with your HTTP tests. |
| Valid output | specout stops with a fix on a value that would make the document break OpenAPI 3.1 or JSON Schema 2020-12. |
A field tag gives the location of each request value. A field with no location tag is part of the JSON request body:
type UpdateItemRequest struct {
ID int64 `path:"id" jsonschema:"format=int64,description=Item ID"`
DryRun bool `query:"dry_run,omitempty"`
TraceID string `header:"X-Trace-ID,omitempty"`
Session string `cookie:"session,omitempty"`
Name string `json:"name" jsonschema:"minLength=1"`
Price float64 `json:"price" jsonschema:"minimum=0"`
}Here, the first four fields become parameters. The request body has only name
and price.
| Tag | What it describes |
|---|---|
path, query, header, cookie |
The location of a parameter. |
json |
The name of a body field. omitempty makes the field optional. |
form |
The name of a field in a form or multipart body, when it is not the JSON name. |
jsonschema:"minLength=1,maxLength=40" |
The length limits of a string. |
jsonschema:"minimum=1,maximum=3" |
The limits of a number. |
jsonschema:"minItems=1,maxItems=3" |
The length limits of an array. |
jsonschema:"enum=en|es" |
The allowed values. |
jsonschema:"default=en" |
A default value. Your handler must apply it. |
A specout.File field makes the request body multipart/form-data:
type UploadAvatar struct {
ID string `path:"id"`
Avatar specout.File `json:"avatar"`
Note string `json:"note,omitempty"`
}The second type of specout.Handler is the success response: a 200 with that
body, or a 204 when the type is specout.NoContent. Add other responses with
Responses:
doc := specout.New(specout.Config{
Title: "Items API", Version: "1.0.0",
ErrorType: Problem{}, DefaultErrors: []int{http.StatusInternalServerError},
})
routes.Post("/items", specout.Handler[CreateItem, Item]{
HandlerFunc: createItem,
Responses: []specout.Response{
{Status: http.StatusCreated, Headers: []specout.Header{{Name: "Location", Required: true}}},
{Status: http.StatusBadRequest},
},
})
routes.Delete("/items/{id}", specout.Delete{
HandlerFunc: deleteItem,
Responses: []specout.Response{{Status: http.StatusNotFound}},
})A declared success response replaces the 200 or the 204. A success response
is a 2xx, a redirect (a 3xx other than 304), or a 101. In the example,
the 201 replaces the 200, and the 204 stays next to the 404.
A response with no Type gets the body of its status:
| Status | Body |
|---|---|
2xx |
The second type of the handler. |
4xx, 5xx, and Key: "default" |
Config.ErrorType. With no ErrorType, the response has no body. |
| Any other status | No body. |
Set Type to give a response another body. Set Type: specout.NoContent{} for
a response with no body. Set Omit: true to remove a response, such as a status
from DefaultErrors. A route with Public: true does not get the 401 and
403 from DefaultErrors.
Use specout.Get[Item] for a route with no request data. Use
specout.NoContent as the response type for a route with no response body. Use
specout.File as the type of a binary body, and ContentTypes for its media
types:
routes.Get("/report", specout.Get[specout.File]{
HandlerFunc: getReport,
Responses: []specout.Response{{Status: http.StatusOK, ContentTypes: []string{"application/pdf"}}},
})Use Request to describe the request body, in the same way:
routes.Post("/items", specout.Handler[CreateItem, Item]{
HandlerFunc: createItem,
Request: specout.Request{Description: "The new item", Example: CreateItem{Name: "Lamp"}},
})A webhook is a request that your API sends on its own, such as an order event. Describe it as you describe a route, but give it a name and no handler. The request type is what your API sends:
type OrderCreated struct {
Signature string `header:"X-Signature"`
OrderID string `json:"orderId"`
}
doc.Webhook(http.MethodPost, "orderCreated", specout.Event[OrderCreated]{
Summary: "An order was created",
})specout.Event[T] means that the receiver answers 204 with no body. Add
Responses if the receiver can answer in another way. A declared success
response replaces the 204. For example, use Key: "2XX" for any success
status.
A callback is a request that your API sends because of one route. The URL is usually a runtime expression that reads the original request:
jobDone := specout.NewCallback(
http.MethodPost, "jobDone", "{$request.body#/callbackUrl}",
specout.Event[JobResult]{Summary: "The job finished"},
)
routes.Post("/jobs", specout.Handler[StartJob, Job]{
HandlerFunc: startJob,
Callbacks: []specout.Callback{jobDone},
})Webhooks and callbacks take the same fields as a route, such as Summary,
Responses, and Request. These rules are different from a route:
- They stay out of the route checks and the status comparison, because your API does not serve them.
- They do not get
DefaultErrors, because those errors come from your API. - They do not need
AuthfromConfig. SetSecurityif your API uses one of those schemes to send the request.
A link tells a client how to use a response in a next request. The build fails if no operation has the operation ID in the link:
routes.Post("/jobs", specout.Handler[StartJob, Job]{
HandlerFunc: startJob,
Responses: []specout.Response{{
Status: http.StatusOK,
Links: map[string]specout.Link{
"status": {
OperationID: "getJob",
Parameters: map[string]string{"id": "$response.body#/id"},
},
},
}},
})
routes.Get("/jobs/{id}", specout.Get[Job]{HandlerFunc: getJob, OperationID: "getJob"})Use Request.Encoding to describe the fields of a form or multipart request
body. A wrong field name panics at registration:
Request: specout.Request{
Encoding: map[string]specout.Encoding{
"avatar": {ContentType: "image/png"},
"labels": {Style: "pipeDelimited", Explode: new(false)},
},
},Use Handler.Servers when one route has a different base URL from the rest of
the API.
Only Title and Version are required in specout.Config. Add other fields
when your API needs them:
| Field | Purpose |
|---|---|
Description, Contact, TermsOfService, License |
Give general information about the API. |
ExternalDocs |
Link to an API guide. |
Servers |
List the base URLs of the API. This does not change the server address or the route paths. |
Tags |
Describe each group of routes. Use Handler.Tags to put a route in a group. |
Auth |
Describe authentication. Your middleware must check the credentials. |
ErrorType, DefaultErrors |
Describe a shared error body and its status codes. Your handlers write these responses. |
ClosedSchemas |
Mark object schemas as closed to unknown fields. Your code must reject those fields. |
JSONSchemaDialect |
Set the default JSON Schema dialect of the document. Most APIs do not need this. |
specout builds a schema from the Go fields of a type. A type with its own
MarshalJSON method can write a different shape, and specout cannot see that
shape. Give the type a JSONSchema() method. specout.Schema is the schema type
of invopop/jsonschema:
// Money.MarshalJSON writes a string such as "12.50".
func (Money) JSONSchema() *specout.Schema { return &specout.Schema{Type: "string"} }Use specout.Enum for a list of values that several fields share. Each field,
parameter, and body of the type then gets the same list:
type Status string
func (Status) JSONSchema() *specout.Schema { return specout.Enum("todo", "doing", "done") }Schemas describe the output of encoding/json. encoding/json/v2 writes some
values differently. For example, it writes a [N]byte array as a base64 string,
and a nil slice or map as an empty value instead of null.
| Router | Function | Package |
|---|---|---|
http.ServeMux |
specout.Std |
github.com/happytoolin/specout |
| chi | specout.Chi |
github.com/happytoolin/specout |
| gorilla/mux | specout.Gorilla |
github.com/happytoolin/specout |
| Echo v5 | specoutecho.New |
github.com/happytoolin/specout/adapters/specoutecho |
| Echo v4 | specoutechov4.New |
github.com/happytoolin/specout/adapters/specoutechov4 |
| Gin | specoutgin.New |
github.com/happytoolin/specout/adapters/specoutgin |
| Fiber v3 | specoutfiber.New |
github.com/happytoolin/specout/adapters/specoutfiber |
Your build includes Echo, Gin, or Fiber only when you import its adapter.
The Echo, Gin, and Fiber adapters work the same way. This example uses Gin:
doc := specout.New(specout.Config{Title: "Inventory API", Version: "1.0.0"})
engine := gin.New()
routes := specoutgin.New(doc, engine.Group("/v1"))
routes.Get("/items/:id", specout.Handler[GetItemRequest, Item]{
HandlerFunc: getItem,
Summary: "Get an item",
})
engine.GET("/openapi.json", gin.WrapH(doc))
if err := specoutgin.Adopt(doc, engine, specout.Skip("/openapi.json")); err != nil {
log.Fatal(err)
}With chi, call Adopt on the outermost router before Build. Only that router
knows the full path of each route in a group. Route, Group, and With work
as they do in chi, and give the binder of the group:
r := chi.NewRouter()
routes := specout.Chi(doc, r)
routes.Route("/v1", func(r *specout.ChiRouter) {
r.Get("/items/{id}", specout.Handler[GetItemRequest, Item]{HandlerFunc: getItem})
})
r.Handle("/openapi.json", doc)
if err := routes.Adopt(); err != nil {
log.Fatal(err)
}The document shows each path in OpenAPI form, so /items/:id becomes
/items/{id}. Your handler reads the value with r.PathValue("id") on every
router.
For another router, use doc.Bind to register and document each route. Use
doc.Document when the router registers the handler and you only want the
documentation. To request an adapter, open an
issue.
A request for /users/a%40b gives one of these values:
| Router | Value of r.PathValue |
To get a@b |
|---|---|---|
http.ServeMux, gorilla/mux, Echo v4, Gin, Fiber |
a@b |
No action. |
| Echo v5 | a%40b |
Set UnescapePathParamValues in echo.RouterConfig. |
| chi | a%40b |
Call url.PathUnescape on the value. |
Two router settings keep the value encoded:
- gorilla/mux with
UseEncodedPath. - Gin with
UseRawPathorUseEscapedPath, andUnescapePathValuescleared.
- A path with an empty segment (
//) or a dot segment (/.,/..) panics when you register it. Clients remove those segments, so the document would show a different path. - The document leaves out catch-all routes. A chi parameter is a catch-all when
its regex can match a
/before more text, as in/files/{name:.+}.json. http.ServeMuxserves/a/{$}for/a/only, and/a/for every path below it. OpenAPI has one path for both, so the build fails ifspecout.Stdregisters both. Register one of them withspecout.Std, and the other on the mux. Give the recorder a skip rule for the second one, as inrecorder.New(mux, specout.Skip("/a/")).
Generator is an http.Handler, so you can serve it on any route. Use
WriteJSON to write the document to a file or a buffer:
var out bytes.Buffer
if err := doc.WriteJSON(&out); err != nil {
return err
}Register all routes first. The first ServeHTTP or WriteJSON call builds the
document, and you cannot add routes after a successful build.
| Problem | Result |
|---|---|
Bad Config |
specout.New panics. |
| Bad route data or bad path | The registration panics, and the router does not get the route. |
Failed build, with Build or WriteJSON |
The call returns an error that lists every problem. If the build panics, the call panics. |
Failed build, with ServeHTTP |
ServeHTTP returns 500 without the error text. It logs the error once, and also the stack after a panic. |
Call doc.Build() at startup or in a test, so that a bad document fails there
and not on the first request:
if err := doc.Build(); err != nil {
log.Fatal(err)
}Each message starts with the place of the problem, and it gives the fix when specout knows it:
specout: POST /jobs: response 200: link "status": no operation has operationId "getJb"; set OperationID on the target route; did you mean "getJob"?
specout also panics on a jsonschema keyword that it does not know, such as
minLenght=1. The tag parser ignores such a keyword, so the rule would be
missing from the document.
The document must stay valid OpenAPI 3.1 and JSON Schema 2020-12. Thus specout also stops on these values:
| Value | Example |
|---|---|
A default or example that its own schema rejects |
enum=en|es,default=de |
| A schema keyword with a value that is not valid | pattern=(a, multipleOf=0, a repeated enum value |
A jsonschema_extras key with a checked form, or a 3.0 keyword |
required=true, nullable=true |
| A header parameter that OpenAPI ignores | Accept, Content-Type, Authorization |
| A style that does not fit the parameter type | style=deepObject on a string |
A body that cannot carry its files, or a TRACE body |
A File field with application/json |
A media type that is not type/subtype, or a repeated media type |
json |
| A path with a query, a fragment or a space | /items?x=1 |
A $ref that points to nothing |
oneof_ref=#/$defs/Missing |
A Raw value that breaks the object it replaces |
A Raw response with no description |
| A URL or an email address that is not valid | Contact.Email: "nobody" |
A list default in a tag, such as default=1 on a []int, becomes [1].
A few path checks need the full path, and the router knows it only after it adds the route. In these cases, the router has the route when the panic occurs:
- A path that a
doc.Bindmount function returns. - A gorilla/mux subrouter prefix or host.
- An empty gorilla/mux path.
Adopt returns an error that names each route with no documentation. It works
with chi, gorilla/mux, Echo, Gin, and Fiber. Use RequireDocumented to make the
same check in a test:
func TestRoutesAreDocumented(t *testing.T) {
specoutgin.RequireDocumented(t, doc, engine, specout.Skip("/openapi.json"))
}With chi, call Adopt on the outermost router after you register the routes.
This also gives specout the full paths of route groups and mounted routers. If
chi serves the document, the build fails when an adopted router is mounted under
a prefix that no server URL declares.
http.ServeMux cannot list its routes. Register each route that needs
documentation with specout.Std.
The optional recorder package records the response statuses of your HTTP tests.
recorder.Verify then checks that each declared status occurred, and that the
document declares each status that occurred. It does not check response bodies.
func TestStatuses(t *testing.T) {
rec := recorder.New(mux)
rec.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/hello/Ada", nil))
rec.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/hello/Ada?language=fr", nil))
recorder.Verify(t, doc, rec)
}With Echo, Gin, and Fiber, use recorder.New(nil) and install the adapter's
Record middleware.
You can pass the production handler, with its middleware, to recorder.New.
Middleware outside the router hides the route of a request, so Verify finds
the route from the request path. A 401 from an auth middleware then counts for
the route. A request for the document itself is not drift.
A handler that hijacks the connection writes its own response. If the handler
wrote no status and the request asks to switch protocols, as in a WebSocket
handshake, the recorder records 101. It records nothing for any other hijack
that wrote no status.
- Runnable examples
- Sample API document
- Types and responses
- Routers and checks
- Configuration and authentication
- OpenAPI 3.1 field coverage
Coding agents can use the optional specout skill:
npx skills add happytoolin/specout --skill specoutFeedback and contributions are welcome, and so are new router adapters. Read the code guide for the file layout.
just check # Run all CI and release checks.
just lint # Check Go lint rules and formatting.
just nilaway # Find code that can panic on nil.
just demo # Run the onboarding example.just check requires Go, Python 3, and Node.js with npm.
