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:
- a well-defined collection of API endpoints
- to automatically generate documentation for those endpoints
- to have one source of truth so I'm not constantly going back and forth between code and documentation, keeping them in sync
- 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 HandlerFromMux
, HandlerFromMuxWithBaseURL
, 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.