-
Notifications
You must be signed in to change notification settings - Fork 25
Description
OpenAPI Generation
Problem
The OpenAPI schema (docs/apis/openapi.yaml, 4,053 lines) is manually maintained. This creates:
- Drift risk - Schema can get out of sync with actual API behavior
- Duplication - Request/response types defined in both Go and YAML
- Manual updates - Every API change requires updating the schema
Goals
- Single source of truth - Go code defines the API, OpenAPI is derived
- No build step - Spec generated at runtime, always in sync
- Type-safe - Errors, inputs, outputs are Go types, not strings
- 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:
-
Comments are not type-safe - The annotations are strings that can drift from the actual code. Nothing prevents
@Success 201 {object} WrongTypefrom compiling. -
Requires a build step - Running
swag initbefore 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 (implementsHTTPStatus()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) // 204Pagination
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:
- Reflect on
Intype → extract path/query/body parameters - Reflect on
Outtype:- Check if it implements
HTTPStatus()→ use that status code, else 200 - Unwrap generic wrappers (
Created[T]→T) for the response schema
- Check if it implements
- Reflect on
Errtype → extract error response schemas from struct fields - Add implicit errors (400, 401, 422, 500)
- 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
- Polymorphic destinations - Destinations have type-specific config/credentials. How do we generate
oneOfschemas? Likely leveragedestregistry.ProviderMetadata.
Dependencies
- Auth Refactor (prerequisite) - Provides simplified
AuthMode(Public, Tenant, Admin) andTenantScopedcontract with*Tenantinjection - 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.yamlMetadata
Metadata
Assignees
Labels
Type
Projects
Status