diff --git a/cmd/schema.go b/cmd/schema.go index 786e169..1e92b3e 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -69,36 +69,112 @@ func runSchema(cmd *cobra.Command, args []string) error { return err } + q := parseSchemaArgs(args) + switch q.kind { + case schemaQueryResources: + return schemaOutput(cmd, listEndpoints(reg)) + case schemaQueryPath: + return schemaForPath(cmd, reg, q.a) + case schemaQueryResourceMethod: + return schemaForResourceMethod(cmd, reg, q.a, q.b) + case schemaQueryResource: + return schemaForResource(cmd, reg, q.a) + case schemaQueryHTTPEndpoint: + return schemaForHTTPEndpoint(cmd, reg, q.a, q.b) + case schemaQuerySpacedResourceMethod: + return schemaForSpacedResourceMethod(cmd, reg, q.a, q.b) + default: + // Unreachable while Args is MaximumNArgs(2); still emits the structured + // error every other path does, so relaxing that validator can't silently + // break the JSON contract agents parse. + err := fmt.Errorf("expected at most 2 arguments, got %d", len(args)) + output.PrintError(output.CodeValidationError, err.Error(), nil) + return err + } +} + +type schemaQueryKind int + +const ( + schemaQueryInvalid schemaQueryKind = iota + schemaQueryResources + schemaQueryResource + schemaQueryResourceMethod + schemaQueryPath + schemaQueryHTTPEndpoint + schemaQuerySpacedResourceMethod +) + +type schemaQuery struct { + kind schemaQueryKind + a, b string +} + +// httpMethods are the verbs the "METHOD /path" form accepts. No resource is named +// after one, so a first argument that matches is unambiguously that form. +var httpMethods = map[string]bool{ + "GET": true, "POST": true, "PUT": true, "PATCH": true, + "DELETE": true, "HEAD": true, "OPTIONS": true, +} + +// parseSchemaArgs interprets the documented argument forms. Two args normally mean +// "METHOD /path"; `schema campaigns update` can only be `campaigns.update` typed +// with a space, which gets its own kind so the caller can name the dotted form +// instead of reporting the resource as an unknown HTTP method. Agents hit that +// error, read it as "the endpoint does not exist", and go looking for another way +// to do the thing. +// +// The first argument decides: a verb keeps the "METHOD /path" reading even when the +// path is malformed, so `schema DELETE segments` (a path missing its leading slash) +// still reports an unknown endpoint rather than being read as a resource named +// "DELETE". +func parseSchemaArgs(args []string) schemaQuery { switch len(args) { case 0: - // List all resources with endpoint counts. - return schemaOutput(cmd, listEndpoints(reg)) + return schemaQuery{kind: schemaQueryResources} case 1: arg := args[0] - - // If it starts with /, it's a path — show all methods. if strings.HasPrefix(arg, "/") { - return schemaForPath(cmd, reg, arg) + return schemaQuery{kind: schemaQueryPath, a: arg} } - - // If it contains a dot, treat as resource.method. if parts := strings.SplitN(arg, ".", 2); len(parts) == 2 { - return schemaForResourceMethod(cmd, reg, parts[0], parts[1]) + return schemaQuery{kind: schemaQueryResourceMethod, a: parts[0], b: parts[1]} } - - // Otherwise treat as a resource name. - return schemaForResource(cmd, reg, arg) + return schemaQuery{kind: schemaQueryResource, a: arg} case 2: - // "METHOD /path" form. method := strings.ToUpper(args[0]) - path := args[1] - return schemaForHTTPEndpoint(cmd, reg, method, path) + if !httpMethods[method] && !strings.HasPrefix(args[1], "/") { + return schemaQuery{kind: schemaQuerySpacedResourceMethod, a: args[0], b: args[1]} + } + return schemaQuery{kind: schemaQueryHTTPEndpoint, a: method, b: args[1]} default: - return fmt.Errorf("too many arguments") + return schemaQuery{kind: schemaQueryInvalid} + } +} + +// schemaForSpacedResourceMethod rejects `schema ` and names the +// dotted form. The separator is one character away from correct, so the old +// "unknown endpoint: CAMPAIGNS update" read as "no such endpoint" and sent callers +// looking elsewhere. Suggesting the fix rather than accepting the spelling keeps one +// documented form, matching schemaForResourceMethod's reject-and-suggest shape. +func schemaForSpacedResourceMethod(cmd *cobra.Command, reg *routes.Registry, resource, method string) error { + dotted := resource + "." + method + msg := fmt.Sprintf("%q is not an argument form: use \".\" or \"METHOD /path\"", resource+" "+method) + + if reg.FindRoute(resource, method) != nil { + msg += "\n\nDid you mean:\n cio schema " + dotted + } else if suggestions := suggestRoutes(reg, resource, method); len(suggestions) > 0 { + msg += "\n\nDid you mean:" + for _, s := range suggestions { + msg += fmt.Sprintf("\n cio schema %s.%s", s.Resource, s.Method) + } } + + output.PrintError(output.CodeValidationError, msg, map[string]any{"dotted_form": dotted}) + return fmt.Errorf("%s", msg) } // schemaForResource lists all endpoints for a given resource. diff --git a/cmd/schema_test.go b/cmd/schema_test.go index 32ba8cc..1ed63b6 100644 --- a/cmd/schema_test.go +++ b/cmd/schema_test.go @@ -99,3 +99,39 @@ func TestRouteDetailIncludesRequestBodySchema(t *testing.T) { t.Fatalf("expected response schema type=object, got %v", parsedResponseSchema["type"]) } } + +func TestParseSchemaArgs(t *testing.T) { + cases := []struct { + name string + args []string + want schemaQuery + }{ + {"no args", nil, schemaQuery{kind: schemaQueryResources}}, + {"resource", []string{"campaigns"}, schemaQuery{kind: schemaQueryResource, a: "campaigns"}}, + {"dotted method", []string{"campaigns.update"}, schemaQuery{kind: schemaQueryResourceMethod, a: "campaigns", b: "update"}}, + {"path", []string{"/v1/environments/{environment_id}/campaigns"}, schemaQuery{kind: schemaQueryPath, a: "/v1/environments/{environment_id}/campaigns"}}, + {"http endpoint", []string{"get", "/v1/environments/{environment_id}/campaigns"}, schemaQuery{kind: schemaQueryHTTPEndpoint, a: "GET", b: "/v1/environments/{environment_id}/campaigns"}}, + // A second arg that isn't a path can only be the dotted form typed with a + // space; reading it as an HTTP method produces "unknown endpoint: CAMPAIGNS + // update", which looks like the endpoint doesn't exist. + // Not accepted as a second spelling: it gets its own kind so the caller can + // reject it and name `campaigns.update`, keeping one documented form. + {"space instead of dot", []string{"campaigns", "update"}, schemaQuery{kind: schemaQuerySpacedResourceMethod, a: "campaigns", b: "update"}}, + // A verb keeps the "METHOD /path" reading even when the path is malformed: + // otherwise a slash-less path gets fuzzy-matched as a resource name and the + // error points somewhere unrelated instead of at the missing slash. + {"verb with slashless path", []string{"DELETE", "segments"}, schemaQuery{kind: schemaQueryHTTPEndpoint, a: "DELETE", b: "segments"}}, + {"lowercase verb with slashless path", []string{"delete", "segments"}, schemaQuery{kind: schemaQueryHTTPEndpoint, a: "DELETE", b: "segments"}}, + // Cobra's MaximumNArgs(2) rejects this before RunE, but parseSchemaArgs is a + // pure function with its own contract — pinned so it stays total. + {"too many args", []string{"a", "b", "c"}, schemaQuery{kind: schemaQueryInvalid}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := parseSchemaArgs(tc.args); got != tc.want { + t.Errorf("parseSchemaArgs(%q) = %+v, want %+v", tc.args, got, tc.want) + } + }) + } +}