Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,29 @@ go install github.com/andreagrandi/mb-cli/cmd/mb@latest

## Configuration

Set two environment variables:
Set `MB_HOST` and **one** of the two authentication variables:

### Option 1: API key (recommended for long-lived access)

```bash
export MB_HOST=https://your-metabase-instance.com
export MB_API_KEY=your-api-key
```

Both are required. `MB_HOST` is the base URL of your Metabase instance. `MB_API_KEY` is a [Metabase API key](https://www.metabase.com/docs/latest/people-and-groups/api-keys).
`MB_API_KEY` is a [Metabase API key](https://www.metabase.com/docs/latest/people-and-groups/api-keys). Requires admin access to generate.

### Option 2: Session token (when you don't have API key access)

```bash
export MB_HOST=https://your-metabase-instance.com
export MB_SESSION_TOKEN=your-session-token
```

To get your session token: open your Metabase instance in Chrome → DevTools → Application → Cookies → copy the `metabase.SESSION` value. Session tokens expire when you log out or after the server's session timeout.

Setting both `MB_API_KEY` and `MB_SESSION_TOKEN` is an error — use one or the other.

Optional:
### Optional

```bash
export MB_REDACT_PII=false # Disable PII redaction (enabled by default)
Expand Down
20 changes: 13 additions & 7 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ type HTTPDoer interface {

// Client represents the Metabase API client.
type Client struct {
BaseURL string
HTTPClient HTTPDoer
APIKey string
Verbose bool
RedactPII bool
BaseURL string
HTTPClient HTTPDoer
APIKey string
SessionToken string
Verbose bool
RedactPII bool
}

// NewClient creates a new Metabase API client from the provided config.
Expand All @@ -37,13 +38,18 @@ func NewClient(cfg *config.Config) *Client {
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
APIKey: cfg.APIKey,
APIKey: cfg.APIKey,
SessionToken: cfg.SessionToken,
}
}

// Do executes an HTTP request with authentication headers and error handling.
func (c *Client) Do(req *http.Request) (*http.Response, error) {
req.Header.Set("x-api-key", c.APIKey)
if c.SessionToken != "" {
req.Header.Set("X-Metabase-Session", c.SessionToken)
} else {
req.Header.Set("x-api-key", c.APIKey)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", UserAgent)

Expand Down
20 changes: 14 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import (
)

type Config struct {
Host string
APIKey string
Host string
APIKey string
SessionToken string
}

func LoadConfig() (*Config, error) {
Expand All @@ -17,12 +18,19 @@ func LoadConfig() (*Config, error) {
}

apiKey := os.Getenv("MB_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("MB_API_KEY environment variable is required")
sessionToken := os.Getenv("MB_SESSION_TOKEN")

if apiKey == "" && sessionToken == "" {
return nil, fmt.Errorf("either MB_API_KEY or MB_SESSION_TOKEN environment variable is required")
}

if apiKey != "" && sessionToken != "" {
return nil, fmt.Errorf("MB_API_KEY and MB_SESSION_TOKEN are mutually exclusive, set only one")
}

return &Config{
Host: host,
APIKey: apiKey,
Host: host,
APIKey: apiKey,
SessionToken: sessionToken,
}, nil
}
109 changes: 109 additions & 0 deletions tests/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,112 @@ func TestVerboseMode(t *testing.T) {
}
defer resp.Body.Close()
}


func newTestClientWithSessionToken(serverURL string) *client.Client {
cfg := &config.Config{
Host: serverURL,
SessionToken: "test-session-token",
}
return client.NewClient(cfg)
}

func TestNewClient_WithSessionToken(t *testing.T) {
cfg := &config.Config{
Host: "https://metabase.example.com",
SessionToken: "my-session-token",
}

c := client.NewClient(cfg)

if c.BaseURL != "https://metabase.example.com" {
t.Errorf("expected base URL 'https://metabase.example.com', got '%s'", c.BaseURL)
}

if c.SessionToken != "my-session-token" {
t.Errorf("expected session token 'my-session-token', got '%s'", c.SessionToken)
}
}

func TestDo_SetsSessionTokenHeader(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionToken := r.Header.Get("X-Metabase-Session")
if sessionToken != "test-session-token" {
t.Errorf("expected X-Metabase-Session 'test-session-token', got '%s'", sessionToken)
}

apiKey := r.Header.Get("x-api-key")
if apiKey != "" {
t.Errorf("expected no x-api-key header when using session token, got '%s'", apiKey)
}

contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
t.Errorf("expected Content-Type 'application/json', got '%s'", contentType)
}

w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok": true}`))
}))
defer server.Close()

c := newTestClientWithSessionToken(server.URL)

req, err := http.NewRequest("GET", server.URL+"/test", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}

resp, err := c.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
}


func TestGet_WithSessionToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionToken := r.Header.Get("X-Metabase-Session")
if sessionToken != "test-session-token" {
t.Errorf("expected X-Metabase-Session header, got '%s'", sessionToken)
}

w.WriteHeader(http.StatusOK)
w.Write([]byte(`[]`))
}))
defer server.Close()

c := newTestClientWithSessionToken(server.URL)

resp, err := c.Get("/api/database/", nil)
if err != nil {
t.Fatalf("GET request failed: %v", err)
}
defer resp.Body.Close()
}

func TestPost_WithSessionToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionToken := r.Header.Get("X-Metabase-Session")
if sessionToken != "test-session-token" {
t.Errorf("expected X-Metabase-Session header, got '%s'", sessionToken)
}

w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
}))
defer server.Close()

c := newTestClientWithSessionToken(server.URL)

resp, err := c.Post("/api/dataset/", map[string]any{"query": "SELECT 1"})
if err != nil {
t.Fatalf("POST request failed: %v", err)
}
defer resp.Body.Close()
}
56 changes: 52 additions & 4 deletions tests/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
func TestLoadConfig_Success(t *testing.T) {
os.Setenv("MB_HOST", "https://metabase.example.com")
os.Setenv("MB_API_KEY", "test-api-key")
os.Unsetenv("MB_SESSION_TOKEN")
defer os.Unsetenv("MB_HOST")
defer os.Unsetenv("MB_API_KEY")

Expand All @@ -27,9 +28,54 @@ func TestLoadConfig_Success(t *testing.T) {
}
}

func TestLoadConfig_SessionTokenOnly(t *testing.T) {
os.Setenv("MB_HOST", "https://metabase.example.com")
os.Unsetenv("MB_API_KEY")
os.Setenv("MB_SESSION_TOKEN", "test-session-token")
defer os.Unsetenv("MB_HOST")
defer os.Unsetenv("MB_SESSION_TOKEN")

cfg, err := config.LoadConfig()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}

if cfg.Host != "https://metabase.example.com" {
t.Errorf("expected host 'https://metabase.example.com', got '%s'", cfg.Host)
}

if cfg.SessionToken != "test-session-token" {
t.Errorf("expected session token 'test-session-token', got '%s'", cfg.SessionToken)
}

if cfg.APIKey != "" {
t.Errorf("expected empty api key, got '%s'", cfg.APIKey)
}
}

func TestLoadConfig_BothAuthMethodsErrors(t *testing.T) {
os.Setenv("MB_HOST", "https://metabase.example.com")
os.Setenv("MB_API_KEY", "test-api-key")
os.Setenv("MB_SESSION_TOKEN", "test-session-token")
defer os.Unsetenv("MB_HOST")
defer os.Unsetenv("MB_API_KEY")
defer os.Unsetenv("MB_SESSION_TOKEN")

_, err := config.LoadConfig()
if err == nil {
t.Fatal("expected error when both MB_API_KEY and MB_SESSION_TOKEN are set")
}

expected := "MB_API_KEY and MB_SESSION_TOKEN are mutually exclusive, set only one"
if err.Error() != expected {
t.Errorf("expected error '%s', got '%s'", expected, err.Error())
}
}

func TestLoadConfig_MissingHost(t *testing.T) {
os.Unsetenv("MB_HOST")
os.Setenv("MB_API_KEY", "test-api-key")
os.Unsetenv("MB_SESSION_TOKEN")
defer os.Unsetenv("MB_API_KEY")

_, err := config.LoadConfig()
Expand All @@ -43,17 +89,18 @@ func TestLoadConfig_MissingHost(t *testing.T) {
}
}

func TestLoadConfig_MissingAPIKey(t *testing.T) {
func TestLoadConfig_NoAuthMethod(t *testing.T) {
os.Setenv("MB_HOST", "https://metabase.example.com")
os.Unsetenv("MB_API_KEY")
os.Unsetenv("MB_SESSION_TOKEN")
defer os.Unsetenv("MB_HOST")

_, err := config.LoadConfig()
if err == nil {
t.Fatal("expected error when MB_API_KEY is missing")
t.Fatal("expected error when neither MB_API_KEY nor MB_SESSION_TOKEN is set")
}

expected := "MB_API_KEY environment variable is required"
expected := "either MB_API_KEY or MB_SESSION_TOKEN environment variable is required"
if err.Error() != expected {
t.Errorf("expected error '%s', got '%s'", expected, err.Error())
}
Expand All @@ -62,9 +109,10 @@ func TestLoadConfig_MissingAPIKey(t *testing.T) {
func TestLoadConfig_BothMissing(t *testing.T) {
os.Unsetenv("MB_HOST")
os.Unsetenv("MB_API_KEY")
os.Unsetenv("MB_SESSION_TOKEN")

_, err := config.LoadConfig()
if err == nil {
t.Fatal("expected error when both env vars are missing")
t.Fatal("expected error when all env vars are missing")
}
}