Featured image of post Building a web service with go-tool-base, part 3: a REST service, by hand and by spec

Building a web service with go-tool-base, part 3: a REST service, by hand and by spec

The gRPC service from part 2 is the right core for service-to-service traffic. It is also useless to a great many of the things that might want to call it: a browser, a webhook from some SaaS, a partner who will not touch protobuf, a curl in a 2am runbook. They all want the same thing, plain JSON over HTTP.

So we need a REST face as well. The only way we know how so far is to build one, a second implementation of the macguffin service, this time over HTTP. We’ll do it two ways, by hand and from a spec, and wire it onto the very same controller from part 1. And yes, we’ll feel the duplication, because the next part is about making it disappear.

Why not just net/http?

A fair question before we register anything: go-tool-base’s HTTP server is net/http underneath, so why not stand up a http.Server{} yourself? You can, and nothing here hides the standard library from you. What gtbhttp.Register adds is the dull, easy-to-botch scaffolding that goes around the handler:

  • It’s still net/http. You write ordinary http.Handlers. There’s no bespoke router and no framework to learn underneath, just the standard library doing the serving.
  • Timeouts and limits you’d otherwise forget. A bare http.Server{} has no ReadTimeout or WriteTimeout, which is a Slowloris waiting to happen. You get sane read/write/idle timeouts and caps on header and body sizes.
  • The hardened TLS from part 2, the same shared certificate and the same 1.2-minimum AEAD config, with no extra setup.
  • Lifecycle for free. It registers on the same controller as your gRPC server, so a SIGTERM drains in-flight requests and stops both together, instead of you re-writing the signal-and-Shutdown dance for every service.
  • Health endpoints, /healthz, /livez and /readyz, backed by the controller and ready for an orchestrator to probe, with nothing to write.

You write the handlers; it owns the lifecycle and the hardening. With that settled, let’s write some handlers.

By hand, on the standard library

net/http’s ServeMux acquired method and path patterns in Go 1.22, so a small REST surface needs no router and no dependencies at all. And the hard part is already behind us: the Store from part 2 is our domain, and the HTTP handlers are just another thin adapter over it, exactly as the gRPC server was. A small type holds the store, and the routes hang off it:

// internal/resthand/server.go

package resthand

import (
	"encoding/json"
	"net/http"

	"gitlab.com/myorg/macguffinsvc/internal/macguffin"
)

type API struct {
	store *macguffin.Store
}

func (a *API) Routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /macguffins", a.list)
	mux.HandleFunc("GET /macguffins/{id}", a.get)
	mux.HandleFunc("POST /macguffins", a.create)

	return mux
}

Each pattern names a method and a path, and {id} is a named wildcard a handler reads back with r.PathValue("id"), no third-party router required. And because the domain Macguffin already carries JSON tags, this adapter can encode it straight to the response, with no separate type to map to:

// internal/resthand/server.go (same file)

func (a *API) get(w http.ResponseWriter, r *http.Request) {
	m, ok := a.store.Get(r.PathValue("id"))
	if !ok {
		http.Error(w, "macguffin not found", http.StatusNotFound)
		return
	}

	writeJSON(w, http.StatusOK, m)
}

list and create are the same shape: ask the store, encode the result with a small writeJSON helper. Routes() hands back a *http.ServeMux, which is itself an http.Handler, so it slots straight into the controller in a moment.

There’s nothing clever in any of it, and that’s the appeal: total control, no tooling, and because we serve the domain type directly, nothing to map. The cost is the kind that creeps up on you. Every route, every bit of marshalling, every status code is yours to write and keep correct, and as the surface grows, so does the area for small mistakes.

By spec, with oapi-codegen

The other road is the OpenAPI mirror of part 2’s proto. You describe the API in an OpenAPI document, and a generator turns it into a Go interface for you to implement. The tool is oapi-codegen.

First the contract, api/macguffin.openapi.yaml (trimmed to one path here):

# api/macguffin.openapi.yaml
paths:
  /macguffins/{id}:
    get:
      operationId: getMacguffin
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Macguffin"
        "404":
          description: Not found
components:
  schemas:
    Macguffin:
      type: object
      required: [id, name, quantity]
      properties:
        id: { type: string }
        name: { type: string }
        quantity: { type: integer, format: int32 }

Install the tool and tell it what to emit with a small config. We want the standard-library server, so std-http-server:

go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
# api/oapi-codegen.yaml
package: restapi
output: internal/restapi/macguffin.gen.go
generate:
  std-http-server: true
  models: true

As in part 2, wire it into go generate so it can’t drift, this time sitting right alongside the buf directive:

// gen.go (at your module root): add the second line alongside part 2's

//go:generate buf generate
//go:generate oapi-codegen -config api/oapi-codegen.yaml api/macguffin.openapi.yaml

go generate ./... now regenerates both the gRPC and the REST code in one go. What oapi-codegen writes is the message models and, the important part, a ServerInterface, one method per operation:

// internal/restapi/macguffin.gen.go (generated)

type ServerInterface interface {
	ListMacguffins(w http.ResponseWriter, r *http.Request)
	CreateMacguffin(w http.ResponseWriter, r *http.Request)
	GetMacguffin(w http.ResponseWriter, r *http.Request, id string)
}

If that feels familiar, it should: it’s the exact shape from part 2. The generator hands you an interface and your job is a type that honours it, an adapter over the same Store. But here’s the wrinkle that earns this part its keep. oapi-codegen generated its own Macguffin type, a DTO, so the adapter has to map the domain into it. (The path parameter, at least, arrives already typed as id string rather than fished out by hand.)

// internal/restapi/server.go

package restapi

import (
	"net/http"

	"gitlab.com/myorg/macguffinsvc/internal/macguffin"
)

func (a *API) GetMacguffin(w http.ResponseWriter, _ *http.Request, id string) {
	m, ok := a.store.Get(id)
	if !ok {
		http.Error(w, "macguffin not found", http.StatusNotFound)
		return
	}

	writeJSON(w, http.StatusOK, toDTO(m))
}

// toDTO maps the domain type to oapi-codegen's generated Macguffin DTO.
func toDTO(m macguffin.Macguffin) Macguffin {
	return Macguffin{Id: m.ID, Name: m.Name, Quantity: m.Quantity}
}

That toDTO is the second mapping of this kind we’ve written. Part 2’s gRPC adapter had toProto; this one has toDTO. The same domain data, encoded twice, into two generated shapes, kept in step by hand. Hold that thought: part 4 is where that second mapping stops being something you write.

A generated helper turns your implementation into an http.Handler:

handler := restapi.HandlerFromMux(restapi.NewAPI(store), http.NewServeMux())

So which road? By hand when it’s a handful of endpoints you fully control and you’d rather not own a generator. Spec-first when the contract carries weight, several teams consume the API, the shape changes often, or you simply want the OpenAPI document to exist, which (spoiler) we’ll be serving as live, clickable docs in part 5.

Wire it onto the controller

Whichever road you took, you’re holding an http.Handler. We don’t replace the gRPC server from part 2; we add the HTTP one beside it, on the same controller, both reading from one shared store. This is the controller from part 1 doing exactly what it exists for: two transports, one lifecycle.

// pkg/cmd/serve/main.go

package serve

import (
	"context"

	"gitlab.com/phpboyscout/go-tool-base/pkg/controls"
	gtbgrpc "gitlab.com/phpboyscout/go-tool-base/pkg/grpc"
	gtbhttp "gitlab.com/phpboyscout/go-tool-base/pkg/http"
	"gitlab.com/phpboyscout/go-tool-base/pkg/props"

	macguffinv1 "gitlab.com/myorg/macguffinsvc/internal/gen/macguffin/v1"
	"gitlab.com/myorg/macguffinsvc/internal/grpcsvc"
	"gitlab.com/myorg/macguffinsvc/internal/macguffin"
	"gitlab.com/myorg/macguffinsvc/internal/resthand"
)

func RunServe(ctx context.Context, p *props.Props, _ *ServeOptions, _ []string) error {
	controller := controls.NewController(ctx, controls.WithLogger(p.Logger))

	// One shared domain behind both transports.
	store := macguffin.NewStore()

	// gRPC, from part 2.
	grpcSrv, err := gtbgrpc.Register(ctx, "grpc", controller, p.Config, p.Logger)
	if err != nil {
		return err
	}

	macguffinv1.RegisterMacguffinServiceServer(grpcSrv, grpcsvc.New(store))

	// HTTP, new this part. (Or the oapi-codegen handler; either is an http.Handler.)
	handler := resthand.New(store).Routes()
	if _, err := gtbhttp.Register(ctx, "http", controller, p.Config, p.Logger, handler); err != nil {
		return err
	}

	controller.Start()
	controller.Wait()

	return nil
}

The HTTP Register is the counterpart of part 2’s gRPC one: same controller, so a single SIGTERM drains and stops both together and /healthz reports on both. The HTTP server reads server.http.port, the gRPC server server.grpc.port from part 2, and both take their TLS from the shared server.tls block, the one certificate:

server:
  grpc:
    port: 50051
    reflection: true
  http:
    port: 8443
  tls:
    enabled: true
    cert: ./localhost+2.pem
    key: ./localhost+2-key.pem

A browser away

Here’s where the mkcert groundwork from part 2 pays off. Because that certificate is signed by a CA your machine already trusts, the service answers over HTTPS with no --cacert flag and, more to the point, no browser warning:

$ curl https://localhost:8443/macguffins
[{"id":"m-1","name":"maltese-falcon","quantity":1}]

$ curl https://localhost:8443/macguffins/m-1
{"id":"m-1","name":"maltese-falcon","quantity":1}

And /healthz now reports both transports, since they registered against the one controller:

$ curl https://localhost:8443/healthz
{"overall_healthy":true,"services":[{"name":"grpc","status":"OK"},{"name":"http","status":"OK"}]}

Open https://localhost:8443/macguffins in an actual browser and it just loads, green padlock and all. That matters more than it sounds, and it’s the reason we set the local CA up early: in part 5 the API docs are a web page, and a docs page behind a cert warning is a docs page nobody trusts.

Where this leaves us

Step back and count what we actually built. There’s one domain, the Store, and it didn’t change at all this part. What we added was a second delivery adapter over it: routing, marshalling, and, on the spec-first road, a toDTO mapping sitting beside part 2’s toProto. The same data, encoded into two generated shapes, kept in step by hand. That’s the real duplication, not the logic, but the transport scaffolding wrapped around it.

And it’s the itch the next part scratches. We built a second transport and a second encoding of the same domain, and kept them in step by hand. Part 4 is where that whole second layer, adapter and encoding both, stops being something you write at all. We felt the cost first on purpose; now we get to remove it.

Built with Hugo
Theme Stack designed by Jimmy