From f953abef8b44c060bbf0d990e78589d62efd1f61 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sun, 31 Dec 2023 18:43:25 -0800 Subject: [PATCH] Resolves #413 - Add support for header groups --- README.md | 33 ++- cmd/headers.go | 130 ++++++++++++ cmd/login.go | 17 +- cmd/logout.go | 14 ++ cmd/root.go | 19 +- external/authentication/auth.go | 4 +- external/completion/completion.go | 95 ++++++--- external/completion/completion_test.go | 74 +++++++ external/completion/headers.go | 52 +++++ external/headergroups/headergroup.go | 234 ++++++++++++++++++++++ external/headergroups/headergroup_test.go | 199 ++++++++++++++++++ external/httpclient/httpclient.go | 18 +- external/resources/resources.go | 10 + external/resources/resources_schema.json | 3 +- 14 files changed, 849 insertions(+), 53 deletions(-) create mode 100644 cmd/headers.go create mode 100644 external/completion/completion_test.go create mode 100644 external/completion/headers.go create mode 100644 external/headergroups/headergroup.go create mode 100644 external/headergroups/headergroup_test.go diff --git a/README.md b/README.md index 0da1ebf..3c8d1f5 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The following is a summary of the main commands, in general you can type `epcc h | `epcc test-json [KEY] [VAL] [KEY] [VAL] ...` | Render a JSON document based on the supplied key and value pairs | #### Power User Commands + | Command | Description | |-----------------------------------------|----------------------------------------------------------------------------| | `epcc reset-store ` | Reset the store to an initial state (on a best effort basis) | @@ -67,7 +68,19 @@ The following is a summary of the main commands, in general you can type `epcc h 3. `--max-concurrency` will control the maximum number of concurrent commands that can run simultaneously. * This differs from the rate limit in that if a request takes 2 seconds, a rate limit of 3 will allow 6 requests in flight at a time, whereas `--max-concurrency` would limit you to 3. A higher value will slow down initial start time. +#### Headers + +Headers can be set in one of three ways, depending on what is most convenient +1. Via the `-H` argument. + * This header will be one time only. +2. Via the `EPCC_CLI_HTTP_HEADER_0` environment variable. + * This header will be always be set. +3. Via the `epcc header set` + * These headers will be set in the current profile and will stay until unset. You can see what headers are set with `epcc headers status` + * Headers set this way support aliases. + * You can also additionally group headers into groups with `--group` and then clear all headers with `epcc headers clear ` + ### Configuration #### Via Prompts @@ -78,16 +91,16 @@ Run the `epcc configure` and it will prompt you for the required settings, when The following environment variables can be set up to control which environment and store to use with the EPCC CLI. -| Environment Variable | Description | -|------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| EPCC_API_BASE_URL | This is the API base URL which can be retrieved via CM. | -| EPCC_BETA_API_FEATURES | This variable allows you to set [Beta Headers](https://documentation.elasticpath.com/commerce-cloud/docs/api/basics/api-contract.html#beta-apis) for all API calls. | -| EPCC_CLI_HTTP_HEADER_**N** | Setting any environment variable like this (where N is a number) will cause it's value to be parsed and added to all HTTP headers (e.g., `EPCC_CLI_HTTP_HEADER_0=Cache-Control: no-cache` will add `Cache-Control: no-cache` as a header). FYI, the surprising syntax is due to different encoding rules. | -| EPCC_CLI_SUPPRESS_NO_AUTH_MESSAGES | This will supress warning messages about not being authenticated or logged out | -| EPCC_CLIENT_ID | This is the Client ID which can be retrieved via CM. | -| EPCC_CLIENT_SECRET | This is the Client Secret which can be retrieved via CM. | -| EPCC_PROFILE | A profile name that allows for an independent session and isolation (e.g., distinct histories) | -| EPCC_RUNBOOK_DIRECTORY | A directory that will be scanned for runbook, a runbook ends with `.epcc.yml` | +| Environment Variable | Description | +|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| EPCC_API_BASE_URL | This is the API base URL which can be retrieved via CM. | +| EPCC_BETA_API_FEATURES | This variable allows you to set [Beta Headers](https://documentation.elasticpath.com/commerce-cloud/docs/api/basics/api-contract.html#beta-apis) for all API calls. | +| EPCC_CLI_HTTP_HEADER_**N** | Setting any environment variable like this (where N is a number) will cause it's value to be parsed and added to all HTTP headers (e.g., `EPCC_CLI_HTTP_HEADER_0=Cache-Control: no-cache` will add `Cache-Control: no-cache` as a header). FYI, the surprising syntax is due to different encoding rules. You can also specify headers using `-H` or `epcc headers` | +| EPCC_CLI_SUPPRESS_NO_AUTH_MESSAGES | This will supress warning messages about not being authenticated or logged out | +| EPCC_CLIENT_ID | This is the Client ID which can be retrieved via CM. | +| EPCC_CLIENT_SECRET | This is the Client Secret which can be retrieved via CM. | +| EPCC_PROFILE | A profile name that allows for an independent session and isolation (e.g., distinct histories) | +| EPCC_RUNBOOK_DIRECTORY | A directory that will be scanned for runbook, a runbook ends with `.epcc.yml` | It is recommended to set EPCC_API_BASE_URL, EPCC_CLIENT_ID, and EPCC_CLIENT_SECRET to be able to interact with most things in the CLI. diff --git a/cmd/headers.go b/cmd/headers.go new file mode 100644 index 0000000..4cbf057 --- /dev/null +++ b/cmd/headers.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "fmt" + "github.com/elasticpath/epcc-cli/external/completion" + "github.com/elasticpath/epcc-cli/external/headergroups" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func NewHeadersCommand(parentCmd *cobra.Command) func() { + + var headersCmd = &cobra.Command{ + Use: "headers", + Short: "Set headers that should be used on all subsequent calls", + SilenceUsage: true, + } + + parentCmd.AddCommand(headersCmd) + + var setGroup = "default" + var delGroup = "default" + resetFunc := func() { + setGroup = "default" + delGroup = "default" + } + + var setHeaderCmd = &cobra.Command{ + Use: "set [HEADER_KEY] [HEADER_VALUE] ...", + Short: "Set a header to be used on all subsequent requests", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + + if len(args)%2 == 0 { + return completion.Complete(completion.Request{ + Type: completion.CompleteHeaderKey, + ToComplete: toComplete, + }) + } else { + return completion.Complete(completion.Request{ + Type: completion.CompleteHeaderValue, + // Get the second last value + Header: args[len(args)-1], + ToComplete: toComplete, + }) + } + + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args)%2 != 0 { + return fmt.Errorf("Invalid number of arguments received, should be an even number of key and values: %d", len(args)) + } + + for i := 0; i < len(args); i += 2 { + headergroups.AddHeaderToGroup(setGroup, args[i], args[i+1]) + } + + return nil + }, + } + + var delHeaderCmd = &cobra.Command{ + Use: "delete HEADER_KEY...", + Short: "Deletes a header from a group", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + results, compDir := completion.Complete(completion.Request{ + Type: completion.CompleteHeaderKey, + ToComplete: toComplete, + }) + + for h, _ := range headergroups.GetAllHeaders() { + results = append(results, h) + } + + return results, compDir + }, + + RunE: func(cmd *cobra.Command, args []string) error { + for _, headerKey := range args { + headergroups.RemoveHeaderFromGroup(delGroup, headerKey) + } + return nil + }, + } + + var statusCmd = &cobra.Command{ + Use: "status", + Short: "Displays the current headers that are set, and the header groups that are in use.", + RunE: func(cmd *cobra.Command, args []string) error { + hgs := headergroups.GetAllHeaderGroups() + + for _, hg := range hgs { + log.Infof("We are using a header group: %s", hg) + } + + for k, v := range headergroups.GetAllHeaders() { + log.Infof("Using header %s: %s", k, v) + } + + log.Infof("Header information stored in %v", headergroups.GetHeaderGroupPath()) + return nil + }, + } + + var clearGroupCmd = &cobra.Command{ + Use: "clear [GROUP_NAME]", + Short: "Clears a header group", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return headergroups.GetAllHeaderGroups(), cobra.ShellCompDirectiveNoFileComp + }, + + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("expected exactly one argument, the group name, got %d", len(args)) + } + + headergroups.RemoveHeaderGroup(args[0]) + return nil + }, + } + + setHeaderCmd.PersistentFlags().StringVar(&setGroup, "group", "default", "Stores the header with a group (so that you can easily clear them)") + delHeaderCmd.PersistentFlags().StringVar(&delGroup, "group", "default", "Removes the header from within a group") + headersCmd.AddCommand(setHeaderCmd) + headersCmd.AddCommand(delHeaderCmd) + headersCmd.AddCommand(statusCmd) + headersCmd.AddCommand(clearGroupCmd) + + return resetFunc + +} diff --git a/cmd/login.go b/cmd/login.go index e862a43..52742ce 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -9,6 +9,7 @@ import ( "github.com/elasticpath/epcc-cli/external/authentication" "github.com/elasticpath/epcc-cli/external/browser" "github.com/elasticpath/epcc-cli/external/completion" + "github.com/elasticpath/epcc-cli/external/headergroups" "github.com/elasticpath/epcc-cli/external/httpclient" "github.com/elasticpath/epcc-cli/external/json" "github.com/elasticpath/epcc-cli/external/resources" @@ -24,7 +25,7 @@ const ( ClientSecret = "client_secret" ) -var loginCmd = &cobra.Command{ +var LoginCmd = &cobra.Command{ Use: "login", Short: "Login to the API via client_credentials, implicit, customer or account management tokens.", SilenceUsage: false, @@ -105,6 +106,16 @@ var loginInfo = &cobra.Command{ } + hgs := headergroups.GetAllHeaderGroups() + + for _, hg := range hgs { + log.Infof("We are using a header group: %s", hg) + } + + for k, v := range headergroups.GetAllHeaders() { + log.Infof("Using header %s: %s", k, v) + } + log.Infof("All tokens are stored in %s", authentication.GetAuthenticationCacheDirectory()) return nil @@ -208,7 +219,7 @@ var loginClientCredentials = &cobra.Command{ } } - token, err := authentication.GetAuthenticationToken(false, &values) + token, err := authentication.GetAuthenticationToken(false, &values, true) if err != nil { return err @@ -259,7 +270,7 @@ var loginImplicit = &cobra.Command{ values.Set(k, args[i+1]) } - token, err := authentication.GetAuthenticationToken(false, &values) + token, err := authentication.GetAuthenticationToken(false, &values, true) if err != nil { return err diff --git a/cmd/logout.go b/cmd/logout.go index b31e02b..7a8aa9c 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/elasticpath/epcc-cli/external/authentication" + "github.com/elasticpath/epcc-cli/external/headergroups" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -73,3 +74,16 @@ var logoutAccountManagement = &cobra.Command{ return nil }, } + +var LogoutHeaders = &cobra.Command{ + Use: "headers", + Short: "Clear all headers that are persisted in the profile", + RunE: func(cmd *cobra.Command, args []string) error { + for k, v := range headergroups.GetAllHeaders() { + log.Infof("Unsetting: %s = %s", k, v) + } + + headergroups.ClearAllHeaderGroups() + return nil + }, +} diff --git a/cmd/root.go b/cmd/root.go index 92ddc6f..6b14ae3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "github.com/elasticpath/epcc-cli/config" "github.com/elasticpath/epcc-cli/external/aliases" "github.com/elasticpath/epcc-cli/external/clictx" + "github.com/elasticpath/epcc-cli/external/headergroups" "github.com/elasticpath/epcc-cli/external/httpclient" "github.com/elasticpath/epcc-cli/external/logger" "github.com/elasticpath/epcc-cli/external/misc" @@ -84,7 +85,7 @@ func InitializeCmd() { resourceListCommand, aliasesCmd, configure, - loginCmd, + LoginCmd, logoutCmd, ResetStore, runbookGlobalCmd, @@ -120,16 +121,19 @@ func InitializeCmd() { aliasesCmd.AddCommand(aliasListCmd, aliasClearCmd) - loginCmd.AddCommand(loginClientCredentials) - loginCmd.AddCommand(loginImplicit) - loginCmd.AddCommand(loginInfo) - loginCmd.AddCommand(loginDocs) - loginCmd.AddCommand(loginCustomer) - loginCmd.AddCommand(loginAccountManagement) + LoginCmd.AddCommand(loginClientCredentials) + LoginCmd.AddCommand(loginImplicit) + LoginCmd.AddCommand(loginInfo) + LoginCmd.AddCommand(loginDocs) + LoginCmd.AddCommand(loginCustomer) + LoginCmd.AddCommand(loginAccountManagement) logoutCmd.AddCommand(logoutBearer) logoutCmd.AddCommand(logoutCustomer) logoutCmd.AddCommand(logoutAccountManagement) + logoutCmd.AddCommand(LogoutHeaders) + + NewHeadersCommand(RootCmd) } // If there is a log level argument, we will set it much earlier on a dummy command @@ -251,6 +255,7 @@ func Execute() { httpclient.LogStats() aliases.FlushAliases() + headergroups.FlushHeaderGroups() if exit { os.Exit(3) diff --git a/external/authentication/auth.go b/external/authentication/auth.go index 6fbfc9c..40112be 100644 --- a/external/authentication/auth.go +++ b/external/authentication/auth.go @@ -51,7 +51,7 @@ func AddPostAuthHook(f func(r *http.Request, s *http.Response)) { defer getTokenMutex.Unlock() postAuthHook = append(postAuthHook, f) } -func GetAuthenticationToken(useTokenFromProfileDir bool, valuesOverride *url.Values) (*ApiTokenResponse, error) { +func GetAuthenticationToken(useTokenFromProfileDir bool, valuesOverride *url.Values, warnOnNoAuthentication bool) (*ApiTokenResponse, error) { if useTokenFromProfileDir { bearerToken.Store(GetApiToken()) @@ -98,7 +98,7 @@ func GetAuthenticationToken(useTokenFromProfileDir bool, valuesOverride *url.Val defer noTokenWarningMutex.Unlock() if noTokenWarningMessageLogged == false { noTokenWarningMessageLogged = true - if !env.EPCC_CLI_SUPPRESS_NO_AUTH_MESSAGES { + if !env.EPCC_CLI_SUPPRESS_NO_AUTH_MESSAGES && warnOnNoAuthentication { log.Warn("No client id set in profile or env var, no authentication will be used for API request. To get started, set the EPCC_CLIENT_ID and (optionally) EPCC_CLIENT_SECRET environment variables") } diff --git a/external/completion/completion.go b/external/completion/completion.go index 854b602..f0bd3e4 100644 --- a/external/completion/completion.go +++ b/external/completion/completion.go @@ -22,6 +22,11 @@ const ( CompleteLoginClientID = 512 CompleteLoginClientSecret = 1024 CompleteLoginAccountManagementKey = 2048 + + CompleteHeaderKey = 4096 + CompleteHeaderValue = 8192 + + CompleteCurrency = 16384 ) const ( @@ -41,6 +46,8 @@ type Request struct { Verb int Attribute string QueryParam string + Header string + // The current string argument being completed ToComplete string NoAliases bool } @@ -256,34 +263,12 @@ func Complete(c Request) ([]string, cobra.ShellCompDirective) { } } else if attribute.Type == "CURRENCY" { - currencies := []string{"AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", - "BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", - "CAD", "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUC", "CUP", "CVE", "CZK", - "DJF", "DKK", "DOP", "DZD", - "EGP", "ERN", "ETB", "EUR", - "FJD", "FKP", - "GBP", "GEL", "GGP", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD", - "HKD", "HNL", "HRK", "HTG", "HUF", - "IDR", "ILS", "IMP", "INR", "IQD", "IRR", "ISK", - "JEP", "JMD", "JOD", "JPY", - "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", - "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", - "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN", - "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", - "OMR", - "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", - "QAR", - "RON", "RSD", "RUB", "RWF", - "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLL", "SOS", "SPL", "SRD", "STN", "SVC", "SYP", "SZL", - "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TVD", "TWD", "TZS", - "UAH", "UGX", "USD", "UYU", "UZS", - "VEF", "VND", "VUV", - "WST", - "XAF", "XCD", "XDR", "XOF", "XPF", - "YER", - "ZAR", "ZMW", "ZWD"} - - results = append(results, currencies...) + res, _ := Complete(Request{ + Type: CompleteCurrency, + }) + + results = append(results, res...) + } else if attribute.Type == "FILE" { compDir = cobra.ShellCompDirectiveFilterFileExt @@ -362,6 +347,60 @@ func Complete(c Request) ([]string, cobra.ShellCompDirective) { results = append(results, "account_id", "account_name") } + if c.Type&CompleteHeaderKey > 0 { + + headersMutex.RLock() + defer headersMutex.RUnlock() + + for k, _ := range supportedHeadersToCompletionRequest { + results = append(results, supportedHeadersOriginalCasing[k]) + } + } + + if c.Type&CompleteHeaderValue > 0 { + headersMutex.RLock() + defer headersMutex.RUnlock() + + v := supportedHeadersToCompletionRequest[strings.ToLower(c.Header)] + + if v != nil { + r, _ := Complete(*v) + + results = append(results, r...) + } + } + + if c.Type&CompleteCurrency > 0 { + currencies := []string{"AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", + "BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", + "CAD", "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUC", "CUP", "CVE", "CZK", + "DJF", "DKK", "DOP", "DZD", + "EGP", "ERN", "ETB", "EUR", + "FJD", "FKP", + "GBP", "GEL", "GGP", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD", + "HKD", "HNL", "HRK", "HTG", "HUF", + "IDR", "ILS", "IMP", "INR", "IQD", "IRR", "ISK", + "JEP", "JMD", "JOD", "JPY", + "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", + "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", + "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN", + "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", + "OMR", + "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", + "QAR", + "RON", "RSD", "RUB", "RWF", + "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLL", "SOS", "SPL", "SRD", "STN", "SVC", "SYP", "SZL", + "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TVD", "TWD", "TZS", + "UAH", "UGX", "USD", "UYU", "UZS", + "VEF", "VND", "VUV", + "WST", + "XAF", "XCD", "XDR", "XOF", "XPF", + "YER", + "ZAR", "ZMW", "ZWD"} + + results = append(results, currencies...) + } + // This is dead code since I hacked the aliases to never return spaces. newResults := make([]string, 0, len(results)) diff --git a/external/completion/completion_test.go b/external/completion/completion_test.go new file mode 100644 index 0000000..2078246 --- /dev/null +++ b/external/completion/completion_test.go @@ -0,0 +1,74 @@ +package completion + +import ( + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "testing" +) + +func TestHeaderKeyWithNilValueCompletes(t *testing.T) { + // Fixture Setup + toComplete := "EP-" + request := Request{ + Type: CompleteHeaderKey, + ToComplete: toComplete, + } + + // Exercise SUT + completions, compDir := Complete(request) + + // Verify Results + require.Equal(t, compDir, cobra.ShellCompDirectiveNoFileComp) + require.Contains(t, completions, "EP-Beta-Features") +} + +func TestHeaderKeyWithNonNilValueCompletes(t *testing.T) { + // Fixture Setup + toComplete := "X-Moltin" + request := Request{ + Type: CompleteHeaderKey, + ToComplete: toComplete, + } + + // Exercise SUT + completions, compDir := Complete(request) + + // Verify Results + require.Equal(t, compDir, cobra.ShellCompDirectiveNoFileComp) + require.Contains(t, completions, "X-Moltin-Currency") +} + +func TestHeaderValueWithNilValueCompletesWithoutPanicing(t *testing.T) { + // Fixture Setup + toComplete := "ac" + request := Request{ + Type: CompleteHeaderValue, + ToComplete: toComplete, + Header: "EP-Beta-Features", + } + + // Exercise SUT + completions, compDir := Complete(request) + + // Verify Results + require.Equal(t, compDir, cobra.ShellCompDirectiveNoFileComp) + require.Empty(t, completions) + +} + +func TestHeaderValueWithNonNilValueCompletes(t *testing.T) { + // Fixture Setup + toComplete := "U" + request := Request{ + Type: CompleteHeaderValue, + ToComplete: toComplete, + Header: "X-Moltin-Currency", + } + + // Exercise SUT + completions, compDir := Complete(request) + + // Verify Results + require.Equal(t, compDir, cobra.ShellCompDirectiveNoFileComp) + require.Contains(t, completions, "USD") +} diff --git a/external/completion/headers.go b/external/completion/headers.go new file mode 100644 index 0000000..089cb54 --- /dev/null +++ b/external/completion/headers.go @@ -0,0 +1,52 @@ +package completion + +import ( + "strings" + "sync" +) + +// Map that specifies headers that we will complete, and how to auto-complete their values. +var supportedHeadersToCompletionRequest = map[string]*Request{ + "EP-Beta-Features": nil, + "EP-Channel": nil, + "EP-Context-Tag": nil, + "EP-Account-Management-Authentication-Token": nil, + "X-Moltin-Customer-Token": nil, + "X-Moltin-Currency": { + Type: CompleteCurrency, + }, + "X-Moltin-Currencies": nil, +} + +var supportedHeadersOriginalCasing = map[string]string{} + +var headersMutex = &sync.RWMutex{} + +func postProcessMap() { + + newSupportedHeadersToCompletionRequest := make(map[string]*Request, len(supportedHeadersToCompletionRequest)) + + for k, v := range supportedHeadersToCompletionRequest { + newSupportedHeadersToCompletionRequest[strings.ToLower(k)] = v + supportedHeadersOriginalCasing[strings.ToLower(k)] = k + } + + supportedHeadersToCompletionRequest = newSupportedHeadersToCompletionRequest + +} + +func init() { + headersMutex.Lock() + defer headersMutex.Unlock() + postProcessMap() +} + +func AddHeaderCompletions(hc map[string]*Request) { + headersMutex.Lock() + defer headersMutex.Unlock() + + for k, v := range hc { + supportedHeadersToCompletionRequest[k] = v + } + postProcessMap() +} diff --git a/external/headergroups/headergroup.go b/external/headergroups/headergroup.go new file mode 100644 index 0000000..038a25c --- /dev/null +++ b/external/headergroups/headergroup.go @@ -0,0 +1,234 @@ +package headergroups + +import ( + "encoding/json" + "github.com/elasticpath/epcc-cli/external/aliases" + "github.com/elasticpath/epcc-cli/external/profiles" + log "github.com/sirupsen/logrus" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" +) + +var headerGroups = map[string]map[string]string{} + +var headerGroupMutex = sync.RWMutex{} + +var headerGroupsLoaded = atomic.Bool{} + +var headerToAliasType = map[string]string{} + +var headerToAliasMutex = sync.RWMutex{} + +func AddHeaderAliasMapping(header string, resource string) { + + headerToAliasMutex.Lock() + defer headerToAliasMutex.Unlock() + + headerToAliasType[strings.ToLower(header)] = resource + +} + +func ClearAllHeaderAliasMappings() { + headerToAliasMutex.Lock() + defer headerToAliasMutex.Unlock() + + headerToAliasType = map[string]string{} +} + +func AddHeaderGroup(name string, headers map[string]string) { + initializeHeaderGroups() + headerGroupMutex.Lock() + defer headerGroupMutex.Unlock() + + headerToAliasMutex.RLock() + defer headerToAliasMutex.RUnlock() + + headerGroups[name] = map[string]string{} + + for k, v := range headers { + aliasType := headerToAliasType[strings.ToLower(k)] + + if aliasType != "" { + headerGroups[name][k] = aliases.ResolveAliasValuesOrReturnIdentity(aliasType, []string{}, v, "id") + } else { + headerGroups[name][k] = v + } + + } +} + +func AddHeaderToGroup(name string, header string, value string) { + initializeHeaderGroups() + headerGroupMutex.Lock() + defer headerGroupMutex.Unlock() + + headerToAliasMutex.RLock() + defer headerToAliasMutex.RUnlock() + + if headerGroups[name] == nil { + headerGroups[name] = map[string]string{} + } + + aliasType := headerToAliasType[strings.ToLower(header)] + + if aliasType != "" { + headerGroups[name][header] = aliases.ResolveAliasValuesOrReturnIdentity(aliasType, []string{}, value, "id") + } else { + headerGroups[name][header] = value + } +} + +func RemoveHeaderGroup(name string) { + initializeHeaderGroups() + headerGroupMutex.Lock() + defer headerGroupMutex.Unlock() + + delete(headerGroups, name) + +} + +func RemoveHeaderFromGroup(name string, header string) { + initializeHeaderGroups() + headerGroupMutex.Lock() + defer headerGroupMutex.Unlock() + + if headerGroups[name] == nil { + return + } + + delete(headerGroups[name], header) + + if len(headerGroups[name]) == 0 { + delete(headerGroups, name) + } +} + +func GetAllHeaders() map[string]string { + initializeHeaderGroups() + headerGroupMutex.RLock() + defer headerGroupMutex.RUnlock() + + headers := map[string]string{} + + for s, headerGroup := range headerGroups { + for k, v := range headerGroup { + if headers[k] != "" { + log.Warnf("Duplicate header found in group %s, overwriting: %s", s, k) + } + headers[k] = v + } + } + + return headers +} + +func GetAllHeaderGroups() []string { + initializeHeaderGroups() + headerGroupMutex.RLock() + defer headerGroupMutex.RUnlock() + + groups := []string{} + + for s, _ := range headerGroups { + groups = append(groups, s) + } + + return groups +} + +func ClearAllHeaderGroups() { + initializeHeaderGroups() + headerGroupMutex.Lock() + defer headerGroupMutex.Unlock() + + headerGroups = map[string]map[string]string{} + + path := GetHeaderGroupPath() + err := os.Remove(path) + + if err == nil || os.IsNotExist(err) { + return + } + + log.Warnf("Could not delete header groups(%s): %v", path, err) + +} + +func initializeHeaderGroups() { + if headerGroupsLoaded.Load() { + return + } else { + headerGroupMutex.Lock() + defer headerGroupMutex.Unlock() + loadHeaderGroupsFromDisk() + } +} + +func GetHeaderGroupPath() string { + return filepath.Clean(profiles.GetProfileDataDirectory() + "/header_groups.json") +} + +func loadHeaderGroupsFromDisk() { + headerGroupPath := GetHeaderGroupPath() + data, err := os.ReadFile(headerGroupPath) + if err != nil { + if !os.IsNotExist(err) { + log.Warnf("Could not read %s, error %s", headerGroupPath, err) + } else { + log.Debugf("Error occurred while reading header group path %s: %v", headerGroupPath, err) + } + data = []byte{} + } else { + + err = json.Unmarshal(data, &headerGroups) + if err != nil { + log.Debugf("Could not unmarshall existing file %s, error %s", data, err) + } + } + log.Tracef("Retrieved %s from disk", headerGroups) + headerGroupsLoaded.Store(true) +} + +func saveHeaderGroupsToDisk() { + jsonHeaderGroups, err := json.Marshal(headerGroups) + + headerGroupPath := GetHeaderGroupPath() + log.Debugf("Saving header groups to disk in %v", headerGroupPath) + log.Tracef("Saving all data %s", jsonHeaderGroups) + if err != nil { + log.Warnf("Could not convert token to JSON %v", err) + } else { + err = os.WriteFile(headerGroupPath, jsonHeaderGroups, 0600) + + if err != nil { + log.Warnf("Could not save token %s, error: %v", headerGroupPath, err) + } else { + log.Debugf("Saved token to %s", headerGroupPath) + } + } + +} + +func SyncHeaderGroups() { + initializeHeaderGroups() + + headerGroupMutex.RLock() + defer headerGroupMutex.RUnlock() + + saveHeaderGroupsToDisk() +} + +func FlushHeaderGroups() { + + initializeHeaderGroups() + headerGroupMutex.Lock() + defer headerGroupMutex.Unlock() + + saveHeaderGroupsToDisk() + headerGroupsLoaded.Store(false) + headerGroups = map[string]map[string]string{} + +} diff --git a/external/headergroups/headergroup_test.go b/external/headergroups/headergroup_test.go new file mode 100644 index 0000000..0c54327 --- /dev/null +++ b/external/headergroups/headergroup_test.go @@ -0,0 +1,199 @@ +package headergroups + +import ( + "github.com/elasticpath/epcc-cli/external/aliases" + "github.com/stretchr/testify/require" + "testing" +) + +func TestAddHeaderGroup(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + + // Execute SUT + AddHeaderGroup("test", map[string]string{"foo": "bar"}) + + // Verification + require.Equal(t, []string{"test"}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{"foo": "bar"}, GetAllHeaders()) +} + +func TestAddHeaderToHeaderGroupThatDoesNotExist(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + + // Execute SUT + AddHeaderToGroup("test", "foo", "bar") + + // Verification + require.Equal(t, []string{"test"}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{"foo": "bar"}, GetAllHeaders()) +} + +func TestAddHeaderGroupWithAlias(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + AddHeaderAliasMapping("FUZZY", "wuzzy") + + err := aliases.ClearAllAliases() + + require.NoError(t, err) + + aliases.SaveAliasesForResources( + // language=JSON + ` +{ + "data": { + "id": "123", + "type": "wuzzy" + } +}`) + + // Execute SUT + AddHeaderGroup("test", map[string]string{"Fuzzy": "id=123"}) + + // Verification + require.Equal(t, []string{"test"}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{"Fuzzy": "123"}, GetAllHeaders()) +} + +func TestAddHeaderToHeaderGroupThatDoesExist(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + AddHeaderAliasMapping("FUZZY", "wuzzy") + + err := aliases.ClearAllAliases() + + require.NoError(t, err) + + aliases.SaveAliasesForResources( + // language=JSON + ` +{ + "data": { + "id": "123", + "type": "wuzzy" + } +}`) + + AddHeaderGroup("test", map[string]string{"hello": "world"}) + + // Execute SUT + AddHeaderToGroup("test", "Fuzzy", "id=123") + + // Verification + require.Equal(t, []string{"test"}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{"Fuzzy": "123", "hello": "world"}, GetAllHeaders()) +} + +func TestAddHeaderToHeaderGroupWithAlias(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + AddHeaderGroup("test", map[string]string{"hello": "world"}) + + // Execute SUT + AddHeaderToGroup("test", "foo", "bar") + + // Verification + require.Equal(t, []string{"test"}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{"foo": "bar", "hello": "world"}, GetAllHeaders()) +} + +func TestRemoveHeaderGroupOnExistingGroup(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + AddHeaderGroup("test", map[string]string{"foo": "bar"}) + + // Execute SUT + RemoveHeaderGroup("test") + + // Verification + require.Equal(t, []string{}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{}, GetAllHeaders()) +} + +func TestRemoveHeaderGroupOnNonExistingGroup(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + AddHeaderGroup("test", map[string]string{"foo": "bar"}) + + // Execute SUT + RemoveHeaderGroup("does_not_exist") + + // Verification + require.Equal(t, []string{"test"}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{"foo": "bar"}, GetAllHeaders()) +} + +func TestRemoveHeaderFromGroupOnExistingGroup(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + FlushHeaderGroups() + AddHeaderGroup("test", map[string]string{"foo": "bar"}) + + // Execute SUT + RemoveHeaderFromGroup("test", "foo") + + // Verification + require.Equal(t, []string{}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{}, GetAllHeaders()) +} + +func TestRemoveHeaderFromGroupOnExistingGroupWithMultipleEntries(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + AddHeaderGroup("test", map[string]string{"foo": "bar", "hello": "world"}) + + // Execute SUT + RemoveHeaderFromGroup("test", "foo") + + // Verification + require.Equal(t, []string{"test"}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{"hello": "world"}, GetAllHeaders()) +} + +func TestRemoveHeaderFromGroupOnNonExisting(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + AddHeaderGroup("test", map[string]string{"foo": "bar"}) + + // Execute SUT + RemoveHeaderFromGroup("does_not_exist", "foo") + + // Verification + require.Equal(t, []string{"test"}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{"foo": "bar"}, GetAllHeaders()) +} + +func TestFlushHeaderGroup(t *testing.T) { + // Fixture Setup + ClearAllHeaderGroups() + ClearAllHeaderAliasMappings() + FlushHeaderGroups() + AddHeaderGroup("test", map[string]string{"foo": "bar"}) + + // Execute SUT + FlushHeaderGroups() + + // Verification + require.Equal(t, []string{"test"}, GetAllHeaderGroups()) + require.Equal(t, map[string]string{"foo": "bar"}, GetAllHeaders()) +} diff --git a/external/httpclient/httpclient.go b/external/httpclient/httpclient.go index 842ac9f..e7b76b5 100644 --- a/external/httpclient/httpclient.go +++ b/external/httpclient/httpclient.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/elasticpath/epcc-cli/config" "github.com/elasticpath/epcc-cli/external/authentication" + "github.com/elasticpath/epcc-cli/external/headergroups" "github.com/elasticpath/epcc-cli/external/json" "github.com/elasticpath/epcc-cli/external/profiles" "github.com/elasticpath/epcc-cli/external/shutdown" @@ -188,7 +189,9 @@ func doRequestInternal(ctx context.Context, method string, contentType string, p return nil, err } - bearerToken, err := authentication.GetAuthenticationToken(true, nil) + warnOnNoAuthentication := len(headergroups.GetAllHeaderGroups()) == 0 + + bearerToken, err := authentication.GetAuthenticationToken(true, nil, warnOnNoAuthentication) if err != nil { return nil, err @@ -222,6 +225,10 @@ func doRequestInternal(ctx context.Context, method string, contentType string, p return nil, err } + for k, v := range headergroups.GetAllHeaders() { + req.Header.Add(k, v) + } + dumpReq, err := httputil.DumpRequestOut(req, true) if err != nil { log.Error(err) @@ -235,11 +242,16 @@ func doRequestInternal(ctx context.Context, method string, contentType string, p } rateLimitTime := time.Since(start) + log.Tracef("Rate limiter allowed call, making HTTP Request") + resp, err := HttpClient.Do(req) requestTime := time.Since(start) - log.Tracef("Waiting for stats lock") + log.Tracef("HTTP Request complete, waiting for stats lock") statsLock.Lock() + + // Lock is not deferred (for perf reasons), so don't + // forget to unlock it, if you return before it is so. stats.totalRequests += 1 if rateLimitTime.Milliseconds() > 50 { // Only count rate limit time if it took us longer than 50 ms to get here. @@ -257,6 +269,8 @@ func doRequestInternal(ctx context.Context, method string, contentType string, p requestNumber := stats.totalRequests statsLock.Unlock() + log.Tracef("Stats processing complete") + if err != nil { return nil, err } diff --git a/external/resources/resources.go b/external/resources/resources.go index 33bfa2b..6e381e0 100644 --- a/external/resources/resources.go +++ b/external/resources/resources.go @@ -136,6 +136,16 @@ func GetResourceByName(name string) (Resource, bool) { return Resource{}, false } +func MustGetResourceByName(name string) Resource { + res, ok := GetResourceByName(name) + + if !ok { + panic("Could not find resource: " + name) + } + + return res +} + func GetSingularResourceByName(name string) (Resource, bool) { res, ok := resourcesSingular[name] diff --git a/external/resources/resources_schema.json b/external/resources/resources_schema.json index 9584275..6445bca 100644 --- a/external/resources/resources_schema.json +++ b/external/resources/resources_schema.json @@ -105,7 +105,8 @@ "id", "sku", "code", - "slug" + "slug", + "name" ] } },