Skip to content

Commit 225485f

Browse files
authored
Sim auth (#26)
* Add sim command foundation: auth config, HTTP client, parent commands, root registration - Extend authconfig.Config with SimAPIKey field for sim_api_key in config.yaml - Add SimClient HTTP wrapper (cmd/sim/client.go) with X-Sim-Api-Key auth header and structured HTTP error handling (400/401/404/429/500) - Add SetSimClient/SimClientFromCmd to cmdutil for context-based client injection - Create sim parent command with PersistentPreRunE that resolves sim API key (--sim-api-key flag > DUNE_SIM_API_KEY env > config file) - Create stub evm/svm parent commands for future subcommands - Register sim command in cli/root.go init() - All commands annotated with skipAuth to bypass Dune API key requirement * update start time
1 parent d1ef505 commit 225485f

9 files changed

Lines changed: 458 additions & 0 deletions

File tree

authconfig/authconfig.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
// Config holds the persisted CLI configuration.
1414
type Config struct {
1515
APIKey string `yaml:"api_key"`
16+
SimAPIKey string `yaml:"sim_api_key,omitempty"`
1617
Telemetry *bool `yaml:"telemetry,omitempty"`
1718
}
1819

cli/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/duneanalytics/cli/cmd/docs"
2121
"github.com/duneanalytics/cli/cmd/execution"
2222
"github.com/duneanalytics/cli/cmd/query"
23+
"github.com/duneanalytics/cli/cmd/sim"
2324
"github.com/duneanalytics/cli/cmd/usage"
2425
"github.com/duneanalytics/cli/cmdutil"
2526
"github.com/duneanalytics/cli/tracking"
@@ -106,6 +107,7 @@ func init() {
106107
rootCmd.AddCommand(query.NewQueryCmd())
107108
rootCmd.AddCommand(execution.NewExecutionCmd())
108109
rootCmd.AddCommand(usage.NewUsageCmd())
110+
rootCmd.AddCommand(sim.NewSimCmd())
109111
}
110112

111113
// Execute runs the root command via Fang.

cmd/sim/auth.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package sim
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/duneanalytics/cli/authconfig"
12+
)
13+
14+
// NewAuthCmd returns the `sim auth` command.
15+
func NewAuthCmd() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "auth",
18+
Short: "Authenticate with the Sim API",
19+
Long: "Save your Sim API key so you don't need to pass --sim-api-key or set DUNE_SIM_API_KEY every time.",
20+
Annotations: map[string]string{"skipSimAuth": "true"},
21+
RunE: runSimAuth,
22+
}
23+
24+
cmd.Flags().String("api-key", "", "Sim API key to save")
25+
26+
return cmd
27+
}
28+
29+
func runSimAuth(cmd *cobra.Command, _ []string) error {
30+
key, _ := cmd.Flags().GetString("api-key")
31+
32+
if key == "" {
33+
key = os.Getenv("DUNE_SIM_API_KEY")
34+
}
35+
36+
if key == "" {
37+
fmt.Fprint(cmd.ErrOrStderr(), "Enter your Sim API key: ")
38+
scanner := bufio.NewScanner(cmd.InOrStdin())
39+
if scanner.Scan() {
40+
key = strings.TrimSpace(scanner.Text())
41+
}
42+
}
43+
44+
if key == "" {
45+
return fmt.Errorf("no API key provided")
46+
}
47+
48+
cfg, err := authconfig.Load()
49+
if err != nil {
50+
return fmt.Errorf("loading existing config: %w", err)
51+
}
52+
if cfg == nil {
53+
cfg = &authconfig.Config{}
54+
}
55+
cfg.SimAPIKey = key
56+
if err := authconfig.Save(cfg); err != nil {
57+
return fmt.Errorf("saving config: %w", err)
58+
}
59+
60+
p, _ := authconfig.Path()
61+
fmt.Fprintf(cmd.OutOrStdout(), "Sim API key saved to %s\n", p)
62+
return nil
63+
}

cmd/sim/auth_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package sim_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
"github.com/duneanalytics/cli/authconfig"
12+
"github.com/duneanalytics/cli/cmd/sim"
13+
"github.com/spf13/cobra"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
"gopkg.in/yaml.v3"
17+
)
18+
19+
func setupAuthTest(t *testing.T) string {
20+
t.Helper()
21+
dir := t.TempDir()
22+
authconfig.SetDirFunc(func() (string, error) { return dir, nil })
23+
t.Cleanup(authconfig.ResetDirFunc)
24+
// Clear env var so it doesn't interfere with tests.
25+
t.Setenv("DUNE_SIM_API_KEY", "")
26+
return dir
27+
}
28+
29+
func newSimAuthRoot() *cobra.Command {
30+
root := &cobra.Command{Use: "dune"}
31+
root.SetContext(context.Background())
32+
33+
simCmd := sim.NewSimCmd()
34+
root.AddCommand(simCmd)
35+
36+
return root
37+
}
38+
39+
func TestSimAuth_WithFlag(t *testing.T) {
40+
dir := setupAuthTest(t)
41+
42+
root := newSimAuthRoot()
43+
var buf bytes.Buffer
44+
root.SetOut(&buf)
45+
root.SetArgs([]string{"sim", "auth", "--api-key", "sk_sim_flag_key"})
46+
require.NoError(t, root.Execute())
47+
48+
data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
49+
require.NoError(t, err)
50+
assert.Contains(t, string(data), "sk_sim_flag_key")
51+
assert.Contains(t, buf.String(), "Sim API key saved to")
52+
}
53+
54+
func TestSimAuth_WithEnvVar(t *testing.T) {
55+
dir := setupAuthTest(t)
56+
57+
t.Setenv("DUNE_SIM_API_KEY", "sk_sim_env_key")
58+
59+
root := newSimAuthRoot()
60+
var buf bytes.Buffer
61+
root.SetOut(&buf)
62+
root.SetArgs([]string{"sim", "auth"})
63+
require.NoError(t, root.Execute())
64+
65+
data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
66+
require.NoError(t, err)
67+
assert.Contains(t, string(data), "sk_sim_env_key")
68+
}
69+
70+
func TestSimAuth_WithPrompt(t *testing.T) {
71+
dir := setupAuthTest(t)
72+
73+
root := newSimAuthRoot()
74+
var buf bytes.Buffer
75+
root.SetOut(&buf)
76+
root.SetIn(strings.NewReader("sk_sim_prompt_key\n"))
77+
root.SetArgs([]string{"sim", "auth"})
78+
require.NoError(t, root.Execute())
79+
80+
data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
81+
require.NoError(t, err)
82+
assert.Contains(t, string(data), "sk_sim_prompt_key")
83+
}
84+
85+
func TestSimAuth_EmptyInput(t *testing.T) {
86+
setupAuthTest(t)
87+
88+
root := newSimAuthRoot()
89+
root.SetIn(strings.NewReader("\n"))
90+
root.SetArgs([]string{"sim", "auth"})
91+
err := root.Execute()
92+
assert.Error(t, err)
93+
assert.Contains(t, err.Error(), "no API key provided")
94+
}
95+
96+
func TestSimAuth_PreservesExistingConfig(t *testing.T) {
97+
dir := setupAuthTest(t)
98+
99+
// Pre-populate config with existing fields.
100+
existing := &authconfig.Config{
101+
APIKey: "existing_dune_key",
102+
}
103+
telemetryTrue := true
104+
existing.Telemetry = &telemetryTrue
105+
require.NoError(t, authconfig.Save(existing))
106+
107+
root := newSimAuthRoot()
108+
var buf bytes.Buffer
109+
root.SetOut(&buf)
110+
root.SetArgs([]string{"sim", "auth", "--api-key", "sk_sim_new"})
111+
require.NoError(t, root.Execute())
112+
113+
// Verify all fields are preserved.
114+
data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
115+
require.NoError(t, err)
116+
117+
var cfg authconfig.Config
118+
require.NoError(t, yaml.Unmarshal(data, &cfg))
119+
120+
assert.Equal(t, "existing_dune_key", cfg.APIKey, "existing api_key should be preserved")
121+
assert.Equal(t, "sk_sim_new", cfg.SimAPIKey, "sim_api_key should be set")
122+
require.NotNil(t, cfg.Telemetry, "telemetry should be preserved")
123+
assert.True(t, *cfg.Telemetry, "telemetry value should be preserved")
124+
}

cmd/sim/client.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package sim
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"time"
11+
)
12+
13+
const defaultBaseURL = "https://api.sim.dune.com"
14+
15+
// SimClient is a lightweight HTTP client for the Sim API.
16+
type SimClient struct {
17+
baseURL string
18+
apiKey string
19+
httpClient *http.Client
20+
}
21+
22+
// NewSimClient creates a new Sim API client with the given API key.
23+
func NewSimClient(apiKey string) *SimClient {
24+
return &SimClient{
25+
baseURL: defaultBaseURL,
26+
apiKey: apiKey,
27+
httpClient: &http.Client{
28+
Timeout: 30 * time.Second,
29+
},
30+
}
31+
}
32+
33+
// Get performs a GET request to the Sim API and returns the raw JSON response body.
34+
// The path should include the leading slash (e.g. "/v1/evm/supported-chains").
35+
// Query parameters are appended from params.
36+
func (c *SimClient) Get(ctx context.Context, path string, params url.Values) ([]byte, error) {
37+
u, err := url.Parse(c.baseURL + path)
38+
if err != nil {
39+
return nil, fmt.Errorf("invalid URL: %w", err)
40+
}
41+
if params != nil {
42+
u.RawQuery = params.Encode()
43+
}
44+
45+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
46+
if err != nil {
47+
return nil, fmt.Errorf("creating request: %w", err)
48+
}
49+
req.Header.Set("X-Sim-Api-Key", c.apiKey)
50+
req.Header.Set("Accept", "application/json")
51+
52+
resp, err := c.httpClient.Do(req)
53+
if err != nil {
54+
return nil, fmt.Errorf("request failed: %w", err)
55+
}
56+
defer resp.Body.Close()
57+
58+
body, err := io.ReadAll(resp.Body)
59+
if err != nil {
60+
return nil, fmt.Errorf("reading response: %w", err)
61+
}
62+
63+
if resp.StatusCode >= 400 {
64+
return nil, httpError(resp.StatusCode, body)
65+
}
66+
67+
return body, nil
68+
}
69+
70+
// httpError returns a user-friendly error for HTTP error status codes.
71+
func httpError(status int, body []byte) error {
72+
// Try to extract a message from the JSON error response.
73+
var errResp struct {
74+
Error string `json:"error"`
75+
Message string `json:"message"`
76+
}
77+
msg := ""
78+
if json.Unmarshal(body, &errResp) == nil {
79+
if errResp.Error != "" {
80+
msg = errResp.Error
81+
} else if errResp.Message != "" {
82+
msg = errResp.Message
83+
}
84+
}
85+
86+
switch status {
87+
case http.StatusBadRequest:
88+
if msg != "" {
89+
return fmt.Errorf("bad request: %s", msg)
90+
}
91+
return fmt.Errorf("bad request")
92+
case http.StatusUnauthorized:
93+
return fmt.Errorf("authentication failed: check your Sim API key")
94+
case http.StatusNotFound:
95+
if msg != "" {
96+
return fmt.Errorf("not found: %s", msg)
97+
}
98+
return fmt.Errorf("not found")
99+
case http.StatusTooManyRequests:
100+
return fmt.Errorf("rate limit exceeded: try again later")
101+
default:
102+
if status >= 500 {
103+
return fmt.Errorf("Sim API server error (HTTP %d): try again later", status)
104+
}
105+
if msg != "" {
106+
return fmt.Errorf("Sim API error (HTTP %d): %s", status, msg)
107+
}
108+
return fmt.Errorf("Sim API error (HTTP %d)", status)
109+
}
110+
}

cmd/sim/evm/evm.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package evm
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
// NewEvmCmd returns the `sim evm` parent command.
8+
func NewEvmCmd() *cobra.Command {
9+
cmd := &cobra.Command{
10+
Use: "evm",
11+
Short: "Query EVM chain data (balances, activity, transactions, etc.)",
12+
Long: "Access real-time EVM blockchain data including token balances, activity feeds,\n" +
13+
"transaction history, NFT collectibles, token metadata, token holders,\n" +
14+
"and DeFi positions.",
15+
}
16+
17+
// Subcommands will be added here as they are implemented.
18+
19+
return cmd
20+
}

0 commit comments

Comments
 (0)