Code Is All You NeedHow we generate our OpenAPI spec directly from the Go types that power our runtime API.
Published on June 6, 202610 min read

Follow me at @dane_albaugh

At Augno we maintain a public API with hundreds of endpoints, a TypeScript SDK, and interactive documentation. Keeping all three in sync used to be manual, tedious, and — predictably — a steady source of drift and bugs.

When we sat down to design our newest release, forge, I decided (for my own sanity) that the whole process had to be automated. Now we generate our OpenAPI specification directly from the same Go types that power our runtime API. There's no spec to hand-edit, and no way for the implementation to fall out of sync with our published docs or SDK. It's also fast:

$ make openapi

Generating OpenAPI spec for api (version 1.0.forge-preview.1)...
OpenAPI spec generated in specs/public_openapi_spec.json (146 public endpoints)
OpenAPI spec generated in specs/internal_openapi_spec.json (464 internal endpoints)

OpenAPI spec generation completed in 226ms

The SDKs and docs are themselves generated from that spec. Each release produces a fresh spec, and a few GitHub workflows pick it up to rebuild the docs and SDKs. Keeping everything current costs us nothing.

Traditional OpenAPI workflows

Most teams maintain OpenAPI specs in one of two ways:

  1. Spec-first: The official1 recommendation. The idea is to first design the API contract and document it by hand writing an OpenAPI specification. You then develop the API to conform to that specification. Tools exist to generate server stubs from a spec, and building your own is trivial if you need something custom. The spec is authoritative but drifts from the implementation as developers make changes without updating the OpenAPI spec.
  2. Code-first: The other approach involves generating an OpenAPI spec directly from source code. Typically, this process is a bit unwieldy since you need to add special comments or struct tags to your handlers to make sure they are properly scraped into a spec. This keeps things closer to the code, but you end up with noisy annotations cluttering your source files, and the spec quality depends on developers remembering to annotate correctly.

Both approaches are flawed in the same way: they treat the spec and the implementation as independent artifacts.

The code is the spec

The endpoint definitions we created earlier already contain everything we need to produce an OpenAPI spec:

// Request to create an account group.
type CreateAccountGroupRequest struct {
	// Display name.
	Name string `json:"name" validate:"required,max=255"`
	// Account group type.
	//
	// Cannot be changed after creation.
	// - `pricing_group`: used for pricing rules, such as a "Preferred" group that receives a special discount.
	// - `type_group`: used to categorize accounts, such as "Consumers" or "Distributors".
	Type constants.AccountGroupType `json:"type" validate:"required"`
	// Commission policy. Defaults to `commission_exempt`.
	//
	// - `commission_exempt`: no commission applies.
	// - `commission_applied`: commission applies; if the account group is within a sales rep's territory, it will be assigned to that rep unless overridden.
	CommissionPolicy field.Optional[constants.CommissionPolicy] `json:"commission_policy,omitzero" default:"commission_exempt"`
	// Freight policy. Defaults to `billed_freight`.
	//
	// - `free_freight`: customers within this group will not have to pay for freight.
	// - `billed_freight`: freight will be applied to any order within this account group, unless overridden elsewhere.
	FreightPolicy field.Optional[constants.FreightPolicy] `json:"freight_policy,omitzero" default:"billed_freight"`
	// Description.
	Description field.Optional[string] `json:"description,omitzero"`
}

This single struct tells us:

  • Property names (from json tags)
  • Which fields are required (from validate:"required", or simply from being a plain value type with no way to be omitted)
  • Which fields are optional (from field.Optional[T]).
  • Enum values (from the constants.AccountGroupType type, which implements EnumValues())
  • Default values (from the default tag)
  • Human-readable markdown descriptions (from the Go doc comments above the struct and each field)

This article explains why the field.Optional[T] and field.Clearable[T] wrapper types exist. The short version is that a Go pointer can't tell an absent key from an explicit null, and these types can.

The endpoint definition tells us the route, method, and response type:

// Creates an account group.
type CreateAccountGroupEndpoint struct{}

func (e *CreateAccountGroupEndpoint) Materialize() *apiendpoint.APIEndpoint[*CreateAccountGroupRequest, *apiresource.AccountGroup] {
    return (&apiendpoint.APIEndpoint[*CreateAccountGroupRequest, *apiresource.AccountGroup]{
        Title:             "Create Account Group",
        Method:            http.MethodPost,
        ContentType:       "application/json",
        Route:             "/v1/sales/account-groups",
        SuccessStatusCode: http.StatusCreated,
        Public:            true,
        Preview:           true,
        ObjectType:        constants.ObjectTypeAccountGroup,
        // ...
    })
}

The generator reads the Go doc comment on the wrapping endpoint struct CreateAccountGroupEndpoint to populate the description for this endpoint. This way, the same comment developers see on hover in their IDE is the same annotation on the OpenAPI spec. The apiendpoint.From(&CreateAccountGroupEndpoint{}) helper used at registration time captures the wrapper's reflect.Type so the generator can look the comment up later.

All the information needed for a complete OpenAPI spec is already expressed in the Go type system. We just need a tool to extract it.

The specification generator

Our spec generator lives in tools/apidocs/ and runs as a simple Go command:

make openapi  # runs: cd tools && go run ./apidocs

It imports the same endpoint group packages used by the API gateway, materializes every group with dummy gRPC clients, and then reflects on the resulting endpoint definitions:

func openAPIEndpointGroups() []apiendpoint.APIEndpointGroup {
    // Dummy clients — we only need the type structure, not live connections
    authClient := &grpcclient.AuthServiceClient{
        Client: struct{ authpb.AuthServiceClient }{},
    }
    coreClient := &grpcclient.CoreServiceClient{
        Client: struct{ pbgrpc.CoreServiceClient }{},
        Sales:  struct{ pbgrpc.CoreSalesServiceClient }{},
        // ... other sub-clients on CoreServiceClient ...
    }

    // Materialize all endpoint groups
    return []apiendpoint.APIEndpointGroup{
        *(&httpgroup.AuthEndpointGroup{}).Materialize(&httpgroup.AuthEndpointGroupConfig{
            AuthClient: authClient,
            CoreClient: coreClient,
        }).APIEndpointGroup,
        *(&httpgroup.APIKeysEndpointGroup{}).Materialize(&httpgroup.APIKeysEndpointGroupConfig{
            AuthClient: authClient,
        }).APIEndpointGroup,
        // ... 80+ more groups ...
    }
}

func main() {
    // ... flag parsing ...
    groups := openAPIEndpointGroups()

    // Generate two specs: public-only and internal (all endpoints)
    generate(groups, "specs/public_openapi_spec.json", true, ver)
    generate(groups, "specs/internal_openapi_spec.json", false, ver)
}

We need to materialize the endpoint groups so the generic type information is accessible via reflection, but we never actually call any gRPC methods.

The generator always uses the exact same endpoint definitions as the running server. If someone adds a field to a request struct, the spec updates automatically on the next make openapi run. We added a simple test to make sure these are always in sync.

Step 1: Walking the endpoint groups

The generate() function iterates over every endpoint in every group, using reflection to read the APIEndpoint struct fields:

func generate(groups []apiendpoint.APIEndpointGroup, outputPath string, publicOnly bool, version string) {
    spec := OpenAPI{
        OpenAPI: "3.0.0",
        Info:    Info{Title: "Augno API", Version: version},
        Paths:   make(map[string]map[string]Operation),
        Components: Components{Schemas: make(map[string]Schema)},
    }

    for _, group := range groups {
        for _, e := range group.Endpoints {
            val := reflect.ValueOf(e)
            if val.Kind() == reflect.Pointer {
                val = val.Elem()
            }

            // Access the embedded APIEndpoint struct
            specField := val.FieldByName("APIEndpoint")
            if !specField.IsValid() {
                specField = val
            }

            // Read endpoint metadata directly from struct fields
            isPublic := specField.FieldByName("Public").Bool()
            if publicOnly && !isPublic {
                continue
            }

            title := specField.FieldByName("Title").String()
            method := specField.FieldByName("Method").String()
            route := specField.FieldByName("Route").String()
            // ...
        }
    }
}

For each endpoint, we build an OpenAPI Operation object. The group title becomes the operation's tag (for grouping in documentation UIs), and the endpoint's title and description become the operation summary and description.

Step 2: Classifying request parameters

Next, we turn the struct tags into the schema source. We flatten the request type's fields and classify each one based on its tags:

reqVal := specField.FieldByName("Request")
reqType := reqVal.Type()
if reqType.Kind() == reflect.Pointer {
    reqType = reqType.Elem()
}

hasJSONFields := false
for _, f := range flattenStructFields(reqType) {
    // Header parameters
    if header := f.Tag.Get("header"); header != "" {
        operation.Parameters = append(operation.Parameters, Parameter{
            Name:     header,
            In:       "header",
            Required: strings.Contains(f.Tag.Get("validate"), "required"),
            Schema:   generateSchema(f.Type, &spec.Components, docReader),
        })
    }

    // Query parameters
    if query := f.Tag.Get("query"); query != "" {
        paramSchema := generateSchema(f.Type, &spec.Components, docReader)
        paramName := query
        // Array query params get [] suffix for consistency
        if paramSchema.Type == "array" && !strings.HasSuffix(paramName, "[]") {
            paramName = paramName + "[]"
        }
        operation.Parameters = append(operation.Parameters, Parameter{
            Name:     paramName,
            In:       "query",
            Required: strings.Contains(f.Tag.Get("validate"), "required"),
            Schema:   paramSchema,
        })
    }

    // Path parameters (always required)
    if pathParam := f.Tag.Get("path"); pathParam != "" {
        operation.Parameters = append(operation.Parameters, Parameter{
            Name:     pathParam,
            In:       "path",
            Required: true,
            Schema:   generateSchema(f.Type, &spec.Components, docReader),
        })
    }

    // Detect body fields
    jsonTag := f.Tag.Get("json")
    if jsonTag != "" && jsonTag != "-" {
        hasJSONFields = true
    }
}

The same struct tags that drive runtime request binding (path, query, header, json) drive OpenAPI parameter classification. A field tagged path:"id" becomes an OpenAPI path parameter. A field tagged query:"limit" becomes a query parameter. Fields with json tags become properties of the request/response body schema.

Step 3: Generating JSON schemas from Go types

The generateSchema() function recursively converts any Go type into an OpenAPI Schema object:

func generateSchema(t reflect.Type, components *Components, docReader *DocReader) Schema {
    if t.Kind() == reflect.Pointer {
        t = t.Elem()
    }

    // Special cases
    if t.PkgPath() == "time" && t.Name() == "Time" {
        return Schema{Type: "string", Format: "date-time"}
    }
    if t.PkgPath() == "encoding/json" && t.Name() == "RawMessage" {
        return Schema{Type: "object"}
    }

    // Primitives
    switch t.Kind() {
    case reflect.String:
        schema := Schema{Type: "string"}
        if enumValues := getEnumValuesForStringType(t); len(enumValues) > 0 {
            schema.Enum = enumValues
        }
        return schema
    case reflect.Int, reflect.Int32, reflect.Int64:
        return Schema{Type: "integer"}
    case reflect.Float32, reflect.Float64:
        return Schema{Type: "number"}
    case reflect.Bool:
        return Schema{Type: "boolean"}
    case reflect.Slice:
        elemSchema := generateSchema(t.Elem(), components, docReader)
        return Schema{Type: "array", Items: &elemSchema}
    }

    // Structs: generate object schema with properties
    schema := Schema{
        Type:       "object",
        Properties: make(map[string]Schema),
    }

    for i := 0; i < t.NumField(); i++ {
        f := t.Field(i)
        jsonTag := f.Tag.Get("json")

        // Skip non-JSON fields
        if jsonTag == "-" || jsonTag == "" {
            continue
        }

        name := strings.Split(jsonTag, ",")[0]

        // `field.Clearable[T]` and `field.Optional[T]` wrap a value but document as the inner type T.
        isClearable := field.IsClearableType(f.Type) // three-state: unset / null / value
        isOptional := field.IsOptionalType(f.Type)   // optional input value

        // Determine if required
        isPointer := f.Type.Kind() == reflect.Pointer
        hasOmitempty := hasOmitempty(jsonTag)            // ",omitempty" or ",omitzero"
        hasRequired := strings.Contains(f.Tag.Get("validate"), "required")

        var isRequired bool
        switch {
        case isClearable || isOptional:
            // Always optional — these wrapper types model omittable inputs.
            isRequired = false
        case f.Type.Kind() == reflect.Slice && hasOmitempty:
            // Slices use omitempty semantics like pointers; an empty slice is omitted.
            isRequired = hasRequired
        default:
            isRequired = hasRequired || !(isPointer && hasOmitempty)
        }

        if isRequired {
            schema.Required = append(schema.Required, name)
        }

        // Build the field schema. Nullability is derived from the field's shape:
        fieldSchema := Schema{}
        switch {
        case isClearable:
            // PATCH field: send a value, omit it, or send null to clear. Null is an accepted wire value, so in a request body nullable means clearable.
            fieldSchema.Nullable = true
        case isPointer && !hasOmitempty:
            // Response-style "value or null" field.
            fieldSchema.Nullable = true
        case isOptional, isPointer && hasOmitempty:
            // Optional input: omit to leave unset; an explicit null is rejected at runtime.
            fieldSchema.Nullable = false
        }

        // ... type-specific handling, enums, nested structs ...

        schema.Properties[name] = fieldSchema
    }

    return schema
}

This function handles every type we use: primitives, slices, maps, nested structs (registered as $ref components), embedded structs (expressed as allOf), and special types like time.Time and json.RawMessage.

Determining required vs optional

The logic for whether a field is required mirrors how our runtime validation works. The optional and clearable wrapper types are always optional. Everything else is required when it is explicitly marked with validate:"required", or when it simply must be present because it is neither an optional pointer nor a slice that can be omitted:

hasRequired := strings.Contains(f.Tag.Get("validate"), "required")

switch {
case isClearable || isOptional:
    // field.Optional[T] and field.Clearable[T] exist precisely to model optional
    // inputs: the client may always omit them, so they are never required. A
    // genuinely required field uses a plain value type T with validate:"required".
    isRequired = false
case f.Type.Kind() == reflect.Slice && hasOmitempty:
    // Slices use omitempty semantics like pointers; an empty slice is omitted.
    isRequired = hasRequired
default:
    isRequired = hasRequired || !(isPointer && hasOmitempty)
}

Both omitempty and omitzero count as omitting the field here, matching what encoding/json actually drops from the payload.

For path parameters specifically, the validate:"required" tag isn't optional. A test statically scans every endpoint and fails the build if any path:"..." field is missing it (or sneaks in an omitempty that would short-circuit the check), so a misbound path param is always rejected rather than flowing through as an empty string.

Determining nullability

Nullability can be a bit tricky, so we use some custom types to help. For example, consider a request that looks like this:

type PatchRequest struct {
    // ...
    Description   *string   `json:"description,omitempty"`
    // ...
}

We know that the field is optional and that if included it will be a string. However, if a client excludes the field from their request, the struct will resolve this to nil, and if the client includes the field in their request as null, the struct will also resolve this to nil.

A pointer cannot tell "absent" from "explicitly null". That ambiguity caused us to utilize two custom wrappers field.Optional[T] (which rejects null) and field.Clearable[T] (which accepts null to clear). We have explained Go's marshal/unmarshal behavior that forces this in detail. Here we only care that each field's type encodes its contract:

PatternField declarationRequiredNullable
Required valueName string `json:"name" validate:"required"`
Optional inputNumber field.Optional[string] `json:"number,omitzero"`
PATCH / clearable fieldNotes field.Clearable[string] `json:"notes,omitzero"`
Response-style pointerLastUsedAt *time.Time `json:"last_used_at"`
Arbitrary JSONjson.RawMessage

The generator unwraps field.Clearable[T] and field.Optional[T] so they are documented as the inner T in the schema.

Finally, OpenAPI 3.0.x requires a nullable enum to include null among its values, so whenever a field ends up both nullable and enumerated we append null to the enum list.

Resolving enums automatically

Go doesn't have any built-in enum support, so we created a package constants for all the system's enums. These all must implement an EnumValues() method and there is a unit test that ensures that is always respected in this package. The generator looks for string types that implement EnumValues() and automatically populates the schema's enum array:

func getEnumValuesForStringType(t reflect.Type) []any {
    if t.Kind() != reflect.String || t.Name() == "" || t.Name() == "string" {
        return nil
    }

    ptrType := reflect.PointerTo(t)
    method, ok := ptrType.MethodByName("EnumValues")
    if !ok {
        return nil
    }

    // Call EnumValues() on a zero-value instance
    zeroVal := reflect.New(t)
    results := method.Func.Call([]reflect.Value{zeroVal})
    // Convert to []any for the schema
    // ...
}

So when a field is typed as constants.AccountGroupType, and that type has:

type AccountGroupType string

const (
    AccountGroupTypePricingGroup AccountGroupType = "pricing_group"
    AccountGroupTypeTypeGroup    AccountGroupType = "type_group"
)

func (m AccountGroupType) EnumValues() []string {
    return []string{string(AccountGroupTypePricingGroup), string(AccountGroupTypeTypeGroup)}
}

The generated schema automatically includes enum: ["pricing_group", "type_group"]. Adding a new enum value to the constants package updates the spec. Enum values are defined once in the constants package, enforced at runtime by validation, and documented in the spec. All three stay in sync automatically.

Step 4: Extracting descriptions from Go source

Property descriptions do not come from struct tags (that would make tags unreadably long). Instead, we parse the Go source files using go/ast to extract doc comments:

type DocReader struct {
    fset *token.FileSet
    docs map[string]map[string]TypeDoc  // pkgPath -> typeName -> TypeDoc
}

type TypeDoc struct {
    Doc    string            // type-level doc comment
    Fields map[string]string // fieldName -> field doc comment
}

func (r *DocReader) loadPackage(pkgPath string) {
    // Find the directory for this package
    dir := resolvePackageDir(pkgPath)

    // Parse every .go file (excluding tests)
    entries, _ := os.ReadDir(dir)
    for _, entry := range entries {
        if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
            continue
        }

        file, _ := parser.ParseFile(r.fset, filepath.Join(dir, entry.Name()), nil, parser.ParseComments)

        for _, decl := range file.Decls {
            gen, ok := decl.(*ast.GenDecl)
            if !ok || gen.Tok != token.TYPE {
                continue
            }

            for _, spec := range gen.Specs {
                typeSpec := spec.(*ast.TypeSpec)
                typeName := typeSpec.Name.Name
                typeDoc := TypeDoc{
                    Doc:    strings.TrimSpace(gen.Doc.Text()),
                    Fields: make(map[string]string),
                }

                if structType, ok := typeSpec.Type.(*ast.StructType); ok {
                    for _, field := range structType.Fields.List {
                        if len(field.Names) > 0 {
                            fieldName := field.Names[0].Name
                            typeDoc.Fields[fieldName] = strings.TrimSpace(field.Doc.Text())
                        }
                    }
                }

                pkgDocs[typeName] = typeDoc
            }
        }
    }
}

Given this source:

// Request to create an API key.
type CreateAPIKeyRequest struct {
    // Role ID assigned to the API key.
    RoleID string `json:"role_id" validate:"required"`
    // Human-readable name for the API key.
    Name string `json:"name" validate:"required,max=255"`
    // Expiration timestamp. If not set, the key does not expire.
    ExpiresAt field.Optional[time.Time] `json:"expires_at,omitzero"`
}

The DocReader produces:

ElementDescription
TypeRequest to create an API key.
RoleIDRole ID assigned to the API key.
NameHuman-readable name for the API key.
ExpiresAtExpiration timestamp. If not set, the key does not expire.

These become the description fields in the OpenAPI schema. Developers write comments and get rich API documentation for free.

Step 5: Generating examples from resource types

Good API documentation needs realistic examples. To make that happen, we created a DocumentedType interface:

type DocumentedType interface {
    SchemaExample() any
}

Resource types implement this to provide a representative sample:

var SampleAPIKey = &APIKey{
    ID:            SampleAPIKeyID, // "apke_01fba3a7db3996e3b3b1a07e00"
    Object:        constants.ObjectTypeAPIKey,
    Name:          SampleAPIKeyName, // "Production API Key"
    RedactedValue: SampleProdAPIKeyRedactedValue, // "aug_sk_prod_****hjt4"
    Role:          SampleRole,
    CreatedAt:     timeutil.TimestampToTime(sampleCreatedAtTimestamp),
    UpdatedAt:     timeutil.TimestampToTime(sampleUpdatedAtTimestamp),
    LastUsedAt:    timeutil.TimestampToTimePtr(sampleUpdatedAtTimestamp),
    ExpiresAt:     timeutil.TimestampToTimePtr(sampleExpiresAtTimestamp),
    RevokedAt:     nil,
}

func (*APIKey) SchemaExample() any {
    return apiexample.ValidateAndMarshalToMap(SampleAPIKey)
}

Pulling shared sample values from package-level constants and a single SampleAPIKey variable keeps the same IDs and timestamps consistent across every endpoint that returns or embeds an API key.

The generator detects this interface via reflection and attaches the example to the schema:

if reflect.PointerTo(t).Implements(reflect.TypeFor[contracts.DocumentedType]()) {
    v := reflect.New(t).Interface().(contracts.DocumentedType)
    func() {
        defer func() {
            if r := recover(); r != nil {
                panic(fmt.Errorf("SchemaExample() panicked for %s: %v", t, r))
            }
        }()
        example = v.SchemaExample()
    }()
}

If an example panics (perhaps referencing uninitialized data), we let it crash the generation process rather than ignoring it. The recover() exists only to name the offending type before re-panicking. A silently missing example would otherwise ship a spec that looks complete but isn't.

For list endpoints, the generator synthesizes pagination examples automatically. It expands the route's path parameters using the same sample IDs used in the parameter examples, then builds a relative next-page URL with a cursor query param:

if strings.HasPrefix(typeName, "List_") && route != "" {
    return map[string]any{
        "object": "list",
        "page_info": map[string]any{
            "next_page_url":     nextPageURL,  // "/v1/.../resources?cursor=..."
            "previous_page_url": nil,
            "has_next_page":     true,
            "has_prev_page":     false,
        },
        "data": []any{itemExample},
    }
}

If the resource type changes and the example no longer compiles, you find out immediately.

Step 6: Handling the include/expand system

Our API supports selective expansion of sub-objects via ?include[]=role. The endpoint declares what is expandable:

IncludeConfig: apiendpoint.IncludesFor(apiendpoint.IncludesParams{
    ObjectType: constants.ObjectTypeAPIKey,
    Fields:     []string{"role", "role.permissions"},
}),

The generator reads this at spec-generation time and synthesizes a proper query parameter:

includeConfigField := specField.FieldByName("IncludeConfig")
if includeConfigField.IsValid() && !includeConfigField.IsNil() {
    includeConfig := includeConfigField.Interface().(*apiendpoint.IncludeConfig)
    allowedKeys := includeConfig.AllowedKeys()

    enumValues := make([]any, len(allowedKeys))
    for i, k := range allowedKeys {
        enumValues[i] = k
    }

    operation.Parameters = append(operation.Parameters, Parameter{
        Name:        "include[]",
        In:          "query",
        Description: "Sub-objects to expand in the response. When omitted, sub-objects are returned as `null`.",
        Schema: Schema{
            Type:  "array",
            Items: &Schema{Type: "string", Enum: enumValues},
        },
        Example: []any{allowedKeys[0]},
    })
}

This query parameter is documented as an enum of all the supported includes fields.

Step 7: Nested type references

When the generator encounters a struct field that is itself a named struct, it registers that type as a reusable component and references it:

case reflect.Struct:
    if name := getCleanTypeName(fieldType); name != "" {
        // Register in components if not already present
        if _, ok := components.Schemas[name]; !ok {
            components.Schemas[name] = generateSchema(fieldType, components, docReader)
        }
        // Reference via allOf (required in OpenAPI 3.0 for $ref + siblings)
        fieldSchema.AllOf = []Schema{{Ref: "#/components/schemas/" + name}}
    }

This means a type like Role that appears in multiple endpoints is defined once in components.schemas and referenced everywhere. The resulting spec is compact and SDK generators can create proper shared types.

For embedded structs (Go's composition mechanism), we use allOf to express inheritance:

if f.Anonymous && jsonTag == "" {
    embeddedName := getCleanTypeName(embeddedType)
    if _, ok := components.Schemas[embeddedName]; !ok {
        components.Schemas[embeddedName] = generateSchema(embeddedType, components, docReader)
    }
    schema.AllOf = append(schema.AllOf, Schema{Ref: "#/components/schemas/" + embeddedName})
}

Step 8: Property ordering

JSON objects are unordered, but documentation is much more readable when properties appear in a logical order (typically matching the struct field declaration order). We track insertion order during generation:

schema.Properties[name] = fieldSchema
schema.PropertyOrder = append(schema.PropertyOrder, name)

After marshaling the spec to JSON, we replace standard maps with ordered maps that serialize keys in the recorded order. This means id always appears first, object second, and so on - matching how developers declare their struct fields.

The output

Running make openapi produces two files:

  • specs/public_openapi_spec.json — Only endpoints marked Public: true. This will be used to generate public documentation and SDKs.
  • specs/internal_openapi_spec.json — All endpoints. Used to generate our internal TypeScript SDK.

Both specs are validated in CI using the vacuum linter to catch structural issues.

Conclusion

The whole thing collapses into one rule: change the Go type, and the spec, docs, and SDKs follow. There's no second artifact to keep honest, no // @Summary annotations to forget, and no way to ship an endpoint that accepts a field the spec doesn't document — the server and the spec are built from the same types, so they can't disagree.

Footnotes

  1. Best Practices - OpenAPI Initiative