diff --git a/.gitignore b/.gitignore index 3a284a3cf..73c652f17 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,4 @@ out/ .github/copilot-instructions.md # LLM files -.remember/ +.remember/ \ No newline at end of file diff --git a/docs/auth0_actions.md b/docs/auth0_actions.md index a16c78bdd..34f081ee4 100644 --- a/docs/auth0_actions.md +++ b/docs/auth0_actions.md @@ -5,7 +5,21 @@ has_children: true --- # auth0 actions -Actions are secure, tenant-specific, versioned functions written in Node.js that execute at certain points within the Auth0 platform. Actions are used to customize and extend Auth0's capabilities with custom logic. +Actions are secure, tenant-specific, versioned functions written in Node.js that execute +at certain points within the Auth0 platform. Actions are used to customize and extend Auth0's +capabilities with custom logic. + +## Schema Discovery & JSON Input + +Use '--schema' on a command to print its request payload schema, and '--data' +to provide that payload programmatically (validated against the schema before the call). + +Examples: + auth0 actions create --schema # Show the create payload schema + auth0 actions create --data @action.json # Create from JSON file + auth0 actions create --data '{"name":"..."}' # Create from inline JSON + +For more details: https://auth0.com/docs/api/management/v2 ## Commands diff --git a/docs/auth0_actions_create.md b/docs/auth0_actions_create.md index 626301948..55e1e3290 100644 --- a/docs/auth0_actions_create.md +++ b/docs/auth0_actions_create.md @@ -7,10 +7,20 @@ has_toc: false Create a new action. -To create interactively, use `auth0 actions create` with no flags. +To create interactively, use 'auth0 actions create' with no flags. To create non-interactively, supply the action name, trigger, code, secrets and dependencies through the flags. +## JSON Input (for agents and automation) + +Use '--schema' to print the request payload schema, then '--data' to provide +action data as JSON: + - Inline JSON: --data '{"name":"my-action",...}' + - From file: --data @action.json + - From stdin: pipe data in (e.g. cat action.json | auth0 actions create) + +The JSON is validated against the OpenAPI schema before sending to the API. + ## Usage ``` auth0 actions create [flags] @@ -19,16 +29,24 @@ auth0 actions create [flags] ## Examples ``` + # Interactive mode auth0 actions create - auth0 actions create --name myaction + + # Flag-based mode auth0 actions create --name myaction --trigger post-login - auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --runtime node18 - auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" - auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" --secret "SECRET=value" - auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" --dependency "uuid=9.0.0" --secret "API_KEY=value" --secret "SECRET=value" auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --module "module_id=mod_123,module_version_id=ver_456" - auth0 actions create -n myaction -t post-login -c "$(cat path/to/code.js)" -r node18 -d "lodash=4.0.0" -d "uuid=9.0.0" -s "API_KEY=value" -s "SECRET=value" --json - auth0 actions create -n myaction -t post-login -c "$(cat path/to/code.js)" -r node18 -d "lodash=4.0.0" -d "uuid=9.0.0" -s "API_KEY=value" -s "SECRET=value" --json-compact + auth0 actions create -n myaction -t post-login -c "$(cat path/to/code.js)" -r node18 --json + auth0 actions create -n myaction -t post-login -c "$(cat path/to/code.js)" -d "lodash=4.0.0" -s "API_KEY=value" --json-compact + + # Discover the payload schema (add --json for machine-readable output) + auth0 actions create --schema + auth0 actions create --schema --json + + # JSON input mode (for agents and automation) + auth0 actions create --data '{"name":"my-action","supported_triggers":[{"id":"post-login","version":"v3"}]}' + auth0 actions create --data @action.json + cat action.json | auth0 actions create + auth0 actions create --data @action.json --json ``` @@ -36,12 +54,14 @@ auth0 actions create [flags] ``` -c, --code string Code content for the action. + --data string JSON payload for the operation, as a JSON string or file path (@file.json). Can also be piped via stdin. -d, --dependency stringToString Third party npm module, and its version, that the action depends on. (default []) --json Output in json format. --json-compact Output in compact json format. -m, --module stringArray 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. -n, --name string Name of the action. -r, --runtime string Runtime to be used in the action. Possible values are: node22(recommended), node18, node16, node12 + --schema Print the request payload schema for this command and exit. Use with --json or --json-compact for machine-readable output. -s, --secret stringToString Secrets to be used in the action. (default []) -t, --trigger string Trigger of the action. At this time, an action can only target a single trigger at a time. ``` diff --git a/docs/auth0_actions_update.md b/docs/auth0_actions_update.md index 8a2d19152..df5ed659e 100644 --- a/docs/auth0_actions_update.md +++ b/docs/auth0_actions_update.md @@ -7,9 +7,17 @@ has_toc: false Update an action. -To update interactively, use `auth0 actions update` with no arguments. - +To update interactively, use 'auth0 actions update' with no arguments. To update non-interactively, supply the action id, name, code, secrets and dependencies through the flags. +## JSON Input (for agents and automation) + +Use '--schema' to print the request payload schema, then '--data' to provide +update data as JSON: + - Inline JSON: --data '{"name":"updated-name","runtime":"node22"}' + - From file: --data @update.json + - From stdin: pipe data in (e.g. cat update.json | auth0 actions update ) + +The JSON is validated against the OpenAPI schema before sending to the API. ## Usage ``` @@ -19,16 +27,24 @@ auth0 actions update [flags] ## Examples ``` + # Interactive mode + auth0 actions update auth0 actions update + + # Flag-based mode auth0 actions update --runtime node18 - auth0 actions update --name myaction --runtime node18 - auth0 actions update --name myaction --code "$(cat path/to/code.js) --r node18" - auth0 actions update --name myaction --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" - auth0 actions update --name myaction --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" --secret "SECRET=value" - auth0 actions update --name myaction --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" --dependency "uuid=9.0.0" --secret "API_KEY=value" --secret "SECRET=value" - auth0 actions update --module "module_id=mod_123,module_version_id=ver_456" - auth0 actions update -n myaction -c "$(cat path/to/code.js)" -r node18 -d "lodash=4.0.0" -d "uuid=9.0.0" -s "API_KEY=value" -s "SECRET=value" --json - auth0 actions update -n myaction -c "$(cat path/to/code.js)" -r node18 -d "lodash=4.0.0" -d "uuid=9.0.0" -s "API_KEY=value" -s "SECRET=value" --json-compact + auth0 actions update --name myaction --code "$(cat path/to/code.js)" + auth0 actions update --module "module_id=mod_123,module_version_id=ver_456" --json + auth0 actions update -n myaction -c "$(cat path/to/code.js)" -d "lodash=4.0.0" --json-compact + + # Discover the payload schema (add --json for machine-readable output) + auth0 actions update --schema + auth0 actions update --schema --json + + # JSON input mode (for agents and automation) + auth0 actions update --data '{"name":"updated-name","runtime":"node22"}' + auth0 actions update --data @update.json + cat update.json | auth0 actions update ``` @@ -36,6 +52,7 @@ auth0 actions update [flags] ``` -c, --code string Code content for the action. + --data string JSON payload for the operation, as a JSON string or file path (@file.json). Can also be piped via stdin. -d, --dependency stringToString Third party npm module, and its version, that the action depends on. (default []) --force Skip confirmation. --json Output in json format. @@ -43,6 +60,7 @@ auth0 actions update [flags] -m, --module stringArray 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. -n, --name string Name of the action. -r, --runtime string Runtime to be used in the action. Possible values are: node22(recommended), node18, node16, node12 + --schema Print the request payload schema for this command and exit. Use with --json or --json-compact for machine-readable output. -s, --secret stringToString Secrets to be used in the action. (default []) ``` diff --git a/go.mod b/go.mod index bd0b08e11..4716b9c98 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/charmbracelet/glamour v1.0.0 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e github.com/fsnotify/fsnotify v1.10.1 + github.com/getkin/kin-openapi v0.145.0 github.com/getsentry/sentry-go v0.48.0 github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.7.0 @@ -62,6 +63,8 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/fatih/color v1.16.0 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gorilla/css v1.0.1 // indirect @@ -69,7 +72,6 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/terraform-json v0.28.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.3.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect @@ -87,7 +89,10 @@ require ( github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/valyala/fastjson v1.6.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index de09f1111..5101f03a9 100644 --- a/go.sum +++ b/go.sum @@ -63,7 +63,6 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= @@ -85,6 +84,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/getkin/kin-openapi v0.145.0 h1:htBX+Q7SevVaCUqymFegUKzH2WCbewl9tsmyn2FMGWY= +github.com/getkin/kin-openapi v0.145.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY= github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -95,6 +96,12 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -197,6 +204,10 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -217,6 +228,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU= github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39AgQeiPCJtlTWIW8= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= diff --git a/internal/cli/actions.go b/internal/cli/actions.go index b52467eec..92e9700e1 100644 --- a/internal/cli/actions.go +++ b/internal/cli/actions.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" "strings" @@ -92,9 +93,21 @@ func actionsCmd(cli *cli) *cobra.Command { cmd := &cobra.Command{ Use: "actions", Short: "Manage resources for actions", - Long: "Actions are secure, tenant-specific, versioned functions written in Node.js that execute " + - "at certain points within the Auth0 platform. Actions are used to customize and extend Auth0's " + - "capabilities with custom logic.", + Long: `Actions are secure, tenant-specific, versioned functions written in Node.js that execute +at certain points within the Auth0 platform. Actions are used to customize and extend Auth0's +capabilities with custom logic. + +## Schema Discovery & JSON Input + +Use '--schema' on a command to print its request payload schema, and '--data' +to provide that payload programmatically (validated against the schema before the call). + +Examples: + auth0 actions create --schema # Show the create payload schema + auth0 actions create --data @action.json # Create from JSON file + auth0 actions create --data '{"name":"..."}' # Create from inline JSON + +For more details: https://auth0.com/docs/api/management/v2`, } cmd.SetUsageTemplate(resourceUsageTemplate()) @@ -200,26 +213,62 @@ func createActionCmd(cli *cli) *cobra.Command { Secrets map[string]string Runtime string Modules []string + Data string + Schema bool } cmd := &cobra.Command{ Use: "create", Args: cobra.NoArgs, Short: "Create a new action", - Long: "Create a new action.\n\n" + - "To create interactively, use `auth0 actions create` with no flags.\n\n" + - "To create non-interactively, supply the action name, trigger, code, secrets and dependencies through the flags.", - Example: ` auth0 actions create - auth0 actions create --name myaction + Long: `Create a new action. + +To create interactively, use 'auth0 actions create' with no flags. + +To create non-interactively, supply the action name, trigger, code, secrets and dependencies through the flags. + +## JSON Input (for agents and automation) + +Use '--schema' to print the request payload schema, then '--data' to provide +action data as JSON: + - Inline JSON: --data '{"name":"my-action",...}' + - From file: --data @action.json + - From stdin: pipe data in (e.g. cat action.json | auth0 actions create) + +The JSON is validated against the OpenAPI schema before sending to the API.`, + Example: ` # Interactive mode + auth0 actions create + + # Flag-based mode auth0 actions create --name myaction --trigger post-login - auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --runtime node18 - auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" - auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" --secret "SECRET=value" - auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" --dependency "uuid=9.0.0" --secret "API_KEY=value" --secret "SECRET=value" auth0 actions create --name myaction --trigger post-login --code "$(cat path/to/code.js)" --module "module_id=mod_123,module_version_id=ver_456" - auth0 actions create -n myaction -t post-login -c "$(cat path/to/code.js)" -r node18 -d "lodash=4.0.0" -d "uuid=9.0.0" -s "API_KEY=value" -s "SECRET=value" --json - auth0 actions create -n myaction -t post-login -c "$(cat path/to/code.js)" -r node18 -d "lodash=4.0.0" -d "uuid=9.0.0" -s "API_KEY=value" -s "SECRET=value" --json-compact`, + auth0 actions create -n myaction -t post-login -c "$(cat path/to/code.js)" -r node18 --json + auth0 actions create -n myaction -t post-login -c "$(cat path/to/code.js)" -d "lodash=4.0.0" -s "API_KEY=value" --json-compact + + # Discover the payload schema (add --json for machine-readable output) + auth0 actions create --schema + auth0 actions create --schema --json + + # JSON input mode (for agents and automation) + auth0 actions create --data '{"name":"my-action","supported_triggers":[{"id":"post-login","version":"v3"}]}' + auth0 actions create --data @action.json + cat action.json | auth0 actions create + auth0 actions create --data @action.json --json`, RunE: func(cmd *cobra.Command, args []string) error { + // Schema discovery mode: print the request payload and exit. + if inputs.Schema { + return printOperationSchema(cli, "POST", "/actions/actions") + } + + // JSON input mode (for agents and automation): explicit --data or piped stdin. + payload, provided, err := ResolveData(cmd) + if err != nil { + return err + } + if provided { + return createActionFromJSON(cli, cmd, payload) + } + if err := actionName.Ask(cmd, &inputs.Name, nil); err != nil { return err } @@ -299,6 +348,12 @@ func createActionCmd(cli *cli) *cobra.Command { actionSecret.RegisterStringMap(cmd, &inputs.Secrets, nil) actionRuntime.RegisterString(cmd, &inputs.Runtime, "") actionModule.RegisterStringArray(cmd, &inputs.Modules, nil) + dataFlag.RegisterString(cmd, &inputs.Data, "") + schemaFlag.RegisterBool(cmd, &inputs.Schema, false) + + // --data supplies the whole payload, so it cannot be combined with the + // granular input flags. Output flags (--json) and --schema are not affected. + markDataExclusive(cmd) return cmd } @@ -312,27 +367,52 @@ func updateActionCmd(cli *cli) *cobra.Command { Secrets map[string]string Runtime string Modules []string + Data string + Schema bool } cmd := &cobra.Command{ Use: "update", Args: cobra.MaximumNArgs(1), Short: "Update an action", - Long: "Update an action.\n\n" + - "To update interactively, use `auth0 actions update` with no arguments.\n\n" + - "To update non-interactively, supply the action id, name, code, secrets and " + - "dependencies through the flags.", - Example: ` auth0 actions update + Long: `Update an action. + +To update interactively, use 'auth0 actions update' with no arguments. +To update non-interactively, supply the action id, name, code, secrets and dependencies through the flags. +## JSON Input (for agents and automation) + +Use '--schema' to print the request payload schema, then '--data' to provide +update data as JSON: + - Inline JSON: --data '{"name":"updated-name","runtime":"node22"}' + - From file: --data @update.json + - From stdin: pipe data in (e.g. cat update.json | auth0 actions update ) + +The JSON is validated against the OpenAPI schema before sending to the API.`, + Example: ` # Interactive mode + auth0 actions update + auth0 actions update + + # Flag-based mode auth0 actions update --runtime node18 - auth0 actions update --name myaction --runtime node18 - auth0 actions update --name myaction --code "$(cat path/to/code.js) --r node18" - auth0 actions update --name myaction --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" - auth0 actions update --name myaction --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" --secret "SECRET=value" - auth0 actions update --name myaction --code "$(cat path/to/code.js)" --dependency "lodash=4.0.0" --dependency "uuid=9.0.0" --secret "API_KEY=value" --secret "SECRET=value" - auth0 actions update --module "module_id=mod_123,module_version_id=ver_456" - auth0 actions update -n myaction -c "$(cat path/to/code.js)" -r node18 -d "lodash=4.0.0" -d "uuid=9.0.0" -s "API_KEY=value" -s "SECRET=value" --json - auth0 actions update -n myaction -c "$(cat path/to/code.js)" -r node18 -d "lodash=4.0.0" -d "uuid=9.0.0" -s "API_KEY=value" -s "SECRET=value" --json-compact`, + auth0 actions update --name myaction --code "$(cat path/to/code.js)" + auth0 actions update --module "module_id=mod_123,module_version_id=ver_456" --json + auth0 actions update -n myaction -c "$(cat path/to/code.js)" -d "lodash=4.0.0" --json-compact + + # Discover the payload schema (add --json for machine-readable output) + auth0 actions update --schema + auth0 actions update --schema --json + + # JSON input mode (for agents and automation) + auth0 actions update --data '{"name":"updated-name","runtime":"node22"}' + auth0 actions update --data @update.json + cat update.json | auth0 actions update `, RunE: func(cmd *cobra.Command, args []string) error { + // Schema discovery mode: print the request payload and exit. + // This does not require an action ID. + if inputs.Schema { + return printOperationSchema(cli, "PATCH", "/actions/actions/{id}") + } + if len(args) > 0 { inputs.ID = args[0] } else { @@ -341,8 +421,17 @@ func updateActionCmd(cli *cli) *cobra.Command { } } + // JSON input mode (for agents and automation): explicit --data or piped stdin. + payload, provided, err := ResolveData(cmd) + if err != nil { + return err + } + if provided { + return updateActionFromJSON(cli, cmd, inputs.ID, payload) + } + var oldAction *management.Action - err := ansi.Waiting(func() (err error) { + err = ansi.Waiting(func() (err error) { oldAction, err = cli.api.Action.Read(cmd.Context(), inputs.ID) return err }) @@ -429,10 +518,53 @@ func updateActionCmd(cli *cli) *cobra.Command { actionSecret.RegisterStringMapU(cmd, &inputs.Secrets, nil) actionRuntime.RegisterStringU(cmd, &inputs.Runtime, "") actionModule.RegisterStringArrayU(cmd, &inputs.Modules, nil) + dataFlag.RegisterString(cmd, &inputs.Data, "") + schemaFlag.RegisterBool(cmd, &inputs.Schema, false) + + // --data supplies the whole payload, so it cannot be combined with the + // granular input flags. Output flags (--json) and --schema are not affected. + markDataExclusive(cmd) return cmd } +// createActionFromJSON creates an action from a --data JSON payload. +func createActionFromJSON(cli *cli, cmd *cobra.Command, dataStr string) error { + action, err := runJSONWrite[management.Action](cli, cmd, jsonWriteSpec{ + Method: http.MethodPost, + SchemaPath: "/actions/actions", + URI: cli.api.HTTPClient.URI("actions", "actions"), + Data: dataStr, + SchemaCmd: "auth0 actions create", + }) + if err != nil { + return fmt.Errorf("failed to create action: %w", err) + } + + cli.renderer.ActionCreate(action) + + return nil +} + +// updateActionFromJSON updates an action from a --data JSON payload. The endpoint +// applies PATCH semantics, so unspecified fields keep their current values. +func updateActionFromJSON(cli *cli, cmd *cobra.Command, id, dataStr string) error { + action, err := runJSONWrite[management.Action](cli, cmd, jsonWriteSpec{ + Method: http.MethodPatch, + SchemaPath: "/actions/actions/{id}", + URI: cli.api.HTTPClient.URI("actions", "actions", id), + Data: dataStr, + SchemaCmd: "auth0 actions update", + }) + if err != nil { + return fmt.Errorf("failed to update action with ID %q: %w", id, err) + } + + cli.renderer.ActionUpdate(action) + + return nil +} + // hasNonCodeFlagSet reports whether the user set any update flag other than the // action code, so a non-code update (e.g. --module) can skip the code editor. func hasNonCodeFlagSet(cmd *cobra.Command) bool { diff --git a/internal/cli/data_json.go b/internal/cli/data_json.go new file mode 100644 index 000000000..28b68dd19 --- /dev/null +++ b/internal/cli/data_json.go @@ -0,0 +1,167 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/iostream" + "github.com/auth0/auth0-cli/internal/openapi" +) + +var ( + dataFlag = Flag{ + Name: "Data", + LongForm: "data", + Help: "JSON payload for the operation, as a JSON string or file path (@file.json). Can also be piped via stdin.", + } +) + +// DataJSONHandler handles the --data flag for create/update commands. +type DataJSONHandler struct { + cli *cli + manager *openapi.SchemaManager +} + +// NewDataJSONHandler creates a new data JSON handler. +func NewDataJSONHandler(c *cli) (*DataJSONHandler, error) { + manager, err := openapi.NewSchemaManager() + if err != nil { + return nil, err + } + return &DataJSONHandler{ + cli: c, + manager: manager, + }, nil +} + +// ReadAndValidate reads the JSON input and validates it against the schema, +// returning the raw bytes so the caller can send them to the API unchanged +// (no SDK struct round-trip that would drop fields or apply omitempty). +func (h *DataJSONHandler) ReadAndValidate(inputStr, method, path string) (json.RawMessage, error) { + jsonData, err := h.readJSONInput(inputStr) + if err != nil { + return nil, fmt.Errorf("failed to read JSON input: %w", err) + } + + result, err := h.manager.ValidateRequest(method, path, jsonData) + if err != nil { + return nil, fmt.Errorf("schema validation error: %w", err) + } + + if !result.Valid { + return nil, fmt.Errorf("schema validation failed:\n%s", formatValidationErrors(result.Errors)) + } + + return json.RawMessage(jsonData), nil +} + +// readJSONInput reads JSON from various input sources. +func (h *DataJSONHandler) readJSONInput(input string) ([]byte, error) { + if input == "" { + return nil, fmt.Errorf("no input provided") + } + + if input[0] == '@' { // @file. + return os.ReadFile(input[1:]) + } + + return []byte(input), nil // Inline JSON. +} + +// formatValidationErrors formats validation errors in a user-friendly way. +func formatValidationErrors(errors []string) string { + lines := make([]string, len(errors)) + for i, err := range errors { + lines[i] = fmt.Sprintf("%d. %s", i+1, err) + } + return strings.Join(lines, "\n") +} + +// HasData checks if the --data flag is set. +func HasData(cmd *cobra.Command) bool { + flag := cmd.Flags().Lookup("data") + return flag != nil && flag.Changed +} + +// ResolveData resolves the JSON payload from --data (inline JSON or @file) or +// piped stdin; provided is false when neither is given. JSON input is a +// whole-payload alternative to the individual flags and cannot be combined with them. +func ResolveData(cmd *cobra.Command) (payload string, provided bool, err error) { + // --data wins and is used as-is; stdin is not read, so a create/update with + // --data never blocks on an open stdin pipe. + if HasData(cmd) { + flagValue, _ := GetData(cmd) + return flagValue, true, nil + } + + pipedPayload := iostream.PipedInput() + if len(pipedPayload) == 0 { + return "", false, nil + } + + // Piped JSON is the whole payload; it cannot be combined with input flags. + if conflicting := setInputFlagNames(cmd); len(conflicting) > 0 { + return "", false, fmt.Errorf( + "cannot combine piped JSON input with individual flags (%s); "+ + "provide the whole payload as JSON or use the flags, not both", + strings.Join(conflicting, ", "), + ) + } + + return string(pipedPayload), true, nil +} + +// GetData gets the value of the --data flag. +func GetData(cmd *cobra.Command) (string, error) { + return cmd.Flags().GetString("data") +} + +// jsonWriteSpec describes a create/update driven by a --data JSON payload. +// +// SchemaPath MUST be the OpenAPI-keyed path template (e.g. "/actions/actions/{id}"), +// never a concrete path. The kin-openapi Paths.Find helper matches templated paths +// only when their template-variable counts are equal, so "/actions/actions/act_123" +// (0 vars) would never resolve against the stored "/actions/actions/{id}" (1 var). +// Build the actual request URL separately in URI (e.g. via cli.api.HTTPClient.URI(...)). +type jsonWriteSpec struct { + Method string // HTTP method, e.g. http.MethodPost / http.MethodPatch. + SchemaPath string // OpenAPI-keyed path template used for validation + error hints. + URI string // Fully-qualified request URL. + Data string // Raw --data value (inline JSON, @file, or piped payload). + SchemaCmd string // Command to suggest in the "--schema" hint, e.g. "auth0 actions create". +} + +// runJSONWrite validates a --data payload against the OpenAPI schema, sends it to +// the Management API verbatim (no SDK struct round-trip, like `auth0 api`), and +// returns the response decoded into *T for rendering. New resources reuse this by +// supplying their spec and the management type; the API's own semantics (e.g. PATCH +// preserving unspecified fields) apply to the exact bytes sent. +func runJSONWrite[T any](cli *cli, cmd *cobra.Command, spec jsonWriteSpec) (*T, error) { + handler, err := NewDataJSONHandler(cli) + if err != nil { + return nil, fmt.Errorf("failed to initialize JSON handler: %w", err) + } + + payload, err := handler.ReadAndValidate(spec.Data, spec.Method, spec.SchemaPath) + if err != nil { + cli.renderer.Infof("Run '%s --schema' to see the expected schema.", spec.SchemaCmd) + return nil, err + } + + if err := ansi.Waiting(func() error { + return cli.api.HTTPClient.Request(cmd.Context(), spec.Method, spec.URI, &payload) + }); err != nil { + return nil, enhanceAPIError(err, spec.Method, spec.SchemaPath) + } + + out := new(T) + if err := json.Unmarshal(payload, out); err != nil { + return nil, fmt.Errorf("failed to parse API response: %w", err) + } + return out, nil +} diff --git a/internal/cli/data_json_test.go b/internal/cli/data_json_test.go new file mode 100644 index 000000000..01f85fd66 --- /dev/null +++ b/internal/cli/data_json_test.go @@ -0,0 +1,136 @@ +package cli + +import ( + "os" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/iostream" +) + +// newDataCommand builds a minimal create-like command with the flags that matter +// for ResolveData: --data, a granular input flag (--name), and an output flag +// (--json). It mirrors how a real resource command registers these. +func newDataCommand() (*cobra.Command, *struct { + Data string + Name string + JSON bool +}) { + inputs := &struct { + Data string + Name string + JSON bool + }{} + + cmd := &cobra.Command{Use: "create", RunE: func(*cobra.Command, []string) error { return nil }} + dataFlag.RegisterString(cmd, &inputs.Data, "") + actionName.RegisterString(cmd, &inputs.Name, "") + cmd.Flags().BoolVar(&inputs.JSON, "json", false, "Output in json format.") + + return cmd, inputs +} + +// withPipedStdin swaps iostream.Input for a pipe carrying content (empty content +// means "closed pipe with no data"), runs fn, and restores the original stdin. +// A pipe is a non-terminal file, so iostream.PipedInput() reads from it. +func withPipedStdin(t *testing.T, content string, fn func()) { + t.Helper() + + r, w, err := os.Pipe() + require.NoError(t, err) + + original := iostream.Input + iostream.Input = r + defer func() { iostream.Input = original }() + + _, err = w.WriteString(content) + require.NoError(t, err) + require.NoError(t, w.Close()) + + fn() + require.NoError(t, r.Close()) +} + +func TestResolveData(t *testing.T) { + t.Run("explicit --data flag", func(t *testing.T) { + cmd, _ := newDataCommand() + require.NoError(t, cmd.ParseFlags([]string{"--data", `{"name":"x"}`})) + + withPipedStdin(t, "", func() { + payload, provided, err := ResolveData(cmd) + require.NoError(t, err) + assert.True(t, provided) + assert.Equal(t, `{"name":"x"}`, payload) + }) + }) + + // --data wins over piped stdin (like `auth0 api`); the flag value is used. + t.Run("--data flag takes precedence over piped stdin", func(t *testing.T) { + cmd, _ := newDataCommand() + require.NoError(t, cmd.ParseFlags([]string{"--data", `{"name":"from-flag"}`})) + + withPipedStdin(t, `{"name":"from-pipe"}`, func() { + payload, provided, err := ResolveData(cmd) + require.NoError(t, err) + assert.True(t, provided) + assert.Equal(t, `{"name":"from-flag"}`, payload) + }) + }) + + t.Run("piped stdin, no flags", func(t *testing.T) { + cmd, _ := newDataCommand() + require.NoError(t, cmd.ParseFlags([]string{})) + + withPipedStdin(t, `{"name":"from-pipe"}`, func() { + payload, provided, err := ResolveData(cmd) + require.NoError(t, err) + assert.True(t, provided) + assert.Equal(t, `{"name":"from-pipe"}`, payload) + }) + }) + + // JSON input replaces the individual flags, so piped JSON combined with a + // granular input flag is a clear error. MarkFlagsMutuallyExclusive cannot see + // stdin, so ResolveData must reject this itself. + t.Run("piped stdin combined with input flag is rejected", func(t *testing.T) { + cmd, _ := newDataCommand() + require.NoError(t, cmd.ParseFlags([]string{"--name", "from-flag"})) + + withPipedStdin(t, `{"name":"from-pipe"}`, func() { + payload, provided, err := ResolveData(cmd) + require.Error(t, err) + assert.False(t, provided) + assert.Empty(t, payload) + assert.Contains(t, err.Error(), "name") + assert.Contains(t, err.Error(), "cannot combine") + }) + }) + + // Output flags are not input flags, so a pipe may coexist with --json. + t.Run("piped stdin with output flag is allowed", func(t *testing.T) { + cmd, _ := newDataCommand() + require.NoError(t, cmd.ParseFlags([]string{"--json"})) + + withPipedStdin(t, `{"name":"from-pipe"}`, func() { + payload, provided, err := ResolveData(cmd) + require.NoError(t, err) + assert.True(t, provided) + assert.Equal(t, `{"name":"from-pipe"}`, payload) + }) + }) + + t.Run("no data and no pipe falls through to interactive", func(t *testing.T) { + cmd, _ := newDataCommand() + require.NoError(t, cmd.ParseFlags([]string{})) + + withPipedStdin(t, "", func() { + payload, provided, err := ResolveData(cmd) + require.NoError(t, err) + assert.False(t, provided) + assert.Empty(t, payload) + }) + }) +} diff --git a/internal/cli/error_enhancer.go b/internal/cli/error_enhancer.go new file mode 100644 index 000000000..38d5ccae0 --- /dev/null +++ b/internal/cli/error_enhancer.go @@ -0,0 +1,38 @@ +package cli + +import ( + "strings" + + "github.com/auth0/auth0-cli/internal/openapi" +) + +// enhanceAPIError enriches an API error with the expected schema, best-effort: +// on any lookup failure it returns the original error unchanged. +func enhanceAPIError(err error, method, path string) error { + if err == nil { + return nil + } + + manager, managerErr := openapi.NewSchemaManager() + if managerErr != nil { + return err + } + + apiPath := normalizeAPIPath(path) + if apiPath == "" { + return err + } + + return manager.EnhanceError(err, method, apiPath) +} + +// normalizeAPIPath converts a full URL or relative path to the OpenAPI path format. +func normalizeAPIPath(path string) string { + if strings.Contains(path, "/api/v2") { + return openapi.ExtractPathFromURL(path) + } + if strings.HasPrefix(path, "/") { + return path + } + return "/" + path +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0d818c088..795305026 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -26,6 +26,25 @@ import ( const rootShort = "Build, manage and test your Auth0 integrations from the command line." +const rootLong = `Build, manage and test your Auth0 integrations from the command line. + +## For Agents and Automation + +The Auth0 CLI now includes features for AI agents and automation: + + • Schema Discovery: Use the '--schema' flag on a create/update command to print + its request payload schema. Add '--json' for machine-readable output. + Example: auth0 actions create --schema --json + + • JSON Input: Use '--data' flag for programmatic resource creation/updates + Example: auth0 actions create --data @action.json + + • Schema Validation: JSON inputs are validated locally before API calls + Example: auth0 actions create --data '{"name":"my-action",...}' + +See 'auth0 --help' for details on specific resources. +For agent integration guide, visit: https://github.com/auth0/auth0-cli` + const panicMessage = ` !! Uh oh. Something went wrong. !! If this problem keeps happening feel free to report an issue at @@ -114,7 +133,7 @@ func buildRootCmd(cli *cli) *cobra.Command { SilenceUsage: true, SilenceErrors: true, Short: rootShort, - Long: rootShort + "\n" + getLogin(cli), + Long: rootLong + "\n\n" + getLogin(cli), Version: buildinfo.GetVersionWithCommit(), PersistentPreRunE: func(cmd *cobra.Command, args []string) error { cli.executedCommandPath = cmd.CommandPath() diff --git a/internal/cli/schema.go b/internal/cli/schema.go new file mode 100644 index 000000000..37af4c41c --- /dev/null +++ b/internal/cli/schema.go @@ -0,0 +1,104 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/openapi" +) + +// outputFlags control output or behavior, not input, so they may be combined +// with --data. Every other input flag conflicts with a whole-payload --data. +var outputFlags = map[string]bool{ + "json": true, + "json-compact": true, + "csv": true, + "force": true, +} + +var schemaFlag = Flag{ + Name: "Schema", + LongForm: "schema", + Help: "Print the request payload schema for this command and exit. Use with --json or --json-compact for machine-readable output.", +} + +// printOperationSchema prints the request payload schema for an operation, as +// JSON when cli.json is set and text otherwise. +func printOperationSchema(cli *cli, method, path string) error { + var manager *openapi.SchemaManager + if err := ansi.Waiting(func() (err error) { + manager, err = openapi.NewSchemaManager() + return err + }); err != nil { + return fmt.Errorf("failed to load OpenAPI schema: %w", err) + } + + opSchema, err := manager.GetOperationSchema(method, path) + if err != nil { + return fmt.Errorf("failed to get schema for %s %s: %w", method, path, err) + } + + if cli.json || cli.jsonCompact { + output, err := opSchema.FormatAsJSON() + if err != nil { + return err + } + if cli.jsonCompact { + var buf bytes.Buffer + if err := json.Compact(&buf, []byte(output)); err != nil { + return err + } + output = buf.String() + } + cli.renderer.Output(output) + return nil + } + + cli.renderer.Output(opSchema.FormatAsText()) + return nil +} + +// markDataExclusive rejects combining a whole-payload --data with any granular +// input flag. Call after all flags are registered. +func markDataExclusive(cmd *cobra.Command) { + if cmd.Flags().Lookup("data") == nil { + return + } + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if isInputFlag(f.Name) { + cmd.MarkFlagsMutuallyExclusive("data", 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 { + switch { + case name == "data": // How input is delivered, not input itself. + return false + case name == schemaFlag.LongForm: // Help-class; exits before RunE. + return false + case outputFlags[name]: // Output/meta. + return false + default: + return true + } +} + +// setInputFlagNames returns the input flags the user explicitly set — used to +// detect conflicts with a stdin payload, which MarkFlagsMutuallyExclusive can't see. +func setInputFlagNames(cmd *cobra.Command) []string { + var names []string + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Changed && isInputFlag(f.Name) { + names = append(names, f.Name) + } + }) + return names +} diff --git a/internal/openapi/error_handler.go b/internal/openapi/error_handler.go new file mode 100644 index 000000000..606377473 --- /dev/null +++ b/internal/openapi/error_handler.go @@ -0,0 +1,53 @@ +package openapi + +import ( + "fmt" + "strings" + + "github.com/auth0/go-auth0/management" + "github.com/getkin/kin-openapi/openapi3" +) + +// EnhanceError appends the expected request schema to a 400 Bad Request error. +// Non-400 errors are returned unchanged. +func (sm *SchemaManager) EnhanceError(err error, method, path string) error { + if err == nil { + return nil + } + + // Only enhance 400 Bad Request errors from the management API. + mgmtErr, ok := err.(management.Error) + if !ok || mgmtErr.Status() != 400 { + return err + } + + // Return the original error if the schema can't supply a hint. + operation, opErr := FindOperation(sm.doc, method, path) + if opErr != nil { + return err + } + requestSchema := GetRequestSchema(operation) + if requestSchema == nil { + return err + } + + schemaInfo := formatSchemaInfo(requestSchema.Value, operation) + return fmt.Errorf("%s\n\n%s", err.Error(), schemaInfo) +} + +// formatSchemaInfo renders the expected request schema, reusing the shared +// renderer so the 400 hint matches '--schema' output and stays $ref-free. +func formatSchemaInfo(schema *openapi3.Schema, operation *openapi3.Operation) string { + var sb strings.Builder + + sb.WriteString("Expected Request Schema:\n") + sb.WriteString("=======================\n\n") + + if operation.Summary != "" { + fmt.Fprintf(&sb, "Operation: %s\n\n", operation.Summary) + } + + sb.WriteString(formatSchema(schema, "")) + + return sb.String() +} diff --git a/internal/openapi/error_handler_test.go b/internal/openapi/error_handler_test.go new file mode 100644 index 000000000..19ce9697c --- /dev/null +++ b/internal/openapi/error_handler_test.go @@ -0,0 +1,191 @@ +package openapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockError implements management.Error interface for testing. +type mockError struct { + statusCode int + message string +} + +func (m *mockError) Error() string { + return m.message +} + +func (m *mockError) Status() int { + return m.statusCode +} + +func TestEnhanceError_400Error(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + mockErr := &mockError{ + statusCode: 400, + message: "Bad Request: Invalid action data", + } + + enhanced := manager.EnhanceError(mockErr, "POST", "/actions/actions") + require.NotNil(t, enhanced) + + enhancedMsg := enhanced.Error() + + // Verify that the enhanced error contains the original message. + assert.Contains(t, enhancedMsg, "Bad Request: Invalid action data") + + // Verify that it contains schema information. + assert.Contains(t, enhancedMsg, "Expected Request Schema") + assert.Contains(t, enhancedMsg, "Required fields") + assert.Contains(t, enhancedMsg, "name") + assert.Contains(t, enhancedMsg, "supported_triggers") +} + +// TestEnhanceError_ActionUpdatePath strictly guards the regression where the +// update --data flow passed a concrete-ID path to EnhanceError. The templated +// path must enhance a 400; the concrete-ID path cannot resolve, so the error is +// returned unchanged. Unlike TestEnhanceError_MultipleOperations, this asserts +// enhancement unconditionally so a silent no-op regression fails the test. +func TestEnhanceError_ActionUpdatePath(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + mockErr := &mockError{statusCode: 400, message: "Bad Request: bad update"} + + // Templated path: enhancement fires and appends the schema. + enhanced := manager.EnhanceError(mockErr, "PATCH", "/actions/actions/{id}") + require.NotEqual(t, mockErr, enhanced, "templated path must enhance the 400 error") + assert.Contains(t, enhanced.Error(), "Bad Request: bad update") + assert.Contains(t, enhanced.Error(), "Expected Request Schema") + + // Concrete-ID path: operation not found, so the error is returned unchanged. + notEnhanced := manager.EnhanceError(mockErr, "PATCH", "/actions/actions/act_123") + assert.Equal(t, mockErr, notEnhanced, "concrete-ID path must not resolve, error returned as-is") +} + +func TestEnhanceError_NonManagementError(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + // Regular error should be returned as-is. + regularErr := assert.AnError + enhanced := manager.EnhanceError(regularErr, "POST", "/actions/actions") + assert.Equal(t, regularErr, enhanced) +} + +func TestEnhanceError_Non400Error(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + mockErr := &mockError{ + statusCode: 404, + message: "Not Found", + } + + enhanced := manager.EnhanceError(mockErr, "GET", "/actions/actions/act_123") + // Should return the original error for non-400 errors. + assert.Equal(t, mockErr, enhanced) +} + +func TestEnhanceError_InvalidPath(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + mockErr := &mockError{ + statusCode: 400, + message: "Bad Request", + } + + // Invalid path should return the original error. + enhanced := manager.EnhanceError(mockErr, "POST", "/invalid/path") + assert.Equal(t, mockErr, enhanced) +} + +func TestFormatSchemaInfo(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + // Get a real operation and schema. + operation, err := FindOperation(manager.doc, "POST", "/actions/actions") + require.NoError(t, err) + + requestSchema := GetRequestSchema(operation) + require.NotNil(t, requestSchema) + require.NotNil(t, requestSchema.Value) + + schemaInfo := formatSchemaInfo(requestSchema.Value, operation) + + // Verify the formatted output contains expected elements. + assert.Contains(t, schemaInfo, "Expected Request Schema") + assert.Contains(t, schemaInfo, "Required fields") + assert.Contains(t, schemaInfo, "name") + assert.Contains(t, schemaInfo, "supported_triggers") + assert.Contains(t, schemaInfo, "Optional fields") +} + +func TestEnhanceError_MultipleOperations(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + tests := []struct { + name string + method string + path string + shouldEnhance bool + requiredFields []string + }{ + { + name: "POST actions", + method: "POST", + path: "/actions/actions", + shouldEnhance: true, + requiredFields: []string{"name", "supported_triggers"}, + }, + { + name: "PATCH actions", + method: "PATCH", + path: "/actions/actions/{id}", + shouldEnhance: true, + requiredFields: []string{}, // PATCH typically has no required fields. + }, + { + name: "GET users", + method: "GET", + path: "/users", + shouldEnhance: false, // GET has no request body. + requiredFields: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockErr := &mockError{ + statusCode: 400, + message: "Bad Request", + } + + enhanced := manager.EnhanceError(mockErr, tt.method, tt.path) + require.NotNil(t, enhanced) + + enhancedMsg := enhanced.Error() + + if tt.shouldEnhance { + // Should have schema info. + hasSchemaInfo := enhanced != mockErr + if hasSchemaInfo { + assert.Contains(t, enhancedMsg, "Expected Request Schema") + + for _, field := range tt.requiredFields { + if len(tt.requiredFields) > 0 { + assert.Contains(t, enhancedMsg, field) + } + } + } + } + }) + } +} diff --git a/internal/openapi/schema.go b/internal/openapi/schema.go new file mode 100644 index 000000000..eecb4f18e --- /dev/null +++ b/internal/openapi/schema.go @@ -0,0 +1,216 @@ +package openapi + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/auth0/auth0-cli/internal/buildinfo" +) + +const ( + // SchemaURL is the URL to the Auth0 Management API OpenAPI schema. + SchemaURL = "https://auth0.com/docs/oas/management/v2/management-api-oas.json" + + // CacheTTL is how long to cache the schema before re-fetching. + CacheTTL = 3 * 24 * time.Hour + + // Bound the schema fetch so a slow or unreachable host cannot hang the CLI. + 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). +var schemaHTTPClient = &http.Client{Timeout: schemaHTTPTimeout} + +var ( + globalDoc *openapi3.T + cachedAt time.Time +) + +// GetDoc returns the OpenAPI document, serving a fresh copy (in-memory or on-disk, +// "/actions/actions". +func ExtractPathFromURL(fullURL string) string { + // Remove the base URL part - use the last occurrence of /api/v2. + parts := strings.Split(fullURL, "/api/v2") + if len(parts) < 2 { + return "" + } + // Take the last part (in case /api/v2 appears multiple times). + return parts[len(parts)-1] +} diff --git a/internal/openapi/schema_manager.go b/internal/openapi/schema_manager.go new file mode 100644 index 000000000..ace186b6c --- /dev/null +++ b/internal/openapi/schema_manager.go @@ -0,0 +1,408 @@ +package openapi + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// sortedPropertyNames returns a schema's property names in deterministic +// (alphabetical) order. Go randomizes map iteration, so ranging Properties +// directly would render fields in a different order on every run. +func sortedPropertyNames(props openapi3.Schemas) []string { + names := make([]string, 0, len(props)) + for name := range props { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// SchemaManager provides centralized access to OpenAPI schemas. +// It loads the schema once and provides methods to inspect and validate requests. +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. +func NewSchemaManager() (*SchemaManager, error) { + doc, err := GetDoc() + if err != nil { + return nil, err + } + return &SchemaManager{doc: doc}, nil +} + +// GetOperationSchema returns the schema information for an operation. +func (sm *SchemaManager) GetOperationSchema(method, path string) (*OperationSchema, error) { + operation, err := FindOperation(sm.doc, method, path) + if err != nil { + return nil, err + } + + result := &OperationSchema{ + OperationID: operation.OperationID, + Summary: operation.Summary, + Description: operation.Description, + Method: strings.ToUpper(method), + 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 + } + + 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 +} + +// FormatAsJSON formats the schema as JSON for display. +func (os *OperationSchema) FormatAsJSON() (string, error) { + output := map[string]interface{}{ + "operation_id": os.OperationID, + "summary": os.Summary, + "description": os.Description, + "method": os.Method, + "path": os.Path, + } + + if os.RequestSchema != nil { + output["request_schema"] = schemaToMap(os.RequestSchema) + } + + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +// FormatAsText formats the schema as human-readable text. +func (os *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) + } + sb.WriteString("\n") + + if os.RequestSchema != nil { + sb.WriteString("Request Payload:\n") + sb.WriteString(strings.Repeat("=", 80)) + sb.WriteString("\n\n") + sb.WriteString(formatSchema(os.RequestSchema, "")) + } else { + sb.WriteString("No request body required for this operation.\n") + } + + return sb.String() +} + +// ValidateRequest validates a request using openapi3filter. +func (sm *SchemaManager) ValidateRequest(method, path string, body []byte) (*ValidationResult, error) { + result := &ValidationResult{ + Valid: true, + Errors: []string{}, + } + + operation, err := FindOperation(sm.doc, method, path) + if err != nil { + return nil, fmt.Errorf("operation not found: %w", err) + } + + requestSchema := GetRequestSchema(operation) + if requestSchema == nil || requestSchema.Value == nil { + // No schema to validate against. + return result, nil + } + + // Parse the JSON body. + var data interface{} + if err := json.Unmarshal(body, &data); err != nil { + result.Valid = false + result.Errors = append(result.Errors, fmt.Sprintf("Invalid JSON: %v", err)) + return result, nil + } + + // Validate against schema. MultiErrors collects every validation failure + // instead of stopping at the first, so the caller sees all issues at once. + if err := requestSchema.Value.VisitJSON(data, openapi3.MultiErrors()); err != nil { + result.Valid = false + result.Errors = append(result.Errors, formatValidationError(err)...) + return result, nil + } + + return result, nil +} + +// ValidationResult contains the result of schema validation. +type ValidationResult struct { + Valid bool + Errors []string +} + +// formatValidationError turns a kin-openapi validation error into concise messages, +// reading SchemaError's structured fields so raw "$ref" entries never leak. +func formatValidationError(err error) []string { + var messages []string + + var multiErr openapi3.MultiError + if errors.As(err, &multiErr) { + for _, e := range multiErr { + messages = append(messages, formatValidationError(e)...) + } + return messages + } + + var schemaErr *openapi3.SchemaError + if errors.As(err, &schemaErr) { + location := jsonPath(schemaErr.JSONPointer()) + reason := schemaErr.Reason + if reason == "" { + reason = fmt.Sprintf("does not match schema constraint %q", schemaErr.SchemaField) + } + return []string{fmt.Sprintf("%s: %s", location, reason)} + } + + return []string{err.Error()} +} + +// jsonPath renders JSON Pointer segments as a JSONPath-style query, e.g. +// ["supported_triggers","0","id"] → "supported_triggers[0].id" ("payload" if empty). +func jsonPath(segments []string) string { + if len(segments) == 0 { + return "payload" + } + var sb strings.Builder + for i, seg := range segments { + switch { + case isArrayIndex(seg): + fmt.Fprintf(&sb, "[%s]", seg) + case isSimpleIdentifier(seg): + if i > 0 { + sb.WriteByte('.') + } + sb.WriteString(seg) + default: + // Keys with dots, spaces, etc. use bracket-quoted notation. + fmt.Fprintf(&sb, "[%q]", seg) + } + } + return sb.String() +} + +// isArrayIndex reports whether seg is a non-negative integer (an array index). +func isArrayIndex(seg string) bool { + if seg == "" { + return false + } + for _, r := range seg { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// isSimpleIdentifier reports whether seg can be written with dot notation +// (letters, digits, underscores; not starting with a digit). +func isSimpleIdentifier(seg string) bool { + if seg == "" { + return false + } + for i, r := range seg { + isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' + isDigit := r >= '0' && r <= '9' + if i == 0 && !isLetter { + return false + } + if !isLetter && !isDigit { + return false + } + } + return true +} + +// schemaToMap converts an OpenAPI schema to a map for JSON serialization. +func schemaToMap(schema *openapi3.Schema) map[string]interface{} { + result := make(map[string]interface{}) + + if schema.Type != nil { + result["type"] = schema.Type.Slice() + } + + if schema.Description != "" { + result["description"] = schema.Description + } + + if len(schema.Required) > 0 { + result["required"] = schema.Required + } + + if len(schema.Properties) > 0 { + props := make(map[string]interface{}) + for name, propRef := range schema.Properties { + if propRef.Value != nil { + props[name] = schemaToMap(propRef.Value) + } + } + result["properties"] = props + } + + if schema.Items != nil && schema.Items.Value != nil { + result["items"] = schemaToMap(schema.Items.Value) + } + + if len(schema.Enum) > 0 { + result["enum"] = schema.Enum + } + + if schema.Default != nil { + result["default"] = schema.Default + } + + if schema.MinLength != 0 { + result["minLength"] = schema.MinLength + } + + if schema.MaxLength != nil { + result["maxLength"] = *schema.MaxLength + } + + if schema.Pattern != "" { + result["pattern"] = schema.Pattern + } + + if schema.MinItems != 0 { + result["minItems"] = schema.MinItems + } + + if schema.MaxItems != nil { + result["maxItems"] = *schema.MaxItems + } + + return result +} + +// formatSchema formats a schema as human-readable text. +func formatSchema(schema *openapi3.Schema, indent string) string { + var sb strings.Builder + + if schema.Type != nil && schema.Type.Is("object") { + // Required fields. + if len(schema.Required) > 0 { + fmt.Fprintf(&sb, "%sRequired fields:\n", indent) + for _, fieldName := range schema.Required { + if propRef, ok := schema.Properties[fieldName]; ok && propRef.Value != nil { + sb.WriteString(formatField(fieldName, propRef.Value, indent+" ")) + } + } + sb.WriteString("\n") + } + + // Optional fields. + optionalFields := []string{} + for fieldName := range schema.Properties { + isRequired := false + for _, req := range schema.Required { + if req == fieldName { + isRequired = true + break + } + } + if !isRequired { + optionalFields = append(optionalFields, fieldName) + } + } + sort.Strings(optionalFields) + + if len(optionalFields) > 0 { + fmt.Fprintf(&sb, "%sOptional fields:\n", indent) + for _, fieldName := range optionalFields { + if propRef, ok := schema.Properties[fieldName]; ok && propRef.Value != nil { + sb.WriteString(formatField(fieldName, propRef.Value, indent+" ")) + } + } + } + } else { + // Non-object type. + if schema.Type != nil { + types := schema.Type.Slice() + fmt.Fprintf(&sb, "%sType: %s\n", indent, strings.Join(types, "|")) + } + if schema.Description != "" { + fmt.Fprintf(&sb, "%sDescription: %s\n", indent, schema.Description) + } + } + + return sb.String() +} + +// formatField formats a single field with its type and description. +func formatField(name string, schema *openapi3.Schema, indent string) string { + var sb strings.Builder + + fmt.Fprintf(&sb, "%s- %s", indent, name) + + if schema.Type != nil { + types := schema.Type.Slice() + fmt.Fprintf(&sb, " (%s)", strings.Join(types, "|")) + } + + if schema.Description != "" { + fmt.Fprintf(&sb, ": %s", schema.Description) + } + + if len(schema.Enum) > 0 { + fmt.Fprintf(&sb, " [possible values: %v]", schema.Enum) + } + + if schema.Default != nil { + fmt.Fprintf(&sb, " (default: %v)", schema.Default) + } + + sb.WriteString("\n") + + // If field is an object with properties, show nested structure. + if schema.Type != nil && schema.Type.Is("object") && len(schema.Properties) > 0 { + fmt.Fprintf(&sb, "%s Properties:\n", indent) + for _, propName := range sortedPropertyNames(schema.Properties) { + if propRef := schema.Properties[propName]; propRef.Value != nil { + sb.WriteString(formatField(propName, propRef.Value, indent+" ")) + } + } + } + + // If field is an array with object items, show item structure. + if schema.Type != nil && schema.Type.Is("array") && schema.Items != nil && schema.Items.Value != nil { + itemSchema := schema.Items.Value + if itemSchema.Type != nil && itemSchema.Type.Is("object") && len(itemSchema.Properties) > 0 { + fmt.Fprintf(&sb, "%s Item properties:\n", indent) + for _, propName := range sortedPropertyNames(itemSchema.Properties) { + if propRef := itemSchema.Properties[propName]; propRef.Value != nil { + sb.WriteString(formatField(propName, propRef.Value, indent+" ")) + } + } + } + } + + return sb.String() +} diff --git a/internal/openapi/schema_manager_test.go b/internal/openapi/schema_manager_test.go new file mode 100644 index 000000000..c4785bd03 --- /dev/null +++ b/internal/openapi/schema_manager_test.go @@ -0,0 +1,337 @@ +package openapi + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSchemaManager(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + require.NotNil(t, manager) + require.NotNil(t, manager.doc) +} + +func TestGetOperationSchema(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + tests := []struct { + name string + method string + path string + expectError bool + expectRequestBody bool + }{ + { + name: "POST /actions/actions", + method: "POST", + path: "/actions/actions", + expectError: false, + expectRequestBody: true, + }, + { + name: "GET /actions/actions", + method: "GET", + path: "/actions/actions", + expectError: false, + expectRequestBody: false, // GET has no request body. + }, + { + name: "PATCH /actions/actions/{id}", + method: "PATCH", + path: "/actions/actions/{id}", + expectError: false, + expectRequestBody: true, + }, + { + name: "Invalid path", + method: "GET", + path: "/invalid/path", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opSchema, err := manager.GetOperationSchema(tt.method, tt.path) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, opSchema) + } else { + require.NoError(t, err) + require.NotNil(t, opSchema) + + assert.Equal(t, tt.method, opSchema.Method) + assert.Equal(t, tt.path, opSchema.Path) + assert.NotEmpty(t, opSchema.OperationID) + assert.NotEmpty(t, opSchema.Summary) + + if tt.expectRequestBody { + assert.NotNil(t, opSchema.RequestSchema) + } + } + }) + } +} + +func TestFormatAsJSON(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + opSchema, err := manager.GetOperationSchema("POST", "/actions/actions") + require.NoError(t, err) + + jsonOutput, err := opSchema.FormatAsJSON() + require.NoError(t, err) + assert.NotEmpty(t, jsonOutput) + + // Verify it's valid JSON. + assert.Contains(t, jsonOutput, "operation_id") + assert.Contains(t, jsonOutput, "summary") + assert.Contains(t, jsonOutput, "request_schema") +} + +func TestFormatAsText(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + opSchema, err := manager.GetOperationSchema("POST", "/actions/actions") + require.NoError(t, err) + + textOutput := opSchema.FormatAsText() + assert.NotEmpty(t, textOutput) + + // Verify it contains expected sections. + assert.Contains(t, textOutput, "Operation:") + assert.Contains(t, textOutput, "Endpoint:") + assert.Contains(t, textOutput, "Request Payload:") + assert.Contains(t, textOutput, "Required fields:") + assert.Contains(t, textOutput, "name") + assert.Contains(t, textOutput, "supported_triggers") +} + +func TestValidateRequest(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + tests := []struct { + name string + method string + path string + body string + expectValid bool + }{ + { + name: "Valid action creation", + method: "POST", + path: "/actions/actions", + body: `{ + "name": "my-action", + "supported_triggers": [{"id": "post-login", "version": "v3"}], + "code": "module.exports = () => {}" + }`, + expectValid: true, + }, + { + name: "Missing required field", + method: "POST", + path: "/actions/actions", + body: `{ + "code": "module.exports = () => {}" + }`, + expectValid: false, + }, + { + name: "Invalid JSON", + method: "POST", + path: "/actions/actions", + body: `{invalid`, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := manager.ValidateRequest(tt.method, tt.path, []byte(tt.body)) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, tt.expectValid, result.Valid) + + if !tt.expectValid { + assert.NotEmpty(t, result.Errors) + } + }) + } +} + +func TestValidateRequestActionUpdatePath(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + body := []byte(`{"name": "my-action", "runtime": "node22"}`) + + // Templated path: the operation resolves and the body validates. + result, err := manager.ValidateRequest("PATCH", "/actions/actions/{id}", body) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Valid, "templated path should validate; errors: %v", result.Errors) + + // Concrete-ID path: the operation cannot be found, so ValidateRequest errors. + // This is exactly the trap that broke `auth0 actions update --data`. + _, err = manager.ValidateRequest("PATCH", "/actions/actions/act_123", body) + assert.Error(t, err, "concrete-ID path must not resolve against the templated schema") +} + +func TestValidateRequestErrorsAreResolved(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + tests := []struct { + name string + body string + wantContains string + }{ + { + name: "Wrong type on a $ref array field", + body: `{"name": "x", "supported_triggers": "not-an-array"}`, + wantContains: `supported_triggers`, + }, + { + name: "Missing required field", + body: `{"name": "x", "code": "module.exports = () => {}"}`, + wantContains: "supported_triggers", + }, + { + name: "Bad enum inside a nested $ref item — JSONPath location", + body: `{"name": "x", "supported_triggers": [{"id": "not-a-trigger", "version": "v3"}]}`, + wantContains: `supported_triggers[0].id`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := manager.ValidateRequest("POST", "/actions/actions", []byte(tt.body)) + require.NoError(t, err) + require.False(t, result.Valid) + require.NotEmpty(t, result.Errors) + + joined := strings.Join(result.Errors, "\n") + // The raw kin-openapi error dumps the schema with unresolved + // "$ref" entries; our formatter must never surface those. + assert.NotContains(t, joined, "$ref") + assert.NotContains(t, joined, "#/components/schemas") + assert.Contains(t, joined, tt.wantContains) + }) + } +} + +func TestJSONPath(t *testing.T) { + tests := []struct { + name string + segments []string + want string + }{ + {name: "root", segments: nil, want: "payload"}, + {name: "single key", segments: []string{"name"}, want: "name"}, + {name: "nested keys", segments: []string{"config", "url"}, want: "config.url"}, + {name: "array index", segments: []string{"supported_triggers", "0", "id"}, want: "supported_triggers[0].id"}, + {name: "index then object", segments: []string{"items", "2", "meta", "key"}, want: "items[2].meta.key"}, + {name: "non-identifier key", segments: []string{"a.b"}, want: `["a.b"]`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, jsonPath(tt.segments)) + }) + } +} + +func TestValidateRequestReportsAllErrors(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + // An empty body is missing both required fields; validation must report + // all of them, not stop at the first. + result, err := manager.ValidateRequest("POST", "/actions/actions", []byte(`{}`)) + require.NoError(t, err) + require.False(t, result.Valid) + + assert.GreaterOrEqual(t, len(result.Errors), 2) + joined := strings.Join(result.Errors, "\n") + assert.Contains(t, joined, "name") + assert.Contains(t, joined, "supported_triggers") +} + +func TestSchemaToMap(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + opSchema, err := manager.GetOperationSchema("POST", "/actions/actions") + require.NoError(t, err) + + schemaMap := schemaToMap(opSchema.RequestSchema) + assert.NotEmpty(t, schemaMap) + + // Should have required fields. + required, ok := schemaMap["required"].([]string) + assert.True(t, ok) + assert.Contains(t, required, "name") + assert.Contains(t, required, "supported_triggers") + + // Should have properties. + props, ok := schemaMap["properties"].(map[string]interface{}) + assert.True(t, ok) + assert.NotEmpty(t, props) + + // Check a specific property. + nameProp, ok := props["name"].(map[string]interface{}) + assert.True(t, ok) + assert.NotNil(t, nameProp["type"]) + assert.NotNil(t, nameProp["description"]) +} + +func TestFormatSchema(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + opSchema, err := manager.GetOperationSchema("POST", "/actions/actions") + require.NoError(t, err) + + formatted := formatSchema(opSchema.RequestSchema, "") + assert.NotEmpty(t, formatted) + + // Should contain required and optional sections. + assert.Contains(t, formatted, "Required fields:") + assert.Contains(t, formatted, "Optional fields:") + + // Should contain field names. + assert.Contains(t, formatted, "name") + assert.Contains(t, formatted, "supported_triggers") + assert.Contains(t, formatted, "code") +} + +func TestFormatField(t *testing.T) { + manager, err := NewSchemaManager() + require.NoError(t, err) + + opSchema, err := manager.GetOperationSchema("POST", "/actions/actions") + require.NoError(t, err) + + nameField := opSchema.RequestSchema.Properties["name"] + require.NotNil(t, nameField) + require.NotNil(t, nameField.Value) + + formatted := formatField("name", nameField.Value, " ") + assert.NotEmpty(t, formatted) + + // Should contain field name and type. + assert.Contains(t, formatted, "name") + assert.Contains(t, formatted, "string") + assert.Contains(t, formatted, "The name of an action") +} diff --git a/internal/openapi/schema_test.go b/internal/openapi/schema_test.go new file mode 100644 index 000000000..379e11e3c --- /dev/null +++ b/internal/openapi/schema_test.go @@ -0,0 +1,140 @@ +package openapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDoc(t *testing.T) { + doc, err := GetDoc() + require.NoError(t, err) + require.NotNil(t, doc) + + assert.NotEmpty(t, doc.OpenAPI) + assert.NotNil(t, doc.Paths) + assert.NotNil(t, doc.Components) + assert.NotNil(t, doc.Components.Schemas) +} + +func TestFindOperation(t *testing.T) { + doc, err := GetDoc() + require.NoError(t, err) + + tests := []struct { + name string + method string + path string + expectError bool + expectOperationID string + }{ + { + name: "POST actions/actions", + method: "POST", + path: "/actions/actions", + expectError: false, + expectOperationID: "post_action", + }, + { + name: "GET actions/actions", + method: "GET", + path: "/actions/actions", + expectError: false, + expectOperationID: "get_actions", + }, + { + name: "Invalid path", + method: "GET", + path: "/invalid/path", + expectError: true, + }, + { + name: "Invalid method", + method: "INVALID", + path: "/actions/actions", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + operation, err := FindOperation(doc, tt.method, tt.path) + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, operation) + } else { + require.NoError(t, err) + require.NotNil(t, operation) + assert.Equal(t, tt.expectOperationID, operation.OperationID) + } + }) + } +} + +func TestGetRequestSchema(t *testing.T) { + doc, err := GetDoc() + require.NoError(t, err) + + operation, err := FindOperation(doc, "POST", "/actions/actions") + require.NoError(t, err) + + requestSchema := GetRequestSchema(operation) + require.NotNil(t, requestSchema) + require.NotNil(t, requestSchema.Value) + + // Verify it has the expected required fields. + assert.Contains(t, requestSchema.Value.Required, "name") + assert.Contains(t, requestSchema.Value.Required, "supported_triggers") +} + +func TestExtractPathFromURL(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + { + name: "Full URL with tenant", + url: "https://tenant.auth0.com/api/v2/actions/actions", + expected: "/actions/actions", + }, + { + name: "URL with path parameters", + url: "https://tenant.auth0.com/api/v2/actions/actions/act_123", + expected: "/actions/actions/act_123", + }, + { + name: "URL without api/v2", + url: "https://tenant.auth0.com/some/path", + expected: "", + }, + { + name: "URL with query parameters", + url: "https://tenant.auth0.com/api/v2/actions/actions?page=1", + expected: "/actions/actions?page=1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractPathFromURL(tt.url) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCaching(t *testing.T) { + // First call - should fetch or load from cache. + doc1, err := GetDoc() + require.NoError(t, err) + require.NotNil(t, doc1) + + // Second call - should return cached doc. + doc2, err := GetDoc() + require.NoError(t, err) + require.NotNil(t, doc2) + + // Should be the same instance (pointer equality). + assert.Equal(t, doc1, doc2) +}