a73x

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
}