Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f5174c7
feat: support custom headers
alexanderzobnin Apr 8, 2026
6f9b420
add docs to readme
alexanderzobnin Apr 8, 2026
59399fb
avoid empty header
alexanderzobnin Apr 8, 2026
540a9ea
validate malformed custom headers and improve docs
alexanderzobnin Apr 15, 2026
80b85f9
use sentinel errors for custom header validation
alexanderzobnin Apr 15, 2026
0eba131
Merge branch 'main' into alexz/custom-headers
alexanderzobnin Apr 15, 2026
d9497fb
add integration test verifying custom headers are sent in requests
alexanderzobnin Apr 15, 2026
3d4df97
lint
alexanderzobnin Apr 15, 2026
181be80
chore: bump toolchain to 1.26.2
alexanderzobnin Apr 15, 2026
a1f2afe
fix viper binding for StringArray flags from YAML config
alexanderzobnin Apr 15, 2026
ea77157
lint
alexanderzobnin Apr 15, 2026
1890217
fix linter violations in viper binding
alexanderzobnin Apr 15, 2026
d05304d
fix linter
alexanderzobnin Apr 15, 2026
257d29f
refactor TestCustomHeadersSentInRequest to use a channel for capturin…
alexanderzobnin Apr 16, 2026
80efc8a
use strings.Cut
alexanderzobnin Apr 16, 2026
81a0323
Enhance viperValueToStrings to handle typed slices for strings and in…
alexanderzobnin Apr 16, 2026
f488c7a
softer validation rules
alexanderzobnin Apr 16, 2026
ed898fd
handle arrays the same way as slices
alexanderzobnin Apr 16, 2026
a085d3d
return ErrInvalidHeaderFormat instead
alexanderzobnin Apr 16, 2026
00d4e94
fix linters
alexanderzobnin Apr 17, 2026
853ffed
use viperInstance.GetStringSlice()
alexanderzobnin Apr 21, 2026
b814e5b
allow viper in linter
alexanderzobnin Apr 21, 2026
ed4e7cf
Merge branch 'main' into alexz/custom-headers
alexanderzobnin Apr 23, 2026
89919a0
add env var style test case
alexanderzobnin Apr 24, 2026
2e365cd
fix expected format
alexanderzobnin Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ linters:
main:
files:
- $all
- '!$test'
- "!$test"
allow:
- $gostd
- github.com/gocarina/gocsv
Expand Down Expand Up @@ -57,6 +57,8 @@ linters:
- github.com/openfga/openfga
- github.com/stretchr
- go.uber.org/mock/gomock
- github.com/spf13/cobra
- github.com/spf13/viper
funlen:
lines: 120
statements: 80
Expand Down
2 changes: 2 additions & 0 deletions .mise.toml
Comment thread
senojj marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
go = "1.26.2"
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -151,6 +152,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` |
Comment thread
alexanderzobnin marked this conversation as resolved.

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:

Expand All @@ -164,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 `<name>: <value>` 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"
```
Comment thread
alexanderzobnin marked this conversation as resolved.

### Commands

#### Stores
Expand Down
7 changes: 4 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +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().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")
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions internal/cmdutils/bind-viper-to-flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ limitations under the License.
package cmdutils

import (
"fmt"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
Expand All @@ -30,9 +28,9 @@ func BindViperToFlags(cmd *cobra.Command, viperInstance *viper.Viper) {
configName := flag.Name

if !flag.Changed && viperInstance.IsSet(configName) {
value := viperInstance.Get(configName)
err := cmd.Flags().Set(flag.Name, fmt.Sprintf("%v", value))
cobra.CheckErr(err)
for _, strVal := range viperInstance.GetStringSlice(configName) {
cobra.CheckErr(cmd.Flags().Set(flag.Name, strVal))
}
}
})

Expand Down
105 changes: 105 additions & 0 deletions internal/cmdutils/bind-viper-to-flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
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

import (
"testing"

"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBindViperToFlags(t *testing.T) {
t.Parallel()

const flagName = "header"

testcases := []struct {
name string
value any
expected []string
}{
{
name: "slice value produces one flag value per element",
value: []any{
"X-Custom-Header: value1",
"X-Request-ID: abc123",
},
expected: []string{"X-Custom-Header: value1", "X-Request-ID: abc123"},
},
Comment thread
senojj marked this conversation as resolved.
{
name: "single element slice",
value: []any{"X-Custom-Header: value1"},
expected: []string{"X-Custom-Header: value1"},
},
{
name: "empty slice leaves flag untouched",
value: []any{},
expected: []string{},
},
{
name: "typed string slice produces one flag value 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 flag value per element",
value: []int{1, 2, 3},
expected: []string{"1", "2", "3"},
},
{
name: "scalar string produces single flag value",
value: "https://api.fga.example",
expected: []string{"https://api.fga.example"},
},
{
name: "space separated scalar string (env var style) splits into multiple flag values",
value: "X-Custom-Header:value1 X-Request-ID:abc123",
expected: []string{"X-Custom-Header:value1", "X-Request-ID:abc123"},
},
{
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: "root"}
cmd.Flags().StringArray(flagName, nil, "")

viperInstance := viper.New()
viperInstance.Set(flagName, test.value)

BindViperToFlags(cmd, viperInstance)

got, err := cmd.Flags().GetStringArray(flagName)
require.NoError(t, err)
assert.Equal(t, test.expected, got)
})
}
}
2 changes: 2 additions & 0 deletions internal/cmdutils/get-client-config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -56,6 +57,7 @@ func GetClientConfig(cmd *cobra.Command) fga.ClientConfig {
ClientID: clientCredentialsClientID,
ClientSecret: clientCredentialsClientSecret,
APIScopes: clientCredentialsScopes,
CustomHeaders: customHeaders,
Debug: debug,
}
}
43 changes: 39 additions & 4 deletions internal/fga/fga.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ limitations under the License.
package fga

import (
"errors"
"fmt"
"strings"

openfga "github.com/openfga/go-sdk"
Expand All @@ -32,7 +34,11 @@ const (
MinSdkWaitInMs = 500
)

var userAgent = "openfga-cli/" + build.Version
var (
userAgent = "openfga-cli/" + build.Version

ErrInvalidHeaderFormat = errors.New("expected format \"Header-Name:value\"")
)

type ClientConfig struct {
ApiUrl string `json:"api_url,omitempty"` //nolint:revive,stylecheck
Expand All @@ -44,11 +50,17 @@ 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"`
}

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
}
Expand Down Expand Up @@ -84,7 +96,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,
Expand All @@ -95,6 +112,24 @@ func (c ClientConfig) getClientConfig() *client.ClientConfiguration {
MaxRetry: MaxSdkRetry,
MinWaitInMs: MinSdkWaitInMs,
},
Debug: c.Debug,
Debug: c.Debug,
DefaultHeaders: customHeaders,
}, nil
}

func (c ClientConfig) getCustomHeaders() (map[string]string, error) {
headers := make(map[string]string, len(c.CustomHeaders))

for _, header := range c.CustomHeaders {
name, value, _ := strings.Cut(header, ":")

name, value = strings.TrimSpace(name), strings.TrimSpace(value)
if name == "" {
return nil, fmt.Errorf("invalid custom header %q: %w", header, ErrInvalidHeaderFormat)
}
Comment thread
alexanderzobnin marked this conversation as resolved.

headers[name] = value
}

return headers, nil
}
Loading
Loading