From dfaa300fea843007be4ed78861aba96864226b8d Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Mon, 31 Aug 2026 07:44:58 +0530 Subject: [PATCH] feat: add --schema and --query flags to actions list command for enhanced filtering and schema display --- docs/auth0_actions_list.md | 9 ++ internal/cli/actions.go | 36 +++++- internal/cli/actions_test.go | 77 ++++++++++++ internal/cli/query_json.go | 69 +++++++++++ internal/cli/query_json_test.go | 157 ++++++++++++++++++++++++ internal/openapi/schema.go | 36 ++++-- internal/openapi/schema_manager.go | 68 +++++----- internal/openapi/schema_manager_test.go | 53 +++++++- internal/openapi/schema_test.go | 37 ++++++ 9 files changed, 503 insertions(+), 39 deletions(-) create mode 100644 internal/cli/query_json.go create mode 100644 internal/cli/query_json_test.go diff --git a/docs/auth0_actions_list.md b/docs/auth0_actions_list.md index 80ed2cb25..06f8d1c71 100644 --- a/docs/auth0_actions_list.md +++ b/docs/auth0_actions_list.md @@ -7,6 +7,9 @@ has_toc: false List your existing actions. To create one, run: `auth0 actions create`. +Use '--schema' to see available query parameters. +Use '--query' to filter results via a JSON object (any API-supported parameter works immediately). + ## Usage ``` auth0 actions list [flags] @@ -20,6 +23,10 @@ auth0 actions list [flags] auth0 actions ls --json auth0 actions ls --json-compact auth0 actions ls --csv + auth0 actions list --schema + auth0 actions list --schema --json + auth0 actions list --query '{"triggerId":"post-login"}' + auth0 actions list --query '{"deployed":"true"}' --json ``` @@ -29,6 +36,8 @@ auth0 actions list [flags] --csv Output in csv format. --json Output in json format. --json-compact Output in compact json format. + -q, --query string Filter actions with a JSON object of query parameters (e.g. '{"triggerId":"post-login"}'). Any API-supported parameter works immediately. Run '--schema' to see documented parameters. + --schema Print the request payload schema for this command and exit. Use with --json or --json-compact for machine-readable output. ``` diff --git a/internal/cli/actions.go b/internal/cli/actions.go index 92e9700e1..1f82ce15f 100644 --- a/internal/cli/actions.go +++ b/internal/cli/actions.go @@ -76,6 +76,13 @@ var ( Help: "Action module to associate with the action, as comma-separated key=value pairs matching the API fields: module_id and module_version_id (both required, UUIDs). Can be passed multiple times to associate several modules.", } + actionListQuery = Flag{ + Name: "Query", + LongForm: "query", + ShortForm: "q", + Help: "Filter actions with a JSON object of query parameters (e.g. '{\"triggerId\":\"post-login\"}'). Any API-supported parameter works immediately. Run '--schema' to see documented parameters.", + } + actionTemplates = map[string]string{ "post-login": actionTemplatePostLogin, "credentials-exchange": actionTemplateCredentialsExchange, @@ -125,18 +132,41 @@ For more details: https://auth0.com/docs/api/management/v2`, } func listActionsCmd(cli *cli) *cobra.Command { + var inputs struct { + Schema bool + Query string + } + cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Args: cobra.NoArgs, Short: "List your actions", - Long: "List your existing actions. To create one, run: `auth0 actions create`.", + Long: `List your existing actions. To create one, run: ` + "`auth0 actions create`" + `. + +Use '--schema' to see available query parameters. +Use '--query' to filter results via a JSON object (any API-supported parameter works immediately).`, Example: ` auth0 actions list auth0 actions ls auth0 actions ls --json auth0 actions ls --json-compact - auth0 actions ls --csv`, + auth0 actions ls --csv + auth0 actions list --schema + auth0 actions list --schema --json + auth0 actions list --query '{"triggerId":"post-login"}' + auth0 actions list --query '{"deployed":"true"}' --json`, RunE: func(cmd *cobra.Command, args []string) error { + if inputs.Schema { + return printOperationSchema(cli, "GET", "/actions/actions") + } + + if inputs.Query != "" { + return runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, inputs.Query) + } + var list *management.ActionList if err := ansi.Waiting(func() (err error) { @@ -156,6 +186,8 @@ func listActionsCmd(cli *cli) *cobra.Command { 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.MarkFlagsMutuallyExclusive("json", "json-compact", "csv") + schemaFlag.RegisterBool(cmd, &inputs.Schema, false) + actionListQuery.RegisterString(cmd, &inputs.Query, "") return cmd } diff --git a/internal/cli/actions_test.go b/internal/cli/actions_test.go index d32270201..9a8074ade 100644 --- a/internal/cli/actions_test.go +++ b/internal/cli/actions_test.go @@ -17,6 +17,83 @@ import ( "github.com/auth0/auth0-cli/internal/display" ) +func TestActionsListCmd(t *testing.T) { + t.Run("it lists actions using the default SDK path", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + actionAPI := mock.NewMockActionAPI(ctrl) + actionAPI.EXPECT(). + List(context.Background(), gomock.Any()). + Return(&management.ActionList{ + Actions: []*management.Action{ + { + ID: auth0.String("action-1"), + Name: auth0.String("my-action"), + SupportedTriggers: []management.ActionTrigger{ + {ID: auth0.String("post-login")}, + }, + }, + }, + }, nil) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + api: &auth0.API{Action: actionAPI}, + } + + cmd := listActionsCmd(cli) + err := cmd.Execute() + + assert.NoError(t, err) + }) + + t.Run("it returns error when API fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + actionAPI := mock.NewMockActionAPI(ctrl) + actionAPI.EXPECT(). + List(context.Background(), gomock.Any()). + Return(nil, errors.New("connection failed")) + + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + api: &auth0.API{Action: actionAPI}, + } + + cmd := listActionsCmd(cli) + err := cmd.Execute() + + assert.EqualError(t, err, "failed to list actions: connection failed") + }) + + t.Run("it returns error for invalid --query JSON", func(t *testing.T) { + stdout := &bytes.Buffer{} + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: stdout, + }, + api: &auth0.API{}, + } + + cmd := listActionsCmd(cli) + cmd.SetArgs([]string{"--query", "not-valid-json"}) + err := cmd.Execute() + + assert.ErrorContains(t, err, "invalid --query value") + }) +} + func TestActionsDeployCmd(t *testing.T) { t.Run("it successfully deploys an action", func(t *testing.T) { actionID := "1221c74c-cfd6-40db-af13-7bc9bb1c38db" diff --git a/internal/cli/query_json.go b/internal/cli/query_json.go new file mode 100644 index 000000000..a4c3de943 --- /dev/null +++ b/internal/cli/query_json.go @@ -0,0 +1,69 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" +) + +// jsonQuerySpec describes a list operation driven by a --query JSON payload. +type jsonQuerySpec struct { + Path string // API path segments (e.g. "actions/actions"). + SchemaCmd string +} + +// runJSONQuery executes a GET request against the Management API with query parameters parsed from queryJSON. +func runJSONQuery(cli *cli, cmd *cobra.Command, spec jsonQuerySpec, queryJSON string) error { + var queryParams map[string]interface{} + if err := json.Unmarshal([]byte(queryJSON), &queryParams); err != nil { + cli.renderer.Infof("Run '%s --schema' to see the expected query parameters.", spec.SchemaCmd) + return fmt.Errorf("invalid --query value: must be a JSON object: %w", err) + } + + u, err := url.Parse(cli.api.HTTPClient.URI(strings.Split(spec.Path, "/")...)) + if err != nil { + return fmt.Errorf("failed to parse URI: %w", err) + } + q := u.Query() + for key, val := range queryParams { + q.Set(key, fmt.Sprintf("%v", val)) + } + u.RawQuery = q.Encode() + + var response *http.Response + if err := ansi.Waiting(func() error { + request, err := cli.api.HTTPClient.NewRequest(cmd.Context(), http.MethodGet, u.String(), nil) + if err != nil { + return err + } + response, err = cli.api.HTTPClient.Do(request) + return err + }); err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + defer func() { _ = response.Body.Close() }() + + rawJSON, err := io.ReadAll(response.Body) + if err != nil { + return err + } + + if response.StatusCode >= http.StatusBadRequest { + return newAPIResponseError(response.StatusCode, response.Header, rawJSON) + } + + var prettyJSON bytes.Buffer + if err := json.Indent(&prettyJSON, rawJSON, "", " "); err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + cli.renderer.Output(ansi.ColorizeJSON(prettyJSON.String())) + return nil +} diff --git a/internal/cli/query_json_test.go b/internal/cli/query_json_test.go new file mode 100644 index 000000000..8f9b610d1 --- /dev/null +++ b/internal/cli/query_json_test.go @@ -0,0 +1,157 @@ +package cli + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/auth0/go-auth0/management" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/auth0" + "github.com/auth0/auth0-cli/internal/display" +) + +// mockHTTPClientAPI is a minimal implementation of auth0.HTTPClientAPI for testing. +type mockHTTPClientAPI struct { + baseURL string +} + +func (m *mockHTTPClientAPI) NewRequest(ctx context.Context, method, uri string, payload interface{}, opts ...management.RequestOption) (*http.Request, error) { + return http.NewRequestWithContext(ctx, method, uri, nil) +} + +func (m *mockHTTPClientAPI) Do(req *http.Request) (*http.Response, error) { + return http.DefaultClient.Do(req) +} + +func (m *mockHTTPClientAPI) Request(ctx context.Context, method, uri string, payload interface{}, opts ...management.RequestOption) error { + return nil +} + +func (m *mockHTTPClientAPI) URI(path ...string) string { + return m.baseURL + "/api/v2/" + strings.Join(path, "/") +} + +func TestRunJSONQuery_InvalidJSON(t *testing.T) { + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + api: &auth0.API{}, + } + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, "not-valid-json") + + assert.ErrorContains(t, err, "invalid --query value: must be a JSON object") +} + +func TestRunJSONQuery_Success(t *testing.T) { + expected := map[string]interface{}{ + "actions": []interface{}{}, + "total": float64(0), + } + responseBody, err := json.Marshal(expected) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "post-login", r.URL.Query().Get("triggerId")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(responseBody) + })) + defer server.Close() + + var resultBuf strings.Builder + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: &resultBuf, + }, + api: &auth0.API{ + HTTPClient: &mockHTTPClientAPI{baseURL: server.URL}, + }, + } + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err = runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, `{"triggerId":"post-login"}`) + + assert.NoError(t, err) + assert.Contains(t, resultBuf.String(), "actions") +} + +func TestRunJSONQuery_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"statusCode":401,"error":"Unauthorized","message":"Invalid token"}`)) + })) + defer server.Close() + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + api: &auth0.API{ + HTTPClient: &mockHTTPClientAPI{baseURL: server.URL}, + }, + } + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, `{"triggerId":"post-login"}`) + + assert.Error(t, err) +} + +func TestRunJSONQuery_BuildsURLWithQueryParams(t *testing.T) { + var capturedURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"actions":[]}`)) + })) + defer server.Close() + + cli := &cli{ + renderer: &display.Renderer{ + MessageWriter: io.Discard, + ResultWriter: io.Discard, + }, + api: &auth0.API{ + HTTPClient: &mockHTTPClientAPI{baseURL: server.URL}, + }, + } + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runJSONQuery(cli, cmd, jsonQuerySpec{ + Path: "actions/actions", + SchemaCmd: "auth0 actions list", + }, `{"deployed":"true","per_page":"5"}`) + + assert.NoError(t, err) + assert.Contains(t, capturedURL, "deployed=true") + assert.Contains(t, capturedURL, "per_page=5") +} diff --git a/internal/openapi/schema.go b/internal/openapi/schema.go index eecb4f18e..786237982 100644 --- a/internal/openapi/schema.go +++ b/internal/openapi/schema.go @@ -26,8 +26,7 @@ const ( schemaHTTPTimeout = 30 * time.Second ) -// schemaHTTPClient fetches the OpenAPI schema with an explicit timeout, matching -// the convention used elsewhere for ad-hoc external fetches (see auth0.quickstartHTTPClient). +// schemaHTTPClient fetches the OpenAPI schema with an explicit timeout. var schemaHTTPClient = &http.Client{Timeout: schemaHTTPTimeout} var ( @@ -35,8 +34,7 @@ var ( cachedAt time.Time ) -// GetDoc returns the OpenAPI document, serving a fresh copy (in-memory or on-disk, -// "/actions/actions". func ExtractPathFromURL(fullURL string) string { diff --git a/internal/openapi/schema_manager.go b/internal/openapi/schema_manager.go index ace186b6c..1c6c0a778 100644 --- a/internal/openapi/schema_manager.go +++ b/internal/openapi/schema_manager.go @@ -22,14 +22,12 @@ func sortedPropertyNames(props openapi3.Schemas) []string { return names } -// SchemaManager provides centralized access to OpenAPI schemas. -// It loads the schema once and provides methods to inspect and validate requests. +// SchemaManager provides access to OpenAPI operation schemas and request validation. type SchemaManager struct { doc *openapi3.T } -// NewSchemaManager creates a new schema manager. -// The schema is loaded once and cached for the lifetime of the manager. +// NewSchemaManager creates a new schema manager backed by the cached OpenAPI document. func NewSchemaManager() (*SchemaManager, error) { doc, err := GetDoc() if err != nil { @@ -53,37 +51,47 @@ func (sm *SchemaManager) GetOperationSchema(method, path string) (*OperationSche Path: path, } - // Get request schema only - agents only need to know what to send. if requestSchema := GetRequestSchema(operation); requestSchema != nil && requestSchema.Value != nil { result.RequestSchema = requestSchema.Value } + if result.RequestSchema == nil { + if qpSchema := GetQueryParamSchema(operation); qpSchema != nil { + result.RequestSchema = qpSchema + result.IsQueryParamSchema = true + } + } + return result, nil } // OperationSchema contains schema information for an API operation. -// Focus is on request payload - what agents need to send. type OperationSchema struct { - OperationID string - Summary string - Description string - Method string - Path string - RequestSchema *openapi3.Schema + OperationID string + Summary string + Description string + Method string + Path string + RequestSchema *openapi3.Schema + IsQueryParamSchema bool } // FormatAsJSON formats the schema as JSON for display. -func (os *OperationSchema) FormatAsJSON() (string, error) { +func (op *OperationSchema) FormatAsJSON() (string, error) { output := map[string]interface{}{ - "operation_id": os.OperationID, - "summary": os.Summary, - "description": os.Description, - "method": os.Method, - "path": os.Path, + "operation_id": op.OperationID, + "summary": op.Summary, + "description": op.Description, + "method": op.Method, + "path": op.Path, } - if os.RequestSchema != nil { - output["request_schema"] = schemaToMap(os.RequestSchema) + if op.RequestSchema != nil { + key := "request_schema" + if op.IsQueryParamSchema { + key = "query_params_schema" + } + output[key] = schemaToMap(op.RequestSchema) } data, err := json.MarshalIndent(output, "", " ") @@ -94,21 +102,25 @@ func (os *OperationSchema) FormatAsJSON() (string, error) { } // FormatAsText formats the schema as human-readable text. -func (os *OperationSchema) FormatAsText() string { +func (op *OperationSchema) FormatAsText() string { var sb strings.Builder - fmt.Fprintf(&sb, "Operation: %s\n", os.Summary) - fmt.Fprintf(&sb, "Endpoint: %s %s\n", os.Method, os.Path) - if os.Description != "" { - fmt.Fprintf(&sb, "Description: %s\n", os.Description) + fmt.Fprintf(&sb, "Operation: %s\n", op.Summary) + fmt.Fprintf(&sb, "Endpoint: %s %s\n", op.Method, op.Path) + if op.Description != "" { + fmt.Fprintf(&sb, "Description: %s\n", op.Description) } sb.WriteString("\n") - if os.RequestSchema != nil { - sb.WriteString("Request Payload:\n") + if op.RequestSchema != nil { + if op.IsQueryParamSchema { + sb.WriteString("Query Parameters:\n") + } else { + sb.WriteString("Request Payload:\n") + } sb.WriteString(strings.Repeat("=", 80)) sb.WriteString("\n\n") - sb.WriteString(formatSchema(os.RequestSchema, "")) + sb.WriteString(formatSchema(op.RequestSchema, "")) } else { sb.WriteString("No request body required for this operation.\n") } diff --git a/internal/openapi/schema_manager_test.go b/internal/openapi/schema_manager_test.go index c4785bd03..bd68dd35c 100644 --- a/internal/openapi/schema_manager_test.go +++ b/internal/openapi/schema_manager_test.go @@ -38,7 +38,7 @@ func TestGetOperationSchema(t *testing.T) { method: "GET", path: "/actions/actions", expectError: false, - expectRequestBody: false, // GET has no request body. + expectRequestBody: true, // Synthesized from query params. }, { name: "PATCH /actions/actions/{id}", @@ -79,6 +79,57 @@ func TestGetOperationSchema(t *testing.T) { } } +func TestGetOperationSchema_IsQueryParamSchema(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + t.Run("GET sets IsQueryParamSchema and uses query_params_schema key", func(t *testing.T) { + opSchema, err := manager.GetOperationSchema("GET", "/actions/actions") + require.NoError(t, err) + + assert.True(t, opSchema.IsQueryParamSchema) + assert.NotNil(t, opSchema.RequestSchema) + }) + + t.Run("POST does not set IsQueryParamSchema", func(t *testing.T) { + opSchema, err := manager.GetOperationSchema("POST", "/actions/actions") + require.NoError(t, err) + + assert.False(t, opSchema.IsQueryParamSchema) + assert.NotNil(t, opSchema.RequestSchema) + }) +} + +func TestFormatAsJSON_QueryParams(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + opSchema, err := manager.GetOperationSchema("GET", "/actions/actions") + require.NoError(t, err) + + jsonOutput, err := opSchema.FormatAsJSON() + require.NoError(t, err) + + assert.Contains(t, jsonOutput, "query_params_schema") + assert.NotContains(t, jsonOutput, "request_schema") + assert.Contains(t, jsonOutput, "operation_id") + assert.Contains(t, jsonOutput, "summary") +} + +func TestFormatAsText_QueryParams(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + opSchema, err := manager.GetOperationSchema("GET", "/actions/actions") + require.NoError(t, err) + + textOutput := opSchema.FormatAsText() + + assert.Contains(t, textOutput, "Query Parameters:") + assert.NotContains(t, textOutput, "Request Payload:") + assert.NotContains(t, textOutput, "No request body required") +} + func TestFormatAsJSON(t *testing.T) { manager, err := NewSchemaManager() require.NoError(t, err) diff --git a/internal/openapi/schema_test.go b/internal/openapi/schema_test.go index 379e11e3c..b6367588c 100644 --- a/internal/openapi/schema_test.go +++ b/internal/openapi/schema_test.go @@ -88,6 +88,43 @@ func TestGetRequestSchema(t *testing.T) { assert.Contains(t, requestSchema.Value.Required, "supported_triggers") } +func TestGetQueryParamSchema(t *testing.T) { + doc, err := GetDoc() + require.NoError(t, err) + + t.Run("returns schema for operation with query params", func(t *testing.T) { + operation, err := FindOperation(doc, "GET", "/actions/actions") + require.NoError(t, err) + + schema := GetQueryParamSchema(operation) + require.NotNil(t, schema) + assert.NotEmpty(t, schema.Properties) + assert.True(t, schema.Type.Is("object")) + }) + + t.Run("returns nil when no query params exist", func(t *testing.T) { + // POST /actions/actions has no query params. + operation, err := FindOperation(doc, "POST", "/actions/actions") + require.NoError(t, err) + + schema := GetQueryParamSchema(operation) + assert.Nil(t, schema) + }) + + t.Run("includes only query params, not path or header params", func(t *testing.T) { + operation, err := FindOperation(doc, "GET", "/actions/actions") + require.NoError(t, err) + + schema := GetQueryParamSchema(operation) + require.NotNil(t, schema) + + for name := range schema.Properties { + // Path-level params (like action id) should not appear. + assert.NotEqual(t, "id", name) + } + }) +} + func TestExtractPathFromURL(t *testing.T) { tests := []struct { name string