Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 79 additions & 7 deletions docs/api-spec/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package apispec

import (
"encoding/json"
"fmt"
"sort"
"strings"
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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),
})
}
}
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/api-spec/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
82 changes: 82 additions & 0 deletions docs/api-spec/response_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 3 additions & 3 deletions docs/general/api/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
var Usage = []string{"api <endpoint-path>"}

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 <query>' to look up an endpoint locally, offline, from this binary's embedded OpenAPI spec."
}

func GetArguments() string {
Expand Down Expand Up @@ -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 <query>' 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 <query>' 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 <method> <path>' 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.
Expand All @@ -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`
}
8 changes: 4 additions & 4 deletions docs/general/apidocs/help.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package apidocs

var Usage = []string{"api docs search <query> [command options]"}
var Usage = []string{"api docs search <query> [command options]", "api docs describe <method> <path> [command options]"}

func GetDescription() string {
return "Discover JFrog Platform REST API operations. Run 'jf api docs search <query>' to find the right endpoint before using 'jf api <path>'."
return "Discover JFrog Platform REST API operations. Run 'jf api docs search <query>' to find a candidate endpoint, then 'jf api docs describe <method> <path>' to see its full shape, before using 'jf api <path>'."
}

func GetAIDescription() string {
return `Namespace for API-discovery subcommands. Run 'jf api docs search <query>' to look up a REST endpoint by keyword before guessing at 'jf api <path>'.
return `Namespace for API-discovery subcommands. Run 'jf api docs search <query>' to look up a REST endpoint by keyword, then 'jf api docs describe <method> <path>' to see its parameters/request body/response codes, before guessing at 'jf api <path>'.

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.`
}
59 changes: 59 additions & 0 deletions docs/general/apidocsdescribe/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package apidocsdescribe

var Usage = []string{"api docs describe <method> <path> [--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 <path>'.

Prerequisites: none. This command is fully local/offline — no server configuration, credentials, or network call.

Typical flow: 'jf api docs search <query>' → pick a method+path from the results → 'jf api docs describe <method> <path>' → 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`
}
2 changes: 1 addition & 1 deletion docs/general/apidocssearch/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}
Loading
Loading