Skip to content

Commit 5826777

Browse files
feat: support custom headers (#670)
* feat: support custom headers * add docs to readme * avoid empty header * validate malformed custom headers and improve docs * use sentinel errors for custom header validation * add integration test verifying custom headers are sent in requests * lint * chore: bump toolchain to 1.26.2 * fix viper binding for StringArray flags from YAML config * lint * fix linter violations in viper binding * fix linter * refactor TestCustomHeadersSentInRequest to use a channel for capturing headers * use strings.Cut * Enhance viperValueToStrings to handle typed slices for strings and ints in BindViperToFlags * softer validation rules * handle arrays the same way as slices * return ErrInvalidHeaderFormat instead * fix linters * use viperInstance.GetStringSlice() * allow viper in linter * add env var style test case * fix expected format
1 parent 6e2a665 commit 5826777

12 files changed

Lines changed: 383 additions & 19 deletions

File tree

.golangci.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ linters:
2121
main:
2222
files:
2323
- $all
24-
- '!$test'
24+
- "!$test"
2525
allow:
2626
- $gostd
2727
- github.com/gocarina/gocsv
@@ -57,6 +57,8 @@ linters:
5757
- github.com/openfga/openfga
5858
- github.com/stretchr
5959
- go.uber.org/mock/gomock
60+
- github.com/spf13/cobra
61+
- github.com/spf13/viper
6062
funlen:
6163
lines: 120
6264
statements: 80

.mise.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[tools]
2+
go = "1.26.2"

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ A cross-platform CLI to interact with an OpenFGA server
1717
- [Building from Source](#building-from-source)
1818
- [Usage](#usage)
1919
- [Configuration](#configuration)
20+
- [Custom Headers](#custom-headers)
2021
- [Commands](#commands)
2122
- [Stores](#stores)
2223
- [List All Stores](#list-stores)
@@ -151,6 +152,7 @@ For any command that interacts with an OpenFGA server, these configuration value
151152
| Token Audience | `--api-audience` | `FGA_API_AUDIENCE` | `api-audience` |
152153
| Store ID | `--store-id` | `FGA_STORE_ID` | `store-id` |
153154
| Authorization Model ID | `--model-id` | `FGA_MODEL_ID` | `model-id` |
155+
| Custom Headers | `--custom-headers` | `FGA_CUSTOM_HEADERS` | `custom-headers` |
154156

155157
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:
156158

@@ -164,6 +166,37 @@ api-token-issuer: auth.fga.dev
164166
store-id: 01H0H015178Y2V4CX10C2KGHF4
165167
```
166168

169+
#### Custom Headers
170+
171+
You can add custom HTTP headers to all requests sent to the API using the `--custom-headers` flag. Headers are specified in `<name>: <value>` format, and the flag can be repeated to add multiple headers.
172+
173+
##### Flag
174+
```shell
175+
--custom-headers "Header-Name: header-value"
176+
```
177+
178+
##### Example
179+
```shell
180+
fga store list --custom-headers "X-Custom-Header: value1" --custom-headers "X-Request-ID: abc123"
181+
```
182+
183+
##### Configuration
184+
185+
Custom headers can also be configured via the CLI environment variable or the configuration file:
186+
187+
| Name | Flag | CLI | ~/.fga.yaml |
188+
|----------------|----------------------|------------------------|---------------------|
189+
| Custom Headers | `--custom-headers` | `FGA_CUSTOM_HEADERS` | `custom-headers` |
190+
191+
Example `~/.fga.yaml`:
192+
```yaml
193+
api-url: https://api.fga.example
194+
store-id: 01H0H015178Y2V4CX10C2KGHF4
195+
custom-headers:
196+
- "X-Custom-Header: value1"
197+
- "X-Request-ID: abc123"
198+
```
199+
167200
### Commands
168201
169202
#### Stores

cmd/root.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ func init() {
6565
rootCmd.PersistentFlags().String("api-token", "", "API Token. Will be sent in as a Bearer in the Authorization header")
6666
rootCmd.PersistentFlags().String("api-token-issuer", "", "API Token Issuer. API responsible for issuing the API Token. Used in the Client Credentials flow") //nolint:lll
6767
rootCmd.PersistentFlags().String("api-audience", "", "API Audience. Used when performing the Client Credentials flow")
68-
rootCmd.PersistentFlags().String("client-id", "", "Client ID. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll
69-
rootCmd.PersistentFlags().String("client-secret", "", "Client Secret. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll
70-
rootCmd.PersistentFlags().StringArray("api-scopes", []string{}, "API Scopes (repeat option for multiple values). Used in the Client Credentials flow") //nolint:lll
68+
rootCmd.PersistentFlags().String("client-id", "", "Client ID. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll
69+
rootCmd.PersistentFlags().String("client-secret", "", "Client Secret. Sent to the Token Issuer during the Client Credentials flow") //nolint:lll
70+
rootCmd.PersistentFlags().StringArray("api-scopes", []string{}, "API Scopes (repeat option for multiple values). Used in the Client Credentials flow") //nolint:lll
71+
rootCmd.PersistentFlags().StringArray("custom-headers", []string{}, "Custom HTTP headers in 'Header: value' format (repeat option for multiple values)") //nolint:lll
7172
rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode - can print more detailed information for debugging")
7273

7374
_ = rootCmd.Flags().MarkHidden("debug")

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ module github.com/openfga/cli
22

33
go 1.25.0
44

5-
toolchain go1.26.1
5+
toolchain go1.26.2
66

77
require (
88
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1

internal/cmdutils/bind-viper-to-flags.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ limitations under the License.
1717
package cmdutils
1818

1919
import (
20-
"fmt"
21-
2220
"github.com/spf13/cobra"
2321
"github.com/spf13/pflag"
2422
"github.com/spf13/viper"
@@ -30,9 +28,9 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) {
3028
configName := flag.Name
3129

3230
if !flag.Changed && viperInstance.IsSet(configName) {
33-
value := viperInstance.Get(configName)
34-
err := cmd.Flags().Set(flag.Name, fmt.Sprintf("%v", value))
35-
cobra.CheckErr(err)
31+
for _, strVal := range viperInstance.GetStringSlice(configName) {
32+
cobra.CheckErr(cmd.Flags().Set(flag.Name, strVal))
33+
}
3634
}
3735
})
3836

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
Copyright © 2023 OpenFGA
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cmdutils
18+
19+
import (
20+
"testing"
21+
22+
"github.com/spf13/cobra"
23+
"github.com/spf13/viper"
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
func TestBindViperToFlags(t *testing.T) {
29+
t.Parallel()
30+
31+
const flagName = "header"
32+
33+
testcases := []struct {
34+
name string
35+
value any
36+
expected []string
37+
}{
38+
{
39+
name: "slice value produces one flag value per element",
40+
value: []any{
41+
"X-Custom-Header: value1",
42+
"X-Request-ID: abc123",
43+
},
44+
expected: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"},
45+
},
46+
{
47+
name: "single element slice",
48+
value: []any{"X-Custom-Header: value1"},
49+
expected: []string{"X-Custom-Header: value1"},
50+
},
51+
{
52+
name: "empty slice leaves flag untouched",
53+
value: []any{},
54+
expected: []string{},
55+
},
56+
{
57+
name: "typed string slice produces one flag value per element",
58+
value: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"},
59+
expected: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"},
60+
},
61+
{
62+
name: "typed int slice produces one flag value per element",
63+
value: []int{1, 2, 3},
64+
expected: []string{"1", "2", "3"},
65+
},
66+
{
67+
name: "scalar string produces single flag value",
68+
value: "https://api.fga.example",
69+
expected: []string{"https://api.fga.example"},
70+
},
71+
{
72+
name: "space separated scalar string (env var style) splits into multiple flag values",
73+
value: "X-Custom-Header:value1 X-Request-ID:abc123",
74+
expected: []string{"X-Custom-Header:value1", "X-Request-ID:abc123"},
75+
},
76+
{
77+
name: "boolean value is stringified",
78+
value: true,
79+
expected: []string{"true"},
80+
},
81+
{
82+
name: "integer value is stringified",
83+
value: 42,
84+
expected: []string{"42"},
85+
},
86+
}
87+
88+
for _, test := range testcases {
89+
t.Run(test.name, func(t *testing.T) {
90+
t.Parallel()
91+
92+
cmd := &cobra.Command{Use: "root"}
93+
cmd.Flags().StringArray(flagName, nil, "")
94+
95+
viperInstance := viper.New()
96+
viperInstance.Set(flagName, test.value)
97+
98+
BindViperToFlags(cmd, viperInstance)
99+
100+
got, err := cmd.Flags().GetStringArray(flagName)
101+
require.NoError(t, err)
102+
assert.Equal(t, test.expected, got)
103+
})
104+
}
105+
}

internal/cmdutils/get-client-config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func GetClientConfig(cmd *cobra.Command) fga.ClientConfig {
4444
clientCredentialsClientID, _ := cmd.Flags().GetString("client-id")
4545
clientCredentialsClientSecret, _ := cmd.Flags().GetString("client-secret")
4646
clientCredentialsScopes, _ := cmd.Flags().GetStringArray("api-scopes")
47+
customHeaders, _ := cmd.Flags().GetStringArray("custom-headers")
4748
debug, _ := cmd.Flags().GetBool("debug")
4849

4950
return fga.ClientConfig{
@@ -56,6 +57,7 @@ func GetClientConfig(cmd *cobra.Command) fga.ClientConfig {
5657
ClientID: clientCredentialsClientID,
5758
ClientSecret: clientCredentialsClientSecret,
5859
APIScopes: clientCredentialsScopes,
60+
CustomHeaders: customHeaders,
5961
Debug: debug,
6062
}
6163
}

internal/fga/fga.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ limitations under the License.
1818
package fga
1919

2020
import (
21+
"errors"
22+
"fmt"
2123
"strings"
2224

2325
openfga "github.com/openfga/go-sdk"
@@ -32,7 +34,11 @@ const (
3234
MinSdkWaitInMs = 500
3335
)
3436

35-
var userAgent = "openfga-cli/" + build.Version
37+
var (
38+
userAgent = "openfga-cli/" + build.Version
39+
40+
ErrInvalidHeaderFormat = errors.New("expected format \"Header-Name:value\"")
41+
)
3642

3743
type ClientConfig struct {
3844
ApiUrl string `json:"api_url,omitempty"` //nolint:revive,stylecheck
@@ -44,11 +50,17 @@ type ClientConfig struct {
4450
APIScopes []string `json:"api_scopes,omitempty"`
4551
ClientID string `json:"client_id,omitempty"`
4652
ClientSecret string `json:"client_secret,omitempty"` //nolint:gosec
53+
CustomHeaders []string `json:"custom_headers,omitempty"`
4754
Debug bool `json:"debug,omitempty"`
4855
}
4956

5057
func (c ClientConfig) GetFgaClient() (*client.OpenFgaClient, error) {
51-
fgaClient, err := client.NewSdkClient(c.getClientConfig())
58+
clientConfig, err := c.getClientConfig()
59+
if err != nil {
60+
return nil, err
61+
}
62+
63+
fgaClient, err := client.NewSdkClient(clientConfig)
5264
if err != nil {
5365
return nil, err //nolint:wrapcheck
5466
}
@@ -84,7 +96,12 @@ func (c ClientConfig) getCredentials() *credentials.Credentials {
8496
}
8597
}
8698

87-
func (c ClientConfig) getClientConfig() *client.ClientConfiguration {
99+
func (c ClientConfig) getClientConfig() (*client.ClientConfiguration, error) {
100+
customHeaders, err := c.getCustomHeaders()
101+
if err != nil {
102+
return nil, fmt.Errorf("invalid custom headers configuration: %w", err)
103+
}
104+
88105
return &client.ClientConfiguration{
89106
ApiUrl: c.ApiUrl,
90107
StoreId: c.StoreID,
@@ -95,6 +112,24 @@ func (c ClientConfig) getClientConfig() *client.ClientConfiguration {
95112
MaxRetry: MaxSdkRetry,
96113
MinWaitInMs: MinSdkWaitInMs,
97114
},
98-
Debug: c.Debug,
115+
Debug: c.Debug,
116+
DefaultHeaders: customHeaders,
117+
}, nil
118+
}
119+
120+
func (c ClientConfig) getCustomHeaders() (map[string]string, error) {
121+
headers := make(map[string]string, len(c.CustomHeaders))
122+
123+
for _, header := range c.CustomHeaders {
124+
name, value, _ := strings.Cut(header, ":")
125+
126+
name, value = strings.TrimSpace(name), strings.TrimSpace(value)
127+
if name == "" {
128+
return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat)
129+
}
130+
131+
headers[name] = value
99132
}
133+
134+
return headers, nil
100135
}

0 commit comments

Comments
 (0)