From f5174c7bc3d32bd3ef7f11b5f05e39c0d323b49a Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 8 Apr 2026 12:18:07 +0200 Subject: [PATCH 01/18] feat: support custom headers --- cmd/root.go | 1 + internal/cmdutils/get-client-config.go | 2 ++ internal/fga/fga.go | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 46fa3cd1..b9bbdd5a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -68,6 +68,7 @@ func init() { rootCmd.PersistentFlags().String("client-id", "", "Client ID. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll rootCmd.PersistentFlags().String("client-secret", "", "Client Secret. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll rootCmd.PersistentFlags().StringArray("api-scopes", []string{}, "API Scopes (repeat option for multiple values). Used in the Client Credentials flow") //nolint:lll + rootCmd.PersistentFlags().StringArray("custom-headers", []string{}, "Custom HTTP Headers (repeat option for multiple values)") //nolint:lll rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode - can print more detailed information for debugging") _ = rootCmd.Flags().MarkHidden("debug") diff --git a/internal/cmdutils/get-client-config.go b/internal/cmdutils/get-client-config.go index f6af6032..94bb1498 100644 --- a/internal/cmdutils/get-client-config.go +++ b/internal/cmdutils/get-client-config.go @@ -44,6 +44,7 @@ func GetClientConfig(cmd *cobra.Command) fga.ClientConfig { clientCredentialsClientID, _ := cmd.Flags().GetString("client-id") clientCredentialsClientSecret, _ := cmd.Flags().GetString("client-secret") clientCredentialsScopes, _ := cmd.Flags().GetStringArray("api-scopes") + customHeaders, _ := cmd.Flags().GetStringArray("custom-headers") debug, _ := cmd.Flags().GetBool("debug") return fga.ClientConfig{ @@ -56,6 +57,7 @@ func GetClientConfig(cmd *cobra.Command) fga.ClientConfig { ClientID: clientCredentialsClientID, ClientSecret: clientCredentialsClientSecret, APIScopes: clientCredentialsScopes, + CustomHeaders: customHeaders, Debug: debug, } } diff --git a/internal/fga/fga.go b/internal/fga/fga.go index efa7a130..911a7e0e 100644 --- a/internal/fga/fga.go +++ b/internal/fga/fga.go @@ -44,6 +44,7 @@ type ClientConfig struct { APIScopes []string `json:"api_scopes,omitempty"` ClientID string `json:"client_id,omitempty"` ClientSecret string `json:"client_secret,omitempty"` //nolint:gosec + CustomHeaders []string `json:"custom_headers,omitempty"` Debug bool `json:"debug,omitempty"` } @@ -95,6 +96,20 @@ func (c ClientConfig) getClientConfig() *client.ClientConfiguration { MaxRetry: MaxSdkRetry, MinWaitInMs: MinSdkWaitInMs, }, - Debug: c.Debug, + Debug: c.Debug, + DefaultHeaders: c.getCustomHeaders(), } } + +func (c ClientConfig) getCustomHeaders() map[string]string { + headers := map[string]string{} + for _, header := range c.CustomHeaders { + parts := strings.SplitN(header, ":", 2) + if len(parts) == 2 { + head := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + headers[head] = value + } + } + return headers +} From 6f9b42009f636432439d478d55ea9910c3b2899d Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 8 Apr 2026 12:30:04 +0200 Subject: [PATCH 02/18] add docs to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4ab46e96..04a9c2bc 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ For any command that interacts with an OpenFGA server, these configuration value | Token Audience | `--api-audience` | `FGA_API_AUDIENCE` | `api-audience` | | Store ID | `--store-id` | `FGA_STORE_ID` | `store-id` | | Authorization Model ID | `--model-id` | `FGA_MODEL_ID` | `model-id` | +| Custom Headers | `--custom-headers` | `FGA_CUSTOM_HEADERS` | `custom-headers` | If you are authenticating with a shared secret, you should specify the API Token value. If you are authenticating using OAuth, you should specify the Client ID, Client Secret, API Audience and Token Issuer. For example: From 59399fbcf6baeb0578525877393a7b592f0f430e Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 8 Apr 2026 12:35:14 +0200 Subject: [PATCH 03/18] avoid empty header --- cmd/root.go | 8 ++++---- internal/fga/fga.go | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index b9bbdd5a..d00a65e5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -65,10 +65,10 @@ func init() { rootCmd.PersistentFlags().String("api-token", "", "API Token. Will be sent in as a Bearer in the Authorization header") rootCmd.PersistentFlags().String("api-token-issuer", "", "API Token Issuer. API responsible for issuing the API Token. Used in the Client Credentials flow") //nolint:lll rootCmd.PersistentFlags().String("api-audience", "", "API Audience. Used when performing the Client Credentials flow") - rootCmd.PersistentFlags().String("client-id", "", "Client ID. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll - rootCmd.PersistentFlags().String("client-secret", "", "Client Secret. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll - rootCmd.PersistentFlags().StringArray("api-scopes", []string{}, "API Scopes (repeat option for multiple values). Used in the Client Credentials flow") //nolint:lll - rootCmd.PersistentFlags().StringArray("custom-headers", []string{}, "Custom HTTP Headers (repeat option for multiple values)") //nolint:lll + rootCmd.PersistentFlags().String("client-id", "", "Client ID. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll + rootCmd.PersistentFlags().String("client-secret", "", "Client Secret. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll + rootCmd.PersistentFlags().StringArray("api-scopes", []string{}, "API Scopes (repeat option for multiple values). Used in the Client Credentials flow") //nolint:lll + rootCmd.PersistentFlags().StringArray("custom-headers", []string{}, "Custom HTTP headers in 'Header: value' format (repeat option for multiple values)") //nolint:lll rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode - can print more detailed information for debugging") _ = rootCmd.Flags().MarkHidden("debug") diff --git a/internal/fga/fga.go b/internal/fga/fga.go index 911a7e0e..3135ea38 100644 --- a/internal/fga/fga.go +++ b/internal/fga/fga.go @@ -108,7 +108,9 @@ func (c ClientConfig) getCustomHeaders() map[string]string { if len(parts) == 2 { head := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) - headers[head] = value + if head != "" { + headers[head] = value + } } } return headers From 540a9eaeb7ed9902e812dfe972a6887fe9ecb522 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 15 Apr 2026 10:58:13 +0200 Subject: [PATCH 04/18] validate malformed custom headers and improve docs --- README.md | 32 +++++++++++ internal/fga/fga.go | 40 +++++++++----- internal/fga/fga_test.go | 114 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 internal/fga/fga_test.go diff --git a/README.md b/README.md index 04a9c2bc..7f5acf1d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A cross-platform CLI to interact with an OpenFGA server - [Building from Source](#building-from-source) - [Usage](#usage) - [Configuration](#configuration) + - [Custom Headers](#custom-headers) - [Commands](#commands) - [Stores](#stores) - [List All Stores](#list-stores) @@ -165,6 +166,37 @@ api-token-issuer: auth.fga.dev store-id: 01H0H015178Y2V4CX10C2KGHF4 ``` +#### Custom Headers + +You can add custom HTTP headers to all requests sent to the API using the `--custom-headers` flag. Headers are specified in `: ` format, and the flag can be repeated to add multiple headers. + +##### Flag +```shell +--custom-headers "Header-Name: header-value" +``` + +##### Example +```shell +fga store list --custom-headers "X-Custom-Header: value1" --custom-headers "X-Request-ID: abc123" +``` + +##### Configuration + +Custom headers can also be configured via the CLI environment variable or the configuration file: + +| Name | Flag | CLI | ~/.fga.yaml | +|----------------|----------------------|------------------------|---------------------| +| Custom Headers | `--custom-headers` | `FGA_CUSTOM_HEADERS` | `custom-headers` | + +Example `~/.fga.yaml`: +```yaml +api-url: https://api.fga.example +store-id: 01H0H015178Y2V4CX10C2KGHF4 +custom-headers: + - "X-Custom-Header: value1" + - "X-Request-ID: abc123" +``` + ### Commands #### Stores diff --git a/internal/fga/fga.go b/internal/fga/fga.go index 3135ea38..2493e6cb 100644 --- a/internal/fga/fga.go +++ b/internal/fga/fga.go @@ -18,6 +18,7 @@ limitations under the License. package fga import ( + "fmt" "strings" openfga "github.com/openfga/go-sdk" @@ -49,7 +50,12 @@ type ClientConfig struct { } func (c ClientConfig) GetFgaClient() (*client.OpenFgaClient, error) { - fgaClient, err := client.NewSdkClient(c.getClientConfig()) + clientConfig, err := c.getClientConfig() + if err != nil { + return nil, err + } + + fgaClient, err := client.NewSdkClient(clientConfig) if err != nil { return nil, err //nolint:wrapcheck } @@ -85,7 +91,12 @@ func (c ClientConfig) getCredentials() *credentials.Credentials { } } -func (c ClientConfig) getClientConfig() *client.ClientConfiguration { +func (c ClientConfig) getClientConfig() (*client.ClientConfiguration, error) { + customHeaders, err := c.getCustomHeaders() + if err != nil { + return nil, fmt.Errorf("invalid custom headers configuration: %w", err) + } + return &client.ClientConfiguration{ ApiUrl: c.ApiUrl, StoreId: c.StoreID, @@ -97,21 +108,26 @@ func (c ClientConfig) getClientConfig() *client.ClientConfiguration { MinWaitInMs: MinSdkWaitInMs, }, Debug: c.Debug, - DefaultHeaders: c.getCustomHeaders(), - } + DefaultHeaders: customHeaders, + }, nil } -func (c ClientConfig) getCustomHeaders() map[string]string { +func (c ClientConfig) getCustomHeaders() (map[string]string, error) { headers := map[string]string{} + for _, header := range c.CustomHeaders { parts := strings.SplitN(header, ":", 2) - if len(parts) == 2 { - head := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - if head != "" { - headers[head] = value - } + if len(parts) != 2 { + return nil, fmt.Errorf("invalid custom header %q: expected format \"Header-Name: value\"", header) + } + + name := strings.TrimSpace(parts[0]) + if name == "" { + return nil, fmt.Errorf("invalid custom header %q: header name must not be empty", header) } + + headers[name] = strings.TrimSpace(parts[1]) } - return headers + + return headers, nil } diff --git a/internal/fga/fga_test.go b/internal/fga/fga_test.go new file mode 100644 index 00000000..d5970900 --- /dev/null +++ b/internal/fga/fga_test.go @@ -0,0 +1,114 @@ +/* +Copyright © 2023 OpenFGA + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fga + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetCustomHeaders(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + headers []string + expected map[string]string + err string + }{ + { + name: "no headers", + headers: []string{}, + expected: map[string]string{}, + }, + { + name: "single valid header", + headers: []string{"X-Custom: value1"}, + expected: map[string]string{ + "X-Custom": "value1", + }, + }, + { + name: "multiple valid headers", + headers: []string{"X-Custom: value1", "X-Request-ID: abc123"}, + expected: map[string]string{ + "X-Custom": "value1", + "X-Request-ID": "abc123", + }, + }, + { + name: "colon in value is preserved", + headers: []string{"X-Custom: host:port"}, + expected: map[string]string{ + "X-Custom": "host:port", + }, + }, + { + name: "whitespace is trimmed", + headers: []string{" X-Custom : value1 "}, + expected: map[string]string{ + "X-Custom": "value1", + }, + }, + { + name: "empty value is valid", + headers: []string{"X-Custom: "}, + expected: map[string]string{ + "X-Custom": "", + }, + }, + { + name: "missing colon returns error", + headers: []string{"nocolon"}, + err: `invalid custom header "nocolon": expected format "Header-Name: value"`, + }, + { + name: "empty string returns error", + headers: []string{""}, + err: `invalid custom header "": expected format "Header-Name: value"`, + }, + { + name: "empty header name returns error", + headers: []string{": value"}, + err: `invalid custom header ": value": header name must not be empty`, + }, + { + name: "valid header before invalid stops at first error", + headers: []string{"X-Good: ok", "bad-header"}, + err: `invalid custom header "bad-header": expected format "Header-Name: value"`, + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + cfg := ClientConfig{CustomHeaders: test.headers} + result, err := cfg.getCustomHeaders() + + if test.err != "" { + require.Error(t, err) + assert.ErrorContains(t, err, test.err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected, result) + } + }) + } +} From 80b85f9473fdcec5578cb566da27686b23ecdbf5 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 15 Apr 2026 11:16:11 +0200 Subject: [PATCH 05/18] use sentinel errors for custom header validation --- internal/fga/fga.go | 12 +++++++++--- internal/fga/fga_test.go | 15 ++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/internal/fga/fga.go b/internal/fga/fga.go index 2493e6cb..d6be1364 100644 --- a/internal/fga/fga.go +++ b/internal/fga/fga.go @@ -18,6 +18,7 @@ limitations under the License. package fga import ( + "errors" "fmt" "strings" @@ -33,7 +34,12 @@ const ( MinSdkWaitInMs = 500 ) -var userAgent = "openfga-cli/" + build.Version +var ( + userAgent = "openfga-cli/" + build.Version + + ErrInvalidHeaderFormat = errors.New("expected format \"Header-Name: value\"") + ErrEmptyHeaderName = errors.New("header name must not be empty") +) type ClientConfig struct { ApiUrl string `json:"api_url,omitempty"` //nolint:revive,stylecheck @@ -118,12 +124,12 @@ func (c ClientConfig) getCustomHeaders() (map[string]string, error) { for _, header := range c.CustomHeaders { parts := strings.SplitN(header, ":", 2) if len(parts) != 2 { - return nil, fmt.Errorf("invalid custom header %q: expected format \"Header-Name: value\"", header) + return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat) } name := strings.TrimSpace(parts[0]) if name == "" { - return nil, fmt.Errorf("invalid custom header %q: header name must not be empty", header) + return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrEmptyHeaderName) } headers[name] = strings.TrimSpace(parts[1]) diff --git a/internal/fga/fga_test.go b/internal/fga/fga_test.go index d5970900..487586f2 100644 --- a/internal/fga/fga_test.go +++ b/internal/fga/fga_test.go @@ -17,6 +17,7 @@ limitations under the License. package fga import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -30,7 +31,7 @@ func TestGetCustomHeaders(t *testing.T) { name string headers []string expected map[string]string - err string + err error }{ { name: "no headers", @@ -76,22 +77,22 @@ func TestGetCustomHeaders(t *testing.T) { { name: "missing colon returns error", headers: []string{"nocolon"}, - err: `invalid custom header "nocolon": expected format "Header-Name: value"`, + err: ErrInvalidHeaderFormat, }, { name: "empty string returns error", headers: []string{""}, - err: `invalid custom header "": expected format "Header-Name: value"`, + err: ErrInvalidHeaderFormat, }, { name: "empty header name returns error", headers: []string{": value"}, - err: `invalid custom header ": value": header name must not be empty`, + err: ErrEmptyHeaderName, }, { name: "valid header before invalid stops at first error", headers: []string{"X-Good: ok", "bad-header"}, - err: `invalid custom header "bad-header": expected format "Header-Name: value"`, + err: ErrInvalidHeaderFormat, }, } @@ -102,9 +103,9 @@ func TestGetCustomHeaders(t *testing.T) { cfg := ClientConfig{CustomHeaders: test.headers} result, err := cfg.getCustomHeaders() - if test.err != "" { + if test.err != nil { require.Error(t, err) - assert.ErrorContains(t, err, test.err) + assert.True(t, errors.Is(err, test.err)) } else { require.NoError(t, err) assert.Equal(t, test.expected, result) From d9497fbb272a1c7bc072fec07609026a356b0d07 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 15 Apr 2026 11:23:43 +0200 Subject: [PATCH 06/18] add integration test verifying custom headers are sent in requests --- internal/fga/fga_test.go | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/internal/fga/fga_test.go b/internal/fga/fga_test.go index 487586f2..4362ff1c 100644 --- a/internal/fga/fga_test.go +++ b/internal/fga/fga_test.go @@ -17,9 +17,13 @@ limitations under the License. package fga import ( + "context" "errors" + "net/http" + "net/http/httptest" "testing" + "github.com/openfga/go-sdk/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -113,3 +117,67 @@ func TestGetCustomHeaders(t *testing.T) { }) } } + +func TestCustomHeadersSentInRequest(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + customHeaders []string + expectedHeaders map[string]string + }{ + { + name: "single header is sent", + customHeaders: []string{"X-Custom-Header: value1"}, + expectedHeaders: map[string]string{"X-Custom-Header": "value1"}, + }, + { + name: "multiple headers are sent", + customHeaders: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"}, + expectedHeaders: map[string]string{ + "X-Custom-Header": "value1", + "X-Request-ID": "abc123", + }, + }, + { + name: "no custom headers", + customHeaders: []string{}, + expectedHeaders: map[string]string{}, + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var capturedHeaders http.Header + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"stores": []}`)) + })) + defer server.Close() + + cfg := ClientConfig{ + ApiUrl: server.URL, + StoreID: "01H0H015178Y2V4CX10C2KGHF4", + CustomHeaders: test.customHeaders, + } + + fgaClient, err := cfg.GetFgaClient() + require.NoError(t, err) + + _, err = fgaClient.ListStores(context.Background()). + Options(client.ClientListStoresOptions{}). + Execute() + require.NoError(t, err) + + for name, value := range test.expectedHeaders { + assert.Equal(t, value, capturedHeaders.Get(name), + "expected header %s to have value %q", name, value) + } + }) + } +} From 3d4df9767ea50ffe1c28d132d3422404ad4673a1 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 15 Apr 2026 14:33:54 +0200 Subject: [PATCH 07/18] lint --- internal/build/build.go | 1 - internal/fga/fga.go | 4 ++-- internal/fga/fga_test.go | 3 +-- internal/requests/rampup.go | 6 +++--- internal/requests/rampup_test.go | 4 ++-- internal/utils/context.go | 2 +- 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/internal/build/build.go b/internal/build/build.go index 786f84a2..a48b450a 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -17,7 +17,6 @@ limitations under the License. // Package build provides build information that is linked into the application. Other // packages within this project can use this information in logs etc.. -//nolint:revive // package name conflicts with stdlib is acceptable here package build var ( diff --git a/internal/fga/fga.go b/internal/fga/fga.go index d6be1364..bf98e96c 100644 --- a/internal/fga/fga.go +++ b/internal/fga/fga.go @@ -45,12 +45,12 @@ type ClientConfig struct { ApiUrl string `json:"api_url,omitempty"` //nolint:revive,stylecheck StoreID string `json:"store_id,omitempty"` AuthorizationModelID string `json:"authorization_model_id,omitempty"` - APIToken string `json:"api_token,omitempty"` //nolint:gosec + APIToken string `json:"api_token,omitempty"` APITokenIssuer string `json:"api_token_issuer,omitempty"` APIAudience string `json:"api_audience,omitempty"` APIScopes []string `json:"api_scopes,omitempty"` ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` //nolint:gosec + ClientSecret string `json:"client_secret,omitempty"` CustomHeaders []string `json:"custom_headers,omitempty"` Debug bool `json:"debug,omitempty"` } diff --git a/internal/fga/fga_test.go b/internal/fga/fga_test.go index 4362ff1c..52cc232b 100644 --- a/internal/fga/fga_test.go +++ b/internal/fga/fga_test.go @@ -18,7 +18,6 @@ package fga import ( "context" - "errors" "net/http" "net/http/httptest" "testing" @@ -109,7 +108,7 @@ func TestGetCustomHeaders(t *testing.T) { if test.err != nil { require.Error(t, err) - assert.True(t, errors.Is(err, test.err)) + assert.ErrorIs(t, err, test.err) } else { require.NoError(t, err) assert.Equal(t, test.expected, result) diff --git a/internal/requests/rampup.go b/internal/requests/rampup.go index b72210c6..9f453411 100644 --- a/internal/requests/rampup.go +++ b/internal/requests/rampup.go @@ -30,7 +30,7 @@ func RampUpAPIRequests( //nolint:cyclop semaphore = make(chan struct{}, maxInFlight) waitGroup sync.WaitGroup ticker = time.NewTicker(rampupPeriodDuration) - requestIndex int32 + requestIndex atomic.Int32 ) // if the ramp up period is 0, go to max rps directly @@ -65,7 +65,7 @@ func RampUpAPIRequests( //nolint:cyclop } for i := 0; i < int(limiter.Limit()); i++ { //nolint:intrange - idx := atomic.AddInt32(&requestIndex, 1) - 1 + idx := requestIndex.Add(1) - 1 if idx >= requestsLen { waitGroup.Wait() @@ -102,7 +102,7 @@ func RampUpAPIRequests( //nolint:cyclop } for i := 0; i < int(limiter.Limit()); i++ { //nolint:intrange - idx := atomic.AddInt32(&requestIndex, 1) - 1 + idx := requestIndex.Add(1) - 1 if idx >= requestsLen { waitGroup.Wait() diff --git a/internal/requests/rampup_test.go b/internal/requests/rampup_test.go index ce74ad2e..c065c050 100644 --- a/internal/requests/rampup_test.go +++ b/internal/requests/rampup_test.go @@ -47,7 +47,7 @@ func TestRampUpAPIRequests_RampUpRate(t *testing.T) { defer cancel() var ( - callCount int32 + callCount atomic.Int32 mutex sync.Mutex ) @@ -55,7 +55,7 @@ func TestRampUpAPIRequests_RampUpRate(t *testing.T) { for i := range requestsList { requestsList[i] = func() error { mutex.Lock() - atomic.AddInt32(&callCount, 1) + callCount.Add(1) mutex.Unlock() return nil diff --git a/internal/utils/context.go b/internal/utils/context.go index 98277fb1..766dcd5b 100644 --- a/internal/utils/context.go +++ b/internal/utils/context.go @@ -1,4 +1,4 @@ -package utils //nolint:revive +package utils import "context" From 181be807a528d504d28da3f921c5293878f62a73 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 15 Apr 2026 14:38:50 +0200 Subject: [PATCH 08/18] chore: bump toolchain to 1.26.2 --- .mise.toml | 2 ++ go.mod | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .mise.toml diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 00000000..966f88a7 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.26.2" diff --git a/go.mod b/go.mod index a3572449..a4df06a4 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/openfga/cli go 1.25.0 -toolchain go1.26.1 +toolchain go1.26.2 require ( github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 From a1f2afec752a6a0e8629dbaa6dbcd9d949f65fb7 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 15 Apr 2026 14:55:29 +0200 Subject: [PATCH 09/18] fix viper binding for StringArray flags from YAML config --- internal/cmdutils/bind-viper-to-flags.go | 16 ++- internal/cmdutils/bind-viper-to-flags_test.go | 117 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 internal/cmdutils/bind-viper-to-flags_test.go diff --git a/internal/cmdutils/bind-viper-to-flags.go b/internal/cmdutils/bind-viper-to-flags.go index 8f032851..696e5e48 100644 --- a/internal/cmdutils/bind-viper-to-flags.go +++ b/internal/cmdutils/bind-viper-to-flags.go @@ -31,8 +31,7 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) { if !flag.Changed && viperInstance.IsSet(configName) { value := viperInstance.Get(configName) - err := cmd.Flags().Set(flag.Name, fmt.Sprintf("%v", value)) - cobra.CheckErr(err) + setFlagFromViper(cmd, flag, value) } }) @@ -40,3 +39,16 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) { BindViperToFlags(subcmd, viperInstance) } } + +func setFlagFromViper(cmd *cobra.Command, flag *pflag.Flag, value interface{}) { + switch v := value.(type) { + case []interface{}: + for _, elem := range v { + err := cmd.Flags().Set(flag.Name, fmt.Sprintf("%v", elem)) + cobra.CheckErr(err) + } + default: + err := cmd.Flags().Set(flag.Name, fmt.Sprintf("%v", v)) + cobra.CheckErr(err) + } +} diff --git a/internal/cmdutils/bind-viper-to-flags_test.go b/internal/cmdutils/bind-viper-to-flags_test.go new file mode 100644 index 00000000..18cf8d93 --- /dev/null +++ b/internal/cmdutils/bind-viper-to-flags_test.go @@ -0,0 +1,117 @@ +/* +Copyright © 2023 OpenFGA + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmdutils_test + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openfga/cli/internal/cmdutils" +) + +func TestBindViperToFlags_StringArrayFromYAML(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + config map[string]interface{} + expected []string + }{ + { + name: "yaml list binds as multiple values", + config: map[string]interface{}{ + "custom-headers": []interface{}{ + "X-Custom-Header: value1", + "X-Request-ID: abc123", + }, + }, + expected: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"}, + }, + { + name: "single element list", + config: map[string]interface{}{ + "custom-headers": []interface{}{ + "X-Custom-Header: value1", + }, + }, + expected: []string{"X-Custom-Header: value1"}, + }, + { + name: "no config leaves default", + config: map[string]interface{}{}, + expected: []string{}, + }, + } + + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringArray("custom-headers", []string{}, "test flag") + + v := viper.New() + for k, val := range test.config { + v.Set(k, val) + } + + cmdutils.BindViperToFlags(cmd, v) + + result, err := cmd.Flags().GetStringArray("custom-headers") + require.NoError(t, err) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestBindViperToFlags_ScalarFlagUnchanged(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("api-url", "http://localhost:8080", "test flag") + + v := viper.New() + v.Set("api-url", "https://api.fga.example") + + cmdutils.BindViperToFlags(cmd, v) + + result, err := cmd.Flags().GetString("api-url") + require.NoError(t, err) + assert.Equal(t, "https://api.fga.example", result) +} + +func TestBindViperToFlags_CLIFlagTakesPrecedence(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringArray("custom-headers", []string{}, "test flag") + + require.NoError(t, cmd.Flags().Set("custom-headers", "X-CLI: from-flag")) + + v := viper.New() + v.Set("custom-headers", []interface{}{"X-Config: from-yaml"}) + + cmdutils.BindViperToFlags(cmd, v) + + result, err := cmd.Flags().GetStringArray("custom-headers") + require.NoError(t, err) + assert.Equal(t, []string{"X-CLI: from-flag"}, result) +} From ea77157ffafe97996bf3e714091b7687333d0489 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 15 Apr 2026 14:56:35 +0200 Subject: [PATCH 10/18] lint --- internal/cmdutils/bind-viper-to-flags.go | 4 ++-- internal/cmdutils/bind-viper-to-flags_test.go | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/cmdutils/bind-viper-to-flags.go b/internal/cmdutils/bind-viper-to-flags.go index 696e5e48..bf6079a0 100644 --- a/internal/cmdutils/bind-viper-to-flags.go +++ b/internal/cmdutils/bind-viper-to-flags.go @@ -40,9 +40,9 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) { } } -func setFlagFromViper(cmd *cobra.Command, flag *pflag.Flag, value interface{}) { +func setFlagFromViper(cmd *cobra.Command, flag *pflag.Flag, value any) { switch v := value.(type) { - case []interface{}: + case []any: for _, elem := range v { err := cmd.Flags().Set(flag.Name, fmt.Sprintf("%v", elem)) cobra.CheckErr(err) diff --git a/internal/cmdutils/bind-viper-to-flags_test.go b/internal/cmdutils/bind-viper-to-flags_test.go index 18cf8d93..a14c1363 100644 --- a/internal/cmdutils/bind-viper-to-flags_test.go +++ b/internal/cmdutils/bind-viper-to-flags_test.go @@ -32,13 +32,13 @@ func TestBindViperToFlags_StringArrayFromYAML(t *testing.T) { testcases := []struct { name string - config map[string]interface{} + config map[string]any expected []string }{ { name: "yaml list binds as multiple values", - config: map[string]interface{}{ - "custom-headers": []interface{}{ + config: map[string]any{ + "custom-headers": []any{ "X-Custom-Header: value1", "X-Request-ID: abc123", }, @@ -47,8 +47,8 @@ func TestBindViperToFlags_StringArrayFromYAML(t *testing.T) { }, { name: "single element list", - config: map[string]interface{}{ - "custom-headers": []interface{}{ + config: map[string]any{ + "custom-headers": []any{ "X-Custom-Header: value1", }, }, @@ -56,7 +56,7 @@ func TestBindViperToFlags_StringArrayFromYAML(t *testing.T) { }, { name: "no config leaves default", - config: map[string]interface{}{}, + config: map[string]any{}, expected: []string{}, }, } @@ -107,7 +107,7 @@ func TestBindViperToFlags_CLIFlagTakesPrecedence(t *testing.T) { require.NoError(t, cmd.Flags().Set("custom-headers", "X-CLI: from-flag")) v := viper.New() - v.Set("custom-headers", []interface{}{"X-Config: from-yaml"}) + v.Set("custom-headers", []any{"X-Config: from-yaml"}) cmdutils.BindViperToFlags(cmd, v) From 189021717809f542e0c1366f8d1148c30794e0ad Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 15 Apr 2026 17:33:09 +0200 Subject: [PATCH 11/18] fix linter violations in viper binding --- internal/cmdutils/bind-viper-to-flags.go | 28 +++--- internal/cmdutils/bind-viper-to-flags_test.go | 95 ++++++------------- 2 files changed, 44 insertions(+), 79 deletions(-) diff --git a/internal/cmdutils/bind-viper-to-flags.go b/internal/cmdutils/bind-viper-to-flags.go index bf6079a0..16e20ac9 100644 --- a/internal/cmdutils/bind-viper-to-flags.go +++ b/internal/cmdutils/bind-viper-to-flags.go @@ -31,7 +31,9 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) { if !flag.Changed && viperInstance.IsSet(configName) { value := viperInstance.Get(configName) - setFlagFromViper(cmd, flag, value) + for _, strVal := range viperValueToStrings(value) { + cobra.CheckErr(cmd.Flags().Set(flag.Name, strVal)) + } } }) @@ -40,15 +42,19 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) { } } -func setFlagFromViper(cmd *cobra.Command, flag *pflag.Flag, value any) { - switch v := value.(type) { - case []any: - for _, elem := range v { - err := cmd.Flags().Set(flag.Name, fmt.Sprintf("%v", elem)) - cobra.CheckErr(err) - } - default: - err := cmd.Flags().Set(flag.Name, fmt.Sprintf("%v", v)) - cobra.CheckErr(err) +// viperValueToStrings converts a Viper config value to a slice of strings +// suitable for pflag.Set calls. Slice values (from YAML lists) produce one +// string per element; scalar values produce a single-element slice. +func viperValueToStrings(value any) []string { + sliceValue, ok := value.([]any) + if !ok { + return []string{fmt.Sprintf("%v", value)} + } + + result := make([]string, 0, len(sliceValue)) + for _, elem := range sliceValue { + result = append(result, fmt.Sprintf("%v", elem)) } + + return result } diff --git a/internal/cmdutils/bind-viper-to-flags_test.go b/internal/cmdutils/bind-viper-to-flags_test.go index a14c1363..829cc436 100644 --- a/internal/cmdutils/bind-viper-to-flags_test.go +++ b/internal/cmdutils/bind-viper-to-flags_test.go @@ -14,104 +14,63 @@ See the License for the specific language governing permissions and limitations under the License. */ -package cmdutils_test +package cmdutils import ( "testing" - "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/openfga/cli/internal/cmdutils" ) -func TestBindViperToFlags_StringArrayFromYAML(t *testing.T) { +func TestViperValueToStrings(t *testing.T) { t.Parallel() testcases := []struct { name string - config map[string]any + value any expected []string }{ { - name: "yaml list binds as multiple values", - config: map[string]any{ - "custom-headers": []any{ - "X-Custom-Header: value1", - "X-Request-ID: abc123", - }, + name: "slice value produces one string per element", + value: []any{ + "X-Custom-Header: value1", + "X-Request-ID: abc123", }, expected: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"}, }, { - name: "single element list", - config: map[string]any{ - "custom-headers": []any{ - "X-Custom-Header: value1", - }, - }, + name: "single element slice", + value: []any{"X-Custom-Header: value1"}, expected: []string{"X-Custom-Header: value1"}, }, { - name: "no config leaves default", - config: map[string]any{}, + name: "empty slice", + value: []any{}, expected: []string{}, }, + { + name: "scalar string produces single-element slice", + value: "https://api.fga.example", + expected: []string{"https://api.fga.example"}, + }, + { + name: "boolean value is stringified", + value: true, + expected: []string{"true"}, + }, + { + name: "integer value is stringified", + value: 42, + expected: []string{"42"}, + }, } for _, test := range testcases { t.Run(test.name, func(t *testing.T) { t.Parallel() - cmd := &cobra.Command{Use: "test"} - cmd.Flags().StringArray("custom-headers", []string{}, "test flag") - - v := viper.New() - for k, val := range test.config { - v.Set(k, val) - } - - cmdutils.BindViperToFlags(cmd, v) - - result, err := cmd.Flags().GetStringArray("custom-headers") - require.NoError(t, err) + result := viperValueToStrings(test.value) assert.Equal(t, test.expected, result) }) } } - -func TestBindViperToFlags_ScalarFlagUnchanged(t *testing.T) { - t.Parallel() - - cmd := &cobra.Command{Use: "test"} - cmd.Flags().String("api-url", "http://localhost:8080", "test flag") - - v := viper.New() - v.Set("api-url", "https://api.fga.example") - - cmdutils.BindViperToFlags(cmd, v) - - result, err := cmd.Flags().GetString("api-url") - require.NoError(t, err) - assert.Equal(t, "https://api.fga.example", result) -} - -func TestBindViperToFlags_CLIFlagTakesPrecedence(t *testing.T) { - t.Parallel() - - cmd := &cobra.Command{Use: "test"} - cmd.Flags().StringArray("custom-headers", []string{}, "test flag") - - require.NoError(t, cmd.Flags().Set("custom-headers", "X-CLI: from-flag")) - - v := viper.New() - v.Set("custom-headers", []any{"X-Config: from-yaml"}) - - cmdutils.BindViperToFlags(cmd, v) - - result, err := cmd.Flags().GetStringArray("custom-headers") - require.NoError(t, err) - assert.Equal(t, []string{"X-CLI: from-flag"}, result) -} From d05304d42f807f7029fb770d2091ac42c9252261 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Wed, 15 Apr 2026 18:05:47 +0200 Subject: [PATCH 12/18] fix linter --- internal/build/build.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/build/build.go b/internal/build/build.go index a48b450a..19bc4a1c 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -16,7 +16,6 @@ limitations under the License. // Package build provides build information that is linked into the application. Other // packages within this project can use this information in logs etc.. - package build var ( From 257d29fdee705790cf3f73af0e642d48385eba34 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 16 Apr 2026 09:33:30 +0200 Subject: [PATCH 13/18] refactor TestCustomHeadersSentInRequest to use a channel for capturing headers --- internal/fga/fga_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/fga/fga_test.go b/internal/fga/fga_test.go index 52cc232b..e097cede 100644 --- a/internal/fga/fga_test.go +++ b/internal/fga/fga_test.go @@ -149,10 +149,10 @@ func TestCustomHeadersSentInRequest(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - var capturedHeaders http.Header + headersCh := make(chan http.Header, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedHeaders = r.Header.Clone() + headersCh <- r.Header.Clone() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"stores": []}`)) @@ -173,6 +173,7 @@ func TestCustomHeadersSentInRequest(t *testing.T) { Execute() require.NoError(t, err) + capturedHeaders := <-headersCh for name, value := range test.expectedHeaders { assert.Equal(t, value, capturedHeaders.Get(name), "expected header %s to have value %q", name, value) From 80efc8a8a4755d835114bc7a98d6fdd28c62572b Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 16 Apr 2026 09:45:53 +0200 Subject: [PATCH 14/18] use strings.Cut --- internal/fga/fga.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/fga/fga.go b/internal/fga/fga.go index bf98e96c..dc6b0768 100644 --- a/internal/fga/fga.go +++ b/internal/fga/fga.go @@ -119,20 +119,21 @@ func (c ClientConfig) getClientConfig() (*client.ClientConfiguration, error) { } func (c ClientConfig) getCustomHeaders() (map[string]string, error) { - headers := map[string]string{} + headers := make(map[string]string, len(c.CustomHeaders)) for _, header := range c.CustomHeaders { - parts := strings.SplitN(header, ":", 2) - if len(parts) != 2 { + name, value, found := strings.Cut(header, ":") + name, value = strings.TrimSpace(name), strings.TrimSpace(value) + if !found { + return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat) + } + if name == "" && value == "" { return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat) } - - name := strings.TrimSpace(parts[0]) if name == "" { return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrEmptyHeaderName) } - - headers[name] = strings.TrimSpace(parts[1]) + headers[name] = value } return headers, nil From 81a032390a5e06118df06b165126860b23611e3a Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 16 Apr 2026 09:50:57 +0200 Subject: [PATCH 15/18] Enhance viperValueToStrings to handle typed slices for strings and ints in BindViperToFlags --- internal/cmdutils/bind-viper-to-flags.go | 11 ++++++----- internal/cmdutils/bind-viper-to-flags_test.go | 10 ++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/internal/cmdutils/bind-viper-to-flags.go b/internal/cmdutils/bind-viper-to-flags.go index 16e20ac9..604fb080 100644 --- a/internal/cmdutils/bind-viper-to-flags.go +++ b/internal/cmdutils/bind-viper-to-flags.go @@ -18,6 +18,7 @@ package cmdutils import ( "fmt" + "reflect" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -46,14 +47,14 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) { // suitable for pflag.Set calls. Slice values (from YAML lists) produce one // string per element; scalar values produce a single-element slice. func viperValueToStrings(value any) []string { - sliceValue, ok := value.([]any) - if !ok { + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice { return []string{fmt.Sprintf("%v", value)} } - result := make([]string, 0, len(sliceValue)) - for _, elem := range sliceValue { - result = append(result, fmt.Sprintf("%v", elem)) + result := make([]string, 0, rv.Len()) + for i := range rv.Len() { + result = append(result, fmt.Sprintf("%v", rv.Index(i).Interface())) } return result diff --git a/internal/cmdutils/bind-viper-to-flags_test.go b/internal/cmdutils/bind-viper-to-flags_test.go index 829cc436..bf5225b4 100644 --- a/internal/cmdutils/bind-viper-to-flags_test.go +++ b/internal/cmdutils/bind-viper-to-flags_test.go @@ -48,6 +48,16 @@ func TestViperValueToStrings(t *testing.T) { value: []any{}, expected: []string{}, }, + { + name: "typed string slice produces one string per element", + value: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"}, + expected: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"}, + }, + { + name: "typed int slice produces one string per element", + value: []int{1, 2, 3}, + expected: []string{"1", "2", "3"}, + }, { name: "scalar string produces single-element slice", value: "https://api.fga.example", From f488c7a9b061a233bb72b418f5a272130c91c09f Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 16 Apr 2026 15:17:20 +0200 Subject: [PATCH 16/18] softer validation rules --- internal/fga/fga.go | 13 ++++--------- internal/fga/fga_test.go | 15 +++++++++------ 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/internal/fga/fga.go b/internal/fga/fga.go index dc6b0768..8fd9e8d9 100644 --- a/internal/fga/fga.go +++ b/internal/fga/fga.go @@ -37,8 +37,7 @@ const ( var ( userAgent = "openfga-cli/" + build.Version - ErrInvalidHeaderFormat = errors.New("expected format \"Header-Name: value\"") - ErrEmptyHeaderName = errors.New("header name must not be empty") + ErrEmptyHeaderName = errors.New("header name must not be empty") ) type ClientConfig struct { @@ -122,17 +121,13 @@ func (c ClientConfig) getCustomHeaders() (map[string]string, error) { headers := make(map[string]string, len(c.CustomHeaders)) for _, header := range c.CustomHeaders { - name, value, found := strings.Cut(header, ":") + name, value, _ := strings.Cut(header, ":") + name, value = strings.TrimSpace(name), strings.TrimSpace(value) - if !found { - return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat) - } - if name == "" && value == "" { - return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat) - } if name == "" { return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrEmptyHeaderName) } + headers[name] = value } diff --git a/internal/fga/fga_test.go b/internal/fga/fga_test.go index e097cede..b81c85a1 100644 --- a/internal/fga/fga_test.go +++ b/internal/fga/fga_test.go @@ -78,14 +78,16 @@ func TestGetCustomHeaders(t *testing.T) { }, }, { - name: "missing colon returns error", - headers: []string{"nocolon"}, - err: ErrInvalidHeaderFormat, + name: "no colon allowed", + headers: []string{"X-Custom"}, + expected: map[string]string{ + "X-Custom": "", + }, }, { name: "empty string returns error", headers: []string{""}, - err: ErrInvalidHeaderFormat, + err: ErrEmptyHeaderName, }, { name: "empty header name returns error", @@ -94,8 +96,8 @@ func TestGetCustomHeaders(t *testing.T) { }, { name: "valid header before invalid stops at first error", - headers: []string{"X-Good: ok", "bad-header"}, - err: ErrInvalidHeaderFormat, + headers: []string{"X-Good: ok", ""}, + err: ErrEmptyHeaderName, }, } @@ -153,6 +155,7 @@ func TestCustomHeadersSentInRequest(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { headersCh <- r.Header.Clone() + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"stores": []}`)) From ed898fd94c6fda48f242dc85169692766bfe2f39 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 16 Apr 2026 15:26:00 +0200 Subject: [PATCH 17/18] handle arrays the same way as slices --- internal/cmdutils/bind-viper-to-flags.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/cmdutils/bind-viper-to-flags.go b/internal/cmdutils/bind-viper-to-flags.go index 604fb080..8e24093e 100644 --- a/internal/cmdutils/bind-viper-to-flags.go +++ b/internal/cmdutils/bind-viper-to-flags.go @@ -47,14 +47,15 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) { // suitable for pflag.Set calls. Slice values (from YAML lists) produce one // string per element; scalar values produce a single-element slice. func viperValueToStrings(value any) []string { - rv := reflect.ValueOf(value) - if rv.Kind() != reflect.Slice { + reflectValue := reflect.ValueOf(value) + + if reflectValue.Kind() != reflect.Slice && reflectValue.Kind() != reflect.Array { return []string{fmt.Sprintf("%v", value)} } - result := make([]string, 0, rv.Len()) - for i := range rv.Len() { - result = append(result, fmt.Sprintf("%v", rv.Index(i).Interface())) + result := make([]string, 0, reflectValue.Len()) + for i := range reflectValue.Len() { + result = append(result, fmt.Sprintf("%v", reflectValue.Index(i).Interface())) } return result From a085d3da8b308656b34e7aee3670cefa85657ea8 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 16 Apr 2026 15:31:52 +0200 Subject: [PATCH 18/18] return ErrInvalidHeaderFormat instead --- internal/fga/fga.go | 4 ++-- internal/fga/fga_test.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/fga/fga.go b/internal/fga/fga.go index 8fd9e8d9..07ba66bd 100644 --- a/internal/fga/fga.go +++ b/internal/fga/fga.go @@ -37,7 +37,7 @@ const ( var ( userAgent = "openfga-cli/" + build.Version - ErrEmptyHeaderName = errors.New("header name must not be empty") + ErrInvalidHeaderFormat = errors.New("expected format \"Header-Name: value\"") ) type ClientConfig struct { @@ -125,7 +125,7 @@ func (c ClientConfig) getCustomHeaders() (map[string]string, error) { name, value = strings.TrimSpace(name), strings.TrimSpace(value) if name == "" { - return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrEmptyHeaderName) + return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat) } headers[name] = value diff --git a/internal/fga/fga_test.go b/internal/fga/fga_test.go index b81c85a1..ee90e26a 100644 --- a/internal/fga/fga_test.go +++ b/internal/fga/fga_test.go @@ -87,17 +87,17 @@ func TestGetCustomHeaders(t *testing.T) { { name: "empty string returns error", headers: []string{""}, - err: ErrEmptyHeaderName, + err: ErrInvalidHeaderFormat, }, { name: "empty header name returns error", headers: []string{": value"}, - err: ErrEmptyHeaderName, + err: ErrInvalidHeaderFormat, }, { name: "valid header before invalid stops at first error", headers: []string{"X-Good: ok", ""}, - err: ErrEmptyHeaderName, + err: ErrInvalidHeaderFormat, }, }