From daac0102e7eb9c3a0ea8d3fe085d7fd84fe26119 Mon Sep 17 00:00:00 2001 From: Daniel Spangenberger Date: Thu, 25 Jun 2026 14:21:26 -0400 Subject: [PATCH 1/3] feat(openapi): add --schema flag to inspect request body shapes Every generated command that takes a request body now accepts --schema, which prints the body's resolved JSON schema (types, descriptions, enums, formats, required fields) plus a filled-in example, then exits without making an API call or requiring a token. This closes the discovery gap where --help told an agent a body was required but said nothing about its shape. The schema is derived entirely from the embedded spec, so it covers all body operations (including the ~56 without a hand-written shorthand) with no per-endpoint maintenance. allOf members are merged into a single object; nesting is depth-capped and $ref cycles are guarded so deeply nested or self-referential bodies can't blow up the output. The example skeleton includes only required fields, filled from the spec's field-level examples, then defaults, first enum value, or a typed placeholder. The flag short-circuits before positional-arg validation, body assembly, auth, and the HTTP call, so `omni --schema` works with no args and no token. Normal runs (arg validation, shorthands, --body/stdin) are unchanged. Discovery is wired into per-command --help and agent-help. go.yaml.in/yaml/v4 (already a transitive dep via libopenapi) is promoted to a direct dependency for decoding schema example/default/enum nodes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017Egqycqj1GcK81yyLxNeg4 --- cmd/omni/agent_help.go | 9 + go.mod | 2 +- internal/openapi/generate.go | 55 ++++- internal/openapi/schema.go | 402 ++++++++++++++++++++++++++++++++ internal/openapi/schema_test.go | 200 ++++++++++++++++ 5 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 internal/openapi/schema.go create mode 100644 internal/openapi/schema_test.go diff --git a/cmd/omni/agent_help.go b/cmd/omni/agent_help.go index 3b42409..cdac5a9 100644 --- a/cmd/omni/agent_help.go +++ b/cmd/omni/agent_help.go @@ -78,12 +78,21 @@ 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 + ## 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 ## 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..7d4b840 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,60 @@ 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)") + + 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) { + 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..ac6be6a --- /dev/null +++ b/internal/openapi/schema.go @@ -0,0 +1,402 @@ +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"` + Required []string `json:"required,omitempty"` + Body interface{} `json:"body"` + Example interface{} `json:"example,omitempty"` +} + +// 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. +func emitBodySchema(cmd *cobra.Command, op *operationInfo) error { + doc := describeBody(op) + + compact, _ := cmd.Flags().GetBool("compact") + var data []byte + var err error + 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. +func describeBody(op *operationInfo) bodySchemaDoc { + doc := bodySchemaDoc{Method: op.Method, Path: op.Path} + if op.BodySchema == nil { + return doc + } + + body := simplifySchema(op.BodySchema, 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 = synthExample(op.BodySchema, "", 0, nil) + return doc +} + +// simplifySchema 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 +// 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 simplifySchema(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 > maxSchemaDepth { + 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 := simplifySchema(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()] = simplifySchema(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"] = simplifySchema(sch.Items.A, depth+1, childSeen) + } + if len(sch.OneOf) > 0 { + out["oneOf"] = simplifyList(sch.OneOf, depth+1, childSeen) + } + if len(sch.AnyOf) > 0 { + out["anyOf"] = simplifyList(sch.AnyOf, depth+1, childSeen) + } + if len(properties) > 0 { + out["properties"] = properties + } + if len(required) > 0 { + out["required"] = required + } + + return out +} + +func simplifyList(proxies []*base.SchemaProxy, depth int, seen map[string]bool) []interface{} { + out := make([]interface{}, 0, len(proxies)) + for _, p := range proxies { + out = append(out, simplifySchema(p, depth, seen)) + } + return out +} + +// synthExample 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 synthExample(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 > maxSchemaDepth { + return obj + } + reqSet, props := gatherObject(sch, childSeen) + for _, fieldName := range sortedKeys(reqSet) { + if p, ok := props[fieldName]; ok { + obj[fieldName] = synthExample(p, fieldName, depth+1, childSeen) + } else { + obj[fieldName] = placeholder(fieldName, "string") + } + } + return obj + } + + if t == "array" { + if sch.Items != nil && sch.Items.IsA() { + return []interface{}{synthExample(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..594ebbe --- /dev/null +++ b/internal/openapi/schema_test.go @@ -0,0 +1,200 @@ +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)) + } +} + +// 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") + } +} From e946eee769769486d14228fc4a42437b0f7052e6 Mon Sep 17 00:00:00 2001 From: Daniel Spangenberger Date: Thu, 25 Jun 2026 19:53:21 -0400 Subject: [PATCH 2/3] fix(openapi): expand map-typed (additionalProperties) value schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --schema dump described map-typed fields by their `properties` and `items` only, so fields that carry their value shape in `additionalProperties` — e.g. documents v2-create's `queryPresentations.data`, a tile object keyed by tab ID — rendered as a bare `{type: object}` with the value schema dropped. The data was in the spec (QueryPresentationPatchExternal) but never reached the output. simplifySchema now expands additionalProperties (mirroring the Items handling), and synthExample renders one representative `` entry for pure map types so the example shows the value shape. Recursion/depth guards still bound the output. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H8ndoDtArqEjtDRjzjEJGE --- internal/openapi/schema.go | 11 +++ internal/openapi/schema_test.go | 124 ++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/internal/openapi/schema.go b/internal/openapi/schema.go index ac6be6a..3ba31fc 100644 --- a/internal/openapi/schema.go +++ b/internal/openapi/schema.go @@ -172,6 +172,12 @@ func simplifySchema(proxy *base.SchemaProxy, depth int, seen map[string]bool) in if sch.Items != nil && sch.Items.IsA() { out["items"] = simplifySchema(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"] = simplifySchema(sch.AdditionalProperties.A, depth+1, childSeen) + } if len(sch.OneOf) > 0 { out["oneOf"] = simplifyList(sch.OneOf, depth+1, childSeen) } @@ -249,6 +255,11 @@ func synthExample(proxy *base.SchemaProxy, name string, depth int, seen map[stri 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[""] = synthExample(sch.AdditionalProperties.A, "", depth+1, childSeen) + } return obj } diff --git a/internal/openapi/schema_test.go b/internal/openapi/schema_test.go index 594ebbe..5545c22 100644 --- a/internal/openapi/schema_test.go +++ b/internal/openapi/schema_test.go @@ -178,6 +178,130 @@ func TestSchema_RecursiveRefIsGuarded(t *testing.T) { } } +// 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"]) + } +} + // 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) { From 9c2abbe0ebcc9a14cdb254e4f60b2c3e8825edbc Mon Sep 17 00:00:00 2001 From: Daniel Spangenberger Date: Thu, 25 Jun 2026 21:27:36 -0400 Subject: [PATCH 3/3] feat(openapi): add --field and --depth to narrow --schema output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deeply nested bodies (notably documents v2-create) produce a ~400KB --schema dump that's unwieldy to read. Two flags make it navigable: --field drill to a sub-schema, e.g. queryPresentations.data.query. The path auto-descends through array items and map (additionalProperties) values, so a caller can name a leaf without knowing the container shape. Unknown segments error with the fields available at that level — which doubles as discovery. --depth N cap expansion depth; --schema --depth 1 gives a compact top-level overview (428KB -> ~1.7KB on v2-create). Both scope the body and the synthesized example, and compose. The recursive describers move onto a small describer{maxDepth} value so --depth threads through without a global. Schema errors silence cobra's usage block so only the helpful message prints. agent-help documents the new flags. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H8ndoDtArqEjtDRjzjEJGE --- cmd/omni/agent_help.go | 10 ++ internal/openapi/generate.go | 12 +++ internal/openapi/schema.go | 172 ++++++++++++++++++++++++++------ internal/openapi/schema_test.go | 104 +++++++++++++++++++ 4 files changed, 266 insertions(+), 32 deletions(-) diff --git a/cmd/omni/agent_help.go b/cmd/omni/agent_help.go index cdac5a9..e70be6f 100644 --- a/cmd/omni/agent_help.go +++ b/cmd/omni/agent_help.go @@ -86,6 +86,14 @@ 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) @@ -93,6 +101,8 @@ instead of guessing the JSON for --body. --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/internal/openapi/generate.go b/internal/openapi/generate.go index 7d4b840..a0662bb 100644 --- a/internal/openapi/generate.go +++ b/internal/openapi/generate.go @@ -276,6 +276,15 @@ func buildCommand(op *operationInfo, exec Executor) *cobra.Command { // 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 { @@ -288,6 +297,9 @@ func buildCommand(op *operationInfo, exec Executor) *cobra.Command { 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) diff --git a/internal/openapi/schema.go b/internal/openapi/schema.go index 3ba31fc..3816c4c 100644 --- a/internal/openapi/schema.go +++ b/internal/openapi/schema.go @@ -22,20 +22,37 @@ const maxSchemaDepth = 8 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. +// 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 { - doc := describeBody(op) + 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 - var err error if compact { data, err = json.Marshal(doc) } else { @@ -48,31 +65,122 @@ func emitBodySchema(cmd *cobra.Command, op *operationInfo) error { return nil } -// describeBody builds the schema document for an operation's request body. -func describeBody(op *operationInfo) bodySchemaDoc { - doc := bodySchemaDoc{Method: op.Method, Path: op.Path} +// 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 + return doc, nil + } + + root := op.BodySchema + if field != "" { + resolved, err := resolveField(root, field) + if err != nil { + return doc, err + } + root = resolved } - body := simplifySchema(op.BodySchema, 0, nil) + 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 = synthExample(op.BodySchema, "", 0, nil) - return doc + 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)) + } } -// simplifySchema 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 -// 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 simplifySchema(proxy *base.SchemaProxy, depth int, seen map[string]bool) interface{} { +// 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 } @@ -93,7 +201,7 @@ func simplifySchema(proxy *base.SchemaProxy, depth int, seen map[string]bool) in return nil } - if depth > maxSchemaDepth { + if depth > d.maxDepth { out := map[string]interface{}{"note": "max depth reached; expansion omitted"} if len(sch.Type) > 0 { out["type"] = joinTypes(sch.Type) @@ -126,7 +234,7 @@ func simplifySchema(proxy *base.SchemaProxy, depth int, seen map[string]bool) in // into this object. Members are expanded at the same depth, since allOf is // composition rather than nesting. for _, member := range sch.AllOf { - sub, ok := simplifySchema(member, depth, childSeen).(map[string]interface{}) + sub, ok := d.simplify(member, depth, childSeen).(map[string]interface{}) if !ok { continue } @@ -143,7 +251,7 @@ func simplifySchema(proxy *base.SchemaProxy, depth int, seen map[string]bool) in // 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()] = simplifySchema(pair.Value(), depth+1, childSeen) + properties[pair.Key()] = d.simplify(pair.Value(), depth+1, childSeen) } } addRequired(sch.Required) @@ -170,19 +278,19 @@ func simplifySchema(proxy *base.SchemaProxy, depth int, seen map[string]bool) in out["default"] = def } if sch.Items != nil && sch.Items.IsA() { - out["items"] = simplifySchema(sch.Items.A, depth+1, childSeen) + 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"] = simplifySchema(sch.AdditionalProperties.A, depth+1, childSeen) + out["additionalProperties"] = d.simplify(sch.AdditionalProperties.A, depth+1, childSeen) } if len(sch.OneOf) > 0 { - out["oneOf"] = simplifyList(sch.OneOf, depth+1, childSeen) + out["oneOf"] = d.simplifyList(sch.OneOf, depth+1, childSeen) } if len(sch.AnyOf) > 0 { - out["anyOf"] = simplifyList(sch.AnyOf, depth+1, childSeen) + out["anyOf"] = d.simplifyList(sch.AnyOf, depth+1, childSeen) } if len(properties) > 0 { out["properties"] = properties @@ -194,19 +302,19 @@ func simplifySchema(proxy *base.SchemaProxy, depth int, seen map[string]bool) in return out } -func simplifyList(proxies []*base.SchemaProxy, depth int, seen map[string]bool) []interface{} { +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, simplifySchema(p, depth, seen)) + out = append(out, d.simplify(p, depth, seen)) } return out } -// synthExample builds a minimal, copy-pasteable example value for a schema: +// 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 synthExample(proxy *base.SchemaProxy, name string, depth int, seen map[string]bool) interface{} { +func (d *describer) synth(proxy *base.SchemaProxy, name string, depth int, seen map[string]bool) interface{} { if proxy == nil { return placeholder(name, "string") } @@ -244,13 +352,13 @@ func synthExample(proxy *base.SchemaProxy, name string, depth int, seen map[stri if t == "object" || sch.Properties != nil || len(sch.AllOf) > 0 { obj := map[string]interface{}{} - if depth > maxSchemaDepth { + if depth > d.maxDepth { return obj } reqSet, props := gatherObject(sch, childSeen) for _, fieldName := range sortedKeys(reqSet) { if p, ok := props[fieldName]; ok { - obj[fieldName] = synthExample(p, fieldName, depth+1, childSeen) + obj[fieldName] = d.synth(p, fieldName, depth+1, childSeen) } else { obj[fieldName] = placeholder(fieldName, "string") } @@ -258,14 +366,14 @@ func synthExample(proxy *base.SchemaProxy, name string, depth int, seen map[stri // 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[""] = synthExample(sch.AdditionalProperties.A, "", depth+1, childSeen) + obj[""] = d.synth(sch.AdditionalProperties.A, "", depth+1, childSeen) } return obj } if t == "array" { if sch.Items != nil && sch.Items.IsA() { - return []interface{}{synthExample(sch.Items.A, singular(name), depth+1, childSeen)} + return []interface{}{d.synth(sch.Items.A, singular(name), depth+1, childSeen)} } return []interface{}{} } diff --git a/internal/openapi/schema_test.go b/internal/openapi/schema_test.go index 5545c22..8a6702c 100644 --- a/internal/openapi/schema_test.go +++ b/internal/openapi/schema_test.go @@ -302,6 +302,110 @@ func TestSchema_MapExampleShowsRepresentativeEntry(t *testing.T) { } } +// 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) {