internal/server/api/spec/spec.go
Ref: Size: 7.6 KiB History
// Package spec projects the api route table into an OpenAPI 3.1 document.
// It reflects over the contract types — json tags and Go kinds only, no
// annotations — so the document can never drift from the code.
//
// Two deliberate asymmetries encode how the server actually behaves:
// request schemas emit NO required array (the server defaults absent fields;
// hand-rolled validation is the authority), while response schemas require
// every non-pointer field (writeJSON always emits all of them). Pointer
// FIELDS become nullable; a typed-nil pointer exemplar at a route's top
// level just means "this struct is the payload" and is unwrapped, never
// rendered nullable.
package spec
import (
"encoding/json"
"fmt"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/a73x/eitri/internal/server/api"
"github.com/a73x/eitri/internal/server/api/types"
)
// typesPkgPath guards the wire boundary: every struct that reaches the spec
// must live in the contract package.
var typesPkgPath = reflect.TypeFor[types.Host]().PkgPath()
var pathParamRe = regexp.MustCompile(`\{([a-z]+)\}`)
// Generate renders the full OpenAPI 3.1 document for api.Routes(),
// deterministically (sorted keys, sorted required arrays, trailing newline).
func Generate() ([]byte, error) {
g := &generator{schemas: map[string]any{}, direction: map[string]bool{}}
paths := map[string]any{}
for _, r := range api.Routes() {
item, _ := paths[r.Path].(map[string]any)
if item == nil {
item = map[string]any{}
paths[r.Path] = item
}
item[strings.ToLower(r.Method)] = g.operation(r)
}
doc := map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": "eitri server API",
"version": "v1", // the /api/v1 surface version, not a release
},
"paths": paths,
"components": map[string]any{
"schemas": g.schemas,
"securitySchemes": map[string]any{
"patToken": map[string]any{"type": "http", "scheme": "bearer"},
},
},
}
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
type generator struct {
schemas map[string]any // components.schemas, memoized by type name
// direction remembers which side (request=true) first memoized each
// schema. A schema's shape depends on the side — request structs carry no
// required array — so one type reached from both sides would silently get
// whichever shape came first; sharing is instead rejected loudly in
// schemaFor, forcing the contract to keep the sets disjoint (the route
// table test pins the top level; this guards nested structs too).
direction map[string]bool
}
func (g *generator) operation(r api.Route) map[string]any {
op := map[string]any{"summary": r.Doc}
var params []any
for _, m := range pathParamRe.FindAllStringSubmatch(r.Path, -1) {
params = append(params, map[string]any{
"name": m[1],
"in": "path",
"required": true,
"schema": map[string]any{"type": "string"},
})
}
for _, q := range r.Query {
params = append(params, map[string]any{
"name": q.Name,
"in": "query",
"required": false,
"description": q.Doc,
"schema": map[string]any{"type": "string"},
})
}
if len(params) > 0 {
op["parameters"] = params
}
if r.Auth == api.AuthUser {
op["security"] = []any{map[string]any{"patToken": []any{}}}
}
if r.Request != nil {
op["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": g.schemaFor(rootType(r.Request), true),
},
},
}
}
responses := map[string]any{}
success := strconv.Itoa(r.Success)
switch r.Kind {
case api.KindSSE:
responses[success] = map[string]any{
"description": "success",
"content": map[string]any{
"text/event-stream": map[string]any{
"schema": g.schemaFor(rootType(r.Response), false),
},
},
}
case api.KindWS:
responses[success] = map[string]any{
"description": "switching protocols (WebSocket)",
}
default:
resp := map[string]any{"description": "success"}
if r.Response != nil {
resp["content"] = map[string]any{
"application/json": map[string]any{
"schema": g.schemaFor(rootType(r.Response), false),
},
}
}
responses[success] = resp
}
responses["default"] = map[string]any{
"description": "error (plain text)",
"content": map[string]any{
"text/plain": map[string]any{"schema": map[string]any{"type": "string"}},
},
}
op["responses"] = responses
return op
}
// rootType unwraps ONE pointer level from a route exemplar: a typed-nil
// pointer at the top level means "this struct is the payload", not nullable.
func rootType(exemplar any) reflect.Type {
t := reflect.TypeOf(exemplar)
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t
}
// schemaFor renders one Go type as a JSON schema. The request flag threads
// through nesting so request-side structs skip the required array.
func (g *generator) schemaFor(t reflect.Type, request bool) map[string]any {
switch t {
case reflect.TypeFor[time.Time]():
return map[string]any{"type": "string", "format": "date-time"}
case reflect.TypeFor[json.RawMessage]():
return map[string]any{} // any JSON value
}
switch t.Kind() {
case reflect.Pointer:
return nullable(g.schemaFor(t.Elem(), request))
case reflect.Slice:
return map[string]any{"type": "array", "items": g.schemaFor(t.Elem(), request)}
case reflect.String:
return map[string]any{"type": "string"}
case reflect.Bool:
return map[string]any{"type": "boolean"}
case reflect.Int, reflect.Int64, reflect.Uint64:
return map[string]any{"type": "integer"}
case reflect.Float64:
return map[string]any{"type": "number"}
case reflect.Struct:
if t.PkgPath() != typesPkgPath {
panic("spec: non-contract struct on the wire: " + t.String())
}
name := t.Name()
if _, seen := g.schemas[name]; !seen {
g.schemas[name] = nil // reserve before recursing (cycle safety)
g.direction[name] = request
g.schemas[name] = g.structSchema(t, request)
} else if g.direction[name] != request {
panic("spec: contract type " + name + " is reachable from both request and response sides; split it — the sides get different schemas")
}
return map[string]any{"$ref": "#/components/schemas/" + name}
default:
panic(fmt.Sprintf("spec: unsupported kind %s for %s", t.Kind(), t))
}
}
func (g *generator) structSchema(t reflect.Type, request bool) map[string]any {
props := map[string]any{}
var required []string
for i := range t.NumField() {
f := t.Field(i)
if !f.IsExported() {
continue
}
name, opts, _ := strings.Cut(f.Tag.Get("json"), ",")
if name == "" || name == "-" {
continue
}
props[name] = g.schemaFor(f.Type, request)
// Responses require every field writeJSON is guaranteed to emit:
// non-pointer, no omitempty (none exists in the contract today).
if !request && f.Type.Kind() != reflect.Pointer && !hasOpt(opts, "omitempty") {
required = append(required, name)
}
}
s := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
sort.Strings(required)
s["required"] = required
}
return s
}
func hasOpt(opts, want string) bool {
for opt := range strings.SplitSeq(opts, ",") {
if opt == want {
return true
}
}
return false
}
// nullable widens a field schema for a pointer field: $refs wrap in
// anyOf [$ref, null]; typed schemas grow "null" into their type; typeless
// schemas (raw JSON) already admit null.
func nullable(s map[string]any) map[string]any {
if _, isRef := s["$ref"]; isRef {
return map[string]any{"anyOf": []any{s, map[string]any{"type": "null"}}}
}
if typ, ok := s["type"].(string); ok {
s["type"] = []any{typ, "null"}
}
return s
}