From 7191d086c443758104e1571cee7af7e132b43d04 Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Sat, 10 Oct 2026 19:26:44 +0530 Subject: [PATCH 1/2] feat: add early access API search command with filtering and sorting options --- docs/auth0_apis.md | 1 + docs/auth0_apis_create.md | 1 + docs/auth0_apis_delete.md | 1 + docs/auth0_apis_list.md | 1 + docs/auth0_apis_open.md | 1 + docs/auth0_apis_show.md | 1 + docs/auth0_apis_update.md | 1 + internal/auth0/auth0.go | 2 + internal/cli/apis.go | 222 ++++++++++++++++++++ internal/cli/apis_test.go | 402 +++++++++++++++++++++++++++++++++++++ internal/cli/query_json.go | 45 +++++ internal/cli/schema.go | 13 ++ internal/display/apis.go | 106 +++++++++- 13 files changed, 787 insertions(+), 10 deletions(-) diff --git a/docs/auth0_apis.md b/docs/auth0_apis.md index 3a2a45bba..911e27e8a 100644 --- a/docs/auth0_apis.md +++ b/docs/auth0_apis.md @@ -14,6 +14,7 @@ Manage resources for APIs. An API is an entity that represents an external resou - [auth0 apis list](auth0_apis_list.md) - List your APIs - [auth0 apis open](auth0_apis_open.md) - Open the settings page of an API - [auth0 apis scopes](auth0_apis_scopes.md) - Manage resources for API scopes +- [auth0 apis search](auth0_apis_search.md) - Search your APIs (Early Access) - [auth0 apis show](auth0_apis_show.md) - Show an API - [auth0 apis update](auth0_apis_update.md) - Update an API diff --git a/docs/auth0_apis_create.md b/docs/auth0_apis_create.md index 97ca570ef..ddb3ec7a2 100644 --- a/docs/auth0_apis_create.md +++ b/docs/auth0_apis_create.md @@ -82,6 +82,7 @@ auth0 apis create [flags] - [auth0 apis list](auth0_apis_list.md) - List your APIs - [auth0 apis open](auth0_apis_open.md) - Open the settings page of an API - [auth0 apis scopes](auth0_apis_scopes.md) - Manage resources for API scopes +- [auth0 apis search](auth0_apis_search.md) - Search your APIs (Early Access) - [auth0 apis show](auth0_apis_show.md) - Show an API - [auth0 apis update](auth0_apis_update.md) - Update an API diff --git a/docs/auth0_apis_delete.md b/docs/auth0_apis_delete.md index 1f70b0bea..24451d760 100644 --- a/docs/auth0_apis_delete.md +++ b/docs/auth0_apis_delete.md @@ -53,6 +53,7 @@ auth0 apis delete [flags] - [auth0 apis list](auth0_apis_list.md) - List your APIs - [auth0 apis open](auth0_apis_open.md) - Open the settings page of an API - [auth0 apis scopes](auth0_apis_scopes.md) - Manage resources for API scopes +- [auth0 apis search](auth0_apis_search.md) - Search your APIs (Early Access) - [auth0 apis show](auth0_apis_show.md) - Show an API - [auth0 apis update](auth0_apis_update.md) - Update an API diff --git a/docs/auth0_apis_list.md b/docs/auth0_apis_list.md index 31fcbdbf5..09877d074 100644 --- a/docs/auth0_apis_list.md +++ b/docs/auth0_apis_list.md @@ -61,6 +61,7 @@ auth0 apis list [flags] - [auth0 apis list](auth0_apis_list.md) - List your APIs - [auth0 apis open](auth0_apis_open.md) - Open the settings page of an API - [auth0 apis scopes](auth0_apis_scopes.md) - Manage resources for API scopes +- [auth0 apis search](auth0_apis_search.md) - Search your APIs (Early Access) - [auth0 apis show](auth0_apis_show.md) - Show an API - [auth0 apis update](auth0_apis_update.md) - Update an API diff --git a/docs/auth0_apis_open.md b/docs/auth0_apis_open.md index e9471c64a..fe7c3175e 100644 --- a/docs/auth0_apis_open.md +++ b/docs/auth0_apis_open.md @@ -40,6 +40,7 @@ auth0 apis open [flags] - [auth0 apis list](auth0_apis_list.md) - List your APIs - [auth0 apis open](auth0_apis_open.md) - Open the settings page of an API - [auth0 apis scopes](auth0_apis_scopes.md) - Manage resources for API scopes +- [auth0 apis search](auth0_apis_search.md) - Search your APIs (Early Access) - [auth0 apis show](auth0_apis_show.md) - Show an API - [auth0 apis update](auth0_apis_update.md) - Update an API diff --git a/docs/auth0_apis_show.md b/docs/auth0_apis_show.md index 612d9b7cb..501fd7daf 100644 --- a/docs/auth0_apis_show.md +++ b/docs/auth0_apis_show.md @@ -48,6 +48,7 @@ auth0 apis show [flags] - [auth0 apis list](auth0_apis_list.md) - List your APIs - [auth0 apis open](auth0_apis_open.md) - Open the settings page of an API - [auth0 apis scopes](auth0_apis_scopes.md) - Manage resources for API scopes +- [auth0 apis search](auth0_apis_search.md) - Search your APIs (Early Access) - [auth0 apis show](auth0_apis_show.md) - Show an API - [auth0 apis update](auth0_apis_update.md) - Update an API diff --git a/docs/auth0_apis_update.md b/docs/auth0_apis_update.md index f46c93a41..1e63f2486 100644 --- a/docs/auth0_apis_update.md +++ b/docs/auth0_apis_update.md @@ -80,6 +80,7 @@ auth0 apis update [flags] - [auth0 apis list](auth0_apis_list.md) - List your APIs - [auth0 apis open](auth0_apis_open.md) - Open the settings page of an API - [auth0 apis scopes](auth0_apis_scopes.md) - Manage resources for API scopes +- [auth0 apis search](auth0_apis_search.md) - Search your APIs (Early Access) - [auth0 apis show](auth0_apis_show.md) - Show an API - [auth0 apis update](auth0_apis_update.md) - Update an API diff --git a/internal/auth0/auth0.go b/internal/auth0/auth0.go index 13c096cf6..b12c92a5c 100644 --- a/internal/auth0/auth0.go +++ b/internal/auth0/auth0.go @@ -99,6 +99,7 @@ type APIV3 struct { NetworkACLKey NetworkACLKeyAPIV3 Connection ConnectionAPIV3 ConnectionEnabledClient ConnectionEnabledClientAPIV3 + ResourceServerV3 ResourceServerAPIV3 } func NewAPIV3(m *managementv3.Management) *APIV3 { @@ -128,6 +129,7 @@ func NewAPIV3(m *managementv3.Management) *APIV3 { NetworkACLKey: m.Keys.NetworkACLs, Connection: m.Connections, ConnectionEnabledClient: m.Connections.Clients, + ResourceServerV3: m.ResourceServers, } } diff --git a/internal/cli/apis.go b/internal/cli/apis.go index 8822b3bd8..e02590579 100644 --- a/internal/cli/apis.go +++ b/internal/cli/apis.go @@ -8,12 +8,17 @@ import ( "net/http" "net/url" "strconv" + "strings" + "unicode/utf8" "github.com/auth0/go-auth0" "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" "github.com/spf13/cobra" "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/display" "github.com/auth0/auth0-cli/internal/prompt" ) @@ -85,6 +90,42 @@ var ( LongForm: "token-dialect", Help: "Dialect of access tokens for this API. Can be one of access_token, access_token_authz, rfc9068_profile, or rfc9068_profile_authz.", } + apiSearchFilter = Flag{ + Name: "Filter", + LongForm: "filter", + Help: "Filter expression, sent as the API's 'q' parameter. Lucene syntax by default, or SCIM with --parser scim. " + + "Supported fields: id, identifier, name, updated_at. Maximum 5 filter operations.\n\n" + + "For example: 'name:\"My API\"' or, with --parser scim, 'name co \"billing\" and updated_at gt \"2026-01-01\"'. " + + "Equivalent to --query '{\"q\":\"\"}'.", + } + apiSearchParser = Flag{ + Name: "Parser", + LongForm: "parser", + Help: "Syntax of --filter: 'lucene' or 'scim'.", + } + apiSearchSort = Flag{ + Name: "Sort", + LongForm: "sort", + ShortForm: "s", + Help: "Field to sort by, ascending only: 'name', 'identifier' or 'updated_at'. " + + "The 'field:1' form is also accepted. Defaults to insertion order.", + } + apiSearchFields = Flag{ + Name: "Fields", + LongForm: "fields", + Help: "Comma-separated list of fields to include in the response, e.g. 'id,name,identifier'. " + + "'updated_at' can be filtered and sorted on but is never returned.", + } + apiSearchExcludeFields = Flag{ + Name: "Exclude Fields", + LongForm: "exclude-fields", + Help: "Exclude the --fields list from the response instead of returning only those fields.", + } +) + +const ( + apiSearchPageSize = 100 // Max `take` per request. + apiSearchMaxLength = 1000 // Max length of the `q` and `fields` params. ) var ( @@ -110,6 +151,7 @@ func apisCmd(cli *cli) *cobra.Command { cmd.SetUsageTemplate(resourceUsageTemplate()) cmd.AddCommand(listApisCmd(cli)) + cmd.AddCommand(searchApisCmd(cli)) cmd.AddCommand(createAPICmd(cli)) cmd.AddCommand(showAPICmd(cli)) cmd.AddCommand(updateAPICmd(cli)) @@ -217,6 +259,186 @@ Use '--query' to filter results via a JSON object (any API-supported parameter w return cmd } +// apiSearchInputs holds the typed-mode flags of `apis search`. +type apiSearchInputs struct { + Filter string + Parser string + Sort string + Fields string + ExcludeFields bool + Number int +} + +func searchApisCmd(cli *cli) *cobra.Command { + var inputs struct { + apiSearchInputs + Query string + Schema bool + } + + cmd := &cobra.Command{ + Use: "search", + Args: cobra.NoArgs, + Short: "Search your APIs (Early Access)", + Long: `[Early Access] Search your APIs using Lucene or SCIM filter syntax. +Results are eventually consistent; use ` + "`auth0 apis list`" + ` for up-to-date data. + +Use '--schema' to see available query parameters. +Use '--query' to filter results via a JSON object (any API-supported parameter works immediately).`, + Example: ` auth0 apis search + auth0 apis search --filter 'name:"My API"' + auth0 apis search --parser scim --filter 'identifier sw "https://internal"' --sort identifier + auth0 apis search --parser scim --filter 'name co "billing"' -s name:1 + auth0 apis search --parser scim --filter 'updated_at gt "2026-01-01"' -n 200 --csv + auth0 apis search --filter 'name:"My API"' --fields id,name,identifier --json + auth0 apis search --schema + auth0 apis search --query '{"q":"name co \"billing\"","parser":"scim","sort":"name"}'`, + RunE: func(cmd *cobra.Command, args []string) error { + if handled, err := runSearchSchemaOrQuery(cli, cmd, jsonQuerySpec{ + Path: "resource-servers/search", + SchemaCmd: "auth0 apis search", + }, "APIs", inputs.Schema, inputs.Query); handled { + return err + } + + if inputs.Number < 1 || inputs.Number > 1000 { + return validationError{err: fmt.Errorf("number flag invalid, please pass a number between 1 and 1000")} + } + + request, err := buildResourceServerSearchRequest(inputs.apiSearchInputs) + if err != nil { + return err + } + + fields := parseSearchFields(inputs.Fields) + if cli.csv && len(display.APISearchColumns(fields, inputs.ExcludeFields)) == 0 { + return validationError{err: fmt.Errorf("--csv needs at least one table column (id, name, identifier, scopes) left by --fields")} + } + + results, err := collectV3Pages(cmd.Context(), inputs.Number, + func(ctx context.Context) (*core.Page[*string, *managementv3.ResourceServerSearchResponse, *managementv3.SearchResourceServersResponseContent], error) { + return cli.apiv3.ResourceServerV3.Search(ctx, request) + }) + if err != nil { + return searchError("APIs", err) + } + + if len(results) == inputs.Number { + hint := "Refine --filter or raise --number (max 1000)." + if inputs.Number == 1000 { + hint = "Refine --filter." + } + + cli.renderer.Warnf("Results may be limited by --number; more may match. %s", hint) + } + + cli.renderer.APISearchList(results, fields, inputs.ExcludeFields) + + return nil + }, + } + + cmd.Flags().BoolVar(&cli.jsonCompact, "json-compact", false, "Output in compact json format.") + cmd.Flags().BoolVar(&cli.csv, "csv", false, "Output in csv format.") + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + + apiSearchFilter.RegisterString(cmd, &inputs.Filter, "") + apiSearchParser.RegisterString(cmd, &inputs.Parser, "lucene") + apiSearchSort.RegisterString(cmd, &inputs.Sort, "") + apiSearchFields.RegisterString(cmd, &inputs.Fields, "") + apiSearchExcludeFields.RegisterBool(cmd, &inputs.ExcludeFields, false) + apiNumber.RegisterInt(cmd, &inputs.Number, defaultPageSize) + schemaFlag.RegisterBool(cmd, &inputs.Schema, false) + searchQueryFlag.RegisterString(cmd, &inputs.Query, "") + markQueryExclusive(cmd) + + return cmd +} + +// buildResourceServerSearchRequest validates the flag values and maps them onto the SDK request. +func buildResourceServerSearchRequest(inputs apiSearchInputs) (*managementv3.SearchResourceServersRequestParameters, error) { + fields := strings.Join(parseSearchFields(inputs.Fields), ",") + + // The API caps both at 1000 characters; reject early with a clearer error than its 400. + if utf8.RuneCountInString(inputs.Filter) > apiSearchMaxLength { + return nil, validationError{err: fmt.Errorf("--filter must be at most %d characters", apiSearchMaxLength)} + } + if utf8.RuneCountInString(fields) > apiSearchMaxLength { + return nil, validationError{err: fmt.Errorf("--fields must be at most %d characters", apiSearchMaxLength)} + } + if inputs.ExcludeFields && fields == "" { + return nil, validationError{err: fmt.Errorf("--exclude-fields requires --fields")} + } + + request := &managementv3.SearchResourceServersRequestParameters{ + Take: auth0.Int(min(inputs.Number, apiSearchPageSize)), + } + + switch inputs.Parser { + case "", "lucene": + request.Parser = managementv3.SearchParserEnumLucene.Ptr() + case "scim": + request.Parser = managementv3.SearchParserEnumSCIM.Ptr() + default: + return nil, validationError{err: fmt.Errorf("invalid --parser %q: must be 'lucene' or 'scim'", inputs.Parser)} + } + + if inputs.Filter != "" { + request.Q = auth0.String(inputs.Filter) + } + + if fields != "" { + request.Fields = auth0.String(fields) + } + + if inputs.ExcludeFields { + request.IncludeFields = auth0.Bool(false) + } + + if inputs.Sort != "" { + field, err := resourceServerSortField(inputs.Sort) + if err != nil { + return nil, err + } + request.Sort = field.Ptr() + } + + return request, nil +} + +// parseSearchFields splits a --fields value into trimmed, non-empty field names. +func parseSearchFields(fields string) []string { + var names []string + for name := range strings.SplitSeq(fields, ",") { + if name = strings.TrimSpace(name); name != "" { + names = append(names, name) + } + } + + return names +} + +// resourceServerSortField maps 'field' or 'field:1' onto the SDK enum. The +// endpoint only sorts ascending, so 'field:-1' is rejected. +func resourceServerSortField(s string) (managementv3.ResourceServerSortFieldEnum, error) { + field, order, hasOrder := strings.Cut(s, ":") + if hasOrder && order != "1" { + return "", validationError{err: fmt.Errorf("invalid --sort %q: the search endpoint only sorts ascending, use 'field' or 'field:1'", s)} + } + + switch field { + case "name": + return managementv3.ResourceServerSortFieldEnumName, nil + case "identifier": + return managementv3.ResourceServerSortFieldEnumIdentifier, nil + case "updated_at": + return managementv3.ResourceServerSortFieldEnumUpdatedAt, nil + default: + return "", validationError{err: fmt.Errorf("invalid --sort %q: must be 'name', 'identifier' or 'updated_at'", s)} + } +} + func showAPICmd(cli *cli) *cobra.Command { var inputs struct { ID string diff --git a/internal/cli/apis_test.go b/internal/cli/apis_test.go index 5826bb6fe..27628048c 100644 --- a/internal/cli/apis_test.go +++ b/internal/cli/apis_test.go @@ -3,9 +3,16 @@ package cli import ( "context" "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/go-auth0/v3/management/option" "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" @@ -135,3 +142,398 @@ func TestAPIsPickerOptions(t *testing.T) { }) } } + +// newResourceServerSearchPages chains pages of search results, ending with core.ErrNoPages. +func newResourceServerSearchPages(pages ...[]string) *auth0.ResourceServerSearchPage { + var build func(i int) *auth0.ResourceServerSearchPage + build = func(i int) *auth0.ResourceServerSearchPage { + var results []*managementv3.ResourceServerSearchResponse + for _, id := range pages[i] { + results = append(results, &managementv3.ResourceServerSearchResponse{ID: auth0.String(id)}) + } + + return &auth0.ResourceServerSearchPage{ + Results: results, + NextPageFunc: func(context.Context) (*auth0.ResourceServerSearchPage, error) { + if i+1 >= len(pages) { + return nil, core.ErrNoPages + } + return build(i + 1), nil + }, + } + } + + return build(0) +} + +func TestResourceServerSortField(t *testing.T) { + tests := []struct { + input string + want managementv3.ResourceServerSortFieldEnum + wantErr string + }{ + {input: "name", want: managementv3.ResourceServerSortFieldEnumName}, + {input: "identifier", want: managementv3.ResourceServerSortFieldEnumIdentifier}, + {input: "updated_at", want: managementv3.ResourceServerSortFieldEnumUpdatedAt}, + {input: "name:1", want: managementv3.ResourceServerSortFieldEnumName}, + {input: "name:-1", wantErr: `invalid --sort "name:-1": the search endpoint only sorts ascending, use 'field' or 'field:1'`}, + {input: "created_at", wantErr: `invalid --sort "created_at": must be 'name', 'identifier' or 'updated_at'`}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + got, err := resourceServerSortField(test.input) + if test.wantErr != "" { + assert.EqualError(t, err, test.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +func TestBuildResourceServerSearchRequest(t *testing.T) { + tests := []struct { + name string + inputs apiSearchInputs + want *managementv3.SearchResourceServersRequestParameters + wantErr string + }{ + { + name: "defaults to lucene and omits unset params", + inputs: apiSearchInputs{Number: 50}, + want: &managementv3.SearchResourceServersRequestParameters{ + Parser: managementv3.SearchParserEnumLucene.Ptr(), + Take: auth0.Int(50), + }, + }, + { + name: "maps every flag onto the request", + inputs: apiSearchInputs{Filter: `name co "billing"`, Parser: "scim", Sort: "updated_at:1", Fields: "id,name", Number: 20}, + want: &managementv3.SearchResourceServersRequestParameters{ + Q: auth0.String(`name co "billing"`), + Parser: managementv3.SearchParserEnumSCIM.Ptr(), + Sort: managementv3.ResourceServerSortFieldEnumUpdatedAt.Ptr(), + Fields: auth0.String("id,name"), + Take: auth0.Int(20), + }, + }, + { + name: "caps the page size at 100", + inputs: apiSearchInputs{Number: 1000}, + want: &managementv3.SearchResourceServersRequestParameters{ + Parser: managementv3.SearchParserEnumLucene.Ptr(), + Take: auth0.Int(100), + }, + }, + { + name: "trims and normalizes the fields list", + inputs: apiSearchInputs{Fields: " id, name ,,identifier ", Number: 50}, + want: &managementv3.SearchResourceServersRequestParameters{ + Fields: auth0.String("id,name,identifier"), + Parser: managementv3.SearchParserEnumLucene.Ptr(), + Take: auth0.Int(50), + }, + }, + { + name: "rejects an unknown parser", + inputs: apiSearchInputs{Parser: "sql", Number: 50}, + wantErr: `invalid --parser "sql": must be 'lucene' or 'scim'`, + }, + { + name: "rejects an invalid sort", + inputs: apiSearchInputs{Sort: "name:-1", Number: 50}, + wantErr: `invalid --sort "name:-1": the search endpoint only sorts ascending, use 'field' or 'field:1'`, + }, + { + name: "sends include_fields=false with --exclude-fields", + inputs: apiSearchInputs{Fields: "scopes", ExcludeFields: true, Number: 50}, + want: &managementv3.SearchResourceServersRequestParameters{ + Fields: auth0.String("scopes"), + IncludeFields: auth0.Bool(false), + Parser: managementv3.SearchParserEnumLucene.Ptr(), + Take: auth0.Int(50), + }, + }, + { + name: "rejects --exclude-fields without --fields", + inputs: apiSearchInputs{ExcludeFields: true, Number: 50}, + wantErr: "--exclude-fields requires --fields", + }, + { + name: "rejects a --filter over 1000 characters", + inputs: apiSearchInputs{Filter: strings.Repeat("a", 1001), Number: 50}, + wantErr: "--filter must be at most 1000 characters", + }, + { + name: "counts --filter length in characters, not bytes", + inputs: apiSearchInputs{Filter: strings.Repeat("界", 400), Number: 50}, + want: &managementv3.SearchResourceServersRequestParameters{ + Q: auth0.String(strings.Repeat("界", 400)), + Parser: managementv3.SearchParserEnumLucene.Ptr(), + Take: auth0.Int(50), + }, + }, + { + name: "measures --fields after trimming", + inputs: apiSearchInputs{Fields: strings.Repeat(" ,", 600) + "id", Number: 50}, + want: &managementv3.SearchResourceServersRequestParameters{ + Fields: auth0.String("id"), + Parser: managementv3.SearchParserEnumLucene.Ptr(), + Take: auth0.Int(50), + }, + }, + { + name: "treats a blank --fields as unset", + inputs: apiSearchInputs{Fields: " , ,", Number: 50}, + want: &managementv3.SearchResourceServersRequestParameters{ + Parser: managementv3.SearchParserEnumLucene.Ptr(), + Take: auth0.Int(50), + }, + }, + { + name: "rejects --exclude-fields with a blank --fields", + inputs: apiSearchInputs{Fields: " , ", ExcludeFields: true, Number: 50}, + wantErr: "--exclude-fields requires --fields", + }, + { + name: "rejects --fields over 1000 characters", + inputs: apiSearchInputs{Fields: strings.Repeat("a", 1001), Number: 50}, + wantErr: "--fields must be at most 1000 characters", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := buildResourceServerSearchRequest(test.inputs) + if test.wantErr != "" { + assert.EqualError(t, err, test.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +func TestSearchApisCmd(t *testing.T) { + thousandIDs := make([]string, 1000) + for i := range thousandIDs { + thousandIDs[i] = fmt.Sprintf("rs_%d", i) + } + + tests := []struct { + name string + args []string + searchPage *auth0.ResourceServerSearchPage + searchErr error + serverStatus int + serverBody string + assertRequest func(t testing.TB, req *managementv3.SearchResourceServersRequestParameters) + assertHTTP func(t testing.TB, r *http.Request) + assertOutput func(t testing.TB, stdout, stderr string) + wantErr string + }{ + { + name: "sends the flags to the search endpoint", + args: []string{"--filter", `name:"My API"`, "-s", "name", "--fields", "id,name", "-n", "10"}, + searchPage: newResourceServerSearchPages([]string{"rs_1"}), + assertRequest: func(t testing.TB, req *managementv3.SearchResourceServersRequestParameters) { + assert.Equal(t, `name:"My API"`, *req.Q) + assert.Equal(t, managementv3.SearchParserEnumLucene, *req.Parser) + assert.Equal(t, managementv3.ResourceServerSortFieldEnumName, *req.Sort) + assert.Equal(t, "id,name", *req.Fields) + assert.Equal(t, 10, *req.Take) + }, + }, + { + name: "sends --exclude-fields as include_fields=false", + args: []string{"--fields", "scopes", "--exclude-fields"}, + searchPage: newResourceServerSearchPages([]string{"rs_1"}), + assertRequest: func(t testing.TB, req *managementv3.SearchResourceServersRequestParameters) { + assert.Equal(t, "scopes", *req.Fields) + assert.False(t, *req.IncludeFields) + }, + assertOutput: func(t testing.TB, stdout, stderr string) { + assert.Contains(t, stdout, "IDENTIFIER") + assert.NotContains(t, stdout, "SCOPES") + }, + }, + { + name: "collects results across pages up to --number", + args: []string{"--filter", "name:*", "-n", "3", "--json"}, + searchPage: newResourceServerSearchPages([]string{"rs_1", "rs_2"}, []string{"rs_3", "rs_4"}), + assertOutput: func(t testing.TB, stdout, stderr string) { + assert.Contains(t, stdout, "rs_3") + assert.NotContains(t, stdout, "rs_4") + assert.Contains(t, stderr, "Results may be limited by --number; more may match.") + }, + }, + { + name: "does not suggest raising --number when it is already at the maximum", + args: []string{"-n", "1000", "--json"}, + searchPage: newResourceServerSearchPages(thousandIDs), + assertOutput: func(t testing.TB, stdout, stderr string) { + assert.Contains(t, stderr, "more may match. Refine --filter.") + assert.NotContains(t, stderr, "raise --number") + }, + }, + { + name: "renders an empty result", + args: []string{"--filter", `name:"missing"`}, + searchPage: newResourceServerSearchPages([]string{}), + }, + { + name: "sends --query JSON params to the search endpoint", + args: []string{"-q", `{"q":"name co \"billing\"","parser":"scim"}`}, + serverBody: `{"resource_servers":[{"id":"rs_1"}]}`, + assertHTTP: func(t testing.TB, r *http.Request) { + assert.Equal(t, "/api/v2/resource-servers/search", r.URL.Path) + assert.Equal(t, `name co "billing"`, r.URL.Query().Get("q")) + assert.Equal(t, "scim", r.URL.Query().Get("parser")) + }, + assertOutput: func(t testing.TB, stdout, stderr string) { + assert.Contains(t, stdout, "rs_1") + }, + }, + { + name: "allows --number with --query, which ignores it", + args: []string{"-q", `{"q":"name:*"}`, "-n", "5"}, + serverBody: `{"resource_servers":[]}`, + assertHTTP: func(t testing.TB, r *http.Request) { + assert.Empty(t, r.URL.Query().Get("take")) + }, + }, + { + name: "rejects --query combined with --filter", + args: []string{"-q", `{"q":"name:*"}`, "--filter", "name:*"}, + wantErr: "if any flags in the group [query filter] are set none of the others can be; [filter query] were all set", + }, + { + name: "rejects --query combined with --parser", + args: []string{"-q", `{"q":"name:*"}`, "--parser", "scim"}, + wantErr: "if any flags in the group [query parser] are set none of the others can be; [parser query] were all set", + }, + { + name: "rejects --query combined with --sort", + args: []string{"-q", `{"q":"name:*"}`, "--sort", "name"}, + wantErr: "if any flags in the group [query sort] are set none of the others can be; [query sort] were all set", + }, + { + name: "rejects --query combined with --fields", + args: []string{"-q", `{"q":"name:*"}`, "--fields", "id"}, + wantErr: "if any flags in the group [query fields] are set none of the others can be; [fields query] were all set", + }, + { + name: "warns that --number is ignored with --query", + args: []string{"-q", `{"q":"name:*"}`, "-n", "500"}, + serverBody: `{"resource_servers":[]}`, + assertOutput: func(t testing.TB, stdout, stderr string) { + assert.Contains(t, stderr, `--number is ignored with --query; set "take" (max 100) in the JSON instead.`) + }, + }, + { + name: "rejects --csv when --fields leaves no table column", + args: []string{"--fields", "token_lifetime", "--csv"}, + wantErr: "--csv needs at least one table column (id, name, identifier, scopes) left by --fields", + }, + { + name: "rejects --query combined with --exclude-fields", + args: []string{"-q", `{"q":"name:*"}`, "--exclude-fields"}, + wantErr: "if any flags in the group [query exclude-fields] are set none of the others can be; [exclude-fields query] were all set", + }, + { + name: "rejects an out-of-range --number before calling the API", + args: []string{"--filter", "name:*", "-n", "1001"}, + wantErr: "number flag invalid, please pass a number between 1 and 1000", + }, + { + name: "wraps a generic API error", + args: []string{"--filter", "name:*"}, + searchErr: errors.New("boom"), + wantErr: "failed to search APIs: boom", + }, + { + name: "explains a search timeout", + args: []string{"--filter", "name:*"}, + searchErr: core.NewAPIError(http.StatusGatewayTimeout, nil, errors.New("timeout")), + wantErr: "failed to search APIs: the search timed out, simplify your query and try again: 504: timeout", + }, + { + name: "explains an unavailable Early Access endpoint", + args: []string{"--filter", "name:*"}, + searchErr: core.NewAPIError(http.StatusNotFound, nil, errors.New("not found")), + wantErr: "failed to search APIs: the search endpoint is in Early Access and may not be enabled for this tenant: 404: not found", + }, + { + name: "explains a search timeout in --query mode", + args: []string{"-q", `{"q":"name:*"}`}, + serverStatus: http.StatusGatewayTimeout, + wantErr: "failed to search APIs: the search timed out, simplify your query and try again: 504: API request failed: Gateway Timeout", + }, + { + name: "explains an unavailable Early Access endpoint in --query mode", + args: []string{"-q", `{"q":"name:*"}`}, + serverStatus: http.StatusNotFound, + wantErr: "failed to search APIs: the search endpoint is in Early Access and may not be enabled for this tenant: 404: API request failed: Not Found", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var captured *managementv3.SearchResourceServersRequestParameters + searchAPI := mock.NewMockResourceServerAPIV3(ctrl) + if test.searchPage != nil || test.searchErr != nil { + searchAPI.EXPECT(). + Search(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *managementv3.SearchResourceServersRequestParameters, _ ...option.RequestOption) (*auth0.ResourceServerSearchPage, error) { + captured = req + return test.searchPage, test.searchErr + }) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if test.assertHTTP != nil { + test.assertHTTP(t, r) + } + if test.serverStatus != 0 { + w.WriteHeader(test.serverStatus) + _, _ = w.Write([]byte(http.StatusText(test.serverStatus))) + return + } + _, _ = w.Write([]byte(test.serverBody)) + })) + defer server.Close() + + renderer := testRenderer() + cli := &cli{ + api: &auth0.API{HTTPClient: &mockHTTPClientAPI{baseURL: server.URL}}, + apiv3: &auth0.APIV3{ResourceServerV3: searchAPI}, + renderer: renderer, + } + + cmd := searchApisCmd(cli) + cmd.SetArgs(test.args) + err := cmd.Execute() + + if test.wantErr != "" { + assert.EqualError(t, err, test.wantErr) + return + } + + assert.NoError(t, err) + if test.assertRequest != nil { + test.assertRequest(t, captured) + } + if test.assertOutput != nil { + test.assertOutput(t, fmt.Sprint(renderer.ResultWriter), fmt.Sprint(renderer.MessageWriter)) + } + }) + } +} diff --git a/internal/cli/query_json.go b/internal/cli/query_json.go index e29331df6..eb5b2ac1b 100644 --- a/internal/cli/query_json.go +++ b/internal/cli/query_json.go @@ -22,6 +22,51 @@ var listQueryFlag = Flag{ Help: "Filter results with a JSON object of query parameters. Any API-supported parameter works immediately. Run '--schema' to see documented parameters. On offset-paginated endpoints, add \"include_totals\":true to receive total counts and a pagination hint (without it the API returns a bare array and no hint can be given).", } +// searchQueryFlag is --query for search commands, whose endpoints are cursor-paginated. +var searchQueryFlag = Flag{ + Name: "Query", + LongForm: "query", + ShortForm: "q", + Help: "Search with a JSON object of query parameters, sent as-is. Any API-supported parameter works immediately. Run '--schema' to see documented parameters. Returns one page: set \"take\" (max 100) for the page size, and \"from\" to the previous response's \"next\" to fetch the next page.", +} + +// runSearchSchemaOrQuery handles --schema, then --query, for a search command; resource names +// it in errors (e.g. "APIs"). It reports handled=false when neither is set. +func runSearchSchemaOrQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, resource string, schema bool, query string) (bool, error) { + if schema { + return true, printOperationSchema(cli, http.MethodGet, "/"+spec.Path) + } + + if query == "" { + return false, nil + } + + if f := cmd.Flags().Lookup("number"); f != nil && f.Changed { + cli.renderer.Warnf("--number is ignored with --query; set \"take\" (max 100) in the JSON instead.") + } + + err := runJSONQuery(cli, cmd, spec, query) + if _, isAPIError := managementHTTPStatus(err); isAPIError { + return true, searchError(resource, err) + } + + return true, err +} + +// searchError adds guidance for the failure modes specific to search endpoints. +func searchError(resource string, err error) error { + status, _ := managementHTTPStatus(err) + + switch status { + case http.StatusGatewayTimeout: + return fmt.Errorf("failed to search %s: the search timed out, simplify your query and try again: %w", resource, err) + case http.StatusNotFound: + return fmt.Errorf("failed to search %s: the search endpoint is in Early Access and may not be enabled for this tenant: %w", resource, err) + default: + return fmt.Errorf("failed to search %s: %w", resource, err) + } +} + // jsonQuerySpec describes a list operation driven by a --query JSON payload. type jsonQuerySpec struct { Path string // API path segments (e.g. "actions/actions"). diff --git a/internal/cli/schema.go b/internal/cli/schema.go index d39f9dc5e..a3ce70b22 100644 --- a/internal/cli/schema.go +++ b/internal/cli/schema.go @@ -91,6 +91,19 @@ func markDataExclusive(cmd *cobra.Command) { }) } +// markQueryExclusive rejects combining --query with any granular input flag, as both +// set the same query params. --number stays allowed (ignored). Call after flags are registered. +func markQueryExclusive(cmd *cobra.Command) { + if cmd.Flags().Lookup("query") == nil { + return + } + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Name != "query" && f.Name != "number" && isInputFlag(f.Name) { + cmd.MarkFlagsMutuallyExclusive("query", f.Name) + } + }) +} + // isInputFlag reports whether a flag supplies request input, as opposed to // delivery (--data, --schema) or output (--json, --csv, --force). func isInputFlag(name string) bool { diff --git a/internal/display/apis.go b/internal/display/apis.go index 85b22dea2..d19c66c2c 100644 --- a/internal/display/apis.go +++ b/internal/display/apis.go @@ -2,10 +2,12 @@ package display import ( "fmt" + "slices" "strconv" "strings" "github.com/auth0/go-auth0/management" + managementv3 "github.com/auth0/go-auth0/v3/management" "golang.org/x/term" "github.com/auth0/auth0-cli/internal/ansi" @@ -67,21 +69,58 @@ func (v *apiView) Object() interface{} { return v.raw } +type apiTableColumn struct{ field, header string } + +// apiTableColumns are the table columns, keyed by their API field name. +var apiTableColumns = []apiTableColumn{ + {"id", "ID"}, + {"name", "Name"}, + {"identifier", "Identifier"}, + {"scopes", "Scopes"}, +} + type apiTableView struct { ID string Name string Identifier string Scopes int - raw interface{} + // Fields limits the columns to these API fields; empty shows every column. + fields []string + raw interface{} } func (v *apiTableView) AsTableHeader() []string { - return []string{"ID", "Name", "Identifier", "Scopes"} + var header []string + for _, column := range apiTableColumns { + if v.shows(column.field) { + header = append(header, column.header) + } + } + + return header } func (v *apiTableView) AsTableRow() []string { - return []string{ansi.Faint(v.ID), v.Name, v.Identifier, fmt.Sprint(v.Scopes)} + values := map[string]string{ + "id": ansi.Faint(v.ID), + "name": v.Name, + "identifier": v.Identifier, + "scopes": fmt.Sprint(v.Scopes), + } + + var row []string + for _, column := range apiTableColumns { + if v.shows(column.field) { + row = append(row, values[column.field]) + } + } + + return row +} + +func (v *apiTableView) shows(field string) bool { + return len(v.fields) == 0 || slices.Contains(v.fields, field) } func (v *apiTableView) Object() interface{} { @@ -89,19 +128,66 @@ func (v *apiTableView) Object() interface{} { } func (r *Renderer) APIList(apis []*management.ResourceServer) { - resource := "apis" + views := make([]*apiTableView, 0, len(apis)) + for _, api := range apis { + views = append(views, makeAPITableView(api)) + } - r.Heading(fmt.Sprintf("%s (%d)", resource, len(apis))) + r.apiTableResults(views, "Use 'auth0 apis create' to add one") +} - if len(apis) == 0 { - r.EmptyState(resource, "Use 'auth0 apis create' to add one") - return +// APISearchColumns returns the table columns left in the response by the --fields list +// (or, with exclude, by --exclude-fields). Empty fields keeps every column. +func APISearchColumns(fields []string, exclude bool) []string { + var columns []string + for _, column := range apiTableColumns { + if len(fields) == 0 || slices.Contains(fields, column.field) != exclude { + columns = append(columns, column.field) + } } - results := []View{} + return columns +} +// APISearchList renders search results, limiting the table to the columns left by fields. +func (r *Renderer) APISearchList(apis []*managementv3.ResourceServerSearchResponse, fields []string, exclude bool) { + columns := APISearchColumns(fields, exclude) + + if len(columns) == 0 && len(apis) > 0 && r.Format == "" { + r.Infof("No table columns (id, name, identifier, scopes) are left in the response; showing JSON instead.") + r.JSONResult(apis) + return + } + + views := make([]*apiTableView, 0, len(apis)) for _, api := range apis { - results = append(results, makeAPITableView(api)) + views = append(views, &apiTableView{ + ID: api.GetID(), + Name: api.GetName(), + Identifier: api.GetIdentifier(), + Scopes: len(api.GetScopes()), + + fields: columns, + raw: api, + }) + } + + r.apiTableResults(views, "Try a broader --filter; results may lag very recent writes") +} + +func (r *Renderer) apiTableResults(views []*apiTableView, emptyHint string) { + resource := "apis" + + r.Heading(fmt.Sprintf("%s (%d)", resource, len(views))) + + if len(views) == 0 { + r.EmptyState(resource, emptyHint) + return + } + + results := make([]View, 0, len(views)) + for _, view := range views { + results = append(results, view) } r.Results(results) From feab3e2d11ab2ed540ffd97028ea4f9ba93c41e4 Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Thu, 15 Oct 2026 00:27:19 +0530 Subject: [PATCH 2/2] feat: add resource server search functionality with mock implementation --- docs/auth0_apis_search.md | 74 +++++++++++++++++++ .../auth0/mock/resource_server_v3_mock.go | 64 ++++++++++++++++ internal/auth0/resource_server_v3.go | 25 +++++++ 3 files changed, 163 insertions(+) create mode 100644 docs/auth0_apis_search.md create mode 100644 internal/auth0/mock/resource_server_v3_mock.go create mode 100644 internal/auth0/resource_server_v3.go diff --git a/docs/auth0_apis_search.md b/docs/auth0_apis_search.md new file mode 100644 index 000000000..ef6bee0e4 --- /dev/null +++ b/docs/auth0_apis_search.md @@ -0,0 +1,74 @@ +--- +layout: default +parent: auth0 apis +has_toc: false +--- +# auth0 apis search + +[Early Access] Search your APIs using Lucene or SCIM filter syntax. +Results are eventually consistent; use `auth0 apis list` for up-to-date data. + +Use '--schema' to see available query parameters. +Use '--query' to filter results via a JSON object (any API-supported parameter works immediately). + +## Usage +``` +auth0 apis search [flags] +``` + +## Examples + +``` + auth0 apis search + auth0 apis search --filter 'name:"My API"' + auth0 apis search --parser scim --filter 'identifier sw "https://internal"' --sort identifier + auth0 apis search --parser scim --filter 'name co "billing"' -s name:1 + auth0 apis search --parser scim --filter 'updated_at gt "2026-01-01"' -n 200 --csv + auth0 apis search --filter 'name:"My API"' --fields id,name,identifier --json + auth0 apis search --schema + auth0 apis search --query '{"q":"name co \"billing\"","parser":"scim","sort":"name"}' +``` + + +## Flags + +``` + --csv Output in csv format. + --exclude-fields Exclude the --fields list from the response instead of returning only those fields. + --fields string Comma-separated list of fields to include in the response, e.g. 'id,name,identifier'. 'updated_at' can be filtered and sorted on but is never returned. + --filter string Filter expression, sent as the API's 'q' parameter. Lucene syntax by default, or SCIM with --parser scim. Supported fields: id, identifier, name, updated_at. Maximum 5 filter operations. + + For example: 'name:"My API"' or, with --parser scim, 'name co "billing" and updated_at gt "2026-01-01"'. Equivalent to --query '{"q":""}'. + --json Output in json format. + --json-compact Output in compact json format. + -n, --number int Number of APIs to retrieve. Minimum 1, maximum 1000. (default 100) + --parser string Syntax of --filter: 'lucene' or 'scim'. (default "lucene") + -q, --query string Search with a JSON object of query parameters, sent as-is. Any API-supported parameter works immediately. Run '--schema' to see documented parameters. Returns one page: set "take" (max 100) for the page size, and "from" to the previous response's "next" to fetch the next page. + --schema Print the request payload schema for this command and exit. Use with --json or --json-compact for machine-readable output. + -s, --sort string Field to sort by, ascending only: 'name', 'identifier' or 'updated_at'. The 'field:1' form is also accepted. Defaults to insertion order. +``` + + +## Inherited Flags + +``` + --agent-mode Output JSON, disable prompts and colors. Auto-enabled for AI agents; set AUTH0_AGENT_MODE=false to disable. + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + +## Related Commands + +- [auth0 apis create](auth0_apis_create.md) - Create a new API +- [auth0 apis delete](auth0_apis_delete.md) - Delete an API +- [auth0 apis list](auth0_apis_list.md) - List your APIs +- [auth0 apis open](auth0_apis_open.md) - Open the settings page of an API +- [auth0 apis scopes](auth0_apis_scopes.md) - Manage resources for API scopes +- [auth0 apis search](auth0_apis_search.md) - Search your APIs (Early Access) +- [auth0 apis show](auth0_apis_show.md) - Show an API +- [auth0 apis update](auth0_apis_update.md) - Update an API + + diff --git a/internal/auth0/mock/resource_server_v3_mock.go b/internal/auth0/mock/resource_server_v3_mock.go new file mode 100644 index 000000000..d1ef20d49 --- /dev/null +++ b/internal/auth0/mock/resource_server_v3_mock.go @@ -0,0 +1,64 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: resource_server_v3.go +// +// Generated by this command: +// +// mockgen -source=resource_server_v3.go -destination=mock/resource_server_v3_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + management "github.com/auth0/go-auth0/v3/management" + core "github.com/auth0/go-auth0/v3/management/core" + option "github.com/auth0/go-auth0/v3/management/option" + gomock "go.uber.org/mock/gomock" +) + +// MockResourceServerAPIV3 is a mock of ResourceServerAPIV3 interface. +type MockResourceServerAPIV3 struct { + ctrl *gomock.Controller + recorder *MockResourceServerAPIV3MockRecorder + isgomock struct{} +} + +// MockResourceServerAPIV3MockRecorder is the mock recorder for MockResourceServerAPIV3. +type MockResourceServerAPIV3MockRecorder struct { + mock *MockResourceServerAPIV3 +} + +// NewMockResourceServerAPIV3 creates a new mock instance. +func NewMockResourceServerAPIV3(ctrl *gomock.Controller) *MockResourceServerAPIV3 { + mock := &MockResourceServerAPIV3{ctrl: ctrl} + mock.recorder = &MockResourceServerAPIV3MockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockResourceServerAPIV3) EXPECT() *MockResourceServerAPIV3MockRecorder { + return m.recorder +} + +// Search mocks base method. +func (m *MockResourceServerAPIV3) Search(ctx context.Context, request *management.SearchResourceServersRequestParameters, opts ...option.RequestOption) (*core.Page[*string, *management.ResourceServerSearchResponse, *management.SearchResourceServersResponseContent], error) { + m.ctrl.T.Helper() + varargs := []any{ctx, request} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Search", varargs...) + ret0, _ := ret[0].(*core.Page[*string, *management.ResourceServerSearchResponse, *management.SearchResourceServersResponseContent]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Search indicates an expected call of Search. +func (mr *MockResourceServerAPIV3MockRecorder) Search(ctx, request any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, request}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockResourceServerAPIV3)(nil).Search), varargs...) +} diff --git a/internal/auth0/resource_server_v3.go b/internal/auth0/resource_server_v3.go new file mode 100644 index 000000000..2e349c35e --- /dev/null +++ b/internal/auth0/resource_server_v3.go @@ -0,0 +1,25 @@ +//go:generate go tool mockgen -source=resource_server_v3.go -destination=mock/resource_server_v3_mock.go -package=mock + +package auth0 + +import ( + "context" + + managementv3 "github.com/auth0/go-auth0/v3/management" + "github.com/auth0/go-auth0/v3/management/core" + "github.com/auth0/go-auth0/v3/management/option" +) + +// ResourceServerSearchPage is one cursor-paginated page of resource-servers search results. +type ResourceServerSearchPage = core.Page[*string, *managementv3.ResourceServerSearchResponse, *managementv3.SearchResourceServersResponseContent] + +// ResourceServerAPIV3 is the V3 SDK interface for the eventually consistent +// resource-servers search endpoint (GET /resource-servers/search). +type ResourceServerAPIV3 interface { + // Search resource servers using SCIM or Lucene filter syntax. + Search( + ctx context.Context, + request *managementv3.SearchResourceServersRequestParameters, + opts ...option.RequestOption, + ) (*core.Page[*string, *managementv3.ResourceServerSearchResponse, *managementv3.SearchResourceServersResponseContent], error) +}