diff --git a/AGENTS.md b/AGENTS.md index 2e0aace..6ffe232 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1 +1,15 @@ # Dear Agent + +This is a CLI tool for the Honeycomb.io API, written in Go. + +## Structure + +- `main.go` - entry point +- `cmd/` - cobra command definitions (root, version, etc.) +- `honeycomb/` - API client package + +## Install + +```shell +go install github.com/maragudk/honeycomb-cli@latest +``` diff --git a/README.md b/README.md index d9ced71..7abdf8f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,26 @@ -# template +# honeycomb-cli -[![Docs](https://pkg.go.dev/badge/maragu.dev/template)](https://pkg.go.dev/maragu.dev/template) -[![CI](https://github.com/maragudk/template/actions/workflows/ci.yml/badge.svg)](https://github.com/maragudk/template/actions/workflows/ci.yml) +[![CI](https://github.com/maragudk/honeycomb-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/maragudk/honeycomb-cli/actions/workflows/ci.yml) -Made with ✨sparkles✨ by [maragu](https://www.maragu.dev/): independent software consulting for cloud-native Go apps & AI engineering. +A command-line interface for the [Honeycomb.io](https://www.honeycomb.io/) observability platform. -[Contact me at markus@maragu.dk](mailto:markus@maragu.dk) for consulting work, or perhaps an invoice to support this project? +Made with sparkles by [maragu](https://www.maragu.dev/). + +## Install + +```shell +go install github.com/maragudk/honeycomb-cli@latest +``` + +## Usage + +```shell +# Set your API key +export HONEYCOMB_API_KEY=your-api-key + +# Or pass it as a flag +honeycomb-cli --api-key your-api-key + +# Verify your API key +honeycomb-cli auth +``` diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 0000000..f289164 --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func newAuthCommand() *cobra.Command { + return &cobra.Command{ + Use: "auth", + Short: "Verify your API key and show team/environment info", + RunE: func(cmd *cobra.Command, args []string) error { + key := apiKey(cmd) + if key == "" { + return fmt.Errorf("API key is required (set HONEYCOMB_API_KEY or use --api-key)") + } + + c := honeycomb.NewClient(key, honeycomb.WithBaseURL(apiURL(cmd))) + auth, err := c.Auth(cmd.Context()) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Team: %v\n", auth.Team.Name) + fmt.Fprintf(cmd.OutOrStdout(), "Environment: %v\n", auth.Environment.Name) + + if len(auth.APIKeyAccess) > 0 { + fmt.Fprintln(cmd.OutOrStdout(), "Permissions:") + for perm, granted := range auth.APIKeyAccess { + if granted { + fmt.Fprintf(cmd.OutOrStdout(), " %v\n", perm) + } + } + } + return nil + }, + } +} diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 0000000..a525d69 --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,70 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/cmd" + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestAuthCommand(t *testing.T) { + t.Run("displays team and environment info", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(honeycomb.AuthResponse{ + Team: honeycomb.AuthTeam{ + Name: "Acme Corp", + }, + Environment: honeycomb.AuthEnvironment{ + Name: "Production", + }, + APIKeyAccess: map[string]bool{ + "events": true, + }, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"auth", "--api-key", "test-key", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + output := buf.String() + is.True(t, contains(output, "Acme Corp")) + is.True(t, contains(output, "Production")) + is.True(t, contains(output, "events")) + }) + + t.Run("returns error when API key is missing", func(t *testing.T) { + root := cmd.NewRootCommand() + root.SetArgs([]string{"auth"}) + + // Clear the env var for this test + t.Setenv("HONEYCOMB_API_KEY", "") + + err := root.Execute() + is.True(t, err != nil) + }) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/cmd/datasets.go b/cmd/datasets.go new file mode 100644 index 0000000..564eff0 --- /dev/null +++ b/cmd/datasets.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func newDatasetsCommand() *cobra.Command { + datasetsCmd := &cobra.Command{ + Use: "datasets", + Short: "Manage datasets", + } + + datasetsCmd.AddCommand(newDatasetsListCommand()) + datasetsCmd.AddCommand(newDatasetsGetCommand()) + datasetsCmd.AddCommand(newDatasetsCreateCommand()) + datasetsCmd.AddCommand(newDatasetsDeleteCommand()) + + return datasetsCmd +} + +func newDatasetsListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all datasets", + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + + datasets, err := c.ListDatasets(cmd.Context()) + if err != nil { + return err + } + + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(datasets) + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tSLUG\tDESCRIPTION\tLAST WRITTEN") + for _, d := range datasets { + fmt.Fprintf(w, "%v\t%v\t%v\t%v\n", d.Name, d.Slug, d.Description, d.LastWrittenAt) + } + return w.Flush() + }, + } + cmd.Flags().Bool("json", false, "Output as JSON") + return cmd +} + +func newDatasetsGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a dataset by slug", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + + dataset, err := c.GetDataset(cmd.Context(), args[0]) + if err != nil { + return err + } + + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(dataset) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Name: %v\n", dataset.Name) + fmt.Fprintf(cmd.OutOrStdout(), "Slug: %v\n", dataset.Slug) + fmt.Fprintf(cmd.OutOrStdout(), "Description: %v\n", dataset.Description) + fmt.Fprintf(cmd.OutOrStdout(), "Last written: %v\n", dataset.LastWrittenAt) + return nil + }, + } + cmd.Flags().Bool("json", false, "Output as JSON") + return cmd +} + +func newDatasetsCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a dataset", + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + + dataset, err := c.CreateDataset(cmd.Context(), honeycomb.CreateDatasetRequest{ + Name: name, + Description: description, + }) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Created dataset %q (%v)\n", dataset.Name, dataset.Slug) + return nil + }, + } + cmd.Flags().String("name", "", "Dataset name") + _ = cmd.MarkFlagRequired("name") + cmd.Flags().String("description", "", "Dataset description") + return cmd +} + +func newDatasetsDeleteCommand() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a dataset", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + + if err := c.DeleteDataset(cmd.Context(), args[0]); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Deleted dataset %q\n", args[0]) + return nil + }, + } +} diff --git a/cmd/datasets_test.go b/cmd/datasets_test.go new file mode 100644 index 0000000..96b9ee7 --- /dev/null +++ b/cmd/datasets_test.go @@ -0,0 +1,130 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/cmd" + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestDatasetsListCommand(t *testing.T) { + t.Run("lists datasets in a table", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]honeycomb.Dataset{ + {Name: "requests", Slug: "requests", Description: "HTTP requests"}, + {Name: "errors", Slug: "errors"}, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "list", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + output := buf.String() + is.True(t, contains(output, "requests")) + is.True(t, contains(output, "errors")) + is.True(t, contains(output, "NAME")) + }) + + t.Run("lists datasets as JSON", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]honeycomb.Dataset{ + {Name: "requests", Slug: "requests"}, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "list", "--json", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + var datasets []honeycomb.Dataset + is.NotError(t, json.Unmarshal(buf.Bytes(), &datasets)) + is.Equal(t, 1, len(datasets)) + is.Equal(t, "requests", datasets[0].Name) + }) +} + +func TestDatasetsGetCommand(t *testing.T) { + t.Run("shows dataset details", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(honeycomb.Dataset{ + Name: "requests", + Slug: "requests", + Description: "HTTP requests", + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "get", "requests", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + output := buf.String() + is.True(t, contains(output, "requests")) + is.True(t, contains(output, "HTTP requests")) + }) +} + +func TestDatasetsCreateCommand(t *testing.T) { + t.Run("creates a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(honeycomb.Dataset{ + Name: "new-dataset", + Slug: "new-dataset", + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "create", "--name", "new-dataset", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + is.True(t, contains(buf.String(), "new-dataset")) + }) +} + +func TestDatasetsDeleteCommand(t *testing.T) { + t.Run("deletes a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "delete", "old-dataset", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + is.True(t, contains(buf.String(), "old-dataset")) + }) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..c94bbb2 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +// NewRootCommand creates a new root command with all subcommands registered. +func NewRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "honeycomb-cli", + Short: "A CLI for the Honeycomb.io API", + Long: "A command-line interface for interacting with the Honeycomb.io observability platform.", + SilenceUsage: true, + SilenceErrors: true, + } + + root.PersistentFlags().String("api-key", "", "Honeycomb API key (or set HONEYCOMB_API_KEY)") + root.PersistentFlags().String("api-url", "https://api.honeycomb.io", "Honeycomb API URL (or set HONEYCOMB_API_URL)") + + root.AddCommand(newVersionCommand()) + root.AddCommand(newAuthCommand()) + root.AddCommand(newDatasetsCommand()) + + return root +} + +// Execute the root command and return an exit code. +func Execute() int { + if err := NewRootCommand().Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} + +// apiKey returns the API key from the flag or environment variable. +func apiKey(cmd *cobra.Command) string { + key, _ := cmd.Flags().GetString("api-key") + if key != "" { + return key + } + return os.Getenv("HONEYCOMB_API_KEY") +} + +// apiURL returns the API URL from the flag or environment variable. +func apiURL(cmd *cobra.Command) string { + url, _ := cmd.Flags().GetString("api-url") + if url != "https://api.honeycomb.io" { + return url + } + if envURL := os.Getenv("HONEYCOMB_API_URL"); envURL != "" { + return envURL + } + return url +} + +// newClient creates a new Honeycomb API client from the command's flags. +func newClient(cmd *cobra.Command) *honeycomb.Client { + return honeycomb.NewClient(apiKey(cmd), honeycomb.WithBaseURL(apiURL(cmd))) +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..5c7b0cd --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,16 @@ +package cmd_test + +import ( + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/cmd" +) + +func TestExecute(t *testing.T) { + t.Run("returns 0 on success", func(t *testing.T) { + code := cmd.Execute() + is.Equal(t, 0, code) + }) +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..30e77a4 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// version is set at build time via -ldflags. +var version = "dev" + +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintln(cmd.OutOrStdout(), version) + }, + } +} diff --git a/go.mod b/go.mod index f52a35e..38a8e78 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,13 @@ -module maragu.dev/template +module github.com/maragudk/honeycomb-cli go 1.26 + +require ( + github.com/spf13/cobra v1.10.2 + maragu.dev/is v0.3.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bf67f6c --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +maragu.dev/is v0.3.1 h1:1sj4Ewc9Ecqtvp1Aro+kRCpnuu4D5CB8w//GOfM7jFs= +maragu.dev/is v0.3.1/go.mod h1:bviaM5S0fBshCw7wuumFGTju/izopZ/Yvq4g7Klc7y8= diff --git a/honeycomb/auth.go b/honeycomb/auth.go new file mode 100644 index 0000000..522ab91 --- /dev/null +++ b/honeycomb/auth.go @@ -0,0 +1,48 @@ +package honeycomb + +import ( + "context" + "encoding/json" + "net/http" +) + +// AuthResponse from the Honeycomb auth endpoint. +type AuthResponse struct { + Team AuthTeam `json:"team"` + Environment AuthEnvironment `json:"environment"` + APIKeyAccess map[string]bool `json:"api_key_access"` +} + +// AuthTeam information. +type AuthTeam struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +// AuthEnvironment information. +type AuthEnvironment struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +// Auth verifies the API key and returns team and environment information. +func (c *Client) Auth(ctx context.Context) (*AuthResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/1/auth", nil) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var auth AuthResponse + if err := json.NewDecoder(res.Body).Decode(&auth); err != nil { + return nil, err + } + return &auth, nil +} diff --git a/honeycomb/client.go b/honeycomb/client.go new file mode 100644 index 0000000..446d60f --- /dev/null +++ b/honeycomb/client.go @@ -0,0 +1,98 @@ +// Package honeycomb provides a client for the Honeycomb.io API. +package honeycomb + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client for the Honeycomb API. +type Client struct { + apiKey string + baseURL string + http *http.Client +} + +// NewClient with the given API key and options. +func NewClient(apiKey string, opts ...Option) *Client { + c := &Client{ + apiKey: apiKey, + baseURL: "https://api.honeycomb.io", + http: &http.Client{ + Timeout: 30 * time.Second, + }, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// Option for configuring a [Client]. +type Option func(*Client) + +// WithBaseURL sets the base URL for the API. +func WithBaseURL(url string) Option { + return func(c *Client) { + c.baseURL = url + } +} + +// WithHTTPClient sets the underlying HTTP client. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { + c.http = hc + } +} + +// APIError returned from the Honeycomb API. +type APIError struct { + StatusCode int + Status int `json:"status"` + Type string `json:"type"` + Title string `json:"title"` + Detail string `json:"detail"` + Err string `json:"error"` +} + +func (e *APIError) Error() string { + if e.Detail != "" { + return fmt.Sprintf("honeycomb API error (%d): %v", e.StatusCode, e.Detail) + } + if e.Err != "" { + return fmt.Sprintf("honeycomb API error (%d): %v", e.StatusCode, e.Err) + } + return fmt.Sprintf("honeycomb API error (%d): %v", e.StatusCode, e.Title) +} + +func (c *Client) do(req *http.Request) (*http.Response, error) { + req.Header.Set("X-Honeycomb-Team", c.apiKey) + req.Header.Set("Content-Type", "application/json") + + res, err := c.http.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode >= 400 { + defer func() { + _ = res.Body.Close() + }() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("reading error response: %w", err) + } + + apiErr := &APIError{StatusCode: res.StatusCode} + if err := json.Unmarshal(body, apiErr); err != nil { + return nil, fmt.Errorf("honeycomb API error (%d): %v", res.StatusCode, string(body)) + } + return nil, apiErr + } + + return res, nil +} diff --git a/honeycomb/client_test.go b/honeycomb/client_test.go new file mode 100644 index 0000000..678b2bf --- /dev/null +++ b/honeycomb/client_test.go @@ -0,0 +1,76 @@ +package honeycomb_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestNewClient(t *testing.T) { + t.Run("uses default base URL", func(t *testing.T) { + c := honeycomb.NewClient("test-key") + is.NotNil(t, c) + }) + + t.Run("accepts custom base URL", func(t *testing.T) { + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL("https://custom.example.com")) + is.NotNil(t, c) + }) +} + +func TestClient_Auth(t *testing.T) { + t.Run("returns auth info for a valid API key", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/auth", r.URL.Path) + is.Equal(t, http.MethodGet, r.Method) + is.Equal(t, "test-key", r.Header.Get("X-Honeycomb-Team")) + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(honeycomb.AuthResponse{ + Team: honeycomb.AuthTeam{ + Name: "My Team", + Slug: "my-team", + }, + Environment: honeycomb.AuthEnvironment{ + Name: "Production", + Slug: "production", + }, + APIKeyAccess: map[string]bool{ + "events": true, + "markers": true, + }, + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + auth, err := c.Auth(t.Context()) + is.NotError(t, err) + is.Equal(t, "My Team", auth.Team.Name) + is.Equal(t, "my-team", auth.Team.Slug) + is.Equal(t, "Production", auth.Environment.Name) + is.Equal(t, "production", auth.Environment.Slug) + is.True(t, auth.APIKeyAccess["events"]) + is.True(t, auth.APIKeyAccess["markers"]) + }) + + t.Run("returns error for invalid API key", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": 401, + "error": "unknown API key - check your credentials", + }) + })) + defer server.Close() + + c := honeycomb.NewClient("bad-key", honeycomb.WithBaseURL(server.URL)) + _, err := c.Auth(t.Context()) + is.True(t, err != nil) + }) +} diff --git a/honeycomb/datasets.go b/honeycomb/datasets.go new file mode 100644 index 0000000..4348319 --- /dev/null +++ b/honeycomb/datasets.go @@ -0,0 +1,112 @@ +package honeycomb + +import ( + "bytes" + "context" + "encoding/json" + "net/http" +) + +// Dataset in Honeycomb. +type Dataset struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + LastWrittenAt string `json:"last_written_at,omitempty"` + RegularColumns int `json:"regular_columns,omitempty"` + ExpandJSONDepth int `json:"expand_json_depth,omitempty"` +} + +// ListDatasets in the environment. +func (c *Client) ListDatasets(ctx context.Context) ([]Dataset, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/1/datasets", nil) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var datasets []Dataset + if err := json.NewDecoder(res.Body).Decode(&datasets); err != nil { + return nil, err + } + return datasets, nil +} + +// GetDataset by slug. +func (c *Client) GetDataset(ctx context.Context, slug string) (*Dataset, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/1/datasets/"+slug, nil) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var dataset Dataset + if err := json.NewDecoder(res.Body).Decode(&dataset); err != nil { + return nil, err + } + return &dataset, nil +} + +// CreateDatasetRequest for creating a dataset. +type CreateDatasetRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ExpandJSONDepth int `json:"expand_json_depth,omitempty"` +} + +// CreateDataset with the given name. +func (c *Client) CreateDataset(ctx context.Context, create CreateDatasetRequest) (*Dataset, error) { + body, err := json.Marshal(create) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/1/datasets", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var dataset Dataset + if err := json.NewDecoder(res.Body).Decode(&dataset); err != nil { + return nil, err + } + return &dataset, nil +} + +// DeleteDataset by slug. +func (c *Client) DeleteDataset(ctx context.Context, slug string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+"/1/datasets/"+slug, nil) + if err != nil { + return err + } + + res, err := c.do(req) + if err != nil { + return err + } + _ = res.Body.Close() + return nil +} diff --git a/honeycomb/datasets_test.go b/honeycomb/datasets_test.go new file mode 100644 index 0000000..d0892b4 --- /dev/null +++ b/honeycomb/datasets_test.go @@ -0,0 +1,96 @@ +package honeycomb_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestClient_ListDatasets(t *testing.T) { + t.Run("returns list of datasets", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/datasets", r.URL.Path) + is.Equal(t, http.MethodGet, r.Method) + + _ = json.NewEncoder(w).Encode([]honeycomb.Dataset{ + {Name: "requests", Slug: "requests"}, + {Name: "errors", Slug: "errors"}, + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + datasets, err := c.ListDatasets(t.Context()) + is.NotError(t, err) + is.Equal(t, 2, len(datasets)) + is.Equal(t, "requests", datasets[0].Name) + is.Equal(t, "errors", datasets[1].Name) + }) +} + +func TestClient_GetDataset(t *testing.T) { + t.Run("returns dataset by slug", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/datasets/requests", r.URL.Path) + is.Equal(t, http.MethodGet, r.Method) + + _ = json.NewEncoder(w).Encode(honeycomb.Dataset{ + Name: "requests", + Slug: "requests", + Description: "HTTP requests", + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + dataset, err := c.GetDataset(t.Context(), "requests") + is.NotError(t, err) + is.Equal(t, "requests", dataset.Name) + is.Equal(t, "HTTP requests", dataset.Description) + }) +} + +func TestClient_CreateDataset(t *testing.T) { + t.Run("creates a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/datasets", r.URL.Path) + is.Equal(t, http.MethodPost, r.Method) + + var req honeycomb.CreateDatasetRequest + _ = json.NewDecoder(r.Body).Decode(&req) + is.Equal(t, "new-dataset", req.Name) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(honeycomb.Dataset{ + Name: "new-dataset", + Slug: "new-dataset", + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + dataset, err := c.CreateDataset(t.Context(), honeycomb.CreateDatasetRequest{Name: "new-dataset"}) + is.NotError(t, err) + is.Equal(t, "new-dataset", dataset.Name) + }) +} + +func TestClient_DeleteDataset(t *testing.T) { + t.Run("deletes a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/datasets/old-dataset", r.URL.Path) + is.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + err := c.DeleteDataset(t.Context(), "old-dataset") + is.NotError(t, err) + }) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e345bdb --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/maragudk/honeycomb-cli/cmd" +) + +func main() { + os.Exit(cmd.Execute()) +} diff --git a/template.go b/template.go deleted file mode 100644 index 38cdfe4..0000000 --- a/template.go +++ /dev/null @@ -1 +0,0 @@ -package template