Skip to content
gohacks.dev
APIs

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.

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.

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.

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 generator config asks for client and models instead of a server:

cfg.yamlText
package: main
output: client.gen.go
generate:
  client: true
  models: true
Shell
go 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
Text
     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.

stub.goGo
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.

tasks.goGo
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

main.goGo
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()
}
Text
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=true

The output tells the story.

  • Create and list. The created task came back as a Task with its server-assigned id and the default todo status. A list with limit=1 returned one item, so the query parameter was encoded by the generated code, not by string concatenation.
  • A 404. Get(99) returned api: 404 task not found, IsNotFound was true, and errors.As extracted the *APIError. The caller's code reads if client.IsNotFound(err).
  • A 422. Create("") returned api: 422 title is required. The document declares only a default error 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 Authorization header 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 with json: 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 usual net.Error handling works through the generated layer.

Tests

client_test.goGo
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")
	}
}
Text
=== 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.322s

The 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.

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.