diff --git a/cmd/omni/agent_help.go b/cmd/omni/agent_help.go index 3b42409..e70be6f 100644 --- a/cmd/omni/agent_help.go +++ b/cmd/omni/agent_help.go @@ -78,12 +78,31 @@ Set OMNI_API_TOKEN env var, or run: omni config init users User/group role management, set user attribute values config CLI configuration profiles +## Discovering request body shapes +Any command that takes a body accepts --schema. It prints the body's JSON +schema (field types, descriptions, enums, required fields) plus a filled-in +example, then exits without making an API call (no token needed). Use this +instead of guessing the JSON for --body. + omni query run --schema + omni connections create --schema --compact + +Deeply nested bodies (e.g. documents v2-create) can be large. Narrow the +output with --depth N for a shallow overview, then --field PATH to drill into +one part. PATH is dotted and auto-descends through arrays and maps, so you can +name a leaf without knowing the container shape: + omni documents v2-create --schema --depth 1 # top-level overview + omni documents v2-create --schema --field queryPresentations.data # just the tiles map + omni documents v2-create --schema --field queryPresentations.data.query + ## Common Flags --compact Non-indented JSON output --token TOKEN API token (overrides env/config) --base-url URL API base URL (overrides config) --profile NAME Config profile to use --body JSON Request body (JSON string or "-" for stdin) + --schema Print the request body's schema + example, then exit + --field PATH With --schema: drill into a dotted field path + --depth N With --schema: cap nesting depth (lower = smaller) ## Tips - Use "omni ai generate-query" to answer data questions — it picks fields and filters for you. diff --git a/go.mod b/go.mod index 9ee05a3..1fe5bb7 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/pb33f/libopenapi v0.34.4 github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v4 v4.0.0-rc.4 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.41.0 ) @@ -31,7 +32,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect ) diff --git a/internal/openapi/generate.go b/internal/openapi/generate.go index 2eb218b..a0662bb 100644 --- a/internal/openapi/generate.go +++ b/internal/openapi/generate.go @@ -91,6 +91,7 @@ type operationInfo struct { PathParams []paramInfo QueryParams []paramInfo HasBody bool + BodySchema *base.SchemaProxy // request body schema, when HasBody Deprecated bool } @@ -157,6 +158,7 @@ func extractOperations(pathStr string, item *v3.PathItem, groups map[string][]*o // Check for request body if op.RequestBody != nil { info.HasBody = true + info.BodySchema = requestBodySchema(op.RequestBody) } groups[tag] = append(groups[tag], info) @@ -259,7 +261,7 @@ func buildCommand(op *operationInfo, exec Executor) *cobra.Command { // If the operation accepts a body, add --body and --json-body flags if op.HasBody { - cmd.Flags().String("body", "", `request body as JSON string, or "-" for stdin`) + cmd.Flags().String("body", "", `request body as JSON string, or "-" for stdin (run with --schema to see its shape)`) cmd.Flags().String("json-body", "", `request body as JSON string, or "-" for stdin (alias for --body)`) cmd.Flags().MarkHidden("json-body") } @@ -269,9 +271,72 @@ func buildCommand(op *operationInfo, exec Executor) *cobra.Command { applyBodyShorthand(cmd, op, sh) } + // Add the --schema discovery flag last, wrapping arg validation and RunE so + // it short-circuits before any positional-arg checks, body assembly, auth, + // or network call. This lets `omni --schema` work with no args/token. + if op.HasBody { + cmd.Flags().Bool("schema", false, "print the request body's JSON schema and a filled-in example, then exit (no API call)") + // --field / --depth refine the --schema output for deeply nested bodies. + // Guarded so a future query/path param of the same name can't panic the + // flag registration. + if cmd.Flags().Lookup("field") == nil { + cmd.Flags().String("field", "", "with --schema: drill into a dotted field path (e.g. queryPresentations.data.query); auto-descends arrays and maps") + } + if cmd.Flags().Lookup("depth") == nil { + cmd.Flags().Int("depth", maxSchemaDepth, "with --schema: max nesting depth to expand; lower for a compact overview") + } + + innerArgs := cmd.Args + cmd.Args = func(c *cobra.Command, args []string) error { + if schemaRequested(c) || innerArgs == nil { + return nil + } + return innerArgs(c, args) + } + + innerRun := cmd.RunE + cmd.RunE = func(c *cobra.Command, args []string) error { + if schemaRequested(c) { + // A schema error (e.g. a bad --field path) should print just the + // helpful message, not the full usage block. + c.SilenceUsage = true + return emitBodySchema(c, op) + } + return innerRun(c, args) + } + } + return cmd } +// schemaRequested reports whether the --schema discovery flag is set. +func schemaRequested(cmd *cobra.Command) bool { + v, err := cmd.Flags().GetBool("schema") + return err == nil && v +} + +// requestBodySchema returns the schema for a request body, preferring the +// application/json media type and falling back to the first declared one. +func requestBodySchema(rb *v3.RequestBody) *base.SchemaProxy { + if rb == nil || rb.Content == nil { + return nil + } + var first *base.SchemaProxy + for pair := rb.Content.First(); pair != nil; pair = pair.Next() { + mt := pair.Value() + if mt == nil || mt.Schema == nil { + continue + } + if pair.Key() == "application/json" { + return mt.Schema + } + if first == nil { + first = mt.Schema + } + } + return first +} + // commandName derives a CLI subcommand name from the operationId or method+path. func commandName(op *operationInfo) string { if op.OperationID != "" { diff --git a/internal/openapi/schema.go b/internal/openapi/schema.go new file mode 100644 index 0000000..3816c4c --- /dev/null +++ b/internal/openapi/schema.go @@ -0,0 +1,521 @@ +package openapi + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/spf13/cobra" + "go.yaml.in/yaml/v4" +) + +// maxSchemaDepth caps how deep we expand nested objects when describing a +// request body. Some bodies (notably the v2 document content blob) nest very +// deeply; without a cap a single --schema dump could be megabytes. Beyond this +// depth we emit a short placeholder noting the omission rather than recursing. +const maxSchemaDepth = 8 + +// bodySchemaDoc is the JSON document emitted by `omni --schema`. It gives +// an agent both the authoritative contract (Body) and a copy-pasteable starting +// point (Example) for an operation's request body. +type bodySchemaDoc struct { + Method string `json:"method"` + Path string `json:"path"` + Field string `json:"field,omitempty"` + Required []string `json:"required,omitempty"` + Body interface{} `json:"body"` + Example interface{} `json:"example,omitempty"` +} + +// describer carries the per-invocation expansion budget so --depth can override +// the default cap without threading it through every recursive call or reaching +// for a package global (which would not be concurrency-safe under tests). +type describer struct { + maxDepth int +} + +// emitBodySchema writes the resolved request-body schema and a synthesized +// example to the command's stdout, honoring the global --compact flag. It makes +// no network call and needs no auth. The optional --field flag drills into a +// dotted sub-path of the body; --depth caps how deep nested objects expand. +func emitBodySchema(cmd *cobra.Command, op *operationInfo) error { + field, _ := cmd.Flags().GetString("field") + depth, derr := cmd.Flags().GetInt("depth") + if derr != nil || depth < 0 { + depth = maxSchemaDepth + } + + doc, err := describeBody(op, field, depth) + if err != nil { + return err + } + + compact, _ := cmd.Flags().GetBool("compact") + var data []byte + if compact { + data, err = json.Marshal(doc) + } else { + data, err = json.MarshalIndent(doc, "", " ") + } + if err != nil { + return fmt.Errorf("encoding schema: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil +} + +// describeBody builds the schema document for an operation's request body. When +// field is non-empty it drills to that dotted sub-path; maxDepth caps nested +// expansion. Drilling restarts the depth budget from the resolved node, so a +// deep field still expands fully. +func describeBody(op *operationInfo, field string, maxDepth int) (bodySchemaDoc, error) { + doc := bodySchemaDoc{Method: op.Method, Path: op.Path, Field: field} + if op.BodySchema == nil { + return doc, nil + } + + root := op.BodySchema + if field != "" { + resolved, err := resolveField(root, field) + if err != nil { + return doc, err + } + root = resolved + } + + d := &describer{maxDepth: maxDepth} + body := d.simplify(root, 0, nil) + doc.Body = body + if m, ok := body.(map[string]interface{}); ok { + if req, ok := m["required"].([]string); ok { + doc.Required = req + } + } + doc.Example = d.synth(root, "", 0, nil) + return doc, nil +} + +// resolveField walks a dotted path (e.g. "queryPresentations.data.query") from +// the request-body schema to a sub-schema. A plain segment selects an object +// property (flattening allOf). When a segment doesn't name a property, the +// walker transparently descends through array items and map +// (additionalProperties) values and retries — so callers can write +// "queryPresentations.data.query" without knowing data is a map keyed by tab +// ID. It returns the resolved proxy, or an error naming the failing segment and +// listing the fields available there. +func resolveField(root *base.SchemaProxy, path string) (*base.SchemaProxy, error) { + cur := root + segs := strings.Split(path, ".") + for i, seg := range segs { + seg = strings.TrimSpace(seg) + if seg == "" { + return nil, fmt.Errorf("--field %q has an empty path segment", path) + } + next, err := descendTo(cur, seg) + if err != nil { + return nil, fmt.Errorf("--field %q: %v", strings.Join(segs[:i+1], "."), err) + } + cur = next + } + return cur, nil +} + +// descendTo finds the schema for property `seg` reachable from proxy, unwrapping +// any array/map container layers in between. The unwrap loop is bounded by the +// set of $refs already visited so a recursive container can't spin forever. +func descendTo(proxy *base.SchemaProxy, seg string) (*base.SchemaProxy, error) { + seen := map[string]bool{} + for { + if proxy == nil { + return nil, fmt.Errorf("no field %q", seg) + } + if proxy.IsReference() { + ref := proxy.GetReference() + if seen[ref] { + return nil, fmt.Errorf("no field %q", seg) + } + seen[ref] = true + } + sch := proxy.Schema() + if sch == nil { + return nil, fmt.Errorf("no field %q", seg) + } + + _, props := gatherObject(sch, nil) + if p, ok := props[seg]; ok { + return p, nil + } + + // Not a direct property — unwrap one container layer and retry the same + // segment one level in (arrays carry their value in items, maps in + // additionalProperties). + switch { + case sch.Items != nil && sch.Items.IsA(): + proxy = sch.Items.A + case sch.AdditionalProperties != nil && sch.AdditionalProperties.IsA(): + proxy = sch.AdditionalProperties.A + default: + return nil, fieldNotFoundErr(seg, props, sch) + } + } +} + +// fieldNotFoundErr explains a failed path lookup, listing the fields available +// at that point so an agent can correct the path in one step. +func fieldNotFoundErr(seg string, props map[string]*base.SchemaProxy, sch *base.Schema) error { + switch { + case len(props) > 0: + return fmt.Errorf("no field %q; available: %s", seg, strings.Join(sortedKeys(props), ", ")) + case len(sch.OneOf) > 0 || len(sch.AnyOf) > 0: + return fmt.Errorf("no field %q; this is a union (oneOf/anyOf), drill not supported here", seg) + default: + return fmt.Errorf("no field %q; %v has no named fields", seg, joinTypes(sch.Type)) + } +} + +// simplify turns a libopenapi schema into a compact, agent-friendly map. It +// merges allOf composition into a single object, preserves descriptions, enums, +// formats, required fields and examples, and guards against deep nesting (via +// d.maxDepth) and recursive $refs. `seen` tracks the $refs already expanded on +// the current path so a self-referential schema (e.g. folder → children → +// folder) stops instead of looping forever. +func (d *describer) simplify(proxy *base.SchemaProxy, depth int, seen map[string]bool) interface{} { + if proxy == nil { + return nil + } + + ref := "" + if proxy.IsReference() { + ref = proxy.GetReference() + if seen[ref] { + return map[string]interface{}{"$ref": ref, "note": "recursive reference; expansion omitted"} + } + } + + sch := proxy.Schema() + if sch == nil { + if ref != "" { + return map[string]interface{}{"$ref": ref} + } + return nil + } + + if depth > d.maxDepth { + out := map[string]interface{}{"note": "max depth reached; expansion omitted"} + if len(sch.Type) > 0 { + out["type"] = joinTypes(sch.Type) + } + if ref != "" { + out["$ref"] = ref + } + return out + } + + childSeen := seen + if ref != "" { + childSeen = cloneSeen(seen, ref) + } + + out := map[string]interface{}{} + properties := map[string]interface{}{} + var required []string + reqSeen := map[string]bool{} + addRequired := func(names []string) { + for _, n := range names { + if !reqSeen[n] { + reqSeen[n] = true + required = append(required, n) + } + } + } + + // allOf composes at the same level: merge member properties and required + // into this object. Members are expanded at the same depth, since allOf is + // composition rather than nesting. + for _, member := range sch.AllOf { + sub, ok := d.simplify(member, depth, childSeen).(map[string]interface{}) + if !ok { + continue + } + if props, ok := sub["properties"].(map[string]interface{}); ok { + for k, v := range props { + properties[k] = v + } + } + if req, ok := sub["required"].([]string); ok { + addRequired(req) + } + } + + // This schema's own properties (nested one level deeper). + if sch.Properties != nil { + for pair := sch.Properties.First(); pair != nil; pair = pair.Next() { + properties[pair.Key()] = d.simplify(pair.Value(), depth+1, childSeen) + } + } + addRequired(sch.Required) + + switch { + case len(sch.Type) > 0: + out["type"] = joinTypes(sch.Type) + case len(properties) > 0: + out["type"] = "object" + } + if sch.Description != "" { + out["description"] = sch.Description + } + if sch.Format != "" { + out["format"] = sch.Format + } + if enum := decodeNodes(sch.Enum); len(enum) > 0 { + out["enum"] = enum + } + if ex := exampleOf(sch); ex != nil { + out["example"] = ex + } + if def := decodeNode(sch.Default); def != nil { + out["default"] = def + } + if sch.Items != nil && sch.Items.IsA() { + out["items"] = d.simplify(sch.Items.A, depth+1, childSeen) + } + // Map types carry their value shape in additionalProperties (a $ref or + // inline schema) rather than properties — e.g. queryPresentations.data, + // keyed by tab ID. Expand it so the value schema isn't dropped. + if sch.AdditionalProperties != nil && sch.AdditionalProperties.IsA() { + out["additionalProperties"] = d.simplify(sch.AdditionalProperties.A, depth+1, childSeen) + } + if len(sch.OneOf) > 0 { + out["oneOf"] = d.simplifyList(sch.OneOf, depth+1, childSeen) + } + if len(sch.AnyOf) > 0 { + out["anyOf"] = d.simplifyList(sch.AnyOf, depth+1, childSeen) + } + if len(properties) > 0 { + out["properties"] = properties + } + if len(required) > 0 { + out["required"] = required + } + + return out +} + +func (d *describer) simplifyList(proxies []*base.SchemaProxy, depth int, seen map[string]bool) []interface{} { + out := make([]interface{}, 0, len(proxies)) + for _, p := range proxies { + out = append(out, d.simplify(p, depth, seen)) + } + return out +} + +// synth builds a minimal, copy-pasteable example value for a schema: +// only required object fields are included, filled from explicit examples, +// defaults, enums, or a typed placeholder. `name` is the field name, used to +// make string placeholders self-describing (e.g. ""). +func (d *describer) synth(proxy *base.SchemaProxy, name string, depth int, seen map[string]bool) interface{} { + if proxy == nil { + return placeholder(name, "string") + } + + ref := "" + if proxy.IsReference() { + ref = proxy.GetReference() + if seen[ref] { + return nil + } + } + + sch := proxy.Schema() + if sch == nil { + return placeholder(name, "string") + } + + // Explicit example / default / enum win over a synthesized placeholder. + if ex := exampleOf(sch); ex != nil { + return ex + } + if def := decodeNode(sch.Default); def != nil { + return def + } + if enum := decodeNodes(sch.Enum); len(enum) > 0 { + return enum[0] + } + + childSeen := seen + if ref != "" { + childSeen = cloneSeen(seen, ref) + } + + t := firstType(sch) + + if t == "object" || sch.Properties != nil || len(sch.AllOf) > 0 { + obj := map[string]interface{}{} + if depth > d.maxDepth { + return obj + } + reqSet, props := gatherObject(sch, childSeen) + for _, fieldName := range sortedKeys(reqSet) { + if p, ok := props[fieldName]; ok { + obj[fieldName] = d.synth(p, fieldName, depth+1, childSeen) + } else { + obj[fieldName] = placeholder(fieldName, "string") + } + } + // Pure map type (additionalProperties, no fixed properties) — show one + // representative entry so the agent sees the value shape. + if len(props) == 0 && sch.AdditionalProperties != nil && sch.AdditionalProperties.IsA() { + obj[""] = d.synth(sch.AdditionalProperties.A, "", depth+1, childSeen) + } + return obj + } + + if t == "array" { + if sch.Items != nil && sch.Items.IsA() { + return []interface{}{d.synth(sch.Items.A, singular(name), depth+1, childSeen)} + } + return []interface{}{} + } + + return placeholder(name, t) +} + +// gatherObject collects the required field names and property proxies for an +// object schema, flattening any allOf members. `seen` guards against recursive +// allOf composition. +func gatherObject(sch *base.Schema, seen map[string]bool) (map[string]bool, map[string]*base.SchemaProxy) { + reqSet := map[string]bool{} + props := map[string]*base.SchemaProxy{} + + for _, member := range sch.AllOf { + if member == nil { + continue + } + ref := "" + if member.IsReference() { + ref = member.GetReference() + if seen[ref] { + continue + } + } + ms := member.Schema() + if ms == nil { + continue + } + memberSeen := seen + if ref != "" { + memberSeen = cloneSeen(seen, ref) + } + subReq, subProps := gatherObject(ms, memberSeen) + for k := range subReq { + reqSet[k] = true + } + for k, v := range subProps { + props[k] = v + } + } + + if sch.Properties != nil { + for pair := sch.Properties.First(); pair != nil; pair = pair.Next() { + props[pair.Key()] = pair.Value() + } + } + for _, r := range sch.Required { + reqSet[r] = true + } + + return reqSet, props +} + +// placeholder returns a typed stand-in value for a required field that carries +// no example. Strings echo the field name so the agent knows what to fill in. +func placeholder(name, typ string) interface{} { + switch typ { + case "integer", "number": + return 0 + case "boolean": + return false + case "array": + return []interface{}{} + case "object": + return map[string]interface{}{} + default: + if name == "" { + return "" + } + return "<" + name + ">" + } +} + +// exampleOf returns the decoded value of a schema's example (or first of its +// examples list), or nil if none is set. +func exampleOf(sch *base.Schema) interface{} { + if sch.Example != nil { + if v := decodeNode(sch.Example); v != nil { + return v + } + } + for _, e := range sch.Examples { + if v := decodeNode(e); v != nil { + return v + } + } + return nil +} + +// decodeNode converts a YAML node from the spec into a plain Go value. +func decodeNode(n *yaml.Node) interface{} { + if n == nil { + return nil + } + var v interface{} + if err := n.Decode(&v); err != nil { + return nil + } + return v +} + +func decodeNodes(nodes []*yaml.Node) []interface{} { + var out []interface{} + for _, n := range nodes { + if v := decodeNode(n); v != nil { + out = append(out, v) + } + } + return out +} + +func firstType(sch *base.Schema) string { + if len(sch.Type) > 0 { + return sch.Type[0] + } + return "" +} + +// joinTypes renders a type list. OpenAPI 3.1 allows unions like +// ["string","null"]; we surface them as "string | null". +func joinTypes(types []string) interface{} { + if len(types) == 1 { + return types[0] + } + return strings.Join(types, " | ") +} + +func cloneSeen(seen map[string]bool, add string) map[string]bool { + next := make(map[string]bool, len(seen)+1) + for k := range seen { + next[k] = true + } + next[add] = true + return next +} + +// singular strips a trailing plural "s" so an array named "users" yields an +// item example labeled "user". Best-effort; cosmetic only. +func singular(name string) string { + if len(name) > 1 && strings.HasSuffix(name, "s") { + return name[:len(name)-1] + } + return name +} diff --git a/internal/openapi/schema_test.go b/internal/openapi/schema_test.go new file mode 100644 index 0000000..8a6702c --- /dev/null +++ b/internal/openapi/schema_test.go @@ -0,0 +1,428 @@ +package openapi + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// schemaTestSpec exercises the body-schema describer: allOf composition, +// required-field union, field-level examples, enums, scalar placeholders, +// nested objects, arrays, and a self-referential ($ref-recursive) schema. +const schemaTestSpec = `{ + "openapi": "3.1.0", + "info": {"title": "test", "version": "1.0"}, + "paths": { + "/api/v1/widgets": { + "post": { + "operationId": "widgetsCreate", + "tags": ["widgets"], + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/CreateWidget"} + } + } + }, + "responses": {"200": {"description": "ok"}} + } + } + }, + "components": { + "schemas": { + "Base": { + "type": "object", + "required": ["baseField"], + "properties": { + "baseField": {"type": "string", "description": "from base", "example": "base-ex"} + } + }, + "Node": { + "type": "object", + "required": ["label"], + "properties": { + "label": {"type": "string"}, + "next": {"$ref": "#/components/schemas/Node"} + } + }, + "CreateWidget": { + "allOf": [ + {"$ref": "#/components/schemas/Base"}, + { + "type": "object", + "required": ["name", "status", "count"], + "properties": { + "name": {"type": "string", "example": "my-widget"}, + "status": {"type": "string", "enum": ["active", "archived"]}, + "count": {"type": "integer"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "child": {"$ref": "#/components/schemas/Node"} + } + } + ] + } + } + } +}` + +// runSchema generates commands from a spec and runs the "widgets create" +// command with --schema, returning the parsed JSON document. It dispatches +// through the parent group with the full arg path, since cobra's Execute() +// re-parses from the root regardless of the receiver. +func runSchema(t *testing.T) bodySchemaDoc { + t.Helper() + noop := func(req APIRequest) error { return nil } + cmds, err := GenerateCommands([]byte(schemaTestSpec), noop) + if err != nil { + t.Fatalf("GenerateCommands: %v", err) + } + if len(cmds) != 1 { + t.Fatalf("expected 1 group, got %d", len(cmds)) + } + group := cmds[0] + + var buf bytes.Buffer + group.SetOut(&buf) + group.SetErr(&buf) + group.SetArgs([]string{"create", "--schema"}) + if err := group.Execute(); err != nil { + t.Fatalf("Execute --schema: %v\n%s", err, buf.String()) + } + + var doc bodySchemaDoc + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("unmarshal schema output: %v\n%s", err, buf.String()) + } + return doc +} + +func TestSchema_MetaAndRequiredUnion(t *testing.T) { + doc := runSchema(t) + if doc.Method != "POST" || doc.Path != "/api/v1/widgets" { + t.Errorf("method/path = %q %q", doc.Method, doc.Path) + } + // Required is the union of the allOf members: baseField (Base) + name, + // status, count (inline member). + want := map[string]bool{"baseField": true, "name": true, "status": true, "count": true} + if len(doc.Required) != len(want) { + t.Errorf("required = %v, want %d entries", doc.Required, len(want)) + } + for _, r := range doc.Required { + if !want[r] { + t.Errorf("unexpected required field %q", r) + } + } +} + +func TestSchema_BodyMergesAllOfProperties(t *testing.T) { + doc := runSchema(t) + body, ok := doc.Body.(map[string]interface{}) + if !ok { + t.Fatalf("body is not an object: %T", doc.Body) + } + props, ok := body["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("body.properties missing") + } + // baseField comes from the allOf $ref; name/status/count from the inline + // member — all flattened into one property set. + for _, key := range []string{"baseField", "name", "status", "count", "tags", "child"} { + if _, ok := props[key]; !ok { + t.Errorf("merged properties missing %q", key) + } + } +} + +func TestSchema_ExampleUsesExamplesEnumsAndPlaceholders(t *testing.T) { + doc := runSchema(t) + ex, ok := doc.Example.(map[string]interface{}) + if !ok { + t.Fatalf("example is not an object: %T", doc.Example) + } + // Only required fields appear in the example skeleton. + if _, ok := ex["tags"]; ok { + t.Errorf("optional field tags should not be in example: %v", ex) + } + if _, ok := ex["child"]; ok { + t.Errorf("optional field child should not be in example: %v", ex) + } + // Explicit field examples win. + if ex["baseField"] != "base-ex" { + t.Errorf("baseField = %v, want base-ex", ex["baseField"]) + } + if ex["name"] != "my-widget" { + t.Errorf("name = %v, want my-widget", ex["name"]) + } + // Enum with no example uses the first enum value. + if ex["status"] != "active" { + t.Errorf("status = %v, want active (first enum)", ex["status"]) + } + // Integer with no example gets a typed zero placeholder. + if ex["count"] != float64(0) { + t.Errorf("count = %v (%T), want 0", ex["count"], ex["count"]) + } +} + +func TestSchema_RecursiveRefIsGuarded(t *testing.T) { + doc := runSchema(t) + // The Node schema references itself via "next"; the body dump must not loop + // forever and must mark the recursion. Easiest check: the serialized body + // contains the recursion note and is bounded in size. + raw, _ := json.Marshal(doc.Body) + if !strings.Contains(string(raw), "recursive reference") { + t.Errorf("expected a recursion note in body for self-referential Node schema") + } + if len(raw) > 200_000 { + t.Errorf("body unexpectedly large (%d bytes) — recursion may not be bounded", len(raw)) + } +} + +// mapTestSpec exercises a map-typed (additionalProperties) field — the shape of +// documents v2-create's queryPresentations.data, which keys tile objects by tab +// ID. The value schema lives in additionalProperties, not properties. +const mapTestSpec = `{ + "openapi": "3.1.0", + "info": {"title": "test", "version": "1.0"}, + "paths": { + "/api/v1/docs": { + "post": { + "operationId": "docsCreate", + "tags": ["docs"], + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/CreateDoc"} + } + } + }, + "responses": {"200": {"description": "ok"}} + } + } + }, + "components": { + "schemas": { + "Tile": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "example": "Revenue"}, + "prefersChart": {"type": "boolean"} + } + }, + "CreateDoc": { + "type": "object", + "required": ["presentations"], + "properties": { + "presentations": { + "type": "object", + "description": "Tiles keyed by tab ID.", + "additionalProperties": {"$ref": "#/components/schemas/Tile"} + } + } + } + } + } +}` + +func runMapSchema(t *testing.T) bodySchemaDoc { + t.Helper() + noop := func(req APIRequest) error { return nil } + cmds, err := GenerateCommands([]byte(mapTestSpec), noop) + if err != nil { + t.Fatalf("GenerateCommands: %v", err) + } + if len(cmds) != 1 { + t.Fatalf("expected 1 group, got %d", len(cmds)) + } + group := cmds[0] + var buf bytes.Buffer + group.SetOut(&buf) + group.SetErr(&buf) + group.SetArgs([]string{"create", "--schema"}) + if err := group.Execute(); err != nil { + t.Fatalf("Execute --schema: %v\n%s", err, buf.String()) + } + var doc bodySchemaDoc + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("unmarshal schema output: %v\n%s", err, buf.String()) + } + return doc +} + +// A map-typed field must expand its value schema from additionalProperties +// rather than dropping it (the queryPresentations.data gap). +func TestSchema_MapExpandsAdditionalProperties(t *testing.T) { + doc := runMapSchema(t) + body, ok := doc.Body.(map[string]interface{}) + if !ok { + t.Fatalf("body is not an object: %T", doc.Body) + } + props, ok := body["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("body.properties missing") + } + pres, ok := props["presentations"].(map[string]interface{}) + if !ok { + t.Fatalf("presentations property missing or not an object") + } + addl, ok := pres["additionalProperties"].(map[string]interface{}) + if !ok { + t.Fatalf("presentations.additionalProperties missing — map value schema was dropped") + } + tileProps, ok := addl["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("additionalProperties.properties missing — Tile $ref not expanded") + } + for _, key := range []string{"name", "prefersChart"} { + if _, ok := tileProps[key]; !ok { + t.Errorf("expanded tile schema missing %q", key) + } + } +} + +// The synthesized example must render one representative map entry so the value +// shape is copy-pasteable, not an empty object. +func TestSchema_MapExampleShowsRepresentativeEntry(t *testing.T) { + doc := runMapSchema(t) + ex, ok := doc.Example.(map[string]interface{}) + if !ok { + t.Fatalf("example is not an object: %T", doc.Example) + } + pres, ok := ex["presentations"].(map[string]interface{}) + if !ok { + t.Fatalf("example.presentations missing or not an object: %v", ex) + } + entry, ok := pres[""].(map[string]interface{}) + if !ok { + t.Fatalf("expected a sample entry in the map example, got %v", pres) + } + if entry["name"] != "Revenue" { + t.Errorf("sample tile name = %v, want Revenue (field example)", entry["name"]) + } +} + +// runSchemaWith runs "create --schema" plus extra flags against a spec. On +// success it returns the parsed doc; on failure it returns the execution error +// (usage/errors silenced so the error carries only the message under test). +func runSchemaWith(t *testing.T, spec string, extra ...string) (bodySchemaDoc, error) { + t.Helper() + noop := func(req APIRequest) error { return nil } + cmds, err := GenerateCommands([]byte(spec), noop) + if err != nil { + t.Fatalf("GenerateCommands: %v", err) + } + group := cmds[0] + group.SilenceUsage = true + group.SilenceErrors = true + var buf bytes.Buffer + group.SetOut(&buf) + group.SetErr(&buf) + group.SetArgs(append([]string{"create", "--schema"}, extra...)) + if execErr := group.Execute(); execErr != nil { + return bodySchemaDoc{}, execErr + } + var doc bodySchemaDoc + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("unmarshal schema output: %v\n%s", err, buf.String()) + } + return doc, nil +} + +// --field drills to a nested property; the $ref ("child" → Node) is resolved +// and expanded so the drilled body is the Node object, not the whole widget. +func TestSchema_FieldDrillsNestedProperty(t *testing.T) { + doc, err := runSchemaWith(t, schemaTestSpec, "--field", "child") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if doc.Field != "child" { + t.Errorf("field = %q, want child", doc.Field) + } + body, ok := doc.Body.(map[string]interface{}) + if !ok { + t.Fatalf("body is not an object: %T", doc.Body) + } + props, ok := body["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("drilled body.properties missing") + } + if _, ok := props["label"]; !ok { + t.Errorf("expected Node.label in drilled body, got %v", props) + } +} + +// A dotted path transparently descends through a map (additionalProperties): +// "presentations.prefersChart" reaches the Tile's boolean leaf without the +// caller naming the map's value layer. +func TestSchema_FieldAutoDescendsMap(t *testing.T) { + doc, err := runSchemaWith(t, mapTestSpec, "--field", "presentations.prefersChart") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + body, ok := doc.Body.(map[string]interface{}) + if !ok { + t.Fatalf("body is not an object: %T", doc.Body) + } + if body["type"] != "boolean" { + t.Errorf("drilled leaf type = %v, want boolean", body["type"]) + } +} + +// An unknown field errors and lists the fields available at that level — which, +// after descending through the map, are the Tile's fields. +func TestSchema_FieldNotFoundListsAvailable(t *testing.T) { + _, err := runSchemaWith(t, mapTestSpec, "--field", "presentations.bogus") + if err == nil { + t.Fatal("expected an error for an unknown field") + } + msg := err.Error() + if !strings.Contains(msg, `no field "bogus"`) { + t.Errorf("error = %q, want it to name the missing field", msg) + } + for _, want := range []string{"name", "prefersChart"} { + if !strings.Contains(msg, want) { + t.Errorf("error = %q, want it to list available field %q", msg, want) + } + } +} + +// --depth bounds expansion: at depth 0 the top-level object lists its +// properties but nested objects are truncated with the depth note. +func TestSchema_DepthLimitsExpansion(t *testing.T) { + doc, err := runSchemaWith(t, schemaTestSpec, "--depth", "0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw, _ := json.Marshal(doc.Body) + if !strings.Contains(string(raw), "max depth reached") { + t.Errorf("expected a depth-truncation note at --depth 0: %s", raw) + } + // The deep default must still fully expand (no truncation note). + full, _ := runSchemaWith(t, schemaTestSpec) + rawFull, _ := json.Marshal(full.Body) + if strings.Contains(string(rawFull), "max depth reached") { + t.Errorf("default depth should not truncate this small schema: %s", rawFull) + } +} + +// Without --schema, the command must still enforce normal behavior (here, that +// the body flag path is taken and the executor is invoked). +func TestSchema_FlagDoesNotAffectNormalRun(t *testing.T) { + var called bool + exec := func(req APIRequest) error { called = true; return nil } + cmds, err := GenerateCommands([]byte(schemaTestSpec), exec) + if err != nil { + t.Fatalf("GenerateCommands: %v", err) + } + group := cmds[0] + group.SilenceUsage = true + group.SilenceErrors = true + group.SetArgs([]string{"create", "--body", `{"name":"x"}`}) + if err := group.Execute(); err != nil { + t.Fatalf("Execute with --body: %v", err) + } + if !called { + t.Error("executor was not called on a normal (non --schema) run") + } +}