Skip to content
Merged
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
15 changes: 15 additions & 0 deletions docs/api-spec/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,21 @@ func Info() Metadata {
}
}

// RequireFullBundle returns an error when this binary was built without the full
// rdme-admin OpenAPI bundle (e.g. plain `go build` or Homebrew source builds).
// `jf api docs search` and `jf api docs describe` call this before serving
// from the embedded catalog.
func RequireFullBundle() error {
if Bundle == "full" {
return nil
}
return fmt.Errorf(
"this command requires the full OpenAPI spec bundle, but this binary embeds the %q bundle "+
"(typical of source/Homebrew builds). Install the official release from "+
"https://install-cli.jfrog.io for full `jf api docs` support",
Bundle)
}

// isSpecFile reports whether name is a top-level OpenAPI YAML file that should
// be parsed. Excludes rdme-admin's per-endpoint _order.yaml nav files and any
// dotfile (including this package's own full/.placeholder.yaml).
Expand Down
4 changes: 4 additions & 0 deletions docs/api-spec/parser_full_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ func TestInfo_Full(t *testing.T) {
assert.Equal(t, "full", info.SpecBundle)
assert.NotEmpty(t, info.SpecVersion, "full builds should report the rdme-admin version they were fetched from")
}

func TestRequireFullBundle_Full(t *testing.T) {
require.NoError(t, RequireFullBundle())
}
7 changes: 7 additions & 0 deletions docs/api-spec/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ func TestInfo_Stub(t *testing.T) {
assert.Empty(t, info.SpecVersion, "stub builds have no rdme-admin version")
}

func TestRequireFullBundle_Stub(t *testing.T) {
err := RequireFullBundle()
require.Error(t, err)
assert.Contains(t, err.Error(), `"stub"`)
assert.Contains(t, err.Error(), "install-cli.jfrog.io")
}

func TestIsSpecFile(t *testing.T) {
tests := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion docs/general/apidocsdescribe/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Common patterns:
$ 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.
- The embedded spec bundle may be a small "stub" subset in source/Homebrew builds, not the full JFrog REST API surface. By default, 'jf api docs describe' fails fast on stub builds; set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=false to allow the partial catalog (dev/OSS only).
- 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/general/apidocssearch/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Common patterns:
$ jf api docs search repository --limit 3 --format json

Gotchas:
- The embedded spec bundle may be a small "stub" subset in this build, not the full JFrog REST API surface. An empty match list includes spec_bundle so you know whether that's the likely cause.
- The embedded spec bundle may be a small "stub" subset in source/Homebrew builds, not the full JFrog REST API surface. By default, 'jf api docs search' and 'jf api docs describe' fail fast on stub builds; set $JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE=false to allow the partial catalog (dev/OSS only).
- 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.
- Filters (--tag, --method) are hard excludes, applied before ranking/scoring.
- A query with no contains-match anywhere falls back to fuzzy (typo-tolerant) matching, gated by a similarity floor to avoid coincidental false positives (e.g. "evidence" vs "environments"). Advanced: override the floor (0-1, default 0.6) with $JFROG_CLI_API_DOCS_SEARCH_FUZZY_MIN.
Expand Down
26 changes: 26 additions & 0 deletions general/api/docs_bundle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package api

import (
"os"
"strings"

apispec "github.com/jfrog/jfrog-cli/docs/api-spec"
"github.com/jfrog/jfrog-client-go/utils/errorutils"
)

// envRequireFullBundle controls whether `jf api docs search` and
// `jf api docs describe` fail on binaries that embed the dev "stub" OpenAPI
// bundle. Enabled by default; set to "false" to allow the partial catalog.
const envRequireFullBundle = "JFROG_CLI_API_DOCS_REQUIRE_FULL_BUNDLE"

func apiDocsRequireFullBundle() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv(envRequireFullBundle)))
return v != "false"
}

func maybeRequireFullApiDocsBundle() error {
if !apiDocsRequireFullBundle() {
return nil
}
return errorutils.CheckError(apispec.RequireFullBundle())
}
89 changes: 89 additions & 0 deletions general/api/docs_cmd_test_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package api

import (
"bytes"
"encoding/json"
"testing"

clientlog "github.com/jfrog/jfrog-client-go/utils/log"
"github.com/stretchr/testify/require"
"github.com/urfave/cli"
)

// newSearchApp builds a minimal cli.App exercising runSearchCmd exactly like
// the real "search" subcommand's flag set, without going through main.go's
// full command tree.
func newSearchApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App {
app := cli.NewApp()
app.Flags = []cli.Flag{
cli.StringFlag{Name: flagTag},
cli.StringFlag{Name: flagMethod},
cli.IntFlag{Name: flagLimit, Value: defaultLimit},
cli.StringFlag{Name: "format"},
}
app.Action = func(c *cli.Context) error {
*capturedErr = runSearchCmd(c, stdOut)
return nil
}
return app
}

// newDescribeApp builds a minimal cli.App exercising runDescribeCmd exactly
// like the real "describe" subcommand's flag set.
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
}

// runSearchJSON runs the search app with JSON output (the default) and returns
// the parsed result body plus whatever landed on the logger's Warn/Info/Error
// channel.
func runSearchJSON(t *testing.T, args ...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 := newSearchApp(&stdOut, &runErr)

require.NoError(t, app.Run(append([]string{"cmd"}, args...)))
require.NoError(t, runErr)

require.NoError(t, json.Unmarshal(jsonOut.Bytes(), &result), "output should be parseable JSON")
return result, logOut.String()
}

// runDescribeJSON runs the describe app with JSON output (the default) and
// returns the parsed result body.
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)
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)

var result map[string]any
require.NoError(t, json.Unmarshal(jsonOut.Bytes(), &result), "output should be parseable JSON")
return result
}
3 changes: 3 additions & 0 deletions general/api/docs_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ func runDescribeCmd(c *cli.Context, stdOut io.Writer) error {
}
method := c.Args().Get(0)
path := normalizeApiPath(c.Args().Get(1))
if err := maybeRequireFullApiDocsBundle(); err != nil {
return err
}

info := apispec.Info()
op, ok := apispec.FindOperation(method, path)
Expand Down
69 changes: 20 additions & 49 deletions general/api/docs_describe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
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) {
Expand All @@ -34,22 +33,8 @@ func TestFormatResponses(t *testing.T) {
}))
}

// 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) {
allowStubApiDocsBundle(t)
result := runDescribeJSON(t, "GET", "/access/api/v2/users")
assert.Equal(t, "GET", result["method"])
assert.Equal(t, "/access/api/v2/users", result["path"])
Expand All @@ -61,6 +46,7 @@ func TestRunDescribeCmd_KnownGetOperation(t *testing.T) {
}

func TestRunDescribeCmd_KnownPostOperation(t *testing.T) {
allowStubApiDocsBundle(t)
result := runDescribeJSON(t, "POST", "/access/api/v2/users")
assert.Equal(t, "POST", result["method"])

Expand All @@ -80,16 +66,19 @@ func TestRunDescribeCmd_KnownPostOperation(t *testing.T) {
}

func TestRunDescribeCmd_CaseInsensitiveMethod(t *testing.T) {
allowStubApiDocsBundle(t)
result := runDescribeJSON(t, "get", "/access/api/v2/users")
assert.Equal(t, "GET", result["method"])
}

func TestRunDescribeCmd_PathWithoutLeadingSlashNormalizes(t *testing.T) {
allowStubApiDocsBundle(t)
result := runDescribeJSON(t, "GET", "access/api/v2/users")
assert.Equal(t, "/access/api/v2/users", result["path"])
}

func TestRunDescribeCmd_NotFoundReturnsError(t *testing.T) {
allowStubApiDocsBundle(t)
var stdOut bytes.Buffer
var runErr error
app := newDescribeApp(&stdOut, &runErr)
Expand All @@ -115,6 +104,7 @@ func TestRunDescribeCmd_WrongNumberOfArguments(t *testing.T) {
}

func TestRunDescribeCmd_TableOutput(t *testing.T) {
allowStubApiDocsBundle(t)
var stdOut bytes.Buffer
var runErr error
app := newDescribeApp(&stdOut, &runErr)
Expand All @@ -128,10 +118,8 @@ func TestRunDescribeCmd_TableOutput(t *testing.T) {
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) {
allowStubApiDocsBundle(t)
var out bytes.Buffer
prevLogger := clientlog.GetLogger()
t.Cleanup(func() { clientlog.SetLogger(prevLogger) })
Expand All @@ -150,11 +138,20 @@ func TestRunDescribeCmd_DefaultsToJSON(t *testing.T) {
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 TestRunDescribeCmd_RequireFullBundleFailsOnStub(t *testing.T) {
var stdOut bytes.Buffer
var runErr error
app := newDescribeApp(&stdOut, &runErr)

require.NoError(t, app.Run([]string{"cmd", "GET", "/access/api/v2/users"}))
require.Error(t, runErr)
assert.Contains(t, runErr.Error(), `"stub"`)
assert.Contains(t, runErr.Error(), "install-cli.jfrog.io")
assert.Empty(t, stdOut.String())
}

func TestSearchThenDescribe_EndToEnd(t *testing.T) {
allowStubApiDocsBundle(t)
matches := filterAndScore(stubOps(t), "user", "", "")
require.NotEmpty(t, matches)
top := matches[0]
Expand All @@ -164,29 +161,3 @@ func TestSearchThenDescribe_EndToEnd(t *testing.T) {
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. 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)
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)

var result map[string]any
require.NoError(t, json.Unmarshal(jsonOut.Bytes(), &result), "output should be parseable JSON")
return result
}
3 changes: 3 additions & 0 deletions general/api/docs_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ func runSearchCmd(c *cli.Context, stdOut io.Writer) error {
if limit <= 0 {
limit = defaultLimit
}
if err := maybeRequireFullApiDocsBundle(); err != nil {
return err
}

ops, err := apispec.Operations()
if err != nil {
Expand Down
Loading
Loading