Generate a Typed Go Client From an OpenAPI Document
Generate a typed Go client from an OpenAPI file with oapi-codegen, then wrap it: auth by request editor, Go errors from problem documents and a drift check.

Hand-written API clients decay. Someone adds a field to the server, forgets the client, and a month later a script is reading map[string]any and checking status codes it copied from the docs. A generated client removes that class of problem: the client is built from the same document the server is checked against, so a renamed field is a compile error, and an operation cannot be called with the wrong parameter types.
Tested with Go 1.25.6, oapi-codegen v2.8.1, macOS on Apple Silicon (arm64)
This article generates a client with oapi-codegen from the task API's OpenAPI document, then wraps it in the thin layer that makes it pleasant to use, and runs it against a stub server. It builds on the server generation article and the comparison of code-first and spec-first, and its error handling follows problem details. Everything ran on Go 1.25.6 with oapi-codegen v2.8.1.
The inputs
The document is the one from the server article, repeated here so the lab runs on its own.
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 generator config asks for client and models instead of a server:
package: main
output: client.gen.go
generate:
client: true
models: truego run github.com/oapi-codegen/oapi-codegen/v2/cmd/[email protected] -config cfg.yaml spec.yaml
wc -l client.gen.go
grep -n "^func (c \*ClientWithResponses)" client.gen.go | sed 's/(ctx context.Context, //; s/ ...RequestEditorFn.*//'
sed -n '/^type CreateTaskResponse struct/,/^}/p' client.gen.go 833 client.gen.go
660:func (c *ClientWithResponses) ListTasksWithResponseparams *ListTasksParams, reqEditors
672:func (c *ClientWithResponses) CreateTaskWithBodyWithResponsecontentType string, body io.Reader, reqEditors
682:func (c *ClientWithResponses) CreateTaskWithResponsebody CreateTaskJSONRequestBody, reqEditors
693:func (c *ClientWithResponses) DeleteTaskWithResponseid int64, reqEditors
704:func (c *ClientWithResponses) GetTaskWithResponseid int64, reqEditors
type CreateTaskResponse struct {
Body []byte
HTTPResponse *http.Response
// JSON201 the response for an HTTP 201 `application/json` response
JSON201 *Task
// ApplicationproblemJSONDefault the response for an HTTP default `application/problem+json` response
ApplicationproblemJSONDefault *Problem
}The generated file is 833 lines. Two types matter.
Client is the low-level one. Each method takes the parameters and returns an *http.Response, leaving you to read the body. ClientWithResponses is the one to use. Each method has a WithResponse suffix and returns a typed struct with the raw Body, the HTTPResponse, and one field per documented response. For CreateTask that is JSON201 *Task for the success and ApplicationproblemJSONDefault *Problem for the shared error response. A caller checks which field is non-nil; the generated code has already decoded the body into the right struct, based on the status code and the content type.
Every method accepts ...RequestEditorFn, and the constructor accepts WithRequestEditorFn and WithHTTPClient. Those three hooks cover nearly everything you need to customise.
A stub server
The program needs something to call. This stub speaks the contract, checks for a bearer token before routing, and has one route that deliberately violates the document: GET /v1/tasks/666 returns the id as a string.
package main
import (
"encoding/json"
"net/http"
"strings"
"sync"
)
// stubServer is a stand-in for the real service: it speaks the contract in spec.yaml and checks the bearer token.
func stubServer() http.Handler {
var mu sync.Mutex
tasks := map[int64]Task{}
next := int64(1)
mux := http.NewServeMux()
write := func(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
prob := func(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})
}
mux.HandleFunc("POST /v1/tasks", func(w http.ResponseWriter, r *http.Request) {
var in NewTask
if json.NewDecoder(r.Body).Decode(&in) != nil || in.Title == "" {
prob(w, 422, "title is required")
return
}
st := Todo
if in.Status != nil {
st = *in.Status
}
mu.Lock()
t := Task{Id: next, Title: in.Title, Status: st}
tasks[t.Id] = t
next++
mu.Unlock()
write(w, 201, t)
})
mux.HandleFunc("GET /v1/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
var id int64
for _, c := range r.PathValue("id") {
id = id*10 + int64(c-'0')
}
mu.Lock()
t, ok := tasks[id]
mu.Unlock()
if id == 666 { // a server that drifted from the contract: id is a string
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"666","title":"drift","status":"todo"}`))
return
}
if !ok {
prob(w, 404, "task not found")
return
}
write(w, 200, t)
})
mux.HandleFunc("GET /v1/tasks", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
out := []Task{}
for i := int64(1); i < next; i++ {
out = append(out, tasks[i])
}
mu.Unlock()
if l := r.URL.Query().Get("limit"); l == "1" && len(out) > 1 {
out = out[:1]
}
write(w, 200, map[string]any{"data": out})
})
// Everything needs a token, checked before routing.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
prob(w, 401, "missing bearer token")
return
}
mux.ServeHTTP(w, r)
})
}The wrapper
Using ClientWithResponses directly works but pushes three chores onto every caller: adding credentials, deciding what a non-2xx answer means, and creating an http.Client with sensible limits. One small type does all of that once.
package main
import (
"context"
"errors"
"fmt"
"net/http"
)
// APIError is what callers see for any non-2xx answer, with the server's problem document attached when it sent one.
type APIError struct {
Status int
Problem *Problem
}
func (e *APIError) Error() string {
if e.Problem != nil {
return fmt.Sprintf("api: %d %s", e.Status, e.Problem.Title)
}
return fmt.Sprintf("api: %d", e.Status)
}
// Tasks wraps the generated client with the behavior every caller wants: Go errors, not status codes.
type Tasks struct{ c *ClientWithResponses }
func NewTasks(baseURL, token string, hc *http.Client) (*Tasks, error) {
c, err := NewClientWithResponses(baseURL,
WithHTTPClient(hc),
WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "tasks-client/1.0")
return nil
}))
if err != nil {
return nil, err
}
return &Tasks{c}, nil
}
func (t *Tasks) Create(ctx context.Context, title string) (Task, error) {
resp, err := t.c.CreateTaskWithResponse(ctx, NewTask{Title: title})
if err != nil {
return Task{}, err
}
if resp.JSON201 == nil {
return Task{}, &APIError{resp.StatusCode(), resp.ApplicationproblemJSONDefault}
}
return *resp.JSON201, nil
}
func (t *Tasks) Get(ctx context.Context, id int64) (Task, error) {
resp, err := t.c.GetTaskWithResponse(ctx, id)
if err != nil {
return Task{}, err
}
if resp.JSON200 == nil {
return Task{}, &APIError{resp.StatusCode(), resp.ApplicationproblemJSON404}
}
return *resp.JSON200, nil
}
func (t *Tasks) List(ctx context.Context, limit int) ([]Task, error) {
resp, err := t.c.ListTasksWithResponse(ctx, &ListTasksParams{Limit: &limit})
if err != nil {
return nil, err
}
if resp.JSON200 == nil {
return nil, &APIError{Status: resp.StatusCode()}
}
return resp.JSON200.Data, nil
}
// IsNotFound lets callers branch without comparing numbers.
func IsNotFound(err error) bool {
var ae *APIError
return errors.As(err, &ae) && ae.Status == http.StatusNotFound
}Credentials are added by a request editor passed to the constructor, so no call site can forget them. The same hook is the place for a User-Agent, tracing headers or an idempotency key (idempotency keys).
The HTTP client is yours. WithHTTPClient takes any *http.Client, so timeouts, transports and proxies are the usual ones. The default client has no timeout at all, which is not a setting to ship; HTTP client timeouts lists the others worth setting.
Errors are Go errors. Create, Get and List return (Task, error). A non-2xx answer becomes an *APIError holding the status and, when the server sent one, the decoded Problem. Callers use errors.As or a helper like IsNotFound, and never compare numbers. The wrapper reads the typed field that matches each operation: JSON201 for create, JSON200 for get and list, and the problem field the document declares.
Notice what the wrapper does not do: it does not retry. A generic retry loop belongs in the http.Client's transport or a request wrapper where it can treat idempotent and non-idempotent methods differently (retries).
Using it
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func main() {
srv := httptest.NewServer(stubServer())
defer srv.Close()
ctx := context.Background()
hc := &http.Client{Timeout: 3 * time.Second}
api, _ := NewTasks(srv.URL, "secret-token", hc)
a, err := api.Create(ctx, "write the article")
fmt.Printf("create: %+v err=%v\n", a, err)
api.Create(ctx, "review it")
list, err := api.List(ctx, 1)
fmt.Printf("list limit=1: %d item(s) err=%v\n", len(list), err)
_, err = api.Get(ctx, 99)
var ae *APIError
fmt.Printf("get 99: err=%v notFound=%v asAPIError=%v\n", err, IsNotFound(err), errors.As(err, &ae))
_, err = api.Create(ctx, "")
fmt.Printf("create \"\": err=%v\n", err)
// The generated client alone sends no credentials: the request editor in NewTasks is what adds them.
raw, _ := NewClientWithResponses(srv.URL, WithHTTPClient(hc))
resp, _ := raw.GetTaskWithResponse(ctx, 1)
fmt.Printf("no credentials: status=%d body=%s", resp.StatusCode(), resp.Body)
_, err = api.Get(ctx, 666)
fmt.Printf("server drifted: err=%v\n", err)
short, _ := NewTasks(srv.URL, "t", &http.Client{Timeout: time.Nanosecond})
_, err = short.Get(ctx, 1)
fmt.Printf("1ns timeout: timeout=%v\n", isTimeout(err))
}
func isTimeout(err error) bool {
var t interface{ Timeout() bool }
return errors.As(err, &t) && t.Timeout()
}create: {Id:1 Status:todo Title:write the article} err=<nil>
list limit=1: 1 item(s) err=<nil>
get 99: err=api: 404 task not found notFound=true asAPIError=true
create "": err=api: 422 title is required
no credentials: status=401 body={"status":401,"title":"missing bearer token"}
server drifted: err=json: cannot unmarshal string into Go struct field Task.id of type int64
1ns timeout: timeout=trueThe output tells the story.
- Create and list. The created task came back as a
Taskwith its server-assigned id and the defaulttodostatus. A list withlimit=1returned one item, so the query parameter was encoded by the generated code, not by string concatenation. - A 404.
Get(99)returnedapi: 404 task not found,IsNotFoundwas true, anderrors.Asextracted the*APIError. The caller's code readsif client.IsNotFound(err). - A 422.
Create("")returnedapi: 422 title is required. The document declares only adefaulterror response, and the generated code decoded the problem document into it, so the wrapper reports the server's own title. - No credentials. The raw generated client sent no
Authorizationheader and got a 401 with the problem body. The wrapper never has this problem, because its editor adds the token to every request. - A drifted server. For id 666 the stub returned
"id":"666". The client failed withjson: cannot unmarshal string into Go struct field Task.id of type int64. That is the contract being enforced from the client side: a server that stops matching the document is noticed by the first typed caller, with the offending field named. Running a handful of such calls in a post-deploy check gives you a cheap contract test. - A timeout. A client with a 1 ns timeout produced an error whose
Timeout()method returned true, so the usualnet.Errorhandling works through the generated layer.
Tests
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newAPI(t *testing.T) (*Tasks, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(stubServer())
t.Cleanup(srv.Close)
api, err := NewTasks(srv.URL, "tok", &http.Client{Timeout: 2 * time.Second})
if err != nil {
t.Fatal(err)
}
return api, srv
}
func TestCreateThenGet(t *testing.T) {
api, _ := newAPI(t)
made, err := api.Create(context.Background(), "write")
if err != nil || made.Status != Todo {
t.Fatalf("%+v %v", made, err)
}
got, err := api.Get(context.Background(), made.Id)
if err != nil || got != made {
t.Fatalf("%+v %v", got, err)
}
}
func TestErrorsCarryTheProblemDocument(t *testing.T) {
api, _ := newAPI(t)
_, err := api.Get(context.Background(), 12345)
var ae *APIError
if !errors.As(err, &ae) || ae.Status != 404 || ae.Problem == nil || ae.Problem.Title != "task not found" || !IsNotFound(err) {
t.Fatalf("got %#v", err)
}
_, err = api.Create(context.Background(), "")
if !errors.As(err, &ae) || ae.Status != 422 {
t.Fatalf("got %#v", err)
}
}
func TestEveryCallCarriesTheBearerToken(t *testing.T) {
var seen string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()
api, _ := NewTasks(srv.URL, "abc123", srv.Client())
if _, err := api.List(context.Background(), 5); err != nil || seen != "Bearer abc123" {
t.Fatalf("seen=%q err=%v", seen, err)
}
}
func TestDriftIsReportedAsADecodeError(t *testing.T) {
api, _ := newAPI(t)
if _, err := api.Get(context.Background(), 666); err == nil {
t.Fatal("expected a decode error")
}
}=== RUN TestCreateThenGet
--- PASS: TestCreateThenGet (0.00s)
=== RUN TestErrorsCarryTheProblemDocument
--- PASS: TestErrorsCarryTheProblemDocument (0.00s)
=== RUN TestEveryCallCarriesTheBearerToken
--- PASS: TestEveryCallCarriesTheBearerToken (0.00s)
=== RUN TestDriftIsReportedAsADecodeError
--- PASS: TestDriftIsReportedAsADecodeError (0.00s)
PASS
ok example.test/lab 1.322sThe tests exercise the wrapper against the stub: the APIError mapping for a 404 and a 422, the bearer header reaching the server, and the typed decode of a created task. In a real project the stub is replaced by your actual server under test, so the client and the server are compiled from the same document and exercised together. That pairing is the whole point.
Working with a generated client
Version the document and regenerate on change. Keep the spec in the repository that owns the API, publish it with each release, and have client consumers regenerate in CI. A pinned generator version keeps the output stable. If the spec is published at a URL, oapi-codegen can read it from there.
Do not edit the generated file. Add convenience in a separate wrapper, as above. Where the generator's naming is wrong (Id for an identifier), fix the document or use the tool's extension attributes.
Check how it treats optional and nullable values. Optional properties become pointers, which is accurate and awkward. Decide on helper functions (ptr(5)) and keep them in one place.
Distribute carefully. If other teams use the client, put it in its own module and tag releases. Changing the document in a breaking way is then a version bump with a visible diff (API versioning).
Know the alternatives. ogen generates both client and server with its own encoder for speed and a stricter model; hand-written clients are fine for two or three calls; OpenAPI Generator produces Go too, with a different style. The choice is about how much of the generated surface you want to own.
The generator is at pkg.go.dev, and the standard library pieces used here are net/http's Client and errors.As.


