Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

RFC: OpenAPI Generation from Go Types

未关闭
#644 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
5/5
预计耗时
一周以上
新手友好度
15/100
Issue 类型
功能
描述清晰度
需要澄清
活跃度
停滞
技术栈
go

调研方向

Start by reading this RFC and inspecting the existing route definitions and docs/apis/openapi.yaml; the issue does not name the route-definition file or tests. The proposal depends on an auth refactor and leaves polymorphic destination schemas open. Clarify the design and scope with maintainers before implementation; the issue does not define a concrete completion criterion.

由索引模型根据 Issue 内容生成。

描述

documentation

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
主要语言
Go
星标
1.1k
派生
70
平均合并
1 天 18 小时
30 天内合并 PR
56

环境准备

这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

hookdeck/outpost 的其他 Issue

查看 hookdeck/outpost 的全部 Issue

相似的 Issue

更多 Go Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。