diff --git a/docs/api-spec/parser.go b/docs/api-spec/parser.go index fcb2cea7c..114522fab 100644 --- a/docs/api-spec/parser.go +++ b/docs/api-spec/parser.go @@ -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). diff --git a/docs/api-spec/parser_full_test.go b/docs/api-spec/parser_full_test.go index a4bfb5f6b..a7d19e513 100644 --- a/docs/api-spec/parser_full_test.go +++ b/docs/api-spec/parser_full_test.go @@ -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()) +} diff --git a/docs/api-spec/parser_test.go b/docs/api-spec/parser_test.go index a20eed0ae..400581676 100644 --- a/docs/api-spec/parser_test.go +++ b/docs/api-spec/parser_test.go @@ -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 diff --git a/docs/general/apidocsdescribe/help.go b/docs/general/apidocsdescribe/help.go index f887c8e28..251b95969 100644 --- a/docs/general/apidocsdescribe/help.go +++ b/docs/general/apidocsdescribe/help.go @@ -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. diff --git a/docs/general/apidocssearch/help.go b/docs/general/apidocssearch/help.go index cf3c20580..cee3cba9c 100644 --- a/docs/general/apidocssearch/help.go +++ b/docs/general/apidocssearch/help.go @@ -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. diff --git a/general/api/docs_bundle.go b/general/api/docs_bundle.go new file mode 100644 index 000000000..be55eb9db --- /dev/null +++ b/general/api/docs_bundle.go @@ -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()) +} diff --git a/general/api/docs_cmd_test_helpers_test.go b/general/api/docs_cmd_test_helpers_test.go new file mode 100644 index 000000000..26c61053b --- /dev/null +++ b/general/api/docs_cmd_test_helpers_test.go @@ -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 +} diff --git a/general/api/docs_describe.go b/general/api/docs_describe.go index afa8a41d6..0f4057148 100644 --- a/general/api/docs_describe.go +++ b/general/api/docs_describe.go @@ -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) diff --git a/general/api/docs_describe_test.go b/general/api/docs_describe_test.go index 2d77a2a54..13068370b 100644 --- a/general/api/docs_describe_test.go +++ b/general/api/docs_describe_test.go @@ -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) { @@ -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"]) @@ -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"]) @@ -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) @@ -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) @@ -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) }) @@ -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] @@ -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 -} diff --git a/general/api/docs_search.go b/general/api/docs_search.go index ba1a52403..13643c271 100644 --- a/general/api/docs_search.go +++ b/general/api/docs_search.go @@ -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 { diff --git a/general/api/docs_search_test.go b/general/api/docs_search_test.go index d058abe0f..cc3f25185 100644 --- a/general/api/docs_search_test.go +++ b/general/api/docs_search_test.go @@ -17,7 +17,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 stubOps(t *testing.T) []apispec.Operation { @@ -175,23 +174,19 @@ func TestHasTag(t *testing.T) { assert.False(t, hasTag([]string{"Users"}, "workers")) } -// 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 -- same technique as TestResolveRequestBody in -// cli_test.go. -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 +func allowStubApiDocsBundle(t *testing.T) { + t.Helper() + t.Setenv(envRequireFullBundle, "false") +} + +func TestApiDocsRequireFullBundle_DefaultOn(t *testing.T) { + t.Setenv(envRequireFullBundle, "") + assert.True(t, apiDocsRequireFullBundle()) +} + +func TestApiDocsRequireFullBundle_Disabled(t *testing.T) { + t.Setenv(envRequireFullBundle, "false") + assert.False(t, apiDocsRequireFullBundle()) } // TestRunSearchCmd_DefaultsToJSON verifies JSON is the default output format @@ -201,6 +196,7 @@ func newSearchApp(stdOut *bytes.Buffer, capturedErr *error) *cli.App { // the JSON path writes via its Output channel, same technique as // TestApiJSONErrorMode_EmitsJSONOnStdout in cli_test.go. func TestRunSearchCmd_DefaultsToJSON(t *testing.T) { + allowStubApiDocsBundle(t) var out bytes.Buffer prevLogger := clientlog.GetLogger() t.Cleanup(func() { clientlog.SetLogger(prevLogger) }) @@ -220,6 +216,7 @@ func TestRunSearchCmd_DefaultsToJSON(t *testing.T) { } func TestRunSearchCmd_TableOutput(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) @@ -231,6 +228,7 @@ func TestRunSearchCmd_TableOutput(t *testing.T) { } func TestRunSearchCmd_EmptyResultTableStillReportsSpecBundle(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) @@ -242,13 +240,13 @@ func TestRunSearchCmd_EmptyResultTableStillReportsSpecBundle(t *testing.T) { } func TestRunSearchCmd_LimitTruncates(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) require.NoError(t, runErr) - // header + exactly one data row lineCount := 0 for _, b := range stdOut.Bytes() { if b == '\n' { @@ -258,33 +256,8 @@ func TestRunSearchCmd_LimitTruncates(t *testing.T) { assert.Equal(t, 2, lineCount, "expected a header row plus exactly one match row") } -// 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 -- kept on a *separate* buffer from the JSON body's -// Output channel (clientlog.NewLoggerWithFlags points both at the same -// writer by default, which would otherwise interleave a truncation warning -// into the JSON bytes and break json.Unmarshal). -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() -} - func TestRunSearchCmd_TruncationFieldsInJSON(t *testing.T) { + allowStubApiDocsBundle(t) result, logged := runSearchJSON(t, "--limit", "1", "") assert.Equal(t, float64(10), result["total_matches"], "stub has exactly 10 operations") assert.Equal(t, true, result["truncated"]) @@ -293,6 +266,7 @@ func TestRunSearchCmd_TruncationFieldsInJSON(t *testing.T) { } func TestRunSearchCmd_NoTruncationFieldsFalse(t *testing.T) { + allowStubApiDocsBundle(t) result, logged := runSearchJSON(t, "") assert.Equal(t, float64(10), result["total_matches"]) assert.Equal(t, false, result["truncated"]) @@ -305,13 +279,13 @@ func TestRunSearchCmd_NoTruncationFieldsFalse(t *testing.T) { // writer carrying the table -- otherwise it would corrupt/clutter the table // (or, for JSON, break parseability). func TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable(t *testing.T) { + allowStubApiDocsBundle(t) var stdOut bytes.Buffer var runErr error app := newSearchApp(&stdOut, &runErr) require.NoError(t, app.Run([]string{"cmd", "--format", "table", "--limit", "1", ""})) require.NoError(t, runErr) - // header + exactly one data row -- unchanged by the new warning path. lineCount := 0 for _, b := range stdOut.Bytes() { if b == '\n' { @@ -322,6 +296,18 @@ func TestRunSearchCmd_TruncationWarningDoesNotLeakIntoTable(t *testing.T) { assert.NotContains(t, stdOut.String(), "increase --limit", "the warning text must not appear in the table's stdOut writer") } +func TestRunSearchCmd_RequireFullBundleFailsOnStub(t *testing.T) { + var stdOut bytes.Buffer + var runErr error + app := newSearchApp(&stdOut, &runErr) + + require.NoError(t, app.Run([]string{"cmd", "user"})) + 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 TestRunSearchCmd_WrongNumberOfArguments(t *testing.T) { var stdOut bytes.Buffer var runErr error