Skip to content
gohacks.dev
APIs

Generate a Go Server From an OpenAPI Spec Step by Step

Generate a Go net/http server from an OpenAPI file with oapi-codegen: config, the interface, validation middleware and what generated code leaves unchecked.

12 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the OpenAPI Initiative logo.

Generating a server from an OpenAPI document trades a little flexibility for a contract that cannot silently diverge. The document says what the routes, parameters and bodies are; the generator writes the Go types and the routing glue; you write only the behavior behind each operation. If the document changes and the code does not, the build fails.

Tested with Go 1.25.6, oapi-codegen v2.8.1, kin-openapi v0.152.0, macOS on Apple Silicon (arm64)

This article does it end to end with oapi-codegen, the most widely used generator for Go, targeting the standard library's net/http router. It writes the spec, generates the code, implements the interface, adds request validation, and then runs the server twice to show what the generated code enforces and what it does not, which is the part tutorials skip. It follows the comparison of code-first and spec-first and leads into generating a typed client. Everything ran on Go 1.25.6 with oapi-codegen v2.8.1.

The document

spec.yamlText
openapi: 3.0.3
info:
  title: Tasks
  version: 1.0.0
paths:
  /v1/tasks:
    get:
      operationId: listTasks
      parameters:
        - name: limit
          in: query
          schema: {type: integer, minimum: 1, maximum: 100, default: 20}
        - name: status
          in: query
          schema: {$ref: '#/components/schemas/Status'}
      responses:
        '200':
          description: a page of tasks
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/Task'}
    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'}
        default:
          $ref: '#/components/responses/Problem'
  /v1/tasks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: integer, format: int64, minimum: 1}
    get:
      operationId: getTask
      responses:
        '200':
          description: the task
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Task'}
        '404':
          $ref: '#/components/responses/Problem'
    delete:
      operationId: deleteTask
      responses:
        '204': {description: deleted}
        '404':
          $ref: '#/components/responses/Problem'
components:
  responses:
    Problem:
      description: an error
      content:
        application/problem+json:
          schema: {$ref: '#/components/schemas/Problem'}
  schemas:
    Status:
      type: string
      enum: [todo, doing, done]
    NewTask:
      type: object
      additionalProperties: false
      required: [title]
      properties:
        title: {type: string, minLength: 1, maxLength: 120}
        status: {$ref: '#/components/schemas/Status'}
    Task:
      type: object
      required: [id, title, status]
      properties:
        id: {type: integer, format: int64}
        title: {type: string}
        status: {$ref: '#/components/schemas/Status'}
    Problem:
      type: object
      required: [title, status]
      properties:
        title: {type: string}
        status: {type: integer}
        detail: {type: string}

The API is the same task list as the earlier articles: list with a limit and a status filter, create, get and delete. Four choices in the document shape the generated code.

  • Status is a named schema with an enum, so the generator emits a Go type with constants and a Valid method instead of a bare string.
  • limit has minimum, maximum and a default. These are in the document, and, as the run shows, they are enforced by the validator, not by generated parsing.
  • NewTask has additionalProperties: false and minLength: 1 on the title, constraints that Go struct types cannot express.
  • Errors use one shared Problem response with the application/problem+json media type, referenced from several operations, in the shape of problem details.

The generator config

cfg.yamlText
package: main
output: api.gen.go
generate:
  std-http-server: true
  models: true
  embedded-spec: true

Four lines of configuration. package: main places the output in the same package as the handlers. generate.std-http-server asks for routing for the standard library's ServeMux, which uses the method-and-wildcard patterns from Go 1.22 (the stdlib router). The same tool also supports chi, gin, echo and others, and you pick one. models writes the schema types and embedded-spec embeds the document in the binary, which is what the validator reads at run time.

Generate

The command pins the generator's version, which is what makes the output reproducible.

Shell
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/[email protected] -config cfg.yaml spec.yaml
wc -l api.gen.go
sed -n '/^type ServerInterface/,/^}/p' api.gen.go
grep -n "^type .* struct" api.gen.go | head -8
Text
     448 api.gen.go
type ServerInterface interface {

	// (GET /v1/tasks)
	ListTasks(w http.ResponseWriter, r *http.Request, params ListTasksParams)

	// (POST /v1/tasks)
	CreateTask(w http.ResponseWriter, r *http.Request)

	// (DELETE /v1/tasks/{id})
	DeleteTask(w http.ResponseWriter, r *http.Request, id int64)

	// (GET /v1/tasks/{id})
	GetTask(w http.ResponseWriter, r *http.Request, id int64)
}
type ServerInterfaceWrapper struct {
	Handler            ServerInterface
	HandlerMiddlewares []MiddlewareFunc
	ErrorHandlerFunc   func(w http.ResponseWriter, r *http.Request, err error)
}
45:type NewTask struct {
51:type Problem struct {
61:type Task struct {
68:type ListTasksParams struct {
93:type ServerInterfaceWrapper struct {
213:type UnescapedCookieParamError struct {
226:type UnmarshalingParamError struct {
239:type RequiredParamError struct {

The generated file is 448 lines. Its important parts:

  • Models. Task, NewTask, Problem, Status and the constants Todo, Doing, Done. The field id became Id, because the generator follows Go's exported-name rule without the initialism handling ID that a person would use, and optional properties such as NewTask.Status are pointers (*Status), so absence is representable.
  • ListTasksParams. A struct with Limit *int and Status *Status, filled from the query string by the wrapper.
  • ServerInterface. One method per operation, with parameters already parsed: GetTask(w, r, id int64). A path segment that is not an integer is rejected before your method runs.
  • ServerInterfaceWrapper and HandlerWithOptions. The glue that registers GET /v1/tasks/{id} and friends on the mux and binds parameters.
  • GetSwagger. The embedded document, loaded for the validator.

The behavior

server.goGo
package main

import (
	"encoding/json"
	"net/http"
	"sort"
	"sync"
)

// Server implements the generated ServerInterface. The generated code parses and type-checks parameters;
// this file holds only behavior.
type Server struct {
	mu    sync.Mutex
	next  int64
	tasks map[int64]Task
}

func NewServer() *Server { return &Server{next: 1, tasks: map[int64]Task{}} }

var _ ServerInterface = (*Server)(nil)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func problem(w http.ResponseWriter, status int, title string) {
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(Problem{Title: title, Status: status})
}

func (s *Server) ListTasks(w http.ResponseWriter, r *http.Request, params ListTasksParams) {
	limit := 20
	if params.Limit != nil {
		limit = *params.Limit
	}
	s.mu.Lock()
	out := []Task{}
	for _, t := range s.tasks {
		if params.Status == nil || t.Status == *params.Status {
			out = append(out, t)
		}
	}
	s.mu.Unlock()
	sort.Slice(out, func(i, j int) bool { return out[i].Id < out[j].Id })
	if len(out) > limit {
		out = out[:limit]
	}
	writeJSON(w, 200, struct {
		Data []Task `json:"data"`
	}{out})
}

func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request) {
	var in NewTask
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		problem(w, 400, "malformed body")
		return
	}
	status := Todo
	if in.Status != nil {
		status = *in.Status
	}
	s.mu.Lock()
	t := Task{Id: s.next, Title: in.Title, Status: status}
	s.tasks[t.Id] = t
	s.next++
	s.mu.Unlock()
	w.Header().Set("Location", "/v1/tasks/"+itoa(t.Id))
	writeJSON(w, 201, t)
}

func (s *Server) GetTask(w http.ResponseWriter, r *http.Request, id int64) {
	s.mu.Lock()
	t, ok := s.tasks[id]
	s.mu.Unlock()
	if !ok {
		problem(w, 404, "task not found")
		return
	}
	writeJSON(w, 200, t)
}

func (s *Server) DeleteTask(w http.ResponseWriter, r *http.Request, id int64) {
	s.mu.Lock()
	_, ok := s.tasks[id]
	delete(s.tasks, id)
	s.mu.Unlock()
	if !ok {
		problem(w, 404, "task not found")
		return
	}
	w.WriteHeader(204)
}

func itoa(n int64) string {
	if n == 0 {
		return "0"
	}
	var b []byte
	for ; n > 0; n /= 10 {
		b = append([]byte{byte('0' + n%10)}, b...)
	}
	return string(b)
}

Server implements the interface with an in-memory map. The line var _ ServerInterface = (*Server)(nil) is the compile-time check: add an operation to the document, regenerate, and the build breaks until you implement it. That is the main benefit of the whole approach, and it costs one line.

Handlers use the generated types for their results. They return bodies with Task and Problem, so a field renamed in the document is a compile error here, not a surprise in production. They do not validate beyond what the types give them. That is the next point.

Wiring and running it twice

main.goGo
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

	nethttpmiddleware "github.com/oapi-codegen/nethttp-middleware"
)

func handler(validate bool) http.Handler {
	spec, err := GetSwagger()
	if err != nil {
		panic(err)
	}
	spec.Servers = nil // match any host, including the test server's
	opts := StdHTTPServerOptions{BaseRouter: http.NewServeMux()}
	if validate {
		opts.Middlewares = []MiddlewareFunc{nethttpmiddleware.OapiRequestValidator(spec)}
	}
	return HandlerWithOptions(NewServer(), opts)
}

func main() {
	for _, validate := range []bool{false, true} {
		srv := httptest.NewServer(handler(validate))
		fmt.Printf("== validation middleware: %v\n", validate)
		do := func(method, path, body string) {
			req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
			req.Header.Set("Content-Type", "application/json")
			res, err := http.DefaultClient.Do(req)
			if err != nil {
				panic(err)
			}
			b, _ := io.ReadAll(res.Body)
			res.Body.Close()
			out := strings.TrimSpace(string(b))
			if len(out) > 90 {
				out = out[:90] + "…"
			}
			fmt.Printf("%-6s %-24s %d %s\n", method, path, res.StatusCode, out)
		}
		do("POST", "/v1/tasks", `{"title":"write"}`)
		do("POST", "/v1/tasks", `{"title":"review","status":"doing"}`)
		do("GET", "/v1/tasks?status=doing", "")
		do("GET", "/v1/tasks/1", "")
		do("GET", "/v1/tasks/99", "")
		do("POST", "/v1/tasks", `{"title":""}`)
		do("POST", "/v1/tasks", `{"title":"x","status":"urgent"}`)
		do("POST", "/v1/tasks", `{"title":"x","colour":"red"}`)
		do("GET", "/v1/tasks?limit=1000", "")
		do("GET", "/v1/tasks/abc", "")
		do("DELETE", "/v1/tasks/1", "")
		do("DELETE", "/v1/tasks/1", "")
		srv.Close()
		fmt.Println()
	}
}
Text
== validation middleware: false
POST   /v1/tasks                201 {"id":1,"status":"todo","title":"write"}
POST   /v1/tasks                201 {"id":2,"status":"doing","title":"review"}
GET    /v1/tasks?status=doing   200 {"data":[{"id":2,"status":"doing","title":"review"}]}
GET    /v1/tasks/1              200 {"id":1,"status":"todo","title":"write"}
GET    /v1/tasks/99             404 {"status":404,"title":"task not found"}
POST   /v1/tasks                201 {"id":3,"status":"todo","title":""}
POST   /v1/tasks                201 {"id":4,"status":"urgent","title":"x"}
POST   /v1/tasks                201 {"id":5,"status":"todo","title":"x"}
GET    /v1/tasks?limit=1000     200 {"data":[{"id":1,"status":"todo","title":"write"},{"id":2,"status":"doing","title":"review…
GET    /v1/tasks/abc            400 Invalid format for parameter id: error binding string parameter: strconv.ParseInt: parsing…
DELETE /v1/tasks/1              204 
DELETE /v1/tasks/1              404 {"status":404,"title":"task not found"}

== validation middleware: true
POST   /v1/tasks                201 {"id":1,"status":"todo","title":"write"}
POST   /v1/tasks                201 {"id":2,"status":"doing","title":"review"}
GET    /v1/tasks?status=doing   200 {"data":[{"id":2,"status":"doing","title":"review"}]}
GET    /v1/tasks/1              200 {"id":1,"status":"todo","title":"write"}
GET    /v1/tasks/99             404 {"status":404,"title":"task not found"}
POST   /v1/tasks                400 request body has an error: doesn't match schema #/components/schemas/NewTask: Error at "/t…
POST   /v1/tasks                400 request body has an error: doesn't match schema #/components/schemas/NewTask: Error at "/s…
POST   /v1/tasks                400 request body has an error: doesn't match schema #/components/schemas/NewTask: property "co…
GET    /v1/tasks?limit=1000     400 parameter "limit" in query has an error: number must be at most 100
GET    /v1/tasks/abc            400 Invalid format for parameter id: error binding string parameter: strconv.ParseInt: parsing…
DELETE /v1/tasks/1              204 
DELETE /v1/tasks/1              404 {"status":404,"title":"task not found"}

The program starts the server twice, first with no middleware and then with nethttp-middleware's OapiRequestValidator built from the embedded spec, and sends the same twelve requests to each.

The first four requests behave the same in both runs: tasks are created, the status filter works, GET /v1/tasks/99 is a 404 problem document, and abc as an id is a 400 from the generated binder.

The difference is in the next four.

RequestWithout validatorWith validator
POST {"title":""}201, an empty title is stored400, error at /title
POST {"status":"urgent"}201, an invalid status is stored400, error at /status
POST {"title":"x","colour":"red"}201, unknown field ignored400, property "colour" unsupported
GET ?limit=1000200, ignores the maximum400, number must be at most 100

That is the finding. Generated code type-checks the input. It makes limit an int and id an int64, and the JSON decoder makes Title a string. It does not enforce minLength, enum on a plain string, maximum, or additionalProperties. Those constraints are in the document and nothing reads them until you add the validator. A server built from a spec without it is more permissive than its own documentation, which is a quiet way to ship an API that accepts garbage.

The validator is a middleware that finds the operation for each request, validates parameters and body against the document, and answers 400 on failure. Two points on using it for real:

  • Its error body is plain text. By default the failure message came back as text, not as application/problem+json. Provide an error handler in the middleware options that writes a problem document, so the contract applies to errors too.
  • Set spec.Servers = nil. The validator matches the request host against the document's servers list unless you clear it, which is why the program does so. In production, either list your real hosts or clear it behind a router that has already matched the host.

Tests

server_test.goGo
package main

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func post(h http.Handler, body string) *httptest.ResponseRecorder {
	req := httptest.NewRequest("POST", "/v1/tasks", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestGeneratedServerAloneDoesNotEnforceMinLength(t *testing.T) {
	if rec := post(handler(false), `{"title":""}`); rec.Code != 201 {
		t.Fatalf("expected the generated code to accept it, got %d", rec.Code)
	}
}

func TestValidatorEnforcesTheDocument(t *testing.T) {
	h := handler(true)
	for _, body := range []string{`{"title":""}`, `{"title":"x","status":"urgent"}`, `{"title":"x","colour":"red"}`, ``} {
		if rec := post(h, body); rec.Code != 400 {
			t.Errorf("%q -> %d, want 400", body, rec.Code)
		}
	}
}

func TestCreateThenGetThroughGeneratedRouting(t *testing.T) {
	h := handler(true)
	if rec := post(h, `{"title":"write","status":"doing"}`); rec.Code != 201 {
		t.Fatalf("create: %d", rec.Code)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/tasks/1", nil))
	if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"status":"doing"`) {
		t.Fatalf("get: %d %s", rec.Code, rec.Body.String())
	}
}
Text
=== RUN   TestGeneratedServerAloneDoesNotEnforceMinLength
--- PASS: TestGeneratedServerAloneDoesNotEnforceMinLength (0.01s)
=== RUN   TestValidatorEnforcesTheDocument
--- PASS: TestValidatorEnforcesTheDocument (0.01s)
=== RUN   TestCreateThenGetThroughGeneratedRouting
--- PASS: TestCreateThenGetThroughGeneratedRouting (0.01s)
PASS
ok  	example.test/lab	1.365s

The tests pin the lesson. The first asserts that the generated server alone accepts an empty title, which documents the gap and will fail, usefully, if a generator update starts enforcing it. The second asserts that the validated handler refuses it. The third is a round trip: create, then fetch and compare, through the generated routing.

Working with generated code

Commit it, and regenerate in CI. A checked-in api.gen.go makes the repository build without the tool; a CI step that regenerates and runs git diff --exit-code fails if the file is stale. Pin the generator version in the command, or in go.mod as a tool dependency (go get -tool), so every machine produces the same bytes. Put the command in a //go:generate comment next to main.

Never edit the generated file. Add behavior in your own files. Where the generator's output is wrong for you, change the spec, the config (it has options for naming and for extensions such as x-go-type) or a template.

Consider the strict server. The tool can generate a "strict" interface where each operation takes a typed request object and returns a typed response object, so GetTask returns GetTask200JSONResponse or GetTask404ApplicationProblemPlusJSONResponse and the compiler checks that you return only documented responses. It trades the http.ResponseWriter freedom for a tighter contract. Try it on a new service.

Know the limits. Complex schemas (oneOf, allOf with discriminators, recursive types) generate awkward Go, and are where the tool needs the most configuration. Prefer simpler schemas in the API, which also helps every client.

Pick the validator's cost deliberately. It parses the document once and then validates each request, which adds some CPU. For a public API this is cheap insurance; measure with your own routes before deciding to skip it (benchmarking handlers).

The generator is documented at pkg.go.dev, and the standard library routing it targets is described in the net/http package.

Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.