diff --git a/.gitignore b/.gitignore index cde0123..022143d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist/ +.env diff --git a/go.mod b/go.mod index 3f45a27..35cf0a4 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,15 @@ module github.com/openstatusHQ/cli -go 1.23.2 +go 1.24.0 require github.com/urfave/cli/v3 v3.0.0-alpha9.2 // direct require ( + buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202165838-5bd92a1e5d53.2 + buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202165838-5bd92a1e5d53.1 + connectrpc.com/connect v1.19.1 github.com/fatih/color v1.18.0 + github.com/google/go-cmp v0.7.0 github.com/knadh/koanf/parsers/yaml v0.1.0 github.com/knadh/koanf/providers/file v1.1.2 github.com/knadh/koanf/v2 v2.1.1 @@ -17,10 +21,10 @@ require ( ) require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -32,5 +36,6 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect golang.org/x/sys v0.26.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 53bae45..7250bbe 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,15 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202080906-4f3d33d3bed3.2 h1:nQzgK01nlgbSQn/7/qjHxSwcv/C6I2f9lMfhgJ98FV0= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202080906-4f3d33d3bed3.2/go.mod h1:ikyyG3mJiNpeGcAywdhzGOt5fTSs78jvlvAqJo8ZYf4= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202165838-5bd92a1e5d53.2 h1:MeP+r7GwYHWKSMa1ltvtRNwzT+gCyHIYCEpNxWeNwS4= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260202165838-5bd92a1e5d53.2/go.mod h1:W/PtF1QguqXdSkOHAD0VAOTMNfuESeNOdR2cF/CWeOQ= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202080906-4f3d33d3bed3.1 h1:PeaMjGloj9U860iUOmX7pNwx2hdudlOus8ietWw7IWE= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202080906-4f3d33d3bed3.1/go.mod h1:pZsKB5l3aT2mKtGkAZTC8pXhTptdfyYwFGCyH+KVfOM= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202165838-5bd92a1e5d53.1 h1:vw4PznfU8x7XrFtc/HHPjWfxNnFExtaSwrPS8cEKq+w= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260202165838-5bd92a1e5d53.1/go.mod h1:pZsKB5l3aT2mKtGkAZTC8pXhTptdfyYwFGCyH+KVfOM= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -10,7 +22,6 @@ github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyT github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -66,6 +77,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/monitors/monitor_apply.go b/internal/monitors/monitor_apply.go index 7e14da9..31c12b7 100644 --- a/internal/monitors/monitor_apply.go +++ b/internal/monitors/monitor_apply.go @@ -61,7 +61,7 @@ func CompareLockWithConfig(apiKey string, applyChange bool, lock config.Monitors if _, exist := configData[v]; !exist { if applyChange { - err := DeleteMonitor(http.DefaultClient, apiKey, fmt.Sprintf("%d", value.ID)) + err := DeleteMonitorWithHTTPClient(http.DefaultClient, apiKey, fmt.Sprintf("%d", value.ID)) if err != nil { fmt.Println(err) } diff --git a/internal/monitors/monitor_create.go b/internal/monitors/monitor_create.go index 3a5067b..a6677e8 100644 --- a/internal/monitors/monitor_create.go +++ b/internal/monitors/monitor_create.go @@ -1,55 +1,97 @@ package monitors import ( - "bytes" "context" - "encoding/json" "errors" "fmt" - "io" "net/http" "os" + "strconv" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" confirmation "github.com/openstatusHQ/cli/internal/cli" "github.com/openstatusHQ/cli/internal/config" "github.com/urfave/cli/v3" ) +// CreateMonitor creates a monitor using the SDK, dispatching to the appropriate type func CreateMonitor(httpClient *http.Client, apiKey string, monitor config.Monitor) (Monitor, error) { + client := NewMonitorClientWithHTTPClient(httpClient, apiKey) + + switch monitor.Kind { + case config.HTTP: + return CreateHTTPMonitor(client, monitor) + case config.TCP: + return CreateTCPMonitor(client, monitor) + default: + return Monitor{}, fmt.Errorf("unsupported monitor kind: %s", monitor.Kind) + } +} - url := fmt.Sprintf("%s/monitor/%s", APIBaseURL, monitor.Kind) +// CreateHTTPMonitor creates an HTTP monitor using the SDK +func CreateHTTPMonitor(client monitorv1connect.MonitorServiceClient, monitor config.Monitor) (Monitor, error) { + req := &monitorv1.CreateHTTPMonitorRequest{ + Monitor: configToHTTPMonitor(monitor), + } - payloadBuf := new(bytes.Buffer) - json.NewEncoder(payloadBuf).Encode(monitor) - req, err := http.NewRequest(http.MethodPost, url, payloadBuf) + resp, err := client.CreateHTTPMonitor(context.Background(), req) if err != nil { - return Monitor{}, fmt.Errorf("failed to create request: %w", err) + return Monitor{}, fmt.Errorf("failed to create HTTP monitor: %w", err) } - req.Header.Add("x-openstatus-key", apiKey) - req.Header.Add("Content-Type", "application/json") + return httpMonitorToLocal(resp.GetMonitor()), nil +} - res, err := httpClient.Do(req) - if err != nil { - return Monitor{}, err +// CreateTCPMonitor creates a TCP monitor using the SDK +func CreateTCPMonitor(client monitorv1connect.MonitorServiceClient, monitor config.Monitor) (Monitor, error) { + req := &monitorv1.CreateTCPMonitorRequest{ + Monitor: configToTCPMonitor(monitor), } - if res.StatusCode != http.StatusOK { - return Monitor{}, fmt.Errorf("Failed to create monitor") - } - defer res.Body.Close() - body, err := io.ReadAll(res.Body) + resp, err := client.CreateTCPMonitor(context.Background(), req) if err != nil { - return Monitor{}, fmt.Errorf("failed to read response body: %w", err) + return Monitor{}, fmt.Errorf("failed to create TCP monitor: %w", err) } - var monitors Monitor - err = json.Unmarshal(body, &monitors) - if err != nil { - return Monitor{}, err + return tcpMonitorToLocal(resp.GetMonitor()), nil +} + +// httpMonitorToLocal converts SDK HTTPMonitor to local Monitor type +func httpMonitorToLocal(m *monitorv1.HTTPMonitor) Monitor { + id, _ := strconv.Atoi(m.GetId()) + return Monitor{ + ID: id, + Name: m.GetName(), + Description: m.GetDescription(), + URL: m.GetUrl(), + Periodicity: periodicityToString(m.GetPeriodicity()), + Method: httpMethodToString(m.GetMethod()), + Regions: regionsToStrings(m.GetRegions()), + Active: m.GetActive(), + Public: m.GetPublic(), + Timeout: int(m.GetTimeout()), + Retry: int(m.GetRetry()), + JobType: "http", } +} - return monitors, nil +// tcpMonitorToLocal converts SDK TCPMonitor to local Monitor type +func tcpMonitorToLocal(m *monitorv1.TCPMonitor) Monitor { + id, _ := strconv.Atoi(m.GetId()) + return Monitor{ + ID: id, + Name: m.GetName(), + Description: m.GetDescription(), + URL: m.GetUri(), + Periodicity: periodicityToString(m.GetPeriodicity()), + Regions: regionsToStrings(m.GetRegions()), + Active: m.GetActive(), + Public: m.GetPublic(), + Timeout: int(m.GetTimeout()), + Retry: int(m.GetRetry()), + JobType: "tcp", + } } func GetMonitorCreateCmd() *cli.Command { diff --git a/internal/monitors/monitor_create_test.go b/internal/monitors/monitor_create_test.go index f854134..b41a63e 100644 --- a/internal/monitors/monitor_create_test.go +++ b/internal/monitors/monitor_create_test.go @@ -6,7 +6,6 @@ import ( "net/http" "testing" - "github.com/google/go-cmp/cmp" "github.com/openstatusHQ/cli/internal/config" "github.com/openstatusHQ/cli/internal/monitors" ) @@ -14,37 +13,20 @@ import ( func Test_CreateMonitor(t *testing.T) { t.Parallel() - t.Run("Create monitor successfully", func(t *testing.T) { - body := `{ - "id": 123, - "name": "Test Monitor", - "url": "https://example.com", - "periodicity": "10m", - "method": "GET", - "regions": ["iad", "ams"], - "active": true, - "public": false, - "timeout": 45000, - "body": "", - "retry": 3, - "jobType": "http" - }` + t.Run("Create HTTP monitor successfully", func(t *testing.T) { + // Connect RPC response format + body := `{"monitor":{"id":"123","name":"Test Monitor","url":"https://example.com","periodicity":"PERIODICITY_10M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD","REGION_FLY_AMS"],"active":true}}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPost { - t.Errorf("Expected POST method, got %s", req.Method) - } if req.Header.Get("x-openstatus-key") != "test-api-key" { t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) } - if req.Header.Get("Content-Type") != "application/json" { - t.Errorf("Expected Content-Type header, got %s", req.Header.Get("Content-Type")) - } return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }, } @@ -59,7 +41,6 @@ func Test_CreateMonitor(t *testing.T) { URL: "https://example.com", Method: config.Get, }, - Retry: 3, } result, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) @@ -75,12 +56,62 @@ func Test_CreateMonitor(t *testing.T) { } }) + t.Run("Create TCP monitor successfully", func(t *testing.T) { + // Connect RPC response format for TCP monitor + body := `{"monitor":{"id":"456","name":"TCP Monitor","uri":"example.com:443","periodicity":"PERIODICITY_5M","regions":["REGION_FLY_IAD"],"active":true}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if req.Header.Get("x-openstatus-key") != "test-api-key" { + t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "TCP Monitor", + Active: true, + Frequency: config.The5M, + Kind: config.TCP, + Regions: []config.Region{config.Iad}, + Request: config.Request{ + Host: "example.com", + Port: 443, + }, + } + + result, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != 456 { + t.Errorf("Expected ID 456, got %d", result.ID) + } + if result.Name != "TCP Monitor" { + t.Errorf("Expected name 'TCP Monitor', got %s", result.Name) + } + if result.JobType != "tcp" { + t.Errorf("Expected jobType 'tcp', got %s", result.JobType) + } + }) + t.Run("Create monitor fails with non-200 status", func(t *testing.T) { + body := `{"code":"internal","message":"internal error"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "bad request"}`))), + StatusCode: http.StatusInternalServerError, + Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }, } @@ -96,25 +127,9 @@ func Test_CreateMonitor(t *testing.T) { } }) - t.Run("Create monitor returns correct Monitor struct", func(t *testing.T) { - body := `{ - "id": 456, - "name": "Full Monitor", - "url": "https://test.example.com", - "periodicity": "5m", - "description": "Test description", - "method": "POST", - "regions": ["iad", "ams", "syd"], - "active": true, - "public": true, - "timeout": 30000, - "degraded_after": 5000, - "body": "{\"key\": \"value\"}", - "headers": [{"key": "Content-Type", "value": "application/json"}], - "assertions": [{"type": "statusCode", "compare": "eq", "target": 200}], - "retry": 2, - "jobType": "http" - }` + t.Run("Create HTTP monitor returns correct Monitor struct", func(t *testing.T) { + // Connect RPC response format with all fields + body := `{"monitor":{"id":"789","name":"Full Monitor","description":"Test description","url":"https://test.example.com","periodicity":"PERIODICITY_5M","method":"HTTP_METHOD_POST","regions":["REGION_FLY_IAD","REGION_FLY_AMS","REGION_FLY_SYD"],"active":true,"public":true,"timeout":30000,"retry":2}}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ @@ -122,6 +137,7 @@ func Test_CreateMonitor(t *testing.T) { return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }, } @@ -136,27 +152,60 @@ func Test_CreateMonitor(t *testing.T) { t.Fatalf("Expected no error, got %v", err) } - expected := monitors.Monitor{ - ID: 456, - Name: "Full Monitor", - URL: "https://test.example.com", - Periodicity: "5m", - Description: "Test description", - Method: "POST", - Regions: []string{"iad", "ams", "syd"}, - Active: true, - Public: true, - Timeout: 30000, - DegradedAfter: 5000, - Body: "{\"key\": \"value\"}", - Headers: []monitors.Header{{Key: "Content-Type", Value: "application/json"}}, - Assertions: []monitors.Assertion{{Type: "statusCode", Compare: "eq", Target: float64(200)}}, - Retry: 2, - JobType: "http", - } - - if !cmp.Equal(expected, result) { - t.Errorf("Expected %+v, got %+v", expected, result) + if result.ID != 789 { + t.Errorf("Expected ID 789, got %d", result.ID) + } + if result.Name != "Full Monitor" { + t.Errorf("Expected name 'Full Monitor', got %s", result.Name) + } + if result.Description != "Test description" { + t.Errorf("Expected description 'Test description', got %s", result.Description) + } + if result.URL != "https://test.example.com" { + t.Errorf("Expected URL 'https://test.example.com', got %s", result.URL) + } + if result.Periodicity != "5m" { + t.Errorf("Expected periodicity '5m', got %s", result.Periodicity) + } + if result.Method != "POST" { + t.Errorf("Expected method 'POST', got %s", result.Method) + } + if result.Active != true { + t.Errorf("Expected active true, got %v", result.Active) + } + if result.Public != true { + t.Errorf("Expected public true, got %v", result.Public) + } + if result.Timeout != 30000 { + t.Errorf("Expected timeout 30000, got %d", result.Timeout) + } + if result.Retry != 2 { + t.Errorf("Expected retry 2, got %d", result.Retry) + } + if result.JobType != "http" { + t.Errorf("Expected jobType 'http', got %s", result.JobType) + } + }) + + t.Run("Unsupported monitor kind returns error", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Test Monitor", + Kind: "unsupported", + } + + _, err := monitors.CreateMonitor(interceptor.GetHTTPClient(), "test-api-key", monitor) + if err == nil { + t.Error("Expected error for unsupported monitor kind, got nil") } }) } diff --git a/internal/monitors/monitor_delete.go b/internal/monitors/monitor_delete.go index 80f94dd..d99cdc7 100644 --- a/internal/monitors/monitor_delete.go +++ b/internal/monitors/monitor_delete.go @@ -2,52 +2,37 @@ package monitors import ( "context" - "encoding/json" "fmt" - "io" "net/http" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" confirmation "github.com/openstatusHQ/cli/internal/cli" "github.com/urfave/cli/v3" ) -func DeleteMonitor(httpClient *http.Client, apiKey string, monitorId string) error { - +// DeleteMonitor deletes a monitor using the SDK +func DeleteMonitor(client monitorv1connect.MonitorServiceClient, monitorId string) error { if monitorId == "" { return fmt.Errorf("Monitor ID is required") } - url := fmt.Sprintf("%s/monitor/%s", APIBaseURL, monitorId) - - req, err := http.NewRequest(http.MethodDelete, url, nil) - if err != nil { - return err - } - req.Header.Add("x-openstatus-key", apiKey) - res, err := httpClient.Do(req) - if err != nil { - return err - } - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("Failed to delete monitor") - } - - defer res.Body.Close() - body, err := io.ReadAll(res.Body) + _, err := client.DeleteMonitor(context.Background(), &monitorv1.DeleteMonitorRequest{ + Id: monitorId, + }) if err != nil { - return fmt.Errorf("failed to read response body: %w", err) + return fmt.Errorf("failed to delete monitor: %w", err) } - var r MonitorTriggerResponse - err = json.Unmarshal(body, &r) - if err != nil { - - return err - } return nil } +// DeleteMonitorWithHTTPClient is a convenience function that creates a client and deletes a monitor +func DeleteMonitorWithHTTPClient(httpClient *http.Client, apiKey string, monitorId string) error { + client := NewMonitorClientWithHTTPClient(httpClient, apiKey) + return DeleteMonitor(client, monitorId) +} + func GetMonitorDeleteCmd() *cli.Command { monitorsCmd := cli.Command{ Name: "delete", @@ -84,7 +69,8 @@ func GetMonitorDeleteCmd() *cli.Command { return nil } } - err := DeleteMonitor(http.DefaultClient, cmd.String("access-token"), monitorId) + client := NewMonitorClient(cmd.String("access-token")) + err := DeleteMonitor(client, monitorId) if err != nil { return cli.Exit("Failed to delete monitor", 1) } diff --git a/internal/monitors/monitor_delete_test.go b/internal/monitors/monitor_delete_test.go index 7bb45cc..4ec0884 100644 --- a/internal/monitors/monitor_delete_test.go +++ b/internal/monitors/monitor_delete_test.go @@ -17,11 +17,14 @@ func Test_DeleteMonitor(t *testing.T) { f: func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } - err := monitors.DeleteMonitor(interceptor.GetHTTPClient(), "test-api-key", "") + err := monitors.DeleteMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", "") if err == nil { t.Error("Expected error for empty monitor ID, got nil") } @@ -31,66 +34,52 @@ func Test_DeleteMonitor(t *testing.T) { }) t.Run("Delete monitor successfully", func(t *testing.T) { - body := `{"resultId": 123}` + body := `{"success": true}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodDelete { - t.Errorf("Expected DELETE method, got %s", req.Method) + if req.Method != http.MethodPost { + t.Errorf("Expected POST method (Connect RPC), got %s", req.Method) } if req.Header.Get("x-openstatus-key") != "test-api-key" { t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) } - expectedURL := "https://api.openstatus.dev/v1/monitor/123" - if req.URL.String() != expectedURL { - t.Errorf("Expected URL %s, got %s", expectedURL, req.URL.String()) - } return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } - err := monitors.DeleteMonitor(interceptor.GetHTTPClient(), "test-api-key", "123") + err := monitors.DeleteMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", "123") if err != nil { t.Errorf("Expected no error, got %v", err) } }) - t.Run("Delete monitor fails with non-200 status", func(t *testing.T) { - interceptor := &interceptorHTTPClient{ - f: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusNotFound, - Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "not found"}`))), - }, nil - }, - } - - err := monitors.DeleteMonitor(interceptor.GetHTTPClient(), "test-api-key", "999") - if err == nil { - t.Error("Expected error for non-200 status, got nil") - } - }) - - t.Run("Delete monitor with valid response body", func(t *testing.T) { - body := `{"resultId": 456}` + t.Run("Delete monitor fails with error status", func(t *testing.T) { + body := `{"code":"not_found","message":"monitor not found"}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ - StatusCode: http.StatusOK, + StatusCode: http.StatusNotFound, Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } - err := monitors.DeleteMonitor(interceptor.GetHTTPClient(), "test-api-key", "456") - if err != nil { - t.Errorf("Expected no error, got %v", err) + err := monitors.DeleteMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", "999") + if err == nil { + t.Error("Expected error for non-200 status, got nil") } }) } diff --git a/internal/monitors/monitor_import.go b/internal/monitors/monitor_import.go index cc80d7c..73c08bb 100644 --- a/internal/monitors/monitor_import.go +++ b/internal/monitors/monitor_import.go @@ -2,49 +2,28 @@ package monitors import ( "context" - "encoding/json" "fmt" - "io" "net/http" "os" "strconv" "strings" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" "github.com/openstatusHQ/cli/internal/config" "github.com/urfave/cli/v3" "sigs.k8s.io/yaml" ) -func ExportMonitor(httpClient *http.Client, apiKey string, path string) error { - url := APIBaseURL + "/monitor" - - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Add("x-openstatus-key", apiKey) - res, err := httpClient.Do(req) - if err != nil { - return err - } - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("failed to get all monitors") - } - - defer res.Body.Close() - body, err := io.ReadAll(res.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - var monitors []Monitor - err = json.Unmarshal(body, &monitors) +// ExportMonitor exports all monitors to a YAML file using the SDK +func ExportMonitor(client monitorv1connect.MonitorServiceClient, path string) error { + resp, err := client.ListMonitors(context.Background(), &monitorv1.ListMonitorsRequest{}) if err != nil { - return err + return fmt.Errorf("failed to list monitors: %w", err) } t := map[string]config.Monitor{} - lock := make(map[string]config.Lock, len(monitors)) + lock := make(map[string]config.Lock) file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { @@ -52,81 +31,28 @@ func ExportMonitor(httpClient *http.Client, apiKey string, path string) error { } defer file.Close() - for _, monitor := range monitors { - - var regions []config.Region - - for _, region := range monitor.Regions { - regions = append(regions, config.Region(region)) - } - var request config.Request - var assertions []config.Assertion - switch monitor.JobType { - case "http": - var headers = make(map[string]string) - for _, header := range monitor.Headers { - // Our API should not allow empty headers - if header.Key == "" { - continue - } - headers[header.Key] = header.Value - } - - for _, assertion := range monitor.Assertions { - - kind := config.AssertionKind(assertion.Type) - // Our api return status instead of Status Code - if kind == "status" { - kind = config.StatusCode - } - - assertions = append(assertions, config.Assertion{ - Kind: kind, - Target: assertion.Target, - Compare: config.Compare(assertion.Compare), - Key: assertion.Key, - }) - } - - request = config.Request{ - URL: monitor.URL, - Method: config.Method(monitor.Method), - Body: monitor.Body, - Headers: headers, - } - case "tcp": - uri := strings.Split(monitor.URL, ":") + // Process HTTP monitors + for _, monitor := range resp.GetHttpMonitors() { + configMonitor := convertHTTPMonitorToConfig(monitor) + id := monitor.GetId() + t[id] = configMonitor + } - port, _ := strconv.Atoi(uri[1]) - request = config.Request{ - Host: uri[0], - Port: int64(port), - } + // Process TCP monitors + for _, monitor := range resp.GetTcpMonitors() { + configMonitor := convertTCPMonitorToConfig(monitor) + id := monitor.GetId() + t[id] = configMonitor + } - default: - return fmt.Errorf("unknown job type: %s", monitor.JobType) - } + // Process DNS monitors (skip for now as config.Monitor doesn't support DNS) + // DNS monitors would need config.Kind = "dns" support - t[fmt.Sprint(monitor.ID)] = config.Monitor{ - Name: monitor.Name, - Active: monitor.Active, - Public: monitor.Public, - Description: monitor.Description, - DegradedAfter: int64(monitor.DegradedAfter), - Frequency: config.Frequency(monitor.Periodicity), - Request: request, - Kind: config.CoordinateKind(monitor.JobType), - Retry: int64(monitor.Retry), - Regions: regions, - Assertions: assertions, - } - } y, err := yaml.Marshal(&t) if err != nil { return err } - // file.WriteString("# yaml-language-server: $schema=https://raw.githubusercontent.com/openstatusHQ/json-schema/refs/heads/improve-schema/1.0.1.json\n\n") _, err = file.WriteString("# yaml-language-server: $schema=https://www.openstatus.dev/schema.json\n\n") if err != nil { return err @@ -136,7 +62,7 @@ func ExportMonitor(httpClient *http.Client, apiKey string, path string) error { return err } - // + // Build lock file for id, monitor := range t { i, _ := strconv.Atoi(id) lock[id] = config.Lock{ @@ -148,7 +74,6 @@ func ExportMonitor(httpClient *http.Client, apiKey string, path string) error { lockFile, err := os.OpenFile("openstatus.lock", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return cli.Exit("Failed to apply change", 1) - } defer lockFile.Close() @@ -165,6 +90,196 @@ func ExportMonitor(httpClient *http.Client, apiKey string, path string) error { return nil } +// convertHTTPMonitorToConfig converts an SDK HTTPMonitor to config.Monitor +func convertHTTPMonitorToConfig(m *monitorv1.HTTPMonitor) config.Monitor { + regions := make([]config.Region, len(m.GetRegions())) + for i, r := range m.GetRegions() { + regions[i] = config.Region(regionToString(r)) + } + + headers := make(map[string]string) + for _, h := range m.GetHeaders() { + if h.GetKey() != "" { + headers[h.GetKey()] = h.GetValue() + } + } + + var assertions []config.Assertion + for _, a := range m.GetStatusCodeAssertions() { + assertions = append(assertions, config.Assertion{ + Kind: config.StatusCode, + Target: int(a.GetTarget()), + Compare: convertNumberComparator(a.GetComparator()), + }) + } + for _, a := range m.GetBodyAssertions() { + assertions = append(assertions, config.Assertion{ + Kind: config.TextBody, + Target: a.GetTarget(), + Compare: convertStringComparator(a.GetComparator()), + }) + } + for _, a := range m.GetHeaderAssertions() { + assertions = append(assertions, config.Assertion{ + Kind: config.Header, + Target: a.GetTarget(), + Compare: convertStringComparator(a.GetComparator()), + Key: a.GetKey(), + }) + } + + return config.Monitor{ + Name: m.GetName(), + Description: m.GetDescription(), + Active: m.GetActive(), + Public: m.GetPublic(), + Frequency: convertPeriodicity(m.GetPeriodicity()), + DegradedAfter: m.GetDegradedAt(), + Timeout: m.GetTimeout(), + Retry: m.GetRetry(), + Kind: config.HTTP, + Regions: regions, + Assertions: assertions, + Request: config.Request{ + URL: m.GetUrl(), + Method: convertHTTPMethod(m.GetMethod()), + Body: m.GetBody(), + Headers: headers, + }, + } +} + +// convertTCPMonitorToConfig converts an SDK TCPMonitor to config.Monitor +func convertTCPMonitorToConfig(m *monitorv1.TCPMonitor) config.Monitor { + regions := make([]config.Region, len(m.GetRegions())) + for i, r := range m.GetRegions() { + regions[i] = config.Region(regionToString(r)) + } + + // Parse host:port from URI + uri := m.GetUri() + parts := strings.Split(uri, ":") + host := parts[0] + var port int64 + if len(parts) > 1 { + p, _ := strconv.Atoi(parts[1]) + port = int64(p) + } + + return config.Monitor{ + Name: m.GetName(), + Description: m.GetDescription(), + Active: m.GetActive(), + Public: m.GetPublic(), + Frequency: convertPeriodicity(m.GetPeriodicity()), + DegradedAfter: m.GetDegradedAt(), + Timeout: m.GetTimeout(), + Retry: m.GetRetry(), + Kind: config.TCP, + Regions: regions, + Request: config.Request{ + Host: host, + Port: port, + }, + } +} + +// convertPeriodicity converts SDK Periodicity enum to config.Frequency string +func convertPeriodicity(p monitorv1.Periodicity) config.Frequency { + switch p { + case monitorv1.Periodicity_PERIODICITY_30S: + return config.The30S + case monitorv1.Periodicity_PERIODICITY_1M: + return config.The1M + case monitorv1.Periodicity_PERIODICITY_5M: + return config.The5M + case monitorv1.Periodicity_PERIODICITY_10M: + return config.The10M + case monitorv1.Periodicity_PERIODICITY_30M: + return config.The30M + case monitorv1.Periodicity_PERIODICITY_1H: + return config.The1H + default: + return config.The10M + } +} + +// convertHTTPMethod converts SDK HTTPMethod enum to config.Method string +func convertHTTPMethod(m monitorv1.HTTPMethod) config.Method { + switch m { + case monitorv1.HTTPMethod_HTTP_METHOD_GET: + return config.Get + case monitorv1.HTTPMethod_HTTP_METHOD_POST: + return config.Post + case monitorv1.HTTPMethod_HTTP_METHOD_PUT: + return config.Put + case monitorv1.HTTPMethod_HTTP_METHOD_PATCH: + return config.Patch + case monitorv1.HTTPMethod_HTTP_METHOD_DELETE: + return config.Delete + case monitorv1.HTTPMethod_HTTP_METHOD_HEAD: + return config.Head + case monitorv1.HTTPMethod_HTTP_METHOD_OPTIONS: + return config.Options + default: + return config.Get + } +} + +// convertNumberComparator converts SDK NumberComparator to config.Compare +func convertNumberComparator(c monitorv1.NumberComparator) config.Compare { + switch c { + case monitorv1.NumberComparator_NUMBER_COMPARATOR_EQUAL: + return config.Eq + case monitorv1.NumberComparator_NUMBER_COMPARATOR_NOT_EQUAL: + return config.NotEq + case monitorv1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN: + return config.Gt + case monitorv1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL: + return config.Gte + case monitorv1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN: + return config.Lt + case monitorv1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL: + return config.LTE + default: + return config.Eq + } +} + +// convertStringComparator converts SDK StringComparator to config.Compare +func convertStringComparator(c monitorv1.StringComparator) config.Compare { + switch c { + case monitorv1.StringComparator_STRING_COMPARATOR_EQUAL: + return config.Eq + case monitorv1.StringComparator_STRING_COMPARATOR_NOT_EQUAL: + return config.NotEq + case monitorv1.StringComparator_STRING_COMPARATOR_CONTAINS: + return config.Contains + case monitorv1.StringComparator_STRING_COMPARATOR_NOT_CONTAINS: + return config.NotContains + case monitorv1.StringComparator_STRING_COMPARATOR_EMPTY: + return config.Empty + case monitorv1.StringComparator_STRING_COMPARATOR_NOT_EMPTY: + return config.NotEmpty + case monitorv1.StringComparator_STRING_COMPARATOR_GREATER_THAN: + return config.Gt + case monitorv1.StringComparator_STRING_COMPARATOR_GREATER_THAN_OR_EQUAL: + return config.Gte + case monitorv1.StringComparator_STRING_COMPARATOR_LESS_THAN: + return config.Lt + case monitorv1.StringComparator_STRING_COMPARATOR_LESS_THAN_OR_EQUAL: + return config.LTE + default: + return config.Eq + } +} + +// ExportMonitorWithHTTPClient is a convenience function that creates a client and exports monitors +func ExportMonitorWithHTTPClient(httpClient *http.Client, apiKey string, path string) error { + client := NewMonitorClientWithHTTPClient(httpClient, apiKey) + return ExportMonitor(client, path) +} + func GetMonitorImportCmd() *cli.Command { monitorInfoCmd := cli.Command{ Name: "import", @@ -172,8 +287,8 @@ func GetMonitorImportCmd() *cli.Command { UsageText: "openstatus monitors import [options]", Description: "Import all your monitors from your workspace to a YAML file; it will also create a lock file to manage your monitors with 'apply'.", Action: func(ctx context.Context, cmd *cli.Command) error { - // monitorId := cmd.Args().Get(0) - err := ExportMonitor(http.DefaultClient, cmd.String("access-token"), cmd.String("output")) + client := NewMonitorClient(cmd.String("access-token")) + err := ExportMonitor(client, cmd.String("output")) if err != nil { return cli.Exit(err.Error(), 1) } diff --git a/internal/monitors/monitor_import_test.go b/internal/monitors/monitor_import_test.go index 561a232..b7761ba 100644 --- a/internal/monitors/monitor_import_test.go +++ b/internal/monitors/monitor_import_test.go @@ -14,38 +14,21 @@ func Test_ExportMonitor(t *testing.T) { t.Parallel() t.Run("Export HTTP monitors successfully", func(t *testing.T) { - body := `[ - { - "id": 123, - "name": "HTTP Monitor", - "url": "https://example.com", - "periodicity": "10m", - "description": "Test monitor", - "method": "GET", - "regions": ["iad", "ams"], - "active": true, - "public": false, - "timeout": 45000, - "body": "", - "headers": [{"key": "User-Agent", "value": "OpenStatus"}], - "assertions": [{"type": "statusCode", "compare": "eq", "target": 200}], - "retry": 3, - "jobType": "http" - } - ]` + // Connect RPC response format with ListMonitorsResponse + body := `{"httpMonitors":[{"id":"123","name":"HTTP Monitor","url":"https://example.com","periodicity":"PERIODICITY_10M","active":true,"public":false}]}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Errorf("Expected GET method, got %s", req.Method) - } if req.Header.Get("x-openstatus-key") != "test-api-key" { t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) } return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } @@ -58,7 +41,7 @@ func Test_ExportMonitor(t *testing.T) { defer os.Remove("openstatus.lock") outputFile.Close() - err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + err = monitors.ExportMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) if err != nil { t.Fatalf("Expected no error, got %v", err) } @@ -74,25 +57,7 @@ func Test_ExportMonitor(t *testing.T) { }) t.Run("Export TCP monitors successfully", func(t *testing.T) { - body := `[ - { - "id": 456, - "name": "TCP Monitor", - "url": "example.com:443", - "periodicity": "5m", - "description": "TCP test", - "method": "", - "regions": ["iad"], - "active": true, - "public": false, - "timeout": 10000, - "body": "", - "headers": [], - "assertions": [], - "retry": 0, - "jobType": "tcp" - } - ]` + body := `{"tcpMonitors":[{"id":"456","name":"TCP Monitor","uri":"example.com:443","periodicity":"PERIODICITY_5M","active":true}]}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ @@ -100,6 +65,9 @@ func Test_ExportMonitor(t *testing.T) { return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } @@ -112,128 +80,36 @@ func Test_ExportMonitor(t *testing.T) { defer os.Remove("openstatus.lock") outputFile.Close() - err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + err = monitors.ExportMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) if err != nil { t.Fatalf("Expected no error, got %v", err) } }) - t.Run("Export fails with non-200 status", func(t *testing.T) { - interceptor := &interceptorHTTPClient{ - f: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusUnauthorized, - Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "unauthorized"}`))), - }, nil - }, - } - - err := monitors.ExportMonitor(interceptor.GetHTTPClient(), "invalid-key", "output.yaml") - if err == nil { - t.Error("Expected error for non-200 status, got nil") - } - }) - - t.Run("Export fails with unknown job type", func(t *testing.T) { - body := `[ - { - "id": 789, - "name": "Unknown Monitor", - "url": "https://example.com", - "periodicity": "10m", - "method": "GET", - "regions": ["iad"], - "active": true, - "jobType": "unknown" - } - ]` + t.Run("Export fails with error status", func(t *testing.T) { + body := `{"code":"permission_denied","message":"unauthorized"}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ - StatusCode: http.StatusOK, + StatusCode: http.StatusUnauthorized, Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } - outputFile, err := os.CreateTemp(".", "export_unknown*.yaml") - if err != nil { - t.Fatal(err) - } - defer os.Remove(outputFile.Name()) - outputFile.Close() - - err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + err := monitors.ExportMonitorWithHTTPClient(interceptor.GetHTTPClient(), "invalid-key", "output.yaml") if err == nil { - t.Error("Expected error for unknown job type, got nil") - } - }) - - t.Run("Export handles empty headers", func(t *testing.T) { - body := `[ - { - "id": 111, - "name": "No Headers Monitor", - "url": "https://example.com", - "periodicity": "10m", - "method": "GET", - "regions": ["iad"], - "active": true, - "public": false, - "timeout": 45000, - "body": "", - "headers": [{"key": "", "value": ""}], - "assertions": [], - "retry": 0, - "jobType": "http" - } - ]` - r := io.NopCloser(bytes.NewReader([]byte(body))) - - interceptor := &interceptorHTTPClient{ - f: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Body: r, - }, nil - }, - } - - outputFile, err := os.CreateTemp(".", "export_noheaders*.yaml") - if err != nil { - t.Fatal(err) - } - defer os.Remove(outputFile.Name()) - defer os.Remove("openstatus.lock") - outputFile.Close() - - err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) - if err != nil { - t.Fatalf("Expected no error, got %v", err) + t.Error("Expected error for non-200 status, got nil") } }) - t.Run("Export handles status assertion type conversion", func(t *testing.T) { - body := `[ - { - "id": 222, - "name": "Assertion Monitor", - "url": "https://example.com", - "periodicity": "10m", - "method": "GET", - "regions": ["iad"], - "active": true, - "public": false, - "timeout": 45000, - "body": "", - "headers": [], - "assertions": [{"type": "status", "compare": "eq", "target": 200}], - "retry": 0, - "jobType": "http" - } - ]` + t.Run("Export handles empty monitors", func(t *testing.T) { + body := `{"httpMonitors":[],"tcpMonitors":[],"dnsMonitors":[]}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ @@ -241,11 +117,14 @@ func Test_ExportMonitor(t *testing.T) { return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } - outputFile, err := os.CreateTemp(".", "export_assertion*.yaml") + outputFile, err := os.CreateTemp(".", "export_empty*.yaml") if err != nil { t.Fatal(err) } @@ -253,7 +132,7 @@ func Test_ExportMonitor(t *testing.T) { defer os.Remove("openstatus.lock") outputFile.Close() - err = monitors.ExportMonitor(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) + err = monitors.ExportMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-api-key", outputFile.Name()) if err != nil { t.Fatalf("Expected no error, got %v", err) } diff --git a/internal/monitors/monitor_info.go b/internal/monitors/monitor_info.go index b17deb2..6222026 100644 --- a/internal/monitors/monitor_info.go +++ b/internal/monitors/monitor_info.go @@ -2,14 +2,12 @@ package monitors import ( "context" - "encoding/json" "fmt" - "io" "net/http" "os" "strings" - // "github.com/logrusorgru/aurora/v4" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" "github.com/logrusorgru/aurora/v4" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/renderer" @@ -23,33 +21,26 @@ func GetMonitorInfo(httpClient *http.Client, apiKey string, monitorId string) er return fmt.Errorf("Monitor ID is required") } - url := APIBaseURL + "/monitor/" + monitorId + client := NewMonitorClientWithHTTPClient(httpClient, apiKey) - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) + req := &monitorv1.GetMonitorRequest{ + Id: monitorId, } - req.Header.Add("x-openstatus-key", apiKey) - - res, err := httpClient.Do(req) - if err != nil { - return err - } - if res.StatusCode != http.StatusOK { - return fmt.Errorf("You don't have permission to access this monitor") - } - defer res.Body.Close() - body, err := io.ReadAll(res.Body) + resp, err := client.GetMonitor(context.Background(), req) if err != nil { - return fmt.Errorf("failed to read response body: %w", err) + return fmt.Errorf("failed to get monitor: %w", err) } + monitorConfig := resp.GetMonitor() var monitor Monitor - err = json.Unmarshal(body, &monitor) - if err != nil { - fmt.Println(err) - return err + switch { + case monitorConfig.HasHttp(): + monitor = httpMonitorToLocal(monitorConfig.GetHttp()) + case monitorConfig.HasTcp(): + monitor = tcpMonitorToLocal(monitorConfig.GetTcp()) + default: + return fmt.Errorf("unknown monitor type") } fmt.Println(aurora.Bold("Monitor:")) diff --git a/internal/monitors/monitor_info_test.go b/internal/monitors/monitor_info_test.go index 5a48ac9..54ac988 100644 --- a/internal/monitors/monitor_info_test.go +++ b/internal/monitors/monitor_info_test.go @@ -36,35 +36,8 @@ func Test_getMonitorInfo(t *testing.T) { }) t.Run("Should work", func(t *testing.T) { - - body := `{ - "id": 2260, - "periodicity": "10m", - "url": "https://www.openstatus.dev", - "regions": [ - "iad", - "hkg", - "jnb", - "syd", - "gru" - ], - "name": "Vercel Checker Edge", - "description": "", - "method": "GET", - "body": "", - "headers": [ - { - "key": "", - "value": "" - } - ], - "assertions": [], - "active": false, - "public": false, - "degradedAfter": null, - "timeout": 45000, - "jobType": "http" -}` + // Connect RPC response format with MonitorConfig oneof + body := `{"monitor":{"http":{"id":"2260","name":"Vercel Checker Edge","description":"","url":"https://www.openstatus.dev","periodicity":"PERIODICITY_10M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD","REGION_FLY_JNB","REGION_FLY_SYD","REGION_FLY_GRU"],"active":false,"public":false,"timeout":45000}}}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ @@ -72,6 +45,7 @@ func Test_getMonitorInfo(t *testing.T) { return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }, } @@ -83,7 +57,7 @@ func Test_getMonitorInfo(t *testing.T) { }) err := monitors.GetMonitorInfo(interceptor.GetHTTPClient(), "test", "1") if err != nil { - t.Errorf("Expected log output, got nothing") + t.Errorf("Expected no error, got %v", err) } }) diff --git a/internal/monitors/monitor_trigger.go b/internal/monitors/monitor_trigger.go index 4467f08..d344d5e 100644 --- a/internal/monitors/monitor_trigger.go +++ b/internal/monitors/monitor_trigger.go @@ -2,53 +2,38 @@ package monitors import ( "context" - "encoding/json" "fmt" - "io" "net/http" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" "github.com/urfave/cli/v3" ) -func MonitorTrigger(httpClient *http.Client, apiKey string, monitorId string) error { - +// TriggerMonitor triggers a monitor using the SDK +func TriggerMonitor(client monitorv1connect.MonitorServiceClient, monitorId string) error { if monitorId == "" { return fmt.Errorf("Monitor ID is required") } fmt.Println("Waiting for the result...") - url := fmt.Sprintf("%s/monitor/%s/trigger", APIBaseURL, monitorId) - - req, err := http.NewRequest(http.MethodPost, url, nil) - if err != nil { - return err - } - req.Header.Add("x-openstatus-key", apiKey) - res, err := httpClient.Do(req) - if err != nil { - return err - } - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("Failed to trigger monitor test") - } - - defer res.Body.Close() - body, err := io.ReadAll(res.Body) + _, err := client.TriggerMonitor(context.Background(), &monitorv1.TriggerMonitorRequest{ + Id: monitorId, + }) if err != nil { - return fmt.Errorf("failed to read response body: %w", err) + return fmt.Errorf("failed to trigger monitor: %w", err) } - var r MonitorTriggerResponse - err = json.Unmarshal(body, &r) - if err != nil { - return err - } fmt.Printf("Check triggered successfully\n") - return nil } +// TriggerMonitorWithHTTPClient is a convenience function that creates a client and triggers a monitor +func TriggerMonitorWithHTTPClient(httpClient *http.Client, apiKey string, monitorId string) error { + client := NewMonitorClientWithHTTPClient(httpClient, apiKey) + return TriggerMonitor(client, monitorId) +} + func GetMonitorsTriggerCmd() *cli.Command { monitorsCmd := cli.Command{ Name: "trigger", @@ -66,7 +51,8 @@ func GetMonitorsTriggerCmd() *cli.Command { }, Action: func(ctx context.Context, cmd *cli.Command) error { monitorId := cmd.Args().Get(0) - err := MonitorTrigger(http.DefaultClient, cmd.String("access-token"), monitorId) + client := NewMonitorClient(cmd.String("access-token")) + err := TriggerMonitor(client, monitorId) if err != nil { return cli.Exit("Failed to trigger monitor", 1) } diff --git a/internal/monitors/monitor_trigger_test.go b/internal/monitors/monitor_trigger_test.go index 469b919..cdd10c9 100644 --- a/internal/monitors/monitor_trigger_test.go +++ b/internal/monitors/monitor_trigger_test.go @@ -15,11 +15,13 @@ func Test_getMonitorTrigger(t *testing.T) { t.Parallel() t.Run("Monitor ID is required", func(t *testing.T) { - interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } @@ -29,14 +31,14 @@ func Test_getMonitorTrigger(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.MonitorTrigger(interceptor.GetHTTPClient(), "", "") + err := monitors.TriggerMonitorWithHTTPClient(interceptor.GetHTTPClient(), "", "") if err == nil { - t.Errorf("Expected log output, got nothing") + t.Errorf("Expected error for empty monitor ID, got nil") } }) t.Run("Successfully return", func(t *testing.T) { - body := `{"resultId": 1}` + body := `{"success": true}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ @@ -44,6 +46,9 @@ func Test_getMonitorTrigger(t *testing.T) { return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } @@ -53,17 +58,23 @@ func Test_getMonitorTrigger(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.MonitorTrigger(interceptor.GetHTTPClient(), "", "1") + err := monitors.TriggerMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "1") if err != nil { - t.Errorf("Expected no output, got error") + t.Errorf("Expected no error, got: %v", err) } }) t.Run("No 200 throw error", func(t *testing.T) { + body := `{"code":"internal","message":"internal error"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusInternalServerError, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } @@ -73,9 +84,9 @@ func Test_getMonitorTrigger(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.MonitorTrigger(interceptor.GetHTTPClient(), "1", "1") + err := monitors.TriggerMonitorWithHTTPClient(interceptor.GetHTTPClient(), "test-token", "1") if err == nil { - t.Errorf("Expected log output, got nothing") + t.Errorf("Expected error for non-200 status, got nil") } }) } diff --git a/internal/monitors/monitor_update.go b/internal/monitors/monitor_update.go index 2bfcf9e..e99d3ae 100644 --- a/internal/monitors/monitor_update.go +++ b/internal/monitors/monitor_update.go @@ -1,49 +1,62 @@ package monitors import ( - "bytes" - "encoding/json" + "context" "fmt" - "io" "net/http" + "strconv" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" "github.com/openstatusHQ/cli/internal/config" ) +// UpdateMonitor updates a monitor using the SDK, dispatching to the appropriate type func UpdateMonitor(httpClient *http.Client, apiKey string, id int, monitor config.Monitor) (Monitor, error) { + client := NewMonitorClientWithHTTPClient(httpClient, apiKey) + + switch monitor.Kind { + case config.HTTP: + return UpdateHTTPMonitor(client, id, monitor) + case config.TCP: + return UpdateTCPMonitor(client, id, monitor) + default: + return Monitor{}, fmt.Errorf("unsupported monitor kind: %s", monitor.Kind) + } +} - url := fmt.Sprintf("%s/monitor/%s/%d", APIBaseURL, monitor.Kind, id) +// UpdateHTTPMonitor updates an HTTP monitor using the SDK +func UpdateHTTPMonitor(client monitorv1connect.MonitorServiceClient, id int, monitor config.Monitor) (Monitor, error) { + httpMonitor := configToHTTPMonitor(monitor) + httpMonitor.Id = strconv.Itoa(id) - payloadBuf := new(bytes.Buffer) - json.NewEncoder(payloadBuf).Encode(monitor) - req, err := http.NewRequest(http.MethodPut, url, payloadBuf) - if err != nil { - return Monitor{}, fmt.Errorf("failed to create request: %w", err) + req := &monitorv1.UpdateHTTPMonitorRequest{ + Id: strconv.Itoa(id), + Monitor: httpMonitor, } - req.Header.Add("x-openstatus-key", apiKey) - req.Header.Add("Content-Type", "application/json") - - res, err := httpClient.Do(req) + resp, err := client.UpdateHTTPMonitor(context.Background(), req) if err != nil { - return Monitor{}, err + return Monitor{}, fmt.Errorf("failed to update HTTP monitor: %w", err) } - if res.StatusCode != http.StatusOK { - return Monitor{}, fmt.Errorf("Failed to Update monitor") - } + return httpMonitorToLocal(resp.GetMonitor()), nil +} - defer res.Body.Close() - body, err := io.ReadAll(res.Body) - if err != nil { - return Monitor{}, fmt.Errorf("failed to read response body: %w", err) +// UpdateTCPMonitor updates a TCP monitor using the SDK +func UpdateTCPMonitor(client monitorv1connect.MonitorServiceClient, id int, monitor config.Monitor) (Monitor, error) { + tcpMonitor := configToTCPMonitor(monitor) + tcpMonitor.Id = strconv.Itoa(id) + + req := &monitorv1.UpdateTCPMonitorRequest{ + Id: strconv.Itoa(id), + Monitor: tcpMonitor, } - var monitors Monitor - err = json.Unmarshal(body, &monitors) + resp, err := client.UpdateTCPMonitor(context.Background(), req) if err != nil { - return Monitor{}, err + return Monitor{}, fmt.Errorf("failed to update TCP monitor: %w", err) } - return monitors, nil + return tcpMonitorToLocal(resp.GetMonitor()), nil } diff --git a/internal/monitors/monitor_update_test.go b/internal/monitors/monitor_update_test.go index 4b7191a..c0ef6f5 100644 --- a/internal/monitors/monitor_update_test.go +++ b/internal/monitors/monitor_update_test.go @@ -6,7 +6,6 @@ import ( "net/http" "testing" - "github.com/google/go-cmp/cmp" "github.com/openstatusHQ/cli/internal/config" "github.com/openstatusHQ/cli/internal/monitors" ) @@ -14,41 +13,19 @@ import ( func Test_UpdateMonitor(t *testing.T) { t.Parallel() - t.Run("Update monitor successfully", func(t *testing.T) { - body := `{ - "id": 123, - "name": "Updated Monitor", - "url": "https://example.com", - "periodicity": "5m", - "method": "GET", - "regions": ["iad", "ams", "syd"], - "active": true, - "public": false, - "timeout": 45000, - "body": "", - "retry": 5, - "jobType": "http" - }` + t.Run("Update HTTP monitor successfully", func(t *testing.T) { + body := `{"monitor":{"id":"123","name":"Updated Monitor","url":"https://updated.example.com","periodicity":"PERIODICITY_5M","method":"HTTP_METHOD_POST","regions":["REGION_FLY_IAD"],"active":true}}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPut { - t.Errorf("Expected PUT method, got %s", req.Method) - } if req.Header.Get("x-openstatus-key") != "test-api-key" { t.Errorf("Expected x-openstatus-key header, got %s", req.Header.Get("x-openstatus-key")) } - if req.Header.Get("Content-Type") != "application/json" { - t.Errorf("Expected Content-Type header, got %s", req.Header.Get("Content-Type")) - } - expectedURL := "https://api.openstatus.dev/v1/monitor/http/123" - if req.URL.String() != expectedURL { - t.Errorf("Expected URL %s, got %s", expectedURL, req.URL.String()) - } return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }, } @@ -58,12 +35,11 @@ func Test_UpdateMonitor(t *testing.T) { Active: true, Frequency: config.The5M, Kind: config.HTTP, - Regions: []config.Region{config.Iad, config.Ams, config.Syd}, + Regions: []config.Region{config.Iad}, Request: config.Request{ - URL: "https://example.com", - Method: config.Get, + URL: "https://updated.example.com", + Method: config.Post, }, - Retry: 5, } result, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 123, monitor) @@ -79,129 +55,148 @@ func Test_UpdateMonitor(t *testing.T) { } }) - t.Run("Update monitor fails with non-200 status", func(t *testing.T) { + t.Run("Update TCP monitor successfully", func(t *testing.T) { + body := `{"monitor":{"id":"456","name":"Updated TCP Monitor","uri":"updated.example.com:8080","periodicity":"PERIODICITY_1M","regions":["REGION_FLY_AMS"],"active":true}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ - StatusCode: http.StatusBadRequest, - Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "bad request"}`))), + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }, } monitor := config.Monitor{ - Name: "Test Monitor", - Kind: config.HTTP, + Name: "Updated TCP Monitor", + Active: true, + Frequency: config.The1M, + Kind: config.TCP, + Regions: []config.Region{config.Ams}, + Request: config.Request{ + Host: "updated.example.com", + Port: 8080, + }, } - _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 123, monitor) - if err == nil { - t.Error("Expected error for non-200 status, got nil") + result, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 456, monitor) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != 456 { + t.Errorf("Expected ID 456, got %d", result.ID) + } + if result.JobType != "tcp" { + t.Errorf("Expected jobType 'tcp', got %s", result.JobType) } }) - t.Run("Update monitor returns correct Monitor struct", func(t *testing.T) { - body := `{ - "id": 789, - "name": "Full Updated Monitor", - "url": "https://updated.example.com", - "periodicity": "30m", - "description": "Updated description", - "method": "PUT", - "regions": ["lhr", "fra"], - "active": false, - "public": true, - "timeout": 60000, - "degraded_after": 10000, - "body": "{\"updated\": true}", - "headers": [{"key": "Authorization", "value": "Bearer token"}], - "assertions": [{"type": "statusCode", "compare": "eq", "target": 201}], - "retry": 1, - "jobType": "http" - }` + t.Run("Update monitor fails with error response", func(t *testing.T) { + body := `{"code":"not_found","message":"monitor not found"}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ - StatusCode: http.StatusOK, + StatusCode: http.StatusNotFound, Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }, } monitor := config.Monitor{ - Name: "Full Updated Monitor", + Name: "Test Monitor", Kind: config.HTTP, } - result, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 789, monitor) - if err != nil { - t.Fatalf("Expected no error, got %v", err) + _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 999, monitor) + if err == nil { + t.Error("Expected error for not found status, got nil") } + }) - expected := monitors.Monitor{ - ID: 789, - Name: "Full Updated Monitor", - URL: "https://updated.example.com", - Periodicity: "30m", - Description: "Updated description", - Method: "PUT", - Regions: []string{"lhr", "fra"}, - Active: false, - Public: true, - Timeout: 60000, - DegradedAfter: 10000, - Body: "{\"updated\": true}", - Headers: []monitors.Header{{Key: "Authorization", Value: "Bearer token"}}, - Assertions: []monitors.Assertion{{Type: "statusCode", Compare: "eq", Target: float64(201)}}, - Retry: 1, - JobType: "http", - } - - if !cmp.Equal(expected, result) { - t.Errorf("Expected %+v, got %+v", expected, result) + t.Run("Unsupported monitor kind returns error", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } + + monitor := config.Monitor{ + Name: "Test Monitor", + Kind: "unsupported", + } + + _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 123, monitor) + if err == nil { + t.Error("Expected error for unsupported monitor kind, got nil") } }) - t.Run("Update TCP monitor uses correct URL", func(t *testing.T) { - body := `{ - "id": 100, - "name": "TCP Monitor", - "url": "example.com:443", - "periodicity": "1m", - "method": "", - "regions": ["iad"], - "active": true, - "public": false, - "timeout": 10000, - "body": "", - "retry": 0, - "jobType": "tcp" - }` + t.Run("Update HTTP monitor returns correct Monitor struct", func(t *testing.T) { + body := `{"monitor":{"id":"789","name":"Full Monitor","description":"Test description","url":"https://test.example.com","periodicity":"PERIODICITY_5M","method":"HTTP_METHOD_POST","regions":["REGION_FLY_IAD","REGION_FLY_AMS","REGION_FLY_SYD"],"active":true,"public":true,"timeout":30000,"retry":2}}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { - expectedURL := "https://api.openstatus.dev/v1/monitor/tcp/100" - if req.URL.String() != expectedURL { - t.Errorf("Expected URL %s, got %s", expectedURL, req.URL.String()) - } return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{"Content-Type": []string{"application/json"}}, }, nil }, } monitor := config.Monitor{ - Name: "TCP Monitor", - Kind: config.TCP, + Name: "Full Monitor", + Kind: config.HTTP, } - _, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 100, monitor) + result, err := monitors.UpdateMonitor(interceptor.GetHTTPClient(), "test-api-key", 789, monitor) if err != nil { t.Fatalf("Expected no error, got %v", err) } + + if result.ID != 789 { + t.Errorf("Expected ID 789, got %d", result.ID) + } + if result.Name != "Full Monitor" { + t.Errorf("Expected name 'Full Monitor', got %s", result.Name) + } + if result.Description != "Test description" { + t.Errorf("Expected description 'Test description', got %s", result.Description) + } + if result.URL != "https://test.example.com" { + t.Errorf("Expected URL 'https://test.example.com', got %s", result.URL) + } + if result.Periodicity != "5m" { + t.Errorf("Expected periodicity '5m', got %s", result.Periodicity) + } + if result.Method != "POST" { + t.Errorf("Expected method 'POST', got %s", result.Method) + } + if result.Active != true { + t.Errorf("Expected active true, got %v", result.Active) + } + if result.Public != true { + t.Errorf("Expected public true, got %v", result.Public) + } + if result.Timeout != 30000 { + t.Errorf("Expected timeout 30000, got %d", result.Timeout) + } + if result.Retry != 2 { + t.Errorf("Expected retry 2, got %d", result.Retry) + } + if result.JobType != "http" { + t.Errorf("Expected jobType 'http', got %s", result.JobType) + } }) } diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index cb941fe..1ac8e70 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -1,14 +1,393 @@ package monitors import ( + "context" "encoding/json" + "fmt" + "net/http" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" + "connectrpc.com/connect" + "github.com/openstatusHQ/cli/internal/config" "github.com/urfave/cli/v3" ) // APIBaseURL is the base URL for the OpenStatus API const APIBaseURL = "https://api.openstatus.dev/v1" +// ConnectBaseURL is the base URL for the Connect RPC API +const ConnectBaseURL = "https://api.openstatus.dev/rpc" + +// NewAuthInterceptor creates an interceptor that adds the API key to all requests +func NewAuthInterceptor(apiKey string) connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + req.Header().Set("x-openstatus-key", apiKey) + return next(ctx, req) + } + } +} + +// NewMonitorClient creates a new Monitor service client with authentication +func NewMonitorClient(apiKey string) monitorv1connect.MonitorServiceClient { + return monitorv1connect.NewMonitorServiceClient( + http.DefaultClient, + ConnectBaseURL, + connect.WithInterceptors(NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +// NewMonitorClientWithHTTPClient creates a new Monitor service client with a custom HTTP client +func NewMonitorClientWithHTTPClient(httpClient *http.Client, apiKey string) monitorv1connect.MonitorServiceClient { + return monitorv1connect.NewMonitorServiceClient( + httpClient, + ConnectBaseURL, + connect.WithInterceptors(NewAuthInterceptor(apiKey)), + connect.WithProtoJSON(), + ) +} + +// Helper functions to convert SDK types to CLI display types + +// periodicityToString converts SDK Periodicity enum to string +func periodicityToString(p monitorv1.Periodicity) string { + switch p { + case monitorv1.Periodicity_PERIODICITY_30S: + return "30s" + case monitorv1.Periodicity_PERIODICITY_1M: + return "1m" + case monitorv1.Periodicity_PERIODICITY_5M: + return "5m" + case monitorv1.Periodicity_PERIODICITY_10M: + return "10m" + case monitorv1.Periodicity_PERIODICITY_30M: + return "30m" + case monitorv1.Periodicity_PERIODICITY_1H: + return "1h" + default: + return "unknown" + } +} + +// httpMethodToString converts SDK HTTPMethod enum to string +func httpMethodToString(m monitorv1.HTTPMethod) string { + switch m { + case monitorv1.HTTPMethod_HTTP_METHOD_GET: + return "GET" + case monitorv1.HTTPMethod_HTTP_METHOD_POST: + return "POST" + case monitorv1.HTTPMethod_HTTP_METHOD_HEAD: + return "HEAD" + case monitorv1.HTTPMethod_HTTP_METHOD_PUT: + return "PUT" + case monitorv1.HTTPMethod_HTTP_METHOD_PATCH: + return "PATCH" + case monitorv1.HTTPMethod_HTTP_METHOD_DELETE: + return "DELETE" + case monitorv1.HTTPMethod_HTTP_METHOD_TRACE: + return "TRACE" + case monitorv1.HTTPMethod_HTTP_METHOD_CONNECT: + return "CONNECT" + case monitorv1.HTTPMethod_HTTP_METHOD_OPTIONS: + return "OPTIONS" + default: + return "" + } +} + +// regionToString converts SDK Region enum to string +func regionToString(r monitorv1.Region) string { + switch r { + case monitorv1.Region_REGION_FLY_AMS: + return "ams" + case monitorv1.Region_REGION_FLY_ARN: + return "arn" + case monitorv1.Region_REGION_FLY_BOM: + return "bom" + case monitorv1.Region_REGION_FLY_CDG: + return "cdg" + case monitorv1.Region_REGION_FLY_DFW: + return "dfw" + case monitorv1.Region_REGION_FLY_EWR: + return "ewr" + case monitorv1.Region_REGION_FLY_FRA: + return "fra" + case monitorv1.Region_REGION_FLY_GRU: + return "gru" + case monitorv1.Region_REGION_FLY_IAD: + return "iad" + case monitorv1.Region_REGION_FLY_JNB: + return "jnb" + case monitorv1.Region_REGION_FLY_LAX: + return "lax" + case monitorv1.Region_REGION_FLY_LHR: + return "lhr" + case monitorv1.Region_REGION_FLY_NRT: + return "nrt" + case monitorv1.Region_REGION_FLY_ORD: + return "ord" + case monitorv1.Region_REGION_FLY_SJC: + return "sjc" + case monitorv1.Region_REGION_FLY_SIN: + return "sin" + case monitorv1.Region_REGION_FLY_SYD: + return "syd" + case monitorv1.Region_REGION_FLY_YYZ: + return "yyz" + default: + return r.String() + } +} + +// regionsToStrings converts a slice of SDK Region enums to strings +func regionsToStrings(regions []monitorv1.Region) []string { + result := make([]string, len(regions)) + for i, r := range regions { + result[i] = r.String() + } + return result +} + +// Inverse converter functions (config types → SDK types) + +// stringToPeriodicity converts config.Frequency to SDK Periodicity +func stringToPeriodicity(f config.Frequency) monitorv1.Periodicity { + switch f { + case config.The30S: + return monitorv1.Periodicity_PERIODICITY_30S + case config.The1M: + return monitorv1.Periodicity_PERIODICITY_1M + case config.The5M: + return monitorv1.Periodicity_PERIODICITY_5M + case config.The10M: + return monitorv1.Periodicity_PERIODICITY_10M + case config.The30M: + return monitorv1.Periodicity_PERIODICITY_30M + case config.The1H: + return monitorv1.Periodicity_PERIODICITY_1H + default: + return monitorv1.Periodicity_PERIODICITY_10M + } +} + +// stringToHTTPMethod converts config.Method to SDK HTTPMethod +func stringToHTTPMethod(m config.Method) monitorv1.HTTPMethod { + switch m { + case config.Get: + return monitorv1.HTTPMethod_HTTP_METHOD_GET + case config.Post: + return monitorv1.HTTPMethod_HTTP_METHOD_POST + case config.Put: + return monitorv1.HTTPMethod_HTTP_METHOD_PUT + case config.Patch: + return monitorv1.HTTPMethod_HTTP_METHOD_PATCH + case config.Delete: + return monitorv1.HTTPMethod_HTTP_METHOD_DELETE + case config.Head: + return monitorv1.HTTPMethod_HTTP_METHOD_HEAD + case config.Options: + return monitorv1.HTTPMethod_HTTP_METHOD_OPTIONS + default: + return monitorv1.HTTPMethod_HTTP_METHOD_GET + } +} + +// stringToRegion converts config.Region to SDK Region +func stringToRegion(r config.Region) monitorv1.Region { + switch r { + case config.Ams: + return monitorv1.Region_REGION_FLY_AMS + case config.Arn: + return monitorv1.Region_REGION_FLY_ARN + case config.BOM: + return monitorv1.Region_REGION_FLY_BOM + case config.Cdg: + return monitorv1.Region_REGION_FLY_CDG + case config.Dfw: + return monitorv1.Region_REGION_FLY_DFW + case config.Ewr: + return monitorv1.Region_REGION_FLY_EWR + case config.Fra: + return monitorv1.Region_REGION_FLY_FRA + case config.Gru: + return monitorv1.Region_REGION_FLY_GRU + case config.Iad: + return monitorv1.Region_REGION_FLY_IAD + case config.Jnb: + return monitorv1.Region_REGION_FLY_JNB + case config.Lax: + return monitorv1.Region_REGION_FLY_LAX + case config.Lhr: + return monitorv1.Region_REGION_FLY_LHR + case config.Nrt: + return monitorv1.Region_REGION_FLY_NRT + case config.Ord: + return monitorv1.Region_REGION_FLY_ORD + case config.Sin: + return monitorv1.Region_REGION_FLY_SIN + case config.Sjc: + return monitorv1.Region_REGION_FLY_SJC + case config.Syd: + return monitorv1.Region_REGION_FLY_SYD + case config.Yyz: + return monitorv1.Region_REGION_FLY_YYZ + default: + return monitorv1.Region_REGION_UNSPECIFIED + } +} + +// stringsToRegions converts []config.Region to []monitorv1.Region +func stringsToRegions(regions []config.Region) []monitorv1.Region { + result := make([]monitorv1.Region, len(regions)) + for i, r := range regions { + result[i] = stringToRegion(r) + } + return result +} + +// configCompareToNumberComparator converts config.Compare to NumberComparator +func configCompareToNumberComparator(c config.Compare) monitorv1.NumberComparator { + switch c { + case config.Eq: + return monitorv1.NumberComparator_NUMBER_COMPARATOR_EQUAL + case config.NotEq: + return monitorv1.NumberComparator_NUMBER_COMPARATOR_NOT_EQUAL + case config.Gt: + return monitorv1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN + case config.Gte: + return monitorv1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL + case config.Lt: + return monitorv1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN + case config.LTE: + return monitorv1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL + default: + return monitorv1.NumberComparator_NUMBER_COMPARATOR_EQUAL + } +} + +// configCompareToStringComparator converts config.Compare to StringComparator +func configCompareToStringComparator(c config.Compare) monitorv1.StringComparator { + switch c { + case config.Eq: + return monitorv1.StringComparator_STRING_COMPARATOR_EQUAL + case config.NotEq: + return monitorv1.StringComparator_STRING_COMPARATOR_NOT_EQUAL + case config.Contains: + return monitorv1.StringComparator_STRING_COMPARATOR_CONTAINS + case config.NotContains: + return monitorv1.StringComparator_STRING_COMPARATOR_NOT_CONTAINS + case config.Empty: + return monitorv1.StringComparator_STRING_COMPARATOR_EMPTY + case config.NotEmpty: + return monitorv1.StringComparator_STRING_COMPARATOR_NOT_EMPTY + case config.Gt: + return monitorv1.StringComparator_STRING_COMPARATOR_GREATER_THAN + case config.Gte: + return monitorv1.StringComparator_STRING_COMPARATOR_GREATER_THAN_OR_EQUAL + case config.Lt: + return monitorv1.StringComparator_STRING_COMPARATOR_LESS_THAN + case config.LTE: + return monitorv1.StringComparator_STRING_COMPARATOR_LESS_THAN_OR_EQUAL + default: + return monitorv1.StringComparator_STRING_COMPARATOR_EQUAL + } +} + +// Builder functions (config.Monitor → SDK monitor types) + +// configToHTTPMonitor converts config.Monitor to SDK HTTPMonitor +func configToHTTPMonitor(m config.Monitor) *monitorv1.HTTPMonitor { + // Convert headers + headers := make([]*monitorv1.Headers, 0, len(m.Request.Headers)) + for k, v := range m.Request.Headers { + headers = append(headers, &monitorv1.Headers{Key: k, Value: v}) + } + + // Convert assertions to separate types + var statusCodeAssertions []*monitorv1.StatusCodeAssertion + var bodyAssertions []*monitorv1.BodyAssertion + var headerAssertions []*monitorv1.HeaderAssertion + + for _, a := range m.Assertions { + switch a.Kind { + case config.StatusCode: + var target int64 + switch v := a.Target.(type) { + case int: + target = int64(v) + case int64: + target = v + case float64: + target = int64(v) + } + statusCodeAssertions = append(statusCodeAssertions, &monitorv1.StatusCodeAssertion{ + Target: target, + Comparator: configCompareToNumberComparator(a.Compare), + }) + case config.TextBody: + target, _ := a.Target.(string) + bodyAssertions = append(bodyAssertions, &monitorv1.BodyAssertion{ + Target: target, + Comparator: configCompareToStringComparator(a.Compare), + }) + case config.Header: + target, _ := a.Target.(string) + headerAssertions = append(headerAssertions, &monitorv1.HeaderAssertion{ + Key: a.Key, + Target: target, + Comparator: configCompareToStringComparator(a.Compare), + }) + } + } + + monitor := &monitorv1.HTTPMonitor{ + Name: m.Name, + Description: m.Description, + Url: m.Request.URL, + Method: stringToHTTPMethod(m.Request.Method), + Body: m.Request.Body, + Periodicity: stringToPeriodicity(m.Frequency), + Active: m.Active, + Public: m.Public, + Regions: stringsToRegions(m.Regions), + Timeout: m.Timeout, + Retry: m.Retry, + Headers: headers, + StatusCodeAssertions: statusCodeAssertions, + BodyAssertions: bodyAssertions, + HeaderAssertions: headerAssertions, + } + + if m.DegradedAfter > 0 { + monitor.DegradedAt = &m.DegradedAfter + } + + return monitor +} + +// configToTCPMonitor converts config.Monitor to SDK TCPMonitor +func configToTCPMonitor(m config.Monitor) *monitorv1.TCPMonitor { + monitor := &monitorv1.TCPMonitor{ + Name: m.Name, + Description: m.Description, + Uri: fmt.Sprintf("%s:%d", m.Request.Host, m.Request.Port), + Periodicity: stringToPeriodicity(m.Frequency), + Active: m.Active, + Public: m.Public, + Regions: stringsToRegions(m.Regions), + Timeout: m.Timeout, + Retry: m.Retry, + } + + if m.DegradedAfter > 0 { + monitor.DegradedAt = &m.DegradedAfter + } + + return monitor +} + type Monitor struct { ID int `json:"id"` Name string `json:"name"` diff --git a/internal/monitors/monitors_list.go b/internal/monitors/monitors_list.go index a79496e..849d4d0 100644 --- a/internal/monitors/monitors_list.go +++ b/internal/monitors/monitors_list.go @@ -2,11 +2,11 @@ package monitors import ( "context" - "encoding/json" "fmt" - "io" "net/http" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" "github.com/fatih/color" "github.com/rodaine/table" "github.com/urfave/cli/v3" @@ -14,32 +14,11 @@ import ( var allMonitor bool -func ListMonitors(httpClient *http.Client, apiKey string) error { - url := APIBaseURL + "/monitor" - - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Add("x-openstatus-key", apiKey) - res, err := httpClient.Do(req) - if err != nil { - return err - } - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("Failed to list monitors") - } - - defer res.Body.Close() - body, err := io.ReadAll(res.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - var monitors []Monitor - err = json.Unmarshal(body, &monitors) +// ListMonitors fetches and displays all monitors using the SDK +func ListMonitors(client monitorv1connect.MonitorServiceClient) error { + resp, err := client.ListMonitors(context.Background(), &monitorv1.ListMonitorsRequest{}) if err != nil { - return err + return fmt.Errorf("failed to list monitors: %w", err) } headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() @@ -48,16 +27,38 @@ func ListMonitors(httpClient *http.Client, apiKey string) error { tbl := table.New("ID", "Name", "Url") tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) - for _, monitor := range monitors { - if monitor.Active || allMonitor { - tbl.AddRow(monitor.ID, monitor.Name, monitor.URL) + // Add HTTP monitors + for _, monitor := range resp.GetHttpMonitors() { + if monitor.GetActive() || allMonitor { + tbl.AddRow(monitor.GetId(), monitor.GetName(), monitor.GetUrl()) } } + + // Add TCP monitors + for _, monitor := range resp.GetTcpMonitors() { + if monitor.GetActive() || allMonitor { + tbl.AddRow(monitor.GetId(), monitor.GetName(), monitor.GetUri()) + } + } + + // Add DNS monitors + for _, monitor := range resp.GetDnsMonitors() { + if monitor.GetActive() || allMonitor { + tbl.AddRow(monitor.GetId(), monitor.GetName(), monitor.GetUri()) + } + } + tbl.Print() return nil } +// ListMonitorsWithHTTPClient is a convenience function that creates a client and lists monitors +func ListMonitorsWithHTTPClient(httpClient *http.Client, apiKey string) error { + client := NewMonitorClientWithHTTPClient(httpClient, apiKey) + return ListMonitors(client) +} + func GetMonitorsListCmd() *cli.Command { monitorsListCmd := cli.Command{ Name: "list", @@ -80,7 +81,8 @@ func GetMonitorsListCmd() *cli.Command { }, Action: func(ctx context.Context, cmd *cli.Command) error { fmt.Println("List of all monitors") - err := ListMonitors(http.DefaultClient, cmd.String("access-token")) + client := NewMonitorClient(cmd.String("access-token")) + err := ListMonitors(client) if err != nil { return cli.Exit("Failed to list monitors", 1) } diff --git a/internal/monitors/monitors_list_test.go b/internal/monitors/monitors_list_test.go index e484de1..6399b9b 100644 --- a/internal/monitors/monitors_list_test.go +++ b/internal/monitors/monitors_list_test.go @@ -15,32 +15,9 @@ func Test_listMonitors(t *testing.T) { t.Parallel() t.Run("Successfully return", func(t *testing.T) { - body := `[ - { - "id": 1, - "periodicity": "10m", - "url": "https://www.openstatus.dev", - "regions": [ - "ams", - "scl" - ], - "name": "OpenStatus ", - "description": "Our website 🌐", - "method": "GET", - "body": "", - "headers": [ - { - "key": "", - "value": "" - } - ], - "assertions": [], - "active": true, - "public": true, - "degradedAfter": null, - "timeout": 45000, - "jobType": "http" - }]` + // Connect RPC response format with protobuf content + // The response is a ListMonitorsResponse in JSON format with Connect headers + body := `{"httpMonitors":[{"id":"1","name":"OpenStatus","url":"https://www.openstatus.dev","periodicity":"PERIODICITY_10M","active":true,"public":true}]}` r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ @@ -48,6 +25,9 @@ func Test_listMonitors(t *testing.T) { return &http.Response{ StatusCode: http.StatusOK, Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } @@ -57,18 +37,24 @@ func Test_listMonitors(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.ListMonitors(interceptor.GetHTTPClient(), "") + err := monitors.ListMonitorsWithHTTPClient(interceptor.GetHTTPClient(), "test-token") if err != nil { t.Error(err) t.Errorf("Expected log output, got nothing") } }) t.Run("No 200 throw error", func(t *testing.T) { + body := `{"code":"internal","message":"internal error"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusInternalServerError, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, }, nil }, } @@ -78,9 +64,9 @@ func Test_listMonitors(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.ListMonitors(interceptor.GetHTTPClient(), "1") + err := monitors.ListMonitorsWithHTTPClient(interceptor.GetHTTPClient(), "1") if err == nil { - t.Errorf("Expected log output, got nothing") + t.Errorf("Expected error, got nothing") } }) }