Skip to content

Schemas

wiki edited this page Sep 4, 2026 · 1 revision

Schemas

Go types become JSON Schema 2020-12 — the dialect OpenAPI 3.1 uses — by reflection over the struct.

type CreateUserRequest struct {
	Email   string   `json:"email"   validate:"required,email"`
	Name    string   `json:"name"    validate:"required,min=2,max=100"`
	Age     int      `json:"age"     validate:"gte=18,lte=120"`
	Role    string   `json:"role"    validate:"required,oneof=admin user guest"`
	Website *string  `json:"website"`
	Tags    []string `json:"tags"`
	Address Address  `json:"address"`
}

becomes

{
  "$ref": "#/components/schemas/CreateUserRequest"
}

with, in components/schemas:

{
  "type": "object",
  "required": ["email", "name", "role"],
  "properties": {
    "email":   {"type": "string"},
    "name":    {"type": "string", "minLength": 2, "maxLength": 100},
    "age":     {"type": "integer", "minimum": 18, "maximum": 120},
    "role":    {"type": "string", "enum": ["admin", "user", "guest"]},
    "website": {"type": "string", "nullable": true},
    "tags":    {"type": "array", "items": {"type": "string"}},
    "address": {"$ref": "#/components/schemas/Address"}
  }
}

What is read

From the Go type Into the schema
string, int, float64, bool type, and format where it applies
struct an object, hoisted into components/schemas and referenced by $ref
slice / array type: array with items
pointer the pointee's schema, nullable: true
map an object
json:"name" the property name
json:"-" omitted

What the validate tags contribute

Tag Schema
required added to the object's required list
min / max on a string minLength / maxLength
gte / lte on a number minimum / maximum
oneof=a b c enum

So the documented constraints are the ones actually enforced — the same tags the validation extension reads. A constraint cannot drift out of the document, because there is only one declaration.

Composition

rextension.Scalar(CreateUserRequest{})          // $ref
rextension.OneOf(CardPayment{}, BankTransfer{}) // oneOf: [$ref, $ref]
rextension.AnyOf(EmailContact{}, SMSContact{})  // anyOf
rextension.AllOf(BaseEvent{}, OrderPayload{})   // allOf

These map onto the OpenAPI keywords of the same names, so the document says exactly what the validator enforces.

Component hoisting

Every named struct is registered in components/schemas and referenced by $ref, so a type used by twenty routes appears once. Nested structs are hoisted recursively.

The component key is the Go type name. Two types with the same name in different packages collide — rename one, or wrap it:

type UserResponse struct{ users.User }   // distinct name in the document

Documenting error bodies

func (r *GetUser) Responses() map[int]rextension.BodySchema {
	return map[int]rextension.BodySchema{
		200: rextension.Scalar(UserResponse{}),
		404: rextension.Scalar(rextension.Problem{}),
		500: rextension.Scalar(rextension.Problem{}),
	}
}

rextension.Problem is the shape of every framework and extension error, so documenting it once per status makes the document match reality. A helper keeps it terse:

func problems(codes ...int) map[int]rextension.BodySchema {
	m := make(map[int]rextension.BodySchema, len(codes))
	for _, c := range codes {
		m[c] = rextension.Scalar(rextension.Problem{})
	}
	return m
}

Using the generator directly

g := openapi.NewSchemaGenerator()
s := g.Generate(CreateUserRequest{})
components := g.Components()

Useful in a test that asserts a type's schema, or for generating schemas outside the extension.

Limits

  • No format inference from validate tags. validate:"email" contributes required-style information but does not emit format: email. Add it in the description if it matters to your consumers.
  • Interfaces and any produce an untyped object. The generator reflects on a static type; if a field is interface{}, there is nothing to reflect on.
  • Recursive types are handled by the $ref hoisting — a type referring to itself references its own component.

The reflection that was removed

The generator used to call RequestBody() and Responses() through reflect.MethodByName, on the reasoning that "any route implementing validation.ValidatableRoute works automatically". Reflection was really a workaround for the two extensions having no shared type — and it came with an unchecked .([]interface{}) assertion that panicked inside document generation on any unexpected slice type.

The contract now lives in rextension, both extensions assert the same named interface, and the reflection is gone.

Clone this wiki locally