Sep 27, 2024#side-projects·#productivity·#golang

Streamlining Go + Chi Development: Generating Code from an OpenAPI Spec

That title is a mouthful. And up until a few weeks ago, I wouldn't have known what half those things are. I've been learning Go for a few weeks and recently, I've started building a side project with it. In this post, I'll talk about how I'm building a RESTful API by generating Go code from an OpenAPI specification.

For this project, I wanted:

  1. a well-defined collection of API endpoints
  2. to automatically generate documentation for those endpoints
  3. to have one source of truth so I'm not constantly going back and forth between code and documentation, keeping them in sync
  4. to do something that I've never done before — write an OpenAPI spec

Coincidentally, around the same time, I was listening to the Go Time podcast and one of the episodes featured Jamie Tanna, one of the maintainers of oapi-codegen. This was perfect and was exactly what I was looking for.

So roughly speaking, here's how things are going to be set up:

  • Write an OpenAPI spec in JSON or YAML
  • Write the configuration for oapi-codegen - like where to put the generated code, what router I'm using, etc
  • Write a generate.go file which will use the above files and generate a bunch of boilerplate code for my API endpoints
  • Implement the handlers for each of my endpoints

Let's get started.

Setting up the project and directory structure

Create a directory for the project and initialize a new Go module

mkdir my-chi-project
cd my-chi-project
go mod init my-chi-project
go get -u github.com/go-chi/chi/v5

Here's how I'm going to structure my directories. If you have different preferences for how to organize Go projects, then go for it. Pun totally intended :D

The concepts will apply no matter how you organize your files.

- api # oapi-codegen configuration, generation script, and generated code
- cmd/web # server entry point, route handlers and middleware implementations
- internal/data # connecting to database, model definitions
- tools/tools.go # managing versions for Go tools, like oapi-codegen
- go.mod
- go.sum
- openapi.yaml # OpenAPI specifications for our endpoints

Let's set up a simple http server with Chi. In cmd/web/main.go:

package main

import (
    "log/slog"
    "net/http"

    "github.com/go-chi/chi/v5"
)

func main() {
    router := chi.NewRouter()

	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
	logger.Info("starting server", "addr", ":3000")

    http.ListenAndServe(":3000", router)
}

Once we setup codegen, we'll expand on our http server.

Setting up code generation

The first step is to write an OpenAPI spec. I won't go into details into how to write OpenAPI specs since there are plenty of better resources to learn how to write them. I've never written one before, so I picked it up from the oapi-codegen docs and examples. It's just YAML and most of it is self-explanatory if you've already written RESTful APIs. Let's start with a simple version of our spec in openapi.yaml.

openapi: "3.0.0"
info:
  title: OAPI-Codegen Example
  version: 0.1.0
servers:
  - url: 'http://localhost:4000'
    description: Local Development Server
paths:
  "/health":
    get:
      summary: Health Endpoint
      operationId: get-health
      responses:
        200:
          description: Return OK if server is up and running

We're going to use the tools.go pattern for managing the oapi-codegen version. So create a tools/tools.go file and add this code. Any other helper tools (like linters) you want for your project can also be added here.

//go:build tools
// +build tools

package main

import (
	_ "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen"
)

Now let's install oapi-codegen :

go get github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen

Now create an api directory in the project root for our oapi-codegen configuration, generate.go file, and the generated code file. Let's start with generate.go:

package api

//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=oapi-codegen.yaml ../openapi.yaml

And then the oapi-codegen.yaml:

# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json

generate:
  chi-server: true
output: server.gen.go
package: api

Now all we have to do is run the generate command:

go generate ./...

This will create a new server.gen.go in our api directory that will look like this. There's quite a lot of code here but here are the relevant parts:

// Package api provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.1-0.20240823215434-d232e9efa9f5 DO NOT EDIT.
package api

...

// ServerInterface represents all server handlers.
type ServerInterface interface {
	// Health Endpoint
	// (GET /health)
	GetHealth(w http.ResponseWriter, r *http.Request)
}

type Unimplemented struct{}

// Health Endpoint
// (GET /health)
func (_ Unimplemented) GetHealth(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusNotImplemented)
}

...

type ChiServerOptions struct {
	BaseURL          string
	BaseRouter       chi.Router
	Middlewares      []MiddlewareFunc
	ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
}

// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux.
func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler {
	return HandlerWithOptions(si, ChiServerOptions{
		BaseRouter: r,
	})
}

func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler {
	return HandlerWithOptions(si, ChiServerOptions{
		BaseURL:    baseURL,
		BaseRouter: r,
	})
}

// HandlerWithOptions creates http.Handler with additional options
func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler {
	r := options.BaseRouter

	if r == nil {
		r = chi.NewRouter()
	}
	if options.ErrorHandlerFunc == nil {
		options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) {
			http.Error(w, err.Error(), http.StatusBadRequest)
		}
	}
	wrapper := ServerInterfaceWrapper{
		Handler:            si,
		HandlerMiddlewares: options.Middlewares,
		ErrorHandlerFunc:   options.ErrorHandlerFunc,
	}

	r.Group(func(r chi.Router) {
		r.Get(options.BaseURL+"/health", wrapper.GetHealth)
	})

	return r
}

As we can see, oapi-codegen has generated a ServerInterface interface, an Unimplemented struct with a GetHealth method, and some code for running the GetHealth handler which we have to implement.

Using the generated code in our Chi router

Let's go back to cmd/api/main.go and update the main() : 

package main

...

// create an Application struct
// add dependencies you want to access in your route handlers here
type Application struct {
	logger *slog.Logger
}

func main() {
    port := 3000

    // define a new logger    
    logger := slog.New(slog.NewTextHandler(os.Stderr, nil))

    // create an app instance 
    app := &Application {
        logger: logger,
    }

    handler := api.Handler(app)

    // create a server with some configuration
    server := &http.Server{
		Addr:         fmt.Sprintf(":%d", port),
		Handler:      handler,
		IdleTimeout:  time.Minute,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	logger.Info("starting server", "addr", port)

    // start the server
    server.ListenAndServe()
}

I've used the api.Handler method from the generated code to setup our route handler. It'll create a new Chi router automatically and handle incoming requests. You can also use HandlerFromMuxHandlerFromMuxWithBaseURL, or HandlerWithOptions based  on your requirements.

Note that we're passing app to the api.Handler method. The first argument of all the handler functions in the generated code is si ServerInterface. The methods in this interface will be the handlers for all the routes that we define in our OpenAPI spec. This means that our Application struct has to implement all the methods defined by ServerInterface.

Since we haven't done that yet, your editor or IDE might have already caught this issue in main.go. But let's ignore it for now and start the server:

go run ./cmd/web

This should throw an error:

go run ./cmd/web
# my-chi-project/cmd/web
cmd/web/main.go:36:36: cannot use app (variable of type *Application) as api.ServerInterface value in argument to api.Handler: *Application does not implement api.ServerInterface (missing method GetHealth)

That's what we expected to happen. So let's do implement GetHealth. Create a health.go file in cmd/web:

package main

import "net/http"

// a simple route handler that returns OK
func (app *Application) GetHealth(w http.ResponseWriter, r *http.Request) {
    app.logger.Info("health", "status", "ok")
	w.Write([]byte("OK"))
}

Now if we start our server and run this command in a different terminal:

curl localhost:3000/api/v1/health

we'll get this:

HTTP/1.1 200 OK
Date: Sat, 28 Sep 2024 09:36:22 GMT
Content-Length: 2
Content-Type: text/plain; charset=utf-8

OK%

Yay! Our API works!


That's oapi-codegen folks. It generates a lot of code that we don't have to write ourselves. We just have to implement the business logic in our route handlers. It's quite convenient.

There are still a lot more I want to explore with oapi-codegen, like deep-diving into the code it generates and exploring all the features it offers. I'll write more as I'm learning that stuff.

Share this on:X