Skip to content
Merged
6 changes: 6 additions & 0 deletions cmd/request/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
57 changes: 51 additions & 6 deletions cmd/request/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
}
20 changes: 19 additions & 1 deletion internal/commands/request/request_internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions internal/configuration/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -87,6 +88,7 @@ func Options() []Option {

RequestDataOption,
RequestDataRawOption,
RequestHeaderOption,
RequestHTTPMethodOption,
RequestServiceOption,
RequestAccessTokenOption,
Expand Down Expand Up @@ -170,6 +172,7 @@ var (
var (
RequestDataOption Option
RequestDataRawOption Option
RequestHeaderOption Option
RequestHTTPMethodOption Option
RequestServiceOption Option
RequestAccessTokenOption Option
Expand Down
26 changes: 26 additions & 0 deletions internal/configuration/request/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
func InitRequestOptions() {
initDataOption()
initDataRawOption()
initHeaderOption()
initHTTPMethodOption()
initServiceOption()
initAccessTokenOption()
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 2 additions & 4 deletions internal/customtypes/export_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -62,7 +60,7 @@ func (es *ExportServices) Set(services string) error {

slices.Sort(returnServiceList)

*es = ExportServices(returnServiceList)
*es = returnServiceList

return nil
}
Expand Down
91 changes: 91 additions & 0 deletions internal/customtypes/headers.go
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 40 additions & 0 deletions internal/customtypes/headers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 3 additions & 7 deletions internal/customtypes/string_slice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading