Artifact b3244c2231bbebcb7824de2d35d0431cb83e4a8fcbb387a5b9f8ff0bd973622f:
- File
internal/ops/spec.go
— part of check-in
[839e3e756a]
at
2026-08-15 16:13:52
on branch trunk
— search fixes: folded ticket docs, mark snippets, honest limit (050/060/080/130)
search_doc gains label; tickets one folded doc per id; ⟦⟧ markers become <mark> post-escape; default= validate rule fills absent keys. (user: dmestas, size: 6877) [annotate] [blame] [check-ins using]
// 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 } 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) 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)) 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 } }