Building a Go RESTful API with design-first OpenAPI contracts

Featured image for sharing metadata for article

As mentioned in my post about Shipping services more quickly with design-first OpenAPI contracts, I've recently been working on a fair bit of OpenAPI-driven design-first APIs, in Go. For more info on what that is, and why you'd want to do it, I'd recommend having a read of that post.

This has been using the Go library oapi-codegen, and has been a truly excellent experience.

To give a bit more insight into how this works, so you too can be building services even more quickly, I thought I'd blogument it.

This post has a corresponding repo on GitLab.

OpenAPI

Let's start with the OpenAPI document itself. This is slightly amended from the example from the Deliveroo post:

# Example from https://deliveroo.engineering/2022/06/27/openapi-design-first.html
# © All-Rights-Reserved
openapi: 3.1.0
info:
  title: Care Request API
  version: 0.1.0
paths:
  "/request/{request-id}":
    get:
      summary: Get all requests
      operationId: getRequest
      parameters:
        - $ref: '#/components/parameters/RequestId'
        - $ref: '#/components/parameters/TracingId'
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CareRequest'
        # we'd also add other response options here too
components:
  parameters:
    RequestId:
      name: request-id
      in: path
      required: true
      schema:
        $ref: '#/components/schemas/RequestId'
    TracingId:
      description: A unique tracing ID that can be used for end-to-end tracing
      name: tracing-id
      in: header
      required: false
      schema:
        type: string
        format: uuid
        pattern: "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}"
  schemas:
    CareRequest:
      type: object
      properties:
        id:
          $ref: '#/components/schemas/RequestId'
        status:
          $ref: '#/components/schemas/RequestStatus'
    RequestId:
      type: string
      format: uuid
      pattern: "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}"
    RequestStatus:
      type: string
      enum:
        - active
        - completed

Generation

Now, we need to generate our server. Before we start, a quick word about my recommended package structure:

openapi.yaml              # the service's OpenAPI contract
tools.go                  # tracking the version of `oapi-codegen`'s CLI, via https://www.jvt.me/posts/2022/06/15/go-tools-dependency-management/
domain/
  services/
    carerequestservice.go # underlying business logic in a interface
server/
  server.go               # the code that creates an HTTP server from the generated code, preparing any router-specific configuration
  api/
    generate.go           # go:generate tags
    server.gen.go         # the generated output from oapi-codegen
    implementation.go     # the code you're writing to fulfil the contract of the server

To do this, oapi-codegen requires that we have a configuration file, for instance server/api/oapi-codegen.yaml:

output: server.gen.go
package: api
generate:
  # other servers are available!
  gorilla-server: true
  models: true
  embedded-spec: true

Next, we need to prepare a server/api/generate.go which will trigger the generation via go generate:

package api
//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --config oapi-codegen.yaml ../../openapi.yaml

Running go generate ./... then gives us a freshly generated server.gen.go, which includes the various models required to interact with the API:

// Defines values for RequestStatus.
const (
	Active    RequestStatus = "active"
	Completed RequestStatus = "completed"
)

// CareRequest defines model for CareRequest.
type CareRequest struct {
	Id     RequestId     `json:"id"`
	Status RequestStatus `json:"status"`
}

// RequestId defines model for RequestId.
type RequestId = openapi_types.UUID

// RequestStatus defines model for RequestStatus.
type RequestStatus string

We also have a ServerInterface to implement, which gives us a smaller interface to think about for how we implement our server, and plumbs in the parameters that our specification defines:

// GetRequestParams defines parameters for GetRequest.
type GetRequestParams struct {
	// A unique tracing ID that can be used for end-to-end tracing
	TracingId *TracingId `json:"tracing-id,omitempty"`
}

// ServerInterface represents all server handlers.
type ServerInterface interface {
	// Get all requests
	// (GET /requests/{request-id})
	GetRequest(w http.ResponseWriter, r *http.Request, requestId RequestId, params GetRequestParams)
}

Implementation

Now we've generated the base server, we need to actually implement the ServerInterface.

We can start by preparing our server/api/implementation.go:

package api

import "net/http"

type careRequestApi struct{}

// Get all requests
// (GET /request/{request-id})
func (c *careRequestApi) GetRequest(w http.ResponseWriter, r *http.Request, requestId RequestId, params GetRequestParams) {
	panic("not implemented") // TODO: Implement
}

I'd then reach for TDD, and write the following tests, including my library for OpenAPI validation:

func TestCareRequestApi_GetRequest(t *testing.T) {
	mockService := mocks.NewMockCareRequestService(gomock.NewController(t))
	server := NewCareRequestApi(mockService)

	t.Run("when successful", func(t *testing.T) {
		found := domain.CareRequest{
			Id:     domain.RequestId(uuid.New()),
			Status: domain.RequestStatusActive,
		}

		mockService.EXPECT().GetRequestById(gomock.Any()).Return(&found, nil)

		requestId := uuid.New()
		rr := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/requests/"+requestId.String(), nil)

		server.GetRequest(rr, req, requestId, GetRequestParams{})

		t.Run("it returns 200", func(t *testing.T) {
			assert.Equal(t, 200, rr.Result().StatusCode)
		})

		t.Run("it matches OpenAPI", func(t *testing.T) {
			doc, err := GetSwagger()
			assert.NoError(t, err)

			_ = validator.NewValidator(doc).ForTest(t, rr, req)
			// validation happens in the background
		})
	})

	t.Run("when no request found", func(t *testing.T) {
		mockService.EXPECT().GetRequestById(gomock.Any()).Return(nil, nil)

		requestId := uuid.New()
		rr := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/requests/"+requestId.String(), nil)

		server.GetRequest(rr, req, requestId, GetRequestParams{})

		t.Run("it returns 404", func(t *testing.T) {
			assert.Equal(t, 404, rr.Result().StatusCode)
		})

		t.Run("it matches OpenAPI", func(t *testing.T) {
			doc, err := GetSwagger()
			assert.NoError(t, err)

			_ = validator.NewValidator(doc).ForTest(t, rr, req)
			// validation happens in the background
		})
	})

	t.Run("when an error returned from service", func(t *testing.T) {
		mockService.EXPECT().GetRequestById(gomock.Any()).Return(nil, fmt.Errorf("uh oh"))

		requestId := uuid.New()
		rr := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/requests/"+requestId.String(), nil)

		server.GetRequest(rr, req, requestId, GetRequestParams{})

		t.Run("it returns 500", func(t *testing.T) {
			assert.Equal(t, 500, rr.Result().StatusCode)
		})
	})
}

Once these are written, we can construct the implementation, resulting in the following code:

type careRequestApi struct {
	service services.CareRequestService
}

func NewCareRequestApi(service services.CareRequestService) ServerInterface {
	return &careRequestApi{
		service: service,
	}
}

// Get all requests
// (GET /requests/{request-id})
func (c *careRequestApi) GetRequest(w http.ResponseWriter, r *http.Request, requestId RequestId, params GetRequestParams) {
	found, err := c.service.GetRequestById(domain.RequestId(requestId))
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	if found == nil {
		w.WriteHeader(http.StatusNotFound)
		return
	}

	response := CareRequest{
		Id:     uuid.UUID(found.Id),
		Status: RequestStatus(found.Status),
	}
	bytes, err := json.Marshal(response)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	w.Header().Add("content-type", "application/json")
	w.Write(bytes) // NOTE that we should handle the error here
	w.WriteHeader(http.StatusOK)
}

Serving traffic

Once we've created this, we would produce a method like so, to prepare a generic http.Handler which can then be used when running the application:

func NewHandler() http.Handler {
	openapi, err := api.GetSwagger()
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error loading swagger spec\n: %s", err)
		os.Exit(1)
	}

	openapi.Servers = nil

	var service services.CareRequestService // TODO: in a production app, this would be properly initialised

	server := api.NewCareRequestApi(service)

	r := mux.NewRouter()

  // NOTE that this is important! This enforces consumers use the right contract, required on top of checks that are done in the generated API code
	r.Use(middleware.OapiRequestValidator(openapi))

	return api.HandlerFromMux(server, r)
}

Notice that we need to set up the OapiRequestValidator middleware, and that it enforces further validation on top of what is already done in the generated code.

Conclusion

Notice that unlike using a regular HTTP server's HTTP handlers, we can actually avoid thinking about the parsing of the incoming HTTP request, and instead focus on processing data and returning a valid response.

This makes it so much quicker to ship our handlers, and allows us to make our HTTP handlers very lightweight, delegating to business logic elsewhere, keeping them as slim as possible.

Written by Jamie Tanna's profile image Jamie Tanna on , and last updated on .

Content for this article is shared under the terms of the Creative Commons Attribution Non Commercial Share Alike 4.0 International, and code is shared under the Apache License 2.0.

#blogumentation #openapi #go #api.

Also on:

This post was filed under articles.

Interactions with this post

Interactions with this post

Below you can find the interactions that this page has had using WebMention.

Have you written a response to this post? Let me know the URL:

Do you not have a website set up with WebMention capabilities? You can use Comment Parade.