Skip to content
quarry Artifact [1a88fa1cec]
Login

Artifact [1a88fa1cec]

Artifact 1a88fa1cecb9ef1169ffa70f3259a32048e759c302c979e91d98fda6adcd354d:


// 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)
		}
		item := spec.Paths[d.Pattern]
		if d.Method == "GET" {
			item.Get = op
		} else {
			item.Post = op
		}
		spec.Paths[d.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)
		}
		switch {
		case slices.Contains(wild, name):
			op.Parameters = append(op.Parameters, openapi.Parameter{
				Name: name, In: "path", Required: true,
				Schema: fieldSchema(f.Type, rules),
			})
		case d.Method == "GET":
			op.Parameters = append(op.Parameters, openapi.Parameter{
				Name: name, In: "query", Required: rules.required,
				Schema: fieldSchema(f.Type, rules),
			})
		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
	}
	op.Responses["200"] = openapi.Response{
		Description: "OK",
		Content:     map[string]openapi.MediaType{"application/json": {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)
		props[name] = fieldSchema(f.Type, rules)
		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 flat field type plus its validate rules to a JSON
// Schema fragment. The kinds here are exactly the codec's setScalar set;
// anything else was already rejected at decode/registration boundaries.
func fieldSchema(t reflect.Type, r rule) json.RawMessage {
	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)
	case reflect.Float32, reflect.Float64:
		s["type"] = "number"
		numBounds(s, r)
	case reflect.Slice:
		s["type"] = "array"
		s["items"] = json.RawMessage(fieldSchema(t.Elem(), rule{}))
		if r.hasMin {
			s["minItems"] = int(r.min)
		}
		if r.hasMax {
			s["maxItems"] = int(r.max)
		}
	}
	b, _ := json.Marshal(s)
	return b
}

func numBounds(s map[string]any, r rule) {
	if r.hasMin {
		s["minimum"] = r.min
	}
	if r.hasMax {
		s["maximum"] = r.max
	}
}