Skip to content

OpenAPI as the source of truth

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/openapi.md
DescriptionNot specified

Source Content

OpenAPI as the source of truth

The spec at api/openapi.yaml is authoritative. Code is generated from it, never the reverse. You implement a generated interface; you never hand-write the route signatures the spec already describes. Contract design is api-designer’s job — this file governs the generate-and-implement loop.

Generated code is committed

The output of oapi-codegen is checked into git. This is deliberate:

  • Reviewable diffs — a contract change shows up in the PR as a concrete code change, not a build-time surprise.
  • Hermetic CI — builds do not depend on regenerating code, only on verifying it is current.
  • No drift — the committed code is the single artifact everyone compiles against.

oapi-codegen config

A oapi-codegen.yaml config keeps generation reproducible. Generate the Fiber server interface, the types, and the spec embed.

api/oapi-codegen.yaml
package: api
generate:
fiber-server: true
models: true
embedded-spec: true
output: internal/api/api.gen.go

Drive it from go generate so the command lives next to the code:

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

Implement the generated interface

oapi-codegen emits a ServerInterface with one method per operationId. Your handler struct implements it; the compiler guarantees every operation in the spec has an implementation.

internal/handler/server.go
package handler
import "github.com/acme/billing/internal/api"
// Server implements the generated api.ServerInterface.
type Server struct {
invoices *service.InvoiceService
}
// compile-time assertion: Server satisfies the generated contract
var _ api.ServerInterface = (*Server)(nil)
// FinalizeInvoice implements the operationId from openapi.yaml.
func (s *Server) FinalizeInvoice(c *fiber.Ctx, id string) error {
inv, err := s.invoices.Finalize(c.Context(), domain.InvoiceID(id))
// ... map domain error -> status (see references/errors.md)
return c.Status(fiber.StatusOK).JSON(toResponse(inv))
}

The var _ api.ServerInterface = (*Server)(nil) line is the safety net: add an operation to the spec, regenerate, and the build breaks until you implement it.

Regeneration is a build step

task openapi regenerates from the spec:

Terminal window
go generate ./...

The drift check

CI regenerates and fails if the committed code no longer matches the spec — this is what makes “spec is the source of truth” enforceable rather than aspirational.

Terminal window
go generate ./...
git diff --exit-code -- internal/api/api.gen.go

If the diff is non-empty, someone edited the generated file by hand or changed the spec without regenerating — the build fails. Add this as a CI step and as a task openapi-check target.

Redocly upgrade path (Wave 3, not yet installed)

scripts/lint_openapi.sh’s RFC 9457 check is a text-proximity scan today — it looks for title/status/detail/type keys within a line window, not a real schema parse. redocly lint would replace that with an actual OpenAPI/JSON Schema validation pass.

redocly is not installed on this machine (command -v redocly finds nothing). The upgrade follows the same graceful-degradation pattern as every other tool in this skill: lints.toml carries a commented-out [[lint]] block for it, and scripts/lint.py would skip it with a warning — never a hard failure — until it’s installed. Install with npm i -g @redocly/cli, then uncomment the block in lints.toml.

Where the boundary types live

The generated models are the transport representation, not the domain. The handler maps between generated request/response types and domain types — the same boundary where any/interface{} is allowed (it is a protocol edge). The service and domain never import the generated api package.

client JSON <-> api.* (generated) <-> [handler maps] <-> domain.* <-> service / repository