Skip to content

RFC: OpenAPI Generation from Go Types #644

@alexluong

Description

@alexluong

OpenAPI Generation

Problem

The OpenAPI schema (docs/apis/openapi.yaml, 4,053 lines) is manually maintained. This creates:

  1. Drift risk - Schema can get out of sync with actual API behavior
  2. Duplication - Request/response types defined in both Go and YAML
  3. Manual updates - Every API change requires updating the schema

Goals

  1. Single source of truth - Go code defines the API, OpenAPI is derived
  2. No build step - Spec generated at runtime, always in sync
  3. Type-safe - Errors, inputs, outputs are Go types, not strings
  4. Live endpoint - Serve at /api/v1/openapi.json

Prior Art: swaggo/swag

swaggo/swag is the most popular Go OpenAPI generator. It uses comment annotations:

// CreateDestination godoc
// @Summary      Create a new destination
// @Description  Creates a destination for receiving webhooks
// @Tags         Destinations
// @Accept       json
// @Produce      json
// @Param        tenantID path string true "Tenant ID"
// @Param        body body CreateDestinationRequest true "Destination configuration"
// @Success      201 {object} DestinationDisplay
// @Failure      400 {object} BadRequestError
// @Failure      409 {object} ConflictError
// @Router       /tenants/{tenantID}/destinations [post]
func (h *Handler) CreateDestination(c *gin.Context) {
    // ...
}

Then generate with:

swag init -g cmd/main.go -o docs/

I like this approach - it's battle-tested, widely adopted, and has great Gin integration.

However, I decided against it for two reasons:

  1. Comments are not type-safe - The annotations are strings that can drift from the actual code. Nothing prevents @Success 201 {object} WrongType from compiling.

  2. Requires a build step - Running swag init before every build adds friction. The generated spec can get stale if someone forgets to regenerate.

We want our OpenAPI spec to be derived directly from Go types at runtime, with no opportunity for drift.

Design

Our approach is broken into two distinct scopes:

Scope 1: OpenAPI Metadata in RouteDefinition

Add an OpenAPI field to the existing RouteDefinition struct. This allows us to generate OpenAPI schema from our current route definitions without refactoring handlers.

type RouteDefinition struct {
    Method       string
    Path         string
    Handler      gin.HandlerFunc
    AuthMode     AuthMode
    TenantScoped bool
    OpenAPI      *OpenAPIMeta  // NEW: optional OpenAPI metadata
}

type OpenAPIMeta struct {
    Summary     string
    Description string
    Tags        []string
    OperationID string
    RequestBody reflect.Type  // e.g., reflect.TypeOf(CreateDestinationRequest{})
    Response    reflect.Type  // e.g., reflect.TypeOf(DestinationDisplay{})
    Errors      []ErrorMeta   // e.g., {Status: 409, Type: reflect.TypeOf(ConflictError{})}
}

Scope 2: Generic Operation with Typed Input/Output/Errors

Refactor handlers to use a generic Operation type that replaces RouteDefinition. This encodes the full API contract in the type system, providing both type-safe code AND accurate OpenAPI generation.

Instead of registering handlers directly, define typed operations:

type Operation[In, Out any, Err ErrorSet] struct {
    Method       string
    Path         string
    Auth         AuthMode      // Public, Tenant, Admin
    TenantScoped bool          // Contract: handler receives *Tenant
    OpenAPI      OpenAPIMeta
    Handler      func(ctx context.Context, input In) (*Out, Err)
}

The type parameters make the contract explicit:

  • In - Input type (path params, query params, request body)
  • Out - Success response type (implements HTTPStatus() for non-200)
  • Err - Possible error types (as a struct with pointer fields)

Response Wrappers

Generic wrapper types modify response behavior (status codes, pagination). The OpenAPI generator reflects on Out to extract the inner type for the schema.

Status Codes

By default, success responses return 200. For other status codes, the Out type implements HTTPStatus():

type Created[T any] struct{ *T }
func (Created[T]) HTTPStatus() int { return 201 }

type Accepted[T any] struct{ *T }
func (Accepted[T]) HTTPStatus() int { return 202 }

type NoContent struct{}
func (NoContent) HTTPStatus() int { return 204 }

Usage:

func (h *Handlers) Get(...) (*DestinationDisplay, GetErrors)              // 200
func (h *Handlers) Create(...) (*Created[DestinationDisplay], CreateErrors) // 201
func (h *Handlers) Delete(...) (*NoContent, DeleteErrors)                  // 204

Pagination

List endpoints return paginated responses matching the Hookdeck API paging format:

type Pagination struct {
    OrderBy string `json:"order_by"`
    Dir     string `json:"dir"`   // "asc" | "desc"
    Limit   int    `json:"limit"`
    Next    string `json:"next,omitempty"`
    Prev    string `json:"prev,omitempty"`
}

type Paginated[T any] struct {
    Models     []T        `json:"models"`
    Pagination Pagination `json:"pagination"`
}

Usage:

func (h *Handlers) List(...) (*Paginated[DestinationDisplay], ListErrors)

Response:

{
  "models": [{"id": "dest_123", ...}, ...],
  "pagination": {
    "order_by": "created_at",
    "dir": "desc",
    "limit": 100,
    "next": "dest_456",
    "prev": null
  }
}

Input Types

Struct tags define where values come from:

type CreateDestinationInput struct {
    TenantScopedInput                             // *Tenant from middleware
    Body CreateDestinationRequest `json:",inline"` // Request body
}

type ListEventsInput struct {
    TenantScopedInput
    Topics []string `form:"topic"`                // Query param (repeated)
    Limit  int      `form:"limit" binding:"max=100"`
    Cursor string   `form:"cursor"`
}

Reflection extracts these to generate OpenAPI parameters.

Error Types

Error responses match the Hookdeck API error format:

// Base error struct - all errors embed this
type APIError struct {
    Level   string `json:"level"`   // "info" | "error"
    Handled bool   `json:"handled"` // true if expected error
    Report  bool   `json:"report"`  // true if should be reported
    Status  int    `json:"status"`  // HTTP status code
    Code    string `json:"code"`    // "NOT_FOUND", "CONFLICT", etc.
}

func (e APIError) HTTPStatus() int { return e.Status }

// Specific errors embed APIError and add extra fields as needed
type NotFoundError struct {
    APIError
}

type ConflictError struct {
    APIError
}

type ValidationError struct {
    APIError
    Data []string `json:"data"` // ["name is required", "email is invalid"]
}

Example response:

{
  "level": "info",
  "handled": true,
  "report": true,
  "data": ["name is required"],
  "status": 422,
  "code": "UNPROCESSABLE_ENTITY"
}

ErrorSet - Declaring Possible Errors

Each operation declares which errors it can return:

type CreateDestinationErrors struct {
    BaseErrors
    Conflict *ConflictError   // 409 - ID already exists
}

type GetDeliveryErrors struct {
    BaseErrors
    NotFound *NotFoundError   // 404 - delivery not found
}

The struct fields are pointer types. Returning a non-nil pointer means that error occurred. Reflection walks the struct to discover possible errors for OpenAPI.

Implicit vs Explicit Errors

Implicit (added to all operations automatically):

  • 400 BadRequestError - malformed request body
  • 401 UnauthorizedError - auth failure
  • 422 ValidationError - input validation failed
  • 500 InternalServerError - unexpected error

Explicit (declared per operation via ErrorSet):

  • 404, 409, etc.

Handlers only declare operation-specific errors. Common errors are handled by framework.

Handler Implementation

Handlers are pure functions - no Gin dependency, testable:

func (h *DestinationHandlers) Create(
    ctx context.Context,
    input CreateDestinationInput,
) (*Created[DestinationDisplay], CreateDestinationErrors) {

    existing, _ := h.store.GetDestination(ctx, input.Tenant.ID, input.Body.ID)
    if existing != nil {
        return nil, CreateDestinationErrors{
            Conflict: &ConflictError{Message: "destination already exists"},
        }
    }

    dest, _ := h.store.CreateDestination(ctx, input.Tenant.ID, input.Body)
    return &Created[DestinationDisplay]{dest}, CreateDestinationErrors{}
}

Registration

var CreateDestination = Operation[CreateDestinationInput, Created[DestinationDisplay], CreateDestinationErrors]{
    Method:       "POST",
    Path:         "/tenants/:tenantID/destinations",
    Auth:         AuthTenant,
    TenantScoped: true,
    OpenAPI: OpenAPIMeta{
        Summary:     "Create destination",
        Tags:        []string{"Destinations"},
        OperationID: "createDestination",
    },
}

// In router setup - Operation implements Route interface
CreateDestination.Handler = handlers.Destination.Create
routes := []Route{CreateDestination, GetDestination, ListDestinations, ...}

OpenAPI Generation

At startup, for each operation:

  1. Reflect on In type → extract path/query/body parameters
  2. Reflect on Out type:
    • Check if it implements HTTPStatus() → use that status code, else 200
    • Unwrap generic wrappers (Created[T]T) for the response schema
  3. Reflect on Err type → extract error response schemas from struct fields
  4. Add implicit errors (400, 401, 422, 500)
  5. Build OpenAPI operation and add to spec

Served at GET /api/v1/openapi.json.

Alternatives Considered

Schema-First (oapi-codegen, ogen)

Write OpenAPI YAML first, then generate Go types and handlers from it.

Rejected because: Code-first development is the preferred workflow. Writing Go is more natural than context-switching to YAML, and avoids maintaining separate tooling for schema generation. The API should emerge from the code, not the other way around.

Open Questions

  1. Polymorphic destinations - Destinations have type-specific config/credentials. How do we generate oneOf schemas? Likely leverage destregistry.ProviderMetadata.

Dependencies

  • Auth Refactor (prerequisite) - Provides simplified AuthMode (Public, Tenant, Admin) and TenantScoped contract with *Tenant injection
  • kin-openapi library - For building OpenAPI 3.x spec programmatically

Build-Time Generation (Optional)

If needed for SDK generation or CI workflows, add CLI that dumps runtime spec to file:

go run ./cmd/openapi-gen -output=docs/apis/openapi.yaml

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions