Skip to content
quarry Artifact [336e7ad063]
Login

Artifact [336e7ad063]

Artifact 336e7ad063c9a46e84c34c269b6cc88b1fe931fbd3ead4364510c03c0de24f66:


// 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 {
		// The scheme that AUTHENTICATES. The spec used to declare a
		// bearer token — 147 of 209 operations required it — and the
		// router has never accepted one: Authorization: Bearer answers
		// 401 and the 401 advertises Basic. A client generated from the
		// document built a bearer client, failed on every call, and had
		// nothing in the document saying why (a907073c, G-5). Basic is
		// what the router reads (req.BasicAuth) and what it asks for.
		spec.Components = &openapi.Components{
			SecuritySchemes: map[string]openapi.SecurityScheme{
				"basicAuth": {Type: "http", Scheme: "basic",
					Description: "Account name and password, as the router's WWW-Authenticate asks for. " +
						"The sync token minted by me.sync-token is a PUSH credential for the sync protocol, not an API credential."},
			},
		}
	}
	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{{"basicAuth": {}}}
		level := "token"
		if d.Auth == Admin {
			level = "admin"
		}
		op.Extensions = map[string]any{"x-quarry-auth": level}
		// The level is the router's floor, not the gate. mirror.list is
		// level "token" and refuses every ordinary account: it needs
		// capability a, which a site admin grants. A document that
		// stops at the level tells a client any signed-in account may
		// call it (763c5c89). So the letters and what they are asked
		// about travel with the operation, and in its summary, where a
		// generated client's doc comment will carry them.
		if d.Caps != "" {
			op.Extensions["x-quarry-caps"] = d.Caps
			op.Extensions["x-quarry-scope"] = d.Scope.String()
			op.Summary = strings.TrimSuffix(op.Summary, ".") +
				". Needs capability " + capsPhrase(d.Caps) + " on the " + d.Scope.String() + "."
		}
	}
	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) {
	// A POINTER field is the tri-state an In struct uses when absent has
	// to mean something other than the zero value — "keep what is
	// there" rather than "set it to false". The schema describes the
	// value it carries; that the key may be omitted is already what
	// absence from `required` says.
	if t.Kind() == reflect.Pointer {
		return fieldSchema(t.Elem(), r)
	}
	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
	}
}

// capsPhrase reads a Caps string the way the gate does: ANY listed
// letter passes, so "kf" is "k or f", and a single letter is itself.
func capsPhrase(caps string) string {
	if len(caps) <= 1 {
		return caps
	}
	letters := strings.Split(caps, "")
	return strings.Join(letters[:len(letters)-1], ", ") + " or " + letters[len(letters)-1]
}