diff --git a/cmd/request/request.go b/cmd/request/request.go index 74e86a35..5a614c2a 100644 --- a/cmd/request/request.go +++ b/cmd/request/request.go @@ -24,6 +24,9 @@ const ( Send a custom API request to the configured PingOne tenant, making a POST request to create a new environment with JSON data sourced from a file. pingcli request --service pingone --http-method POST --data ./my-environment.json environments + Send a custom API request to the configured PingOne tenant, making a POST request using a custom header to create users with JSON data sourced from a file. + pingcli request --service pingone --http-method POST --header "Content-Type: application/vnd.pingidentity.user.import+json" --data ./users.json environments/$MY_ENVIRONMENT_ID/users + Send a custom API request to the configured PingOne tenant, making a POST request to create a new environment using raw JSON data. pingcli request --service pingone --http-method POST --data-raw '{"name": "My environment"}' environments @@ -54,6 +57,9 @@ The command offers a cURL-like experience to interact with the Ping platform ser // --fail, -f cmd.Flags().AddFlag(options.RequestFailOption.Flag) + // --header, -r + cmd.Flags().AddFlag(options.RequestHeaderOption.Flag) + // --http-method, -m cmd.Flags().AddFlag(options.RequestHTTPMethodOption.Flag) // auto-completion diff --git a/cmd/request/request_test.go b/cmd/request/request_test.go index 12ad14b0..e7e63b8d 100644 --- a/cmd/request/request_test.go +++ b/cmd/request/request_test.go @@ -31,8 +31,8 @@ func TestRequestCmd_Execute(t *testing.T) { os.Stdout = pipeWriter err = testutils_cobra.ExecutePingcli(t, "request", - "--service", "pingone", - "--http-method", "GET", + "--"+options.RequestServiceOption.CobraParamName, "pingone", + "--"+options.RequestHTTPMethodOption.CobraParamName, "GET", fmt.Sprintf("environments/%s/populations", os.Getenv("TEST_PINGONE_ENVIRONMENT_ID")), ) testutils.CheckExpectedError(t, err, nil) @@ -95,8 +95,8 @@ func TestRequestCmd_Execute_Help(t *testing.T) { func TestRequestCmd_Execute_InvalidService(t *testing.T) { expectedErrorPattern := `^invalid argument ".*" for "-s, --service" flag: unrecognized Request Service: '.*'. Must be one of: .*$` err := testutils_cobra.ExecutePingcli(t, "request", - "--service", "invalid-service", - "--http-method", "GET", + "--"+options.RequestServiceOption.CobraParamName, "invalid-service", + "--"+options.RequestHTTPMethodOption.CobraParamName, "GET", fmt.Sprintf("environments/%s/populations", os.Getenv(options.PingOneAuthenticationWorkerEnvironmentIDOption.EnvVar)), ) testutils.CheckExpectedError(t, err, &expectedErrorPattern) @@ -106,8 +106,8 @@ func TestRequestCmd_Execute_InvalidService(t *testing.T) { func TestRequestCmd_Execute_InvalidHTTPMethod(t *testing.T) { expectedErrorPattern := `^invalid argument ".*" for "-m, --http-method" flag: unrecognized HTTP Method: '.*'. Must be one of: .*$` err := testutils_cobra.ExecutePingcli(t, "request", - "--service", "pingone", - "--http-method", "INVALID", + "--"+options.RequestServiceOption.CobraParamName, "pingone", + "--"+options.RequestHTTPMethodOption.CobraParamName, "INVALID", fmt.Sprintf("environments/%s/populations", os.Getenv(options.PingOneAuthenticationWorkerEnvironmentIDOption.EnvVar)), ) testutils.CheckExpectedError(t, err, &expectedErrorPattern) @@ -119,3 +119,48 @@ func TestRequestCmd_Execute_MissingRequiredServiceFlag(t *testing.T) { err := testutils_cobra.ExecutePingcli(t, "request", fmt.Sprintf("environments/%s/populations", os.Getenv(options.PingOneAuthenticationWorkerEnvironmentIDOption.EnvVar))) testutils.CheckExpectedError(t, err, &expectedErrorPattern) } + +// Test Request Command with Header Flag +func TestRequestCmd_Execute_HeaderFlag(t *testing.T) { + err := testutils_cobra.ExecutePingcli(t, "request", + "--"+options.RequestServiceOption.CobraParamName, "pingone", + "--"+options.RequestHTTPMethodOption.CobraParamName, "GET", + "--"+options.RequestHeaderOption.CobraParamName, "Content-Type: application/vnd.pingidentity.user.import+json", + fmt.Sprintf("environments/%s/users", os.Getenv(options.PingOneAuthenticationWorkerEnvironmentIDOption.EnvVar)), + ) + testutils.CheckExpectedError(t, err, nil) +} + +// Test Request Command with Header Flag with and without spacing +func TestRequestCmd_Execute_HeaderFlagSpacing(t *testing.T) { + err := testutils_cobra.ExecutePingcli(t, "request", + "--"+options.RequestServiceOption.CobraParamName, "pingone", + "--"+options.RequestHTTPMethodOption.CobraParamName, "GET", + "--"+options.RequestHeaderOption.CobraParamName, "Test-Header:TestValue", + "--"+options.RequestHeaderOption.CobraParamName, "Test-Header-Two:\tTestValue", + fmt.Sprintf("environments/%s/users", os.Getenv(options.PingOneAuthenticationWorkerEnvironmentIDOption.EnvVar)), + ) + testutils.CheckExpectedError(t, err, nil) +} + +// Test Request Command with invalid Header Flag +func TestRequestCmd_Execute_InvalidHeaderFlag(t *testing.T) { + expectedErrorPattern := `^invalid argument ".*" for "-r, --header" flag: failed to set Headers: Invalid header: invalid=header. Headers must be in the proper format. Expected regex pattern: .*$` + err := testutils_cobra.ExecutePingcli(t, "request", + "--"+options.RequestServiceOption.CobraParamName, "pingone", + "--"+options.RequestHeaderOption.CobraParamName, "invalid=header", + fmt.Sprintf("environments/%s/populations", os.Getenv(options.PingOneAuthenticationWorkerEnvironmentIDOption.EnvVar)), + ) + testutils.CheckExpectedError(t, err, &expectedErrorPattern) +} + +// Test Request Command with disallowed Authorization Header Flag +func TestRequestCmd_Execute_DisallowedAuthorizationFlag(t *testing.T) { + expectedErrorPattern := `^invalid argument ".*" for "-r, --header" flag: failed to set Headers: Invalid header: Authorization. Authorization header is not allowed$` + err := testutils_cobra.ExecutePingcli(t, "request", + "--"+options.RequestServiceOption.CobraParamName, "pingone", + "--"+options.RequestHeaderOption.CobraParamName, "Authorization: Bearer token", + fmt.Sprintf("environments/%s/populations", os.Getenv(options.PingOneAuthenticationWorkerEnvironmentIDOption.EnvVar)), + ) + testutils.CheckExpectedError(t, err, &expectedErrorPattern) +} diff --git a/internal/commands/request/request_internal.go b/internal/commands/request/request_internal.go index c4998388..733025b4 100644 --- a/internal/commands/request/request_internal.go +++ b/internal/commands/request/request_internal.go @@ -98,8 +98,26 @@ func runInternalPingOneRequest(uri string) (err error) { return err } + headers, err := profiles.GetOptionValue(options.RequestHeaderOption) + if err != nil { + return err + } + + requestHeaders := new(customtypes.HeaderSlice) + err = requestHeaders.Set(headers) + if err != nil { + return err + } + + requestHeaders.SetHttpRequestHeaders(req) + + // Set default content type if not provided + if req.Header.Get("Content-Type") == "" { + req.Header.Add("Content-Type", "application/json") + } + + // Set default authorization header req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) - req.Header.Add("Content-Type", "application/json") res, err := client.Do(req) if err != nil { diff --git a/internal/configuration/options/options.go b/internal/configuration/options/options.go index f81edb32..0c994d9b 100644 --- a/internal/configuration/options/options.go +++ b/internal/configuration/options/options.go @@ -15,6 +15,7 @@ type OptionType string const ( ENUM_BOOL OptionType = "ENUM_BOOL" ENUM_EXPORT_FORMAT OptionType = "ENUM_EXPORT_FORMAT" + ENUM_HEADER OptionType = "ENUM_HEADER" ENUM_INT OptionType = "ENUM_INT" ENUM_EXPORT_SERVICE_GROUP OptionType = "ENUM_EXPORT_SERVICE_GROUP" ENUM_EXPORT_SERVICES OptionType = "ENUM_EXPORT_SERVICES" @@ -87,6 +88,7 @@ func Options() []Option { RequestDataOption, RequestDataRawOption, + RequestHeaderOption, RequestHTTPMethodOption, RequestServiceOption, RequestAccessTokenOption, @@ -170,6 +172,7 @@ var ( var ( RequestDataOption Option RequestDataRawOption Option + RequestHeaderOption Option RequestHTTPMethodOption Option RequestServiceOption Option RequestAccessTokenOption Option diff --git a/internal/configuration/request/request.go b/internal/configuration/request/request.go index 982e758d..c549733b 100644 --- a/internal/configuration/request/request.go +++ b/internal/configuration/request/request.go @@ -14,6 +14,7 @@ import ( func InitRequestOptions() { initDataOption() initDataRawOption() + initHeaderOption() initHTTPMethodOption() initServiceOption() initAccessTokenOption() @@ -67,6 +68,31 @@ func initDataRawOption() { } } +func initHeaderOption() { + cobraParamName := "header" + cobraValue := new(customtypes.HeaderSlice) + defaultValue := customtypes.HeaderSlice([]customtypes.Header{}) + + options.RequestHeaderOption = options.Option{ + CobraParamName: cobraParamName, + CobraParamValue: cobraValue, + DefaultValue: &defaultValue, + EnvVar: "", // No environment variable + Flag: &pflag.Flag{ + Name: cobraParamName, + Shorthand: "r", + Usage: fmt.Sprintf( + "A custom header to send in the request." + + "\nExample: --header \"Content-Type: application/vnd.pingidentity.user.import+json\"", + ), + Value: cobraValue, + }, + Sensitive: false, + Type: options.ENUM_HEADER, + ViperKey: "", // No viper key + } +} + func initHTTPMethodOption() { cobraParamName := "http-method" cobraValue := new(customtypes.HTTPMethod) diff --git a/internal/customtypes/export_services.go b/internal/customtypes/export_services.go index b7e65666..13cb6aac 100644 --- a/internal/customtypes/export_services.go +++ b/internal/customtypes/export_services.go @@ -35,14 +35,12 @@ func (es *ExportServices) Set(services string) error { } if services == "" || services == "[]" { - *es = ExportServices([]string{}) - return nil } validServices := ExportServicesValidValues() serviceList := strings.Split(services, ",") - returnServiceList := []string{} + returnServiceList := *es for _, service := range serviceList { if !slices.ContainsFunc(validServices, func(validService string) bool { @@ -62,7 +60,7 @@ func (es *ExportServices) Set(services string) error { slices.Sort(returnServiceList) - *es = ExportServices(returnServiceList) + *es = returnServiceList return nil } diff --git a/internal/customtypes/headers.go b/internal/customtypes/headers.go new file mode 100644 index 00000000..e21b1847 --- /dev/null +++ b/internal/customtypes/headers.go @@ -0,0 +1,91 @@ +// Copyright © 2025 Ping Identity Corporation + +package customtypes + +import ( + "fmt" + "net/http" + "regexp" + "slices" + "strings" + + "github.com/spf13/pflag" +) + +type Header struct { + Key string + Value string +} + +type HeaderSlice []Header + +// Verify that the custom type satisfies the pflag.Value interface +var _ pflag.Value = (*HeaderSlice)(nil) + +func NewHeader(header string) (Header, error) { + regexPattern := `(^[^\s]+):[\t ]{0,1}(.*)$` + headerNameRegex := regexp.MustCompile(regexPattern) + matches := headerNameRegex.FindStringSubmatch(header) + if len(matches) != 3 { + return Header{}, fmt.Errorf("failed to set Headers: Invalid header: %s. Headers must be in the proper format. Expected regex pattern: %s", header, regexPattern) + } + + if matches[1] == "Authorization" { + return Header{}, fmt.Errorf("failed to set Headers: Invalid header: %s. Authorization header is not allowed", matches[1]) + } + + return Header{ + Key: matches[1], + Value: matches[2], + }, nil +} + +func (h *HeaderSlice) Set(val string) error { + if h == nil { + return fmt.Errorf("failed to set Headers value: %s. Headers is nil", val) + } + + if val == "" || val == "[]" { + return nil + } else { + valH := strings.SplitSeq(val, ",") + for header := range valH { + headerVal, err := NewHeader(header) + if err != nil { + return err + } + *h = append(*h, headerVal) + } + } + + return nil +} + +func (h HeaderSlice) SetHttpRequestHeaders(request *http.Request) { + for _, header := range h { + request.Header.Add(header.Key, header.Value) + } +} + +func (h HeaderSlice) Type() string { + return "[]string" +} + +func (h HeaderSlice) String() string { + return strings.Join(h.StringSlice(), ",") +} + +func (h HeaderSlice) StringSlice() []string { + if h == nil { + return []string{} + } + + headers := []string{} + for _, header := range h { + headers = append(headers, fmt.Sprintf("%s:%s", header.Key, header.Value)) + } + + slices.Sort(headers) + + return headers +} diff --git a/internal/customtypes/headers_test.go b/internal/customtypes/headers_test.go new file mode 100644 index 00000000..c5fc0764 --- /dev/null +++ b/internal/customtypes/headers_test.go @@ -0,0 +1,40 @@ +// Copyright © 2025 Ping Identity Corporation + +package customtypes_test + +import ( + "testing" + + "github.com/pingidentity/pingcli/internal/customtypes" + "github.com/pingidentity/pingcli/internal/testing/testutils" +) + +// Test Headers Set function +func Test_Headers_Set(t *testing.T) { + hs := new(customtypes.HeaderSlice) + + service := "key: value" + err := hs.Set(service) + if err != nil { + t.Errorf("Set returned error: %v", err) + } +} + +// Test Headers Set function with invalid value +func Test_Headers_Set_InvalidValue(t *testing.T) { + hs := new(customtypes.HeaderSlice) + + invalidValue := "invalid=value" + expectedErrorPattern := `^failed to set Headers: Invalid header: .*\. Headers must be in the proper format. Expected regex pattern: .*$` + err := hs.Set(invalidValue) + testutils.CheckExpectedError(t, err, &expectedErrorPattern) +} + +// Test Headers Set function with nil +func Test_Headers_Set_Nil(t *testing.T) { + var hs *customtypes.HeaderSlice + + expectedErrorPattern := `^failed to set Headers value: .* Headers is nil$` + err := hs.Set("key: value") + testutils.CheckExpectedError(t, err, &expectedErrorPattern) +} diff --git a/internal/customtypes/string_slice.go b/internal/customtypes/string_slice.go index 18c43083..b3caf1f1 100644 --- a/internal/customtypes/string_slice.go +++ b/internal/customtypes/string_slice.go @@ -20,10 +20,10 @@ func (ss *StringSlice) Set(val string) error { } if val == "" || val == "[]" { - *ss = StringSlice([]string{}) + return nil } else { valSs := strings.Split(val, ",") - *ss = StringSlice(valSs) + *ss = append(*ss, valSs...) } return nil @@ -34,11 +34,7 @@ func (ss StringSlice) Type() string { } func (ss StringSlice) String() string { - if ss == nil { - return "" - } - - return strings.Join(ss, ",") + return strings.Join(ss.StringSlice(), ",") } func (ss StringSlice) StringSlice() []string { diff --git a/main.go b/main.go index 11fe1e9c..023540b5 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "runtime/debug" + "slices" "github.com/pingidentity/pingcli/cmd" "github.com/pingidentity/pingcli/internal/output" @@ -40,14 +41,19 @@ func main() { os.Exit(1) } - detailedExitCodeWarnLogged, err := output.DetailedExitCodeWarnLogged() - if err != nil { - output.UserError(fmt.Sprintf("Failed to execute pingcli: %v", err), nil) - os.Exit(1) - } - if detailedExitCodeWarnLogged { - os.Exit(2) - } else { - os.Exit(0) + if !slices.Contains(os.Args, "--version") && + !slices.Contains(os.Args, "-v") && + !slices.Contains(os.Args, "--help") && + !slices.Contains(os.Args, "-h") { + detailedExitCodeWarnLogged, err := output.DetailedExitCodeWarnLogged() + if err != nil { + output.UserError(fmt.Sprintf("Failed to execute pingcli: %v", err), nil) + os.Exit(1) + } + if detailedExitCodeWarnLogged { + os.Exit(2) + } } + + os.Exit(0) }