// spec.go — the OpenAPI reflector. The registry generates the spec, never
// the other way around (0001 §9). Document assembly types come from
// dagnats/openapi; this file is only the struct-reflection front half.
// The `validate` rules feed the schemas, so validation and documentation
// have one source of truth.
package ops
import (
"encoding/json"
"fmt"
"maps"
"reflect"
"slices"
"strings"
"github.com/danmestas/dagnats/openapi"
)
// Spec assembles the OpenAPI 3.1 document for every registered op.
// Deterministic for a given registry: ops in All() order, object keys
// sorted by encoding/json.
func Spec(r *Registry, title, version string) (openapi.Spec, error) {
spec := openapi.Spec{
OpenAPI: "3.1.0",
Info: openapi.Info{Title: title, Version: version,
Description: "Generated from the op registry — the registry is the source of truth, never this document."},
Paths: map[string]openapi.PathItem{},
}
needsAuth := false
tagSet := map[string]bool{}
for _, d := range r.All() {
op, err := operation(d)
if err != nil {
return spec, fmt.Errorf("ops: spec: %s: %w", d.Name, err)
}
// ServeMux's {name...} rest-wildcard has no OpenAPI spelling;
// the spec paths use plain {name} templating.
pattern := strings.ReplaceAll(d.Pattern, "...}", "}")
item := spec.Paths[pattern]
if d.Method == "GET" {
item.Get = op
} else {
item.Post = op
}
spec.Paths[pattern] = item
needsAuth = needsAuth || d.Auth != Anonymous
tagSet[op.Tags[0]] = true
}
// Spec has no Tags field; the extension map merges standard top-level
// keys, so global tag declarations ride through it.
tags := make([]map[string]any, 0, len(tagSet))
for _, name := range slices.Sorted(maps.Keys(tagSet)) {
tags = append(tags, map[string]any{"name": name})
}
if len(tags) > 0 {
spec.Extensions = map[string]any{"tags": tags}
}
if needsAuth {
spec.Components = &openapi.Components{
SecuritySchemes: map[string]openapi.SecurityScheme{
"bearerToken": {Type: "http", Scheme: "bearer",
Description: "Forge access token (0001 §8)."},
},
}
}
return spec, nil
}
func operation(d *Descriptor) (*openapi.Operation, error) {
wild := Wildcards(d.Pattern)
op := &openapi.Operation{
OperationID: d.Name,
Summary: d.Doc,
Tags: []string{strings.SplitN(d.Name, ".", 2)[0]},
Responses: map[string]openapi.Response{},
}
// In: path wildcards become path parameters for both kinds; the rest
// are query parameters (GET) or the request body (POST) — mirroring
// exactly what the router+codec do at runtime.
var bodyFields []reflect.StructField
for i := 0; i < d.In.NumField(); i++ {
f := d.In.Field(i)
name := fieldName(f)
if name == "" || !f.IsExported() {
continue
}
rules, err := parseRules(f.Tag.Get("validate"))
if err != nil {
return nil, fmt.Errorf("field %s: %w", f.Name, err)
}
schema, err := fieldSchema(f.Type, rules)
if err != nil {
return nil, fmt.Errorf("field %s: %w", f.Name, err)
}
switch {
case slices.Contains(wild, name):
op.Parameters = append(op.Parameters, openapi.Parameter{
Name: name, In: "path", Required: true, Schema: schema,
})
case d.Method == "GET":
op.Parameters = append(op.Parameters, openapi.Parameter{
Name: name, In: "query", Required: rules.required, Schema: schema,
})
default:
bodyFields = append(bodyFields, f)
}
}
if len(bodyFields) > 0 {
schema, err := structSchema(bodyFields)
if err != nil {
return nil, err
}
// One schema, both codecs (0001 §9: In is expressible as JSON
// and as form values).
op.RequestBody = &openapi.RequestBody{
Required: true,
Content: map[string]openapi.MediaType{
"application/json": {Schema: schema},
"application/x-www-form-urlencoded": {Schema: schema},
},
}
}
outSchema, err := structSchema(exportedFields(d.Out))
if err != nil {
return nil, err
}
// A Stream op's body is Server-Sent Events, not one JSON document;
// Out still documents the shape of each event's data: line.
respType := "application/json"
desc := "OK"
if d.Kind == Stream {
respType = "text/event-stream"
desc = "Server-Sent Events; id: is the resume cursor, honours Last-Event-ID"
op.Parameters = append(op.Parameters, openapi.Parameter{
Name: "Last-Event-ID", In: "header", Required: false,
Schema: json.RawMessage(`{"type":"string"}`),
})
}
op.Responses["200"] = openapi.Response{
Description: desc,
Content: map[string]openapi.MediaType{respType: {Schema: outSchema}},
}
if d.Auth != Anonymous {
op.Security = []map[string][]string{{"bearerToken": {}}}
level := "token"
if d.Auth == Admin {
level = "admin"
}
op.Extensions = map[string]any{"x-quarry-auth": level}
}
return op, nil
}
func exportedFields(t reflect.Type) []reflect.StructField {
var out []reflect.StructField
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.IsExported() && fieldName(f) != "" {
out = append(out, f)
}
}
return out
}
// structSchema builds an object schema over fields, with required drawn
// from the `required` validate rule.
func structSchema(fields []reflect.StructField) (json.RawMessage, error) {
props := map[string]json.RawMessage{}
var required []string
for _, f := range fields {
rules, err := parseRules(f.Tag.Get("validate"))
if err != nil {
return nil, fmt.Errorf("field %s: %w", f.Name, err)
}
name := fieldName(f)
s, err := fieldSchema(f.Type, rules)
if err != nil {
return nil, fmt.Errorf("field %s: %w", f.Name, err)
}
props[name] = s
if rules.required {
required = append(required, name)
}
}
slices.Sort(required)
obj := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
obj["required"] = required
}
return json.Marshal(obj)
}
// fieldSchema maps one field type plus its validate rules to a JSON
// Schema fragment. Scalars and slices of scalars mirror the codec's
// setScalar set; nested structs are legal in Out only (JSON responses),
// which Register's In-flatness is what enforces.
func fieldSchema(t reflect.Type, r rule) (json.RawMessage, error) {
s := map[string]any{}
switch t.Kind() {
case reflect.String:
s["type"] = "string"
if r.hasMin {
s["minLength"] = int(r.min)
}
if r.hasMax {
s["maxLength"] = int(r.max)
}
if len(r.oneof) > 0 {
s["enum"] = r.oneof
}
case reflect.Bool:
s["type"] = "boolean"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
s["type"] = "integer"
numBounds(s, r)
if r.hasDef {
s["default"] = int(r.def)
}
case reflect.Float32, reflect.Float64:
s["type"] = "number"
numBounds(s, r)
if r.hasDef {
s["default"] = r.def
}
case reflect.Slice:
s["type"] = "array"
items, err := fieldSchema(t.Elem(), rule{})
if err != nil {
return nil, err
}
s["items"] = items
if r.hasMin {
s["minItems"] = int(r.min)
}
if r.hasMax {
s["maxItems"] = int(r.max)
}
case reflect.Struct:
return structSchema(exportedFields(t))
case reflect.Map:
// Only string-keyed maps are legal Out shapes (In stays flat,
// enforced at Register) — a per-repo or per-account rollup
// keyed by name (chat.unread's Repos, RFC 0011 §1) is exactly
// this shape and otherwise flattens awkwardly into an array.
if t.Key().Kind() != reflect.String {
return nil, fmt.Errorf("map key must be string, got %s", t.Key().Kind())
}
items, err := fieldSchema(t.Elem(), rule{})
if err != nil {
return nil, err
}
s["type"] = "object"
s["additionalProperties"] = json.RawMessage(items)
return json.Marshal(s)
default:
return nil, fmt.Errorf("unsupported kind %s in op schema", t.Kind())
}
return json.Marshal(s)
}
func numBounds(s map[string]any, r rule) {
if r.hasMin {
s["minimum"] = r.min
}
if r.hasMax {
s["maximum"] = r.max
}
}