From 3d6fc5d0807350934042a5f8ac3fe0e639be83de Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Fri, 28 Aug 2026 17:31:00 +0200 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: e9bfa35e306cac24174f4b46e9040c7f6b82f1a2 --- README.md | 1 + cmd/prime_context.md | 9 + cmd/schema.go | 152 ++++++++--- cmd/schema_compact.go | 429 ++++++++++++++++++++++++++++++ cmd/schema_compact_test.go | 518 +++++++++++++++++++++++++++++++++++++ cmd/schema_test.go | 2 +- 6 files changed, 1070 insertions(+), 41 deletions(-) create mode 100644 cmd/schema_compact.go create mode 100644 cmd/schema_compact_test.go diff --git a/README.md b/README.md index 404eaeb..5ec8333 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ cio schema # list all resources cio schema campaigns # list endpoints for a resource cio schema campaigns.list # full schema for a method cio schema GET /v1/environments/{environment_id}/campaigns # by HTTP method + path +cio schema campaigns.list --compact # flattened one line per field instead of JSON Schema ``` ### Account ID fallback diff --git a/cmd/prime_context.md b/cmd/prime_context.md index 34ddf27..b64a80d 100644 --- a/cmd/prime_context.md +++ b/cmd/prime_context.md @@ -64,10 +64,19 @@ cio schema GET /v1/environments/{environment_id}/campaigns # full schema for a specific HTTP method + path cio schema /v1/environments/{environment_id}/campaigns # show all methods for a path +cio schema campaigns.create --compact # same detail, one line per field ``` Drill down to a specific endpoint (`resource.method`) to get the detailed schema. It includes path/query params, parameter schema details, `request_body_schema`, `response_schemas`, and an example command. The resource-level listing is kept compact on purpose. +Add `--compact` to any endpoint-detail query to get the same information as flattened field lines instead of JSON Schema: dotted paths for nested objects, `[]` for arrays, `?` for optional, inline enums, `ref:Component` for an unresolved `$ref`. It replaces `request_body_schema`/`response_schemas` with `request_body`/`responses`. Median saving across the API is about a third, and roughly half on the large discriminated bodies you introspect before a write (`newsletters.update` 21.5KB to 7.6KB, `campaigns.create` 31.1KB to 16.1KB). Drop `--compact` when you need the exact schema shape (`oneOf`/`anyOf` branches, nesting, validation keywords). + +``` +campaign.audience.type integer Audience selector. 0 = Self, 1 = all related people, 2 = ... +campaign.name string +campaign.tags[] string? +``` + ## Skills — Domain Knowledge Skills provide behavioral guidance, multi-step workflows, and gotchas that are NOT discoverable from the API schema alone. **Read the relevant skill before complex operations.** diff --git a/cmd/schema.go b/cmd/schema.go index 1e92b3e..3fb0aca 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -21,18 +21,23 @@ var schemaCmd = &cobra.Command{ cio schema GET /v1/environments/{environment_id}/campaigns — show schema for a specific HTTP method + path cio schema /v1/environments/{environment_id}/campaigns - — show all methods for a path`, + — show all methods for a path + +Add --compact to render endpoint detail as one line per field (dotted paths, +"[]" for arrays, "?" for optional) instead of full JSON Schema.`, Args: cobra.MaximumNArgs(2), RunE: runSchema, } func init() { schemaCmd.Flags().Bool("refresh", false, "Force re-download of API specs") + schemaCmd.Flags().Bool("compact", false, "Render endpoint detail as flattened one-line-per-field text instead of full JSON Schema") rootCmd.AddCommand(schemaCmd) } func runSchema(cmd *cobra.Command, args []string) error { refresh, _ := cmd.Flags().GetBool("refresh") + compact, _ := cmd.Flags().GetBool("compact") var baseURL string var accessToken string @@ -74,13 +79,13 @@ func runSchema(cmd *cobra.Command, args []string) error { case schemaQueryResources: return schemaOutput(cmd, listEndpoints(reg)) case schemaQueryPath: - return schemaForPath(cmd, reg, q.a) + return schemaForPath(cmd, reg, q.a, compact) case schemaQueryResourceMethod: - return schemaForResourceMethod(cmd, reg, q.a, q.b) + return schemaForResourceMethod(cmd, reg, q.a, q.b, compact) case schemaQueryResource: return schemaForResource(cmd, reg, q.a) case schemaQueryHTTPEndpoint: - return schemaForHTTPEndpoint(cmd, reg, q.a, q.b) + return schemaForHTTPEndpoint(cmd, reg, q.a, q.b, compact) case schemaQuerySpacedResourceMethod: return schemaForSpacedResourceMethod(cmd, reg, q.a, q.b) default: @@ -197,7 +202,7 @@ func schemaForResource(cmd *cobra.Command, reg *routes.Registry, resource string } // schemaForResourceMethod shows the full schema for a resource.method pair. -func schemaForResourceMethod(cmd *cobra.Command, reg *routes.Registry, resource, method string) error { +func schemaForResourceMethod(cmd *cobra.Command, reg *routes.Registry, resource, method string, compact bool) error { route := reg.FindRoute(resource, method) if route == nil { suggestions := suggestRoutes(reg, resource, method) @@ -212,11 +217,11 @@ func schemaForResourceMethod(cmd *cobra.Command, reg *routes.Registry, resource, return fmt.Errorf("%s", msg) } - return schemaOutput(cmd, routeDetail(route)) + return schemaOutput(cmd, routeDetail(route, compact)) } // schemaForPath shows all methods for a given path. -func schemaForPath(cmd *cobra.Command, reg *routes.Registry, path string) error { +func schemaForPath(cmd *cobra.Command, reg *routes.Registry, path string, compact bool) error { var matches []routes.Route for _, r := range reg.Routes { if r.Path == path { @@ -232,16 +237,16 @@ func schemaForPath(cmd *cobra.Command, reg *routes.Registry, path string) error var result []map[string]any for _, r := range matches { - result = append(result, routeDetail(&r)) + result = append(result, routeDetail(&r, compact)) } return schemaOutput(cmd, result) } // schemaForHTTPEndpoint shows schema for a specific METHOD + path. -func schemaForHTTPEndpoint(cmd *cobra.Command, reg *routes.Registry, method, path string) error { +func schemaForHTTPEndpoint(cmd *cobra.Command, reg *routes.Registry, method, path string, compact bool) error { for _, r := range reg.Routes { if r.HTTPMethod == method && r.Path == path { - return schemaOutput(cmd, routeDetail(&r)) + return schemaOutput(cmd, routeDetail(&r, compact)) } } @@ -265,8 +270,45 @@ func routeSummary(r *routes.Route) map[string]any { return m } -// routeDetail returns the full schema view of a route. -func routeDetail(r *routes.Route) map[string]any { +// compactSkippedReason explains a body that came back as JSON Schema despite +// --compact. Saying so matters more than the fallback itself: silently handing +// back a different shape than the flag asked for reads as the flag not working. +const compactSkippedReason = "flattening was larger than the schema itself, so the schema is returned instead" + +// compactIfSmaller renders flattened lines when compact is requested and the +// result is actually smaller than the schema it replaces. +// +// Flattening enumerates one line per leaf path, so a schema whose components +// are shared across many branches can flatten to several times its own size: +// an endpoint embedding a third-party type resolved to 18MB and flattened to +// 54MB. Compact is a size optimisation, so it should never lose to the thing it +// optimises. This is decided per body, not per endpoint, so a small request +// still renders compact next to a pathological response. +// +// The flattening happens here rather than at the call site so it cannot run on +// the default path, where the result would be discarded: a route can carry a +// body and several response schemas, and schemaForPath renders every method on +// a path. +func compactIfSmaller(compact bool, schema json.RawMessage, rootRequired bool) ([]string, bool) { + if !compact { + return nil, false + } + + lines := compactLines(flattenSchemaRoot(schema, rootRequired)) + total := 0 + for _, l := range lines { + total += len(l) + } + if total >= len(schema) { + return nil, false + } + + return lines, true +} + +// routeDetail returns the schema view of a route. When compact is true, params +// and bodies render as flattened field lines instead of full JSON Schema. +func routeDetail(r *routes.Route, compact bool) map[string]any { m := map[string]any{ "resource": r.Resource, "method": r.Method, @@ -280,49 +322,79 @@ func routeDetail(r *routes.Route) map[string]any { } if len(r.PathParams) > 0 { - params := make([]map[string]any, 0, len(r.PathParams)) - for _, p := range r.PathParams { - param := map[string]any{ - "name": p.Name, - "type": p.Type, - "required": p.Required, - "description": p.Description, - } - if len(p.Schema) > 0 { - param["schema"] = json.RawMessage(p.Schema) + if compact { + m["path_params"] = compactPathParamLines(r.PathParams) + } else { + params := make([]map[string]any, 0, len(r.PathParams)) + for _, p := range r.PathParams { + param := map[string]any{ + "name": p.Name, + "type": p.Type, + "required": p.Required, + "description": p.Description, + } + if len(p.Schema) > 0 { + param["schema"] = json.RawMessage(p.Schema) + } + params = append(params, param) } - params = append(params, param) + m["path_params"] = params } - m["path_params"] = params } if len(r.QueryParams) > 0 { - qparams := make([]map[string]any, 0, len(r.QueryParams)) - for _, p := range r.QueryParams { - qparam := map[string]any{ - "name": p.Name, - "type": p.Type, - "required": p.Required, - "description": p.Description, - } - if len(p.Schema) > 0 { - qparam["schema"] = json.RawMessage(p.Schema) + if compact { + m["query_params"] = compactQueryParamLines(r.QueryParams) + } else { + qparams := make([]map[string]any, 0, len(r.QueryParams)) + for _, p := range r.QueryParams { + qparam := map[string]any{ + "name": p.Name, + "type": p.Type, + "required": p.Required, + "description": p.Description, + } + if len(p.Schema) > 0 { + qparam["schema"] = json.RawMessage(p.Schema) + } + qparams = append(qparams, qparam) } - qparams = append(qparams, qparam) + m["query_params"] = qparams } - m["query_params"] = qparams } if len(r.RequestBodySchema) > 0 { - m["request_body_schema"] = json.RawMessage(r.RequestBodySchema) + lines, ok := compactIfSmaller(compact, r.RequestBodySchema, r.RequestBodyRequired) + switch { + case ok: + m["request_body"] = lines + case compact: + m["request_body_schema"] = json.RawMessage(r.RequestBodySchema) + m["compact_skipped"] = compactSkippedReason + default: + m["request_body_schema"] = json.RawMessage(r.RequestBodySchema) + } m["request_body_required"] = r.RequestBodyRequired } if len(r.ResponseSchemas) > 0 { - responseSchemas := make(map[string]json.RawMessage, len(r.ResponseSchemas)) + responses := make(map[string][]string, len(r.ResponseSchemas)) + schemas := make(map[string]json.RawMessage, len(r.ResponseSchemas)) for status, schema := range r.ResponseSchemas { - responseSchemas[status] = json.RawMessage(schema) + if lines, ok := compactIfSmaller(compact, schema, true); ok { + responses[status] = lines + continue + } + schemas[status] = json.RawMessage(schema) + if compact { + m["compact_skipped"] = compactSkippedReason + } + } + if len(responses) > 0 { + m["responses"] = responses + } + if len(schemas) > 0 { + m["response_schemas"] = schemas } - m["response_schemas"] = responseSchemas } // Include example usage. diff --git a/cmd/schema_compact.go b/cmd/schema_compact.go new file mode 100644 index 0000000..c1106e5 --- /dev/null +++ b/cmd/schema_compact.go @@ -0,0 +1,429 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "sort" + "strconv" + "strings" + + "github.com/customerio/cli/internal/routes" +) + +// flatField is one leaf of a flattened JSON schema: a dotted path plus the +// minimum an agent needs to fill the field. +type flatField struct { + Path string + Type string + Required bool + Enum []string + Description string +} + +// flattenSchema turns a resolved JSON schema into a flat list of leaf fields: +// nested objects use dotted paths, arrays append "[]", and a surviving $ref +// becomes a leaf whose type names the component. It renders an already-resolved +// schema and does not resolve refs itself. +func flattenSchema(raw json.RawMessage) []flatField { + return flattenSchemaRoot(raw, true) +} + +// flattenSchemaRoot flattens a schema, marking a root-level leaf (a scalar, +// array, or ref body with no properties) required per rootRequired. For object +// bodies this is irrelevant: each field's optionality comes from the object's +// own required set. Callers pass the body's RequestBodyRequired so a root +// scalar's "?" matches the separate required flag rather than always showing +// optional. +func flattenSchemaRoot(raw json.RawMessage, rootRequired bool) []flatField { + if len(raw) == 0 { + return nil + } + // UseNumber keeps numeric values (e.g. enum members) as their exact source + // text instead of float64, which would mangle them on re-formatting. + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var node any + if err := dec.Decode(&node); err != nil { + return nil + } + var out []flatField + flattenNode(node, "", rootRequired, &out) + return mergeByPath(out) +} + +// mergeByPath collapses fields that flattened to the same dotted path, keeping +// first-seen order. oneOf/anyOf members flatten at the same path, so a field +// shared across members repeats — and a discriminator carries a different enum +// in each member. Union those enums: keeping only the first member's would +// render a 13-branch update_type as the single value "main", which reads as the +// only legal one. Optionality stays first-seen; a description fills in from a +// later member only if the first had none. +func mergeByPath(fields []flatField) []flatField { + at := make(map[string]int, len(fields)) + merged := make([]flatField, 0, len(fields)) + for _, f := range fields { + i, ok := at[f.Path] + if !ok { + at[f.Path] = len(merged) + merged = append(merged, f) + continue + } + merged[i].Enum = unionEnum(merged[i].Enum, f.Enum) + if merged[i].Description == "" { + merged[i].Description = f.Description + } + } + return merged +} + +// unionEnum appends the members of b that a does not already list, preserving +// order so the first member's values stay first. +func unionEnum(a, b []string) []string { + if len(b) == 0 { + return a + } + seen := make(map[string]bool, len(a)+len(b)) + for _, v := range a { + seen[v] = true + } + for _, v := range b { + if !seen[v] { + seen[v] = true + a = append(a, v) + } + } + return a +} + +func flattenNode(node any, path string, required bool, out *[]flatField) { + m, ok := node.(map[string]any) + if !ok { + // A boolean schema (true/false) or other non-object node: emit a labeled + // leaf so it is not silently dropped, including at the schema root. + *out = append(*out, flatField{Path: leafName(path), Type: "any", Required: required}) + return + } + + // A $ref that survived resolution: emit a leaf naming the component. + if ref, ok := m["$ref"].(string); ok { + *out = append(*out, flatField{Path: leafName(path), Type: refType(ref), Required: required, Description: schemaDescription(m)}) + return + } + + descended := false + + // allOf is a conjunction: union the members' required lists so a required + // entry in one member applies to a property defined in another, and skip + // constraint-only members (required but nothing to flatten) so they emit no + // phantom leaf. + if members, ok := m["allOf"].([]any); ok && len(members) > 0 { + req := unionRequired(members) + for _, member := range members { + if mm, isObj := member.(map[string]any); isObj { + if len(req) > 0 { + mm["required"] = req + } + if isConstraintOnly(mm) { + continue + } + } + flattenNode(member, path, required, out) + } + descended = true + } + // anyOf/oneOf are alternatives: flatten each member at the same path to show + // the union of fields an agent might supply. + for _, key := range []string{"anyOf", "oneOf"} { + members, ok := m[key].([]any) + if !ok || len(members) == 0 { + continue + } + for _, member := range members { + flattenNode(member, path, required, out) + } + descended = true + } + + // Object: flatten each property under a dotted path. An empty properties map + // has nothing to descend into and falls through to a leaf. + if props, ok := m["properties"].(map[string]any); ok && len(props) > 0 { + reqSet := stringSet(m["required"]) + names := make([]string, 0, len(props)) + for name := range props { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + childPath := name + if path != "" { + childPath = path + "." + name + } + flattenNode(props[name], childPath, reqSet[name], out) + } + descended = true + } + + // Array: flatten the item schema under path[]. items may be a schema object + // or a boolean (any element type), in which case the element is "any". + if isArrayType(m) { + if items, ok := m["items"].(map[string]any); ok { + flattenNode(items, path+"[]", required, out) + } else { + *out = append(*out, flatField{Path: path + "[]", Type: "any", Required: required, Description: schemaDescription(m)}) + } + descended = true + } + + if descended { + return + } + + // Leaf: scalar, union type, or object with no descendable fields. A field + // with only an enum infers its type from the enum members. + leaf := fieldType(m) + if leaf == "" { + leaf = enumType(m["enum"]) + } + if leaf == "" { + leaf = "object" + } + *out = append(*out, flatField{ + Path: leafName(path), + Type: leaf, + Required: required, + Enum: stringSlice(m["enum"]), + Description: schemaDescription(m), + }) +} + +// leafName labels a nameless leaf (a scalar or ref at the schema root). +func leafName(path string) string { + if path == "" { + return "(value)" + } + return path +} + +// fieldType renders a schema's "type", which may be a string or a union array +// such as ["string","null"]. +func fieldType(m map[string]any) string { + switch t := m["type"].(type) { + case string: + return t + case []any: + parts := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + parts = append(parts, s) + } + } + return strings.Join(parts, "|") + } + return "" +} + +// isArrayType reports whether the schema's type is "array", including a union +// such as ["array","null"]. +func isArrayType(m map[string]any) bool { + switch t := m["type"].(type) { + case string: + return t == "array" + case []any: + for _, e := range t { + if s, ok := e.(string); ok && s == "array" { + return true + } + } + } + return false +} + +// unionRequired collects the union of "required" entries across composition +// members, as a []any suitable for re-injecting into a member schema. +func unionRequired(members []any) []any { + seen := map[string]bool{} + var out []any + for _, member := range members { + mm, ok := member.(map[string]any) + if !ok { + continue + } + for _, r := range stringSlice(mm["required"]) { + if !seen[r] { + seen[r] = true + out = append(out, r) + } + } + } + return out +} + +// isConstraintOnly reports whether an allOf member carries a required list but +// nothing the flattener would render as a field (so it should not emit a leaf). +func isConstraintOnly(m map[string]any) bool { + if _, ok := m["required"]; !ok { + return false + } + if _, ok := m["$ref"]; ok { + return false + } + if p, ok := m["properties"].(map[string]any); ok && len(p) > 0 { + return false + } + if _, ok := m["items"]; ok { + return false + } + for _, k := range []string{"allOf", "anyOf", "oneOf"} { + if a, ok := m[k].([]any); ok && len(a) > 0 { + return false + } + } + return true +} + +// enumType infers a field's type from its enum members when no explicit type is +// set. Returns "" when the members are mixed or not scalars. +func enumType(v any) string { + arr, ok := v.([]any) + if !ok || len(arr) == 0 { + return "" + } + t := "" + for _, e := range arr { + var et string + switch e.(type) { + case nil: + continue // null is nullability, not a type; skip for inference + case string: + et = "string" + case json.Number: + et = "number" + case bool: + et = "boolean" + default: + return "" + } + if t == "" { + t = et + } else if t != et { + return "" + } + } + return t +} + +// paramEnum extracts a parameter schema's enum values for compact rendering. +func paramEnum(schema json.RawMessage) []string { + if len(schema) == 0 { + return nil + } + dec := json.NewDecoder(bytes.NewReader(schema)) + dec.UseNumber() + var m map[string]any + if err := dec.Decode(&m); err != nil { + return nil + } + return stringSlice(m["enum"]) +} + +// compactLines renders fields one per line. Optional fields get a "?" type +// suffix (absence of "?" means required). +func compactLines(fields []flatField) []string { + lines := make([]string, 0, len(fields)) + for _, f := range fields { + typ := f.Type + if !f.Required { + typ += "?" + } + parts := []string{f.Path, typ} + if len(f.Enum) > 0 { + parts = append(parts, "enum: "+strings.Join(f.Enum, "|")) + } + if f.Description != "" { + parts = append(parts, f.Description) + } + lines = append(lines, strings.Join(parts, " ")) + } + return lines +} + +// compactPathParamLines renders path parameters as one line each. +func compactPathParamLines(params []routes.RouteParam) []string { + lines := make([]string, 0, len(params)) + for _, p := range params { + lines = append(lines, compactParam(p.Name, p.Type, p.Required, p.Description, p.Schema)) + } + return lines +} + +// compactQueryParamLines renders query parameters as one line each. +func compactQueryParamLines(params []routes.QueryParam) []string { + lines := make([]string, 0, len(params)) + for _, p := range params { + lines = append(lines, compactParam(p.Name, p.Type, p.Required, p.Description, p.Schema)) + } + return lines +} + +func compactParam(name, typ string, required bool, description string, schema json.RawMessage) string { + if !required { + typ += "?" + } + parts := []string{name, typ} + if enum := paramEnum(schema); len(enum) > 0 { + parts = append(parts, "enum: "+strings.Join(enum, "|")) + } + if description != "" { + parts = append(parts, description) + } + return strings.Join(parts, " ") +} + +func refType(ref string) string { + name := ref + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + // Component names are generated from package-qualified type names (e.g. + // pkg_subpkg_Filter); keep the trailing type name as a readable hint. + if idx := strings.LastIndex(name, "_"); idx >= 0 && idx+1 < len(name) { + name = name[idx+1:] + } + return "ref:" + name +} + +func schemaDescription(m map[string]any) string { + d, _ := m["description"].(string) + // Collapse whitespace so the field stays on one line; keep the full text so + // enum values and constraints reach the agent intact. + return strings.Join(strings.Fields(d), " ") +} + +func stringSet(v any) map[string]bool { + out := map[string]bool{} + for _, s := range stringSlice(v) { + out[s] = true + } + return out +} + +func stringSlice(v any) []string { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, e := range arr { + switch s := e.(type) { + case string: + out = append(out, s) + case json.Number: + out = append(out, s.String()) + case bool: + out = append(out, strconv.FormatBool(s)) + case nil: + out = append(out, "null") + } + } + return out +} diff --git a/cmd/schema_compact_test.go b/cmd/schema_compact_test.go new file mode 100644 index 0000000..2b85af9 --- /dev/null +++ b/cmd/schema_compact_test.go @@ -0,0 +1,518 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + "testing" + + "github.com/customerio/cli/internal/routes" +) + +func fieldByPath(t *testing.T, fields []flatField, path string) flatField { + t.Helper() + for _, f := range fields { + if f.Path == path { + return f + } + } + t.Fatalf("no field at path %q; got %v", path, fieldPaths(fields)) + return flatField{} +} + +func hasFieldPath(fields []flatField, path string) bool { + for _, f := range fields { + if f.Path == path { + return true + } + } + return false +} + +func fieldPaths(fields []flatField) []string { + paths := make([]string, 0, len(fields)) + for _, f := range fields { + paths = append(paths, f.Path) + } + return paths +} + +func TestFlattenSchema_NestedObjectsArraysEnumsRefs(t *testing.T) { + raw := json.RawMessage(`{ + "type": "object", + "required": ["name", "audience"], + "properties": { + "name": { "type": "string" }, + "audience": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "type": "integer", "enum": ["1", "2"], "description": "Audience selector" }, + "person_filters": { "type": "string" } + } + }, + "edges": { + "type": "array", + "items": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" } } } + }, + "tags": { "type": "array", "items": { "type": "string" } }, + "layout": { "$ref": "#/components/schemas/api_ui_Layout" }, + "freeform": { "type": "object" } + } + }`) + + fields := flattenSchema(raw) + + if name := fieldByPath(t, fields, "name"); name.Type != "string" || !name.Required { + t.Errorf("name = %+v, want type=string required=true", name) + } + + at := fieldByPath(t, fields, "audience.type") + if at.Type != "integer" || !at.Required { + t.Errorf("audience.type = %+v, want type=integer required=true", at) + } + if !slices.Equal(at.Enum, []string{"1", "2"}) { + t.Errorf("audience.type enum = %v, want [1 2]", at.Enum) + } + if at.Description != "Audience selector" { + t.Errorf("audience.type description = %q", at.Description) + } + + if pf := fieldByPath(t, fields, "audience.person_filters"); pf.Required { + t.Error("field absent from parent required should be optional") + } + + for _, path := range []string{"edges[].from", "edges[].to"} { + if !hasFieldPath(fields, path) { + t.Errorf("array-of-objects should flatten items under %q", path) + } + } + + if tags := fieldByPath(t, fields, "tags[]"); tags.Type != "string" { + t.Errorf("array-of-scalars leaf type = %q, want string", tags.Type) + } + + if layout := fieldByPath(t, fields, "layout"); !strings.Contains(layout.Type, "Layout") { + t.Errorf("ref leaf type = %q, want it to name the component", layout.Type) + } + + if free := fieldByPath(t, fields, "freeform"); free.Type != "object" { + t.Errorf("object without properties = %q, want object leaf", free.Type) + } +} + +// An object with empty properties must still appear as a leaf, not vanish. +func TestFlattenSchema_EmptyPropertiesObjectIsLeaf(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"meta":{"type":"object","properties":{}}}}`) + if f := fieldByPath(t, flattenSchema(raw), "meta"); f.Type != "object" { + t.Errorf("meta type = %q, want object", f.Type) + } +} + +// A union type ("type": ["string","null"]) must render the members, not "object". +func TestFlattenSchema_UnionType(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"x":{"type":["string","null"]}}}`) + if f := fieldByPath(t, flattenSchema(raw), "x"); f.Type != "string|null" { + t.Errorf("x type = %q, want string|null", f.Type) + } +} + +// A root scalar body must not produce a blank-path line. +func TestFlattenSchema_RootScalarHasName(t *testing.T) { + fields := flattenSchema(json.RawMessage(`{"type":"string"}`)) + if len(fields) != 1 || fields[0].Path == "" { + t.Fatalf("root scalar must produce one labeled leaf, got %+v", fields) + } +} + +// A surviving ref renders as the trailing type name, not the full generated +// component path. +func TestRefType_ShortensToTypeName(t *testing.T) { + cases := map[string]string{ + "#/components/schemas/pkg_libraries_filters_Filter": "ref:Filter", + "#/components/schemas/pkg_core_MapFilter": "ref:MapFilter", + "#/components/schemas/Widget": "ref:Widget", + } + for ref, want := range cases { + if got := refType(ref); got != want { + t.Errorf("refType(%q) = %q, want %q", ref, got, want) + } + } +} + +// oneOf/anyOf members are flattened at the same path, so overlapping fields +// must be de-duplicated to one line each (the union of possible fields). +func TestFlattenSchema_DeduplicatesPaths(t *testing.T) { + raw := json.RawMessage(`{"oneOf":[ + {"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}}}, + {"type":"object","properties":{"id":{"type":"string"},"extra":{"type":"integer"}}} + ]}`) + fields := flattenSchema(raw) + + count := 0 + for _, f := range fields { + if f.Path == "id" { + count++ + } + } + if count != 1 { + t.Errorf("id appears %d times, want 1", count) + } + for _, path := range []string{"name", "extra"} { + if !hasFieldPath(fields, path) { + t.Errorf("union of member fields must keep %q", path) + } + } +} + +// A required root-scalar body must not show the optional "?" marker; its +// optionality is conveyed by request_body_required, not by the root leaf. +func TestRouteDetail_CompactRootScalarBodyMatchesRequiredFlag(t *testing.T) { + base := func(required bool) *routes.Route { + return &routes.Route{ + Resource: "x", Method: "create", HTTPMethod: "POST", Path: "/v1/x", HasBody: true, + RequestBodySchema: json.RawMessage(`{"type":"string"}`), RequestBodyRequired: required, + } + } + cases := []struct { + required bool + want string + }{ + {true, "(value) string"}, + {false, "(value) string?"}, + } + for _, tc := range cases { + body, ok := routeDetail(base(tc.required), true)["request_body"].([]string) + if !ok || len(body) != 1 { + t.Fatalf("required=%v: request_body = %v", tc.required, routeDetail(base(tc.required), true)["request_body"]) + } + if body[0] != tc.want { + t.Errorf("required=%v: body line = %q, want %q", tc.required, body[0], tc.want) + } + } +} + +// A root boolean schema (true/false) must still emit a labeled line. +func TestFlattenSchema_RootBooleanSchema(t *testing.T) { + fields := flattenSchema(json.RawMessage(`true`)) + if len(fields) != 1 || fields[0].Path == "" { + t.Fatalf("root boolean schema must emit one labeled leaf, got %+v", fields) + } +} + +// A field with only an enum (no type) must infer its type, not show "object". +func TestFlattenSchema_EnumOnlyFieldTyped(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"x":{"enum":["a","b"]}}}`) + f := fieldByPath(t, flattenSchema(raw), "x") + if f.Type != "string" { + t.Errorf("enum-only field type = %q, want string inferred from members", f.Type) + } + if !slices.Equal(f.Enum, []string{"a", "b"}) { + t.Errorf("enum = %v, want [a b]", f.Enum) + } +} + +// Compact param lines must surface a param's schema enum. +func TestRouteDetail_CompactParamIncludesEnum(t *testing.T) { + r := &routes.Route{ + Resource: "c", Method: "list", HTTPMethod: "GET", Path: "/v1/x", + QueryParams: []routes.QueryParam{{ + Name: "state", Type: "string", Required: false, Description: "Filter by state", + Schema: json.RawMessage(`{"type":"string","enum":["running","draft"]}`), + }}, + } + qp, ok := routeDetail(r, true)["query_params"].([]string) + if !ok || len(qp) != 1 { + t.Fatalf("query_params = %v, want one line", routeDetail(r, true)["query_params"]) + } + if !strings.Contains(qp[0], "enum: running|draft") { + t.Errorf("query param line = %q, want it to carry the enum", qp[0]) + } +} + +// A union type that includes "array" must still flatten its items. +func TestFlattenSchema_UnionArrayFlattensItems(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"xs":{"type":["array","null"],"items":{"type":"object","properties":{"a":{"type":"string"}}}}}}`) + if !hasFieldPath(flattenSchema(raw), "xs[].a") { + t.Error("union array type must descend into items") + } +} + +// An array whose items is a boolean schema must still use the [] convention. +func TestFlattenSchema_BooleanItemsKeepBracket(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"tags":{"type":"array","items":true}}}`) + if !hasFieldPath(flattenSchema(raw), "tags[]") { + t.Error("array with boolean items must render tags[]") + } +} + +// Boolean and null enum members must render, not be silently dropped, and a +// null member must not break type inference (it is nullability, not a type). +func TestFlattenSchema_BooleanAndNullEnum(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"flag":{"enum":[true,false,null]}}}`) + f := fieldByPath(t, flattenSchema(raw), "flag") + if !slices.Equal(f.Enum, []string{"true", "false", "null"}) { + t.Errorf("enum = %v, want [true false null]", f.Enum) + } + if f.Type != "boolean" { + t.Errorf("type = %q, want boolean (null must not drop inference)", f.Type) + } +} + +// allOf is a conjunction: a required list in one member applies to a property +// defined in another, and a constraint-only member emits no phantom leaf. +func TestFlattenSchema_AllOfRequiredAcrossMembers(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"obj":{"allOf":[ + {"type":"object","properties":{"id":{"type":"string"},"opt":{"type":"string"}}}, + {"required":["id"]} + ]}}}`) + fields := flattenSchema(raw) + + if id := fieldByPath(t, fields, "obj.id"); !id.Required { + t.Error("required from a sibling allOf member must apply") + } + if opt := fieldByPath(t, fields, "obj.opt"); opt.Required { + t.Error("fields not in any member's required stay optional") + } + if hasFieldPath(fields, "obj") { + t.Error("constraint-only allOf member must not emit a phantom leaf") + } +} + +// An empty composition array must not make the field disappear. +func TestFlattenSchema_EmptyCompositionStillLeaf(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"x":{"allOf":[]}}}`) + if f := fieldByPath(t, flattenSchema(raw), "x"); f.Type != "object" { + t.Errorf("x type = %q, want object", f.Type) + } +} + +// A surviving $ref at the schema root must be labeled, not blank-path. +func TestFlattenSchema_RootRefHasName(t *testing.T) { + fields := flattenSchema(json.RawMessage(`{"$ref":"#/components/schemas/Foo"}`)) + if len(fields) != 1 || fields[0].Path == "" { + t.Fatalf("root $ref must produce one labeled leaf, got %+v", fields) + } + if !strings.Contains(fields[0].Type, "Foo") { + t.Errorf("root ref type = %q, want it to name Foo", fields[0].Type) + } +} + +// allOf/anyOf/oneOf members must be flattened, not collapsed to an "object" leaf. +func TestFlattenSchema_AllOfMergesFields(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"w":{"allOf":[ + {"type":"object","properties":{"a":{"type":"string"}}}, + {"type":"object","properties":{"b":{"type":"integer"}}} + ]}}}`) + fields := flattenSchema(raw) + for _, path := range []string{"w.a", "w.b"} { + if !hasFieldPath(fields, path) { + t.Errorf("allOf member field %q must flatten", path) + } + } +} + +// Numeric enum values must render with their exact value (0 must not become +// empty, 10 must not become 1). +func TestFlattenSchema_NumericEnumValuesIntact(t *testing.T) { + raw := json.RawMessage(`{"type":"object","properties":{"state":{"type":"integer","enum":[0,10,1,1.5]}}}`) + f := fieldByPath(t, flattenSchema(raw), "state") + if !slices.Equal(f.Enum, []string{"0", "10", "1", "1.5"}) { + t.Errorf("enum = %v, want [0 10 1 1.5]", f.Enum) + } +} + +// Descriptions must reach the agent in full (enum docs, constraints), with +// internal whitespace collapsed so the field stays on one line. +func TestSchemaDescription_FullTextNoTruncation(t *testing.T) { + raw := "Audience selector.\n0 = Self,\t1 = all, 2 = certain. " + strings.Repeat("detail ", 40) + got := schemaDescription(map[string]any{"description": raw}) + + if strings.ContainsAny(got, "\n\t") { + t.Errorf("description = %q, want newlines and tabs collapsed", got) + } + if !strings.Contains(got, "0 = Self, 1 = all, 2 = certain.") { + t.Errorf("description = %q, want enum docs intact", got) + } + if len(got) <= 200 { + t.Errorf("description length = %d, want the full text preserved", len(got)) + } +} + +func TestCompactLines_Formatting(t *testing.T) { + lines := compactLines([]flatField{ + {Path: "name", Type: "string", Required: true}, + {Path: "audience.person_filters", Type: "string", Required: false}, + {Path: "audience.type", Type: "integer", Required: true, Enum: []string{"1", "2"}, Description: "Audience selector"}, + }) + if len(lines) != 3 { + t.Fatalf("got %d lines, want 3", len(lines)) + } + if lines[0] != "name string" { + t.Errorf("required line = %q, want %q", lines[0], "name string") + } + if lines[1] != "audience.person_filters string?" { + t.Errorf("optional line = %q, want a ? suffix", lines[1]) + } + for _, want := range []string{"audience.type integer", "enum: 1|2", "Audience selector"} { + if !strings.Contains(lines[2], want) { + t.Errorf("line %q missing %q", lines[2], want) + } + } +} + +func TestRouteDetail_CompactReplacesRawBody(t *testing.T) { + r := &routes.Route{ + Resource: "widgets", Method: "create", HTTPMethod: "POST", + Path: "/v1/environments/{environment_id}/widgets", Summary: "Create widget", HasBody: true, + PathParams: []routes.RouteParam{{Name: "environment_id", Type: "integer", Required: true, Description: "The workspace (environment) ID"}}, + RequestBodySchema: json.RawMessage(`{"type":"object","required":["widget"],"properties":{"widget":{"type":"object","properties":{"id":{"type":"integer"}}}}}`), + RequestBodyRequired: true, + ResponseSchemas: map[string]json.RawMessage{"200": json.RawMessage(`{"type":"object","properties":{"ok":{"type":"boolean"}}}`)}, + } + + full := routeDetail(r, false) + if _, ok := full["request_body_schema"]; !ok { + t.Error("non-compact must keep the raw JSON schema") + } + if _, ok := full["request_body"]; ok { + t.Error("non-compact must not emit flattened lines") + } + + compact := routeDetail(r, true) + if _, ok := compact["request_body_schema"]; ok { + t.Error("compact must drop the raw JSON schema") + } + body, ok := compact["request_body"].([]string) + if !ok { + t.Fatalf("compact request_body = %T, want []string", compact["request_body"]) + } + if !slices.Contains(body, "widget.id integer?") { + t.Errorf("request_body = %v, want a flattened widget.id line", body) + } + if compact["request_body_required"] != true { + t.Errorf("request_body_required = %v, want true", compact["request_body_required"]) + } + + pp, ok := compact["path_params"].([]string) + if !ok { + t.Fatalf("compact path_params = %T, want []string", compact["path_params"]) + } + if !slices.Contains(pp, "environment_id integer The workspace (environment) ID") { + t.Errorf("path_params = %v, want a flattened environment_id line", pp) + } + + resp, ok := compact["responses"].(map[string][]string) + if !ok { + t.Fatalf("compact responses = %T, want map[string][]string", compact["responses"]) + } + if !slices.Contains(resp["200"], "ok boolean?") { + t.Errorf("responses[200] = %v, want a flattened ok line", resp["200"]) + } + if _, ok := compact["response_schemas"]; ok { + t.Error("compact must drop response_schemas") + } +} + +// A discriminated union carries a different single-value enum per member (the +// real newsletter update body has 13). Collapsing to the first member's enum +// would present one legal value where 13 exist, so the merge unions them. +func TestFlattenSchema_UnionsEnumsAcrossMembers(t *testing.T) { + raw := json.RawMessage(`{"oneOf":[ + {"type":"object","required":["update_type"],"properties":{"update_type":{"type":"string","enum":["main"]},"name":{"type":"string"}}}, + {"type":"object","required":["update_type"],"properties":{"update_type":{"type":"string","enum":["tracking"]},"conversion":{"type":"boolean"}}}, + {"type":"object","required":["update_type"],"properties":{"update_type":{"type":"string","enum":["main","pause"]}}} + ]}`) + fields := flattenSchema(raw) + + var got []string + for _, f := range fields { + if f.Path == "update_type" { + got = append(got, f.Enum...) + } + } + want := []string{"main", "tracking", "pause"} + if len(got) != len(want) { + t.Fatalf("update_type enum = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("update_type enum = %v, want %v", got, want) + } + } +} + +// A member that documents a field the earlier member left undescribed should +// supply the description rather than lose it to first-seen order. +func TestFlattenSchema_MergeFillsMissingDescription(t *testing.T) { + raw := json.RawMessage(`{"oneOf":[ + {"type":"object","properties":{"id":{"type":"string"}}}, + {"type":"object","properties":{"id":{"type":"string","description":"Newsletter identifier"}}} + ]}`) + fields := flattenSchema(raw) + + for _, f := range fields { + if f.Path == "id" { + if f.Description != "Newsletter identifier" { + t.Fatalf("id description = %q, want the later member's text", f.Description) + } + return + } + } + t.Fatal("id field missing") +} + +// Flattening enumerates one line per leaf path, so a schema whose components are +// shared across branches can flatten larger than the schema it replaces. Compact +// is a size optimisation, so it must fall back rather than return the bigger of +// the two, and it must say that it did. +func TestRouteDetail_CompactFallsBackWhenLarger(t *testing.T) { + // A deep chain with a wide object at the bottom. The schema states each + // level's name once; the flattened form repeats the whole dotted prefix on + // every leaf line, which is what makes it the larger of the two. + var leaves []string + for i := range 40 { + leaves = append(leaves, fmt.Sprintf(`"field%02d":{"type":"string"}`, i)) + } + nested := `{"type":"object","properties":{` + strings.Join(leaves, ",") + `}}` + for i := range 30 { + nested = fmt.Sprintf(`{"type":"object","properties":{"nested_level_%02d":`, i) + nested + `}}` + } + r := &routes.Route{ + Resource: "x", Method: "create", HTTPMethod: "POST", Path: "/v1/x", HasBody: true, + RequestBodySchema: json.RawMessage(nested), RequestBodyRequired: true, + } + + detail := routeDetail(r, true) + + if _, ok := detail["request_body"]; ok { + t.Fatal("compact rendering was kept even though it is larger than the schema") + } + if _, ok := detail["request_body_schema"]; !ok { + t.Fatal("the schema was not returned as the fallback") + } + reason, ok := detail["compact_skipped"].(string) + if !ok || reason == "" { + t.Fatal("the fallback did not explain itself") + } +} + +// The common case must be untouched: when flattening is smaller, compact wins +// and no fallback marker appears. +func TestRouteDetail_CompactKeptWhenSmaller(t *testing.T) { + r := &routes.Route{ + Resource: "x", Method: "create", HTTPMethod: "POST", Path: "/v1/x", HasBody: true, + RequestBodySchema: json.RawMessage(`{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"A reasonably long description that makes the schema bigger than its flattened form."}}}`), + RequestBodyRequired: true, + } + + detail := routeDetail(r, true) + + if _, ok := detail["request_body"]; !ok { + t.Fatal("compact rendering was dropped for a schema it shrinks") + } + if _, ok := detail["compact_skipped"]; ok { + t.Fatal("compact_skipped set on a body that rendered compact") + } +} diff --git a/cmd/schema_test.go b/cmd/schema_test.go index 1ed63b6..439c2be 100644 --- a/cmd/schema_test.go +++ b/cmd/schema_test.go @@ -47,7 +47,7 @@ func TestRouteDetailIncludesRequestBodySchema(t *testing.T) { ResponseSchemas: map[string]json.RawMessage{ "200": json.RawMessage(`{"type":"object","properties":{"id":{"type":"integer"}}}`), }, - }) + }, false) if got := detail["description"]; got != "Create a campaign in the workspace." { t.Fatalf("expected description to be preserved, got %v", got)