From ea639610eef7370571d960d56723564b558e10c2 Mon Sep 17 00:00:00 2001 From: hervee Date: Wed, 29 Jul 2026 10:21:13 +0200 Subject: [PATCH 1/2] JGC-527 - Add jf api docs describe for OpenAPI operation detail lookup Adds "jf api docs describe " as a sibling of "jf api docs search", returning the full trimmed view of a single operation from the embedded OpenAPI spec bundle: parameters, request body schema (with an example payload when the spec declares one), response codes/descriptions, and a ready-to-run "jf api" one-liner. JSON is the unconditional default output format, matching docs search's established convention. Extends the apispec parser with response parsing and request-body example capture, plus a FindOperation exact-match lookup helper. Not-found is a hard error (unlike search's 0-exit empty result), pointing back to docs search. Updates jf api --ai-help and related help text to complete the search -> describe -> execute discovery chain and drops the stale "OpenAPI bundles are not shipped" claim. --- docs/api-spec/parser.go | 86 +++++++++++-- docs/api-spec/parser_test.go | 11 ++ docs/api-spec/response_test.go | 82 ++++++++++++ docs/general/api/help.go | 6 +- docs/general/apidocs/help.go | 8 +- docs/general/apidocsdescribe/help.go | 59 +++++++++ docs/general/apidocssearch/help.go | 2 +- general/api/docs_describe.go | 151 ++++++++++++++++++++++ general/api/docs_describe_test.go | 180 +++++++++++++++++++++++++++ main.go | 10 ++ utils/cliutils/commandsflags.go | 11 ++ 11 files changed, 591 insertions(+), 15 deletions(-) create mode 100644 docs/api-spec/response_test.go create mode 100644 docs/general/apidocsdescribe/help.go create mode 100644 general/api/docs_describe.go create mode 100644 general/api/docs_describe_test.go diff --git a/docs/api-spec/parser.go b/docs/api-spec/parser.go index dbe1dd09c..c401fff01 100644 --- a/docs/api-spec/parser.go +++ b/docs/api-spec/parser.go @@ -4,6 +4,7 @@ package apispec import ( + "encoding/json" "fmt" "sort" "strings" @@ -42,6 +43,17 @@ type Property struct { type RequestBody struct { Required bool `json:"required"` Properties []Property `json:"properties,omitempty"` + // Example is the operation's declared requestBody.content.application/json.example, + // verbatim, when the spec provides one. yaml.v3 decodes YAML mappings into + // map[string]interface{} (not v2's map[interface{}]interface{}), so this + // round-trips through encoding/json without a custom converter. + Example json.RawMessage `json:"example,omitempty"` +} + +// Response describes a single declared HTTP response for an operation. +type Response struct { + Code string `json:"code"` + Description string `json:"description,omitempty"` } // Operation describes a single OpenAPI path+method operation. @@ -55,6 +67,9 @@ type Operation struct { // RequestBody is nil for operations with no application/json request body // (typically GET/DELETE). RequestBody *RequestBody + // Responses is sorted by status code ascending; empty when the spec + // declares no responses object for this operation. + Responses []Response } // Metadata describes which spec bundle is embedded in this binary. @@ -71,11 +86,16 @@ type rawDoc struct { } type rawOperation struct { - Summary string `yaml:"summary"` - OperationId string `yaml:"operationId"` - Tags []string `yaml:"tags"` - Parameters []Parameter `yaml:"parameters"` - RequestBody *rawRequestBody `yaml:"requestBody"` + Summary string `yaml:"summary"` + OperationId string `yaml:"operationId"` + Tags []string `yaml:"tags"` + Parameters []Parameter `yaml:"parameters"` + RequestBody *rawRequestBody `yaml:"requestBody"` + Responses map[string]rawResponse `yaml:"responses"` +} + +type rawResponse struct { + Description string `yaml:"description"` } type rawRequestBody struct { @@ -84,7 +104,8 @@ type rawRequestBody struct { } type rawMediaTypeItem struct { - Schema rawSchema `yaml:"schema"` + Schema rawSchema `yaml:"schema"` + Example any `yaml:"example"` } // rawSchema is a deliberately narrow subset of OpenAPI's Schema Object: only @@ -190,6 +211,7 @@ func parseFile(name string) ([]Operation, error) { OperationId: op.OperationId, Parameters: op.Parameters, RequestBody: buildRequestBody(op.RequestBody, doc.Components.Schemas), + Responses: buildResponses(op.Responses), }) } } @@ -232,7 +254,57 @@ func buildRequestBody(raw *rawRequestBody, schemas map[string]rawSchema) *Reques } sort.Slice(properties, func(i, j int) bool { return properties[i].Name < properties[j].Name }) - return &RequestBody{Required: raw.Required, Properties: properties} + return &RequestBody{Required: raw.Required, Properties: properties, Example: buildExample(media.Example)} +} + +// buildExample marshals a requestBody media-type's declared example (decoded by +// yaml.v3 into map[string]interface{}/[]interface{}/primitives) into JSON. +// Returns nil when there's no example to marshal or marshaling somehow fails -- +// an example is a nice-to-have, not worth failing operation parsing over. +func buildExample(v any) json.RawMessage { + if v == nil { + return nil + } + data, err := json.Marshal(v) + if err != nil { + return nil + } + return data +} + +// buildResponses flattens an operation's responses object into a slice sorted +// by status code ascending. String sort is sufficient for the 2/3/4/5-digit +// numeric codes present in both the stub and full bundles today; neither uses +// wildcard forms like "2XX". +func buildResponses(raw map[string]rawResponse) []Response { + if len(raw) == 0 { + return nil + } + responses := make([]Response, 0, len(raw)) + for code, r := range raw { + responses = append(responses, Response{Code: code, Description: r.Description}) + } + sort.Slice(responses, func(i, j int) bool { return responses[i].Code < responses[j].Code }) + return responses +} + +// FindOperation returns the operation matching method (case-insensitive) and +// path (exact, case-sensitive -- paths may contain literal {param} segments, +// e.g. "/worker/api/v1/workers/{workerKey}", matched verbatim against the +// catalog rather than against a concrete instantiated path) from the embedded +// bundle, or ok=false if none matches. +func FindOperation(method, path string) (op Operation, ok bool) { + ops, err := Operations() + if err != nil { + return Operation{}, false + } + method = strings.ToUpper(strings.TrimSpace(method)) + for _, o := range ops { + if o.Method == method && o.Path == path { + return o, true + } + } + return Operation{}, false } // schemaRefName extracts "Foo" from a local-document ref like diff --git a/docs/api-spec/parser_test.go b/docs/api-spec/parser_test.go index 758750085..33bc73fc0 100644 --- a/docs/api-spec/parser_test.go +++ b/docs/api-spec/parser_test.go @@ -31,13 +31,24 @@ func TestOperations_Stub(t *testing.T) { assert.Len(t, getUserList.Parameters, 10) assert.Nil(t, getUserList.RequestBody, "a GET operation should have no request body") + require.Len(t, getUserList.Responses, 4, "getUserList declares 200/400/401/403") + assert.Equal(t, []Response{ + {Code: "200", Description: "Success"}, + {Code: "400", Description: "Bad Request - Invalid input, object invalid"}, + {Code: "401", Description: "Bad Credentials - Invalid credentials"}, + {Code: "403", Description: "Permission Denied - Insufficient permissions"}, + }, getUserList.Responses, "responses should be sorted by code ascending") createUser, ok := byOperationId["createUser"] require.True(t, ok, "createUser should be present") assert.Equal(t, "POST", createUser.Method) assert.Equal(t, "/access/api/v2/users", createUser.Path) + require.Len(t, createUser.Responses, 5, "createUser declares 201/400/401/403/409") + assert.Equal(t, "201", createUser.Responses[0].Code) + assert.Equal(t, "User created successfully", createUser.Responses[0].Description) require.NotNil(t, createUser.RequestBody, "createUser's requestBody ($ref: UserCreateRequest) should resolve") + assert.Nil(t, createUser.RequestBody.Example, "users-api.yaml's createUser declares no requestBody example") assert.True(t, createUser.RequestBody.Required) propsByName := make(map[string]Property, len(createUser.RequestBody.Properties)) for _, p := range createUser.RequestBody.Properties { diff --git a/docs/api-spec/response_test.go b/docs/api-spec/response_test.go new file mode 100644 index 000000000..a64d9566e --- /dev/null +++ b/docs/api-spec/response_test.go @@ -0,0 +1,82 @@ +package apispec + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildResponses_NilWhenEmpty(t *testing.T) { + assert.Nil(t, buildResponses(nil)) + assert.Nil(t, buildResponses(map[string]rawResponse{})) +} + +func TestBuildResponses_SortedByCode(t *testing.T) { + raw := map[string]rawResponse{ + "404": {Description: "Not Found"}, + "200": {Description: "Success"}, + "400": {Description: "Bad Request"}, + } + responses := buildResponses(raw) + require.Len(t, responses, 3) + assert.Equal(t, []Response{ + {Code: "200", Description: "Success"}, + {Code: "400", Description: "Bad Request"}, + {Code: "404", Description: "Not Found"}, + }, responses) +} + +func TestBuildResponses_MissingDescriptionIsEmpty(t *testing.T) { + responses := buildResponses(map[string]rawResponse{"204": {}}) + require.Len(t, responses, 1) + assert.Equal(t, "204", responses[0].Code) + assert.Empty(t, responses[0].Description) +} + +func TestBuildExample_NilWhenAbsent(t *testing.T) { + assert.Nil(t, buildExample(nil)) +} + +func TestBuildExample_RoundTripsNestedMapping(t *testing.T) { + raw := &rawRequestBody{Required: true, Content: map[string]rawMediaTypeItem{ + "application/json": { + Schema: rawSchema{Type: "object"}, + Example: map[string]any{ + "username": "newuser", + "active": true, + "groups": []any{"readers", "writers"}, + "meta": map[string]any{"count": 2}, + }, + }, + }} + + rb := buildRequestBody(raw, nil) + require.NotNil(t, rb) + require.NotNil(t, rb.Example) + assert.JSONEq(t, `{"username":"newuser","active":true,"groups":["readers","writers"],"meta":{"count":2}}`, string(rb.Example)) +} + +func TestBuildRequestBody_NoExampleLeavesFieldNil(t *testing.T) { + raw := &rawRequestBody{Required: true, Content: map[string]rawMediaTypeItem{ + "application/json": {Schema: rawSchema{Type: "object"}}, + }} + rb := buildRequestBody(raw, nil) + require.NotNil(t, rb) + assert.Nil(t, rb.Example) +} + +func TestFindOperation(t *testing.T) { + op, ok := FindOperation("get", "/access/api/v2/users") + require.True(t, ok, "case-insensitive method match should find getUserList") + assert.Equal(t, "getUserList", op.OperationId) + + _, ok = FindOperation("DELETE", "/access/api/v2/users") + assert.False(t, ok, "wrong method for an existing path should not match") + + _, ok = FindOperation("GET", "/access/api/v2/user") + assert.False(t, ok, "a path that's a prefix/substring of a real one should not match") + + _, ok = FindOperation("GET", "/not/a/real/path") + assert.False(t, ok) +} diff --git a/docs/general/api/help.go b/docs/general/api/help.go index 644fdf0be..5706cb9c9 100644 --- a/docs/general/api/help.go +++ b/docs/general/api/help.go @@ -5,7 +5,7 @@ import "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" var Usage = []string{"api "} func GetDescription() string { - return "Invoke a JFrog Platform HTTP API using the configured server URL and credentials (hostname and token are not passed manually; use 'jf config' or --url / --access-token / --server-id as usual). REST API reference: " + coreutils.JFrogHelpUrl + "jfrog-platform-documentation/rest-apis (OpenAPI bundles are not shipped with the CLI)." + return "Invoke a JFrog Platform HTTP API using the configured server URL and credentials (hostname and token are not passed manually; use 'jf config' or --url / --access-token / --server-id as usual). REST API reference: " + coreutils.JFrogHelpUrl + "jfrog-platform-documentation/rest-apis. Run 'jf api docs search ' to look up an endpoint locally, offline, from this binary's embedded OpenAPI spec." } func GetArguments() string { @@ -70,7 +70,7 @@ When to use: - Scripting platform admin tasks where 'jf rt' or 'jf c' don't cover the operation. - Debugging API responses with full visibility into status code and body. -Before guessing at a path: if you don't already know the exact endpoint/method, run 'jf api docs search ' first (e.g. 'jf api docs search user') — it's a local, offline lookup over this binary's embedded OpenAPI spec that returns ranked matches with a ready-to-run 'jf api' command for each. +Before guessing at a path: if you don't already know the exact endpoint/method, run 'jf api docs search ' first (e.g. 'jf api docs search user') — it's a local, offline lookup over this binary's embedded OpenAPI spec that returns ranked matches with a ready-to-run 'jf api' command for each. Then run 'jf api docs describe ' on the best match to see its full parameters, request body schema, and response codes before calling it for real. Prerequisites: - A configured server (jf c add or jf login) or explicit --url / --access-token / --server-id. @@ -89,5 +89,5 @@ Gotchas: - Some APIs require trailing slashes or specific Accept headers; check the API reference before scripting. - The bare, slash-less path 'docs' (e.g. 'jf api docs') routes to 'jf api docs search' instead of issuing an HTTP call. This does not affect the leading-slash form: 'jf api -X GET /docs' still reaches the platform normally, since no real JFrog REST path is bare '/docs'. -Related: jf api docs search, jf c add, jf rt, jf c show` +Related: jf api docs search, jf api docs describe, jf c add, jf rt, jf c show` } diff --git a/docs/general/apidocs/help.go b/docs/general/apidocs/help.go index 79ad50692..f4e52de24 100644 --- a/docs/general/apidocs/help.go +++ b/docs/general/apidocs/help.go @@ -1,13 +1,13 @@ package apidocs -var Usage = []string{"api docs search [command options]"} +var Usage = []string{"api docs search [command options]", "api docs describe [command options]"} func GetDescription() string { - return "Discover JFrog Platform REST API operations. Run 'jf api docs search ' to find the right endpoint before using 'jf api '." + return "Discover JFrog Platform REST API operations. Run 'jf api docs search ' to find a candidate endpoint, then 'jf api docs describe ' to see its full shape, before using 'jf api '." } func GetAIDescription() string { - return `Namespace for API-discovery subcommands. Run 'jf api docs search ' to look up a REST endpoint by keyword before guessing at 'jf api '. + return `Namespace for API-discovery subcommands. Run 'jf api docs search ' to look up a REST endpoint by keyword, then 'jf api docs describe ' to see its parameters/request body/response codes, before guessing at 'jf api '. -See 'jf api docs search --help' for the full set of options.` +See 'jf api docs search --help' and 'jf api docs describe --help' for the full set of options.` } diff --git a/docs/general/apidocsdescribe/help.go b/docs/general/apidocsdescribe/help.go new file mode 100644 index 000000000..f887c8e28 --- /dev/null +++ b/docs/general/apidocsdescribe/help.go @@ -0,0 +1,59 @@ +package apidocsdescribe + +var Usage = []string{"api docs describe [--format table|json]"} + +func GetDescription() string { + return "Return the full trimmed operation view (parameters, request body schema, response codes/descriptions, an example payload when available, and a ready-to-run 'jf api' command) for a single method+path from the OpenAPI operations embedded in this jf binary. Local and offline: no server configuration or network call is involved." +} + +func GetArguments() string { + return ` method + HTTP method of the operation (GET, POST, PUT, DELETE, ...). Case-insensitive. + + path + Exact endpoint path as declared in the catalog, e.g. /access/api/v2/users. Templated path segments (e.g. {workerKey}) must be passed literally, exactly as returned by 'jf api docs search'. + +EXAMPLES + # Describe a GET operation + $ jf api docs describe GET /access/api/v2/users + + # Describe a POST operation, including its request body schema + $ jf api docs describe POST /access/api/v2/users + + # A path with a templated segment, copied verbatim from 'jf api docs search' + $ jf api docs describe DELETE /worker/api/v1/workers/{workerKey} + + # Human-readable table instead of the default JSON + $ jf api docs describe GET /access/api/v2/users --format table + +OUTPUT + JSON by default (this command exists primarily for agent consumption); pass --format table for a human-readable table instead. The result includes the operation's method, path, summary, tags, "parameters" (path/query/header, required ones marked), "request_body" (top-level fields with name/type/required/description/default, plus an "example" payload when the spec declares one), "responses" (status code + description for each declared response), and a "jf_api" field with a ready-to-run 'jf api' invocation. When method+path isn't found in the embedded catalog, the command exits non-zero with an error naming the spec bundle searched and recommending 'jf api docs search' to find the exact method/path.` +} + +func GetAIDescription() string { + return `Return the full detail (parameters, request body schema, response codes, example payload, ready-to-run command) for one exact method+path from the OpenAPI operations embedded in this jf binary. Use this after 'jf api docs search' has narrowed down a candidate operation, to see its full shape before calling it with 'jf api'. + +When to use: +- You already have a method+path (e.g. from 'jf api docs search' results) and need to know its parameters, request body shape, or possible response codes before calling it. +- Before calling a POST/PUT/PATCH endpoint whose exact required/optional body fields you don't already know. +- To confirm a path exists in the embedded catalog at all before guessing with 'jf api '. + +Prerequisites: none. This command is fully local/offline — no server configuration, credentials, or network call. + +Typical flow: 'jf api docs search ' → pick a method+path from the results → 'jf api docs describe ' → construct and run the real 'jf api' call. + +Common patterns: + $ jf api docs describe GET /access/api/v2/users + $ jf api docs describe POST /access/api/v2/users --format json + $ jf api docs describe DELETE /worker/api/v1/workers/{workerKey} + +Gotchas: +- The embedded spec bundle may be a small "stub" subset in this build, not the full JFrog REST API surface — an unresolved lookup names spec_bundle so you know whether that's the likely cause. +- Output is JSON by default (unconditionally, unlike most other jf commands' --ai-help-gated JSON defaults); pass --format table for a human-readable table instead. +- path must match the catalog exactly, including any literal {param} placeholders (e.g. "{workerKey}", not a real key) — copy it verbatim from 'jf api docs search' results rather than guessing. +- Not found (wrong method, wrong path, or the stub bundle lacks the operation) is a hard error (non-zero exit), unlike 'jf api docs search', which returns an empty match list with exit 0. +- request_body's "example" field is only present when the underlying spec declares one; its absence doesn't mean the operation has no valid payload — check "properties" either way. +- A request body property that is itself a nested object is reported by its type name (e.g. "PermissionResource") or "object" rather than being recursively flattened — only top-level fields are listed. + +Related: jf api docs search, jf api, jf api --ai-help` +} diff --git a/docs/general/apidocssearch/help.go b/docs/general/apidocssearch/help.go index 0641b2484..cf3c20580 100644 --- a/docs/general/apidocssearch/help.go +++ b/docs/general/apidocssearch/help.go @@ -54,5 +54,5 @@ Gotchas: - A request body property that is itself a nested object is reported by its type name (e.g. "PermissionResource") or "object" rather than being recursively flattened -- only top-level fields are listed. - Results are capped at --limit (default 10). Check "truncated"/"total_matches" in the JSON body if you need to know whether more results exist -- the truncation warning goes to stderr, which you may not be capturing. -Related: jf api, jf api --ai-help` +Related: jf api docs describe, jf api, jf api --ai-help` } diff --git a/general/api/docs_describe.go b/general/api/docs_describe.go new file mode 100644 index 000000000..afa8a41d6 --- /dev/null +++ b/general/api/docs_describe.go @@ -0,0 +1,151 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" + apispec "github.com/jfrog/jfrog-cli/docs/api-spec" + "github.com/jfrog/jfrog-cli/utils/cliutils" + clientUtils "github.com/jfrog/jfrog-client-go/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/urfave/cli" +) + +// describeResult is the JSON/table rendering payload for `jf api docs describe`. +type describeResult struct { + SpecBundle string `json:"spec_bundle"` + SpecVersion string `json:"spec_version"` + Method string `json:"method"` + Path string `json:"path"` + Summary string `json:"summary,omitempty"` + Tags []string `json:"tags,omitempty"` + Parameters []apispec.Parameter `json:"parameters,omitempty"` + RequestBody *apispec.RequestBody `json:"request_body,omitempty"` + Responses []apispec.Response `json:"responses,omitempty"` + JfApi string `json:"jf_api"` +} + +// DescribeCommand implements `jf api docs describe `. It returns +// the full trimmed operation view (parameters, request body schema, response +// codes/descriptions, example payload when declared, and a ready-to-run jf api +// one-liner) for a single method+path pulled from the embedded OpenAPI spec +// bundle -- same local, offline lookup model as `jf api docs search`. +func DescribeCommand(c *cli.Context) error { + return runDescribeCmd(c, os.Stdout) +} + +// runDescribeCmd is split out from DescribeCommand so tests can supply their +// own stdOut without hijacking the real os.Stdout -- same split as +// Command/runApiCmd in cli.go and SearchCommand/runSearchCmd in docs_search.go. +func runDescribeCmd(c *cli.Context, stdOut io.Writer) error { + if c.NArg() != 2 { + return cliutils.WrongNumberOfArgumentsHandler(c) + } + method := c.Args().Get(0) + path := normalizeApiPath(c.Args().Get(1)) + + info := apispec.Info() + op, ok := apispec.FindOperation(method, path) + if !ok { + return errorutils.CheckErrorf( + "no operation found for %s %s in the embedded %q OpenAPI spec bundle. "+ + "Run 'jf api docs search ' to find the exact method/path first -- "+ + "the bundle may be incomplete (see spec_bundle), or the path may need to "+ + "match the catalog's literal {param} placeholders exactly.", + strings.ToUpper(strings.TrimSpace(method)), path, info.SpecBundle) + } + + result := describeResult{ + SpecBundle: info.SpecBundle, + SpecVersion: info.SpecVersion, + Method: op.Method, + Path: op.Path, + Summary: op.Summary, + Tags: op.Tags, + Parameters: op.Parameters, + RequestBody: op.RequestBody, + Responses: op.Responses, + JfApi: jfApiOneLiner(op), + } + + // JSON is the unconditional default -- this command exists primarily for + // agent consumption, matching `jf api docs search`'s convention. + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Json) + if err != nil { + return err + } + + switch outputFormat { + case coreformat.Json: + return renderDescribeJSON(result) + case coreformat.Table: + return renderDescribeTable(result, stdOut) + default: + return errorutils.CheckErrorf("unsupported format '%s' for api docs describe. Accepted values: table, json", outputFormat) + } +} + +// normalizeApiPath prepends a leading "/" when missing, same convention as +// joinPlatformAPIURL in cli.go, so both "GET access/api/v2/users" and +// "GET /access/api/v2/users" resolve against the catalog. +func normalizeApiPath(path string) string { + p := strings.TrimSpace(path) + if p != "" && !strings.HasPrefix(p, "/") { + p = "/" + p + } + return p +} + +// renderDescribeJSON writes result as indented JSON via the shared client +// logger -- same pattern as renderJSON in docs_search.go. +func renderDescribeJSON(result describeResult) error { + data, err := json.Marshal(result) + if err != nil { + return errorutils.CheckErrorf("failed to marshal api docs describe result: %s", err.Error()) + } + log.Output(clientUtils.IndentJson(data)) + return nil +} + +// renderDescribeTable writes result as a single-record key/value dump to w -- +// there is exactly one operation to show, not a ranked list, so this doesn't +// reuse renderTable's multi-row shape from docs_search.go. +func renderDescribeTable(result describeResult, w io.Writer) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintf(tw, "METHOD\t%s\n", result.Method) + _, _ = fmt.Fprintf(tw, "PATH\t%s\n", result.Path) + _, _ = fmt.Fprintf(tw, "SUMMARY\t%s\n", result.Summary) + _, _ = fmt.Fprintf(tw, "TAGS\t%s\n", strings.Join(result.Tags, ",")) + _, _ = fmt.Fprintf(tw, "PARAMETERS\t%s\n", formatParams(result.Parameters)) + _, _ = fmt.Fprintf(tw, "REQUEST BODY\t%s\n", formatRequestBody(result.RequestBody)) + if result.RequestBody != nil && len(result.RequestBody.Example) > 0 { + _, _ = fmt.Fprintf(tw, "REQUEST BODY EXAMPLE\t%s\n", string(result.RequestBody.Example)) + } + _, _ = fmt.Fprintf(tw, "RESPONSES\t%s\n", formatResponses(result.Responses)) + _, _ = fmt.Fprintf(tw, "JF API\t%s\n", result.JfApi) + return tw.Flush() +} + +// formatResponses renders a compact "code:description, ..." summary of an +// operation's declared responses for the table view. +func formatResponses(responses []apispec.Response) string { + if len(responses) == 0 { + return "-" + } + parts := make([]string, len(responses)) + for i, r := range responses { + if r.Description == "" { + parts[i] = r.Code + continue + } + parts[i] = r.Code + ":" + r.Description + } + return strings.Join(parts, ", ") +} diff --git a/general/api/docs_describe_test.go b/general/api/docs_describe_test.go new file mode 100644 index 000000000..e35245db0 --- /dev/null +++ b/general/api/docs_describe_test.go @@ -0,0 +1,180 @@ +package api + +import ( + "bytes" + "encoding/json" + "testing" + + apispec "github.com/jfrog/jfrog-cli/docs/api-spec" + clientlog "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" +) + +func TestNormalizeApiPath(t *testing.T) { + assert.Equal(t, "/access/api/v2/users", normalizeApiPath("/access/api/v2/users")) + assert.Equal(t, "/access/api/v2/users", normalizeApiPath("access/api/v2/users")) + assert.Equal(t, "", normalizeApiPath("")) + assert.Equal(t, "", normalizeApiPath(" ")) +} + +func TestFormatResponses(t *testing.T) { + assert.Equal(t, "-", formatResponses(nil)) + assert.Equal(t, "200:Success, 400", formatResponses([]apispec.Response{ + {Code: "200", Description: "Success"}, + {Code: "400"}, + })) +} + +// newDescribeApp builds a minimal cli.App exercising runDescribeCmd exactly +// like the real "describe" subcommand's flag set -- same technique as +// newSearchApp in docs_search_test.go. +func newDescribeApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { + app := cli.NewApp() + app.Flags = []cli.Flag{ + cli.StringFlag{Name: "format"}, + } + app.Action = func(c *cli.Context) error { + *capturedErr = runDescribeCmd(c, stdOut) + return nil + } + return app +} + +func TestRunDescribeCmd_KnownGetOperation(t *testing.T) { + result, _ := runDescribeJSON(t, "GET", "/access/api/v2/users") + assert.Equal(t, "GET", result["method"]) + assert.Equal(t, "/access/api/v2/users", result["path"]) + assert.Equal(t, "stub", result["spec_bundle"]) + assert.NotEmpty(t, result["parameters"]) + assert.Nil(t, result["request_body"]) + assert.NotEmpty(t, result["responses"]) + assert.Equal(t, "jf api /access/api/v2/users", result["jf_api"]) +} + +func TestRunDescribeCmd_KnownPostOperation(t *testing.T) { + result, _ := runDescribeJSON(t, "POST", "/access/api/v2/users") + assert.Equal(t, "POST", result["method"]) + + requestBody, ok := result["request_body"].(map[string]any) + require.True(t, ok, "createUser should carry a request_body") + assert.True(t, requestBody["required"].(bool)) + properties, ok := requestBody["properties"].([]any) + require.True(t, ok) + assert.NotEmpty(t, properties) + + jfApi, ok := result["jf_api"].(string) + require.True(t, ok) + assert.Contains(t, jfApi, "-X POST") + assert.Contains(t, jfApi, "-d '") +} + +func TestRunDescribeCmd_CaseInsensitiveMethod(t *testing.T) { + result, _ := runDescribeJSON(t, "get", "/access/api/v2/users") + assert.Equal(t, "GET", result["method"]) +} + +func TestRunDescribeCmd_PathWithoutLeadingSlashNormalizes(t *testing.T) { + result, _ := runDescribeJSON(t, "GET", "access/api/v2/users") + assert.Equal(t, "/access/api/v2/users", result["path"]) +} + +func TestRunDescribeCmd_NotFoundReturnsError(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "GET", "/not/a/real/path"})) + require.Error(t, runErr) + assert.Contains(t, runErr.Error(), "spec_bundle") + assert.Contains(t, runErr.Error(), "docs search") +} + +func TestRunDescribeCmd_WrongNumberOfArguments(t *testing.T) { + for _, args := range [][]string{ + {"cmd"}, + {"cmd", "GET"}, + {"cmd", "GET", "/a", "extra"}, + } { + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + require.NoError(t, app.Run(args)) + assert.Error(t, runErr, "args %v should be rejected", args) + } +} + +func TestRunDescribeCmd_TableOutput(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "--format", "table", "POST", "/access/api/v2/users"})) + require.NoError(t, runErr) + assert.Contains(t, stdOut.String(), "METHOD") + assert.Contains(t, stdOut.String(), "POST") + assert.Contains(t, stdOut.String(), "REQUEST BODY") + assert.Contains(t, stdOut.String(), "RESPONSES") + assert.Contains(t, stdOut.String(), "JF API") +} + +// TestRunDescribeCmd_DefaultsToJSON verifies JSON is the default output format +// when --format is omitted, matching docs search's unconditional-JSON-default +// convention (see TestRunSearchCmd_DefaultsToJSON). +func TestRunDescribeCmd_DefaultsToJSON(t *testing.T) { + var out bytes.Buffer + prevLogger := clientlog.GetLogger() + t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) + clientlog.SetLogger(clientlog.NewLoggerWithFlags(clientlog.INFO, &out, 0)) + + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "GET", "/access/api/v2/users"})) + require.NoError(t, runErr) + + var result map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &result), "default output should be parseable JSON") + assert.Equal(t, "stub", result["spec_bundle"]) + assert.Empty(t, stdOut.String(), "JSON goes through the logger's Output channel, not the stdOut writer") +} + +// TestSearchThenDescribe_EndToEnd guards the intended agent flow: a search +// result's method+path must resolve cleanly through describe, and describe's +// jf_api one-liner must match search's one-liner for the same operation (both +// call the shared jfApiOneLiner helper). +func TestSearchThenDescribe_EndToEnd(t *testing.T) { + matches := filterAndScore(stubOps(t), "user", "", "") + require.NotEmpty(t, matches) + top := matches[0] + + result, _ := runDescribeJSON(t, top.Method, top.Path) + assert.Equal(t, top.Method, result["method"]) + assert.Equal(t, top.Path, result["path"]) + assert.Equal(t, top.JfApi, result["jf_api"]) +} + +// runDescribeJSON runs the describe app with JSON output (the default) and +// returns the parsed result body -- same technique as runSearchJSON in +// docs_search_test.go. +func runDescribeJSON(t *testing.T, method, path string) (result map[string]any, logged string) { + t.Helper() + var jsonOut, logOut bytes.Buffer + logger := clientlog.NewLoggerWithFlags(clientlog.INFO, &logOut, 0) + logger.SetOutputWriter(&jsonOut) + prevLogger := clientlog.GetLogger() + t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) + clientlog.SetLogger(logger) + + var stdOut bytes.Buffer + var runErr error + app := newDescribeApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", method, path})) + require.NoError(t, runErr) + + require.NoError(t, json.Unmarshal(jsonOut.Bytes(), &result), "output should be parseable JSON") + return result, logOut.String() +} diff --git a/main.go b/main.go index 7a4715193..74c8786da 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ import ( "github.com/jfrog/jfrog-cli/docs/common" apiDocs "github.com/jfrog/jfrog-cli/docs/general/api" apiDocsNodeDocs "github.com/jfrog/jfrog-cli/docs/general/apidocs" + apiDocsDescribeDocs "github.com/jfrog/jfrog-cli/docs/general/apidocsdescribe" apiDocsSearchDocs "github.com/jfrog/jfrog-cli/docs/general/apidocssearch" loginDocs "github.com/jfrog/jfrog-cli/docs/general/login" oidcDocs "github.com/jfrog/jfrog-cli/docs/general/oidc" @@ -405,6 +406,15 @@ func getCommands() ([]cli.Command, error) { BashComplete: corecommon.CreateBashCompletionFunc(), Action: api.SearchCommand, }, + { + Name: "describe", + Flags: cliutils.GetCommandFlags(cliutils.ApiDocsDescribe), + Usage: corecommon.ResolveDescription(apiDocsDescribeDocs.GetDescription(), apiDocsDescribeDocs.GetAIDescription()), + HelpName: corecommon.CreateUsage("api docs describe", corecommon.ResolveDescription(apiDocsDescribeDocs.GetDescription(), apiDocsDescribeDocs.GetAIDescription()), apiDocsDescribeDocs.Usage), + UsageText: apiDocsDescribeDocs.GetArguments(), + BashComplete: corecommon.CreateBashCompletionFunc(), + Action: api.DescribeCommand, + }, }, }, }, diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index 145937489..18d04305f 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -137,6 +137,7 @@ const ( ExchangeOidcToken = "exchange-oidc-token" Api = "api" ApiDocsSearch = "api-docs-search" + ApiDocsDescribe = "api-docs-describe" // MCP commands keys McpShow = "mcp-show" @@ -668,6 +669,9 @@ const ( apiDocsSearchLimit = "api-docs-search-limit" apiDocsSearchFormat = "api-docs-search-format" + // API docs describe command flags + apiDocsDescribeFormat = "api-docs-describe-format" + // MCP command flags mcpUrl = "mcp-url" mcpAgent = "mcp-agent" @@ -835,6 +839,10 @@ var flagsMap = map[string]cli.Flag{ Name: Format, Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json, format.Table}) + "` `", }, + apiDocsDescribeFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json, format.Table}) + "` `", + }, mcpShowFormat: cli.StringFlag{ Name: Format, Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Table, format.Json}) + "` `", @@ -2288,6 +2296,9 @@ var commandFlags = map[string][]string{ ApiDocsSearch: { apiDocsSearchTag, apiDocsSearchMethod, apiDocsSearchLimit, apiDocsSearchFormat, }, + ApiDocsDescribe: { + apiDocsDescribeFormat, + }, McpShow: { platformUrl, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, ClientCertKeyPath, InsecureTls, configDisableRefreshAccessToken, From 85f8275795dcc9c74db42dbacf551216cd201f84 Mon Sep 17 00:00:00 2001 From: hervee Date: Wed, 29 Jul 2026 14:31:19 +0200 Subject: [PATCH 2/2] JGC-527 - Fix golangci-lint findings in docs_describe_test.go Checks the "required" type assertion instead of asserting it inline (forcetypeassert), and drops runDescribeJSON's unused "logged" return value (unparam) -- no test consumed it. --- general/api/docs_describe_test.go | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/general/api/docs_describe_test.go b/general/api/docs_describe_test.go index e35245db0..d4827df4f 100644 --- a/general/api/docs_describe_test.go +++ b/general/api/docs_describe_test.go @@ -43,7 +43,7 @@ func newDescribeApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { } func TestRunDescribeCmd_KnownGetOperation(t *testing.T) { - result, _ := runDescribeJSON(t, "GET", "/access/api/v2/users") + result := runDescribeJSON(t, "GET", "/access/api/v2/users") assert.Equal(t, "GET", result["method"]) assert.Equal(t, "/access/api/v2/users", result["path"]) assert.Equal(t, "stub", result["spec_bundle"]) @@ -54,12 +54,14 @@ func TestRunDescribeCmd_KnownGetOperation(t *testing.T) { } func TestRunDescribeCmd_KnownPostOperation(t *testing.T) { - result, _ := runDescribeJSON(t, "POST", "/access/api/v2/users") + result := runDescribeJSON(t, "POST", "/access/api/v2/users") assert.Equal(t, "POST", result["method"]) requestBody, ok := result["request_body"].(map[string]any) require.True(t, ok, "createUser should carry a request_body") - assert.True(t, requestBody["required"].(bool)) + required, ok := requestBody["required"].(bool) + require.True(t, ok) + assert.True(t, required) properties, ok := requestBody["properties"].([]any) require.True(t, ok) assert.NotEmpty(t, properties) @@ -71,12 +73,12 @@ func TestRunDescribeCmd_KnownPostOperation(t *testing.T) { } func TestRunDescribeCmd_CaseInsensitiveMethod(t *testing.T) { - result, _ := runDescribeJSON(t, "get", "/access/api/v2/users") + result := runDescribeJSON(t, "get", "/access/api/v2/users") assert.Equal(t, "GET", result["method"]) } func TestRunDescribeCmd_PathWithoutLeadingSlashNormalizes(t *testing.T) { - result, _ := runDescribeJSON(t, "GET", "access/api/v2/users") + result := runDescribeJSON(t, "GET", "access/api/v2/users") assert.Equal(t, "/access/api/v2/users", result["path"]) } @@ -150,7 +152,7 @@ func TestSearchThenDescribe_EndToEnd(t *testing.T) { require.NotEmpty(t, matches) top := matches[0] - result, _ := runDescribeJSON(t, top.Method, top.Path) + result := runDescribeJSON(t, top.Method, top.Path) assert.Equal(t, top.Method, result["method"]) assert.Equal(t, top.Path, result["path"]) assert.Equal(t, top.JfApi, result["jf_api"]) @@ -158,8 +160,10 @@ func TestSearchThenDescribe_EndToEnd(t *testing.T) { // runDescribeJSON runs the describe app with JSON output (the default) and // returns the parsed result body -- same technique as runSearchJSON in -// docs_search_test.go. -func runDescribeJSON(t *testing.T, method, path string) (result map[string]any, logged string) { +// docs_search_test.go. The logger's Info/Warn channel is routed to a separate +// buffer from its Output channel so stray log lines can't corrupt the JSON +// body being unmarshaled here. +func runDescribeJSON(t *testing.T, method, path string) map[string]any { t.Helper() var jsonOut, logOut bytes.Buffer logger := clientlog.NewLoggerWithFlags(clientlog.INFO, &logOut, 0) @@ -175,6 +179,7 @@ func runDescribeJSON(t *testing.T, method, path string) (result map[string]any, require.NoError(t, app.Run([]string{"cmd", method, path})) require.NoError(t, runErr) + var result map[string]any require.NoError(t, json.Unmarshal(jsonOut.Bytes(), &result), "output should be parseable JSON") - return result, logOut.String() + return result }