OpenAPI for Go Services Generated From Code or From Spec
Compare code-first and spec-first OpenAPI in Go with working code: huma generating the spec from types, and kin-openapi catching bad requests and drift.

An API that has no machine-readable description is an API that every client developer has to reverse-engineer from examples. OpenAPI is the standard description: a document listing the paths, parameters, request and response bodies, errors and security schemes. From it you get documentation, generated clients, mock servers, request validation and contract tests. The question for a Go service is where the document comes from, and there are two answers with different consequences.
Tested with Go 1.25.6, huma v2.39.1 and kin-openapi v0.152.0, macOS on Apple Silicon (arm64)
Code first means you write Go types and handlers, and a library generates the OpenAPI document from them. Spec first (also called design first or contract first) means you write the document and either generate Go from it or check your Go code against it. This article runs both on the same small endpoint, with real libraries, shows what each guarantees and where each can drift, and gives a way to choose. It follows the JSON API cornerstone, which documents its contract by hand, and leads into generating a server, generating a client and serving the documentation. Everything ran on Go 1.25.6 with huma v2.39.1 and kin-openapi v0.152.0.
The two directions
| Code first | Spec first | |
|---|---|---|
| Source of truth | Go types and handlers | The OpenAPI document |
| Spec is | generated, always in sync | written, reviewed, versioned |
| Drift risk | low by construction | real; needs a contract test |
| Strength | One place to edit, fast to start | A contract other teams can review before code exists |
| Weakness | The spec reflects Go idioms and accidents | Two artifacts to keep in step |
| Tools in this article | huma | kin-openapi (and oapi-codegen in the next article) |
Code first with huma
huma lets you describe an operation with typed input and output structs, and uses struct tags to declare constraints. The framework validates incoming requests against those constraints before your handler runs, and builds the OpenAPI 3.1 document from the same types.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
)
type CreateTaskInput struct {
Body struct {
Title string `json:"title" minLength:"1" maxLength:"120" doc:"What needs doing"`
Priority string `json:"priority,omitempty" enum:"low,normal,high" default:"normal"`
}
}
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Priority string `json:"priority"`
}
type TaskOutput struct{ Body Task }
func codeFirst() {
mux := http.NewServeMux()
cfg := huma.DefaultConfig("Tasks", "1.0.0")
cfg.CreateHooks = nil // drop the $schema field from responses so the body is exactly the struct
api := humago.New(mux, cfg)
huma.Register(api, huma.Operation{OperationID: "create-task", Method: http.MethodPost, Path: "/v1/tasks", DefaultStatus: 201},
func(ctx context.Context, in *CreateTaskInput) (*TaskOutput, error) {
p := in.Body.Priority
if p == "" {
p = "normal"
}
return &TaskOutput{Body: Task{ID: 1, Title: in.Body.Title, Priority: p}}, nil
})
call := func(label, body string) {
req := httptest.NewRequest("POST", "/v1/tasks", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("%-16s %d %s", label, rec.Code, rec.Body.String())
}
call("valid", `{"title":"write"}`)
call("empty title", `{"title":""}`)
call("bad enum", `{"title":"x","priority":"urgent"}`)
call("unknown field", `{"title":"x","colour":"red"}`)
oapi := api.OpenAPI()
op := oapi.Paths["/v1/tasks"].Post
var codes []string
for c := range op.Responses {
codes = append(codes, c)
}
sort.Strings(codes)
fmt.Printf("\ngenerated: operationId=%s responses=%v\n", op.OperationID, codes)
schema, _ := json.Marshal(oapi.Components.Schemas.Map()["CreateTaskInputBody"])
fmt.Printf("generated request schema: %s\n", schema)
}The input struct has a Body with Title and Priority. The tags are the contract: minLength:"1" and maxLength:"120" bound the title, enum:"low,normal,high" limits priority, and default:"normal" documents what happens when it is omitted. The handler is a plain function from *CreateTaskInput to *TaskOutput. Nothing in it validates anything, because by the time it runs the input has been checked.
One configuration line deserves a mention: cfg.CreateHooks = nil. By default huma adds a $schema property to response bodies that links to the schema, which is useful to some tools and surprising to anyone who expects the body to be exactly the struct. Removing the hook keeps responses to the declared fields. Read a generator's defaults before you ship; each has some.
Spec first with kin-openapi
For the other direction, the document comes first. Here it is, written by hand for the same endpoint:
package main
const spec = `
openapi: 3.0.3
info: {title: Tasks, version: 1.0.0}
paths:
/v1/tasks:
post:
operationId: createTask
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/NewTask'}
responses:
'201':
description: created
content:
application/json:
schema: {$ref: '#/components/schemas/Task'}
components:
schemas:
NewTask:
type: object
additionalProperties: false
required: [title]
properties:
title: {type: string, minLength: 1, maxLength: 120}
priority: {type: string, enum: [low, normal, high]}
Task:
type: object
additionalProperties: false
required: [id, title]
properties:
id: {type: integer}
title: {type: string}
priority: {type: string, enum: [low, normal, high]}
`Two details are worth copying. additionalProperties: false on both schemas makes the contract strict: unknown request fields are errors and unexpected response fields are drift. And the response schema marks id and title as required, which turns a handler that forgets one into a test failure.
kin-openapi loads the document, validates it, builds a router from its paths and checks requests, and responses, against the schemas.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers/legacy"
)
func specFirst() {
ctx := context.Background()
doc, err := openapi3.NewLoader().LoadFromData([]byte(spec))
if err != nil {
panic(err)
}
if err := doc.Validate(ctx); err != nil {
panic(err)
}
router, err := legacy.NewRouter(doc)
if err != nil {
panic(err)
}
check := func(label, method, path, body string) {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
route, params, err := router.FindRoute(req)
if err != nil {
fmt.Printf("%-26s no such route: %v\n", label, err)
return
}
err = openapi3filter.ValidateRequest(ctx, &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route})
if err != nil {
fmt.Printf("%-26s rejected: %v\n", label, firstLine(err.Error()))
return
}
fmt.Printf("%-26s ok\n", label)
}
check("valid", "POST", "/v1/tasks", `{"title":"write","priority":"high"}`)
check("empty title", "POST", "/v1/tasks", `{"title":""}`)
check("bad enum", "POST", "/v1/tasks", `{"title":"x","priority":"urgent"}`)
check("unknown field", "POST", "/v1/tasks", `{"title":"x","colour":"red"}`)
check("missing body", "POST", "/v1/tasks", ``)
check("unknown path", "POST", "/v1/jobs", `{}`)
// Response drift: the handler returns something the spec does not describe.
respond := func(label, out string) {
req := httptest.NewRequest("POST", "/v1/tasks", strings.NewReader(`{"title":"x"}`))
req.Header.Set("Content-Type", "application/json")
route, params, _ := router.FindRoute(req)
in := &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route, Options: &openapi3filter.Options{ExcludeRequestBody: true}}
h := http.Header{"Content-Type": {"application/json"}}
err := openapi3filter.ValidateResponse(ctx, &openapi3filter.ResponseValidationInput{
RequestValidationInput: in, Status: 201, Header: h, Body: io.NopCloser(strings.NewReader(out)),
})
if err != nil {
fmt.Printf("%-26s drift: %v\n", label, firstLine(err.Error()))
return
}
fmt.Printf("%-26s matches the spec\n", label)
}
fmt.Println()
respond("response as documented", `{"id":1,"title":"x"}`)
respond("extra field added", `{"id":1,"title":"x","owner":"ada"}`)
respond("required field dropped", `{"title":"x"}`)
respond("id became a string", `{"id":"1","title":"x"}`)
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}specFirst loads and validates the document, creates a router, and then does two jobs. check finds the route for a request and runs ValidateRequest, the thing you would put in middleware. respond runs ValidateResponse on a body the handler produced, the thing you would put in a contract test. The helper at the bottom trims the library's multi-line error to its first line for readability.
package main
import "fmt"
func main() {
fmt.Println("== code first: Go types are the source, the spec is generated")
codeFirst()
fmt.Println("\n== spec first: the document is the source, the code is checked against it")
specFirst()
}== code first: Go types are the source, the spec is generated
valid 201 {"id":1,"title":"write","priority":"normal"}
empty title 422 {"title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"message":"expected length >= 1","location":"body.title","value":""}]}
bad enum 422 {"title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"message":"expected value to be one of \"low, normal, high\"","location":"body.priority","value":"urgent"}]}
unknown field 422 {"title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"message":"unexpected property","location":"body.colour","value":{"colour":"red","title":"x"}}]}
generated: operationId=create-task responses=[201 default]
generated request schema: {"additionalProperties":false,"properties":{"priority":{"default":"normal","enum":["low","normal","high"],"type":"string"},"title":{"description":"What needs doing","maxLength":120,"minLength":1,"type":"string"}},"required":["title"],"type":"object"}
== spec first: the document is the source, the code is checked against it
valid ok
empty title rejected: request body has an error: doesn't match schema #/components/schemas/NewTask: Error at "/title": minimum string length is 1
bad enum rejected: request body has an error: doesn't match schema #/components/schemas/NewTask: Error at "/priority": value is not one of the allowed values ["low","normal","high"]
unknown field rejected: request body has an error: doesn't match schema #/components/schemas/NewTask: property "colour" is unsupported
missing body rejected: request body has an error: value is required but missing
unknown path no such route: no matching operation was found
response as documented matches the spec
extra field added drift: response body doesn't match schema #/components/schemas/Task: property "owner" is unsupported
required field dropped drift: response body doesn't match schema #/components/schemas/Task: Error at "/id": property "id" is missing
id became a string drift: response body doesn't match schema #/components/schemas/Task: Error at "/id": value must be an integerWhat the two produced
The output is long enough to read in two halves.
Code first. The valid request created a task, and the default priority normal was applied by the handler logic. The three bad requests all came back as 422 with a problem document and a precise location: body.title for the empty title (expected length >= 1), body.priority for the bad enum (expected value to be one of "low, normal, high") and body.colour for the unknown property (unexpected property). None of that is code in the handler. It comes from the tags.
The generated description lists the operation create-task with two responses, 201 and default (the shared error model), and the request schema is exactly the constraints written in tags: additionalProperties:false, required:["title"], the enum with its default, and the bounds on the title. A reader of the YAML sees what the Go author wrote. That is the code-first promise, and it holds for as long as the types are the only definition.
Spec first. The same valid request passes. The empty title is rejected with minimum string length is 1 at /title, the bad enum with the allowed values listed, the unknown field with property "colour" is unsupported, and a missing body with value is required but missing. A request to a path the document does not define gets no matching operation was found, which a middleware can turn into a 404.
The second block is about drift. The first response matches. Then three variations that a refactor could produce: an extra field owner is unsupported, a missing id is property "id" is missing, and "id":"1" fails with value must be an integer. All three are bugs a client would hit, and none is a compile error. A test that records what the real handler returns for each documented operation and validates it against the document catches them before release.
Choosing
Choose code first when:
- One team owns the service and its clients, and the Go types are the natural description.
- You want validation, documentation and the spec from a single declaration, and you accept the framework's conventions in exchange.
- You are building a new service and speed matters more than a negotiated contract.
Choose spec first when:
- Several teams or languages must agree on a contract before anyone writes the implementation. A reviewed document is a better place to argue about field names than a pull request.
- The document is the product: a public API, a partner integration, a compliance requirement.
- You want to generate clients and servers from one file (server generation, client generation).
Hybrid practice that works: write the spec first for the design review, generate server stubs, and from then on treat the document and the code as co-owned, with a contract test that fails when they differ. The test is the thing that keeps the arrangement honest, as in the response validation above.
Pitfalls
A generated spec reflects your Go accidents. A pointer field becomes nullable, a time.Time becomes a date-time string, an embedded struct flattens. Review the generated YAML like code, and diff it in CI so a refactor that changes the public contract shows up in a pull request (backward compatibility).
An unvalidated spec rots. A hand-written document that nothing checks drifts within weeks. Load it in a test, as specFirst does (doc.Validate), validate sample requests and responses, and fail the build.
Versions of OpenAPI differ. The code-first output here is 3.1, which aligns schemas with JSON Schema, while the hand-written document is 3.0.3. Tools support them unevenly, particularly generators. Check what your toolchain accepts before choosing.
Errors are part of the contract. Declare the error responses, and use a shared schema, preferably problem details. A spec with only success responses documents half the API.
Validation in the framework is not authorization. Schema validation says a request is well formed; it does not say the caller may make it. Keep authentication and business rules in the handler.
Don't validate responses in production by default. It costs CPU and turns a documentation bug into an outage. Run it in tests and staging, or sample.
The OpenAPI specification itself is at spec.openapis.org.


