OpenAPI as a Contract, Not Documentation

Many teams and frameworks treat OpenAPI specs as post-facto documentation. They write their server code, then generate a spec from it, then maybe generate some client libraries from that. The spec is a downstream artifact, a byproduct of the “real” code. This is the approach taken by FastAPI, and it works really well for a lot of people!

We do the opposite. At Triple Pat, the OpenAPI spec is the source of truth. Both our Go server and our Swift client must prove compliance with it. The server is validated against the spec at test time. The client is generated from the spec at build time. If either side drifts from the spec, we find out at compile-time or during unit testing.

You can see our spec for the check-in service (and its attendant documentation) at http://triplepat.com/api/docs/. The docs for the API include examples, which means you can manually check-in right on the API page!

This inversion of control, where the spec controls the server, gives us something we care about deeply: reliability. Reliability stemming from a justified belief that the server returns what the spec says it will, and that the client handles what the server actually returns. This in turn promotes a belief that the system will run stably in practice, because when your types are verified at compile time and your responses are verified at test time, whole categories of production incidents simply can’t happen.

(Footnote for pedants: we do still have to evolve the spec quite carefully, because while these techniques to a great job of ensuring the specs align right now, they don’t guarantee that the current spec is compatible with all extant clients. That said, solving part of the problem is still quite valuable)

The problem with generated specs

When you generate an OpenAPI spec from your server code, you have three things that should agree: the server, the client, and the spec. But the spec is derived from the server code, and therefore isn’t authoritative. The server can’t be provably compliant with a protocol because there’s nothing independent to check it against. Worse, because the spec is generated, it is probably not checked in, which means the client is working from a spec that might be stale or wrong, because it probably has to be working from a cached copy.

When conflicts arise, assigning blame becomes a real problem. Is the server or client wrong? There is no neutral arbiter!

Spec-first development

We maintain our OpenAPI specs by hand. They live in the Go codebase alongside the services they describe:

go/checkin-service/openapi.yaml   # Public check-in API
go/user-service/openapi.yaml      # Authenticated user API

The rule is simple: update the spec first, then update the code to match. The spec is the contract. Everything else has to prove compliance with the spec.

Server-side verification in Go

Our Go tests validate HTTP responses against the OpenAPI spec at test time. We built a small spectest package to make this easy:

func LoadSpec(t *testing.T, path string) *openapi3.T {
	t.Helper()
	loader := openapi3.NewLoader()
	loader.IsExternalRefsAllowed = true

	doc, err := loader.LoadFromFile(filepath.Clean(path))
	require.NoError(t, err, "cannot load spec: %v", err)
	err = doc.Validate(t.Context())
	require.NoError(t, err, "spec validation failed: %v", err)
	return doc
}

func Wrap(t *testing.T, doc *openapi3.T) func(*http.Request, *httptest.ResponseRecorder) {
	t.Helper()
	v := validator.NewValidator(doc)
	return func(r *http.Request, rr *httptest.ResponseRecorder) {
		v.ForTest(t, rr, r).Validate(rr, r)
	}
}

In our handler tests, we load the spec and validate responses:

func TestCheckinHandler(t *testing.T) {
	// ... set up handler and request ...
	rr := httptest.NewRecorder()
	handler.ServeHTTP(rr, req)

	if rr.Code == http.StatusOK {
		spec := spectest.LoadSpec(t, "../../checkin-service/openapi.yaml")
		spectest.Wrap(t, spec)(req, rr)
	}
}

If the handler returns JSON that doesn’t match the schema, the test fails. The spec is the arbiter.

We use kin-openapi to parse the spec and openapi.tanna.dev/go/validator to validate requests and responses against it.

Client-side verification in Swift

On the iOS side, we use Apple’s swift-openapi-generator to generate a type-safe client at build time. The generator runs as a Swift package plugin, so there’s no manual code generation step.

The workflow:

  1. A script downloads the OpenAPI specs from our running services
  2. We merge the public and private specs into a single openapi.json
  3. The swift-openapi-generator plugin processes this spec during swift build
  4. We get a generated Client with strongly-typed operations

The generated code looks like this in use:

let response = try await client.getLastCheckin(
    .init(path: .init(uuid: uuid.uuidString))
)

If the spec changes in a way that affects the client, the Swift code won’t compile. A renamed field, a changed type, or a removed endpoint all become compile errors, not runtime surprises. The Swift compiler becomes another validator.

The payoff

Both sides of our API reference the same OpenAPI specs:

When the spec changes, Go tests fail until handlers match. Swift code fails to compile until it’s updated. There’s nowhere for drift to hide. When the server changes and no longer complies with the spec, we find out during unit testing. When the client changes incorrectly, the generated code no longer links correctly with the application code and we find out at compile-time.

We didn’t set out to solve “how do we keep Go and Swift in sync.” We set out to have a single source of truth. We wanted one clear, authoritative description of what the API does. Cross-language type safety fell out naturally, and so did the ability to onboard new developers quickly, because the spec documents the API completely. So did confidence in refactoring, because the tests and compiler catch mistakes.

When you make something simpler and clearer, correctness becomes easier. The spec-first approach isn’t clever. It’s the opposite of clever. It takes an obvious idea - a contract should be written down - and enforces it mechanically.

Practical notes

Lint your specs. We use vacuum to lint our OpenAPI specs in CI and require a perfect score. Stricter linting means stricter validation on both sides. It’s more work to make your spec lint clean in hard mode, but that early work quickly pays off down the line.

Merge specs when needed. Our iOS app talks to two services, so we merge their specs into one before generation. The openapi-merge-cli tool handles this. Many tools assume there’s only one spec per repo, so this is a useful way of working within that constraint while supporting multiple services in a single client codebase.

Validate success responses tightly, and errors loosely. We validate responses when the status is 200 by making sure that the precise schema is tightly adhered to, but we’re more relaxed about error responses. Error formats are less critical to get exactly right and over-validating them can add noise to tests. Most of the information in an error is in the fact that an error was produced (and in the HTTP error code) and not in the precise schema or string produced when the error happens.

Use it!

The tools we use:

The spectest package shown above is about 40 lines of code. You can write your own or just copy the one here.

All code in this blog post is CC0, which means you can freely use it however you want.

Happy coding!