From 7e9b190ad7aed04ed9c8f22fe7dad5f3e0985350 Mon Sep 17 00:00:00 2001 From: javaadsnappcar <92871444+javaadsnappcar@users.noreply.github.com> Date: Tue, 26 Apr 2022 17:43:32 +0200 Subject: [PATCH 01/40] Add functionality to create Application Performance Monitoring (APM) alerts (#25) * Added APMRulesService to allow creating APM alerts * Added APMRulesService tests * Removed RuleService comment --- sentry/apm_rules.go | 81 ++++++++ sentry/apm_rules_test.go | 397 +++++++++++++++++++++++++++++++++++++++ sentry/sentry.go | 2 + 3 files changed, 480 insertions(+) create mode 100644 sentry/apm_rules.go create mode 100644 sentry/apm_rules_test.go diff --git a/sentry/apm_rules.go b/sentry/apm_rules.go new file mode 100644 index 0000000..94db9da --- /dev/null +++ b/sentry/apm_rules.go @@ -0,0 +1,81 @@ +package sentry + +import ( + "net/http" + "time" + + "github.com/dghubble/sling" +) + +type APMRuleService struct { + sling *sling.Sling +} + +type APMRule struct { + ID string `json:"id"` + Name string `json:"name"` + Environment *string `json:"environment,omitempty"` + DataSet string `json:"dataset"` + Query string `json:"query"` + Aggregate string `json:"aggregate"` + TimeWindow float64 `json:"timeWindow"` + ThresholdType int `json:"thresholdType"` + ResolveThreshold float64 `json:"resolveThreshold"` + Triggers []Trigger `json:"triggers"` + Projects []string `json:"projects"` + Owner string `json:"owner"` + Created time.Time `json:"dateCreated"` +} + +type Trigger map[string]interface{} + +func newAPMRuleService(sling *sling.Sling) *APMRuleService { + return &APMRuleService{ + sling: sling, + } +} + +// List APM rules configured for a project +func (s *APMRuleService) List(organizationSlug string, projectSlug string) ([]APMRule, *http.Response, error) { + apmRules := new([]APMRule) + apiError := new(APIError) + resp, err := s.sling.New().Get("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/").Receive(apmRules, apiError) + return *apmRules, resp, relevantError(err, *apiError) +} + +type CreateAPMRuleParams struct { + Name string `json:"name"` + Environment *string `json:"environment,omitempty"` + DataSet string `json:"dataset"` + Query string `json:"query"` + Aggregate string `json:"aggregate"` + TimeWindow float64 `json:"timeWindow"` + ThresholdType int `json:"thresholdType"` + ResolveThreshold float64 `json:"resolveThreshold"` + Triggers []Trigger `json:"triggers"` + Projects []string `json:"projects"` + Owner string `json:"owner"` +} + +// Create a new APM rule bound to a project. +func (s *APMRuleService) Create(organizationSlug string, projectSlug string, params *CreateAPMRuleParams) (*APMRule, *http.Response, error) { + apmRule := new(APMRule) + apiError := new(APIError) + resp, err := s.sling.New().Post("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/").BodyJSON(params).Receive(apmRule, apiError) + return apmRule, resp, relevantError(err, *apiError) +} + +// Update a APM rule. +func (s *APMRuleService) Update(organizationSlug string, projectSlug string, apmRuleID string, params *APMRule) (*APMRule, *http.Response, error) { + apmRule := new(APMRule) + apiError := new(APIError) + resp, err := s.sling.New().Put("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/"+apmRuleID+"/").BodyJSON(params).Receive(apmRule, apiError) + return apmRule, resp, relevantError(err, *apiError) +} + +// Delete a APM rule. +func (s *APMRuleService) Delete(organizationSlug string, projectSlug string, apmRuleID string) (*http.Response, error) { + apiError := new(APIError) + resp, err := s.sling.New().Delete("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/"+apmRuleID+"/").Receive(nil, apiError) + return resp, relevantError(err, *apiError) +} diff --git a/sentry/apm_rules_test.go b/sentry/apm_rules_test.go new file mode 100644 index 0000000..1c38e68 --- /dev/null +++ b/sentry/apm_rules_test.go @@ -0,0 +1,397 @@ +package sentry + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPMRuleService_List(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[ + { + "id": "12345", + "name": "pump-station-alert", + "environment": "production", + "dataset": "transactions", + "query": "http.url:http://service/unreadmessages", + "aggregate": "p50(transaction.duration)", + "thresholdType": 0, + "resolveThreshold": 100.0, + "timeWindow": 5.0, + "triggers": [ + { + "id": "6789", + "alertRuleId": "12345", + "label": "critical", + "thresholdType": 0, + "alertThreshold": 55501.0, + "resolveThreshold": 100.0, + "dateCreated": "2022-04-07T16:46:48.607583Z", + "actions": [ + { + "id": "12345", + "alertRuleTriggerId": "12345", + "type": "slack", + "targetType": "specific", + "targetIdentifier": "#apm-alerts", + "inputChannelId": "C038NF00X4F", + "integrationId": 123, + "sentryAppId": null, + "dateCreated": "2022-04-07T16:46:49.154638Z", + "desc": "Send a Slack notification to #apm-alerts" + } + ] + } + ], + "projects": [ + "pump-station" + ], + "owner": "pump-station:12345", + "dateCreated": "2022-04-07T16:46:48.569571Z" + } + ]`) + }) + + client := NewClient(httpClient, nil, "") + apmRules, _, err := client.APMRules.List("the-interstellar-jurisdiction", "pump-station") + require.NoError(t, err) + + environment := "production" + expected := []APMRule{ + { + ID: "12345", + Name: "pump-station-alert", + Environment: &environment, + DataSet: "transactions", + Query: "http.url:http://service/unreadmessages", + Aggregate: "p50(transaction.duration)", + ThresholdType: int(0), + ResolveThreshold: float64(100.0), + TimeWindow: float64(5.0), + Triggers: []Trigger{ + { + "id": "6789", + "alertRuleId": "12345", + "label": "critical", + "thresholdType": float64(0), + "alertThreshold": float64(55501.0), + "resolveThreshold": float64(100.0), + "dateCreated": "2022-04-07T16:46:48.607583Z", + "actions": []interface{}{map[string]interface{}{ + "id": "12345", + "alertRuleTriggerId": "12345", + "type": "slack", + "targetType": "specific", + "targetIdentifier": "#apm-alerts", + "inputChannelId": "C038NF00X4F", + "integrationId": float64(123), + "sentryAppId": interface{}(nil), + "dateCreated": "2022-04-07T16:46:49.154638Z", + "desc": "Send a Slack notification to #apm-alerts", + }, + }, + }, + }, + Projects: []string{"pump-station"}, + Owner: "pump-station:12345", + Created: mustParseTime("2022-04-07T16:46:48.569571Z"), + }, + } + require.Equal(t, expected, apmRules) +} + +func TestAPMRuleService_Create(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, ` + { + "id": "12345", + "name": "pump-station-alert", + "environment": "production", + "dataset": "transactions", + "query": "http.url:http://service/unreadmessages", + "aggregate": "p50(transaction.duration)", + "timeWindow": 10, + "thresholdType": 0, + "resolveThreshold": 0, + "triggers": [ + { + "actions": [ + { + "alertRuleTriggerId": "56789", + "dateCreated": "2022-04-15T15:06:01.087054Z", + "desc": "Send a Slack notification to #apm-alerts", + "id": "12389", + "inputChannelId": "C0XXXFKLXXX", + "integrationId": 111, + "sentryAppId": null, + "targetIdentifier": "#apm-alerts", + "targetType": "specific", + "type": "slack" + } + ], + "alertRuleId": "12345", + "alertThreshold": 10000, + "dateCreated": "2022-04-15T15:06:01.079598Z", + "id": "56789", + "label": "critical", + "resolveThreshold": 0, + "thresholdType": 0 + } + ], + "projects": [ + "pump-station" + ], + "owner": "pump-station:12345", + "dateCreated": "2022-04-15T15:06:01.05618Z" + } + `) + }) + + client := NewClient(httpClient, nil, "") + environment := "production" + params := CreateAPMRuleParams{ + Name: "pump-station-alert", + Environment: &environment, + DataSet: "transactions", + Query: "http.url:http://service/unreadmessages", + Aggregate: "p50(transaction.duration)", + TimeWindow: 10.0, + ThresholdType: 0, + ResolveThreshold: 0, + Triggers: []Trigger{{ + "actions": []interface{}{map[string]interface{}{ + "type": "slack", + "targetType": "specific", + "targetIdentifier": "#apm-alerts", + "inputChannelId": "C0XXXFKLXXX", + "integrationId": 111, + }, + }, + "alertThreshold": 10000, + "label": "critical", + "resolveThreshold": 0, + "thresholdType": 0, + }}, + Projects: []string{"pump-station"}, + Owner: "pump-station:12345", + } + apmRule, _, err := client.APMRules.Create("the-interstellar-jurisdiction", "pump-station", ¶ms) + require.NoError(t, err) + + expected := &APMRule{ + ID: "12345", + Name: "pump-station-alert", + Environment: &environment, + DataSet: "transactions", + Query: "http.url:http://service/unreadmessages", + Aggregate: "p50(transaction.duration)", + ThresholdType: int(0), + ResolveThreshold: float64(0), + TimeWindow: float64(10.0), + Triggers: []Trigger{ + { + "id": "56789", + "alertRuleId": "12345", + "label": "critical", + "thresholdType": float64(0), + "alertThreshold": float64(10000), + "resolveThreshold": float64(0), + "dateCreated": "2022-04-15T15:06:01.079598Z", + "actions": []interface{}{map[string]interface{}{ + "id": "12389", + "alertRuleTriggerId": "56789", + "type": "slack", + "targetType": "specific", + "targetIdentifier": "#apm-alerts", + "inputChannelId": "C0XXXFKLXXX", + "integrationId": float64(111), + "sentryAppId": interface{}(nil), + "dateCreated": "2022-04-15T15:06:01.087054Z", + "desc": "Send a Slack notification to #apm-alerts", + }, + }, + }, + }, + Projects: []string{"pump-station"}, + Owner: "pump-station:12345", + Created: mustParseTime("2022-04-15T15:06:01.05618Z"), + } + + require.Equal(t, expected, apmRule) +} + +func TestAPMRuleService_Update(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + environment := "production" + params := &APMRule{ + ID: "12345", + Name: "pump-station-alert", + Environment: &environment, + DataSet: "transactions", + Query: "http.url:http://service/unreadmessages", + Aggregate: "p50(transaction.duration)", + TimeWindow: 10, + ThresholdType: 0, + ResolveThreshold: 0, + Triggers: []Trigger{{ + "actions": []interface{}{map[string]interface{}{}}, + "alertRuleId": "12345", + "alertThreshold": 10000, + "dateCreated": "2022-04-15T15:06:01.079598Z", + "id": "56789", + "label": "critical", + "resolveThreshold": 0, + "thresholdType": 0, + }}, + Owner: "pump-station:12345", + Created: mustParseTime("2022-04-15T15:06:01.079598Z"), + } + + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/12345/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "PUT", r) + assertPostJSON(t, map[string]interface{}{ + "id": "12345", + "name": "pump-station-alert", + "environment": environment, + "dataset": "transactions", + "query": "http.url:http://service/unreadmessages", + "aggregate": "p50(transaction.duration)", + "timeWindow": json.Number("10"), + "thresholdType": json.Number("0"), + "resolveThreshold": json.Number("0"), + "triggers": []interface{}{ + map[string]interface{}{ + "actions": []interface{}{map[string]interface{}{}}, + "alertRuleId": "12345", + "alertThreshold": json.Number("10000"), + "dateCreated": "2022-04-15T15:06:01.079598Z", + "id": "56789", + "label": "critical", + "resolveThreshold": json.Number("0"), + "thresholdType": json.Number("0"), + }, + }, + "projects": interface{}(nil), + "owner": "pump-station:12345", + "dateCreated": "2022-04-15T15:06:01.079598Z", + }, r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, ` + { + "id": "12345", + "name": "pump-station-alert", + "environment": "production", + "dataset": "transactions", + "query": "http.url:http://service/unreadmessages", + "aggregate": "p50(transaction.duration)", + "timeWindow": 10, + "thresholdType": 0, + "resolveThreshold": 0, + "triggers": [ + { + "actions": [ + { + "id": "12389", + "alertRuleTriggerId": "56789", + "type": "slack", + "targetType": "specific", + "targetIdentifier": "#apm-alerts", + "inputChannelId": "C0XXXFKLXXX", + "integrationId": 111, + "sentryAppId": null, + "dateCreated": "2022-04-15T15:06:01.087054Z", + "desc": "Send a Slack notification to #apm-alerts" + } + ], + "alertRuleId": "12345", + "alertThreshold": 10000, + "dateCreated": "2022-04-15T15:06:01.079598Z", + "id": "56789", + "label": "critical", + "resolveThreshold": 0, + "thresholdType": 0 + } + ], + "projects": [ + "pump-station" + ], + "owner": "pump-station:12345", + "dateCreated": "2022-04-15T15:06:01.05618Z" + } + `) + }) + + client := NewClient(httpClient, nil, "") + apmRule, _, err := client.APMRules.Update("the-interstellar-jurisdiction", "pump-station", "12345", params) + assert.NoError(t, err) + + expected := &APMRule{ + ID: "12345", + Name: "pump-station-alert", + Environment: &environment, + DataSet: "transactions", + Query: "http.url:http://service/unreadmessages", + Aggregate: "p50(transaction.duration)", + ThresholdType: int(0), + ResolveThreshold: float64(0), + TimeWindow: float64(10.0), + Triggers: []Trigger{ + { + "id": "56789", + "alertRuleId": "12345", + "label": "critical", + "thresholdType": float64(0), + "alertThreshold": float64(10000), + "resolveThreshold": float64(0), + "dateCreated": "2022-04-15T15:06:01.079598Z", + "actions": []interface{}{map[string]interface{}{ + "id": "12389", + "alertRuleTriggerId": "56789", + "type": "slack", + "targetType": "specific", + "targetIdentifier": "#apm-alerts", + "inputChannelId": "C0XXXFKLXXX", + "integrationId": float64(111), + "sentryAppId": interface{}(nil), + "dateCreated": "2022-04-15T15:06:01.087054Z", + "desc": "Send a Slack notification to #apm-alerts", + }}, + }, + }, + Projects: []string{"pump-station"}, + Owner: "pump-station:12345", + Created: mustParseTime("2022-04-15T15:06:01.05618Z"), + } + + require.Equal(t, expected, apmRule) +} + +func TestAPMRuleService_Delete(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/12345/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "DELETE", r) + }) + + client := NewClient(httpClient, nil, "") + _, err := client.APMRules.Delete("the-interstellar-jurisdiction", "pump-station", "12345") + require.NoError(t, err) +} diff --git a/sentry/sentry.go b/sentry/sentry.go index f7fe75d..bd5bfb7 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -24,6 +24,7 @@ type Client struct { ProjectKeys *ProjectKeyService ProjectPlugins *ProjectPluginService Rules *RuleService + APMRules *APMRuleService Ownership *ProjectOwnershipService } @@ -55,6 +56,7 @@ func NewClient(httpClient *http.Client, baseURL *url.URL, token string) *Client ProjectKeys: newProjectKeyService(base.New()), ProjectPlugins: newProjectPluginService(base.New()), Rules: newRuleService(base.New()), + APMRules: newAPMRuleService(base.New()), Ownership: newProjectOwnershipService(base.New()), } return c From 985b5c7716e8699cbeab4c0faf3bb0f01995b83e Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 17 May 2022 09:38:44 +0100 Subject: [PATCH 02/40] Remove CircleCI badge from README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ce16007..a67af3e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# go-sentry [![CircleCI](https://circleci.com/gh/jianyuan/go-sentry/tree/master.svg?style=svg)](https://circleci.com/gh/jianyuan/go-sentry/tree/master) [![GoDoc](https://godoc.org/github.com/jianyuan/go-sentry/sentry?status.svg)](https://godoc.org/github.com/jianyuan/go-sentry/sentry) +# go-sentry [![GoDoc](https://godoc.org/github.com/jianyuan/go-sentry/sentry?status.svg)](https://godoc.org/github.com/jianyuan/go-sentry/sentry) Go library for accessing the [Sentry Web API](https://docs.sentry.io/api/). ## Install From 2e25937cb2990b384033b890c144138e68bee278 Mon Sep 17 00:00:00 2001 From: Edward McFarlane <3036610+emcfarlane@users.noreply.github.com> Date: Thu, 26 May 2022 15:49:28 -0400 Subject: [PATCH 03/40] Custom decode errors for unhandled error types (#30) Currently terraform is failing with the following error response: ``` Error: json: cannot unmarshal string into Go value of type sentry.APIError ``` Believe this is due to a rate limit error. To support different error types this decodes the value to an interface which is typed switch for pretty printing the error response. --- sentry/errors.go | 48 +++++++++++++++++++++++++++++-------------- sentry/errors_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 sentry/errors_test.go diff --git a/sentry/errors.go b/sentry/errors.go index e084fb7..d126972 100644 --- a/sentry/errors.go +++ b/sentry/errors.go @@ -1,29 +1,47 @@ package sentry -import "fmt" +import ( + "encoding/json" + "fmt" +) -// APIError represents a Sentry API Error response -type APIError map[string]interface{} +// APIError represents a Sentry API Error response. +// Should look like: +// +// type apiError struct { +// Detail string `json:"detail"` +// } +// +type APIError struct { + f interface{} // unknown +} -// TODO: use this instead -// type apiError struct { -// Detail string `json:"detail"` -// } +func (e *APIError) UnmarshalJSON(b []byte) error { + if err := json.Unmarshal(b, &e.f); err != nil { + e.f = string(b) + } + return nil +} +func (e *APIError) MarshalJSON() ([]byte, error) { + return json.Marshal(e.f) +} func (e APIError) Error() string { - if len(e) == 1 { - if detail, ok := e["detail"].(string); ok { - return fmt.Sprintf("sentry: %s", detail) + switch v := e.f.(type) { + case map[string]interface{}: + if len(v) == 1 { + if detail, ok := v["detail"].(string); ok { + return fmt.Sprintf("sentry: %s", detail) + } } + return fmt.Sprintf("sentry: %v", v) + default: + return fmt.Sprintf("sentry: %v", v) } - - return fmt.Sprintf("sentry: %v", map[string]interface{}(e)) } // Empty returns true if empty. -func (e APIError) Empty() bool { - return len(e) == 0 -} +func (e APIError) Empty() bool { return e.f != nil } func relevantError(httpError error, apiError APIError) error { if httpError != nil { diff --git a/sentry/errors_test.go b/sentry/errors_test.go new file mode 100644 index 0000000..6dbc2d2 --- /dev/null +++ b/sentry/errors_test.go @@ -0,0 +1,48 @@ +package sentry + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAPIErrors(t *testing.T) { + tests := []struct { + name string + have string + want string + wantErr bool + }{{ + name: "detail", + have: `{"detail": "description"}`, + want: "sentry: description", + }, { + name: "detail+others", + have: `{"detail": "description", "other": "field"}`, + want: "sentry: map[detail:description other:field]", + }, { + name: "jsonstring", + have: `"jsonstring"`, + want: "sentry: jsonstring", + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // https://github.com/dghubble/sling/blob/master/sling.go#L412 + decoder := json.NewDecoder(strings.NewReader(tt.have)) + + var e APIError + err := decoder.Decode(&e) + if err != nil { + if !tt.wantErr { + t.Fatal(err) + } + return + } + got := e.Error() + if tt.want != got { + t.Errorf("want %q, got %q", tt.want, got) + } + }) + } +} From f9cf023d26c4540442633c2038a67febc7bae13e Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Thu, 26 May 2022 21:02:06 +0100 Subject: [PATCH 04/40] Update go dependencies --- go.mod | 13 +++++++++---- go.sum | 22 ++++++++++++++++------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 4a6d9b9..8bddbc3 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,16 @@ module github.com/jianyuan/go-sentry -go 1.14 +go 1.18 + +require ( + github.com/dghubble/sling v1.4.0 + github.com/stretchr/testify v1.7.1 + github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dghubble/sling v1.3.0 + github.com/google/go-querystring v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.2.2 - github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 + gopkg.in/yaml.v3 v3.0.0 // indirect ) diff --git a/go.sum b/go.sum index 180a67b..d0526df 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,22 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU= -github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY= -github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/dghubble/sling v1.4.0 h1:/n8MRosVTthvMbwlNZgLx579OGVjUOy3GNEv5BIqAWY= +github.com/dghubble/sling v1.4.0/go.mod h1:0r40aNsU9EdDUVBNhfCstAtFgutjgJGYbO1oNzkMoM8= +github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From e5aea9b700a1d94ecf129f8f8ae395b6d1205495 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Thu, 26 May 2022 21:03:52 +0100 Subject: [PATCH 05/40] Fix APIError.Empty() logic --- sentry/errors.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry/errors.go b/sentry/errors.go index d126972..9d1ab2f 100644 --- a/sentry/errors.go +++ b/sentry/errors.go @@ -22,6 +22,7 @@ func (e *APIError) UnmarshalJSON(b []byte) error { } return nil } + func (e *APIError) MarshalJSON() ([]byte, error) { return json.Marshal(e.f) } @@ -41,7 +42,9 @@ func (e APIError) Error() string { } // Empty returns true if empty. -func (e APIError) Empty() bool { return e.f != nil } +func (e APIError) Empty() bool { + return e.f == nil +} func relevantError(httpError error, apiError APIError) error { if httpError != nil { From 815b2839ec482d4246595654672e1c032848bdc0 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Thu, 26 May 2022 21:07:13 +0100 Subject: [PATCH 06/40] Matrix testing against different versions of Go --- .github/workflows/go.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 93727d6..c9281f6 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -6,13 +6,20 @@ jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + go: + - "1.15" + - "1.16" + - "1.17" + - "1.18" steps: - uses: actions/checkout@v2 - name: Set up Go uses: actions/setup-go@v2 with: - go-version: "1.15" + go-version: ${{ matrix.go }} - name: Build run: go build -v ./... From 28201974820284c18b68d492a60f4b923eb6da8e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 May 2022 21:12:15 +0100 Subject: [PATCH 07/40] chore(deps): add renovate.json (#31) Co-authored-by: Renovate Bot --- renovate.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f45d8f1 --- /dev/null +++ b/renovate.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "config:base" + ] +} From 7efd8e47589655e4e332c698f6c82f3490184a9e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 May 2022 21:12:48 +0100 Subject: [PATCH 08/40] chore(deps): update actions/checkout action to v3 (#32) Co-authored-by: Renovate Bot --- .github/workflows/go.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index c9281f6..62dd241 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -14,7 +14,7 @@ jobs: - "1.17" - "1.18" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Go uses: actions/setup-go@v2 From 4f3cfa47001c220619f023cb7482f9f7a521accc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 May 2022 21:12:56 +0100 Subject: [PATCH 09/40] chore(deps): update actions/setup-go action to v3 (#33) Co-authored-by: Renovate Bot --- .github/workflows/go.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 62dd241..1af295c 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v3 with: go-version: ${{ matrix.go }} From 1413a59ecc68d8ed5364c31e7c05530b8ab8d24f Mon Sep 17 00:00:00 2001 From: Cameron Hall Date: Sat, 4 Jun 2022 22:37:41 +1000 Subject: [PATCH 10/40] feat: added member CRUD (#35) * feat: added member CRUD * fix: added missing teams attribute * fix: fixed issue with endpoint URIs not ending with / --- sentry/organization_members.go | 47 ++++++ sentry/organization_members_test.go | 249 ++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/sentry/organization_members.go b/sentry/organization_members.go index 048fcea..8858eaa 100644 --- a/sentry/organization_members.go +++ b/sentry/organization_members.go @@ -22,6 +22,7 @@ type OrganizationMember struct { DateCreated time.Time `json:"dateCreated"` InviteStatus string `json:"inviteStatus"` InviterName *string `json:"inviterName"` + Teams []string `json:"teams"` } // OrganizationMemberService provides methods for accessing Sentry membership API endpoints. @@ -47,3 +48,49 @@ func (s *OrganizationMemberService) List(organizationSlug string, params *ListOr resp, err := s.sling.New().Get("organizations/"+organizationSlug+"/members/").QueryStruct(params).Receive(members, apiError) return *members, resp, relevantError(err, *apiError) } + +const ( + RoleMember string = "member" + RoleBilling string = "billing" + RoleAdmin string = "admin" + RoleOwner string = "owner" + RoleManager string = "manager" +) + +type CreateOrganizationMemberParams struct { + Email string `json:"email"` + Role string `json:"role"` + Teams []string `json:"teams,omitempty"` +} + +func (s *OrganizationMemberService) Create(organizationSlug string, params *CreateOrganizationMemberParams) (*OrganizationMember, *http.Response, error) { + apiError := new(APIError) + organizationMember := new(OrganizationMember) + resp, err := s.sling.New().Post("organizations/"+organizationSlug+"/members/").BodyJSON(params).Receive(organizationMember, apiError) + return organizationMember, resp, relevantError(err, *apiError) +} + +func (s *OrganizationMemberService) Delete(organizationSlug string, memberId string) (*http.Response, error) { + apiError := new(APIError) + resp, err := s.sling.New().Delete("organizations/"+organizationSlug+"/members/"+memberId+"/").Receive(nil, apiError) + return resp, relevantError(err, *apiError) +} + +func (s *OrganizationMemberService) Get(organizationSlug string, memberId string) (*OrganizationMember, *http.Response, error) { + apiError := new(APIError) + organizationMember := new(OrganizationMember) + resp, err := s.sling.New().Get("organizations/"+organizationSlug+"/members/"+memberId+"/").Receive(organizationMember, apiError) + return organizationMember, resp, relevantError(err, *apiError) +} + +type UpdateOrganizationMemberParams struct { + Role string `json:"role"` + Teams []string `json:"teams,omitempty"` +} + +func (s *OrganizationMemberService) Update(organizationSlug string, memberId string, params *UpdateOrganizationMemberParams) (*OrganizationMember, *http.Response, error) { + apiError := new(APIError) + organizationMember := new(OrganizationMember) + resp, err := s.sling.New().Put("organizations/"+organizationSlug+"/members/"+memberId+"/").BodyJSON(params).Receive(organizationMember, apiError) + return organizationMember, resp, relevantError(err, *apiError) +} diff --git a/sentry/organization_members_test.go b/sentry/organization_members_test.go index c557b71..cad2208 100644 --- a/sentry/organization_members_test.go +++ b/sentry/organization_members_test.go @@ -126,3 +126,252 @@ func TestOrganizationMemberService_List(t *testing.T) { } assert.Equal(t, expected, members) } + +func TestOrganizationMemberService_Get(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/1/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "inviteStatus": "approved", + "dateCreated": "2020-01-04T00:00:00.000000Z", + "user": { + "username": "test@example.com", + "lastLogin": "2020-01-02T00:00:00.000000Z", + "isSuperuser": false, + "emails": [ + { + "is_verified": true, + "id": "1", + "email": "test@example.com" + } + ], + "isManaged": false, + "experiments": {}, + "lastActive": "2020-01-03T00:00:00.000000Z", + "isStaff": false, + "identities": [], + "id": "1", + "isActive": true, + "has2fa": false, + "name": "John Doe", + "avatarUrl": "https://secure.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=32&d=mm", + "dateJoined": "2020-01-01T00:00:00.000000Z", + "options": { + "timezone": "UTC", + "stacktraceOrder": -1, + "language": "en", + "clock24Hours": false + }, + "flags": { + "newsletter_consent_prompt": false + }, + "avatar": { + "avatarUuid": null, + "avatarType": "letter_avatar" + }, + "hasPasswordAuth": true, + "email": "test@example.com" + }, + "roleName": "Owner", + "expired": false, + "id": "1", + "inviterName": null, + "name": "John Doe", + "role": "owner", + "flags": { + "sso:linked": false, + "sso:invalid": false + }, + "teams": [], + "email": "test@example.com", + "pending": false + }`) + }) + + client := NewClient(httpClient, nil, "") + members, _, err := client.OrganizationMembers.Get("the-interstellar-jurisdiction", "1") + assert.NoError(t, err) + expected := OrganizationMember{ + ID: "1", + Email: "test@example.com", + Name: "John Doe", + User: User{ + ID: "1", + Name: "John Doe", + Username: "test@example.com", + Email: "test@example.com", + AvatarURL: "https://secure.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=32&d=mm", + IsActive: true, + HasPasswordAuth: true, + IsManaged: false, + DateJoined: mustParseTime("2020-01-01T00:00:00.000000Z"), + LastLogin: mustParseTime("2020-01-02T00:00:00.000000Z"), + Has2FA: false, + LastActive: mustParseTime("2020-01-03T00:00:00.000000Z"), + IsSuperuser: false, + IsStaff: false, + Avatar: UserAvatar{ + AvatarType: "letter_avatar", + AvatarUUID: nil, + }, + Emails: []UserEmail{ + { + ID: "1", + Email: "test@example.com", + IsVerified: true, + }, + }, + }, + Role: "owner", + RoleName: "Owner", + Pending: false, + Expired: false, + Flags: map[string]bool{ + "sso:invalid": false, + "sso:linked": false, + }, + Teams: []string{}, + DateCreated: mustParseTime("2020-01-04T00:00:00.000000Z"), + InviteStatus: "approved", + InviterName: nil, + } + assert.Equal(t, &expected, members) +} + +func TestOrganizationMemberService_Delete(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/1/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "DELETE", r) + w.WriteHeader(http.StatusNoContent) + }) + + client := NewClient(httpClient, nil, "") + resp, err := client.OrganizationMembers.Delete("the-interstellar-jurisdiction", "1") + assert.NoError(t, err) + assert.Equal(t, int64(0), resp.ContentLength) +} + +func TestOrganizationMemberService_Create(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "1", + "email": "test@example.com", + "name": "test@example.com", + "user": null, + "role": "member", + "roleName": "Member", + "pending": true, + "expired": false, + "flags": { + "sso:linked": false, + "sso:invalid": false, + "member-limit:restricted": false + }, + "teams": [], + "dateCreated": "2020-01-01T00:00:00.000000Z", + "inviteStatus": "approved", + "inviterName": "John Doe" + }`) + }) + + client := NewClient(httpClient, nil, "") + createOrganizationMemberParams := CreateOrganizationMemberParams{ + Email: "test@example.com", + Role: RoleMember, + } + member, _, err := client.OrganizationMembers.Create("the-interstellar-jurisdiction", &createOrganizationMemberParams) + assert.NoError(t, err) + + inviterName := "John Doe" + expected := OrganizationMember{ + ID: "1", + Email: "test@example.com", + Name: "test@example.com", + User: User{}, + Role: "member", + RoleName: "Member", + Pending: true, + Expired: false, + Flags: map[string]bool{ + "sso:linked": false, + "sso:invalid": false, + "member-limit:restricted": false, + }, + Teams: []string{}, + DateCreated: mustParseTime("2020-01-01T00:00:00.000000Z"), + InviteStatus: "approved", + InviterName: &inviterName, + } + + assert.Equal(t, &expected, member) +} + +func TestOrganizationMemberService_Update(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/1/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "PUT", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "1", + "email": "test@example.com", + "name": "test@example.com", + "user": null, + "role": "manager", + "roleName": "Manager", + "pending": true, + "expired": false, + "flags": { + "sso:linked": false, + "sso:invalid": false, + "member-limit:restricted": false + }, + "teams": [], + "dateCreated": "2020-01-01T00:00:00.000000Z", + "inviteStatus": "approved", + "inviterName": "John Doe" + }`) + }) + + client := NewClient(httpClient, nil, "") + updateOrganizationMemberParams := UpdateOrganizationMemberParams{ + Role: RoleMember, + } + member, _, err := client.OrganizationMembers.Update("the-interstellar-jurisdiction", "1", &updateOrganizationMemberParams) + assert.NoError(t, err) + + inviterName := "John Doe" + expected := OrganizationMember{ + ID: "1", + Email: "test@example.com", + Name: "test@example.com", + User: User{}, + Role: "manager", + RoleName: "Manager", + Pending: true, + Expired: false, + Flags: map[string]bool{ + "sso:linked": false, + "sso:invalid": false, + "member-limit:restricted": false, + }, + Teams: []string{}, + DateCreated: mustParseTime("2020-01-01T00:00:00.000000Z"), + InviteStatus: "approved", + InviterName: &inviterName, + } + + assert.Equal(t, &expected, member) +} From 2abb998b59c826631d144a7f40a872fc96859636 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Sun, 5 Jun 2022 17:34:14 +0100 Subject: [PATCH 11/40] Rename APM Rules to Alert Rules (#38) --- sentry/{apm_rules.go => alert_rules.go} | 46 +++++++-------- ...{apm_rules_test.go => alert_rules_test.go} | 58 +++++++++---------- sentry/sentry.go | 4 +- 3 files changed, 54 insertions(+), 54 deletions(-) rename sentry/{apm_rules.go => alert_rules.go} (55%) rename sentry/{apm_rules_test.go => alert_rules_test.go} (86%) diff --git a/sentry/apm_rules.go b/sentry/alert_rules.go similarity index 55% rename from sentry/apm_rules.go rename to sentry/alert_rules.go index 94db9da..6d70aa3 100644 --- a/sentry/apm_rules.go +++ b/sentry/alert_rules.go @@ -7,11 +7,11 @@ import ( "github.com/dghubble/sling" ) -type APMRuleService struct { +type AlertRuleService struct { sling *sling.Sling } -type APMRule struct { +type AlertRule struct { ID string `json:"id"` Name string `json:"name"` Environment *string `json:"environment,omitempty"` @@ -29,21 +29,21 @@ type APMRule struct { type Trigger map[string]interface{} -func newAPMRuleService(sling *sling.Sling) *APMRuleService { - return &APMRuleService{ +func newAlertRuleService(sling *sling.Sling) *AlertRuleService { + return &AlertRuleService{ sling: sling, } } -// List APM rules configured for a project -func (s *APMRuleService) List(organizationSlug string, projectSlug string) ([]APMRule, *http.Response, error) { - apmRules := new([]APMRule) +// List Alert Rules configured for a project +func (s *AlertRuleService) List(organizationSlug string, projectSlug string) ([]AlertRule, *http.Response, error) { + alertRules := new([]AlertRule) apiError := new(APIError) - resp, err := s.sling.New().Get("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/").Receive(apmRules, apiError) - return *apmRules, resp, relevantError(err, *apiError) + resp, err := s.sling.New().Get("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/").Receive(alertRules, apiError) + return *alertRules, resp, relevantError(err, *apiError) } -type CreateAPMRuleParams struct { +type CreateAlertRuleParams struct { Name string `json:"name"` Environment *string `json:"environment,omitempty"` DataSet string `json:"dataset"` @@ -57,25 +57,25 @@ type CreateAPMRuleParams struct { Owner string `json:"owner"` } -// Create a new APM rule bound to a project. -func (s *APMRuleService) Create(organizationSlug string, projectSlug string, params *CreateAPMRuleParams) (*APMRule, *http.Response, error) { - apmRule := new(APMRule) +// Create a new Alert Rule bound to a project. +func (s *AlertRuleService) Create(organizationSlug string, projectSlug string, params *CreateAlertRuleParams) (*AlertRule, *http.Response, error) { + alertRule := new(AlertRule) apiError := new(APIError) - resp, err := s.sling.New().Post("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/").BodyJSON(params).Receive(apmRule, apiError) - return apmRule, resp, relevantError(err, *apiError) + resp, err := s.sling.New().Post("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/").BodyJSON(params).Receive(alertRule, apiError) + return alertRule, resp, relevantError(err, *apiError) } -// Update a APM rule. -func (s *APMRuleService) Update(organizationSlug string, projectSlug string, apmRuleID string, params *APMRule) (*APMRule, *http.Response, error) { - apmRule := new(APMRule) +// Update an Alert Rule. +func (s *AlertRuleService) Update(organizationSlug string, projectSlug string, alertRuleID string, params *AlertRule) (*AlertRule, *http.Response, error) { + alertRule := new(AlertRule) apiError := new(APIError) - resp, err := s.sling.New().Put("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/"+apmRuleID+"/").BodyJSON(params).Receive(apmRule, apiError) - return apmRule, resp, relevantError(err, *apiError) + resp, err := s.sling.New().Put("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/"+alertRuleID+"/").BodyJSON(params).Receive(alertRule, apiError) + return alertRule, resp, relevantError(err, *apiError) } -// Delete a APM rule. -func (s *APMRuleService) Delete(organizationSlug string, projectSlug string, apmRuleID string) (*http.Response, error) { +// Delete an Alert Rule. +func (s *AlertRuleService) Delete(organizationSlug string, projectSlug string, alertRuleID string) (*http.Response, error) { apiError := new(APIError) - resp, err := s.sling.New().Delete("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/"+apmRuleID+"/").Receive(nil, apiError) + resp, err := s.sling.New().Delete("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/"+alertRuleID+"/").Receive(nil, apiError) return resp, relevantError(err, *apiError) } diff --git a/sentry/apm_rules_test.go b/sentry/alert_rules_test.go similarity index 86% rename from sentry/apm_rules_test.go rename to sentry/alert_rules_test.go index 1c38e68..dccf133 100644 --- a/sentry/apm_rules_test.go +++ b/sentry/alert_rules_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestAPMRuleService_List(t *testing.T) { +func TestAlertRuleService_List(t *testing.T) { httpClient, mux, server := testServer() defer server.Close() @@ -43,12 +43,12 @@ func TestAPMRuleService_List(t *testing.T) { "alertRuleTriggerId": "12345", "type": "slack", "targetType": "specific", - "targetIdentifier": "#apm-alerts", + "targetIdentifier": "#alert-rule-alerts", "inputChannelId": "C038NF00X4F", "integrationId": 123, "sentryAppId": null, "dateCreated": "2022-04-07T16:46:49.154638Z", - "desc": "Send a Slack notification to #apm-alerts" + "desc": "Send a Slack notification to #alert-rule-alerts" } ] } @@ -63,11 +63,11 @@ func TestAPMRuleService_List(t *testing.T) { }) client := NewClient(httpClient, nil, "") - apmRules, _, err := client.APMRules.List("the-interstellar-jurisdiction", "pump-station") + alertRules, _, err := client.AlertRules.List("the-interstellar-jurisdiction", "pump-station") require.NoError(t, err) environment := "production" - expected := []APMRule{ + expected := []AlertRule{ { ID: "12345", Name: "pump-station-alert", @@ -92,12 +92,12 @@ func TestAPMRuleService_List(t *testing.T) { "alertRuleTriggerId": "12345", "type": "slack", "targetType": "specific", - "targetIdentifier": "#apm-alerts", + "targetIdentifier": "#alert-rule-alerts", "inputChannelId": "C038NF00X4F", "integrationId": float64(123), "sentryAppId": interface{}(nil), "dateCreated": "2022-04-07T16:46:49.154638Z", - "desc": "Send a Slack notification to #apm-alerts", + "desc": "Send a Slack notification to #alert-rule-alerts", }, }, }, @@ -107,10 +107,10 @@ func TestAPMRuleService_List(t *testing.T) { Created: mustParseTime("2022-04-07T16:46:48.569571Z"), }, } - require.Equal(t, expected, apmRules) + require.Equal(t, expected, alertRules) } -func TestAPMRuleService_Create(t *testing.T) { +func TestAlertRuleService_Create(t *testing.T) { httpClient, mux, server := testServer() defer server.Close() @@ -134,12 +134,12 @@ func TestAPMRuleService_Create(t *testing.T) { { "alertRuleTriggerId": "56789", "dateCreated": "2022-04-15T15:06:01.087054Z", - "desc": "Send a Slack notification to #apm-alerts", + "desc": "Send a Slack notification to #alert-rule-alerts", "id": "12389", "inputChannelId": "C0XXXFKLXXX", "integrationId": 111, "sentryAppId": null, - "targetIdentifier": "#apm-alerts", + "targetIdentifier": "#alert-rule-alerts", "targetType": "specific", "type": "slack" } @@ -164,7 +164,7 @@ func TestAPMRuleService_Create(t *testing.T) { client := NewClient(httpClient, nil, "") environment := "production" - params := CreateAPMRuleParams{ + params := CreateAlertRuleParams{ Name: "pump-station-alert", Environment: &environment, DataSet: "transactions", @@ -177,7 +177,7 @@ func TestAPMRuleService_Create(t *testing.T) { "actions": []interface{}{map[string]interface{}{ "type": "slack", "targetType": "specific", - "targetIdentifier": "#apm-alerts", + "targetIdentifier": "#alert-rule-alerts", "inputChannelId": "C0XXXFKLXXX", "integrationId": 111, }, @@ -190,10 +190,10 @@ func TestAPMRuleService_Create(t *testing.T) { Projects: []string{"pump-station"}, Owner: "pump-station:12345", } - apmRule, _, err := client.APMRules.Create("the-interstellar-jurisdiction", "pump-station", ¶ms) + alertRule, _, err := client.AlertRules.Create("the-interstellar-jurisdiction", "pump-station", ¶ms) require.NoError(t, err) - expected := &APMRule{ + expected := &AlertRule{ ID: "12345", Name: "pump-station-alert", Environment: &environment, @@ -217,12 +217,12 @@ func TestAPMRuleService_Create(t *testing.T) { "alertRuleTriggerId": "56789", "type": "slack", "targetType": "specific", - "targetIdentifier": "#apm-alerts", + "targetIdentifier": "#alert-rule-alerts", "inputChannelId": "C0XXXFKLXXX", "integrationId": float64(111), "sentryAppId": interface{}(nil), "dateCreated": "2022-04-15T15:06:01.087054Z", - "desc": "Send a Slack notification to #apm-alerts", + "desc": "Send a Slack notification to #alert-rule-alerts", }, }, }, @@ -232,15 +232,15 @@ func TestAPMRuleService_Create(t *testing.T) { Created: mustParseTime("2022-04-15T15:06:01.05618Z"), } - require.Equal(t, expected, apmRule) + require.Equal(t, expected, alertRule) } -func TestAPMRuleService_Update(t *testing.T) { +func TestAlertRuleService_Update(t *testing.T) { httpClient, mux, server := testServer() defer server.Close() environment := "production" - params := &APMRule{ + params := &AlertRule{ ID: "12345", Name: "pump-station-alert", Environment: &environment, @@ -312,12 +312,12 @@ func TestAPMRuleService_Update(t *testing.T) { "alertRuleTriggerId": "56789", "type": "slack", "targetType": "specific", - "targetIdentifier": "#apm-alerts", + "targetIdentifier": "#alert-rule-alerts", "inputChannelId": "C0XXXFKLXXX", "integrationId": 111, "sentryAppId": null, "dateCreated": "2022-04-15T15:06:01.087054Z", - "desc": "Send a Slack notification to #apm-alerts" + "desc": "Send a Slack notification to #alert-rule-alerts" } ], "alertRuleId": "12345", @@ -339,10 +339,10 @@ func TestAPMRuleService_Update(t *testing.T) { }) client := NewClient(httpClient, nil, "") - apmRule, _, err := client.APMRules.Update("the-interstellar-jurisdiction", "pump-station", "12345", params) + alertRule, _, err := client.AlertRules.Update("the-interstellar-jurisdiction", "pump-station", "12345", params) assert.NoError(t, err) - expected := &APMRule{ + expected := &AlertRule{ ID: "12345", Name: "pump-station-alert", Environment: &environment, @@ -366,12 +366,12 @@ func TestAPMRuleService_Update(t *testing.T) { "alertRuleTriggerId": "56789", "type": "slack", "targetType": "specific", - "targetIdentifier": "#apm-alerts", + "targetIdentifier": "#alert-rule-alerts", "inputChannelId": "C0XXXFKLXXX", "integrationId": float64(111), "sentryAppId": interface{}(nil), "dateCreated": "2022-04-15T15:06:01.087054Z", - "desc": "Send a Slack notification to #apm-alerts", + "desc": "Send a Slack notification to #alert-rule-alerts", }}, }, }, @@ -380,10 +380,10 @@ func TestAPMRuleService_Update(t *testing.T) { Created: mustParseTime("2022-04-15T15:06:01.05618Z"), } - require.Equal(t, expected, apmRule) + require.Equal(t, expected, alertRule) } -func TestAPMRuleService_Delete(t *testing.T) { +func TestAlertRuleService_Delete(t *testing.T) { httpClient, mux, server := testServer() defer server.Close() @@ -392,6 +392,6 @@ func TestAPMRuleService_Delete(t *testing.T) { }) client := NewClient(httpClient, nil, "") - _, err := client.APMRules.Delete("the-interstellar-jurisdiction", "pump-station", "12345") + _, err := client.AlertRules.Delete("the-interstellar-jurisdiction", "pump-station", "12345") require.NoError(t, err) } diff --git a/sentry/sentry.go b/sentry/sentry.go index bd5bfb7..89ebe7a 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -24,7 +24,7 @@ type Client struct { ProjectKeys *ProjectKeyService ProjectPlugins *ProjectPluginService Rules *RuleService - APMRules *APMRuleService + AlertRules *AlertRuleService Ownership *ProjectOwnershipService } @@ -56,7 +56,7 @@ func NewClient(httpClient *http.Client, baseURL *url.URL, token string) *Client ProjectKeys: newProjectKeyService(base.New()), ProjectPlugins: newProjectPluginService(base.New()), Rules: newRuleService(base.New()), - APMRules: newAPMRuleService(base.New()), + AlertRules: newAlertRuleService(base.New()), Ownership: newProjectOwnershipService(base.New()), } return c From 0852e2b949bf3bb392d82ede13e15e13c950531b Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Sun, 5 Jun 2022 18:29:50 +0100 Subject: [PATCH 12/40] Normalize base URL (#40) --- sentry/sentry.go | 11 +++++++++-- sentry/sentry_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/sentry/sentry.go b/sentry/sentry.go index 89ebe7a..ef6dc9b 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -3,7 +3,7 @@ package sentry import ( "net/http" "net/url" - "path" + "strings" "github.com/dghubble/sling" ) @@ -31,6 +31,7 @@ type Client struct { // NewClient returns a new Sentry API client. // If a nil httpClient is given, the http.DefaultClient will be used. // If a nil baseURL is given, the DefaultBaseURL will be used. +// If the baseURL does not have the suffix "/api/", it will be added automatically. func NewClient(httpClient *http.Client, baseURL *url.URL, token string) *Client { if httpClient == nil { httpClient = http.DefaultClient @@ -39,7 +40,13 @@ func NewClient(httpClient *http.Client, baseURL *url.URL, token string) *Client if baseURL == nil { baseURL, _ = url.Parse(DefaultBaseURL) } - baseURL.Path = path.Join(baseURL.Path, APIVersion) + "/" + if !strings.HasSuffix(baseURL.Path, "/") { + baseURL.Path += "/" + } + if !strings.HasSuffix(baseURL.Path, "/api/") { + baseURL.Path += "api/" + } + baseURL.Path += APIVersion + "/" base := sling.New().Base(baseURL.String()).Client(httpClient) diff --git a/sentry/sentry_test.go b/sentry/sentry_test.go index e7faca5..d323f67 100644 --- a/sentry/sentry_test.go +++ b/sentry/sentry_test.go @@ -89,3 +89,32 @@ func mustParseTime(value string) time.Time { } return t } + +func TestNewClient(t *testing.T) { + c := NewClient(nil, nil, "") + req, _ := c.sling.New().Request() + + assert.Equal(t, "https://sentry.io/api/0/", req.URL.String()) +} + +func TestNewClient_withBaseURL(t *testing.T) { + testCases := []struct { + baseURL string + }{ + {"https://example.com"}, + {"https://example.com/"}, + {"https://example.com/api"}, + {"https://example.com/api/"}, + } + for _, tc := range testCases { + t.Run(tc.baseURL, func(t *testing.T) { + baseURL, _ := url.Parse(tc.baseURL) + + c := NewClient(nil, baseURL, "") + req, _ := c.sling.New().Request() + + assert.Equal(t, "https://example.com/api/0/", req.URL.String()) + }) + } + +} From f8d3df9c5fc7444b8940fae6001f33279b799f9a Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Sun, 5 Jun 2022 20:18:29 +0100 Subject: [PATCH 13/40] Update Struct (#42) --- sentry/organizations.go | 64 +++-- sentry/organizations_test.go | 460 ++++++++++++++--------------------- 2 files changed, 230 insertions(+), 294 deletions(-) diff --git a/sentry/organizations.go b/sentry/organizations.go index a01f8b2..298aaa3 100644 --- a/sentry/organizations.go +++ b/sentry/organizations.go @@ -28,40 +28,62 @@ type OrganizationAvailableRole struct { } // Organization represents a Sentry organization. -// Based on https://github.com/getsentry/sentry/blob/9.0.0/src/sentry/api/serializers/models/organization.py +// Based on https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/organization.py#L110-L120 type Organization struct { - ID string `json:"id"` - Slug string `json:"slug"` - Status OrganizationStatus `json:"status"` - Name string `json:"name"` - DateCreated time.Time `json:"dateCreated"` - IsEarlyAdopter bool `json:"isEarlyAdopter"` - Avatar Avatar `json:"avatar"` - - Quota OrganizationQuota `json:"quota"` + // Basic + ID string `json:"id"` + Slug string `json:"slug"` + Status OrganizationStatus `json:"status"` + Name string `json:"name"` + DateCreated time.Time `json:"dateCreated"` + IsEarlyAdopter bool `json:"isEarlyAdopter"` + Require2FA bool `json:"require2FA"` + RequireEmailVerification bool `json:"requireEmailVerification"` + Avatar Avatar `json:"avatar"` + Features []string `json:"features"` +} +// DetailedOrganization represents detailed information about a Sentry organization. +// Based on https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/organization.py#L263-L288 +type DetailedOrganization struct { + // Basic + ID string `json:"id"` + Slug string `json:"slug"` + Status OrganizationStatus `json:"status"` + Name string `json:"name"` + DateCreated time.Time `json:"dateCreated"` + IsEarlyAdopter bool `json:"isEarlyAdopter"` + Require2FA bool `json:"require2FA"` + RequireEmailVerification bool `json:"requireEmailVerification"` + Avatar Avatar `json:"avatar"` + Features []string `json:"features"` + + // Detailed + // TODO: experiments + Quota OrganizationQuota `json:"quota"` IsDefault bool `json:"isDefault"` DefaultRole string `json:"defaultRole"` AvailableRoles []OrganizationAvailableRole `json:"availableRoles"` OpenMembership bool `json:"openMembership"` - Require2FA bool `json:"require2FA"` AllowSharedIssues bool `json:"allowSharedIssues"` EnhancedPrivacy bool `json:"enhancedPrivacy"` DataScrubber bool `json:"dataScrubber"` DataScrubberDefaults bool `json:"dataScrubberDefaults"` SensitiveFields []string `json:"sensitiveFields"` SafeFields []string `json:"safeFields"` + StoreCrashReports int `json:"storeCrashReports"` + AttachmentsRole string `json:"attachmentsRole"` + DebugFilesRole string `json:"debugFilesRole"` + EventsMemberAdmin bool `json:"eventsMemberAdmin"` + AlertsMemberWrite bool `json:"alertsMemberWrite"` ScrubIPAddresses bool `json:"scrubIPAddresses"` - + ScrapeJavaScript bool `json:"scrapeJavaScript"` + AllowJoinRequests bool `json:"allowJoinRequests"` + RelayPiiConfig *string `json:"relayPiiConfig"` + // TODO: trustedRelays Access []string `json:"access"` - Features []string `json:"features"` + Role string `json:"role"` PendingAccessRequests int `json:"pendingAccessRequests"` - - AccountRateLimit int `json:"accountRateLimit"` - ProjectRateLimit int `json:"projectRateLimit"` - - Teams []Team `json:"teams"` - Projects []ProjectSummary `json:"projects"` // TODO: onboardingTasks } @@ -100,8 +122,8 @@ type CreateOrganizationParams struct { // Get a Sentry organization. // https://docs.sentry.io/api/organizations/get-organization-details/ -func (s *OrganizationService) Get(slug string) (*Organization, *http.Response, error) { - org := new(Organization) +func (s *OrganizationService) Get(slug string) (*DetailedOrganization, *http.Response, error) { + org := new(DetailedOrganization) apiError := new(APIError) resp, err := s.sling.New().Get(slug+"/").Receive(org, apiError) return org, resp, relevantError(err, *apiError) diff --git a/sentry/organizations_test.go b/sentry/organizations_test.go index 15a31a3..0609e60 100644 --- a/sentry/organizations_test.go +++ b/sentry/organizations_test.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http" "testing" - "time" "github.com/stretchr/testify/assert" ) @@ -61,9 +60,77 @@ func TestOrganizationService_Get(t *testing.T) { assertMethod(t, "GET", r) w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{ - "access": [], - "allowSharedIssues": true, - "availableRoles": [{ + "id": "2", + "slug": "the-interstellar-jurisdiction", + "status": { + "id": "active", + "name": "active" + }, + "name": "The Interstellar Jurisdiction", + "dateCreated": "2022-06-05T17:31:31.170029Z", + "isEarlyAdopter": false, + "require2FA": false, + "requireEmailVerification": false, + "avatar": { + "avatarType": "letter_avatar", + "avatarUuid": null + }, + "features": [ + "release-health-return-metrics", + "slack-overage-notifications", + "symbol-sources", + "discover-frontend-use-events-endpoint", + "dashboard-grid-layout", + "performance-view", + "open-membership", + "integrations-stacktrace-link", + "performance-frontend-use-events-endpoint", + "performance-dry-run-mep", + "auto-start-free-trial", + "event-attachments", + "new-widget-builder-experience-design", + "metrics-extraction", + "shared-issues", + "performance-suspect-spans-view", + "dashboards-template", + "advanced-search", + "performance-autogroup-sibling-spans", + "widget-library", + "performance-span-histogram-view", + "performance-ops-breakdown", + "intl-sales-tax", + "crash-rate-alerts", + "widget-viewer-modal", + "invite-members-rate-limits", + "onboarding", + "images-loaded-v2", + "new-weekly-report", + "unified-span-view", + "org-subdomains", + "ondemand-budgets", + "alert-crash-free-metrics", + "custom-event-title", + "mobile-app", + "minute-resolution-sessions" + ], + "experiments": { + "TargetedOnboardingIntegrationSelectExperiment": 0, + "TargetedOnboardingMobileRedirectExperiment": "hide" + }, + "quota": { + "maxRate": null, + "maxRateInterval": 60, + "accountLimit": 0, + "projectLimit": 100 + }, + "isDefault": false, + "defaultRole": "member", + "availableRoles": [ + { + "id": "billing", + "name": "Billing" + }, + { "id": "member", "name": "Member" }, @@ -80,182 +147,120 @@ func TestOrganizationService_Get(t *testing.T) { "name": "Owner" } ], - "avatar": { - "avatarType": "letter_avatar", - "avatarUuid": null - }, + "openMembership": true, + "allowSharedIssues": true, + "enhancedPrivacy": false, "dataScrubber": false, "dataScrubberDefaults": false, - "dateCreated": "2018-09-20T15:47:52.908Z", - "defaultRole": "member", - "enhancedPrivacy": false, - "experiments": {}, - "features": [ - "sso", - "api-keys", - "github-apps", - "repos", - "new-issue-ui", - "github-enterprise", - "bitbucket-integration", - "jira-integration", - "vsts-integration", - "suggested-commits", - "new-teams", - "open-membership", - "shared-issues" - ], - "id": "2", - "isDefault": false, - "isEarlyAdopter": false, - "name": "The Interstellar Jurisdiction", - "onboardingTasks": [], - "openMembership": true, - "pendingAccessRequests": 0, - "projects": [{ - "dateCreated": "2018-09-20T15:47:56.723Z", - "firstEvent": null, - "hasAccess": true, - "id": "3", - "isBookmarked": false, - "isMember": false, - "latestDeploys": null, - "name": "Prime Mover", - "platform": null, - "platforms": [], - "slug": "prime-mover", - "team": { - "id": "2", - "name": "Powerful Abolitionist", - "slug": "powerful-abolitionist" - }, - "teams": [{ - "id": "2", - "name": "Powerful Abolitionist", - "slug": "powerful-abolitionist" - }] - }, - { - "dateCreated": "2018-09-20T15:47:52.926Z", - "firstEvent": null, - "hasAccess": true, - "id": "2", - "isBookmarked": false, - "isMember": false, - "latestDeploys": null, - "name": "Pump Station", - "platform": null, - "platforms": [], - "slug": "pump-station", - "team": { - "id": "2", - "name": "Powerful Abolitionist", - "slug": "powerful-abolitionist" - }, - "teams": [{ - "id": "2", - "name": "Powerful Abolitionist", - "slug": "powerful-abolitionist" - }] - }, - { - "dateCreated": "2018-09-20T15:48:07.592Z", - "firstEvent": null, - "hasAccess": true, - "id": "4", - "isBookmarked": false, - "isMember": false, - "latestDeploys": null, - "name": "The Spoiled Yoghurt", - "platform": null, - "platforms": [], - "slug": "the-spoiled-yoghurt", - "team": { - "id": "2", - "name": "Powerful Abolitionist", - "slug": "powerful-abolitionist" - }, - "teams": [{ - "id": "2", - "name": "Powerful Abolitionist", - "slug": "powerful-abolitionist" - }] - } - ], - "quota": { - "accountLimit": 0, - "maxRate": 0, - "maxRateInterval": 60, - "projectLimit": 100 - }, - "require2FA": false, + "sensitiveFields": [], "safeFields": [], - "scrapeJavaScript": true, + "storeCrashReports": 0, + "attachmentsRole": "member", + "debugFilesRole": "admin", + "eventsMemberAdmin": true, + "alertsMemberWrite": true, "scrubIPAddresses": false, - "sensitiveFields": [], - "slug": "the-interstellar-jurisdiction", - "status": { - "id": "active", - "name": "active" - }, - "storeCrashReports": false, - "teams": [{ - "avatar": { - "avatarType": "letter_avatar", - "avatarUuid": null - }, - "dateCreated": "2018-09-20T15:48:07.803Z", - "hasAccess": true, - "id": "3", - "isMember": false, - "isPending": false, - "name": "Ancient Gabelers", - "slug": "ancient-gabelers" - }, - { - "avatar": { - "avatarType": "letter_avatar", - "avatarUuid": null - }, - "dateCreated": "2018-09-20T15:47:52.922Z", - "hasAccess": true, - "id": "2", - "isMember": false, - "isPending": false, - "name": "Powerful Abolitionist", - "slug": "powerful-abolitionist" - } - ] + "scrapeJavaScript": true, + "allowJoinRequests": true, + "relayPiiConfig": null, + "trustedRelays": [], + "access": [ + "org:write", + "team:admin", + "alerts:write", + "project:releases", + "member:admin", + "org:admin", + "project:read", + "project:write", + "alerts:read", + "org:integrations", + "event:admin", + "project:admin", + "member:write", + "member:read", + "org:billing", + "team:write", + "event:write", + "event:read", + "org:read", + "team:read" + ], + "role": "owner", + "pendingAccessRequests": 0, + "onboardingTasks": [] }`) }) client := NewClient(httpClient, nil, "") organization, _, err := client.Organizations.Get("the-interstellar-jurisdiction") assert.NoError(t, err) - expected := &Organization{ + expected := &DetailedOrganization{ ID: "2", Slug: "the-interstellar-jurisdiction", Status: OrganizationStatus{ ID: "active", Name: "active", }, - Name: "The Interstellar Jurisdiction", - DateCreated: mustParseTime("2018-09-20T15:47:52.908Z"), - IsEarlyAdopter: false, + Name: "The Interstellar Jurisdiction", + DateCreated: mustParseTime("2022-06-05T17:31:31.170029Z"), + IsEarlyAdopter: false, + Require2FA: false, + RequireEmailVerification: false, Avatar: Avatar{ Type: "letter_avatar", }, - + Features: []string{ + "release-health-return-metrics", + "slack-overage-notifications", + "symbol-sources", + "discover-frontend-use-events-endpoint", + "dashboard-grid-layout", + "performance-view", + "open-membership", + "integrations-stacktrace-link", + "performance-frontend-use-events-endpoint", + "performance-dry-run-mep", + "auto-start-free-trial", + "event-attachments", + "new-widget-builder-experience-design", + "metrics-extraction", + "shared-issues", + "performance-suspect-spans-view", + "dashboards-template", + "advanced-search", + "performance-autogroup-sibling-spans", + "widget-library", + "performance-span-histogram-view", + "performance-ops-breakdown", + "intl-sales-tax", + "crash-rate-alerts", + "widget-viewer-modal", + "invite-members-rate-limits", + "onboarding", + "images-loaded-v2", + "new-weekly-report", + "unified-span-view", + "org-subdomains", + "ondemand-budgets", + "alert-crash-free-metrics", + "custom-event-title", + "mobile-app", + "minute-resolution-sessions", + }, Quota: OrganizationQuota{ MaxRate: 0, MaxRateInterval: 60, AccountLimit: 0, ProjectLimit: 100, }, - IsDefault: false, DefaultRole: "member", AvailableRoles: []OrganizationAvailableRole{ + { + ID: "billing", + Name: "Billing", + }, { ID: "member", Name: "Member", @@ -274,136 +279,45 @@ func TestOrganizationService_Get(t *testing.T) { }, }, OpenMembership: true, - Require2FA: false, AllowSharedIssues: true, EnhancedPrivacy: false, DataScrubber: false, DataScrubberDefaults: false, SensitiveFields: []string{}, SafeFields: []string{}, + StoreCrashReports: 0, + AttachmentsRole: "member", + DebugFilesRole: "admin", + EventsMemberAdmin: true, + AlertsMemberWrite: true, ScrubIPAddresses: false, - - Access: []string{}, - Features: []string{ - "sso", - "api-keys", - "github-apps", - "repos", - "new-issue-ui", - "github-enterprise", - "bitbucket-integration", - "jira-integration", - "vsts-integration", - "suggested-commits", - "new-teams", - "open-membership", - "shared-issues", + ScrapeJavaScript: true, + AllowJoinRequests: true, + RelayPiiConfig: nil, + Access: []string{ + "org:write", + "team:admin", + "alerts:write", + "project:releases", + "member:admin", + "org:admin", + "project:read", + "project:write", + "alerts:read", + "org:integrations", + "event:admin", + "project:admin", + "member:write", + "member:read", + "org:billing", + "team:write", + "event:write", + "event:read", + "org:read", + "team:read", }, + Role: "owner", PendingAccessRequests: 0, - - AccountRateLimit: 0, - ProjectRateLimit: 0, - - Teams: []Team{ - { - ID: "3", - Slug: "ancient-gabelers", - Name: "Ancient Gabelers", - DateCreated: mustParseTime("2018-09-20T15:48:07.803Z"), - IsMember: false, - HasAccess: true, - IsPending: false, - Avatar: Avatar{ - Type: "letter_avatar", - }, - }, - { - ID: "2", - Slug: "powerful-abolitionist", - Name: "Powerful Abolitionist", - DateCreated: mustParseTime("2018-09-20T15:47:52.922Z"), - IsMember: false, - HasAccess: true, - IsPending: false, - Avatar: Avatar{ - Type: "letter_avatar", - }, - }, - }, - Projects: []ProjectSummary{ - { - ID: "3", - Name: "Prime Mover", - Slug: "prime-mover", - IsBookmarked: false, - IsMember: false, - HasAccess: true, - DateCreated: mustParseTime("2018-09-20T15:47:56.723Z"), - FirstEvent: time.Time{}, - Platform: nil, - Platforms: []string{}, - Team: &ProjectSummaryTeam{ - ID: "2", - Name: "Powerful Abolitionist", - Slug: "powerful-abolitionist", - }, - Teams: []ProjectSummaryTeam{ - { - ID: "2", - Name: "Powerful Abolitionist", - Slug: "powerful-abolitionist", - }, - }, - }, - { - ID: "2", - Name: "Pump Station", - Slug: "pump-station", - IsBookmarked: false, - IsMember: false, - HasAccess: true, - DateCreated: mustParseTime("2018-09-20T15:47:52.926Z"), - FirstEvent: time.Time{}, - Platform: nil, - Platforms: []string{}, - Team: &ProjectSummaryTeam{ - ID: "2", - Name: "Powerful Abolitionist", - Slug: "powerful-abolitionist", - }, - Teams: []ProjectSummaryTeam{ - { - ID: "2", - Name: "Powerful Abolitionist", - Slug: "powerful-abolitionist", - }, - }, - }, - { - ID: "4", - Name: "The Spoiled Yoghurt", - Slug: "the-spoiled-yoghurt", - IsBookmarked: false, - IsMember: false, - HasAccess: true, - DateCreated: mustParseTime("2018-09-20T15:48:07.592Z"), - FirstEvent: time.Time{}, - Platform: nil, - Platforms: []string{}, - Team: &ProjectSummaryTeam{ - ID: "2", - Name: "Powerful Abolitionist", - Slug: "powerful-abolitionist", - }, - Teams: []ProjectSummaryTeam{ - { - ID: "2", - Name: "Powerful Abolitionist", - Slug: "powerful-abolitionist", - }, - }, - }, - }, } assert.Equal(t, expected, organization) } From 6dcf8d3563dfcb76583745d429b76fae43029c5b Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Sun, 5 Jun 2022 20:23:52 +0100 Subject: [PATCH 14/40] Organizations: Update doc links --- sentry/organizations.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sentry/organizations.go b/sentry/organizations.go index 298aaa3..e0d547f 100644 --- a/sentry/organizations.go +++ b/sentry/organizations.go @@ -105,7 +105,7 @@ type ListOrganizationParams struct { } // List organizations available to the authenticated session. -// https://docs.sentry.io/api/organizations/get-organization-index/ +// https://docs.sentry.io/api/organizations/list-your-organizations/ func (s *OrganizationService) List(params *ListOrganizationParams) ([]Organization, *http.Response, error) { organizations := new([]Organization) apiError := new(APIError) @@ -121,7 +121,7 @@ type CreateOrganizationParams struct { } // Get a Sentry organization. -// https://docs.sentry.io/api/organizations/get-organization-details/ +// https://docs.sentry.io/api/organizations/retrieve-an-organization/ func (s *OrganizationService) Get(slug string) (*DetailedOrganization, *http.Response, error) { org := new(DetailedOrganization) apiError := new(APIError) @@ -130,7 +130,6 @@ func (s *OrganizationService) Get(slug string) (*DetailedOrganization, *http.Res } // Create a new Sentry organization. -// https://docs.sentry.io/api/organizations/post-organization-index/ func (s *OrganizationService) Create(params *CreateOrganizationParams) (*Organization, *http.Response, error) { org := new(Organization) apiError := new(APIError) @@ -145,7 +144,7 @@ type UpdateOrganizationParams struct { } // Update a Sentry organization. -// https://docs.sentry.io/api/organizations/put-organization-details/ +// https://docs.sentry.io/api/organizations/update-an-organization/ func (s *OrganizationService) Update(slug string, params *UpdateOrganizationParams) (*Organization, *http.Response, error) { org := new(Organization) apiError := new(APIError) From 3b194ff9c10788d0ce8b728b338bbb31ef522ee4 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Sun, 5 Jun 2022 20:30:42 +0100 Subject: [PATCH 15/40] Update Team struct (#44) --- sentry/teams.go | 14 +++++++++----- sentry/teams_test.go | 40 ++++++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/sentry/teams.go b/sentry/teams.go index e1b8b85..c620be7 100644 --- a/sentry/teams.go +++ b/sentry/teams.go @@ -8,16 +8,20 @@ import ( ) // Team represents a Sentry team that is bound to an organization. -// https://github.com/getsentry/sentry/blob/9.0.0/src/sentry/api/serializers/models/team.py#L48 +// https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/team.py#L109-L119 type Team struct { ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` DateCreated time.Time `json:"dateCreated"` IsMember bool `json:"isMember"` + TeamRole string `json:"teamRole"` HasAccess bool `json:"hasAccess"` IsPending bool `json:"isPending"` + MemberCount int `json:"memberCount"` Avatar Avatar `json:"avatar"` + // TODO: externalTeams + // TODO: projects } // TeamService provides methods for accessing Sentry team API endpoints. @@ -42,7 +46,7 @@ func (s *TeamService) List(organizationSlug string) ([]Team, *http.Response, err } // Get details on an individual team of an organization. -// https://docs.sentry.io/api/teams/get-team-details/ +// https://docs.sentry.io/api/teams/retrieve-a-team/ func (s *TeamService) Get(organizationSlug string, slug string) (*Team, *http.Response, error) { team := new(Team) apiError := new(APIError) @@ -57,7 +61,7 @@ type CreateTeamParams struct { } // Create a new Sentry team bound to an organization. -// https://docs.sentry.io/api/teams/post-organization-teams/ +// https://docs.sentry.io/api/teams/create-a-new-team/ func (s *TeamService) Create(organizationSlug string, params *CreateTeamParams) (*Team, *http.Response, error) { team := new(Team) apiError := new(APIError) @@ -72,7 +76,7 @@ type UpdateTeamParams struct { } // Update settings for a given team. -// https://docs.sentry.io/api/teams/put-team-details/ +// https://docs.sentry.io/api/teams/update-a-team/ func (s *TeamService) Update(organizationSlug string, slug string, params *UpdateTeamParams) (*Team, *http.Response, error) { team := new(Team) apiError := new(APIError) @@ -81,7 +85,7 @@ func (s *TeamService) Update(organizationSlug string, slug string, params *Updat } // Delete a team. -// https://docs.sentry.io/api/teams/delete-team-details/ +// https://docs.sentry.io/api/teams/update-a-team/ func (s *TeamService) Delete(organizationSlug string, slug string) (*http.Response, error) { apiError := new(APIError) resp, err := s.sling.New().Delete("teams/"+organizationSlug+"/"+slug+"/").Receive(nil, apiError) diff --git a/sentry/teams_test.go b/sentry/teams_test.go index 34215de..e1df726 100644 --- a/sentry/teams_test.go +++ b/sentry/teams_test.go @@ -17,23 +17,37 @@ func TestTeamService_List(t *testing.T) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `[ { + "id": "3", "slug": "ancient-gabelers", "name": "Ancient Gabelers", - "hasAccess": true, - "isPending": false, "dateCreated": "2017-07-18T19:29:46.305Z", "isMember": false, - "id": "3", + "teamRole": "admin", + "hasAccess": true, + "isPending": false, + "memberCount": 1, + "avatar": { + "avatarType": "letter_avatar", + "avatarUuid": null + }, + "externalTeams": [], "projects": [] }, { + "id": "2", "slug": "powerful-abolitionist", "name": "Powerful Abolitionist", - "hasAccess": true, - "isPending": false, "dateCreated": "2017-07-18T19:29:24.743Z", "isMember": false, - "id": "2", + "teamRole": "admin", + "hasAccess": true, + "isPending": false, + "memberCount": 1, + "avatar": { + "avatarType": "letter_avatar", + "avatarUuid": null + }, + "externalTeams": [], "projects": [ { "status": "active", @@ -112,18 +126,28 @@ func TestTeamService_List(t *testing.T) { Slug: "ancient-gabelers", Name: "Ancient Gabelers", DateCreated: mustParseTime("2017-07-18T19:29:46.305Z"), + IsMember: false, + TeamRole: "admin", HasAccess: true, IsPending: false, - IsMember: false, + MemberCount: 1, + Avatar: Avatar{ + Type: "letter_avatar", + }, }, { ID: "2", Slug: "powerful-abolitionist", Name: "Powerful Abolitionist", DateCreated: mustParseTime("2017-07-18T19:29:24.743Z"), + IsMember: false, + TeamRole: "admin", HasAccess: true, IsPending: false, - IsMember: false, + MemberCount: 1, + Avatar: Avatar{ + Type: "letter_avatar", + }, }, } assert.Equal(t, expected, teams) From d55c0018d1d326a322c7c6768f4d80240644a82d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jun 2022 18:42:35 +0100 Subject: [PATCH 16/40] fix(deps): update module github.com/stretchr/testify to v1.7.2 (#45) Co-authored-by: Renovate Bot --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 8bddbc3..27b1680 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.18 require ( github.com/dghubble/sling v1.4.0 - github.com/stretchr/testify v1.7.1 + github.com/stretchr/testify v1.7.2 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 ) @@ -12,5 +12,5 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d0526df..cba8f49 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -20,3 +22,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 944f71d1614a9ada056bfc1f04bfa6876c31db74 Mon Sep 17 00:00:00 2001 From: Philippe Ballandras Date: Mon, 6 Jun 2022 15:50:22 -0400 Subject: [PATCH 17/40] Support for async rule creation in Sentry (#36) * implementation * added test for feature * test name precision --- sentry/rules.go | 34 +++++++++++ sentry/rules_test.go | 130 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/sentry/rules.go b/sentry/rules.go index b024b42..dd93619 100644 --- a/sentry/rules.go +++ b/sentry/rules.go @@ -1,6 +1,7 @@ package sentry import ( + "errors" "net/http" "time" @@ -20,6 +21,15 @@ type Rule struct { Actions []ActionType `json:"actions"` Filters []FilterType `json:"filters"` Created time.Time `json:"dateCreated"` + TaskUUID string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the rule +} + +// RuleTaskDetail represents the inline struct Sentry defines for task details +// https://github.com/getsentry/sentry/blob/7ce8f5a4bbc3429eef4b2e273148baf6525fede2/src/sentry/api/endpoints/project_rule_task_details.py#L29 +type RuleTaskDetail struct { + Status string `json:"status"` + Rule Rule `json:"rule"` + Error string `json:"error"` } // RuleService provides methods for accessing Sentry project @@ -86,9 +96,33 @@ func (s *RuleService) Create(organizationSlug string, projectSlug string, params rule := new(Rule) apiError := new(APIError) resp, err := s.sling.New().Post("projects/"+organizationSlug+"/"+projectSlug+"/rules/").BodyJSON(params).Receive(rule, apiError) + if resp.StatusCode == 202 { + // We just received a reference to an async task, we need to check another endpoint to retrieve the rule we created + return s.getRuleFromTaskDetail(organizationSlug, projectSlug, rule.TaskUUID) + } return rule, resp, relevantError(err, *apiError) } +// getRuleFromTaskDetail is called when Sentry offloads the rule creation process to an async task and sends us back the task's uuid. +// It usually doesn't happen, but when creating Slack notification rules, it seemed to be sometimes the case. During testing it +// took very long for a task to finish (10+ seconds) which is why this method can take long to return. +func (s *RuleService) getRuleFromTaskDetail(organizationSlug string, projectSlug string, taskUuid string) (*Rule, *http.Response, error) { + taskDetail := &RuleTaskDetail{} + var resp *http.Response + for i := 0; i < 5; i++ { + time.Sleep(5 * time.Second) + resp, err := s.sling.New().Get("projects/" + organizationSlug + "/" + projectSlug + "/rule-task/" + taskUuid + "/").ReceiveSuccess(taskDetail) + if taskDetail.Status == "success" { + return &taskDetail.Rule, resp, err + } else if taskDetail.Status == "failed" { + return &taskDetail.Rule, resp, errors.New(taskDetail.Error) + } else if resp.StatusCode == 404 { + return &Rule{}, resp, errors.New("couldn't find the rule creation task for uuid '" + taskUuid + "' in Sentry (HTTP 404)") + } + } + return &Rule{}, resp, errors.New("getting the status of the rule creation from Sentry took too long") +} + // Update a rule. func (s *RuleService) Update(organizationSlug string, projectSlug string, ruleID string, params *Rule) (*Rule, *http.Response, error) { rule := new(Rule) diff --git a/sentry/rules_test.go b/sentry/rules_test.go index 28ab079..3a393c0 100644 --- a/sentry/rules_test.go +++ b/sentry/rules_test.go @@ -203,6 +203,136 @@ func TestRulesService_Create(t *testing.T) { } +func TestRulesService_Create_With_Async_Task(t *testing.T) { + httpClient, mux, server := testServer() + defer server.Close() + + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/rule-task/fakeuuid/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "status": "success", + "error": null, + "rule": { + "id": "123456", + "actionMatch": "all", + "environment": "production", + "frequency": 30, + "name": "Notify errors", + "conditions": [ + { + "interval": "1h", + "name": "The issue is seen more than 10 times in 1h", + "value": 10, + "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition" + } + ], + "actions": [ + { + "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", + "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", + "tags": "environment", + "channel_id": "XX00X0X0X", + "workspace": "1234", + "channel": "#dummy-channel" + } + ], + "dateCreated": "2019-08-24T18:12:16.321Z" + } + }`) + }) + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/rules/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostJSONValue(t, map[string]interface{}{ + "actionMatch": "all", + "environment": "production", + "frequency": 30, + "name": "Notify errors", + "conditions": []map[string]interface{}{ + { + "interval": "1h", + "name": "The issue is seen more than 10 times in 1h", + "value": 10, + "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", + }, + }, + "actions": []map[string]interface{}{ + { + "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", + "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", + "tags": "environment", + "channel": "#dummy-channel", + "channel_id": "XX00X0X0X", + "workspace": "1234", + }, + }, + }, r) + + w.WriteHeader(http.StatusAccepted) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"uuid": "fakeuuid"}`) + + }) + + params := &CreateRuleParams{ + ActionMatch: "all", + Environment: "production", + Frequency: 30, + Name: "Notify errors", + Conditions: []ConditionType{ + { + "interval": "1h", + "name": "The issue is seen more than 10 times in 1h", + "value": float64(10), + "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", + }, + }, + Actions: []ActionType{ + { + "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", + "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", + "tags": "environment", + "channel_id": "XX00X0X0X", + "workspace": "1234", + "channel": "#dummy-channel", + }, + }, + } + + client := NewClient(httpClient, nil, "") + rule, _, err := client.Rules.Create("the-interstellar-jurisdiction", "pump-station", params) + require.NoError(t, err) + + environment := "production" + expected := &Rule{ + ID: "123456", + ActionMatch: "all", + Environment: &environment, + Frequency: 30, + Name: "Notify errors", + Conditions: []ConditionType{ + { + "interval": "1h", + "name": "The issue is seen more than 10 times in 1h", + "value": float64(10), + "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", + }, + }, + Actions: []ActionType{ + { + "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", + "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", + "tags": "environment", + "channel_id": "XX00X0X0X", + "channel": "#dummy-channel", + "workspace": "1234", + }, + }, + Created: mustParseTime("2019-08-24T18:12:16.321Z"), + } + require.Equal(t, expected, rule) + +} + func TestRulesService_Update(t *testing.T) { httpClient, mux, server := testServer() defer server.Close() From 4dd1dcee6a049ab6fad9fca0160d0f053e2ec39a Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 7 Jun 2022 02:05:43 +0100 Subject: [PATCH 18/40] Introduce context.Context argument to all methods (#47) * Update README.md and bump major version in go.mod * Rewrite Client struct and functions * Update Organizations * Update Teams * Update Issue Alerts * Update Organization Members * Update Projects * Update Project Keys * Update Project Plugins * Update Metric Alerts * Update Project Ownerships --- README.md | 14 +- go.mod | 2 +- sentry/alert_rules.go | 81 ---- sentry/issue_alerts.go | 172 ++++++++ .../{rules_test.go => issue_alerts_test.go} | 77 ++-- sentry/metric_alerts.go | 100 +++++ ...rt_rules_test.go => metric_alerts_test.go} | 49 +-- sentry/organization_members.go | 125 +++--- sentry/organization_members_test.go | 85 ++-- sentry/organizations.go | 109 ++++-- sentry/organizations_test.go | 76 ++-- sentry/project_keys.go | 109 +++--- sentry/project_keys_test.go | 367 ++---------------- sentry/project_ownership.go | 55 --- sentry/project_ownerships.go | 62 +++ ...hip_test.go => project_ownerships_test.go} | 21 +- sentry/project_plugins.go | 89 +++-- sentry/projects.go | 134 ++++--- sentry/projects_test.go | 73 ++-- sentry/rules.go | 139 ------- sentry/sentry.go | 257 ++++++++++-- sentry/sentry_test.go | 33 +- sentry/teams.go | 95 +++-- sentry/teams_test.go | 53 +-- sentry/users.go | 7 +- sentry/users_test.go | 6 +- 26 files changed, 1224 insertions(+), 1166 deletions(-) delete mode 100644 sentry/alert_rules.go create mode 100644 sentry/issue_alerts.go rename sentry/{rules_test.go => issue_alerts_test.go} (89%) create mode 100644 sentry/metric_alerts.go rename sentry/{alert_rules_test.go => metric_alerts_test.go} (91%) delete mode 100644 sentry/project_ownership.go create mode 100644 sentry/project_ownerships.go rename sentry/{project_ownership_test.go => project_ownerships_test.go} (83%) delete mode 100644 sentry/rules.go diff --git a/README.md b/README.md index a67af3e..582eeb4 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# go-sentry [![GoDoc](https://godoc.org/github.com/jianyuan/go-sentry/sentry?status.svg)](https://godoc.org/github.com/jianyuan/go-sentry/sentry) +# go-sentry [![Go Reference](https://pkg.go.dev/badge/github.com/jianyuan/go-sentry/v2.svg)](https://pkg.go.dev/github.com/jianyuan/go-sentry/v2) + Go library for accessing the [Sentry Web API](https://docs.sentry.io/api/). -## Install +## Installation +go-sentry is compatible with modern Go releases in module mode, with Go installed: + ```sh -go get -u github.com/jianyuan/go-sentry/sentry +go get github.com/jianyuan/go-sentry/sentry/v2 ``` -## Documentation -Read the [GoDoc](https://godoc.org/github.com/jianyuan/go-sentry/sentry). - ## Code structure -The code structure was inspired by [dghubble/go-twitter](https://github.com/dghubble/go-twitter). +The code structure was inspired by [google/go-github](https://github.com/google/go-github). ## License This library is distributed under the [MIT License](LICENSE). diff --git a/go.mod b/go.mod index 27b1680..12362e2 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/jianyuan/go-sentry +module github.com/jianyuan/go-sentry/v2 go 1.18 diff --git a/sentry/alert_rules.go b/sentry/alert_rules.go deleted file mode 100644 index 6d70aa3..0000000 --- a/sentry/alert_rules.go +++ /dev/null @@ -1,81 +0,0 @@ -package sentry - -import ( - "net/http" - "time" - - "github.com/dghubble/sling" -) - -type AlertRuleService struct { - sling *sling.Sling -} - -type AlertRule struct { - ID string `json:"id"` - Name string `json:"name"` - Environment *string `json:"environment,omitempty"` - DataSet string `json:"dataset"` - Query string `json:"query"` - Aggregate string `json:"aggregate"` - TimeWindow float64 `json:"timeWindow"` - ThresholdType int `json:"thresholdType"` - ResolveThreshold float64 `json:"resolveThreshold"` - Triggers []Trigger `json:"triggers"` - Projects []string `json:"projects"` - Owner string `json:"owner"` - Created time.Time `json:"dateCreated"` -} - -type Trigger map[string]interface{} - -func newAlertRuleService(sling *sling.Sling) *AlertRuleService { - return &AlertRuleService{ - sling: sling, - } -} - -// List Alert Rules configured for a project -func (s *AlertRuleService) List(organizationSlug string, projectSlug string) ([]AlertRule, *http.Response, error) { - alertRules := new([]AlertRule) - apiError := new(APIError) - resp, err := s.sling.New().Get("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/").Receive(alertRules, apiError) - return *alertRules, resp, relevantError(err, *apiError) -} - -type CreateAlertRuleParams struct { - Name string `json:"name"` - Environment *string `json:"environment,omitempty"` - DataSet string `json:"dataset"` - Query string `json:"query"` - Aggregate string `json:"aggregate"` - TimeWindow float64 `json:"timeWindow"` - ThresholdType int `json:"thresholdType"` - ResolveThreshold float64 `json:"resolveThreshold"` - Triggers []Trigger `json:"triggers"` - Projects []string `json:"projects"` - Owner string `json:"owner"` -} - -// Create a new Alert Rule bound to a project. -func (s *AlertRuleService) Create(organizationSlug string, projectSlug string, params *CreateAlertRuleParams) (*AlertRule, *http.Response, error) { - alertRule := new(AlertRule) - apiError := new(APIError) - resp, err := s.sling.New().Post("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/").BodyJSON(params).Receive(alertRule, apiError) - return alertRule, resp, relevantError(err, *apiError) -} - -// Update an Alert Rule. -func (s *AlertRuleService) Update(organizationSlug string, projectSlug string, alertRuleID string, params *AlertRule) (*AlertRule, *http.Response, error) { - alertRule := new(AlertRule) - apiError := new(APIError) - resp, err := s.sling.New().Put("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/"+alertRuleID+"/").BodyJSON(params).Receive(alertRule, apiError) - return alertRule, resp, relevantError(err, *apiError) -} - -// Delete an Alert Rule. -func (s *AlertRuleService) Delete(organizationSlug string, projectSlug string, alertRuleID string) (*http.Response, error) { - apiError := new(APIError) - resp, err := s.sling.New().Delete("projects/"+organizationSlug+"/"+projectSlug+"/alert-rules/"+alertRuleID+"/").Receive(nil, apiError) - return resp, relevantError(err, *apiError) -} diff --git a/sentry/issue_alerts.go b/sentry/issue_alerts.go new file mode 100644 index 0000000..40ccf17 --- /dev/null +++ b/sentry/issue_alerts.go @@ -0,0 +1,172 @@ +package sentry + +import ( + "context" + "errors" + "fmt" + "time" +) + +// IssueAlert represents an issue alert configured for this project. +// https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/rule.py#L131-L155 +type IssueAlert struct { + ID string `json:"id"` + ActionMatch string `json:"actionMatch"` + FilterMatch string `json:"filterMatch"` + Environment *string `json:"environment,omitempty"` + Frequency int `json:"frequency"` + Name string `json:"name"` + Conditions []ConditionType `json:"conditions"` + Actions []ActionType `json:"actions"` + Filters []FilterType `json:"filters"` + Created time.Time `json:"dateCreated"` + TaskUUID string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the rule +} + +// IssueAlertTaskDetail represents the inline struct Sentry defines for task details +// https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/endpoints/project_rule_task_details.py#L29 +type IssueAlertTaskDetail struct { + Status string `json:"status"` + Rule IssueAlert `json:"rule"` + Error string `json:"error"` +} + +// IssueAlertsService provides methods for accessing Sentry project +// client key API endpoints. +// https://docs.sentry.io/api/projects/ +type IssueAlertsService service + +// List issue alerts configured for a project. +func (s *IssueAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*IssueAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/rules/", organizationSlug, projectSlug) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + alerts := []*IssueAlert{} + resp, err := s.client.Do(ctx, req, &alerts) + if err != nil { + return nil, resp, err + } + return alerts, resp, nil +} + +// ConditionType for defining conditions. +type ConditionType map[string]interface{} + +// ActionType for defining actions. +type ActionType map[string]interface{} + +// FilterType for defining actions. +type FilterType map[string]interface{} + +// CreateIssueAlertParams are the parameters for IssueAlertsService.Create. +type CreateIssueAlertParams struct { + ActionMatch string `json:"actionMatch"` + FilterMatch string `json:"filterMatch"` + Environment string `json:"environment,omitempty"` + Frequency int `json:"frequency"` + Name string `json:"name"` + Conditions []ConditionType `json:"conditions"` + Actions []ActionType `json:"actions"` + Filters []FilterType `json:"filters"` +} + +// CreateIssueAlertActionParams models the actions when creating the action for the rule. +type CreateIssueAlertActionParams struct { + ID string `json:"id"` + Tags string `json:"tags"` + Channel string `json:"channel"` + Workspace string `json:"workspace"` +} + +// CreateIssueAlertConditionParams models the conditions when creating the action for the rule. +type CreateIssueAlertConditionParams struct { + ID string `json:"id"` + Interval string `json:"interval"` + Value int `json:"value"` + Level int `json:"level"` + Match string `json:"match"` +} + +// Create a new issue alert bound to a project. +func (s *IssueAlertsService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *CreateIssueAlertParams) (*IssueAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/rules/", organizationSlug, projectSlug) + req, err := s.client.NewRequest("POST", u, params) + if err != nil { + return nil, nil, err + } + + alert := new(IssueAlert) + resp, err := s.client.Do(ctx, req, alert) + if err != nil { + return nil, resp, err + } + + if resp.StatusCode == 202 { + // We just received a reference to an async task, we need to check another endpoint to retrieve the issue alert we created + return s.getIssueAlertFromTaskDetail(ctx, organizationSlug, projectSlug, alert.TaskUUID) + } + + return alert, resp, nil +} + +// getIssueAlertFromTaskDetail is called when Sentry offloads the issue alert creation process to an async task and sends us back the task's uuid. +// It usually doesn't happen, but when creating Slack notification rules, it seemed to be sometimes the case. During testing it +// took very long for a task to finish (10+ seconds) which is why this method can take long to return. +func (s *IssueAlertsService) getIssueAlertFromTaskDetail(ctx context.Context, organizationSlug string, projectSlug string, taskUuid string) (*IssueAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/rule-task/%v/", organizationSlug, projectSlug, taskUuid) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + var resp *Response + for i := 0; i < 5; i++ { + // TODO: Read poll interval from context + time.Sleep(5 * time.Second) + + taskDetail := new(IssueAlertTaskDetail) + resp, err := s.client.Do(ctx, req, taskDetail) + if err != nil { + return nil, resp, err + } + + if taskDetail.Status == "success" { + return &taskDetail.Rule, resp, err + } else if taskDetail.Status == "failed" { + return &taskDetail.Rule, resp, errors.New(taskDetail.Error) + } else if resp.StatusCode == 404 { + return nil, resp, fmt.Errorf("couldn't find the issue alert creation task for uuid %v in Sentry (HTTP 404)", taskUuid) + } + } + return nil, resp, errors.New("getting the status of the issue alert creation from Sentry took too long") +} + +// Update an issue alert. +func (s *IssueAlertsService) Update(ctx context.Context, organizationSlug string, projectSlug string, issueAlertID string, params *IssueAlert) (*IssueAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/rules/%v/", organizationSlug, projectSlug, issueAlertID) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + + alert := new(IssueAlert) + resp, err := s.client.Do(ctx, req, alert) + if err != nil { + return nil, resp, err + } + return alert, resp, nil +} + +// Delete an issue alert. +func (s *IssueAlertsService) Delete(ctx context.Context, organizationSlug string, projectSlug string, issueAlertID string) (*Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/rules/%v/", organizationSlug, projectSlug, issueAlertID) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/sentry/rules_test.go b/sentry/issue_alerts_test.go similarity index 89% rename from sentry/rules_test.go rename to sentry/issue_alerts_test.go index 3a393c0..fc1caf1 100644 --- a/sentry/rules_test.go +++ b/sentry/issue_alerts_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "encoding/json" "fmt" "net/http" @@ -10,9 +11,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestRulesService_List(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestIssueAlertsService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/rules/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -46,12 +47,13 @@ func TestRulesService_List(t *testing.T) { } ]`) }) - client := NewClient(httpClient, nil, "") - rules, _, err := client.Rules.List("the-interstellar-jurisdiction", "pump-station") + + ctx := context.Background() + alerts, _, err := client.IssueAlerts.List(ctx, "the-interstellar-jurisdiction", "pump-station") require.NoError(t, err) environment := "production" - expected := []Rule{ + expected := []*IssueAlert{ { ID: "12345", ActionMatch: "any", @@ -79,13 +81,13 @@ func TestRulesService_List(t *testing.T) { Created: mustParseTime("2019-08-24T18:12:16.321Z"), }, } - require.Equal(t, expected, rules) + require.Equal(t, expected, alerts) } -func TestRulesService_Create(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestIssueAlertsService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/rules/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "POST", r) @@ -143,7 +145,7 @@ func TestRulesService_Create(t *testing.T) { }`) }) - params := &CreateRuleParams{ + params := &CreateIssueAlertParams{ ActionMatch: "all", Environment: "production", Frequency: 30, @@ -167,13 +169,12 @@ func TestRulesService_Create(t *testing.T) { }, }, } - - client := NewClient(httpClient, nil, "") - rules, _, err := client.Rules.Create("the-interstellar-jurisdiction", "pump-station", params) + ctx := context.Background() + alerts, _, err := client.IssueAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", params) require.NoError(t, err) environment := "production" - expected := &Rule{ + expected := &IssueAlert{ ID: "123456", ActionMatch: "all", Environment: &environment, @@ -199,13 +200,13 @@ func TestRulesService_Create(t *testing.T) { }, Created: mustParseTime("2019-08-24T18:12:16.321Z"), } - require.Equal(t, expected, rules) + require.Equal(t, expected, alerts) } -func TestRulesService_Create_With_Async_Task(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/rule-task/fakeuuid/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -273,7 +274,7 @@ func TestRulesService_Create_With_Async_Task(t *testing.T) { }) - params := &CreateRuleParams{ + params := &CreateIssueAlertParams{ ActionMatch: "all", Environment: "production", Frequency: 30, @@ -297,13 +298,12 @@ func TestRulesService_Create_With_Async_Task(t *testing.T) { }, }, } - - client := NewClient(httpClient, nil, "") - rule, _, err := client.Rules.Create("the-interstellar-jurisdiction", "pump-station", params) + ctx := context.Background() + alert, _, err := client.IssueAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", params) require.NoError(t, err) environment := "production" - expected := &Rule{ + expected := &IssueAlert{ ID: "123456", ActionMatch: "all", Environment: &environment, @@ -329,16 +329,16 @@ func TestRulesService_Create_With_Async_Task(t *testing.T) { }, Created: mustParseTime("2019-08-24T18:12:16.321Z"), } - require.Equal(t, expected, rule) + require.Equal(t, expected, alert) } -func TestRulesService_Update(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestIssueAlertsService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() environment := "staging" - params := &Rule{ + params := &IssueAlert{ ID: "12345", ActionMatch: "all", FilterMatch: "any", @@ -448,12 +448,11 @@ func TestRulesService_Update(t *testing.T) { "dateCreated": "2019-08-24T18:12:16.321Z" }`) }) - - client := NewClient(httpClient, nil, "") - rules, _, err := client.Rules.Update("the-interstellar-jurisdiction", "pump-station", "12345", params) + ctx := context.Background() + alerts, _, err := client.IssueAlerts.Update(ctx, "the-interstellar-jurisdiction", "pump-station", "12345", params) assert.NoError(t, err) - expected := &Rule{ + expected := &IssueAlert{ ID: "12345", ActionMatch: "any", Environment: &environment, @@ -477,19 +476,19 @@ func TestRulesService_Update(t *testing.T) { }, Created: mustParseTime("2019-08-24T18:12:16.321Z"), } - require.Equal(t, expected, rules) + require.Equal(t, expected, alerts) } -func TestRuleService_Delete(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestIssueAlertsService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/rules/12345/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "DELETE", r) }) - client := NewClient(httpClient, nil, "") - _, err := client.Rules.Delete("the-interstellar-jurisdiction", "pump-station", "12345") + ctx := context.Background() + _, err := client.IssueAlerts.Delete(ctx, "the-interstellar-jurisdiction", "pump-station", "12345") require.NoError(t, err) } diff --git a/sentry/metric_alerts.go b/sentry/metric_alerts.go new file mode 100644 index 0000000..45d883e --- /dev/null +++ b/sentry/metric_alerts.go @@ -0,0 +1,100 @@ +package sentry + +import ( + "context" + "fmt" + "time" +) + +type MetricAlertsService service + +type MetricAlert struct { + ID string `json:"id"` + Name string `json:"name"` + Environment *string `json:"environment,omitempty"` + DataSet string `json:"dataset"` + Query string `json:"query"` + Aggregate string `json:"aggregate"` + TimeWindow float64 `json:"timeWindow"` + ThresholdType int `json:"thresholdType"` + ResolveThreshold float64 `json:"resolveThreshold"` + Triggers []Trigger `json:"triggers"` + Projects []string `json:"projects"` + Owner string `json:"owner"` + Created time.Time `json:"dateCreated"` +} + +type Trigger map[string]interface{} + +// List Alert Rules configured for a project +func (s *MetricAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*MetricAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/alert-rules/", organizationSlug, projectSlug) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + metricAlerts := []*MetricAlert{} + resp, err := s.client.Do(ctx, req, &metricAlerts) + if err != nil { + return nil, resp, err + } + return metricAlerts, resp, nil +} + +type CreateAlertRuleParams struct { + Name string `json:"name"` + Environment *string `json:"environment,omitempty"` + DataSet string `json:"dataset"` + Query string `json:"query"` + Aggregate string `json:"aggregate"` + TimeWindow float64 `json:"timeWindow"` + ThresholdType int `json:"thresholdType"` + ResolveThreshold float64 `json:"resolveThreshold"` + Triggers []Trigger `json:"triggers"` + Projects []string `json:"projects"` + Owner string `json:"owner"` +} + +// Create a new Alert Rule bound to a project. +func (s *MetricAlertsService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *CreateAlertRuleParams) (*MetricAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/alert-rules/", organizationSlug, projectSlug) + req, err := s.client.NewRequest("POST", u, params) + if err != nil { + return nil, nil, err + } + + alertRule := new(MetricAlert) + resp, err := s.client.Do(ctx, req, alertRule) + if err != nil { + return nil, resp, err + } + return alertRule, resp, nil +} + +// Update an Alert Rule. +func (s *MetricAlertsService) Update(ctx context.Context, organizationSlug string, projectSlug string, alertRuleID string, params *MetricAlert) (*MetricAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/alert-rules/%v/", organizationSlug, projectSlug, alertRuleID) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + + alertRule := new(MetricAlert) + resp, err := s.client.Do(ctx, req, alertRule) + if err != nil { + return nil, resp, err + } + return alertRule, resp, nil +} + +// Delete an Alert Rule. +func (s *MetricAlertsService) Delete(ctx context.Context, organizationSlug string, projectSlug string, alertRuleID string) (*Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/alert-rules/%v/", organizationSlug, projectSlug, alertRuleID) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/sentry/alert_rules_test.go b/sentry/metric_alerts_test.go similarity index 91% rename from sentry/alert_rules_test.go rename to sentry/metric_alerts_test.go index dccf133..ef6d16a 100644 --- a/sentry/alert_rules_test.go +++ b/sentry/metric_alerts_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "encoding/json" "fmt" "net/http" @@ -10,9 +11,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestAlertRuleService_List(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestMetricAlertService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -62,12 +63,12 @@ func TestAlertRuleService_List(t *testing.T) { ]`) }) - client := NewClient(httpClient, nil, "") - alertRules, _, err := client.AlertRules.List("the-interstellar-jurisdiction", "pump-station") + ctx := context.Background() + alertRules, _, err := client.MetricAlerts.List(ctx, "the-interstellar-jurisdiction", "pump-station") require.NoError(t, err) environment := "production" - expected := []AlertRule{ + expected := []*MetricAlert{ { ID: "12345", Name: "pump-station-alert", @@ -110,9 +111,9 @@ func TestAlertRuleService_List(t *testing.T) { require.Equal(t, expected, alertRules) } -func TestAlertRuleService_Create(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestMetricAlertService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "POST", r) @@ -162,7 +163,6 @@ func TestAlertRuleService_Create(t *testing.T) { `) }) - client := NewClient(httpClient, nil, "") environment := "production" params := CreateAlertRuleParams{ Name: "pump-station-alert", @@ -190,10 +190,11 @@ func TestAlertRuleService_Create(t *testing.T) { Projects: []string{"pump-station"}, Owner: "pump-station:12345", } - alertRule, _, err := client.AlertRules.Create("the-interstellar-jurisdiction", "pump-station", ¶ms) + ctx := context.Background() + alertRule, _, err := client.MetricAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", ¶ms) require.NoError(t, err) - expected := &AlertRule{ + expected := &MetricAlert{ ID: "12345", Name: "pump-station-alert", Environment: &environment, @@ -235,12 +236,12 @@ func TestAlertRuleService_Create(t *testing.T) { require.Equal(t, expected, alertRule) } -func TestAlertRuleService_Update(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestMetricAlertService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() environment := "production" - params := &AlertRule{ + params := &MetricAlert{ ID: "12345", Name: "pump-station-alert", Environment: &environment, @@ -338,11 +339,11 @@ func TestAlertRuleService_Update(t *testing.T) { `) }) - client := NewClient(httpClient, nil, "") - alertRule, _, err := client.AlertRules.Update("the-interstellar-jurisdiction", "pump-station", "12345", params) + ctx := context.Background() + alertRule, _, err := client.MetricAlerts.Update(ctx, "the-interstellar-jurisdiction", "pump-station", "12345", params) assert.NoError(t, err) - expected := &AlertRule{ + expected := &MetricAlert{ ID: "12345", Name: "pump-station-alert", Environment: &environment, @@ -383,15 +384,15 @@ func TestAlertRuleService_Update(t *testing.T) { require.Equal(t, expected, alertRule) } -func TestAlertRuleService_Delete(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestMetricAlertService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/12345/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "DELETE", r) }) - client := NewClient(httpClient, nil, "") - _, err := client.AlertRules.Delete("the-interstellar-jurisdiction", "pump-station", "12345") + ctx := context.Background() + _, err := client.MetricAlerts.Delete(ctx, "the-interstellar-jurisdiction", "pump-station", "12345") require.NoError(t, err) } diff --git a/sentry/organization_members.go b/sentry/organization_members.go index 8858eaa..fbb27ba 100644 --- a/sentry/organization_members.go +++ b/sentry/organization_members.go @@ -1,14 +1,13 @@ package sentry import ( - "net/http" + "context" + "fmt" "time" - - "github.com/dghubble/sling" ) // OrganizationMember represents a User's membership to the organization. -// https://github.com/getsentry/sentry/blob/275e6efa0f364ce05d9bfd09386b895b8a5e0671/src/sentry/api/serializers/models/organization_member.py#L12 +// https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/organization_member/response.py#L57-L69 type OrganizationMember struct { ID string `json:"id"` Email string `json:"email"` @@ -25,16 +24,16 @@ type OrganizationMember struct { Teams []string `json:"teams"` } -// OrganizationMemberService provides methods for accessing Sentry membership API endpoints. -type OrganizationMemberService struct { - sling *sling.Sling -} +const ( + RoleMember string = "member" + RoleBilling string = "billing" + RoleAdmin string = "admin" + RoleOwner string = "owner" + RoleManager string = "manager" +) -func newOrganizationMemberService(sling *sling.Sling) *OrganizationMemberService { - return &OrganizationMemberService{ - sling: sling, - } -} +// OrganizationMembersService provides methods for accessing Sentry membership API endpoints. +type OrganizationMembersService service // ListOrganizationMemberParams are the parameters for OrganizationMemberService.List. type ListOrganizationMemberParams struct { @@ -42,20 +41,39 @@ type ListOrganizationMemberParams struct { } // List organization members. -func (s *OrganizationMemberService) List(organizationSlug string, params *ListOrganizationMemberParams) ([]OrganizationMember, *http.Response, error) { - members := new([]OrganizationMember) - apiError := new(APIError) - resp, err := s.sling.New().Get("organizations/"+organizationSlug+"/members/").QueryStruct(params).Receive(members, apiError) - return *members, resp, relevantError(err, *apiError) +func (s *OrganizationMembersService) List(ctx context.Context, organizationSlug string, params *ListOrganizationMemberParams) ([]*OrganizationMember, *Response, error) { + u, err := addQuery(fmt.Sprintf("0/organizations/%v/members/", organizationSlug), params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + members := []*OrganizationMember{} + resp, err := s.client.Do(ctx, req, &members) + if err != nil { + return nil, resp, err + } + return members, resp, nil } -const ( - RoleMember string = "member" - RoleBilling string = "billing" - RoleAdmin string = "admin" - RoleOwner string = "owner" - RoleManager string = "manager" -) +func (s *OrganizationMembersService) Get(ctx context.Context, organizationSlug string, memberID string) (*OrganizationMember, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/members/%v/", organizationSlug, memberID) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + member := new(OrganizationMember) + resp, err := s.client.Do(ctx, req, member) + if err != nil { + return nil, resp, err + } + return member, resp, nil +} type CreateOrganizationMemberParams struct { Email string `json:"email"` @@ -63,24 +81,19 @@ type CreateOrganizationMemberParams struct { Teams []string `json:"teams,omitempty"` } -func (s *OrganizationMemberService) Create(organizationSlug string, params *CreateOrganizationMemberParams) (*OrganizationMember, *http.Response, error) { - apiError := new(APIError) - organizationMember := new(OrganizationMember) - resp, err := s.sling.New().Post("organizations/"+organizationSlug+"/members/").BodyJSON(params).Receive(organizationMember, apiError) - return organizationMember, resp, relevantError(err, *apiError) -} - -func (s *OrganizationMemberService) Delete(organizationSlug string, memberId string) (*http.Response, error) { - apiError := new(APIError) - resp, err := s.sling.New().Delete("organizations/"+organizationSlug+"/members/"+memberId+"/").Receive(nil, apiError) - return resp, relevantError(err, *apiError) -} +func (s *OrganizationMembersService) Create(ctx context.Context, organizationSlug string, params *CreateOrganizationMemberParams) (*OrganizationMember, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/members/", organizationSlug) + req, err := s.client.NewRequest("POST", u, params) + if err != nil { + return nil, nil, err + } -func (s *OrganizationMemberService) Get(organizationSlug string, memberId string) (*OrganizationMember, *http.Response, error) { - apiError := new(APIError) - organizationMember := new(OrganizationMember) - resp, err := s.sling.New().Get("organizations/"+organizationSlug+"/members/"+memberId+"/").Receive(organizationMember, apiError) - return organizationMember, resp, relevantError(err, *apiError) + member := new(OrganizationMember) + resp, err := s.client.Do(ctx, req, member) + if err != nil { + return nil, resp, err + } + return member, resp, nil } type UpdateOrganizationMemberParams struct { @@ -88,9 +101,27 @@ type UpdateOrganizationMemberParams struct { Teams []string `json:"teams,omitempty"` } -func (s *OrganizationMemberService) Update(organizationSlug string, memberId string, params *UpdateOrganizationMemberParams) (*OrganizationMember, *http.Response, error) { - apiError := new(APIError) - organizationMember := new(OrganizationMember) - resp, err := s.sling.New().Put("organizations/"+organizationSlug+"/members/"+memberId+"/").BodyJSON(params).Receive(organizationMember, apiError) - return organizationMember, resp, relevantError(err, *apiError) +func (s *OrganizationMembersService) Update(ctx context.Context, organizationSlug string, memberID string, params *UpdateOrganizationMemberParams) (*OrganizationMember, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/members/%v/", organizationSlug, memberID) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + + member := new(OrganizationMember) + resp, err := s.client.Do(ctx, req, member) + if err != nil { + return nil, resp, err + } + return member, resp, nil +} + +func (s *OrganizationMembersService) Delete(ctx context.Context, organizationSlug string, memberID string) (*Response, error) { + u := fmt.Sprintf("0/organizations/%v/members/%v/", organizationSlug, memberID) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) } diff --git a/sentry/organization_members_test.go b/sentry/organization_members_test.go index cad2208..bb39662 100644 --- a/sentry/organization_members_test.go +++ b/sentry/organization_members_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "fmt" "net/http" "testing" @@ -8,9 +9,9 @@ import ( "github.com/stretchr/testify/assert" ) -func TestOrganizationMemberService_List(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationMembersService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -74,12 +75,12 @@ func TestOrganizationMemberService_List(t *testing.T) { ]`) }) - client := NewClient(httpClient, nil, "") - members, _, err := client.OrganizationMembers.List("the-interstellar-jurisdiction", &ListOrganizationMemberParams{ + ctx := context.Background() + members, _, err := client.OrganizationMembers.List(ctx, "the-interstellar-jurisdiction", &ListOrganizationMemberParams{ Cursor: "100:-1:1", }) assert.NoError(t, err) - expected := []OrganizationMember{ + expected := []*OrganizationMember{ { ID: "1", Email: "test@example.com", @@ -99,9 +100,9 @@ func TestOrganizationMemberService_List(t *testing.T) { LastActive: mustParseTime("2020-01-03T00:00:00.000000Z"), IsSuperuser: false, IsStaff: false, - Avatar: UserAvatar{ - AvatarType: "letter_avatar", - AvatarUUID: nil, + Avatar: Avatar{ + Type: "letter_avatar", + UUID: nil, }, Emails: []UserEmail{ { @@ -127,9 +128,9 @@ func TestOrganizationMemberService_List(t *testing.T) { assert.Equal(t, expected, members) } -func TestOrganizationMemberService_Get(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationMembersService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/1/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -191,8 +192,8 @@ func TestOrganizationMemberService_Get(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") - members, _, err := client.OrganizationMembers.Get("the-interstellar-jurisdiction", "1") + ctx := context.Background() + members, _, err := client.OrganizationMembers.Get(ctx, "the-interstellar-jurisdiction", "1") assert.NoError(t, err) expected := OrganizationMember{ ID: "1", @@ -213,9 +214,9 @@ func TestOrganizationMemberService_Get(t *testing.T) { LastActive: mustParseTime("2020-01-03T00:00:00.000000Z"), IsSuperuser: false, IsStaff: false, - Avatar: UserAvatar{ - AvatarType: "letter_avatar", - AvatarUUID: nil, + Avatar: Avatar{ + Type: "letter_avatar", + UUID: nil, }, Emails: []UserEmail{ { @@ -241,24 +242,9 @@ func TestOrganizationMemberService_Get(t *testing.T) { assert.Equal(t, &expected, members) } -func TestOrganizationMemberService_Delete(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() - - mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/1/", func(w http.ResponseWriter, r *http.Request) { - assertMethod(t, "DELETE", r) - w.WriteHeader(http.StatusNoContent) - }) - - client := NewClient(httpClient, nil, "") - resp, err := client.OrganizationMembers.Delete("the-interstellar-jurisdiction", "1") - assert.NoError(t, err) - assert.Equal(t, int64(0), resp.ContentLength) -} - -func TestOrganizationMemberService_Create(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationMembersService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "POST", r) @@ -285,12 +271,12 @@ func TestOrganizationMemberService_Create(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") createOrganizationMemberParams := CreateOrganizationMemberParams{ Email: "test@example.com", Role: RoleMember, } - member, _, err := client.OrganizationMembers.Create("the-interstellar-jurisdiction", &createOrganizationMemberParams) + ctx := context.Background() + member, _, err := client.OrganizationMembers.Create(ctx, "the-interstellar-jurisdiction", &createOrganizationMemberParams) assert.NoError(t, err) inviterName := "John Doe" @@ -317,9 +303,9 @@ func TestOrganizationMemberService_Create(t *testing.T) { assert.Equal(t, &expected, member) } -func TestOrganizationMemberService_Update(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationMembersService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/1/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "PUT", r) @@ -345,11 +331,11 @@ func TestOrganizationMemberService_Update(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") updateOrganizationMemberParams := UpdateOrganizationMemberParams{ Role: RoleMember, } - member, _, err := client.OrganizationMembers.Update("the-interstellar-jurisdiction", "1", &updateOrganizationMemberParams) + ctx := context.Background() + member, _, err := client.OrganizationMembers.Update(ctx, "the-interstellar-jurisdiction", "1", &updateOrganizationMemberParams) assert.NoError(t, err) inviterName := "John Doe" @@ -375,3 +361,18 @@ func TestOrganizationMemberService_Update(t *testing.T) { assert.Equal(t, &expected, member) } + +func TestOrganizationMembersService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/members/1/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "DELETE", r) + w.WriteHeader(http.StatusNoContent) + }) + + ctx := context.Background() + resp, err := client.OrganizationMembers.Delete(ctx, "the-interstellar-jurisdiction", "1") + assert.NoError(t, err) + assert.Equal(t, int64(0), resp.ContentLength) +} diff --git a/sentry/organizations.go b/sentry/organizations.go index e0d547f..c2b7faa 100644 --- a/sentry/organizations.go +++ b/sentry/organizations.go @@ -1,10 +1,9 @@ package sentry import ( - "net/http" + "context" + "fmt" "time" - - "github.com/dghubble/sling" ) // OrganizationStatus represents a Sentry organization's status. @@ -87,17 +86,9 @@ type DetailedOrganization struct { // TODO: onboardingTasks } -// OrganizationService provides methods for accessing Sentry organization API endpoints. +// OrganizationsService provides methods for accessing Sentry organization API endpoints. // https://docs.sentry.io/api/organizations/ -type OrganizationService struct { - sling *sling.Sling -} - -func newOrganizationService(sling *sling.Sling) *OrganizationService { - return &OrganizationService{ - sling: sling.Path("organizations/"), - } -} +type OrganizationsService service // ListOrganizationParams are the parameters for OrganizationService.List. type ListOrganizationParams struct { @@ -106,11 +97,40 @@ type ListOrganizationParams struct { // List organizations available to the authenticated session. // https://docs.sentry.io/api/organizations/list-your-organizations/ -func (s *OrganizationService) List(params *ListOrganizationParams) ([]Organization, *http.Response, error) { - organizations := new([]Organization) - apiError := new(APIError) - resp, err := s.sling.New().Get("").QueryStruct(params).Receive(organizations, apiError) - return *organizations, resp, relevantError(err, *apiError) +func (s *OrganizationsService) List(ctx context.Context, params *ListOrganizationParams) ([]*Organization, *Response, error) { + u, err := addQuery("0/organizations/", params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + orgs := []*Organization{} + resp, err := s.client.Do(ctx, req, &orgs) + if err != nil { + return nil, resp, err + } + return orgs, resp, nil +} + +// Get a Sentry organization. +// https://docs.sentry.io/api/organizations/retrieve-an-organization/ +func (s *OrganizationsService) Get(ctx context.Context, slug string) (*DetailedOrganization, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/", slug) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + org := new(DetailedOrganization) + resp, err := s.client.Do(ctx, req, org) + if err != nil { + return nil, resp, err + } + return org, resp, nil } // CreateOrganizationParams are the parameters for OrganizationService.Create. @@ -120,21 +140,20 @@ type CreateOrganizationParams struct { AgreeTerms *bool `json:"agreeTerms,omitempty"` } -// Get a Sentry organization. -// https://docs.sentry.io/api/organizations/retrieve-an-organization/ -func (s *OrganizationService) Get(slug string) (*DetailedOrganization, *http.Response, error) { - org := new(DetailedOrganization) - apiError := new(APIError) - resp, err := s.sling.New().Get(slug+"/").Receive(org, apiError) - return org, resp, relevantError(err, *apiError) -} - // Create a new Sentry organization. -func (s *OrganizationService) Create(params *CreateOrganizationParams) (*Organization, *http.Response, error) { +func (s *OrganizationsService) Create(ctx context.Context, params *CreateOrganizationParams) (*Organization, *Response, error) { + u := "0/organizations/" + req, err := s.client.NewRequest("POST", u, params) + if err != nil { + return nil, nil, err + } + org := new(Organization) - apiError := new(APIError) - resp, err := s.sling.New().Post("").BodyJSON(params).Receive(org, apiError) - return org, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, org) + if err != nil { + return nil, resp, err + } + return org, resp, nil } // UpdateOrganizationParams are the parameters for OrganizationService.Update. @@ -145,16 +164,28 @@ type UpdateOrganizationParams struct { // Update a Sentry organization. // https://docs.sentry.io/api/organizations/update-an-organization/ -func (s *OrganizationService) Update(slug string, params *UpdateOrganizationParams) (*Organization, *http.Response, error) { +func (s *OrganizationsService) Update(ctx context.Context, slug string, params *UpdateOrganizationParams) (*Organization, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/", slug) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + org := new(Organization) - apiError := new(APIError) - resp, err := s.sling.New().Put(slug+"/").BodyJSON(params).Receive(org, apiError) - return org, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, org) + if err != nil { + return nil, resp, err + } + return org, resp, nil } // Delete a Sentry organization. -func (s *OrganizationService) Delete(slug string) (*http.Response, error) { - apiError := new(APIError) - resp, err := s.sling.New().Delete(slug+"/").Receive(nil, apiError) - return resp, relevantError(err, *apiError) +func (s *OrganizationsService) Delete(ctx context.Context, slug string) (*Response, error) { + u := fmt.Sprintf("0/organizations/%v/", slug) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) } diff --git a/sentry/organizations_test.go b/sentry/organizations_test.go index 0609e60..efeb65e 100644 --- a/sentry/organizations_test.go +++ b/sentry/organizations_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "fmt" "net/http" "testing" @@ -8,13 +9,14 @@ import ( "github.com/stretchr/testify/assert" ) -func TestOrganizationService_List(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationsService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + cursor := "1500300636142:0:1" mux.HandleFunc("/api/0/organizations/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) - assertQuery(t, map[string]string{"cursor": "1500300636142:0:1"}, r) + assertQuery(t, map[string]string{"cursor": cursor}, r) w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `[ { @@ -31,12 +33,12 @@ func TestOrganizationService_List(t *testing.T) { ]`) }) - client := NewClient(httpClient, nil, "") - organizations, _, err := client.Organizations.List(&ListOrganizationParams{ - Cursor: "1500300636142:0:1", - }) + params := &ListOrganizationParams{Cursor: cursor} + ctx := context.Background() + orgs, _, err := client.Organizations.List(ctx, params) assert.NoError(t, err) - expected := []Organization{ + + expected := []*Organization{ { ID: "2", Slug: "the-interstellar-jurisdiction", @@ -49,12 +51,12 @@ func TestOrganizationService_List(t *testing.T) { }, }, } - assert.Equal(t, expected, organizations) + assert.Equal(t, expected, orgs) } -func TestOrganizationService_Get(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationsService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -192,9 +194,10 @@ func TestOrganizationService_Get(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") - organization, _, err := client.Organizations.Get("the-interstellar-jurisdiction") + ctx := context.Background() + organization, _, err := client.Organizations.Get(ctx, "the-interstellar-jurisdiction") assert.NoError(t, err) + expected := &DetailedOrganization{ ID: "2", Slug: "the-interstellar-jurisdiction", @@ -322,9 +325,9 @@ func TestOrganizationService_Get(t *testing.T) { assert.Equal(t, expected, organization) } -func TestOrganizationService_Create(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationsService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "POST", r) @@ -340,13 +343,14 @@ func TestOrganizationService_Create(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &CreateOrganizationParams{ Name: "The Interstellar Jurisdiction", Slug: "the-interstellar-jurisdiction", } - organization, _, err := client.Organizations.Create(params) + ctx := context.Background() + organization, _, err := client.Organizations.Create(ctx, params) assert.NoError(t, err) + expected := &Organization{ ID: "2", Name: "The Interstellar Jurisdiction", @@ -355,9 +359,9 @@ func TestOrganizationService_Create(t *testing.T) { assert.Equal(t, expected, organization) } -func TestOrganizationService_Create_AgreeTerms(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationsService_Create_AgreeTerms(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "POST", r) @@ -374,14 +378,15 @@ func TestOrganizationService_Create_AgreeTerms(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &CreateOrganizationParams{ Name: "The Interstellar Jurisdiction", Slug: "the-interstellar-jurisdiction", AgreeTerms: Bool(true), } - organization, _, err := client.Organizations.Create(params) + ctx := context.Background() + organization, _, err := client.Organizations.Create(ctx, params) assert.NoError(t, err) + expected := &Organization{ ID: "2", Name: "The Interstellar Jurisdiction", @@ -390,9 +395,9 @@ func TestOrganizationService_Create_AgreeTerms(t *testing.T) { assert.Equal(t, expected, organization) } -func TestOrganizationService_Update(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationsService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/badly-misnamed/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "PUT", r) @@ -408,13 +413,14 @@ func TestOrganizationService_Update(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &UpdateOrganizationParams{ Name: "Impeccably Designated", Slug: "impeccably-designated", } - organization, _, err := client.Organizations.Update("badly-misnamed", params) + ctx := context.Background() + organization, _, err := client.Organizations.Update(ctx, "badly-misnamed", params) assert.NoError(t, err) + expected := &Organization{ ID: "2", Name: "Impeccably Designated", @@ -423,15 +429,15 @@ func TestOrganizationService_Update(t *testing.T) { assert.Equal(t, expected, organization) } -func TestOrganizationService_Delete(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestOrganizationsService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "DELETE", r) }) - client := NewClient(httpClient, nil, "") - _, err := client.Organizations.Delete("the-interstellar-jurisdiction") + ctx := context.Background() + _, err := client.Organizations.Delete(ctx, "the-interstellar-jurisdiction") assert.NoError(t, err) } diff --git a/sentry/project_keys.go b/sentry/project_keys.go index e1adec6..3c161ec 100644 --- a/sentry/project_keys.go +++ b/sentry/project_keys.go @@ -1,12 +1,9 @@ package sentry import ( + "context" "fmt" - "net/http" "time" - - "github.com/dghubble/sling" - "github.com/tomnomnom/linkheader" ) // ProjectKeyRateLimit represents a project key's rate limit. @@ -40,49 +37,35 @@ type ProjectKey struct { DateCreated time.Time `json:"dateCreated"` } -// ProjectKeyService provides methods for accessing Sentry project +// ProjectKeysService provides methods for accessing Sentry project // client key API endpoints. // https://docs.sentry.io/api/projects/ -type ProjectKeyService struct { - sling *sling.Sling -} +type ProjectKeysService service -func newProjectKeyService(sling *sling.Sling) *ProjectKeyService { - return &ProjectKeyService{ - sling: sling, - } +// ListProjectKeyParams are the parameters for OrganizationService.List. +type ListProjectKeyParams struct { + Cursor string `url:"cursor,omitempty"` } // List client keys bound to a project. // https://docs.sentry.io/api/projects/get-project-keys/ -func (s *ProjectKeyService) List(organizationSlug string, projectSlug string) ([]ProjectKey, *http.Response, error) { - cursor := "" - return s.listPerPage(organizationSlug, projectSlug, cursor) -} +func (s *ProjectKeysService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ListProjectKeyParams) ([]*ProjectKey, *Response, error) { + u, err := addQuery(fmt.Sprintf("0/projects/%v/%v/keys/", organizationSlug, projectSlug), params) + if err != nil { + return nil, nil, err + } -// https://docs.sentry.io/api/projects/get-project-keys/ -func (s *ProjectKeyService) listPerPage(organizationSlug string, projectSlug string, cursor string) ([]ProjectKey, *http.Response, error) { - projectKeys := new([]ProjectKey) - apiError := new(APIError) - - URL := "projects/"+organizationSlug+"/"+projectSlug+"/keys/" + cursor - resp, err := s.sling.New().Get(URL).Receive(projectKeys, apiError) - if resp != nil && resp.StatusCode == 200 { - linkHeaders := linkheader.Parse(resp.Header.Get("Link")) - // If the next Link has results query it as well - nextLink := linkHeaders[len(linkHeaders) - 1] - - if nextLink.Param("results") == "true" { - c := fmt.Sprintf("?&cursor=%s", nextLink.Param("cursor")) - pagedProjectKeys, pagedResp, err2 := s.listPerPage(organizationSlug, projectSlug, c) - if err2 != nil { - return nil, pagedResp, relevantError(err2, *apiError) - } - *projectKeys = append(*projectKeys, pagedProjectKeys...) - } + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err } - return *projectKeys, resp, relevantError(err, *apiError) + projectKeys := []*ProjectKey{} + resp, err := s.client.Do(ctx, req, &projectKeys) + if err != nil { + return nil, resp, err + } + return projectKeys, resp, nil } // CreateProjectKeyParams are the parameters for ProjectKeyService.Create. @@ -93,25 +76,19 @@ type CreateProjectKeyParams struct { // Create a new client key bound to a project. // https://docs.sentry.io/api/projects/post-project-keys/ -func (s *ProjectKeyService) Create(organizationSlug string, projectSlug string, params *CreateProjectKeyParams) (*ProjectKey, *http.Response, error) { - projectKey := new(ProjectKey) - apiError := new(APIError) - resp, err := s.sling.New().Post("projects/"+organizationSlug+"/"+projectSlug+"/keys/").BodyJSON(params).Receive(projectKey, apiError) - +func (s *ProjectKeysService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *CreateProjectKeyParams) (*ProjectKey, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/keys/", organizationSlug, projectSlug) + req, err := s.client.NewRequest("POST", u, params) if err != nil { - return projectKey, resp, relevantError(err, *apiError) + return nil, nil, err } - // Hack as currently the API does not support setting rate limits on Create - if params.RateLimit != nil { - updateParams := &UpdateProjectKeyParams{ - Name: params.Name, - RateLimit: params.RateLimit, - } - projectKey, resp, err = s.Update(organizationSlug, projectSlug, projectKey.ID, updateParams) + projectKey := new(ProjectKey) + resp, err := s.client.Do(ctx, req, projectKey) + if err != nil { + return nil, resp, err } - - return projectKey, resp, relevantError(err, *apiError) + return projectKey, resp, nil } // UpdateProjectKeyParams are the parameters for ProjectKeyService.Update. @@ -122,17 +99,29 @@ type UpdateProjectKeyParams struct { // Update a client key. // https://docs.sentry.io/api/projects/put-project-key-details/ -func (s *ProjectKeyService) Update(organizationSlug string, projectSlug string, keyID string, params *UpdateProjectKeyParams) (*ProjectKey, *http.Response, error) { +func (s *ProjectKeysService) Update(ctx context.Context, organizationSlug string, projectSlug string, keyID string, params *UpdateProjectKeyParams) (*ProjectKey, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/keys/%v/", organizationSlug, projectSlug, keyID) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + projectKey := new(ProjectKey) - apiError := new(APIError) - resp, err := s.sling.New().Put("projects/"+organizationSlug+"/"+projectSlug+"/keys/"+keyID+"/").BodyJSON(params).Receive(projectKey, apiError) - return projectKey, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, projectKey) + if err != nil { + return nil, resp, err + } + return projectKey, resp, nil } // Delete a project. // https://docs.sentry.io/api/projects/delete-project-details/ -func (s *ProjectKeyService) Delete(organizationSlug string, projectSlug string, keyID string) (*http.Response, error) { - apiError := new(APIError) - resp, err := s.sling.New().Delete("projects/"+organizationSlug+"/"+projectSlug+"/keys/"+keyID+"/").Receive(nil, apiError) - return resp, relevantError(err, *apiError) +func (s *ProjectKeysService) Delete(ctx context.Context, organizationSlug string, projectSlug string, keyID string) (*Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/keys/%v/", organizationSlug, projectSlug, keyID) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) } diff --git a/sentry/project_keys_test.go b/sentry/project_keys_test.go index 44a8208..24e5423 100644 --- a/sentry/project_keys_test.go +++ b/sentry/project_keys_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "encoding/json" "fmt" "net/http" @@ -9,9 +10,9 @@ import ( "github.com/stretchr/testify/assert" ) -func TestProjectKeyService_List(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectKeysService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -51,11 +52,12 @@ func TestProjectKeyService_List(t *testing.T) { }]`) }) - client := NewClient(httpClient, nil, "") - projectKeys, _, err := client.ProjectKeys.List("the-interstellar-jurisdiction", "pump-station") + params := &ListProjectKeyParams{} + ctx := context.Background() + projectKeys, _, err := client.ProjectKeys.List(ctx, "the-interstellar-jurisdiction", "pump-station", params) assert.NoError(t, err) - expected := []ProjectKey{ + expected := []*ProjectKey{ { ID: "cfc7b0341c6e4f6ea1a9d256a30dba00", Name: "Fabulous Key", @@ -78,192 +80,9 @@ func TestProjectKeyService_List(t *testing.T) { assert.Equal(t, expected, projectKeys) } -func TestProjectKeyService_ListWithPagination(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() - - mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/test", func(w http.ResponseWriter, r *http.Request) { - assertMethod(t, "GET", r) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Link", "; rel=\"previous\"; results=\"true\"; cursor=\"0:0:1\", ; rel=\"next\"; results=\"true\"; cursor=\"1234:0:1\"") - fmt.Fprint(w, `[{ - "browserSdk": { - "choices": [ - [ - "latest", - "latest" - ], - [ - "4.x", - "4.x" - ] - ] - }, - "browserSdkVersion": "4.x", - "dateCreated": "2018-09-20T15:48:07.397Z", - "dsn": { - "cdn": "https://sentry.io/js-sdk-loader/cfc7b0341c6e4f6ea1a9d256a30dba00.min.js", - "csp": "https://sentry.io/api/2/csp-report/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "minidump": "https://sentry.io/api/2/minidump/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "public": "https://cfc7b0341c6e4f6ea1a9d256a30dba00@sentry.io/2", - "secret": "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", - "security": "https://sentry.io/api/2/security/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00" - }, - "id": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "isActive": true, - "label": "Fabulous Key", - "name": "Fabulous Key", - "projectId": 2, - "public": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "rateLimit": null, - "secret": "a07dcd97aa56481f82aeabaed43ca448" - }]`) - }) - - mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/", func(w http.ResponseWriter, r *http.Request) { - assertMethod(t, "GET", r) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Link", "; rel=\"previous\"; results=\"true\"; cursor=\"0:0:1\", ; rel=\"next\"; results=\"false\"; cursor=\"12:2:1\"") - fmt.Fprint(w, `[{ - "browserSdk": { - "choices": [ - [ - "latest", - "latest" - ], - [ - "4.x", - "4.x" - ] - ] - }, - "browserSdkVersion": "4.x", - "dateCreated": "2018-09-20T15:48:07.397Z", - "dsn": { - "cdn": "https://sentry.io/js-sdk-loader/cfc7b0341c6e4f6ea1a9d256a30dba00.min.js", - "csp": "https://sentry.io/api/2/csp-report/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "minidump": "https://sentry.io/api/2/minidump/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "public": "https://cfc7b0341c6e4f6ea1a9d256a30dba00@sentry.io/2", - "secret": "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", - "security": "https://sentry.io/api/2/security/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00" - }, - "id": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "isActive": true, - "label": "Fabulous Key Number 2", - "name": "Fabulous Key Number 2", - "projectId": 2, - "public": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "rateLimit": null, - "secret": "a07dcd97aa56481f82aeabaed43ca448" - }]`) - }) - - client := NewClient(httpClient, nil, "") - // Kind of abusing the cursor field here. Normally this should always be a query of the form ?&cursor=bla but as - // mux.HandleFunc is somewhat stiff in mocking different results for the same path but with different queries - // calling the function like this allows us to mock a different response for the second page of results - projectKeys, _, err := client.ProjectKeys.listPerPage("the-interstellar-jurisdiction", "pump-station", "test") - assert.NoError(t, err) - - expected := []ProjectKey{ - { - ID: "cfc7b0341c6e4f6ea1a9d256a30dba00", - Name: "Fabulous Key", - Label: "Fabulous Key", - Public: "cfc7b0341c6e4f6ea1a9d256a30dba00", - Secret: "a07dcd97aa56481f82aeabaed43ca448", - ProjectID: 2, - IsActive: true, - DSN: ProjectKeyDSN{ - Secret: "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", - Public: "https://cfc7b0341c6e4f6ea1a9d256a30dba00@sentry.io/2", - CSP: "https://sentry.io/api/2/csp-report/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - Security: "https://sentry.io/api/2/security/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - Minidump: "https://sentry.io/api/2/minidump/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - CDN: "https://sentry.io/js-sdk-loader/cfc7b0341c6e4f6ea1a9d256a30dba00.min.js", - }, - DateCreated: mustParseTime("2018-09-20T15:48:07.397Z"), - }, - { - ID: "cfc7b0341c6e4f6ea1a9d256a30dba00", - Name: "Fabulous Key Number 2", - Label: "Fabulous Key Number 2", - Public: "cfc7b0341c6e4f6ea1a9d256a30dba00", - Secret: "a07dcd97aa56481f82aeabaed43ca448", - ProjectID: 2, - IsActive: true, - DSN: ProjectKeyDSN{ - Secret: "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", - Public: "https://cfc7b0341c6e4f6ea1a9d256a30dba00@sentry.io/2", - CSP: "https://sentry.io/api/2/csp-report/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - Security: "https://sentry.io/api/2/security/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - Minidump: "https://sentry.io/api/2/minidump/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - CDN: "https://sentry.io/js-sdk-loader/cfc7b0341c6e4f6ea1a9d256a30dba00.min.js", - }, - DateCreated: mustParseTime("2018-09-20T15:48:07.397Z"), - }, - } - assert.Equal(t, expected, projectKeys) -} - - -func TestProjectKeyService_ListWithPagination_ReturnsErrorWhenAPageIsNotPresent(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() - - mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/test", func(w http.ResponseWriter, r *http.Request) { - assertMethod(t, "GET", r) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Link", "; rel=\"previous\"; results=\"true\"; cursor=\"0:0:1\", ; rel=\"next\"; results=\"true\"; cursor=\"1234:0:1\"") - fmt.Fprint(w, `[{ - "browserSdk": { - "choices": [ - [ - "latest", - "latest" - ], - [ - "4.x", - "4.x" - ] - ] - }, - "browserSdkVersion": "4.x", - "dateCreated": "2018-09-20T15:48:07.397Z", - "dsn": { - "cdn": "https://sentry.io/js-sdk-loader/cfc7b0341c6e4f6ea1a9d256a30dba00.min.js", - "csp": "https://sentry.io/api/2/csp-report/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "minidump": "https://sentry.io/api/2/minidump/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "public": "https://cfc7b0341c6e4f6ea1a9d256a30dba00@sentry.io/2", - "secret": "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", - "security": "https://sentry.io/api/2/security/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00" - }, - "id": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "isActive": true, - "label": "Fabulous Key", - "name": "Fabulous Key", - "projectId": 2, - "public": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "rateLimit": null, - "secret": "a07dcd97aa56481f82aeabaed43ca448" - }]`) - }) - - client := NewClient(httpClient, nil, "") - // Kind of abusing the cursor field here. Normally this should always be a query of the form ?&cursor=bla but as - // mux.HandleFunc is somewhat stiff in mocking different results for the same path but with different queries - // calling the function like this allows us to have the second call return a 404 - projectKeys, resp, err := client.ProjectKeys.listPerPage("the-interstellar-jurisdiction", "pump-station", "test") - assert.Equal(t, 404, resp.StatusCode) - assert.Error(t, err) - - var expected []ProjectKey - assert.Equal(t, expected, projectKeys) -} - -func TestProjectKeyService_Create(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectKeysService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "POST", r) @@ -305,139 +124,11 @@ func TestProjectKeyService_Create(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &CreateProjectKeyParams{ Name: "Fabulous Key", } - projectKey, _, err := client.ProjectKeys.Create("the-interstellar-jurisdiction", "pump-station", params) - assert.NoError(t, err) - expected := &ProjectKey{ - ID: "cfc7b0341c6e4f6ea1a9d256a30dba00", - Name: "Fabulous Key", - Label: "Fabulous Key", - Public: "cfc7b0341c6e4f6ea1a9d256a30dba00", - Secret: "a07dcd97aa56481f82aeabaed43ca448", - ProjectID: 2, - IsActive: true, - DSN: ProjectKeyDSN{ - Secret: "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", - Public: "https://cfc7b0341c6e4f6ea1a9d256a30dba00@sentry.io/2", - CSP: "https://sentry.io/api/2/csp-report/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - Security: "https://sentry.io/api/2/security/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - Minidump: "https://sentry.io/api/2/minidump/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - CDN: "https://sentry.io/js-sdk-loader/cfc7b0341c6e4f6ea1a9d256a30dba00.min.js", - }, - DateCreated: mustParseTime("2018-09-20T15:48:07.397Z"), - } - assert.Equal(t, expected, projectKey) -} - -func TestProjectKeyService_Create_RateLimit(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() - - mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/", func(w http.ResponseWriter, r *http.Request) { - assertMethod(t, "POST", r) - assertPostJSON(t, map[string]interface{}{ - "name": "Fabulous Key", - "rateLimit": map[string]interface{}{ - "window": json.Number("86400"), - "count": json.Number("1000"), - }, - }, r) - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{ - "browserSdk": { - "choices": [ - [ - "latest", - "latest" - ], - [ - "4.x", - "4.x" - ] - ] - }, - "browserSdkVersion": "4.x", - "dateCreated": "2018-09-20T15:48:07.397Z", - "dsn": { - "cdn": "https://sentry.io/js-sdk-loader/cfc7b0341c6e4f6ea1a9d256a30dba00.min.js", - "csp": "https://sentry.io/api/2/csp-report/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "minidump": "https://sentry.io/api/2/minidump/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "public": "https://cfc7b0341c6e4f6ea1a9d256a30dba00@sentry.io/2", - "secret": "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", - "security": "https://sentry.io/api/2/security/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00" - }, - "id": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "isActive": true, - "label": "Fabulous Key", - "name": "Fabulous Key", - "projectId": 2, - "public": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "rateLimit": null, - "secret": "a07dcd97aa56481f82aeabaed43ca448" - }`) - }) - - mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/cfc7b0341c6e4f6ea1a9d256a30dba00/", func(w http.ResponseWriter, r *http.Request) { - assertMethod(t, "PUT", r) - assertPostJSON(t, map[string]interface{}{ - "name": "Fabulous Key", - "rateLimit": map[string]interface{}{ - "window": json.Number("86400"), - "count": json.Number("1000"), - }, - }, r) - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{ - "browserSdk": { - "choices": [ - [ - "latest", - "latest" - ], - [ - "4.x", - "4.x" - ] - ] - }, - "browserSdkVersion": "4.x", - "dateCreated": "2018-09-20T15:48:07.397Z", - "dsn": { - "cdn": "https://sentry.io/js-sdk-loader/cfc7b0341c6e4f6ea1a9d256a30dba00.min.js", - "csp": "https://sentry.io/api/2/csp-report/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "minidump": "https://sentry.io/api/2/minidump/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00", - "public": "https://cfc7b0341c6e4f6ea1a9d256a30dba00@sentry.io/2", - "secret": "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", - "security": "https://sentry.io/api/2/security/?sentry_key=cfc7b0341c6e4f6ea1a9d256a30dba00" - }, - "id": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "isActive": true, - "label": "Fabulous Key", - "name": "Fabulous Key", - "projectId": 2, - "public": "cfc7b0341c6e4f6ea1a9d256a30dba00", - "rateLimit": { - "count": 1000, - "window": 86400 - }, - "secret": "a07dcd97aa56481f82aeabaed43ca448" - }`) - }) - - rateLimit := ProjectKeyRateLimit{ - Count: 1000, - Window: 86400, - } - - client := NewClient(httpClient, nil, "") - params := &CreateProjectKeyParams{ - Name: "Fabulous Key", - RateLimit: &rateLimit, - } - projectKey, _, err := client.ProjectKeys.Create("the-interstellar-jurisdiction", "pump-station", params) + ctx := context.Background() + projectKey, _, err := client.ProjectKeys.Create(ctx, "the-interstellar-jurisdiction", "pump-station", params) assert.NoError(t, err) expected := &ProjectKey{ ID: "cfc7b0341c6e4f6ea1a9d256a30dba00", @@ -447,7 +138,6 @@ func TestProjectKeyService_Create_RateLimit(t *testing.T) { Secret: "a07dcd97aa56481f82aeabaed43ca448", ProjectID: 2, IsActive: true, - RateLimit: &rateLimit, DSN: ProjectKeyDSN{ Secret: "https://cfc7b0341c6e4f6ea1a9d256a30dba00:a07dcd97aa56481f82aeabaed43ca448@sentry.io/2", Public: "https://cfc7b0341c6e4f6ea1a9d256a30dba00@sentry.io/2", @@ -461,9 +151,9 @@ func TestProjectKeyService_Create_RateLimit(t *testing.T) { assert.Equal(t, expected, projectKey) } -func TestProjectKeyService_Update(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectKeysService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/befdbf32724c4ae0a3d286717b1f8127/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "PUT", r) @@ -505,11 +195,11 @@ func TestProjectKeyService_Update(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &UpdateProjectKeyParams{ Name: "Fabulous Key", } - projectKey, _, err := client.ProjectKeys.Update("the-interstellar-jurisdiction", "pump-station", "befdbf32724c4ae0a3d286717b1f8127", params) + ctx := context.Background() + projectKey, _, err := client.ProjectKeys.Update(ctx, "the-interstellar-jurisdiction", "pump-station", "befdbf32724c4ae0a3d286717b1f8127", params) assert.NoError(t, err) expected := &ProjectKey{ ID: "cfc7b0341c6e4f6ea1a9d256a30dba00", @@ -532,9 +222,9 @@ func TestProjectKeyService_Update(t *testing.T) { assert.Equal(t, expected, projectKey) } -func TestProjectKeyService_Update_RateLimit(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectKeysService_Update_RateLimit(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/befdbf32724c4ae0a3d286717b1f8127/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "PUT", r) @@ -587,13 +277,12 @@ func TestProjectKeyService_Update_RateLimit(t *testing.T) { Count: 1000, Window: 86400, } - - client := NewClient(httpClient, nil, "") params := &UpdateProjectKeyParams{ Name: "Fabulous Key", RateLimit: &rateLimit, } - projectKey, _, err := client.ProjectKeys.Update("the-interstellar-jurisdiction", "pump-station", "befdbf32724c4ae0a3d286717b1f8127", params) + ctx := context.Background() + projectKey, _, err := client.ProjectKeys.Update(ctx, "the-interstellar-jurisdiction", "pump-station", "befdbf32724c4ae0a3d286717b1f8127", params) assert.NoError(t, err) expected := &ProjectKey{ ID: "cfc7b0341c6e4f6ea1a9d256a30dba00", @@ -617,16 +306,16 @@ func TestProjectKeyService_Update_RateLimit(t *testing.T) { assert.Equal(t, expected, projectKey) } -func TestProjectKeyService_Delete(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectKeysService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/keys/befdbf32724c4ae0a3d286717b1f8127/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "DELETE", r) }) - client := NewClient(httpClient, nil, "") - _, err := client.ProjectKeys.Delete("the-interstellar-jurisdiction", "pump-station", "befdbf32724c4ae0a3d286717b1f8127") + ctx := context.Background() + _, err := client.ProjectKeys.Delete(ctx, "the-interstellar-jurisdiction", "pump-station", "befdbf32724c4ae0a3d286717b1f8127") assert.NoError(t, err) } diff --git a/sentry/project_ownership.go b/sentry/project_ownership.go deleted file mode 100644 index d9c56a4..0000000 --- a/sentry/project_ownership.go +++ /dev/null @@ -1,55 +0,0 @@ -package sentry - -import ( - "net/http" - "time" - - "github.com/dghubble/sling" -) - -// https://github.com/getsentry/sentry/blob/master/src/sentry/api/serializers/models/projectownership.py -type ProjectOwnership struct { - Raw string `json:"raw"` - FallThrough bool `json:"fallthrough"` - DateCreated time.Time `json:"dateCreated"` - LastUpdated time.Time `json:"lastUpdated"` - IsActive bool `json:"isActive"` - AutoAssignment bool `json:"autoAssignment"` - CodeownersAutoSync *bool `json:"codeownersAutoSync,omitempty"` -} - -// ProjectOwnershipService provides methods for accessing Sentry project -// client key API endpoints. -type ProjectOwnershipService struct { - sling *sling.Sling -} - -func newProjectOwnershipService(sling *sling.Sling) *ProjectOwnershipService { - return &ProjectOwnershipService{ - sling: sling, - } -} - -// Get details on a project's ownership configuration. -func (s *ProjectOwnershipService) Get(organizationSlug string, projectSlug string) (*ProjectOwnership, *http.Response, error) { - project := new(ProjectOwnership) - apiError := new(APIError) - resp, err := s.sling.New().Get("projects/"+organizationSlug+"/"+projectSlug+"/ownership/").Receive(project, apiError) - return project, resp, relevantError(err, *apiError) -} - -// CreateProjectParams are the parameters for ProjectOwnershipService.Update. -type UpdateProjectOwnershipParams struct { - Raw string `json:"raw,omitempty"` - FallThrough *bool `json:"fallthrough,omitempty"` - AutoAssignment *bool `json:"autoAssignment,omitempty"` - CodeownersAutoSync *bool `json:"codeownersAutoSync,omitempty"` -} - -// Update a Project's Ownership configuration -func (s *ProjectOwnershipService) Update(organizationSlug string, projectSlug string, params *UpdateProjectOwnershipParams) (*ProjectOwnership, *http.Response, error) { - project := new(ProjectOwnership) - apiError := new(APIError) - resp, err := s.sling.New().Put("projects/"+organizationSlug+"/"+projectSlug+"/ownership/").BodyJSON(params).Receive(project, apiError) - return project, resp, relevantError(err, *apiError) -} diff --git a/sentry/project_ownerships.go b/sentry/project_ownerships.go new file mode 100644 index 0000000..5078705 --- /dev/null +++ b/sentry/project_ownerships.go @@ -0,0 +1,62 @@ +package sentry + +import ( + "context" + "fmt" + "time" +) + +// https://github.com/getsentry/sentry/blob/master/src/sentry/api/serializers/models/projectownership.py +type ProjectOwnership struct { + Raw string `json:"raw"` + FallThrough bool `json:"fallthrough"` + DateCreated time.Time `json:"dateCreated"` + LastUpdated time.Time `json:"lastUpdated"` + IsActive bool `json:"isActive"` + AutoAssignment bool `json:"autoAssignment"` + CodeownersAutoSync *bool `json:"codeownersAutoSync,omitempty"` +} + +// ProjectOwnershipsService provides methods for accessing Sentry project +// ownership API endpoints. +type ProjectOwnershipsService service + +// Get details on a project's ownership configuration. +func (s *ProjectOwnershipsService) Get(ctx context.Context, organizationSlug string, projectSlug string) (*ProjectOwnership, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/ownership/", organizationSlug, projectSlug) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + owner := new(ProjectOwnership) + resp, err := s.client.Do(ctx, req, owner) + if err != nil { + return nil, resp, err + } + return owner, resp, nil +} + +// CreateProjectParams are the parameters for ProjectOwnershipService.Update. +type UpdateProjectOwnershipParams struct { + Raw string `json:"raw,omitempty"` + FallThrough *bool `json:"fallthrough,omitempty"` + AutoAssignment *bool `json:"autoAssignment,omitempty"` + CodeownersAutoSync *bool `json:"codeownersAutoSync,omitempty"` +} + +// Update a Project's Ownership configuration +func (s *ProjectOwnershipsService) Update(ctx context.Context, organizationSlug string, projectSlug string, params *UpdateProjectOwnershipParams) (*ProjectOwnership, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/ownership/", organizationSlug, projectSlug) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + + owner := new(ProjectOwnership) + resp, err := s.client.Do(ctx, req, owner) + if err != nil { + return nil, resp, err + } + return owner, resp, nil +} diff --git a/sentry/project_ownership_test.go b/sentry/project_ownerships_test.go similarity index 83% rename from sentry/project_ownership_test.go rename to sentry/project_ownerships_test.go index 0ce69f3..f3583c0 100644 --- a/sentry/project_ownership_test.go +++ b/sentry/project_ownerships_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "fmt" "net/http" "testing" @@ -8,9 +9,9 @@ import ( "github.com/stretchr/testify/assert" ) -func TestProjectOwnershipService_Get(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectOwnershipsService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/powerful-abolitionist/ownership/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -26,8 +27,8 @@ func TestProjectOwnershipService_Get(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") - ownership, _, err := client.Ownership.Get("the-interstellar-jurisdiction", "powerful-abolitionist") + ctx := context.Background() + ownership, _, err := client.ProjectOwnerships.Get(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") assert.NoError(t, err) expected := &ProjectOwnership{ @@ -43,9 +44,9 @@ func TestProjectOwnershipService_Get(t *testing.T) { assert.Equal(t, expected, ownership) } -func TestProjectOwnershipService_Update(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectOwnershipsService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/the-obese-philosophers/ownership/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "PUT", r) @@ -64,11 +65,11 @@ func TestProjectOwnershipService_Update(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &UpdateProjectOwnershipParams{ Raw: "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", } - ownership, _, err := client.Ownership.Update("the-interstellar-jurisdiction", "the-obese-philosophers", params) + ctx := context.Background() + ownership, _, err := client.ProjectOwnerships.Update(ctx, "the-interstellar-jurisdiction", "the-obese-philosophers", params) assert.NoError(t, err) expected := &ProjectOwnership{ Raw: "# assign issues to the product team, no matter the area\nurl:https://example.com/areas/*/*/products/* #product-team", diff --git a/sentry/project_plugins.go b/sentry/project_plugins.go index 1dd151c..e619688 100644 --- a/sentry/project_plugins.go +++ b/sentry/project_plugins.go @@ -1,10 +1,9 @@ package sentry import ( + "context" "encoding/json" - "net/http" - - "github.com/dghubble/sling" + "fmt" ) // ProjectPluginAsset represents an asset of a plugin. @@ -43,32 +42,40 @@ type ProjectPlugin struct { Config []ProjectPluginConfig `json:"config"` } -// ProjectPluginService provides methods for accessing Sentry project +// ProjectPluginsService provides methods for accessing Sentry project // plugin API endpoints. -type ProjectPluginService struct { - sling *sling.Sling -} +type ProjectPluginsService service -func newProjectPluginService(sling *sling.Sling) *ProjectPluginService { - return &ProjectPluginService{ - sling: sling, +// List plugins bound to a project. +func (s *ProjectPluginsService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*ProjectPlugin, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/plugins/", organizationSlug, projectSlug) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err } -} -// List plugins bound to a project. -func (s *ProjectPluginService) List(organizationSlug string, projectSlug string) ([]ProjectPlugin, *http.Response, error) { - projectPlugins := new([]ProjectPlugin) - apiError := new(APIError) - resp, err := s.sling.New().Get("projects/"+organizationSlug+"/"+projectSlug+"/plugins/").Receive(projectPlugins, apiError) - return *projectPlugins, resp, relevantError(err, *apiError) + projectPlugins := []*ProjectPlugin{} + resp, err := s.client.Do(ctx, req, &projectPlugins) + if err != nil { + return nil, resp, err + } + return projectPlugins, resp, nil } // Get details of a project plugin. -func (s *ProjectPluginService) Get(organizationSlug string, projectSlug string, id string) (*ProjectPlugin, *http.Response, error) { +func (s *ProjectPluginsService) Get(ctx context.Context, organizationSlug string, projectSlug string, id string) (*ProjectPlugin, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/plugins/%v/", organizationSlug, projectSlug, id) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + projectPlugin := new(ProjectPlugin) - apiError := new(APIError) - resp, err := s.sling.New().Get("projects/"+organizationSlug+"/"+projectSlug+"/plugins/"+id+"/").Receive(projectPlugin, apiError) - return projectPlugin, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, projectPlugin) + if err != nil { + return nil, resp, err + } + return projectPlugin, resp, nil } // UpdateProjectPluginParams are the parameters for TeamService.Update. @@ -76,23 +83,39 @@ type UpdateProjectPluginParams map[string]interface{} // Update settings for a given team. // https://docs.sentry.io/api/teams/put-team-details/ -func (s *ProjectPluginService) Update(organizationSlug string, projectSlug string, id string, params UpdateProjectPluginParams) (*ProjectPlugin, *http.Response, error) { +func (s *ProjectPluginsService) Update(ctx context.Context, organizationSlug string, projectSlug string, id string, params UpdateProjectPluginParams) (*ProjectPlugin, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/plugins/%v/", organizationSlug, projectSlug, id) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + projectPlugin := new(ProjectPlugin) - apiError := new(APIError) - resp, err := s.sling.New().Put("projects/"+organizationSlug+"/"+projectSlug+"/plugins/"+id+"/").BodyJSON(params).Receive(projectPlugin, apiError) - return projectPlugin, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, projectPlugin) + if err != nil { + return nil, resp, err + } + return projectPlugin, resp, nil } // Enable a project plugin. -func (s *ProjectPluginService) Enable(organizationSlug string, projectSlug string, id string) (*http.Response, error) { - apiError := new(APIError) - resp, err := s.sling.New().Post("projects/"+organizationSlug+"/"+projectSlug+"/plugins/"+id+"/").Receive(nil, apiError) - return resp, relevantError(err, *apiError) +func (s *ProjectPluginsService) Enable(ctx context.Context, organizationSlug string, projectSlug string, id string) (*Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/plugins/%v/", organizationSlug, projectSlug, id) + req, err := s.client.NewRequest("POST", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) } // Disable a project plugin. -func (s *ProjectPluginService) Disable(organizationSlug string, projectSlug string, id string) (*http.Response, error) { - apiError := new(APIError) - resp, err := s.sling.New().Delete("projects/"+organizationSlug+"/"+projectSlug+"/plugins/"+id+"/").Receive(nil, apiError) - return resp, relevantError(err, *apiError) +func (s *ProjectPluginsService) Disable(ctx context.Context, organizationSlug string, projectSlug string, id string) (*Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/plugins/%v/", organizationSlug, projectSlug, id) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) } diff --git a/sentry/projects.go b/sentry/projects.go index 04853b4..e4c196c 100644 --- a/sentry/projects.go +++ b/sentry/projects.go @@ -1,14 +1,13 @@ package sentry import ( - "net/http" + "context" + "fmt" "time" - - "github.com/dghubble/sling" ) // Project represents a Sentry project. -// https://github.com/getsentry/sentry/blob/9.0.0/src/sentry/api/serializers/models/project.py +// https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/project.py type Project struct { ID string `json:"id"` Slug string `json:"slug"` @@ -61,7 +60,6 @@ type Project struct { } // ProjectSummary represents the summary of a Sentry project. -// https://github.com/getsentry/sentry/blob/9.0.0/src/sentry/api/serializers/models/project.py#L258 type ProjectSummary struct { ID string `json:"id"` Name string `json:"name"` @@ -82,41 +80,48 @@ type ProjectSummary struct { } // ProjectSummaryTeam represents a team in a ProjectSummary. -// https://github.com/getsentry/sentry/blob/9.0.0/src/sentry/api/serializers/models/project.py#L223 type ProjectSummaryTeam struct { ID string `json:"id"` Name string `json:"name"` Slug string `json:"slug"` } -// ProjectService provides methods for accessing Sentry project API endpoints. +// ProjectsService provides methods for accessing Sentry project API endpoints. // https://docs.sentry.io/api/projects/ -type ProjectService struct { - sling *sling.Sling -} +type ProjectsService service -func newProjectService(sling *sling.Sling) *ProjectService { - return &ProjectService{ - sling: sling, +// List projects available. +// https://docs.sentry.io/api/projects/list-your-projects/ +func (s *ProjectsService) List(ctx context.Context) ([]*Project, *Response, error) { + u := "0/projects/" + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err } -} -// List projects available. -// https://docs.sentry.io/api/projects/get-project-index/ -func (s *ProjectService) List() ([]Project, *http.Response, error) { - projects := new([]Project) - apiError := new(APIError) - resp, err := s.sling.New().Get("projects/").Receive(projects, apiError) - return *projects, resp, relevantError(err, *apiError) + projects := []*Project{} + resp, err := s.client.Do(ctx, req, &projects) + if err != nil { + return nil, resp, err + } + return projects, resp, nil } // Get details on an individual project. -// https://docs.sentry.io/api/projects/get-project-details/ -func (s *ProjectService) Get(organizationSlug string, slug string) (*Project, *http.Response, error) { +// https://docs.sentry.io/api/projects/retrieve-a-project/ +func (s *ProjectsService) Get(ctx context.Context, organizationSlug string, slug string) (*Project, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/", organizationSlug, slug) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + project := new(Project) - apiError := new(APIError) - resp, err := s.sling.New().Get("projects/"+organizationSlug+"/"+slug+"/").Receive(project, apiError) - return project, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, project) + if err != nil { + return nil, resp, err + } + return project, resp, nil } // CreateProjectParams are the parameters for ProjectService.Create. @@ -127,12 +132,19 @@ type CreateProjectParams struct { } // Create a new project bound to a team. -// https://docs.sentry.io/api/teams/post-team-project-index/ -func (s *ProjectService) Create(organizationSlug string, teamSlug string, params *CreateProjectParams) (*Project, *http.Response, error) { +func (s *ProjectsService) Create(ctx context.Context, organizationSlug string, teamSlug string, params *CreateProjectParams) (*Project, *Response, error) { + u := fmt.Sprintf("0/teams/%v/%v/projects/", organizationSlug, teamSlug) + req, err := s.client.NewRequest("POST", u, params) + if err != nil { + return nil, nil, err + } + project := new(Project) - apiError := new(APIError) - resp, err := s.sling.New().Post("teams/"+organizationSlug+"/"+teamSlug+"/projects/").BodyJSON(params).Receive(project, apiError) - return project, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, project) + if err != nil { + return nil, resp, err + } + return project, resp, nil } // UpdateProjectParams are the parameters for ProjectService.Update. @@ -149,33 +161,57 @@ type UpdateProjectParams struct { } // Update various attributes and configurable settings for a given project. -// https://docs.sentry.io/api/projects/put-project-details/ -func (s *ProjectService) Update(organizationSlug string, slug string, params *UpdateProjectParams) (*Project, *http.Response, error) { +// https://docs.sentry.io/api/projects/update-a-project/ +func (s *ProjectsService) Update(ctx context.Context, organizationSlug string, slug string, params *UpdateProjectParams) (*Project, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/", organizationSlug, slug) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + project := new(Project) - apiError := new(APIError) - resp, err := s.sling.New().Put("projects/"+organizationSlug+"/"+slug+"/").BodyJSON(params).Receive(project, apiError) - return project, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, project) + if err != nil { + return nil, resp, err + } + return project, resp, nil } // Delete a project. -// https://docs.sentry.io/api/projects/delete-project-details/ -func (s *ProjectService) Delete(organizationSlug string, slug string) (*http.Response, error) { - apiError := new(APIError) - resp, err := s.sling.New().Delete("projects/"+organizationSlug+"/"+slug+"/").Receive(nil, apiError) - return resp, relevantError(err, *apiError) +// https://docs.sentry.io/api/projects/delete-a-project/ +func (s *ProjectsService) Delete(ctx context.Context, organizationSlug string, slug string) (*Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/", organizationSlug, slug) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) } // AddTeam add a team to a project. -func (s *ProjectService) AddTeam(organizationSlug string, slug string, teamSlug string) (*Project, *http.Response, error) { +func (s *ProjectsService) AddTeam(ctx context.Context, organizationSlug string, slug string, teamSlug string) (*Project, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/teams/%v/", organizationSlug, slug, teamSlug) + req, err := s.client.NewRequest("POST", u, nil) + if err != nil { + return nil, nil, err + } + project := new(Project) - apiError := new(APIError) - res, err := s.sling.New().Post("projects/"+organizationSlug+"/"+slug+"/teams/"+teamSlug+"/").Receive(project, apiError) - return project, res, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, project) + if err != nil { + return nil, resp, err + } + return project, resp, nil } // RemoveTeam remove a team from a project. -func (s *ProjectService) RemoveTeam(organizationSlug string, slug string, teamSlug string) (*http.Response, error) { - apiError := new(APIError) - res, err := s.sling.New().Delete("projects/"+organizationSlug+"/"+slug+"/teams/"+teamSlug+"/").Receive(nil, apiError) - return res, relevantError(err, *apiError) +func (s *ProjectsService) RemoveTeam(ctx context.Context, organizationSlug string, slug string, teamSlug string) (*Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/teams/%v/", organizationSlug, slug, teamSlug) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) } diff --git a/sentry/projects_test.go b/sentry/projects_test.go index 4040eac..5f4b5a8 100644 --- a/sentry/projects_test.go +++ b/sentry/projects_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "fmt" "net/http" "testing" @@ -8,9 +9,9 @@ import ( "github.com/stretchr/testify/assert" ) -func TestProjectService_List(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectsService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -137,8 +138,8 @@ func TestProjectService_List(t *testing.T) { ]`) }) - client := NewClient(httpClient, nil, "") - projects, _, err := client.Projects.List() + ctx := context.Background() + projects, _, err := client.Projects.List(ctx) assert.NoError(t, err) expectedOrganization := Organization{ @@ -154,7 +155,7 @@ func TestProjectService_List(t *testing.T) { Type: "letter_avatar", }, } - expected := []Project{ + expected := []*Project{ { ID: "4", Slug: "the-spoiled-yoghurt", @@ -216,9 +217,9 @@ func TestProjectService_List(t *testing.T) { assert.Equal(t, expected, projects) } -func TestProjectService_Get(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectsService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -359,8 +360,8 @@ func TestProjectService_Get(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") - project, _, err := client.Projects.Get("the-interstellar-jurisdiction", "pump-station") + ctx := context.Background() + project, _, err := client.Projects.Get(ctx, "the-interstellar-jurisdiction", "pump-station") assert.NoError(t, err) expected := &Project{ ID: "2", @@ -428,9 +429,9 @@ func TestProjectService_Get(t *testing.T) { assert.Equal(t, expected, project) } -func TestProjectService_Create(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectsService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/teams/the-interstellar-jurisdiction/powerful-abolitionist/projects/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "POST", r) @@ -460,11 +461,11 @@ func TestProjectService_Create(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &CreateProjectParams{ Name: "The Spoiled Yoghurt", } - project, _, err := client.Projects.Create("the-interstellar-jurisdiction", "powerful-abolitionist", params) + ctx := context.Background() + project, _, err := client.Projects.Create(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist", params) assert.NoError(t, err) expected := &Project{ @@ -484,9 +485,9 @@ func TestProjectService_Create(t *testing.T) { assert.Equal(t, expected, project) } -func TestProjectService_Update(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectsService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/plain-proxy/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "PUT", r) @@ -530,7 +531,6 @@ func TestProjectService_Update(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &UpdateProjectParams{ Name: "Plane Proxy", Slug: "plane-proxy", @@ -538,7 +538,8 @@ func TestProjectService_Update(t *testing.T) { "sentry:origins": "http://example.com\nhttp://example.invalid", }, } - project, _, err := client.Projects.Update("the-interstellar-jurisdiction", "plain-proxy", params) + ctx := context.Background() + project, _, err := client.Projects.Update(ctx, "the-interstellar-jurisdiction", "plain-proxy", params) assert.NoError(t, err) expected := &Project{ ID: "5", @@ -566,23 +567,23 @@ func TestProjectService_Update(t *testing.T) { assert.Equal(t, expected, project) } -func TestProjectService_Delete(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectsService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/plain-proxy/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "DELETE", r) }) - client := NewClient(httpClient, nil, "") - _, err := client.Projects.Delete("the-interstellar-jurisdiction", "plain-proxy") + ctx := context.Background() + _, err := client.Projects.Delete(ctx, "the-interstellar-jurisdiction", "plain-proxy") assert.NoError(t, err) } -func TestProjectService_UpdateTeam(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectsService_UpdateTeam(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/teams/planet-express/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "POST", r) @@ -599,8 +600,8 @@ func TestProjectService_UpdateTeam(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") - project, _, err := client.Projects.AddTeam("the-interstellar-jurisdiction", "pump-station", "planet-express") + ctx := context.Background() + project, _, err := client.Projects.AddTeam(ctx, "the-interstellar-jurisdiction", "pump-station", "planet-express") assert.NoError(t, err) expected := &Project{ ID: "5", @@ -611,15 +612,15 @@ func TestProjectService_UpdateTeam(t *testing.T) { assert.Equal(t, expected, project) } -func TestProjectService_DeleteTeam(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestProjectsService_DeleteTeam(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/teams/powerful-abolitionist/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "DELETE", r) }) - client := NewClient(httpClient, nil, "") - _, err := client.Projects.RemoveTeam("the-interstellar-jurisdiction", "pump-station", "powerful-abolitionist") + ctx := context.Background() + _, err := client.Projects.RemoveTeam(ctx, "the-interstellar-jurisdiction", "pump-station", "powerful-abolitionist") assert.NoError(t, err) } diff --git a/sentry/rules.go b/sentry/rules.go deleted file mode 100644 index dd93619..0000000 --- a/sentry/rules.go +++ /dev/null @@ -1,139 +0,0 @@ -package sentry - -import ( - "errors" - "net/http" - "time" - - "github.com/dghubble/sling" -) - -// Rule represents an alert rule configured for this project. -// https://github.com/getsentry/sentry/blob/9.0.0/src/sentry/api/serializers/models/rule.py -type Rule struct { - ID string `json:"id"` - ActionMatch string `json:"actionMatch"` - FilterMatch string `json:"filterMatch"` - Environment *string `json:"environment,omitempty"` - Frequency int `json:"frequency"` - Name string `json:"name"` - Conditions []ConditionType `json:"conditions"` - Actions []ActionType `json:"actions"` - Filters []FilterType `json:"filters"` - Created time.Time `json:"dateCreated"` - TaskUUID string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the rule -} - -// RuleTaskDetail represents the inline struct Sentry defines for task details -// https://github.com/getsentry/sentry/blob/7ce8f5a4bbc3429eef4b2e273148baf6525fede2/src/sentry/api/endpoints/project_rule_task_details.py#L29 -type RuleTaskDetail struct { - Status string `json:"status"` - Rule Rule `json:"rule"` - Error string `json:"error"` -} - -// RuleService provides methods for accessing Sentry project -// client key API endpoints. -// https://docs.sentry.io/api/projects/ -type RuleService struct { - sling *sling.Sling -} - -func newRuleService(sling *sling.Sling) *RuleService { - return &RuleService{ - sling: sling, - } -} - -// List alert rules configured for a project. -func (s *RuleService) List(organizationSlug string, projectSlug string) ([]Rule, *http.Response, error) { - rules := new([]Rule) - apiError := new(APIError) - resp, err := s.sling.New().Get("projects/"+organizationSlug+"/"+projectSlug+"/rules/").Receive(rules, apiError) - return *rules, resp, relevantError(err, *apiError) -} - -// ConditionType for defining conditions. -type ConditionType map[string]interface{} - -// ActionType for defining actions. -type ActionType map[string]interface{} - -// FilterType for defining actions. -type FilterType map[string]interface{} - -// CreateRuleParams are the parameters for RuleService.Create. -type CreateRuleParams struct { - ActionMatch string `json:"actionMatch"` - FilterMatch string `json:"filterMatch"` - Environment string `json:"environment,omitempty"` - Frequency int `json:"frequency"` - Name string `json:"name"` - Conditions []ConditionType `json:"conditions"` - Actions []ActionType `json:"actions"` - Filters []FilterType `json:"filters"` -} - -// CreateRuleActionParams models the actions when creating the action for the rule. -type CreateRuleActionParams struct { - ID string `json:"id"` - Tags string `json:"tags"` - Channel string `json:"channel"` - Workspace string `json:"workspace"` -} - -// CreateRuleConditionParams models the conditions when creating the action for the rule. -type CreateRuleConditionParams struct { - ID string `json:"id"` - Interval string `json:"interval"` - Value int `json:"value"` - Level int `json:"level"` - Match string `json:"match"` -} - -// Create a new alert rule bound to a project. -func (s *RuleService) Create(organizationSlug string, projectSlug string, params *CreateRuleParams) (*Rule, *http.Response, error) { - rule := new(Rule) - apiError := new(APIError) - resp, err := s.sling.New().Post("projects/"+organizationSlug+"/"+projectSlug+"/rules/").BodyJSON(params).Receive(rule, apiError) - if resp.StatusCode == 202 { - // We just received a reference to an async task, we need to check another endpoint to retrieve the rule we created - return s.getRuleFromTaskDetail(organizationSlug, projectSlug, rule.TaskUUID) - } - return rule, resp, relevantError(err, *apiError) -} - -// getRuleFromTaskDetail is called when Sentry offloads the rule creation process to an async task and sends us back the task's uuid. -// It usually doesn't happen, but when creating Slack notification rules, it seemed to be sometimes the case. During testing it -// took very long for a task to finish (10+ seconds) which is why this method can take long to return. -func (s *RuleService) getRuleFromTaskDetail(organizationSlug string, projectSlug string, taskUuid string) (*Rule, *http.Response, error) { - taskDetail := &RuleTaskDetail{} - var resp *http.Response - for i := 0; i < 5; i++ { - time.Sleep(5 * time.Second) - resp, err := s.sling.New().Get("projects/" + organizationSlug + "/" + projectSlug + "/rule-task/" + taskUuid + "/").ReceiveSuccess(taskDetail) - if taskDetail.Status == "success" { - return &taskDetail.Rule, resp, err - } else if taskDetail.Status == "failed" { - return &taskDetail.Rule, resp, errors.New(taskDetail.Error) - } else if resp.StatusCode == 404 { - return &Rule{}, resp, errors.New("couldn't find the rule creation task for uuid '" + taskUuid + "' in Sentry (HTTP 404)") - } - } - return &Rule{}, resp, errors.New("getting the status of the rule creation from Sentry took too long") -} - -// Update a rule. -func (s *RuleService) Update(organizationSlug string, projectSlug string, ruleID string, params *Rule) (*Rule, *http.Response, error) { - rule := new(Rule) - apiError := new(APIError) - resp, err := s.sling.New().Put("projects/"+organizationSlug+"/"+projectSlug+"/rules/"+ruleID+"/").BodyJSON(params).Receive(rule, apiError) - return rule, resp, relevantError(err, *apiError) -} - -// Delete a rule. -func (s *RuleService) Delete(organizationSlug string, projectSlug string, ruleID string) (*http.Response, error) { - apiError := new(APIError) - resp, err := s.sling.New().Delete("projects/"+organizationSlug+"/"+projectSlug+"/rules/"+ruleID+"/").Receive(nil, apiError) - return resp, relevantError(err, *apiError) -} diff --git a/sentry/sentry.go b/sentry/sentry.go index ef6dc9b..535a80b 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -1,72 +1,249 @@ package sentry import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" "net/http" "net/url" + "reflect" "strings" "github.com/dghubble/sling" + "github.com/google/go-querystring/query" ) -// public constants for external use const ( - DefaultBaseURL = "https://sentry.io/api/" - APIVersion = "0" + defaultBaseURL = "https://sentry.io/api/" + userAgent = "go-sentry" + + APIVersion = "0" ) -// Client for sentry api +var errNonNilContext = errors.New("context must be non-nil") + +// Client for Sentry API. type Client struct { - sling *sling.Sling - Organizations *OrganizationService - OrganizationMembers *OrganizationMemberService - Teams *TeamService - Projects *ProjectService - ProjectKeys *ProjectKeyService - ProjectPlugins *ProjectPluginService - Rules *RuleService - AlertRules *AlertRuleService - Ownership *ProjectOwnershipService + client *http.Client + + // BaseURL for API requests. + BaseURL *url.URL + + // User agent used when communicating with Sentry. + UserAgent string + + // TODO: Remove sling + sling *sling.Sling + + // Common struct used by all services. + common service + + // Services + IssueAlerts *IssueAlertsService + MetricAlerts *MetricAlertsService + OrganizationMembers *OrganizationMembersService + Organizations *OrganizationsService + ProjectKeys *ProjectKeysService + ProjectOwnerships *ProjectOwnershipsService + ProjectPlugins *ProjectPluginsService + Projects *ProjectsService + Teams *TeamsService +} + +type service struct { + client *Client } // NewClient returns a new Sentry API client. -// If a nil httpClient is given, the http.DefaultClient will be used. -// If a nil baseURL is given, the DefaultBaseURL will be used. -// If the baseURL does not have the suffix "/api/", it will be added automatically. -func NewClient(httpClient *http.Client, baseURL *url.URL, token string) *Client { +// If a nil httpClient is provided, the http.DefaultClient will be used. +func NewClient(httpClient *http.Client) *Client { if httpClient == nil { httpClient = http.DefaultClient } + baseURL, _ := url.Parse(defaultBaseURL) + + base := sling.New().Client(httpClient) + + c := &Client{ + sling: base, - if baseURL == nil { - baseURL, _ = url.Parse(DefaultBaseURL) + client: httpClient, + BaseURL: baseURL, } - if !strings.HasSuffix(baseURL.Path, "/") { - baseURL.Path += "/" + c.common.client = c + c.IssueAlerts = (*IssueAlertsService)(&c.common) + c.MetricAlerts = (*MetricAlertsService)(&c.common) + c.OrganizationMembers = (*OrganizationMembersService)(&c.common) + c.Organizations = (*OrganizationsService)(&c.common) + c.ProjectKeys = (*ProjectKeysService)(&c.common) + c.ProjectOwnerships = (*ProjectOwnershipsService)(&c.common) + c.ProjectPlugins = (*ProjectPluginsService)(&c.common) + c.Projects = (*ProjectsService)(&c.common) + c.Teams = (*TeamsService)(&c.common) + return c +} + +// NewOnPremiseClient returns a new Sentry API client with the provided base URL. +// Note that the base URL must be in the format "http(s)://[hostname]/api/". +// If the base URL does not have the suffix "/api/", it will be added automatically. +// If a nil httpClient is provided, the http.DefaultClient will be used. +func NewOnPremiseClient(baseURL string, httpClient *http.Client) (*Client, error) { + baseEndpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err } - if !strings.HasSuffix(baseURL.Path, "/api/") { - baseURL.Path += "api/" + + if !strings.HasSuffix(baseEndpoint.Path, "/") { + baseEndpoint.Path += "/" + } + if !strings.HasSuffix(baseEndpoint.Path, "/api/") { + baseEndpoint.Path += "api/" } - baseURL.Path += APIVersion + "/" - base := sling.New().Base(baseURL.String()).Client(httpClient) + c := NewClient(httpClient) + c.BaseURL = baseEndpoint + return c, nil +} - if token != "" { - base.Add("Authorization", "Bearer "+token) +func addQuery(s string, params interface{}) (string, error) { + v := reflect.ValueOf(params) + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil } - c := &Client{ - sling: base, - Organizations: newOrganizationService(base.New()), - OrganizationMembers: newOrganizationMemberService(base.New()), - Teams: newTeamService(base.New()), - Projects: newProjectService(base.New()), - ProjectKeys: newProjectKeyService(base.New()), - ProjectPlugins: newProjectPluginService(base.New()), - Rules: newRuleService(base.New()), - AlertRules: newAlertRuleService(base.New()), - Ownership: newProjectOwnershipService(base.New()), + u, err := url.Parse(s) + if err != nil { + return s, err } - return c + + qs, err := query.Values(params) + if err != nil { + return s, err + } + + u.RawQuery = qs.Encode() + return u.String(), nil +} + +// NewRequest creates an API request. +func (c *Client) NewRequest(method, urlRef string, body interface{}) (*http.Request, error) { + if !strings.HasSuffix(c.BaseURL.Path, "/") { + return nil, fmt.Errorf("BaseURL must have a trailing slash, but %q does not", c.BaseURL) + } + + u, err := c.BaseURL.Parse(urlRef) + if err != nil { + return nil, err + } + + var buf io.ReadWriter + if body != nil { + buf = &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + err := enc.Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest(method, u.String(), buf) + if err != nil { + return nil, err + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } + return req, nil +} + +// Response is a Sentry API response. This wraps the standard http.Response +// and provides convenient access to things like pagination links and rate limits. +type Response struct { + *http.Response + + // TODO: Parse rate limit +} + +func newResponse(r *http.Response) *Response { + response := &Response{Response: r} + return response +} + +func (c *Client) BareDo(ctx context.Context, req *http.Request) (*Response, error) { + if ctx == nil { + return nil, errNonNilContext + } + + resp, err := c.client.Do(req) + if err != nil { + // If we got an error, and the context has been canceled, + // the context's error is probably more useful. + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + return nil, err + } + } + + response := newResponse(resp) + + err = CheckResponse(resp) + return response, err +} + +func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Response, error) { + resp, err := c.BareDo(ctx, req) + if err != nil { + return resp, err + } + defer resp.Body.Close() + + switch v := v.(type) { + case nil: + case io.Writer: + _, err = io.Copy(v, resp.Body) + default: + decErr := json.NewDecoder(resp.Body).Decode(v) + if decErr == io.EOF { + decErr = nil + } + if decErr != nil { + err = decErr + } + } + return resp, err +} + +type ErrorResponse struct { + Response *http.Response + Detail string `json:"detail"` +} + +func (r *ErrorResponse) Error() string { + return fmt.Sprintf( + "%v %v: %d %v", + r.Response.Request.Method, r.Response.Request.URL, + r.Response.StatusCode, r.Detail) +} + +func CheckResponse(r *http.Response) error { + if c := r.StatusCode; 200 <= c && c <= 299 { + return nil + } + + errorResponse := &ErrorResponse{Response: r} + // TODO: Handle API errors + + return errorResponse } // Avatar represents an avatar. diff --git a/sentry/sentry_test.go b/sentry/sentry_test.go index d323f67..5aeb7e7 100644 --- a/sentry/sentry_test.go +++ b/sentry/sentry_test.go @@ -13,19 +13,13 @@ import ( "github.com/stretchr/testify/assert" ) -// testServer returns an http Client, ServeMux, and Server. The client proxies -// requests to the server and handlers can be registered on the mux to handle -// requests. The caller must close the test server. -func testServer() (*http.Client, *http.ServeMux, *httptest.Server) { - mux := http.NewServeMux() +func setup() (client *Client, mux *http.ServeMux, serverURL string, teardown func()) { + mux = http.NewServeMux() server := httptest.NewServer(mux) - transport := &RewriteTransport{&http.Transport{ - Proxy: func(req *http.Request) (*url.URL, error) { - return url.Parse(server.URL) - }, - }} - client := &http.Client{Transport: transport} - return client, mux, server + client = NewClient(nil) + url, _ := url.Parse(server.URL + "/api/") + client.BaseURL = url + return client, mux, server.URL, server.Close } // RewriteTransport rewrites https requests to http to avoid TLS cert issues @@ -91,13 +85,12 @@ func mustParseTime(value string) time.Time { } func TestNewClient(t *testing.T) { - c := NewClient(nil, nil, "") - req, _ := c.sling.New().Request() + c := NewClient(nil) - assert.Equal(t, "https://sentry.io/api/0/", req.URL.String()) + assert.Equal(t, "https://sentry.io/api/", c.BaseURL.String()) } -func TestNewClient_withBaseURL(t *testing.T) { +func TestNewOnPremiseClient(t *testing.T) { testCases := []struct { baseURL string }{ @@ -108,12 +101,10 @@ func TestNewClient_withBaseURL(t *testing.T) { } for _, tc := range testCases { t.Run(tc.baseURL, func(t *testing.T) { - baseURL, _ := url.Parse(tc.baseURL) + c, err := NewOnPremiseClient(tc.baseURL, nil) - c := NewClient(nil, baseURL, "") - req, _ := c.sling.New().Request() - - assert.Equal(t, "https://example.com/api/0/", req.URL.String()) + assert.NoError(t, err) + assert.Equal(t, "https://example.com/api/", c.BaseURL.String()) }) } diff --git a/sentry/teams.go b/sentry/teams.go index c620be7..d6e6dc8 100644 --- a/sentry/teams.go +++ b/sentry/teams.go @@ -1,10 +1,9 @@ package sentry import ( - "net/http" + "context" + "fmt" "time" - - "github.com/dghubble/sling" ) // Team represents a Sentry team that is bound to an organization. @@ -24,34 +23,42 @@ type Team struct { // TODO: projects } -// TeamService provides methods for accessing Sentry team API endpoints. +// TeamsService provides methods for accessing Sentry team API endpoints. // https://docs.sentry.io/api/teams/ -type TeamService struct { - sling *sling.Sling -} +type TeamsService service -func newTeamService(sling *sling.Sling) *TeamService { - return &TeamService{ - sling: sling, +// List returns a list of teams bound to an organization. +// https://docs.sentry.io/api/teams/list-an-organizations-teams/ +func (s *TeamsService) List(ctx context.Context, organizationSlug string) ([]*Team, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/teams/", organizationSlug) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err } -} -// List returns a list of teams bound to an organization. -// https://docs.sentry.io/api/teams/get-organization-teams/ -func (s *TeamService) List(organizationSlug string) ([]Team, *http.Response, error) { - teams := new([]Team) - apiError := new(APIError) - resp, err := s.sling.New().Get("organizations/"+organizationSlug+"/teams/").Receive(teams, apiError) - return *teams, resp, relevantError(err, *apiError) + teams := []*Team{} + resp, err := s.client.Do(ctx, req, &teams) + if err != nil { + return nil, resp, err + } + return teams, resp, nil } // Get details on an individual team of an organization. // https://docs.sentry.io/api/teams/retrieve-a-team/ -func (s *TeamService) Get(organizationSlug string, slug string) (*Team, *http.Response, error) { +func (s *TeamsService) Get(ctx context.Context, organizationSlug string, slug string) (*Team, *Response, error) { + u := fmt.Sprintf("0/teams/%v/%v/", organizationSlug, slug) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + team := new(Team) - apiError := new(APIError) - resp, err := s.sling.New().Get("teams/"+organizationSlug+"/"+slug+"/").Receive(team, apiError) - return team, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, team) + if err != nil { + return nil, resp, err + } + return team, resp, nil } // CreateTeamParams are the parameters for TeamService.Create. @@ -62,11 +69,19 @@ type CreateTeamParams struct { // Create a new Sentry team bound to an organization. // https://docs.sentry.io/api/teams/create-a-new-team/ -func (s *TeamService) Create(organizationSlug string, params *CreateTeamParams) (*Team, *http.Response, error) { +func (s *TeamsService) Create(ctx context.Context, organizationSlug string, params *CreateTeamParams) (*Team, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/teams/", organizationSlug) + req, err := s.client.NewRequest("POST", u, params) + if err != nil { + return nil, nil, err + } + team := new(Team) - apiError := new(APIError) - resp, err := s.sling.New().Post("organizations/"+organizationSlug+"/teams/").BodyJSON(params).Receive(team, apiError) - return team, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, team) + if err != nil { + return nil, resp, err + } + return team, resp, nil } // UpdateTeamParams are the parameters for TeamService.Update. @@ -77,17 +92,29 @@ type UpdateTeamParams struct { // Update settings for a given team. // https://docs.sentry.io/api/teams/update-a-team/ -func (s *TeamService) Update(organizationSlug string, slug string, params *UpdateTeamParams) (*Team, *http.Response, error) { +func (s *TeamsService) Update(ctx context.Context, organizationSlug string, slug string, params *UpdateTeamParams) (*Team, *Response, error) { + u := fmt.Sprintf("0/teams/%v/%v/", organizationSlug, slug) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + team := new(Team) - apiError := new(APIError) - resp, err := s.sling.New().Put("teams/"+organizationSlug+"/"+slug+"/").BodyJSON(params).Receive(team, apiError) - return team, resp, relevantError(err, *apiError) + resp, err := s.client.Do(ctx, req, team) + if err != nil { + return nil, resp, err + } + return team, resp, nil } // Delete a team. // https://docs.sentry.io/api/teams/update-a-team/ -func (s *TeamService) Delete(organizationSlug string, slug string) (*http.Response, error) { - apiError := new(APIError) - resp, err := s.sling.New().Delete("teams/"+organizationSlug+"/"+slug+"/").Receive(nil, apiError) - return resp, relevantError(err, *apiError) +func (s *TeamsService) Delete(ctx context.Context, organizationSlug string, slug string) (*Response, error) { + u := fmt.Sprintf("0/teams/%v/%v/", organizationSlug, slug) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) } diff --git a/sentry/teams_test.go b/sentry/teams_test.go index e1df726..8e30917 100644 --- a/sentry/teams_test.go +++ b/sentry/teams_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "fmt" "net/http" "testing" @@ -8,9 +9,9 @@ import ( "github.com/stretchr/testify/assert" ) -func TestTeamService_List(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestTeamsService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/teams/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -116,11 +117,11 @@ func TestTeamService_List(t *testing.T) { ]`) }) - client := NewClient(httpClient, nil, "") - teams, _, err := client.Teams.List("the-interstellar-jurisdiction") + ctx := context.Background() + teams, _, err := client.Teams.List(ctx, "the-interstellar-jurisdiction") assert.NoError(t, err) - expected := []Team{ + expected := []*Team{ { ID: "3", Slug: "ancient-gabelers", @@ -153,9 +154,9 @@ func TestTeamService_List(t *testing.T) { assert.Equal(t, expected, teams) } -func TestTeamService_Get(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestTeamsService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/teams/the-interstellar-jurisdiction/powerful-abolitionist/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "GET", r) @@ -182,8 +183,8 @@ func TestTeamService_Get(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") - team, _, err := client.Teams.Get("the-interstellar-jurisdiction", "powerful-abolitionist") + ctx := context.Background() + team, _, err := client.Teams.Get(ctx, "the-interstellar-jurisdiction", "powerful-abolitionist") assert.NoError(t, err) expected := &Team{ @@ -198,9 +199,9 @@ func TestTeamService_Get(t *testing.T) { assert.Equal(t, expected, team) } -func TestTeamService_Create(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestTeamsService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/teams/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "POST", r) @@ -219,11 +220,11 @@ func TestTeamService_Create(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &CreateTeamParams{ Name: "Ancient Gabelers", } - team, _, err := client.Teams.Create("the-interstellar-jurisdiction", params) + ctx := context.Background() + team, _, err := client.Teams.Create(ctx, "the-interstellar-jurisdiction", params) assert.NoError(t, err) expected := &Team{ @@ -238,9 +239,9 @@ func TestTeamService_Create(t *testing.T) { assert.Equal(t, expected, team) } -func TestTeamService_Update(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestTeamsService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/teams/the-interstellar-jurisdiction/the-obese-philosophers/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "PUT", r) @@ -259,11 +260,11 @@ func TestTeamService_Update(t *testing.T) { }`) }) - client := NewClient(httpClient, nil, "") params := &UpdateTeamParams{ Name: "The Inflated Philosophers", } - team, _, err := client.Teams.Update("the-interstellar-jurisdiction", "the-obese-philosophers", params) + ctx := context.Background() + team, _, err := client.Teams.Update(ctx, "the-interstellar-jurisdiction", "the-obese-philosophers", params) assert.NoError(t, err) expected := &Team{ ID: "4", @@ -277,16 +278,16 @@ func TestTeamService_Update(t *testing.T) { assert.Equal(t, expected, team) } -func TestTeamService_Delete(t *testing.T) { - httpClient, mux, server := testServer() - defer server.Close() +func TestTeamsService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() mux.HandleFunc("/api/0/teams/the-interstellar-jurisdiction/the-obese-philosophers/", func(w http.ResponseWriter, r *http.Request) { assertMethod(t, "DELETE", r) }) - client := NewClient(httpClient, nil, "") - _, err := client.Teams.Delete("the-interstellar-jurisdiction", "the-obese-philosophers") + ctx := context.Background() + _, err := client.Teams.Delete(ctx, "the-interstellar-jurisdiction", "the-obese-philosophers") assert.NoError(t, err) } diff --git a/sentry/users.go b/sentry/users.go index 054640b..7f5dbe7 100644 --- a/sentry/users.go +++ b/sentry/users.go @@ -19,15 +19,10 @@ type User struct { LastActive time.Time `json:"lastActive"` IsSuperuser bool `json:"isSuperuser"` IsStaff bool `json:"isStaff"` - Avatar UserAvatar `json:"avatar"` + Avatar Avatar `json:"avatar"` Emails []UserEmail `json:"emails"` } -type UserAvatar struct { - AvatarType string `json:"avatarType"` - AvatarUUID *string `json:"avatarUuid"` -} - type UserEmail struct { ID string `json:"id"` Email string `json:"email"` diff --git a/sentry/users_test.go b/sentry/users_test.go index 60970ae..2e57457 100644 --- a/sentry/users_test.go +++ b/sentry/users_test.go @@ -66,9 +66,9 @@ func TestUserUnmarshal(t *testing.T) { LastActive: mustParseTime("2020-01-03T00:00:00.000000Z"), IsSuperuser: false, IsStaff: false, - Avatar: UserAvatar{ - AvatarType: "letter_avatar", - AvatarUUID: nil, + Avatar: Avatar{ + Type: "letter_avatar", + UUID: nil, }, Emails: []UserEmail{ { From ed1c1439d5810bc58ecb2a2ee84e6a21aaaccc74 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 7 Jun 2022 02:07:07 +0100 Subject: [PATCH 19/40] Fix go get path in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 582eeb4..822b435 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Go library for accessing the [Sentry Web API](https://docs.sentry.io/api/). go-sentry is compatible with modern Go releases in module mode, with Go installed: ```sh -go get github.com/jianyuan/go-sentry/sentry/v2 +go get github.com/jianyuan/go-sentry/v2/sentry ``` ## Code structure From 2aeb482337ddcdb4db5e769e45a749ea8ef19985 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 7 Jun 2022 02:30:34 +0100 Subject: [PATCH 20/40] Populate pagination cursor (#49) --- go.mod | 1 + go.sum | 2 ++ sentry/organization_members.go | 7 +------ sentry/organization_members_test.go | 2 +- sentry/organizations.go | 7 +------ sentry/organizations_test.go | 2 +- sentry/project_keys.go | 7 +------ sentry/project_keys_test.go | 3 +-- sentry/sentry.go | 20 ++++++++++++++++++++ sentry/sentry_test.go | 26 ++++++++++++++++++++++++++ 10 files changed, 55 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index 12362e2..02a61c2 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/peterhellberg/link v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cba8f49..bcdb397 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/peterhellberg/link v1.1.0 h1:s2+RH8EGuI/mI4QwrWGSYQCRz7uNgip9BaM04HKu5kc= +github.com/peterhellberg/link v1.1.0/go.mod h1:gtSlOT4jmkY8P47hbTc8PTgiDDWpdPbFYl75keYyBB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/sentry/organization_members.go b/sentry/organization_members.go index fbb27ba..fd58548 100644 --- a/sentry/organization_members.go +++ b/sentry/organization_members.go @@ -35,13 +35,8 @@ const ( // OrganizationMembersService provides methods for accessing Sentry membership API endpoints. type OrganizationMembersService service -// ListOrganizationMemberParams are the parameters for OrganizationMemberService.List. -type ListOrganizationMemberParams struct { - Cursor string `url:"cursor,omitempty"` -} - // List organization members. -func (s *OrganizationMembersService) List(ctx context.Context, organizationSlug string, params *ListOrganizationMemberParams) ([]*OrganizationMember, *Response, error) { +func (s *OrganizationMembersService) List(ctx context.Context, organizationSlug string, params *ListCursorParams) ([]*OrganizationMember, *Response, error) { u, err := addQuery(fmt.Sprintf("0/organizations/%v/members/", organizationSlug), params) if err != nil { return nil, nil, err diff --git a/sentry/organization_members_test.go b/sentry/organization_members_test.go index bb39662..b4bc7fe 100644 --- a/sentry/organization_members_test.go +++ b/sentry/organization_members_test.go @@ -76,7 +76,7 @@ func TestOrganizationMembersService_List(t *testing.T) { }) ctx := context.Background() - members, _, err := client.OrganizationMembers.List(ctx, "the-interstellar-jurisdiction", &ListOrganizationMemberParams{ + members, _, err := client.OrganizationMembers.List(ctx, "the-interstellar-jurisdiction", &ListCursorParams{ Cursor: "100:-1:1", }) assert.NoError(t, err) diff --git a/sentry/organizations.go b/sentry/organizations.go index c2b7faa..c91a55e 100644 --- a/sentry/organizations.go +++ b/sentry/organizations.go @@ -90,14 +90,9 @@ type DetailedOrganization struct { // https://docs.sentry.io/api/organizations/ type OrganizationsService service -// ListOrganizationParams are the parameters for OrganizationService.List. -type ListOrganizationParams struct { - Cursor string `url:"cursor,omitempty"` -} - // List organizations available to the authenticated session. // https://docs.sentry.io/api/organizations/list-your-organizations/ -func (s *OrganizationsService) List(ctx context.Context, params *ListOrganizationParams) ([]*Organization, *Response, error) { +func (s *OrganizationsService) List(ctx context.Context, params *ListCursorParams) ([]*Organization, *Response, error) { u, err := addQuery("0/organizations/", params) if err != nil { return nil, nil, err diff --git a/sentry/organizations_test.go b/sentry/organizations_test.go index efeb65e..64677c5 100644 --- a/sentry/organizations_test.go +++ b/sentry/organizations_test.go @@ -33,7 +33,7 @@ func TestOrganizationsService_List(t *testing.T) { ]`) }) - params := &ListOrganizationParams{Cursor: cursor} + params := &ListCursorParams{Cursor: cursor} ctx := context.Background() orgs, _, err := client.Organizations.List(ctx, params) assert.NoError(t, err) diff --git a/sentry/project_keys.go b/sentry/project_keys.go index 3c161ec..ea9bb38 100644 --- a/sentry/project_keys.go +++ b/sentry/project_keys.go @@ -42,14 +42,9 @@ type ProjectKey struct { // https://docs.sentry.io/api/projects/ type ProjectKeysService service -// ListProjectKeyParams are the parameters for OrganizationService.List. -type ListProjectKeyParams struct { - Cursor string `url:"cursor,omitempty"` -} - // List client keys bound to a project. // https://docs.sentry.io/api/projects/get-project-keys/ -func (s *ProjectKeysService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ListProjectKeyParams) ([]*ProjectKey, *Response, error) { +func (s *ProjectKeysService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ListCursorParams) ([]*ProjectKey, *Response, error) { u, err := addQuery(fmt.Sprintf("0/projects/%v/%v/keys/", organizationSlug, projectSlug), params) if err != nil { return nil, nil, err diff --git a/sentry/project_keys_test.go b/sentry/project_keys_test.go index 24e5423..05dd642 100644 --- a/sentry/project_keys_test.go +++ b/sentry/project_keys_test.go @@ -52,9 +52,8 @@ func TestProjectKeysService_List(t *testing.T) { }]`) }) - params := &ListProjectKeyParams{} ctx := context.Background() - projectKeys, _, err := client.ProjectKeys.List(ctx, "the-interstellar-jurisdiction", "pump-station", params) + projectKeys, _, err := client.ProjectKeys.List(ctx, "the-interstellar-jurisdiction", "pump-station", nil) assert.NoError(t, err) expected := []*ProjectKey{ diff --git a/sentry/sentry.go b/sentry/sentry.go index 535a80b..b331e84 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -14,6 +14,7 @@ import ( "github.com/dghubble/sling" "github.com/google/go-querystring/query" + "github.com/peterhellberg/link" ) const ( @@ -108,6 +109,12 @@ func NewOnPremiseClient(baseURL string, httpClient *http.Client) (*Client, error return c, nil } +type ListCursorParams struct { + // A cursor, as given in the Link header. + // If specified, the query continues the search using this cursor. + Cursor string `url:"cursor,omitempty"` +} + func addQuery(s string, params interface{}) (string, error) { v := reflect.ValueOf(params) if v.Kind() == reflect.Ptr && v.IsNil() { @@ -169,14 +176,27 @@ func (c *Client) NewRequest(method, urlRef string, body interface{}) (*http.Requ type Response struct { *http.Response + // For APIs that support cursor pagination, the following field will be populated + // to point to the next page if more results are available. + // Set ListCursorParams.Cursor to this value when calling the endpoint again. + Cursor string + // TODO: Parse rate limit } func newResponse(r *http.Response) *Response { response := &Response{Response: r} + response.populatePaginationCursor() return response } +func (r *Response) populatePaginationCursor() { + rels := link.ParseResponse(r.Response) + if nextRel, ok := rels["next"]; ok && nextRel.Extra["results"] == "true" { + r.Cursor = nextRel.Extra["cursor"] + } +} + func (c *Client) BareDo(ctx context.Context, req *http.Request) (*Response, error) { if ctx == nil { return nil, errNonNilContext diff --git a/sentry/sentry_test.go b/sentry/sentry_test.go index 5aeb7e7..565ad9a 100644 --- a/sentry/sentry_test.go +++ b/sentry/sentry_test.go @@ -109,3 +109,29 @@ func TestNewOnPremiseClient(t *testing.T) { } } + +func TestResponse_populatePaginationCursor_hasNextResults(t *testing.T) { + r := &http.Response{ + Header: http.Header{ + "Link": {`; rel="previous"; results="false"; cursor="100:-1:1", ` + + `; rel="next"; results="true"; cursor="100:1:0"`, + }, + }, + } + + response := newResponse(r) + assert.Equal(t, response.Cursor, "100:1:0") +} + +func TestResponse_populatePaginationCursor_noNextResults(t *testing.T) { + r := &http.Response{ + Header: http.Header{ + "Link": {`; rel="previous"; results="false"; cursor="100:-1:1", ` + + `; rel="next"; results="false"; cursor="100:1:0"`, + }, + }, + } + + response := newResponse(r) + assert.Equal(t, response.Cursor, "") +} From b32f1df34d4d3d589232d35be3c384cfc8a9c682 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 7 Jun 2022 14:19:03 +0100 Subject: [PATCH 21/40] Parse rate limit headers (#51) --- sentry/sentry.go | 54 +++++++++++++++++++++++++++++++++++++++++-- sentry/sentry_test.go | 24 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/sentry/sentry.go b/sentry/sentry.go index b331e84..5e9cbc7 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -10,7 +10,9 @@ import ( "net/http" "net/url" "reflect" + "strconv" "strings" + "time" "github.com/dghubble/sling" "github.com/google/go-querystring/query" @@ -21,7 +23,12 @@ const ( defaultBaseURL = "https://sentry.io/api/" userAgent = "go-sentry" - APIVersion = "0" + // https://docs.sentry.io/api/ratelimits/ + headerRateLimit = "X-Sentry-Rate-Limit-Limit" + headerRateRemaining = "X-Sentry-Rate-Limit-Remaining" + headerRateReset = "X-Sentry-Rate-Limit-Reset" + headerRateConcurrentLimit = "X-Sentry-Rate-Limit-ConcurrentLimit" + headerRateConcurrentRemaining = "X-Sentry-Rate-Limit-ConcurrentRemaining" ) var errNonNilContext = errors.New("context must be non-nil") @@ -181,11 +188,12 @@ type Response struct { // Set ListCursorParams.Cursor to this value when calling the endpoint again. Cursor string - // TODO: Parse rate limit + Rate Rate } func newResponse(r *http.Response) *Response { response := &Response{Response: r} + response.Rate = parseRate(r) response.populatePaginationCursor() return response } @@ -197,6 +205,30 @@ func (r *Response) populatePaginationCursor() { } } +// parseRate parses the rate limit headers. +func parseRate(r *http.Response) Rate { + var rate Rate + if limit := r.Header.Get(headerRateLimit); limit != "" { + rate.Limit, _ = strconv.Atoi(limit) + } + if remaining := r.Header.Get(headerRateRemaining); remaining != "" { + rate.Remaining, _ = strconv.Atoi(remaining) + } + if reset := r.Header.Get(headerRateReset); reset != "" { + if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 { + rate.Reset = time.Unix(v, 0).UTC() + } + } + if concurrentLimit := r.Header.Get(headerRateConcurrentLimit); concurrentLimit != "" { + rate.ConcurrentLimit, _ = strconv.Atoi(concurrentLimit) + } + if concurrentRemaining := r.Header.Get(headerRateConcurrentRemaining); concurrentRemaining != "" { + rate.ConcurrentRemaining, _ = strconv.Atoi(concurrentRemaining) + } + + return rate +} + func (c *Client) BareDo(ctx context.Context, req *http.Request) (*Response, error) { if ctx == nil { return nil, errNonNilContext @@ -266,6 +298,24 @@ func CheckResponse(r *http.Response) error { return errorResponse } +// Rate represents the rate limit for the current client. +type Rate struct { + // The maximum number of requests allowed within the window. + Limit int + + // The number of requests this caller has left on this endpoint within the current window + Remaining int + + // The time when the next rate limit window begins and the count resets, measured in UTC seconds from epoch + Reset time.Time + + // The maximum number of concurrent requests allowed within the window + ConcurrentLimit int + + // The number of concurrent requests this caller has left on this endpoint within the current window + ConcurrentRemaining int +} + // Avatar represents an avatar. type Avatar struct { UUID *string `json:"avatarUuid"` diff --git a/sentry/sentry_test.go b/sentry/sentry_test.go index 565ad9a..c84594a 100644 --- a/sentry/sentry_test.go +++ b/sentry/sentry_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "fmt" "net/http" "net/http/httptest" @@ -135,3 +136,26 @@ func TestResponse_populatePaginationCursor_noNextResults(t *testing.T) { response := newResponse(r) assert.Equal(t, response.Cursor, "") } + +func TestDo_rateLimit(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(headerRateLimit, "40") + w.Header().Set(headerRateRemaining, "39") + w.Header().Set(headerRateReset, "1654566542") + w.Header().Set(headerRateConcurrentLimit, "25") + w.Header().Set(headerRateConcurrentRemaining, "24") + }) + + req, _ := client.NewRequest("GET", "/", nil) + ctx := context.Background() + resp, err := client.Do(ctx, req, nil) + assert.NoError(t, err) + assert.Equal(t, resp.Rate.Limit, 40) + assert.Equal(t, resp.Rate.Remaining, 39) + assert.Equal(t, resp.Rate.Reset, time.Date(2022, time.June, 7, 1, 49, 2, 0, time.UTC)) + assert.Equal(t, resp.Rate.ConcurrentLimit, 25) + assert.Equal(t, resp.Rate.ConcurrentRemaining, 24) +} From ef838949ae4c3b3b9f7392da8634127a0d7c16f0 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 7 Jun 2022 18:53:44 +0100 Subject: [PATCH 22/40] Update Go Reference link in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 822b435..a29cac1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# go-sentry [![Go Reference](https://pkg.go.dev/badge/github.com/jianyuan/go-sentry/v2.svg)](https://pkg.go.dev/github.com/jianyuan/go-sentry/v2) +# go-sentry [![Go Reference](https://pkg.go.dev/badge/github.com/jianyuan/go-sentry/v2/sentry.svg)](https://pkg.go.dev/github.com/jianyuan/go-sentry/v2/sentry) Go library for accessing the [Sentry Web API](https://docs.sentry.io/api/). From 03a23e1c19077c31827bed5383ff0f440f896f06 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 7 Jun 2022 18:56:38 +0100 Subject: [PATCH 23/40] Parse API error --- go.mod | 6 +-- go.sum | 9 ----- sentry/errors.go | 22 ++++------- sentry/sentry.go | 29 ++++++++------ sentry/sentry_test.go | 88 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 38 deletions(-) diff --git a/go.mod b/go.mod index 02a61c2..d5f1b61 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,13 @@ module github.com/jianyuan/go-sentry/v2 go 1.18 require ( - github.com/dghubble/sling v1.4.0 + github.com/google/go-querystring v1.1.0 + github.com/peterhellberg/link v1.1.0 github.com/stretchr/testify v1.7.2 - github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-querystring v1.1.0 // indirect - github.com/peterhellberg/link v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bcdb397..f100a39 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dghubble/sling v1.4.0 h1:/n8MRosVTthvMbwlNZgLx579OGVjUOy3GNEv5BIqAWY= -github.com/dghubble/sling v1.4.0/go.mod h1:0r40aNsU9EdDUVBNhfCstAtFgutjgJGYbO1oNzkMoM8= github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -12,17 +10,10 @@ github.com/peterhellberg/link v1.1.0/go.mod h1:gtSlOT4jmkY8P47hbTc8PTgiDDWpdPbFY github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= -github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= -gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sentry/errors.go b/sentry/errors.go index 9d1ab2f..bb6ad50 100644 --- a/sentry/errors.go +++ b/sentry/errors.go @@ -27,31 +27,25 @@ func (e *APIError) MarshalJSON() ([]byte, error) { return json.Marshal(e.f) } -func (e APIError) Error() string { +func (e APIError) Detail() string { switch v := e.f.(type) { case map[string]interface{}: if len(v) == 1 { if detail, ok := v["detail"].(string); ok { - return fmt.Sprintf("sentry: %s", detail) + return detail } } - return fmt.Sprintf("sentry: %v", v) + return fmt.Sprintf("%v", v) default: - return fmt.Sprintf("sentry: %v", v) + return fmt.Sprintf("%v", v) } } +func (e APIError) Error() string { + return fmt.Sprintf("sentry: %s", e.Detail()) +} + // Empty returns true if empty. func (e APIError) Empty() bool { return e.f == nil } - -func relevantError(httpError error, apiError APIError) error { - if httpError != nil { - return httpError - } - if !apiError.Empty() { - return apiError - } - return nil -} diff --git a/sentry/sentry.go b/sentry/sentry.go index 5e9cbc7..83bcc26 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/ioutil" "net/http" "net/url" "reflect" @@ -14,7 +15,6 @@ import ( "strings" "time" - "github.com/dghubble/sling" "github.com/google/go-querystring/query" "github.com/peterhellberg/link" ) @@ -43,9 +43,6 @@ type Client struct { // User agent used when communicating with Sentry. UserAgent string - // TODO: Remove sling - sling *sling.Sling - // Common struct used by all services. common service @@ -73,13 +70,10 @@ func NewClient(httpClient *http.Client) *Client { } baseURL, _ := url.Parse(defaultBaseURL) - base := sling.New().Client(httpClient) - c := &Client{ - sling: base, - - client: httpClient, - BaseURL: baseURL, + client: httpClient, + BaseURL: baseURL, + UserAgent: userAgent, } c.common.client = c c.IssueAlerts = (*IssueAlertsService)(&c.common) @@ -293,7 +287,20 @@ func CheckResponse(r *http.Response) error { } errorResponse := &ErrorResponse{Response: r} - // TODO: Handle API errors + data, err := ioutil.ReadAll(r.Body) + if err == nil && data != nil { + apiError := new(APIError) + json.Unmarshal(data, apiError) + if apiError.Empty() { + errorResponse.Detail = strings.TrimSpace(string(data)) + } else { + errorResponse.Detail = apiError.Detail() + } + } + // Re-populate error response body. + r.Body = ioutil.NopCloser(bytes.NewBuffer(data)) + + // TODO: Parse rate limit errors return errorResponse } diff --git a/sentry/sentry_test.go b/sentry/sentry_test.go index c84594a..526cb96 100644 --- a/sentry/sentry_test.go +++ b/sentry/sentry_test.go @@ -159,3 +159,91 @@ func TestDo_rateLimit(t *testing.T) { assert.Equal(t, resp.Rate.ConcurrentLimit, 25) assert.Equal(t, resp.Rate.ConcurrentRemaining, 24) } + +func TestDo(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + type foo struct { + A string + } + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + fmt.Fprint(w, `{"A":"a"}`) + }) + + req, _ := client.NewRequest("GET", "/", nil) + body := new(foo) + ctx := context.Background() + client.Do(ctx, req, body) + + expected := &foo{A: "a"} + + assert.Equal(t, expected, body) +} + +func TestDo_nilContext(t *testing.T) { + client, _, _, teardown := setup() + defer teardown() + + req, _ := client.NewRequest("GET", "/", nil) + _, err := client.Do(nil, req, nil) + + assert.Equal(t, errNonNilContext, err) +} + +func TestDo_httpErrorPlainText(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + http.Error(w, "Bad Request", http.StatusBadRequest) + }) + + req, _ := client.NewRequest("GET", ".", nil) + ctx := context.Background() + resp, err := client.Do(ctx, req, nil) + + assert.Equal(t, &ErrorResponse{Response: resp.Response, Detail: "Bad Request"}, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestDo_apiError(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"detail": "API error message"}`) + }) + + req, _ := client.NewRequest("GET", ".", nil) + ctx := context.Background() + resp, err := client.Do(ctx, req, nil) + + assert.Equal(t, &ErrorResponse{Response: resp.Response, Detail: "API error message"}, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestDo_apiError_noDetail(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `"API error message"`) + }) + + req, _ := client.NewRequest("GET", ".", nil) + ctx := context.Background() + resp, err := client.Do(ctx, req, nil) + + assert.Equal(t, &ErrorResponse{Response: resp.Response, Detail: "API error message"}, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} From 8deeedafdb4489c035bf353624b9e623ab64180e Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 7 Jun 2022 20:12:37 +0100 Subject: [PATCH 24/40] Update README.md --- README.md | 44 ++++++++++++++++++++++++++++++++++ sentry/organization_members.go | 3 ++- sentry/project_keys.go | 3 ++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a29cac1..95c43ad 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,50 @@ go-sentry is compatible with modern Go releases in module mode, with Go installe go get github.com/jianyuan/go-sentry/v2/sentry ``` +## Usage + +```go +import "github.com/jianyuan/go-sentry/v2/sentry" +``` + +Create a new Sentry client. Then, use the various services on the client to access different parts of the +Sentry Web API. For example: + +```go +client := sentry.NewClient(nil) + +// List all organizations +orgs, _, err := client.Organizations.List(ctx, nil) +``` + +### Authentication + +The library does not directly handle authentication. When creating a new client, pass an +`http.Client` that can handle authentication for you. We recommend the [oauth2](https://pkg.go.dev/golang.org/x/oauth2) +library. For example: + +```go +package main + +import ( + "github.com/jianyuan/go-sentry/v2/sentry" + "golang.org/x/oauth2" +) + +func main() { + ctx := context.Background() + tokenSrc := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: "YOUR-API-KEY"}, + ) + httpClient := oauth2.NewClient(ctx, tokenSrc) + + client := sentry.NewClient(httpClient) + + // List all organizations + orgs, _, err := client.Organizations.List(ctx, nil) +} +``` + ## Code structure The code structure was inspired by [google/go-github](https://github.com/google/go-github). diff --git a/sentry/organization_members.go b/sentry/organization_members.go index fd58548..e954788 100644 --- a/sentry/organization_members.go +++ b/sentry/organization_members.go @@ -37,7 +37,8 @@ type OrganizationMembersService service // List organization members. func (s *OrganizationMembersService) List(ctx context.Context, organizationSlug string, params *ListCursorParams) ([]*OrganizationMember, *Response, error) { - u, err := addQuery(fmt.Sprintf("0/organizations/%v/members/", organizationSlug), params) + u := fmt.Sprintf("0/organizations/%v/members/", organizationSlug) + u, err := addQuery(u, params) if err != nil { return nil, nil, err } diff --git a/sentry/project_keys.go b/sentry/project_keys.go index ea9bb38..a064d59 100644 --- a/sentry/project_keys.go +++ b/sentry/project_keys.go @@ -45,7 +45,8 @@ type ProjectKeysService service // List client keys bound to a project. // https://docs.sentry.io/api/projects/get-project-keys/ func (s *ProjectKeysService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ListCursorParams) ([]*ProjectKey, *Response, error) { - u, err := addQuery(fmt.Sprintf("0/projects/%v/%v/keys/", organizationSlug, projectSlug), params) + u := fmt.Sprintf("0/projects/%v/%v/keys/", organizationSlug, projectSlug) + u, err := addQuery(u, params) if err != nil { return nil, nil, err } From f31f2534912cdaa0703ac95329115cdd25172cd3 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Tue, 7 Jun 2022 22:38:35 +0100 Subject: [PATCH 25/40] Implement IssueAlertsService.Get and refactor structs to have nils represent absence of values --- sentry/helpers.go | 6 - sentry/issue_alerts.go | 141 ++++++++----- sentry/issue_alerts_test.go | 386 +++++++++++++++++++++++++++++------ sentry/metric_alerts_test.go | 30 +-- sentry/projects_test.go | 3 +- sentry/sentry.go | 17 +- sentry/users.go | 7 + 7 files changed, 448 insertions(+), 142 deletions(-) delete mode 100644 sentry/helpers.go diff --git a/sentry/helpers.go b/sentry/helpers.go deleted file mode 100644 index dca1915..0000000 --- a/sentry/helpers.go +++ /dev/null @@ -1,6 +0,0 @@ -package sentry - -// Bool returns a pointer to the bool value. -func Bool(v bool) *bool { - return &v -} diff --git a/sentry/issue_alerts.go b/sentry/issue_alerts.go index 40ccf17..f775c1d 100644 --- a/sentry/issue_alerts.go +++ b/sentry/issue_alerts.go @@ -10,25 +10,44 @@ import ( // IssueAlert represents an issue alert configured for this project. // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/rule.py#L131-L155 type IssueAlert struct { - ID string `json:"id"` - ActionMatch string `json:"actionMatch"` - FilterMatch string `json:"filterMatch"` - Environment *string `json:"environment,omitempty"` - Frequency int `json:"frequency"` - Name string `json:"name"` - Conditions []ConditionType `json:"conditions"` - Actions []ActionType `json:"actions"` - Filters []FilterType `json:"filters"` - Created time.Time `json:"dateCreated"` - TaskUUID string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the rule + ID *string `json:"id,omitempty"` + Conditions []*IssueAlertCondition `json:"conditions,omitempty"` + Filters []*IssueAlertFilter `json:"filters,omitempty"` + Actions []*IssueAlertAction `json:"actions,omitempty"` + ActionMatch *string `json:"actionMatch,omitempty"` + FilterMatch *string `json:"filterMatch,omitempty"` + Frequency *int `json:"frequency,omitempty"` + Name *string `json:"name,omitempty"` + DateCreated *time.Time `json:"dateCreated,omitempty"` + Owner *string `json:"owner,omitempty"` + CreatedBy *IssueAlertCreatedBy `json:"createdBy,omitempty"` + Environment *string `json:"environment,omitempty"` + Projects []string `json:"projects,omitempty"` + TaskUUID *string `json:"uuid,omitempty"` // This is actually the UUID of the async task that can be spawned to create the rule } +// IssueAlertCreatedBy for defining the rule creator. +type IssueAlertCreatedBy struct { + ID *int `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` +} + +// IssueAlertCondition for defining conditions. +type IssueAlertCondition map[string]interface{} + +// IssueAlertAction for defining actions. +type IssueAlertAction map[string]interface{} + +// IssueAlertFilter for defining actions. +type IssueAlertFilter map[string]interface{} + // IssueAlertTaskDetail represents the inline struct Sentry defines for task details // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/endpoints/project_rule_task_details.py#L29 type IssueAlertTaskDetail struct { - Status string `json:"status"` - Rule IssueAlert `json:"rule"` - Error string `json:"error"` + Status *string `json:"status,omitempty"` + Rule *IssueAlert `json:"rule,omitempty"` + Error *string `json:"error,omitempty"` } // IssueAlertsService provides methods for accessing Sentry project @@ -37,8 +56,13 @@ type IssueAlertTaskDetail struct { type IssueAlertsService service // List issue alerts configured for a project. -func (s *IssueAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*IssueAlert, *Response, error) { +func (s *IssueAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ListCursorParams) ([]*IssueAlert, *Response, error) { u := fmt.Sprintf("0/projects/%v/%v/rules/", organizationSlug, projectSlug) + u, err := addQuery(u, params) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err @@ -52,42 +76,49 @@ func (s *IssueAlertsService) List(ctx context.Context, organizationSlug string, return alerts, resp, nil } -// ConditionType for defining conditions. -type ConditionType map[string]interface{} - -// ActionType for defining actions. -type ActionType map[string]interface{} +// Get details on an issue alert. +func (s *IssueAlertsService) Get(ctx context.Context, organizationSlug string, projectSlug string, id string) (*IssueAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/rules/%v/", organizationSlug, projectSlug, id) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } -// FilterType for defining actions. -type FilterType map[string]interface{} + alert := new(IssueAlert) + resp, err := s.client.Do(ctx, req, alert) + if err != nil { + return nil, resp, err + } + return alert, resp, nil +} // CreateIssueAlertParams are the parameters for IssueAlertsService.Create. type CreateIssueAlertParams struct { - ActionMatch string `json:"actionMatch"` - FilterMatch string `json:"filterMatch"` - Environment string `json:"environment,omitempty"` - Frequency int `json:"frequency"` - Name string `json:"name"` - Conditions []ConditionType `json:"conditions"` - Actions []ActionType `json:"actions"` - Filters []FilterType `json:"filters"` + ActionMatch *string `json:"actionMatch,omitempty"` + FilterMatch *string `json:"filterMatch,omitempty"` + Environment *string `json:"environment,omitempty"` + Frequency *int `json:"frequency,omitempty"` + Name *string `json:"name,omitempty"` + Conditions []*IssueAlertCondition `json:"conditions,omitempty"` + Actions []*IssueAlertAction `json:"actions,omitempty"` + Filters []*IssueAlertFilter `json:"filters,omitempty"` } // CreateIssueAlertActionParams models the actions when creating the action for the rule. type CreateIssueAlertActionParams struct { - ID string `json:"id"` - Tags string `json:"tags"` - Channel string `json:"channel"` - Workspace string `json:"workspace"` + ID *string `json:"id,omitempty"` + Tags *string `json:"tags,omitempty"` + Channel *string `json:"channel,omitempty"` + Workspace *string `json:"workspace,omitempty"` } // CreateIssueAlertConditionParams models the conditions when creating the action for the rule. type CreateIssueAlertConditionParams struct { - ID string `json:"id"` - Interval string `json:"interval"` - Value int `json:"value"` - Level int `json:"level"` - Match string `json:"match"` + ID *string `json:"id,omitempty"` + Interval *string `json:"interval,omitempty"` + Value *int `json:"value,omitempty"` + Level *int `json:"level,omitempty"` + Match *string `json:"match,omitempty"` } // Create a new issue alert bound to a project. @@ -105,8 +136,11 @@ func (s *IssueAlertsService) Create(ctx context.Context, organizationSlug string } if resp.StatusCode == 202 { + if alert.TaskUUID == nil { + return nil, resp, errors.New("missing task uuid") + } // We just received a reference to an async task, we need to check another endpoint to retrieve the issue alert we created - return s.getIssueAlertFromTaskDetail(ctx, organizationSlug, projectSlug, alert.TaskUUID) + return s.getIssueAlertFromTaskDetail(ctx, organizationSlug, projectSlug, *alert.TaskUUID) } return alert, resp, nil @@ -115,8 +149,8 @@ func (s *IssueAlertsService) Create(ctx context.Context, organizationSlug string // getIssueAlertFromTaskDetail is called when Sentry offloads the issue alert creation process to an async task and sends us back the task's uuid. // It usually doesn't happen, but when creating Slack notification rules, it seemed to be sometimes the case. During testing it // took very long for a task to finish (10+ seconds) which is why this method can take long to return. -func (s *IssueAlertsService) getIssueAlertFromTaskDetail(ctx context.Context, organizationSlug string, projectSlug string, taskUuid string) (*IssueAlert, *Response, error) { - u := fmt.Sprintf("0/projects/%v/%v/rule-task/%v/", organizationSlug, projectSlug, taskUuid) +func (s *IssueAlertsService) getIssueAlertFromTaskDetail(ctx context.Context, organizationSlug string, projectSlug string, taskUUID string) (*IssueAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/rule-task/%v/", organizationSlug, projectSlug, taskUUID) req, err := s.client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err @@ -133,12 +167,19 @@ func (s *IssueAlertsService) getIssueAlertFromTaskDetail(ctx context.Context, or return nil, resp, err } - if taskDetail.Status == "success" { - return &taskDetail.Rule, resp, err - } else if taskDetail.Status == "failed" { - return &taskDetail.Rule, resp, errors.New(taskDetail.Error) - } else if resp.StatusCode == 404 { - return nil, resp, fmt.Errorf("couldn't find the issue alert creation task for uuid %v in Sentry (HTTP 404)", taskUuid) + if resp.StatusCode == 404 { + return nil, resp, fmt.Errorf("cannot find issue alert creation task with UUID %v", taskUUID) + } + if taskDetail.Status != nil && taskDetail.Rule != nil { + if *taskDetail.Status == "success" { + return taskDetail.Rule, resp, err + } else if *taskDetail.Status == "failed" { + if taskDetail != nil { + return taskDetail.Rule, resp, errors.New(*taskDetail.Error) + } + + return taskDetail.Rule, resp, errors.New("error while running the issue alert creation task") + } } } return nil, resp, errors.New("getting the status of the issue alert creation from Sentry took too long") @@ -161,8 +202,8 @@ func (s *IssueAlertsService) Update(ctx context.Context, organizationSlug string } // Delete an issue alert. -func (s *IssueAlertsService) Delete(ctx context.Context, organizationSlug string, projectSlug string, issueAlertID string) (*Response, error) { - u := fmt.Sprintf("0/projects/%v/%v/rules/%v/", organizationSlug, projectSlug, issueAlertID) +func (s *IssueAlertsService) Delete(ctx context.Context, organizationSlug string, projectSlug string, id string) (*Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/rules/%v/", organizationSlug, projectSlug, id) req, err := s.client.NewRequest("DELETE", u, nil) if err != nil { return nil, err diff --git a/sentry/issue_alerts_test.go b/sentry/issue_alerts_test.go index fc1caf1..6771c60 100644 --- a/sentry/issue_alerts_test.go +++ b/sentry/issue_alerts_test.go @@ -49,26 +49,26 @@ func TestIssueAlertsService_List(t *testing.T) { }) ctx := context.Background() - alerts, _, err := client.IssueAlerts.List(ctx, "the-interstellar-jurisdiction", "pump-station") + alerts, _, err := client.IssueAlerts.List(ctx, "the-interstellar-jurisdiction", "pump-station", nil) require.NoError(t, err) - environment := "production" + ti := mustParseTime("2019-08-24T18:12:16.321Z") expected := []*IssueAlert{ { - ID: "12345", - ActionMatch: "any", - Environment: &environment, - Frequency: 30, - Name: "Notify errors", - Conditions: []ConditionType{ + ID: String("12345"), + ActionMatch: String("any"), + Environment: String("production"), + Frequency: Int(30), + Name: String("Notify errors"), + Conditions: []*IssueAlertCondition{ { "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition", "name": "An issue is first seen", - "value": float64(500), + "value": json.Number("500"), "interval": "1h", }, }, - Actions: []ActionType{ + Actions: []*IssueAlertAction{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -78,8 +78,266 @@ func TestIssueAlertsService_List(t *testing.T) { "workspace": "1234", }, }, - Created: mustParseTime("2019-08-24T18:12:16.321Z"), + DateCreated: &ti, + }, + } + require.Equal(t, expected, alerts) + +} + +func TestIssueAlertsService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/rules/11185158/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "11185158", + "conditions": [ + { + "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition", + "name": "A new issue is created" + }, + { + "id": "sentry.rules.conditions.regression_event.RegressionEventCondition", + "name": "The issue changes state from resolved to unresolved" + }, + { + "id": "sentry.rules.conditions.reappeared_event.ReappearedEventCondition", + "name": "The issue changes state from ignored to unresolved" + }, + { + "interval": "1h", + "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", + "comparisonType": "count", + "value": 100, + "name": "The issue is seen more than 100 times in 1h" + }, + { + "interval": "1h", + "id": "sentry.rules.conditions.event_frequency.EventUniqueUserFrequencyCondition", + "comparisonType": "count", + "value": 100, + "name": "The issue is seen by more than 100 users in 1h" + }, + { + "interval": "1h", + "id": "sentry.rules.conditions.event_frequency.EventFrequencyPercentCondition", + "comparisonType": "count", + "value": 100, + "name": "The issue affects more than 100.0 percent of sessions in 1h" + } + ], + "filters": [ + { + "comparison_type": "older", + "time": "minute", + "id": "sentry.rules.filters.age_comparison.AgeComparisonFilter", + "value": 10, + "name": "The issue is older than 10 minute" + }, + { + "id": "sentry.rules.filters.issue_occurrences.IssueOccurrencesFilter", + "value": 10, + "name": "The issue has happened at least 10 times" + }, + { + "targetType": "Team", + "id": "sentry.rules.filters.assigned_to.AssignedToFilter", + "targetIdentifier": 1322366, + "name": "The issue is assigned to Team" + }, + { + "id": "sentry.rules.filters.latest_release.LatestReleaseFilter", + "name": "The event is from the latest release" + }, + { + "attribute": "message", + "match": "co", + "id": "sentry.rules.filters.event_attribute.EventAttributeFilter", + "value": "test", + "name": "The event's message value contains test" + }, + { + "match": "co", + "id": "sentry.rules.filters.tagged_event.TaggedEventFilter", + "key": "test", + "value": "test", + "name": "The event's tags match test contains test" + }, + { + "level": "50", + "match": "eq", + "id": "sentry.rules.filters.level.LevelFilter", + "name": "The event's level is equal to fatal" + } + ], + "actions": [ + { + "targetType": "IssueOwners", + "id": "sentry.mail.actions.NotifyEmailAction", + "targetIdentifier": "", + "name": "Send a notification to IssueOwners" + }, + { + "targetType": "Team", + "id": "sentry.mail.actions.NotifyEmailAction", + "targetIdentifier": 1322366, + "name": "Send a notification to Team" + }, + { + "targetType": "Member", + "id": "sentry.mail.actions.NotifyEmailAction", + "targetIdentifier": 94401, + "name": "Send a notification to Member" + }, + { + "id": "sentry.rules.actions.notify_event.NotifyEventAction", + "name": "Send a notification (for all legacy integrations)" + } + ], + "actionMatch": "any", + "filterMatch": "any", + "frequency": 30, + "name": "My Rule Name", + "dateCreated": "2022-05-23T19:54:30.860115Z", + "owner": "team:1322366", + "createdBy": { + "id": 94401, + "name": "John Doe", + "email": "test@example.com" + }, + "environment": null, + "projects": [ + "python" + ] + }`) + }) + + ctx := context.Background() + alerts, _, err := client.IssueAlerts.Get(ctx, "the-interstellar-jurisdiction", "pump-station", "11185158") + require.NoError(t, err) + + ti := mustParseTime("2022-05-23T19:54:30.860115Z") + expected := &IssueAlert{ + ID: String("11185158"), + Conditions: []*IssueAlertCondition{ + { + "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition", + "name": "A new issue is created", + }, + { + "id": "sentry.rules.conditions.regression_event.RegressionEventCondition", + "name": "The issue changes state from resolved to unresolved", + }, + { + "id": "sentry.rules.conditions.reappeared_event.ReappearedEventCondition", + "name": "The issue changes state from ignored to unresolved", + }, + { + "interval": "1h", + "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", + "comparisonType": "count", + "value": json.Number("100"), + "name": "The issue is seen more than 100 times in 1h", + }, + { + "interval": "1h", + "id": "sentry.rules.conditions.event_frequency.EventUniqueUserFrequencyCondition", + "comparisonType": "count", + "value": json.Number("100"), + "name": "The issue is seen by more than 100 users in 1h", + }, + { + "interval": "1h", + "id": "sentry.rules.conditions.event_frequency.EventFrequencyPercentCondition", + "comparisonType": "count", + "value": json.Number("100"), + "name": "The issue affects more than 100.0 percent of sessions in 1h", + }, + }, + Filters: []*IssueAlertFilter{ + { + "comparison_type": "older", + "time": "minute", + "id": "sentry.rules.filters.age_comparison.AgeComparisonFilter", + "value": json.Number("10"), + "name": "The issue is older than 10 minute", + }, + { + "id": "sentry.rules.filters.issue_occurrences.IssueOccurrencesFilter", + "value": json.Number("10"), + "name": "The issue has happened at least 10 times", + }, + { + "targetType": "Team", + "id": "sentry.rules.filters.assigned_to.AssignedToFilter", + "targetIdentifier": json.Number("1322366"), + "name": "The issue is assigned to Team", + }, + { + "id": "sentry.rules.filters.latest_release.LatestReleaseFilter", + "name": "The event is from the latest release", + }, + { + "attribute": "message", + "match": "co", + "id": "sentry.rules.filters.event_attribute.EventAttributeFilter", + "value": "test", + "name": "The event's message value contains test", + }, + { + "match": "co", + "id": "sentry.rules.filters.tagged_event.TaggedEventFilter", + "key": "test", + "value": "test", + "name": "The event's tags match test contains test", + }, + { + "level": "50", + "match": "eq", + "id": "sentry.rules.filters.level.LevelFilter", + "name": "The event's level is equal to fatal", + }, + }, + Actions: []*IssueAlertAction{ + { + "targetType": "IssueOwners", + "id": "sentry.mail.actions.NotifyEmailAction", + "targetIdentifier": "", + "name": "Send a notification to IssueOwners", + }, + { + "targetType": "Team", + "id": "sentry.mail.actions.NotifyEmailAction", + "targetIdentifier": json.Number("1322366"), + "name": "Send a notification to Team", + }, + { + "targetType": "Member", + "id": "sentry.mail.actions.NotifyEmailAction", + "targetIdentifier": json.Number("94401"), + "name": "Send a notification to Member", + }, + { + "id": "sentry.rules.actions.notify_event.NotifyEventAction", + "name": "Send a notification (for all legacy integrations)", + }, + }, + ActionMatch: String("any"), + FilterMatch: String("any"), + Frequency: Int(30), + Name: String("My Rule Name"), + DateCreated: &ti, + Owner: String("team:1322366"), + CreatedBy: &IssueAlertCreatedBy{ + ID: Int(94401), + Name: String("John Doe"), + Email: String("test@example.com"), }, + Projects: []string{"python"}, } require.Equal(t, expected, alerts) @@ -146,19 +404,19 @@ func TestIssueAlertsService_Create(t *testing.T) { }) params := &CreateIssueAlertParams{ - ActionMatch: "all", - Environment: "production", - Frequency: 30, - Name: "Notify errors", - Conditions: []ConditionType{ + ActionMatch: String("all"), + Environment: String("production"), + Frequency: Int(30), + Name: String("Notify errors"), + Conditions: []*IssueAlertCondition{ { "interval": "1h", "name": "The issue is seen more than 10 times in 1h", - "value": float64(10), + "value": json.Number("10"), "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", }, }, - Actions: []ActionType{ + Actions: []*IssueAlertAction{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -173,22 +431,22 @@ func TestIssueAlertsService_Create(t *testing.T) { alerts, _, err := client.IssueAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", params) require.NoError(t, err) - environment := "production" + ti := mustParseTime("2019-08-24T18:12:16.321Z") expected := &IssueAlert{ - ID: "123456", - ActionMatch: "all", - Environment: &environment, - Frequency: 30, - Name: "Notify errors", - Conditions: []ConditionType{ + ID: String("123456"), + ActionMatch: String("all"), + Environment: String("production"), + Frequency: Int(30), + Name: String("Notify errors"), + Conditions: []*IssueAlertCondition{ { "interval": "1h", "name": "The issue is seen more than 10 times in 1h", - "value": float64(10), + "value": json.Number("10"), "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", }, }, - Actions: []ActionType{ + Actions: []*IssueAlertAction{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -198,7 +456,7 @@ func TestIssueAlertsService_Create(t *testing.T) { "workspace": "1234", }, }, - Created: mustParseTime("2019-08-24T18:12:16.321Z"), + DateCreated: &ti, } require.Equal(t, expected, alerts) @@ -275,19 +533,19 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { }) params := &CreateIssueAlertParams{ - ActionMatch: "all", - Environment: "production", - Frequency: 30, - Name: "Notify errors", - Conditions: []ConditionType{ + ActionMatch: String("all"), + Environment: String("production"), + Frequency: Int(30), + Name: String("Notify errors"), + Conditions: []*IssueAlertCondition{ { "interval": "1h", "name": "The issue is seen more than 10 times in 1h", - "value": float64(10), + "value": json.Number("10"), "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", }, }, - Actions: []ActionType{ + Actions: []*IssueAlertAction{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -302,22 +560,22 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { alert, _, err := client.IssueAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", params) require.NoError(t, err) - environment := "production" + ti := mustParseTime("2019-08-24T18:12:16.321Z") expected := &IssueAlert{ - ID: "123456", - ActionMatch: "all", - Environment: &environment, - Frequency: 30, - Name: "Notify errors", - Conditions: []ConditionType{ + ID: String("123456"), + ActionMatch: String("all"), + Environment: String("production"), + Frequency: Int(30), + Name: String("Notify errors"), + Conditions: []*IssueAlertCondition{ { "interval": "1h", "name": "The issue is seen more than 10 times in 1h", - "value": float64(10), + "value": json.Number("10"), "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", }, }, - Actions: []ActionType{ + Actions: []*IssueAlertAction{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -327,7 +585,7 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { "workspace": "1234", }, }, - Created: mustParseTime("2019-08-24T18:12:16.321Z"), + DateCreated: &ti, } require.Equal(t, expected, alert) @@ -337,22 +595,22 @@ func TestIssueAlertsService_Update(t *testing.T) { client, mux, _, teardown := setup() defer teardown() - environment := "staging" + ti := mustParseTime("2019-08-24T18:12:16.321Z") params := &IssueAlert{ - ID: "12345", - ActionMatch: "all", - FilterMatch: "any", - Environment: &environment, - Frequency: 30, - Name: "Notify errors", - Conditions: []ConditionType{ + ID: String("12345"), + ActionMatch: String("all"), + FilterMatch: String("any"), + Environment: String("staging"), + Frequency: Int(30), + Name: String("Notify errors"), + Conditions: []*IssueAlertCondition{ { "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", "value": 500, "interval": "1h", }, }, - Actions: []ActionType{ + Actions: []*IssueAlertAction{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -362,7 +620,7 @@ func TestIssueAlertsService_Update(t *testing.T) { "workspace": "1234", }, }, - Filters: []FilterType{ + Filters: []*IssueAlertFilter{ { "id": "sentry.rules.filters.issue_occurrences.IssueOccurrencesFilter", "name": "The issue has happened at least 4 times", @@ -376,7 +634,7 @@ func TestIssueAlertsService_Update(t *testing.T) { "value": "test", }, }, - Created: mustParseTime("2019-08-24T18:12:16.321Z"), + DateCreated: &ti, } mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/rules/12345/", func(w http.ResponseWriter, r *http.Request) { @@ -453,18 +711,18 @@ func TestIssueAlertsService_Update(t *testing.T) { assert.NoError(t, err) expected := &IssueAlert{ - ID: "12345", - ActionMatch: "any", - Environment: &environment, - Frequency: 30, - Name: "Notify errors", - Conditions: []ConditionType{ + ID: String("12345"), + ActionMatch: String("any"), + Environment: String("staging"), + Frequency: Int(30), + Name: String("Notify errors"), + Conditions: []*IssueAlertCondition{ { "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition", "name": "An issue is first seen", }, }, - Actions: []ActionType{ + Actions: []*IssueAlertAction{ { "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "name": "Send a notification to the Dummy Slack workspace to #dummy-channel and show tags [environment] in notification", @@ -474,7 +732,7 @@ func TestIssueAlertsService_Update(t *testing.T) { "workspace": "1234", }, }, - Created: mustParseTime("2019-08-24T18:12:16.321Z"), + DateCreated: &ti, } require.Equal(t, expected, alerts) diff --git a/sentry/metric_alerts_test.go b/sentry/metric_alerts_test.go index ef6d16a..7314302 100644 --- a/sentry/metric_alerts_test.go +++ b/sentry/metric_alerts_test.go @@ -84,9 +84,9 @@ func TestMetricAlertService_List(t *testing.T) { "id": "6789", "alertRuleId": "12345", "label": "critical", - "thresholdType": float64(0), - "alertThreshold": float64(55501.0), - "resolveThreshold": float64(100.0), + "thresholdType": json.Number("0"), + "alertThreshold": json.Number("55501.0"), + "resolveThreshold": json.Number("100.0"), "dateCreated": "2022-04-07T16:46:48.607583Z", "actions": []interface{}{map[string]interface{}{ "id": "12345", @@ -95,7 +95,7 @@ func TestMetricAlertService_List(t *testing.T) { "targetType": "specific", "targetIdentifier": "#alert-rule-alerts", "inputChannelId": "C038NF00X4F", - "integrationId": float64(123), + "integrationId": json.Number("123"), "sentryAppId": interface{}(nil), "dateCreated": "2022-04-07T16:46:49.154638Z", "desc": "Send a Slack notification to #alert-rule-alerts", @@ -209,9 +209,9 @@ func TestMetricAlertService_Create(t *testing.T) { "id": "56789", "alertRuleId": "12345", "label": "critical", - "thresholdType": float64(0), - "alertThreshold": float64(10000), - "resolveThreshold": float64(0), + "thresholdType": json.Number("0"), + "alertThreshold": json.Number("10000"), + "resolveThreshold": json.Number("0"), "dateCreated": "2022-04-15T15:06:01.079598Z", "actions": []interface{}{map[string]interface{}{ "id": "12389", @@ -220,7 +220,7 @@ func TestMetricAlertService_Create(t *testing.T) { "targetType": "specific", "targetIdentifier": "#alert-rule-alerts", "inputChannelId": "C0XXXFKLXXX", - "integrationId": float64(111), + "integrationId": json.Number("111"), "sentryAppId": interface{}(nil), "dateCreated": "2022-04-15T15:06:01.087054Z", "desc": "Send a Slack notification to #alert-rule-alerts", @@ -254,12 +254,12 @@ func TestMetricAlertService_Update(t *testing.T) { Triggers: []Trigger{{ "actions": []interface{}{map[string]interface{}{}}, "alertRuleId": "12345", - "alertThreshold": 10000, + "alertThreshold": json.Number("10000"), "dateCreated": "2022-04-15T15:06:01.079598Z", "id": "56789", "label": "critical", - "resolveThreshold": 0, - "thresholdType": 0, + "resolveThreshold": json.Number("0"), + "thresholdType": json.Number("0"), }}, Owner: "pump-station:12345", Created: mustParseTime("2022-04-15T15:06:01.079598Z"), @@ -358,9 +358,9 @@ func TestMetricAlertService_Update(t *testing.T) { "id": "56789", "alertRuleId": "12345", "label": "critical", - "thresholdType": float64(0), - "alertThreshold": float64(10000), - "resolveThreshold": float64(0), + "thresholdType": json.Number("0"), + "alertThreshold": json.Number("10000"), + "resolveThreshold": json.Number("0"), "dateCreated": "2022-04-15T15:06:01.079598Z", "actions": []interface{}{map[string]interface{}{ "id": "12389", @@ -369,7 +369,7 @@ func TestMetricAlertService_Update(t *testing.T) { "targetType": "specific", "targetIdentifier": "#alert-rule-alerts", "inputChannelId": "C0XXXFKLXXX", - "integrationId": float64(111), + "integrationId": json.Number("111"), "sentryAppId": interface{}(nil), "dateCreated": "2022-04-15T15:06:01.087054Z", "desc": "Send a Slack notification to #alert-rule-alerts", diff --git a/sentry/projects_test.go b/sentry/projects_test.go index 5f4b5a8..b54a4e1 100644 --- a/sentry/projects_test.go +++ b/sentry/projects_test.go @@ -2,6 +2,7 @@ package sentry import ( "context" + "encoding/json" "fmt" "net/http" "testing" @@ -557,7 +558,7 @@ func TestProjectsService_Update(t *testing.T) { Status: "active", Options: map[string]interface{}{ "sentry:origins": "http://example.com\nhttp://example.invalid", - "sentry:resolve_age": float64(720), + "sentry:resolve_age": json.Number("720"), }, DigestsMinDelay: 300, DigestsMaxDelay: 1800, diff --git a/sentry/sentry.go b/sentry/sentry.go index 83bcc26..f487044 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -258,7 +258,9 @@ func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Res case io.Writer: _, err = io.Copy(v, resp.Body) default: - decErr := json.NewDecoder(resp.Body).Decode(v) + dec := json.NewDecoder(resp.Body) + dec.UseNumber() + decErr := dec.Decode(v) if decErr == io.EOF { decErr = nil } @@ -323,8 +325,11 @@ type Rate struct { ConcurrentRemaining int } -// Avatar represents an avatar. -type Avatar struct { - UUID *string `json:"avatarUuid"` - Type string `json:"avatarType"` -} +// Bool returns a pointer to the bool value passed in. +func Bool(v bool) *bool { return &v } + +// Int returns a pointer to the int value passed in. +func Int(v int) *int { return &v } + +// String returns a pointer to the string value passed in. +func String(v string) *string { return &v } diff --git a/sentry/users.go b/sentry/users.go index 7f5dbe7..af19232 100644 --- a/sentry/users.go +++ b/sentry/users.go @@ -23,8 +23,15 @@ type User struct { Emails []UserEmail `json:"emails"` } +// UserEmail represents a user's email and its verified status. type UserEmail struct { ID string `json:"id"` Email string `json:"email"` IsVerified bool `json:"is_verified"` } + +// Avatar represents an avatar. +type Avatar struct { + UUID *string `json:"avatarUuid"` + Type string `json:"avatarType"` +} From 0583b1c05ac063de518d94f98506a68bb8baed07 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Wed, 8 Jun 2022 03:03:52 +0100 Subject: [PATCH 26/40] Use IssueAlert instead of CreateIssueAlertParams --- sentry/issue_alerts.go | 31 +------------------------------ sentry/issue_alerts_test.go | 4 ++-- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/sentry/issue_alerts.go b/sentry/issue_alerts.go index f775c1d..33e59db 100644 --- a/sentry/issue_alerts.go +++ b/sentry/issue_alerts.go @@ -92,37 +92,8 @@ func (s *IssueAlertsService) Get(ctx context.Context, organizationSlug string, p return alert, resp, nil } -// CreateIssueAlertParams are the parameters for IssueAlertsService.Create. -type CreateIssueAlertParams struct { - ActionMatch *string `json:"actionMatch,omitempty"` - FilterMatch *string `json:"filterMatch,omitempty"` - Environment *string `json:"environment,omitempty"` - Frequency *int `json:"frequency,omitempty"` - Name *string `json:"name,omitempty"` - Conditions []*IssueAlertCondition `json:"conditions,omitempty"` - Actions []*IssueAlertAction `json:"actions,omitempty"` - Filters []*IssueAlertFilter `json:"filters,omitempty"` -} - -// CreateIssueAlertActionParams models the actions when creating the action for the rule. -type CreateIssueAlertActionParams struct { - ID *string `json:"id,omitempty"` - Tags *string `json:"tags,omitempty"` - Channel *string `json:"channel,omitempty"` - Workspace *string `json:"workspace,omitempty"` -} - -// CreateIssueAlertConditionParams models the conditions when creating the action for the rule. -type CreateIssueAlertConditionParams struct { - ID *string `json:"id,omitempty"` - Interval *string `json:"interval,omitempty"` - Value *int `json:"value,omitempty"` - Level *int `json:"level,omitempty"` - Match *string `json:"match,omitempty"` -} - // Create a new issue alert bound to a project. -func (s *IssueAlertsService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *CreateIssueAlertParams) (*IssueAlert, *Response, error) { +func (s *IssueAlertsService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *IssueAlert) (*IssueAlert, *Response, error) { u := fmt.Sprintf("0/projects/%v/%v/rules/", organizationSlug, projectSlug) req, err := s.client.NewRequest("POST", u, params) if err != nil { diff --git a/sentry/issue_alerts_test.go b/sentry/issue_alerts_test.go index 6771c60..339facd 100644 --- a/sentry/issue_alerts_test.go +++ b/sentry/issue_alerts_test.go @@ -403,7 +403,7 @@ func TestIssueAlertsService_Create(t *testing.T) { }`) }) - params := &CreateIssueAlertParams{ + params := &IssueAlert{ ActionMatch: String("all"), Environment: String("production"), Frequency: Int(30), @@ -532,7 +532,7 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { }) - params := &CreateIssueAlertParams{ + params := &IssueAlert{ ActionMatch: String("all"), Environment: String("production"), Frequency: Int(30), From e9d2ff57582f740ad2290a934b3cf45f49ba7a86 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Wed, 8 Jun 2022 08:27:27 +0100 Subject: [PATCH 27/40] Refactor Organization struct to have nils representing the absence of values --- sentry/issue_alerts_test.go | 17 ++--- sentry/organizations.go | 116 ++++++++++++++----------------- sentry/organizations_test.go | 130 +++++++++++++++++------------------ sentry/projects_test.go | 48 +++++++------ sentry/sentry.go | 3 + 5 files changed, 151 insertions(+), 163 deletions(-) diff --git a/sentry/issue_alerts_test.go b/sentry/issue_alerts_test.go index 339facd..e8b759c 100644 --- a/sentry/issue_alerts_test.go +++ b/sentry/issue_alerts_test.go @@ -52,7 +52,6 @@ func TestIssueAlertsService_List(t *testing.T) { alerts, _, err := client.IssueAlerts.List(ctx, "the-interstellar-jurisdiction", "pump-station", nil) require.NoError(t, err) - ti := mustParseTime("2019-08-24T18:12:16.321Z") expected := []*IssueAlert{ { ID: String("12345"), @@ -78,7 +77,7 @@ func TestIssueAlertsService_List(t *testing.T) { "workspace": "1234", }, }, - DateCreated: &ti, + DateCreated: Time(mustParseTime("2019-08-24T18:12:16.321Z")), }, } require.Equal(t, expected, alerts) @@ -220,7 +219,6 @@ func TestIssueAlertsService_Get(t *testing.T) { alerts, _, err := client.IssueAlerts.Get(ctx, "the-interstellar-jurisdiction", "pump-station", "11185158") require.NoError(t, err) - ti := mustParseTime("2022-05-23T19:54:30.860115Z") expected := &IssueAlert{ ID: String("11185158"), Conditions: []*IssueAlertCondition{ @@ -330,7 +328,7 @@ func TestIssueAlertsService_Get(t *testing.T) { FilterMatch: String("any"), Frequency: Int(30), Name: String("My Rule Name"), - DateCreated: &ti, + DateCreated: Time(mustParseTime("2022-05-23T19:54:30.860115Z")), Owner: String("team:1322366"), CreatedBy: &IssueAlertCreatedBy{ ID: Int(94401), @@ -431,7 +429,6 @@ func TestIssueAlertsService_Create(t *testing.T) { alerts, _, err := client.IssueAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", params) require.NoError(t, err) - ti := mustParseTime("2019-08-24T18:12:16.321Z") expected := &IssueAlert{ ID: String("123456"), ActionMatch: String("all"), @@ -456,7 +453,7 @@ func TestIssueAlertsService_Create(t *testing.T) { "workspace": "1234", }, }, - DateCreated: &ti, + DateCreated: Time(mustParseTime("2019-08-24T18:12:16.321Z")), } require.Equal(t, expected, alerts) @@ -560,7 +557,6 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { alert, _, err := client.IssueAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", params) require.NoError(t, err) - ti := mustParseTime("2019-08-24T18:12:16.321Z") expected := &IssueAlert{ ID: String("123456"), ActionMatch: String("all"), @@ -585,7 +581,7 @@ func TestIssueAlertsService_CreateWithAsyncTask(t *testing.T) { "workspace": "1234", }, }, - DateCreated: &ti, + DateCreated: Time(mustParseTime("2019-08-24T18:12:16.321Z")), } require.Equal(t, expected, alert) @@ -595,7 +591,6 @@ func TestIssueAlertsService_Update(t *testing.T) { client, mux, _, teardown := setup() defer teardown() - ti := mustParseTime("2019-08-24T18:12:16.321Z") params := &IssueAlert{ ID: String("12345"), ActionMatch: String("all"), @@ -634,7 +629,7 @@ func TestIssueAlertsService_Update(t *testing.T) { "value": "test", }, }, - DateCreated: &ti, + DateCreated: Time(mustParseTime("2019-08-24T18:12:16.321Z")), } mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/rules/12345/", func(w http.ResponseWriter, r *http.Request) { @@ -732,7 +727,7 @@ func TestIssueAlertsService_Update(t *testing.T) { "workspace": "1234", }, }, - DateCreated: &ti, + DateCreated: Time(mustParseTime("2019-08-24T18:12:16.321Z")), } require.Equal(t, expected, alerts) diff --git a/sentry/organizations.go b/sentry/organizations.go index c91a55e..52b00b3 100644 --- a/sentry/organizations.go +++ b/sentry/organizations.go @@ -8,81 +8,65 @@ import ( // OrganizationStatus represents a Sentry organization's status. type OrganizationStatus struct { - ID string `json:"id"` - Name string `json:"name"` + ID *string `json:"id"` + Name *string `json:"name"` } // OrganizationQuota represents a Sentry organization's quota. type OrganizationQuota struct { - MaxRate int `json:"maxRate"` - MaxRateInterval int `json:"maxRateInterval"` - AccountLimit int `json:"accountLimit"` - ProjectLimit int `json:"projectLimit"` + MaxRate *int `json:"maxRate"` + MaxRateInterval *int `json:"maxRateInterval"` + AccountLimit *int `json:"accountLimit"` + ProjectLimit *int `json:"projectLimit"` } // OrganizationAvailableRole represents a Sentry organization's available role. type OrganizationAvailableRole struct { - ID string `json:"id"` - Name string `json:"name"` + ID *string `json:"id"` + Name *string `json:"name"` } -// Organization represents a Sentry organization. -// Based on https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/organization.py#L110-L120 -type Organization struct { - // Basic - ID string `json:"id"` - Slug string `json:"slug"` - Status OrganizationStatus `json:"status"` - Name string `json:"name"` - DateCreated time.Time `json:"dateCreated"` - IsEarlyAdopter bool `json:"isEarlyAdopter"` - Require2FA bool `json:"require2FA"` - RequireEmailVerification bool `json:"requireEmailVerification"` - Avatar Avatar `json:"avatar"` - Features []string `json:"features"` -} - -// DetailedOrganization represents detailed information about a Sentry organization. +// Organization represents detailed information about a Sentry organization. // Based on https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/organization.py#L263-L288 -type DetailedOrganization struct { +type Organization struct { // Basic - ID string `json:"id"` - Slug string `json:"slug"` - Status OrganizationStatus `json:"status"` - Name string `json:"name"` - DateCreated time.Time `json:"dateCreated"` - IsEarlyAdopter bool `json:"isEarlyAdopter"` - Require2FA bool `json:"require2FA"` - RequireEmailVerification bool `json:"requireEmailVerification"` - Avatar Avatar `json:"avatar"` - Features []string `json:"features"` + ID *string `json:"id,omitempty"` + Slug *string `json:"slug,omitempty"` + Status *OrganizationStatus `json:"status,omitempty"` + Name *string `json:"name,omitempty"` + DateCreated *time.Time `json:"dateCreated,omitempty"` + IsEarlyAdopter *bool `json:"isEarlyAdopter,omitempty"` + Require2FA *bool `json:"require2FA,omitempty"` + RequireEmailVerification *bool `json:"requireEmailVerification,omitempty"` + Avatar *Avatar `json:"avatar,omitempty"` + Features []string `json:"features,omitempty"` // Detailed // TODO: experiments - Quota OrganizationQuota `json:"quota"` - IsDefault bool `json:"isDefault"` - DefaultRole string `json:"defaultRole"` - AvailableRoles []OrganizationAvailableRole `json:"availableRoles"` - OpenMembership bool `json:"openMembership"` - AllowSharedIssues bool `json:"allowSharedIssues"` - EnhancedPrivacy bool `json:"enhancedPrivacy"` - DataScrubber bool `json:"dataScrubber"` - DataScrubberDefaults bool `json:"dataScrubberDefaults"` - SensitiveFields []string `json:"sensitiveFields"` - SafeFields []string `json:"safeFields"` - StoreCrashReports int `json:"storeCrashReports"` - AttachmentsRole string `json:"attachmentsRole"` - DebugFilesRole string `json:"debugFilesRole"` - EventsMemberAdmin bool `json:"eventsMemberAdmin"` - AlertsMemberWrite bool `json:"alertsMemberWrite"` - ScrubIPAddresses bool `json:"scrubIPAddresses"` - ScrapeJavaScript bool `json:"scrapeJavaScript"` - AllowJoinRequests bool `json:"allowJoinRequests"` - RelayPiiConfig *string `json:"relayPiiConfig"` + Quota *OrganizationQuota `json:"quota,omitempty"` + IsDefault *bool `json:"isDefault,omitempty"` + DefaultRole *string `json:"defaultRole,omitempty"` + AvailableRoles []OrganizationAvailableRole `json:"availableRoles,omitempty"` + OpenMembership *bool `json:"openMembership,omitempty"` + AllowSharedIssues *bool `json:"allowSharedIssues,omitempty"` + EnhancedPrivacy *bool `json:"enhancedPrivacy,omitempty"` + DataScrubber *bool `json:"dataScrubber,omitempty"` + DataScrubberDefaults *bool `json:"dataScrubberDefaults,omitempty"` + SensitiveFields []string `json:"sensitiveFields,omitempty"` + SafeFields []string `json:"safeFields,omitempty"` + StoreCrashReports *int `json:"storeCrashReports,omitempty"` + AttachmentsRole *string `json:"attachmentsRole,omitempty"` + DebugFilesRole *string `json:"debugFilesRole,omitempty"` + EventsMemberAdmin *bool `json:"eventsMemberAdmin,omitempty"` + AlertsMemberWrite *bool `json:"alertsMemberWrite,omitempty"` + ScrubIPAddresses *bool `json:"scrubIPAddresses,omitempty"` + ScrapeJavaScript *bool `json:"scrapeJavaScript,omitempty"` + AllowJoinRequests *bool `json:"allowJoinRequests,omitempty"` + RelayPiiConfig *string `json:"relayPiiConfig,omitempty"` // TODO: trustedRelays - Access []string `json:"access"` - Role string `json:"role"` - PendingAccessRequests int `json:"pendingAccessRequests"` + Access []string `json:"access,omitempty"` + Role *string `json:"role,omitempty"` + PendingAccessRequests *int `json:"pendingAccessRequests,omitempty"` // TODO: onboardingTasks } @@ -113,14 +97,14 @@ func (s *OrganizationsService) List(ctx context.Context, params *ListCursorParam // Get a Sentry organization. // https://docs.sentry.io/api/organizations/retrieve-an-organization/ -func (s *OrganizationsService) Get(ctx context.Context, slug string) (*DetailedOrganization, *Response, error) { +func (s *OrganizationsService) Get(ctx context.Context, slug string) (*Organization, *Response, error) { u := fmt.Sprintf("0/organizations/%v/", slug) req, err := s.client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err } - org := new(DetailedOrganization) + org := new(Organization) resp, err := s.client.Do(ctx, req, org) if err != nil { return nil, resp, err @@ -130,9 +114,9 @@ func (s *OrganizationsService) Get(ctx context.Context, slug string) (*DetailedO // CreateOrganizationParams are the parameters for OrganizationService.Create. type CreateOrganizationParams struct { - Name string `json:"name,omitempty"` - Slug string `json:"slug,omitempty"` - AgreeTerms *bool `json:"agreeTerms,omitempty"` + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` + AgreeTerms *bool `json:"agreeTerms,omitempty"` } // Create a new Sentry organization. @@ -153,8 +137,8 @@ func (s *OrganizationsService) Create(ctx context.Context, params *CreateOrganiz // UpdateOrganizationParams are the parameters for OrganizationService.Update. type UpdateOrganizationParams struct { - Name string `json:"name,omitempty"` - Slug string `json:"slug,omitempty"` + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` } // Update a Sentry organization. diff --git a/sentry/organizations_test.go b/sentry/organizations_test.go index 64677c5..e05926a 100644 --- a/sentry/organizations_test.go +++ b/sentry/organizations_test.go @@ -40,12 +40,12 @@ func TestOrganizationsService_List(t *testing.T) { expected := []*Organization{ { - ID: "2", - Slug: "the-interstellar-jurisdiction", - Name: "The Interstellar Jurisdiction", - DateCreated: mustParseTime("2017-07-17T14:10:36.141Z"), - IsEarlyAdopter: false, - Avatar: Avatar{ + ID: String("2"), + Slug: String("the-interstellar-jurisdiction"), + Name: String("The Interstellar Jurisdiction"), + DateCreated: Time(mustParseTime("2017-07-17T14:10:36.141Z")), + IsEarlyAdopter: Bool(false), + Avatar: &Avatar{ UUID: nil, Type: "letter_avatar", }, @@ -198,19 +198,19 @@ func TestOrganizationsService_Get(t *testing.T) { organization, _, err := client.Organizations.Get(ctx, "the-interstellar-jurisdiction") assert.NoError(t, err) - expected := &DetailedOrganization{ - ID: "2", - Slug: "the-interstellar-jurisdiction", - Status: OrganizationStatus{ - ID: "active", - Name: "active", + expected := &Organization{ + ID: String("2"), + Slug: String("the-interstellar-jurisdiction"), + Status: &OrganizationStatus{ + ID: String("active"), + Name: String("active"), }, - Name: "The Interstellar Jurisdiction", - DateCreated: mustParseTime("2022-06-05T17:31:31.170029Z"), - IsEarlyAdopter: false, - Require2FA: false, - RequireEmailVerification: false, - Avatar: Avatar{ + Name: String("The Interstellar Jurisdiction"), + DateCreated: Time(mustParseTime("2022-06-05T17:31:31.170029Z")), + IsEarlyAdopter: Bool(false), + Require2FA: Bool(false), + RequireEmailVerification: Bool(false), + Avatar: &Avatar{ Type: "letter_avatar", }, Features: []string{ @@ -251,51 +251,51 @@ func TestOrganizationsService_Get(t *testing.T) { "mobile-app", "minute-resolution-sessions", }, - Quota: OrganizationQuota{ - MaxRate: 0, - MaxRateInterval: 60, - AccountLimit: 0, - ProjectLimit: 100, + Quota: &OrganizationQuota{ + MaxRate: nil, + MaxRateInterval: Int(60), + AccountLimit: Int(0), + ProjectLimit: Int(100), }, - IsDefault: false, - DefaultRole: "member", + IsDefault: Bool(false), + DefaultRole: String("member"), AvailableRoles: []OrganizationAvailableRole{ { - ID: "billing", - Name: "Billing", + ID: String("billing"), + Name: String("Billing"), }, { - ID: "member", - Name: "Member", + ID: String("member"), + Name: String("Member"), }, { - ID: "admin", - Name: "Admin", + ID: String("admin"), + Name: String("Admin"), }, { - ID: "manager", - Name: "Manager", + ID: String("manager"), + Name: String("Manager"), }, { - ID: "owner", - Name: "Owner", + ID: String("owner"), + Name: String("Owner"), }, }, - OpenMembership: true, - AllowSharedIssues: true, - EnhancedPrivacy: false, - DataScrubber: false, - DataScrubberDefaults: false, + OpenMembership: Bool(true), + AllowSharedIssues: Bool(true), + EnhancedPrivacy: Bool(false), + DataScrubber: Bool(false), + DataScrubberDefaults: Bool(false), SensitiveFields: []string{}, SafeFields: []string{}, - StoreCrashReports: 0, - AttachmentsRole: "member", - DebugFilesRole: "admin", - EventsMemberAdmin: true, - AlertsMemberWrite: true, - ScrubIPAddresses: false, - ScrapeJavaScript: true, - AllowJoinRequests: true, + StoreCrashReports: Int(0), + AttachmentsRole: String("member"), + DebugFilesRole: String("admin"), + EventsMemberAdmin: Bool(true), + AlertsMemberWrite: Bool(true), + ScrubIPAddresses: Bool(false), + ScrapeJavaScript: Bool(true), + AllowJoinRequests: Bool(true), RelayPiiConfig: nil, Access: []string{ "org:write", @@ -319,8 +319,8 @@ func TestOrganizationsService_Get(t *testing.T) { "org:read", "team:read", }, - Role: "owner", - PendingAccessRequests: 0, + Role: String("owner"), + PendingAccessRequests: Int(0), } assert.Equal(t, expected, organization) } @@ -344,17 +344,17 @@ func TestOrganizationsService_Create(t *testing.T) { }) params := &CreateOrganizationParams{ - Name: "The Interstellar Jurisdiction", - Slug: "the-interstellar-jurisdiction", + Name: String("The Interstellar Jurisdiction"), + Slug: String("the-interstellar-jurisdiction"), } ctx := context.Background() organization, _, err := client.Organizations.Create(ctx, params) assert.NoError(t, err) expected := &Organization{ - ID: "2", - Name: "The Interstellar Jurisdiction", - Slug: "the-interstellar-jurisdiction", + ID: String("2"), + Name: String("The Interstellar Jurisdiction"), + Slug: String("the-interstellar-jurisdiction"), } assert.Equal(t, expected, organization) } @@ -379,8 +379,8 @@ func TestOrganizationsService_Create_AgreeTerms(t *testing.T) { }) params := &CreateOrganizationParams{ - Name: "The Interstellar Jurisdiction", - Slug: "the-interstellar-jurisdiction", + Name: String("The Interstellar Jurisdiction"), + Slug: String("the-interstellar-jurisdiction"), AgreeTerms: Bool(true), } ctx := context.Background() @@ -388,9 +388,9 @@ func TestOrganizationsService_Create_AgreeTerms(t *testing.T) { assert.NoError(t, err) expected := &Organization{ - ID: "2", - Name: "The Interstellar Jurisdiction", - Slug: "the-interstellar-jurisdiction", + ID: String("2"), + Name: String("The Interstellar Jurisdiction"), + Slug: String("the-interstellar-jurisdiction"), } assert.Equal(t, expected, organization) } @@ -414,17 +414,17 @@ func TestOrganizationsService_Update(t *testing.T) { }) params := &UpdateOrganizationParams{ - Name: "Impeccably Designated", - Slug: "impeccably-designated", + Name: String("Impeccably Designated"), + Slug: String("impeccably-designated"), } ctx := context.Background() organization, _, err := client.Organizations.Update(ctx, "badly-misnamed", params) assert.NoError(t, err) expected := &Organization{ - ID: "2", - Name: "Impeccably Designated", - Slug: "impeccably-designated", + ID: String("2"), + Name: String("Impeccably Designated"), + Slug: String("impeccably-designated"), } assert.Equal(t, expected, organization) } diff --git a/sentry/projects_test.go b/sentry/projects_test.go index b54a4e1..15ba929 100644 --- a/sentry/projects_test.go +++ b/sentry/projects_test.go @@ -144,15 +144,17 @@ func TestProjectsService_List(t *testing.T) { assert.NoError(t, err) expectedOrganization := Organization{ - ID: "2", - Slug: "the-interstellar-jurisdiction", - Status: OrganizationStatus{ - ID: "active", - Name: "active", + ID: String("2"), + Slug: String("the-interstellar-jurisdiction"), + Status: &OrganizationStatus{ + ID: String("active"), + Name: String("active"), }, - Name: "The Interstellar Jurisdiction", - DateCreated: mustParseTime("2018-09-20T15:47:52.908Z"), - Avatar: Avatar{ + Name: String("The Interstellar Jurisdiction"), + DateCreated: Time(mustParseTime("2018-09-20T15:47:52.908Z")), + IsEarlyAdopter: Bool(false), + Require2FA: Bool(false), + Avatar: &Avatar{ Type: "letter_avatar", }, } @@ -364,6 +366,22 @@ func TestProjectsService_Get(t *testing.T) { ctx := context.Background() project, _, err := client.Projects.Get(ctx, "the-interstellar-jurisdiction", "pump-station") assert.NoError(t, err) + + expectedOrganization := Organization{ + ID: String("2"), + Slug: String("the-interstellar-jurisdiction"), + Status: &OrganizationStatus{ + ID: String("active"), + Name: String("active"), + }, + Name: String("The Interstellar Jurisdiction"), + DateCreated: Time(mustParseTime("2018-10-02T14:19:09.817Z")), + IsEarlyAdopter: Bool(false), + Require2FA: Bool(false), + Avatar: &Avatar{ + Type: "letter_avatar", + }, + } expected := &Project{ ID: "2", Slug: "pump-station", @@ -401,19 +419,7 @@ func TestProjectsService_Get(t *testing.T) { SubjectTemplate: "$shortID - $title", SecurityToken: "320e3180c64e11e8b61e0242ac110002", ScrapeJavaScript: true, - Organization: Organization{ - ID: "2", - Slug: "the-interstellar-jurisdiction", - Status: OrganizationStatus{ - ID: "active", - Name: "active", - }, - Name: "The Interstellar Jurisdiction", - DateCreated: mustParseTime("2018-10-02T14:19:09.817Z"), - Avatar: Avatar{ - Type: "letter_avatar", - }, - }, + Organization: expectedOrganization, Team: Team{ ID: "2", Slug: "powerful-abolitionist", diff --git a/sentry/sentry.go b/sentry/sentry.go index f487044..e50ca5d 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -333,3 +333,6 @@ func Int(v int) *int { return &v } // String returns a pointer to the string value passed in. func String(v string) *string { return &v } + +// Time returns a pointer to the time.Time value passed in. +func Time(v time.Time) *time.Time { return &v } From 241f74fdf18578b7164f7915428e7a099276e700 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Wed, 8 Jun 2022 21:28:07 +0100 Subject: [PATCH 28/40] Handle rate limit (#52) * Parse rate limit error * Add tests * Pre-flight rate limit check --- sentry/sentry.go | 89 ++++++++++++++++++++++++- sentry/sentry_test.go | 147 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 221 insertions(+), 15 deletions(-) diff --git a/sentry/sentry.go b/sentry/sentry.go index e50ca5d..b9a2cdd 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -43,6 +43,9 @@ type Client struct { // User agent used when communicating with Sentry. UserAgent string + // Latest rate limit + rate Rate + // Common struct used by all services. common service @@ -228,6 +231,14 @@ func (c *Client) BareDo(ctx context.Context, req *http.Request) (*Response, erro return nil, errNonNilContext } + // Check rate limit + if err := c.checkRateLimit(req); err != nil { + return &Response{ + Response: err.Response, + Rate: err.Rate, + }, err + } + resp, err := c.client.Do(req) if err != nil { // If we got an error, and the context has been canceled, @@ -242,10 +253,32 @@ func (c *Client) BareDo(ctx context.Context, req *http.Request) (*Response, erro response := newResponse(resp) + c.rate = response.Rate + err = CheckResponse(resp) + return response, err } +func (c *Client) checkRateLimit(req *http.Request) *RateLimitError { + if !c.rate.Reset.IsZero() && c.rate.Remaining == 0 && time.Now().Before(c.rate.Reset) { + resp := &http.Response{ + Status: http.StatusText(http.StatusTooManyRequests), + StatusCode: http.StatusTooManyRequests, + Request: req, + Header: http.Header{}, + Body: ioutil.NopCloser(strings.NewReader("")), + } + return &RateLimitError{ + Rate: c.rate, + Response: resp, + Detail: fmt.Sprintf("API rate limit of %v and concurrent limit of %v still exceeded until %v, not making remote request.", + c.rate.Limit, c.rate.ConcurrentLimit, c.rate.Reset), + } + } + return nil +} + func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Response, error) { resp, err := c.BareDo(ctx, req) if err != nil { @@ -271,6 +304,17 @@ func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Res return resp, err } +// matchHTTPResponse compares two http.Response objects. Currently, only StatusCode is checked. +func matchHTTPResponse(r1, r2 *http.Response) bool { + if r1 == nil && r2 == nil { + return true + } + if r1 != nil && r2 != nil { + return r1.StatusCode == r2.StatusCode + } + return false +} + type ErrorResponse struct { Response *http.Response Detail string `json:"detail"` @@ -283,6 +327,41 @@ func (r *ErrorResponse) Error() string { r.Response.StatusCode, r.Detail) } +func (r *ErrorResponse) Is(target error) bool { + v, ok := target.(*ErrorResponse) + if !ok { + return false + } + if r.Detail != v.Detail || + !matchHTTPResponse(r.Response, v.Response) { + return false + } + return true +} + +type RateLimitError struct { + Rate Rate + Response *http.Response + Detail string +} + +func (r *RateLimitError) Error() string { + return fmt.Sprintf( + "%v %v: %d %v %v", + r.Response.Request.Method, r.Response.Request.URL, + r.Response.StatusCode, r.Detail, fmt.Sprintf("[rate reset in %v]", time.Until(r.Rate.Reset))) +} + +func (r *RateLimitError) Is(target error) bool { + v, ok := target.(*RateLimitError) + if !ok { + return false + } + return r.Rate == v.Rate && + r.Detail == v.Detail && + matchHTTPResponse(r.Response, v.Response) +} + func CheckResponse(r *http.Response) error { if c := r.StatusCode; 200 <= c && c <= 299 { return nil @@ -302,7 +381,15 @@ func CheckResponse(r *http.Response) error { // Re-populate error response body. r.Body = ioutil.NopCloser(bytes.NewBuffer(data)) - // TODO: Parse rate limit errors + switch { + case r.StatusCode == http.StatusTooManyRequests && + (r.Header.Get(headerRateRemaining) == "0" || r.Header.Get(headerRateConcurrentRemaining) == "0"): + return &RateLimitError{ + Rate: parseRate(r), + Response: errorResponse.Response, + Detail: errorResponse.Detail, + } + } return errorResponse } diff --git a/sentry/sentry_test.go b/sentry/sentry_test.go index 526cb96..bd0fd46 100644 --- a/sentry/sentry_test.go +++ b/sentry/sentry_test.go @@ -2,15 +2,16 @@ package sentry import ( "context" + "encoding/json" "fmt" + "io/ioutil" "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" - "encoding/json" - "github.com/stretchr/testify/assert" ) @@ -137,6 +138,29 @@ func TestResponse_populatePaginationCursor_noNextResults(t *testing.T) { assert.Equal(t, response.Cursor, "") } +func TestDo(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + type foo struct { + A string + } + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + fmt.Fprint(w, `{"A":"a"}`) + }) + + req, _ := client.NewRequest("GET", "/", nil) + body := new(foo) + ctx := context.Background() + client.Do(ctx, req, body) + + expected := &foo{A: "a"} + + assert.Equal(t, expected, body) +} + func TestDo_rateLimit(t *testing.T) { client, mux, _, teardown := setup() defer teardown() @@ -160,27 +184,42 @@ func TestDo_rateLimit(t *testing.T) { assert.Equal(t, resp.Rate.ConcurrentRemaining, 24) } -func TestDo(t *testing.T) { +func TestDo_rateLimit_noNetworkCall(t *testing.T) { client, mux, _, teardown := setup() defer teardown() - type foo struct { - A string - } + reset := time.Now().UTC().Add(time.Minute).Round(time.Second) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - assertMethod(t, "GET", r) - fmt.Fprint(w, `{"A":"a"}`) + mux.HandleFunc("/first", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(headerRateLimit, "40") + w.Header().Set(headerRateRemaining, "0") + w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix())) + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(w, `{"detail": "Rate limit exceeded"}`) }) - req, _ := client.NewRequest("GET", "/", nil) - body := new(foo) + madeNetworkCall := false + mux.HandleFunc("/second", func(w http.ResponseWriter, r *http.Request) { + madeNetworkCall = true + }) + + // First request to determine the rate limit. + req, _ := client.NewRequest("GET", "/first", nil) ctx := context.Background() - client.Do(ctx, req, body) + client.Do(ctx, req, nil) - expected := &foo{A: "a"} + // Second request should not make a network call. + req, _ = client.NewRequest("GET", "/second", nil) + _, err := client.Do(ctx, req, nil) - assert.Equal(t, expected, body) + assert.False(t, madeNetworkCall) + assert.Error(t, err) + + if rateLimitErr, ok := err.(*RateLimitError); assert.True(t, ok) { + assert.Equal(t, 40, rateLimitErr.Rate.Limit) + assert.Equal(t, 0, rateLimitErr.Rate.Remaining) + assert.Equal(t, reset, rateLimitErr.Rate.Reset) + } } func TestDo_nilContext(t *testing.T) { @@ -247,3 +286,83 @@ func TestDo_apiError_noDetail(t *testing.T) { assert.Equal(t, &ErrorResponse{Response: resp.Response, Detail: "API error message"}, err) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } + +func TestCheckResponse(t *testing.T) { + testcases := []struct { + description string + body string + }{ + { + description: "JSON object", + body: `{"detail": "Error message"}`, + }, + { + description: "JSON string", + body: `"Error message"`, + }, + { + description: "plain text", + body: `Error message`, + }, + } + for _, tc := range testcases { + t.Run(tc.description, func(t *testing.T) { + res := &http.Response{ + Request: &http.Request{}, + StatusCode: http.StatusBadRequest, + Body: ioutil.NopCloser(strings.NewReader(tc.body)), + } + + err := CheckResponse(res) + + expected := &ErrorResponse{ + Response: res, + Detail: "Error message", + } + assert.ErrorIs(t, err, expected) + }) + } + +} + +func TestCheckResponse_rateLimit(t *testing.T) { + testcases := []struct { + description string + addHeaders func(res *http.Response) + }{ + { + description: "headerRateRemaining", + addHeaders: func(res *http.Response) { + res.Header.Set(headerRateRemaining, "0") + res.Header.Set(headerRateReset, "123456") + }, + }, + { + description: "headerRateConcurrentRemaining", + addHeaders: func(res *http.Response) { + res.Header.Set(headerRateConcurrentRemaining, "0") + res.Header.Set(headerRateReset, "123456") + }, + }, + } + for _, tc := range testcases { + t.Run(tc.description, func(t *testing.T) { + res := &http.Response{ + Request: &http.Request{}, + StatusCode: http.StatusTooManyRequests, + Header: http.Header{}, + Body: ioutil.NopCloser(strings.NewReader(`{"detail": "Rate limit exceeded"}`)), + } + tc.addHeaders(res) + + err := CheckResponse(res) + + expected := &RateLimitError{ + Rate: parseRate(res), + Response: res, + Detail: "Rate limit exceeded", + } + assert.ErrorIs(t, err, expected) + }) + } +} From 232cd66713bb7877ff2bba0b0e68086e82871939 Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Wed, 8 Jun 2022 22:34:14 +0100 Subject: [PATCH 29/40] Add pointer to value converters --- sentry/sentry.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/sentry/sentry.go b/sentry/sentry.go index b9a2cdd..1afa3b1 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -415,11 +415,47 @@ type Rate struct { // Bool returns a pointer to the bool value passed in. func Bool(v bool) *bool { return &v } +// BoolValue returns the value of the bool pointer passed in or +// false if the pointer is nil. +func BoolValue(v *bool) bool { + if v != nil { + return *v + } + return false +} + // Int returns a pointer to the int value passed in. func Int(v int) *int { return &v } +// IntValue returns the value of the int pointer passed in or +// 0 if the pointer is nil. +func IntValue(v *int) int { + if v != nil { + return *v + } + return 0 +} + // String returns a pointer to the string value passed in. func String(v string) *string { return &v } +// StringValue returns the value of the string pointer passed in or +// "" if the pointer is nil. +func StringValue(v *string) string { + if v != nil { + return *v + } + return "" +} + // Time returns a pointer to the time.Time value passed in. func Time(v time.Time) *time.Time { return &v } + +// TimeValue returns the value of the time.Time pointer passed in or +// time.Time{} if the pointer is nil. +func TimeValue(v *time.Time) time.Time { + if v != nil { + return *v + } + return time.Time{} +} From 64ef9646459b1bb2703a7887cfa58d218ed8b5bb Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Wed, 8 Jun 2022 23:50:21 +0100 Subject: [PATCH 30/40] Refactor teams (#53) --- sentry/projects_test.go | 18 +++++---- sentry/teams.go | 28 +++++++------- sentry/teams_test.go | 86 ++++++++++++++++++++--------------------- 3 files changed, 68 insertions(+), 64 deletions(-) diff --git a/sentry/projects_test.go b/sentry/projects_test.go index 15ba929..8be3c6f 100644 --- a/sentry/projects_test.go +++ b/sentry/projects_test.go @@ -421,15 +421,15 @@ func TestProjectsService_Get(t *testing.T) { ScrapeJavaScript: true, Organization: expectedOrganization, Team: Team{ - ID: "2", - Slug: "powerful-abolitionist", - Name: "Powerful Abolitionist", + ID: String("2"), + Slug: String("powerful-abolitionist"), + Name: String("Powerful Abolitionist"), }, Teams: []Team{ { - ID: "2", - Slug: "powerful-abolitionist", - Name: "Powerful Abolitionist", + ID: String("2"), + Slug: String("powerful-abolitionist"), + Name: String("Powerful Abolitionist"), }, }, } @@ -614,7 +614,11 @@ func TestProjectsService_UpdateTeam(t *testing.T) { ID: "5", Slug: "plane-proxy", Name: "Plane Proxy", - Team: Team{ID: "420", Slug: "planet-express", Name: "Planet Express"}, + Team: Team{ + ID: String("420"), + Slug: String("planet-express"), + Name: String("Planet Express"), + }, } assert.Equal(t, expected, project) } diff --git a/sentry/teams.go b/sentry/teams.go index d6e6dc8..5576bae 100644 --- a/sentry/teams.go +++ b/sentry/teams.go @@ -9,16 +9,16 @@ import ( // Team represents a Sentry team that is bound to an organization. // https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/team.py#L109-L119 type Team struct { - ID string `json:"id"` - Slug string `json:"slug"` - Name string `json:"name"` - DateCreated time.Time `json:"dateCreated"` - IsMember bool `json:"isMember"` - TeamRole string `json:"teamRole"` - HasAccess bool `json:"hasAccess"` - IsPending bool `json:"isPending"` - MemberCount int `json:"memberCount"` - Avatar Avatar `json:"avatar"` + ID *string `json:"id,omitempty"` + Slug *string `json:"slug,omitempty"` + Name *string `json:"name,omitempty"` + DateCreated *time.Time `json:"dateCreated,omitempty"` + IsMember *bool `json:"isMember,omitempty"` + TeamRole *string `json:"teamRole,omitempty"` + HasAccess *bool `json:"hasAccess,omitempty"` + IsPending *bool `json:"isPending,omitempty"` + MemberCount *int `json:"memberCount,omitempty"` + Avatar *Avatar `json:"avatar,omitempty"` // TODO: externalTeams // TODO: projects } @@ -63,8 +63,8 @@ func (s *TeamsService) Get(ctx context.Context, organizationSlug string, slug st // CreateTeamParams are the parameters for TeamService.Create. type CreateTeamParams struct { - Name string `json:"name,omitempty"` - Slug string `json:"slug,omitempty"` + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` } // Create a new Sentry team bound to an organization. @@ -86,8 +86,8 @@ func (s *TeamsService) Create(ctx context.Context, organizationSlug string, para // UpdateTeamParams are the parameters for TeamService.Update. type UpdateTeamParams struct { - Name string `json:"name,omitempty"` - Slug string `json:"slug,omitempty"` + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` } // Update settings for a given team. diff --git a/sentry/teams_test.go b/sentry/teams_test.go index 8e30917..e26639a 100644 --- a/sentry/teams_test.go +++ b/sentry/teams_test.go @@ -123,30 +123,30 @@ func TestTeamsService_List(t *testing.T) { expected := []*Team{ { - ID: "3", - Slug: "ancient-gabelers", - Name: "Ancient Gabelers", - DateCreated: mustParseTime("2017-07-18T19:29:46.305Z"), - IsMember: false, - TeamRole: "admin", - HasAccess: true, - IsPending: false, - MemberCount: 1, - Avatar: Avatar{ + ID: String("3"), + Slug: String("ancient-gabelers"), + Name: String("Ancient Gabelers"), + DateCreated: Time(mustParseTime("2017-07-18T19:29:46.305Z")), + IsMember: Bool(false), + TeamRole: String("admin"), + HasAccess: Bool(true), + IsPending: Bool(false), + MemberCount: Int(1), + Avatar: &Avatar{ Type: "letter_avatar", }, }, { - ID: "2", - Slug: "powerful-abolitionist", - Name: "Powerful Abolitionist", - DateCreated: mustParseTime("2017-07-18T19:29:24.743Z"), - IsMember: false, - TeamRole: "admin", - HasAccess: true, - IsPending: false, - MemberCount: 1, - Avatar: Avatar{ + ID: String("2"), + Slug: String("powerful-abolitionist"), + Name: String("Powerful Abolitionist"), + DateCreated: Time(mustParseTime("2017-07-18T19:29:24.743Z")), + IsMember: Bool(false), + TeamRole: String("admin"), + HasAccess: Bool(true), + IsPending: Bool(false), + MemberCount: Int(1), + Avatar: &Avatar{ Type: "letter_avatar", }, }, @@ -188,13 +188,13 @@ func TestTeamsService_Get(t *testing.T) { assert.NoError(t, err) expected := &Team{ - ID: "2", - Slug: "powerful-abolitionist", - Name: "Powerful Abolitionist", - DateCreated: mustParseTime("2017-07-18T19:29:24.743Z"), - HasAccess: true, - IsPending: false, - IsMember: false, + ID: String("2"), + Slug: String("powerful-abolitionist"), + Name: String("Powerful Abolitionist"), + DateCreated: Time(mustParseTime("2017-07-18T19:29:24.743Z")), + HasAccess: Bool(true), + IsPending: Bool(false), + IsMember: Bool(false), } assert.Equal(t, expected, team) } @@ -221,20 +221,20 @@ func TestTeamsService_Create(t *testing.T) { }) params := &CreateTeamParams{ - Name: "Ancient Gabelers", + Name: String("Ancient Gabelers"), } ctx := context.Background() team, _, err := client.Teams.Create(ctx, "the-interstellar-jurisdiction", params) assert.NoError(t, err) expected := &Team{ - ID: "3", - Slug: "ancient-gabelers", - Name: "Ancient Gabelers", - DateCreated: mustParseTime("2017-07-18T19:29:46.305Z"), - HasAccess: true, - IsPending: false, - IsMember: false, + ID: String("3"), + Slug: String("ancient-gabelers"), + Name: String("Ancient Gabelers"), + DateCreated: Time(mustParseTime("2017-07-18T19:29:46.305Z")), + HasAccess: Bool(true), + IsPending: Bool(false), + IsMember: Bool(false), } assert.Equal(t, expected, team) } @@ -261,19 +261,19 @@ func TestTeamsService_Update(t *testing.T) { }) params := &UpdateTeamParams{ - Name: "The Inflated Philosophers", + Name: String("The Inflated Philosophers"), } ctx := context.Background() team, _, err := client.Teams.Update(ctx, "the-interstellar-jurisdiction", "the-obese-philosophers", params) assert.NoError(t, err) expected := &Team{ - ID: "4", - Slug: "the-obese-philosophers", - Name: "The Inflated Philosophers", - DateCreated: mustParseTime("2017-07-18T19:30:14.736Z"), - HasAccess: true, - IsPending: false, - IsMember: false, + ID: String("4"), + Slug: String("the-obese-philosophers"), + Name: String("The Inflated Philosophers"), + DateCreated: Time(mustParseTime("2017-07-18T19:30:14.736Z")), + HasAccess: Bool(true), + IsPending: Bool(false), + IsMember: Bool(false), } assert.Equal(t, expected, team) } From 0f3ec718d87a2d15a8e20695ae0db7fe24f4c12f Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Thu, 9 Jun 2022 22:41:49 +0100 Subject: [PATCH 31/40] Refactor metric alert (#54) --- sentry/metric_alerts.go | 74 ++++++------ sentry/metric_alerts_test.go | 226 ++++++++++++++++++++++++----------- sentry/sentry.go | 14 +++ 3 files changed, 211 insertions(+), 103 deletions(-) diff --git a/sentry/metric_alerts.go b/sentry/metric_alerts.go index 45d883e..325dcd6 100644 --- a/sentry/metric_alerts.go +++ b/sentry/metric_alerts.go @@ -9,22 +9,22 @@ import ( type MetricAlertsService service type MetricAlert struct { - ID string `json:"id"` - Name string `json:"name"` - Environment *string `json:"environment,omitempty"` - DataSet string `json:"dataset"` - Query string `json:"query"` - Aggregate string `json:"aggregate"` - TimeWindow float64 `json:"timeWindow"` - ThresholdType int `json:"thresholdType"` - ResolveThreshold float64 `json:"resolveThreshold"` - Triggers []Trigger `json:"triggers"` - Projects []string `json:"projects"` - Owner string `json:"owner"` - Created time.Time `json:"dateCreated"` + ID *string `json:"id"` + Name *string `json:"name"` + Environment *string `json:"environment,omitempty"` + DataSet *string `json:"dataset"` + Query *string `json:"query"` + Aggregate *string `json:"aggregate"` + TimeWindow *float64 `json:"timeWindow"` + ThresholdType *int `json:"thresholdType"` + ResolveThreshold *float64 `json:"resolveThreshold"` + Triggers []*MetricAlertTrigger `json:"triggers"` + Projects []string `json:"projects"` + Owner *string `json:"owner"` + DateCreated *time.Time `json:"dateCreated"` } -type Trigger map[string]interface{} +type MetricAlertTrigger map[string]interface{} // List Alert Rules configured for a project func (s *MetricAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*MetricAlert, *Response, error) { @@ -34,42 +34,44 @@ func (s *MetricAlertsService) List(ctx context.Context, organizationSlug string, return nil, nil, err } - metricAlerts := []*MetricAlert{} - resp, err := s.client.Do(ctx, req, &metricAlerts) + alerts := []*MetricAlert{} + resp, err := s.client.Do(ctx, req, &alerts) if err != nil { return nil, resp, err } - return metricAlerts, resp, nil + return alerts, resp, nil } -type CreateAlertRuleParams struct { - Name string `json:"name"` - Environment *string `json:"environment,omitempty"` - DataSet string `json:"dataset"` - Query string `json:"query"` - Aggregate string `json:"aggregate"` - TimeWindow float64 `json:"timeWindow"` - ThresholdType int `json:"thresholdType"` - ResolveThreshold float64 `json:"resolveThreshold"` - Triggers []Trigger `json:"triggers"` - Projects []string `json:"projects"` - Owner string `json:"owner"` +// Get details on an issue alert. +func (s *MetricAlertsService) Get(ctx context.Context, organizationSlug string, projectSlug string, id string) (*MetricAlert, *Response, error) { + u := fmt.Sprintf("0/projects/%v/%v/alert-rules/%v/", organizationSlug, projectSlug, id) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + alert := new(MetricAlert) + resp, err := s.client.Do(ctx, req, alert) + if err != nil { + return nil, resp, err + } + return alert, resp, nil } // Create a new Alert Rule bound to a project. -func (s *MetricAlertsService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *CreateAlertRuleParams) (*MetricAlert, *Response, error) { +func (s *MetricAlertsService) Create(ctx context.Context, organizationSlug string, projectSlug string, params *MetricAlert) (*MetricAlert, *Response, error) { u := fmt.Sprintf("0/projects/%v/%v/alert-rules/", organizationSlug, projectSlug) req, err := s.client.NewRequest("POST", u, params) if err != nil { return nil, nil, err } - alertRule := new(MetricAlert) - resp, err := s.client.Do(ctx, req, alertRule) + alert := new(MetricAlert) + resp, err := s.client.Do(ctx, req, alert) if err != nil { return nil, resp, err } - return alertRule, resp, nil + return alert, resp, nil } // Update an Alert Rule. @@ -80,12 +82,12 @@ func (s *MetricAlertsService) Update(ctx context.Context, organizationSlug strin return nil, nil, err } - alertRule := new(MetricAlert) - resp, err := s.client.Do(ctx, req, alertRule) + alert := new(MetricAlert) + resp, err := s.client.Do(ctx, req, alert) if err != nil { return nil, resp, err } - return alertRule, resp, nil + return alert, resp, nil } // Delete an Alert Rule. diff --git a/sentry/metric_alerts_test.go b/sentry/metric_alerts_test.go index 7314302..f1f7d5f 100644 --- a/sentry/metric_alerts_test.go +++ b/sentry/metric_alerts_test.go @@ -67,19 +67,18 @@ func TestMetricAlertService_List(t *testing.T) { alertRules, _, err := client.MetricAlerts.List(ctx, "the-interstellar-jurisdiction", "pump-station") require.NoError(t, err) - environment := "production" expected := []*MetricAlert{ { - ID: "12345", - Name: "pump-station-alert", - Environment: &environment, - DataSet: "transactions", - Query: "http.url:http://service/unreadmessages", - Aggregate: "p50(transaction.duration)", - ThresholdType: int(0), - ResolveThreshold: float64(100.0), - TimeWindow: float64(5.0), - Triggers: []Trigger{ + ID: String("12345"), + Name: String("pump-station-alert"), + Environment: String("production"), + DataSet: String("transactions"), + Query: String("http.url:http://service/unreadmessages"), + Aggregate: String("p50(transaction.duration)"), + ThresholdType: Int(0), + ResolveThreshold: Float64(100.0), + TimeWindow: Float64(5.0), + Triggers: []*MetricAlertTrigger{ { "id": "6789", "alertRuleId": "12345", @@ -103,14 +102,109 @@ func TestMetricAlertService_List(t *testing.T) { }, }, }, - Projects: []string{"pump-station"}, - Owner: "pump-station:12345", - Created: mustParseTime("2022-04-07T16:46:48.569571Z"), + Projects: []string{"pump-station"}, + Owner: String("pump-station:12345"), + DateCreated: Time(mustParseTime("2022-04-07T16:46:48.569571Z")), }, } require.Equal(t, expected, alertRules) } +func TestMetricAlertService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/12345/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, ` + { + "id": "12345", + "name": "pump-station-alert", + "environment": "production", + "dataset": "transactions", + "query": "http.url:http://service/unreadmessages", + "aggregate": "p50(transaction.duration)", + "timeWindow": 10, + "thresholdType": 0, + "resolveThreshold": 0, + "triggers": [ + { + "actions": [ + { + "alertRuleTriggerId": "56789", + "dateCreated": "2022-04-15T15:06:01.087054Z", + "desc": "Send a Slack notification to #alert-rule-alerts", + "id": "12389", + "inputChannelId": "C0XXXFKLXXX", + "integrationId": 111, + "sentryAppId": null, + "targetIdentifier": "#alert-rule-alerts", + "targetType": "specific", + "type": "slack" + } + ], + "alertRuleId": "12345", + "alertThreshold": 10000, + "dateCreated": "2022-04-15T15:06:01.079598Z", + "id": "56789", + "label": "critical", + "resolveThreshold": 0, + "thresholdType": 0 + } + ], + "projects": [ + "pump-station" + ], + "owner": "pump-station:12345", + "dateCreated": "2022-04-15T15:06:01.05618Z" + } + `) + }) + + ctx := context.Background() + alert, _, err := client.MetricAlerts.Get(ctx, "the-interstellar-jurisdiction", "pump-station", "12345") + require.NoError(t, err) + + expected := &MetricAlert{ + ID: String("12345"), + Name: String("pump-station-alert"), + Environment: String("production"), + DataSet: String("transactions"), + Query: String("http.url:http://service/unreadmessages"), + Aggregate: String("p50(transaction.duration)"), + TimeWindow: Float64(10), + ThresholdType: Int(0), + ResolveThreshold: Float64(0), + Triggers: []*MetricAlertTrigger{{ + "actions": []interface{}{map[string]interface{}{ + "alertRuleTriggerId": "56789", + "dateCreated": "2022-04-15T15:06:01.087054Z", + "desc": "Send a Slack notification to #alert-rule-alerts", + "id": "12389", + "inputChannelId": "C0XXXFKLXXX", + "integrationId": json.Number("111"), + "sentryAppId": nil, + "targetIdentifier": "#alert-rule-alerts", + "targetType": "specific", + "type": "slack", + }, + }, + "alertRuleId": "12345", + "alertThreshold": json.Number("10000"), + "dateCreated": "2022-04-15T15:06:01.079598Z", + "id": "56789", + "label": "critical", + "resolveThreshold": json.Number("0"), + "thresholdType": json.Number("0"), + }}, + Projects: []string{"pump-station"}, + Owner: String("pump-station:12345"), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.05618Z")), + } + require.Equal(t, expected, alert) +} + func TestMetricAlertService_Create(t *testing.T) { client, mux, _, teardown := setup() defer teardown() @@ -163,17 +257,16 @@ func TestMetricAlertService_Create(t *testing.T) { `) }) - environment := "production" - params := CreateAlertRuleParams{ - Name: "pump-station-alert", - Environment: &environment, - DataSet: "transactions", - Query: "http.url:http://service/unreadmessages", - Aggregate: "p50(transaction.duration)", - TimeWindow: 10.0, - ThresholdType: 0, - ResolveThreshold: 0, - Triggers: []Trigger{{ + params := &MetricAlert{ + Name: String("pump-station-alert"), + Environment: String("production"), + DataSet: String("transactions"), + Query: String("http.url:http://service/unreadmessages"), + Aggregate: String("p50(transaction.duration)"), + TimeWindow: Float64(10.0), + ThresholdType: Int(0), + ResolveThreshold: Float64(0), + Triggers: []*MetricAlertTrigger{{ "actions": []interface{}{map[string]interface{}{ "type": "slack", "targetType": "specific", @@ -188,23 +281,23 @@ func TestMetricAlertService_Create(t *testing.T) { "thresholdType": 0, }}, Projects: []string{"pump-station"}, - Owner: "pump-station:12345", + Owner: String("pump-station:12345"), } ctx := context.Background() - alertRule, _, err := client.MetricAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", ¶ms) + alertRule, _, err := client.MetricAlerts.Create(ctx, "the-interstellar-jurisdiction", "pump-station", params) require.NoError(t, err) expected := &MetricAlert{ - ID: "12345", - Name: "pump-station-alert", - Environment: &environment, - DataSet: "transactions", - Query: "http.url:http://service/unreadmessages", - Aggregate: "p50(transaction.duration)", - ThresholdType: int(0), - ResolveThreshold: float64(0), - TimeWindow: float64(10.0), - Triggers: []Trigger{ + ID: String("12345"), + Name: String("pump-station-alert"), + Environment: String("production"), + DataSet: String("transactions"), + Query: String("http.url:http://service/unreadmessages"), + Aggregate: String("p50(transaction.duration)"), + ThresholdType: Int(0), + ResolveThreshold: Float64(0), + TimeWindow: Float64(10.0), + Triggers: []*MetricAlertTrigger{ { "id": "56789", "alertRuleId": "12345", @@ -228,9 +321,9 @@ func TestMetricAlertService_Create(t *testing.T) { }, }, }, - Projects: []string{"pump-station"}, - Owner: "pump-station:12345", - Created: mustParseTime("2022-04-15T15:06:01.05618Z"), + Projects: []string{"pump-station"}, + Owner: String("pump-station:12345"), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.05618Z")), } require.Equal(t, expected, alertRule) @@ -240,18 +333,17 @@ func TestMetricAlertService_Update(t *testing.T) { client, mux, _, teardown := setup() defer teardown() - environment := "production" params := &MetricAlert{ - ID: "12345", - Name: "pump-station-alert", - Environment: &environment, - DataSet: "transactions", - Query: "http.url:http://service/unreadmessages", - Aggregate: "p50(transaction.duration)", - TimeWindow: 10, - ThresholdType: 0, - ResolveThreshold: 0, - Triggers: []Trigger{{ + ID: String("12345"), + Name: String("pump-station-alert"), + Environment: String("production"), + DataSet: String("transactions"), + Query: String("http.url:http://service/unreadmessages"), + Aggregate: String("p50(transaction.duration)"), + TimeWindow: Float64(10), + ThresholdType: Int(0), + ResolveThreshold: Float64(0), + Triggers: []*MetricAlertTrigger{{ "actions": []interface{}{map[string]interface{}{}}, "alertRuleId": "12345", "alertThreshold": json.Number("10000"), @@ -261,8 +353,8 @@ func TestMetricAlertService_Update(t *testing.T) { "resolveThreshold": json.Number("0"), "thresholdType": json.Number("0"), }}, - Owner: "pump-station:12345", - Created: mustParseTime("2022-04-15T15:06:01.079598Z"), + Owner: String("pump-station:12345"), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.079598Z")), } mux.HandleFunc("/api/0/projects/the-interstellar-jurisdiction/pump-station/alert-rules/12345/", func(w http.ResponseWriter, r *http.Request) { @@ -270,7 +362,7 @@ func TestMetricAlertService_Update(t *testing.T) { assertPostJSON(t, map[string]interface{}{ "id": "12345", "name": "pump-station-alert", - "environment": environment, + "environment": "production", "dataset": "transactions", "query": "http.url:http://service/unreadmessages", "aggregate": "p50(transaction.duration)", @@ -344,16 +436,16 @@ func TestMetricAlertService_Update(t *testing.T) { assert.NoError(t, err) expected := &MetricAlert{ - ID: "12345", - Name: "pump-station-alert", - Environment: &environment, - DataSet: "transactions", - Query: "http.url:http://service/unreadmessages", - Aggregate: "p50(transaction.duration)", - ThresholdType: int(0), - ResolveThreshold: float64(0), - TimeWindow: float64(10.0), - Triggers: []Trigger{ + ID: String("12345"), + Name: String("pump-station-alert"), + Environment: String("production"), + DataSet: String("transactions"), + Query: String("http.url:http://service/unreadmessages"), + Aggregate: String("p50(transaction.duration)"), + ThresholdType: Int(0), + ResolveThreshold: Float64(0), + TimeWindow: Float64(10.0), + Triggers: []*MetricAlertTrigger{ { "id": "56789", "alertRuleId": "12345", @@ -376,9 +468,9 @@ func TestMetricAlertService_Update(t *testing.T) { }}, }, }, - Projects: []string{"pump-station"}, - Owner: "pump-station:12345", - Created: mustParseTime("2022-04-15T15:06:01.05618Z"), + Projects: []string{"pump-station"}, + Owner: String("pump-station:12345"), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.05618Z")), } require.Equal(t, expected, alertRule) diff --git a/sentry/sentry.go b/sentry/sentry.go index 1afa3b1..18d6ec5 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -436,6 +436,20 @@ func IntValue(v *int) int { return 0 } +// Float64 returns a pointer to the float64 value passed in. +func Float64(v float64) *float64 { + return &v +} + +// Float64Value returns the value of the float64 pointer passed in or +// 0 if the pointer is nil. +func Float64Value(v *float64) float64 { + if v != nil { + return *v + } + return 0 +} + // String returns a pointer to the string value passed in. func String(v string) *string { return &v } From 9059c8d7de465bb0beb27b4e14573f21c7d9a14b Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Sun, 12 Jun 2022 02:23:52 +0100 Subject: [PATCH 32/40] Convert MetricAlertTrigger and MetricAlertTriggerAction into structs --- sentry/metric_alerts.go | 28 ++++- sentry/metric_alerts_test.go | 226 +++++++++++++++++++---------------- 2 files changed, 147 insertions(+), 107 deletions(-) diff --git a/sentry/metric_alerts.go b/sentry/metric_alerts.go index 325dcd6..5bfcdc3 100644 --- a/sentry/metric_alerts.go +++ b/sentry/metric_alerts.go @@ -24,7 +24,33 @@ type MetricAlert struct { DateCreated *time.Time `json:"dateCreated"` } -type MetricAlertTrigger map[string]interface{} +// MetricAlertTrigger represents a metric alert trigger. +// https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/alert_rule_trigger.py#L35-L47 +type MetricAlertTrigger struct { + ID *string `json:"id,omitempty"` + AlertRuleID *string `json:"alertRuleId,omitempty"` + Label *string `json:"label,omitempty"` + ThresholdType *int `json:"thresholdType,omitempty"` + AlertThreshold *float64 `json:"alertThreshold,omitempty"` + ResolveThreshold *float64 `json:"resolveThreshold,omitempty"` + DateCreated *time.Time `json:"dateCreated,omitempty"` + Actions []*MetricAlertTriggerAction `json:"actions,omitempty"` +} + +// MetricAlertTriggerAction represents a metric alert trigger action. +// https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/models/alert_rule_trigger_action.py#L42-L66 +type MetricAlertTriggerAction struct { + ID *string `json:"id,omitempty"` + AlertRuleTriggerID *string `json:"alertRuleTriggerId,omitempty"` + Type *string `json:"type,omitempty"` + TargetType *string `json:"targetType,omitempty"` + TargetIdentifier *string `json:"targetIdentifier,omitempty"` + InputChannelID *string `json:"inputChannelId,omitempty"` + IntegrationID *int `json:"integrationId,omitempty"` + SentryAppID *string `json:"sentryAppId,omitempty"` + DateCreated *time.Time `json:"dateCreated,omitempty"` + Description *string `json:"desc,omitempty"` +} // List Alert Rules configured for a project func (s *MetricAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*MetricAlert, *Response, error) { diff --git a/sentry/metric_alerts_test.go b/sentry/metric_alerts_test.go index f1f7d5f..47f3e70 100644 --- a/sentry/metric_alerts_test.go +++ b/sentry/metric_alerts_test.go @@ -80,25 +80,25 @@ func TestMetricAlertService_List(t *testing.T) { TimeWindow: Float64(5.0), Triggers: []*MetricAlertTrigger{ { - "id": "6789", - "alertRuleId": "12345", - "label": "critical", - "thresholdType": json.Number("0"), - "alertThreshold": json.Number("55501.0"), - "resolveThreshold": json.Number("100.0"), - "dateCreated": "2022-04-07T16:46:48.607583Z", - "actions": []interface{}{map[string]interface{}{ - "id": "12345", - "alertRuleTriggerId": "12345", - "type": "slack", - "targetType": "specific", - "targetIdentifier": "#alert-rule-alerts", - "inputChannelId": "C038NF00X4F", - "integrationId": json.Number("123"), - "sentryAppId": interface{}(nil), - "dateCreated": "2022-04-07T16:46:49.154638Z", - "desc": "Send a Slack notification to #alert-rule-alerts", - }, + ID: String("6789"), + AlertRuleID: String("12345"), + Label: String("critical"), + ThresholdType: Int(0), + AlertThreshold: Float64(55501.0), + ResolveThreshold: Float64(100.0), + DateCreated: Time(mustParseTime("2022-04-07T16:46:48.607583Z")), + Actions: []*MetricAlertTriggerAction{ + { + ID: String("12345"), + AlertRuleTriggerID: String("12345"), + Type: String("slack"), + TargetType: String("specific"), + TargetIdentifier: String("#alert-rule-alerts"), + InputChannelID: String("C038NF00X4F"), + IntegrationID: Int(123), + DateCreated: Time(mustParseTime("2022-04-07T16:46:49.154638Z")), + Description: String("Send a Slack notification to #alert-rule-alerts"), + }, }, }, }, @@ -176,28 +176,30 @@ func TestMetricAlertService_Get(t *testing.T) { TimeWindow: Float64(10), ThresholdType: Int(0), ResolveThreshold: Float64(0), - Triggers: []*MetricAlertTrigger{{ - "actions": []interface{}{map[string]interface{}{ - "alertRuleTriggerId": "56789", - "dateCreated": "2022-04-15T15:06:01.087054Z", - "desc": "Send a Slack notification to #alert-rule-alerts", - "id": "12389", - "inputChannelId": "C0XXXFKLXXX", - "integrationId": json.Number("111"), - "sentryAppId": nil, - "targetIdentifier": "#alert-rule-alerts", - "targetType": "specific", - "type": "slack", - }, + Triggers: []*MetricAlertTrigger{ + { + ID: String("56789"), + AlertRuleID: String("12345"), + Label: String("critical"), + ThresholdType: Int(0), + AlertThreshold: Float64(10000.0), + ResolveThreshold: Float64(0.0), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.079598Z")), + Actions: []*MetricAlertTriggerAction{ + { + ID: String("12389"), + AlertRuleTriggerID: String("56789"), + Type: String("slack"), + TargetType: String("specific"), + TargetIdentifier: String("#alert-rule-alerts"), + InputChannelID: String("C0XXXFKLXXX"), + IntegrationID: Int(111), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), + Description: String("Send a Slack notification to #alert-rule-alerts"), + }, + }, }, - "alertRuleId": "12345", - "alertThreshold": json.Number("10000"), - "dateCreated": "2022-04-15T15:06:01.079598Z", - "id": "56789", - "label": "critical", - "resolveThreshold": json.Number("0"), - "thresholdType": json.Number("0"), - }}, + }, Projects: []string{"pump-station"}, Owner: String("pump-station:12345"), DateCreated: Time(mustParseTime("2022-04-15T15:06:01.05618Z")), @@ -266,20 +268,30 @@ func TestMetricAlertService_Create(t *testing.T) { TimeWindow: Float64(10.0), ThresholdType: Int(0), ResolveThreshold: Float64(0), - Triggers: []*MetricAlertTrigger{{ - "actions": []interface{}{map[string]interface{}{ - "type": "slack", - "targetType": "specific", - "targetIdentifier": "#alert-rule-alerts", - "inputChannelId": "C0XXXFKLXXX", - "integrationId": 111, - }, + Triggers: []*MetricAlertTrigger{ + { + ID: String("56789"), + AlertRuleID: String("12345"), + Label: String("critical"), + ThresholdType: Int(0), + AlertThreshold: Float64(55501.0), + ResolveThreshold: Float64(100.0), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.079598Z")), + Actions: []*MetricAlertTriggerAction{ + { + ID: String("12389"), + AlertRuleTriggerID: String("56789"), + Type: String("slack"), + TargetType: String("specific"), + TargetIdentifier: String("#alert-rule-alerts"), + InputChannelID: String("C0XXXFKLXXX"), + IntegrationID: Int(123), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), + Description: String("Send a Slack notification to #alert-rule-alerts"), + }, + }, }, - "alertThreshold": 10000, - "label": "critical", - "resolveThreshold": 0, - "thresholdType": 0, - }}, + }, Projects: []string{"pump-station"}, Owner: String("pump-station:12345"), } @@ -299,25 +311,25 @@ func TestMetricAlertService_Create(t *testing.T) { TimeWindow: Float64(10.0), Triggers: []*MetricAlertTrigger{ { - "id": "56789", - "alertRuleId": "12345", - "label": "critical", - "thresholdType": json.Number("0"), - "alertThreshold": json.Number("10000"), - "resolveThreshold": json.Number("0"), - "dateCreated": "2022-04-15T15:06:01.079598Z", - "actions": []interface{}{map[string]interface{}{ - "id": "12389", - "alertRuleTriggerId": "56789", - "type": "slack", - "targetType": "specific", - "targetIdentifier": "#alert-rule-alerts", - "inputChannelId": "C0XXXFKLXXX", - "integrationId": json.Number("111"), - "sentryAppId": interface{}(nil), - "dateCreated": "2022-04-15T15:06:01.087054Z", - "desc": "Send a Slack notification to #alert-rule-alerts", - }, + ID: String("56789"), + AlertRuleID: String("12345"), + Label: String("critical"), + ThresholdType: Int(0), + AlertThreshold: Float64(10000.0), + ResolveThreshold: Float64(0.0), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.079598Z")), + Actions: []*MetricAlertTriggerAction{ + { + ID: String("12389"), + AlertRuleTriggerID: String("56789"), + Type: String("slack"), + TargetType: String("specific"), + TargetIdentifier: String("#alert-rule-alerts"), + InputChannelID: String("C0XXXFKLXXX"), + IntegrationID: Int(111), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), + Description: String("Send a Slack notification to #alert-rule-alerts"), + }, }, }, }, @@ -343,16 +355,18 @@ func TestMetricAlertService_Update(t *testing.T) { TimeWindow: Float64(10), ThresholdType: Int(0), ResolveThreshold: Float64(0), - Triggers: []*MetricAlertTrigger{{ - "actions": []interface{}{map[string]interface{}{}}, - "alertRuleId": "12345", - "alertThreshold": json.Number("10000"), - "dateCreated": "2022-04-15T15:06:01.079598Z", - "id": "56789", - "label": "critical", - "resolveThreshold": json.Number("0"), - "thresholdType": json.Number("0"), - }}, + Triggers: []*MetricAlertTrigger{ + { + ID: String("6789"), + AlertRuleID: String("12345"), + Label: String("critical"), + ThresholdType: Int(0), + AlertThreshold: Float64(55501.0), + ResolveThreshold: Float64(100.0), + DateCreated: Time(mustParseTime("2022-04-07T16:46:48.607583Z")), + Actions: []*MetricAlertTriggerAction{}, + }, + }, Owner: String("pump-station:12345"), DateCreated: Time(mustParseTime("2022-04-15T15:06:01.079598Z")), } @@ -371,14 +385,13 @@ func TestMetricAlertService_Update(t *testing.T) { "resolveThreshold": json.Number("0"), "triggers": []interface{}{ map[string]interface{}{ - "actions": []interface{}{map[string]interface{}{}}, + "id": "6789", "alertRuleId": "12345", - "alertThreshold": json.Number("10000"), - "dateCreated": "2022-04-15T15:06:01.079598Z", - "id": "56789", "label": "critical", - "resolveThreshold": json.Number("0"), "thresholdType": json.Number("0"), + "alertThreshold": json.Number("55501"), + "resolveThreshold": json.Number("100"), + "dateCreated": "2022-04-07T16:46:48.607583Z", }, }, "projects": interface{}(nil), @@ -447,25 +460,26 @@ func TestMetricAlertService_Update(t *testing.T) { TimeWindow: Float64(10.0), Triggers: []*MetricAlertTrigger{ { - "id": "56789", - "alertRuleId": "12345", - "label": "critical", - "thresholdType": json.Number("0"), - "alertThreshold": json.Number("10000"), - "resolveThreshold": json.Number("0"), - "dateCreated": "2022-04-15T15:06:01.079598Z", - "actions": []interface{}{map[string]interface{}{ - "id": "12389", - "alertRuleTriggerId": "56789", - "type": "slack", - "targetType": "specific", - "targetIdentifier": "#alert-rule-alerts", - "inputChannelId": "C0XXXFKLXXX", - "integrationId": json.Number("111"), - "sentryAppId": interface{}(nil), - "dateCreated": "2022-04-15T15:06:01.087054Z", - "desc": "Send a Slack notification to #alert-rule-alerts", - }}, + ID: String("56789"), + AlertRuleID: String("12345"), + Label: String("critical"), + ThresholdType: Int(0), + AlertThreshold: Float64(10000.0), + ResolveThreshold: Float64(0.0), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.079598Z")), + Actions: []*MetricAlertTriggerAction{ + { + ID: String("12389"), + AlertRuleTriggerID: String("56789"), + Type: String("slack"), + TargetType: String("specific"), + TargetIdentifier: String("#alert-rule-alerts"), + InputChannelID: String("C0XXXFKLXXX"), + IntegrationID: Int(111), + DateCreated: Time(mustParseTime("2022-04-15T15:06:01.087054Z")), + Description: String("Send a Slack notification to #alert-rule-alerts"), + }, + }, }, }, Projects: []string{"pump-station"}, From 5a227ec5965bdffea37d4b2466b9d3b451d2cbce Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Sun, 12 Jun 2022 16:24:58 +0100 Subject: [PATCH 33/40] Dashboard widget validation endpoint (#57) --- sentry/dashboard_widgets.go | 62 +++++++++++++++++++++ sentry/dashboard_widgets_test.go | 95 ++++++++++++++++++++++++++++++++ sentry/sentry.go | 2 + 3 files changed, 159 insertions(+) create mode 100644 sentry/dashboard_widgets.go create mode 100644 sentry/dashboard_widgets_test.go diff --git a/sentry/dashboard_widgets.go b/sentry/dashboard_widgets.go new file mode 100644 index 0000000..23c1dd1 --- /dev/null +++ b/sentry/dashboard_widgets.go @@ -0,0 +1,62 @@ +package sentry + +import ( + "context" + "fmt" +) + +// DashboardWidget represents a Dashboard Widget. +// https://github.com/getsentry/sentry/blob/22.5.0/src/sentry/api/serializers/rest_framework/dashboard.py#L230-L243 +type DashboardWidget struct { + ID *string `json:"id,omitempty"` + Title *string `json:"title,omitempty"` + DisplayType *string `json:"displayType,omitempty"` + Interval *string `json:"interval,omitempty"` + Queries []*DashboardWidgetQuery `json:"queries,omitempty"` + WidgetType *string `json:"widgetType,omitempty"` + Limit *int `json:"limit,omitempty"` + Layout *DashboardWidgetLayout `json:"layout,omitempty"` +} + +type DashboardWidgetLayout struct { + X *int `json:"x,omitempty"` + Y *int `json:"y,omitempty"` + W *int `json:"w,omitempty"` + H *int `json:"h,omitempty"` + MinH *int `json:"minH,omitempty"` +} + +type DashboardWidgetQuery struct { + ID *string `json:"id,omitempty"` + Fields []string `json:"fields,omitempty"` + Aggregates []string `json:"aggregates,omitempty"` + Columns []string `json:"columns,omitempty"` + FieldAliases []string `json:"fieldAliases,omitempty"` + Name *string `json:"name,omitempty"` + Conditions *string `json:"conditions,omitempty"` + OrderBy *string `json:"orderby,omitempty"` +} + +// DashboardWidgetsService provides methods for accessing Sentry dashboard widget API endpoints. +type DashboardWidgetsService service + +type DashboardWidgetErrors map[string][]string + +func (s *DashboardWidgetsService) Validate(ctx context.Context, organizationSlug string, widget *DashboardWidget) (DashboardWidgetErrors, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/dashboards/widgets/", organizationSlug) + + req, err := s.client.NewRequest("POST", u, widget) + if err != nil { + return nil, nil, err + } + + widgetErrors := make(DashboardWidgetErrors) + resp, err := s.client.Do(ctx, req, &widgetErrors) + if err != nil { + return nil, resp, err + } + if len(widgetErrors) == 0 { + return nil, resp, err + } + return widgetErrors, resp, err +} diff --git a/sentry/dashboard_widgets_test.go b/sentry/dashboard_widgets_test.go new file mode 100644 index 0000000..727b642 --- /dev/null +++ b/sentry/dashboard_widgets_test.go @@ -0,0 +1,95 @@ +package sentry + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDashboardWidgetsService_Validate_pass(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/widgets/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{}`) + }) + + widget := &DashboardWidget{ + Title: String("Number of Errors"), + DisplayType: String("big_number"), + Interval: String("5m"), + Queries: []*DashboardWidgetQuery{ + { + ID: String("115037"), + Fields: []string{"count()"}, + Aggregates: []string{"count()"}, + Columns: []string{}, + FieldAliases: []string{}, + Name: String(""), + Conditions: String("!event.type:transaction"), + OrderBy: String(""), + }, + }, + WidgetType: String("discover"), + Layout: &DashboardWidgetLayout{ + X: Int(0), + Y: Int(0), + W: Int(2), + H: Int(1), + MinH: Int(1), + }, + } + ctx := context.Background() + widgetErrors, _, err := client.DashboardWidgets.Validate(ctx, "the-interstellar-jurisdiction", widget) + assert.Nil(t, widgetErrors) + assert.NoError(t, err) +} + +func TestDashboardWidgetsService_Validate_fail(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/widgets/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"widgetType":["\"discover-invalid\" is not a valid choice."]}`) + }) + + widget := &DashboardWidget{ + Title: String("Number of Errors"), + DisplayType: String("big_number"), + Interval: String("5m"), + Queries: []*DashboardWidgetQuery{ + { + ID: String("115037"), + Fields: []string{"count()"}, + Aggregates: []string{"count()"}, + Columns: []string{}, + FieldAliases: []string{}, + Name: String(""), + Conditions: String("!event.type:transaction"), + OrderBy: String(""), + }, + }, + WidgetType: String("discover-invalid"), + Layout: &DashboardWidgetLayout{ + X: Int(0), + Y: Int(0), + W: Int(2), + H: Int(1), + MinH: Int(1), + }, + } + ctx := context.Background() + widgetErrors, _, err := client.DashboardWidgets.Validate(ctx, "the-interstellar-jurisdiction", widget) + expected := DashboardWidgetErrors{ + "widgetType": []string{`"discover-invalid" is not a valid choice.`}, + } + assert.Equal(t, expected, widgetErrors) + assert.NoError(t, err) +} diff --git a/sentry/sentry.go b/sentry/sentry.go index 18d6ec5..5d4b68d 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -50,6 +50,7 @@ type Client struct { common service // Services + DashboardWidgets *DashboardWidgetsService IssueAlerts *IssueAlertsService MetricAlerts *MetricAlertsService OrganizationMembers *OrganizationMembersService @@ -79,6 +80,7 @@ func NewClient(httpClient *http.Client) *Client { UserAgent: userAgent, } c.common.client = c + c.DashboardWidgets = (*DashboardWidgetsService)(&c.common) c.IssueAlerts = (*IssueAlertsService)(&c.common) c.MetricAlerts = (*MetricAlertsService)(&c.common) c.OrganizationMembers = (*OrganizationMembersService)(&c.common) From df6d8313ac83cc0d6fe48bdba655b25836d0dd6a Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Sun, 12 Jun 2022 17:00:16 +0100 Subject: [PATCH 34/40] New Dashboards service (#58) * Implement DashboardsService.List * Implement DashboardsService.Get * Implement DashboardsService.Create * Implement DashboardsService.Update * Implement DashboardsService.Delete * Add cursor params to MetricAlertsService.List --- sentry/dashboard_widgets.go | 1 + sentry/dashboards.go | 98 +++++++++++++++ sentry/dashboards_test.go | 229 +++++++++++++++++++++++++++++++++++ sentry/metric_alerts.go | 7 +- sentry/metric_alerts_test.go | 2 +- sentry/sentry.go | 2 + 6 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 sentry/dashboards.go create mode 100644 sentry/dashboards_test.go diff --git a/sentry/dashboard_widgets.go b/sentry/dashboard_widgets.go index 23c1dd1..12412e8 100644 --- a/sentry/dashboard_widgets.go +++ b/sentry/dashboard_widgets.go @@ -42,6 +42,7 @@ type DashboardWidgetsService service type DashboardWidgetErrors map[string][]string +// Validate a dashboard widget configuration. func (s *DashboardWidgetsService) Validate(ctx context.Context, organizationSlug string, widget *DashboardWidget) (DashboardWidgetErrors, *Response, error) { u := fmt.Sprintf("0/organizations/%v/dashboards/widgets/", organizationSlug) diff --git a/sentry/dashboards.go b/sentry/dashboards.go new file mode 100644 index 0000000..1d2ca9d --- /dev/null +++ b/sentry/dashboards.go @@ -0,0 +1,98 @@ +package sentry + +import ( + "context" + "fmt" + "time" +) + +// Dashboard represents a Dashboard. +type Dashboard struct { + ID *string `json:"id,omitempty"` + Title *string `json:"title,omitempty"` + DateCreated *time.Time `json:"dateCreated,omitempty"` + Widgets []*DashboardWidget `json:"widgets,omitempty"` +} + +// DashboardsService provides methods for accessing Sentry dashboard API endpoints. +type DashboardsService service + +// List dashboards in an organization. +func (s *DashboardsService) List(ctx context.Context, organizationSlug string, params *ListCursorParams) ([]*Dashboard, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/dashboards/", organizationSlug) + u, err := addQuery(u, params) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + var dashboards []*Dashboard + resp, err := s.client.Do(ctx, req, &dashboards) + if err != nil { + return nil, resp, err + } + return dashboards, resp, nil +} + +// Get details on a dashboard. +func (s *DashboardsService) Get(ctx context.Context, organizationSlug string, id string) (*Dashboard, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/dashboards/%v/", organizationSlug, id) + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + dashboard := new(Dashboard) + resp, err := s.client.Do(ctx, req, dashboard) + if err != nil { + return nil, resp, err + } + return dashboard, resp, nil +} + +// Create a dashboard. +func (s *DashboardsService) Create(ctx context.Context, organizationSlug string, params *Dashboard) (*Dashboard, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/dashboards/", organizationSlug) + req, err := s.client.NewRequest("POST", u, params) + if err != nil { + return nil, nil, err + } + + dashboard := new(Dashboard) + resp, err := s.client.Do(ctx, req, dashboard) + if err != nil { + return nil, resp, err + } + return dashboard, resp, nil +} + +// Update a dashboard. +func (s *DashboardsService) Update(ctx context.Context, organizationSlug string, id string, params *Dashboard) (*Dashboard, *Response, error) { + u := fmt.Sprintf("0/organizations/%v/dashboards/%v/", organizationSlug, id) + req, err := s.client.NewRequest("PUT", u, params) + if err != nil { + return nil, nil, err + } + + dashboard := new(Dashboard) + resp, err := s.client.Do(ctx, req, dashboard) + if err != nil { + return nil, resp, err + } + return dashboard, resp, nil +} + +// Delete a dashboard. +func (s *DashboardsService) Delete(ctx context.Context, organizationSlug string, id string) (*Response, error) { + u := fmt.Sprintf("0/organizations/%v/dashboards/%v/", organizationSlug, id) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + return s.client.Do(ctx, req, nil) +} diff --git a/sentry/dashboards_test.go b/sentry/dashboards_test.go new file mode 100644 index 0000000..5ab0e13 --- /dev/null +++ b/sentry/dashboards_test.go @@ -0,0 +1,229 @@ +package sentry + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDashboardsService_List(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[ + { + "id": "11833", + "title": "General", + "dateCreated": "2022-06-07T16:48:26.255520Z" + }, + { + "id": "11832", + "title": "Mobile Template", + "dateCreated": "2022-06-07T16:43:40.456607Z" + } + ]`) + }) + + ctx := context.Background() + widgetErrors, _, err := client.Dashboards.List(ctx, "the-interstellar-jurisdiction", nil) + + expected := []*Dashboard{ + { + ID: String("11833"), + Title: String("General"), + DateCreated: Time(mustParseTime("2022-06-07T16:48:26.255520Z")), + }, + { + ID: String("11832"), + Title: String("Mobile Template"), + DateCreated: Time(mustParseTime("2022-06-07T16:43:40.456607Z")), + }, + } + assert.Equal(t, expected, widgetErrors) + assert.NoError(t, err) +} + +func TestDashboardsService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/12072/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "GET", r) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "12072", + "title": "General", + "dateCreated": "2022-06-07T16:48:26.255520Z", + "widgets": [ + { + "id": "105567", + "title": "Custom Widget", + "displayType": "world_map", + "interval": "5m", + "dateCreated": "2022-06-12T15:37:19.886736Z", + "dashboardId": "12072", + "queries": [ + { + "id": "117838", + "name": "", + "fields": [ + "count()" + ], + "aggregates": [ + "count()" + ], + "columns": [], + "fieldAliases": [], + "conditions": "", + "orderby": "", + "widgetId": "105567" + } + ], + "limit": null, + "widgetType": "discover", + "layout": { + "y": 0, + "x": 0, + "h": 2, + "minH": 2, + "w": 2 + } + } + ] + }`) + }) + + ctx := context.Background() + dashboard, _, err := client.Dashboards.Get(ctx, "the-interstellar-jurisdiction", "12072") + + expected := &Dashboard{ + ID: String("12072"), + Title: String("General"), + DateCreated: Time(mustParseTime("2022-06-07T16:48:26.255520Z")), + Widgets: []*DashboardWidget{ + { + ID: String("105567"), + Title: String("Custom Widget"), + DisplayType: String("world_map"), + Interval: String("5m"), + Queries: []*DashboardWidgetQuery{ + { + ID: String("117838"), + Fields: []string{"count()"}, + Aggregates: []string{"count()"}, + Columns: []string{}, + FieldAliases: []string{}, + Name: String(""), + Conditions: String(""), + OrderBy: String(""), + }, + }, + WidgetType: String("discover"), + Layout: &DashboardWidgetLayout{ + X: Int(0), + Y: Int(0), + W: Int(2), + H: Int(2), + MinH: Int(2), + }, + }, + }, + } + assert.Equal(t, expected, dashboard) + assert.NoError(t, err) +} + +func TestDashboardsService_Create(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "POST", r) + assertPostJSONValue(t, map[string]interface{}{ + "title": "General", + "widgets": map[string]interface{}{}, + }, r) + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "12072", + "title": "General", + "dateCreated": "2022-06-07T16:48:26.255520Z", + "widgets": [] + }`) + }) + + params := &Dashboard{ + Title: String("General"), + Widgets: []*DashboardWidget{}, + } + ctx := context.Background() + dashboard, _, err := client.Dashboards.Create(ctx, "the-interstellar-jurisdiction", params) + + expected := &Dashboard{ + ID: String("12072"), + Title: String("General"), + DateCreated: Time(mustParseTime("2022-06-07T16:48:26.255520Z")), + Widgets: []*DashboardWidget{}, + } + assert.Equal(t, expected, dashboard) + assert.NoError(t, err) +} + +func TestDashboardsService_Update(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/12072/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "PUT", r) + assertPostJSONValue(t, map[string]interface{}{ + "id": "12072", + "title": "General", + "widgets": map[string]interface{}{}, + }, r) + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "id": "12072", + "title": "General", + "dateCreated": "2022-06-07T16:48:26.255520Z", + "widgets": [] + }`) + }) + + params := &Dashboard{ + ID: String("12072"), + Title: String("General"), + Widgets: []*DashboardWidget{}, + } + ctx := context.Background() + dashboard, _, err := client.Dashboards.Update(ctx, "the-interstellar-jurisdiction", "12072", params) + + expected := &Dashboard{ + ID: String("12072"), + Title: String("General"), + DateCreated: Time(mustParseTime("2022-06-07T16:48:26.255520Z")), + Widgets: []*DashboardWidget{}, + } + assert.Equal(t, expected, dashboard) + assert.NoError(t, err) +} + +func TestDashboardsService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + mux.HandleFunc("/api/0/organizations/the-interstellar-jurisdiction/dashboards/12072/", func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, "DELETE", r) + }) + + ctx := context.Background() + _, err := client.Dashboards.Delete(ctx, "the-interstellar-jurisdiction", "12072") + assert.NoError(t, err) +} diff --git a/sentry/metric_alerts.go b/sentry/metric_alerts.go index 5bfcdc3..0e1c3e7 100644 --- a/sentry/metric_alerts.go +++ b/sentry/metric_alerts.go @@ -53,8 +53,13 @@ type MetricAlertTriggerAction struct { } // List Alert Rules configured for a project -func (s *MetricAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string) ([]*MetricAlert, *Response, error) { +func (s *MetricAlertsService) List(ctx context.Context, organizationSlug string, projectSlug string, params *ListCursorParams) ([]*MetricAlert, *Response, error) { u := fmt.Sprintf("0/projects/%v/%v/alert-rules/", organizationSlug, projectSlug) + u, err := addQuery(u, params) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err diff --git a/sentry/metric_alerts_test.go b/sentry/metric_alerts_test.go index 47f3e70..4f8679d 100644 --- a/sentry/metric_alerts_test.go +++ b/sentry/metric_alerts_test.go @@ -64,7 +64,7 @@ func TestMetricAlertService_List(t *testing.T) { }) ctx := context.Background() - alertRules, _, err := client.MetricAlerts.List(ctx, "the-interstellar-jurisdiction", "pump-station") + alertRules, _, err := client.MetricAlerts.List(ctx, "the-interstellar-jurisdiction", "pump-station", nil) require.NoError(t, err) expected := []*MetricAlert{ diff --git a/sentry/sentry.go b/sentry/sentry.go index 5d4b68d..42c71a5 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -51,6 +51,7 @@ type Client struct { // Services DashboardWidgets *DashboardWidgetsService + Dashboards *DashboardsService IssueAlerts *IssueAlertsService MetricAlerts *MetricAlertsService OrganizationMembers *OrganizationMembersService @@ -81,6 +82,7 @@ func NewClient(httpClient *http.Client) *Client { } c.common.client = c c.DashboardWidgets = (*DashboardWidgetsService)(&c.common) + c.Dashboards = (*DashboardsService)(&c.common) c.IssueAlerts = (*IssueAlertsService)(&c.common) c.MetricAlerts = (*MetricAlertsService)(&c.common) c.OrganizationMembers = (*OrganizationMembersService)(&c.common) From df3ca4aba2de7db384f842d3afd3f036dee04a35 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jun 2022 00:53:42 +0100 Subject: [PATCH 35/40] fix(deps): update module github.com/stretchr/testify to v1.7.4 (#59) --- go.mod | 2 +- go.sum | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d5f1b61..0db127f 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.18 require ( github.com/google/go-querystring v1.1.0 github.com/peterhellberg/link v1.1.0 - github.com/stretchr/testify v1.7.2 + github.com/stretchr/testify v1.7.4 ) require ( diff --git a/go.sum b/go.sum index f100a39..934c7a6 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,15 @@ github.com/peterhellberg/link v1.1.0/go.mod h1:gtSlOT4jmkY8P47hbTc8PTgiDDWpdPbFY github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.7.4 h1:wZRexSlwd7ZXfKINDLsO4r7WBt3gTKONc6K/VesHvHM= +github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 4108a9892a6c422253f3e2af84c8c5588fa0905b Mon Sep 17 00:00:00 2001 From: Jian Yuan Lee Date: Fri, 24 Jun 2022 02:00:57 +0100 Subject: [PATCH 36/40] Omit empty all MetricAlert fields except actions --- sentry/metric_alerts.go | 26 +++++++++++++------------- sentry/metric_alerts_test.go | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/sentry/metric_alerts.go b/sentry/metric_alerts.go index 0e1c3e7..9d8f77a 100644 --- a/sentry/metric_alerts.go +++ b/sentry/metric_alerts.go @@ -9,19 +9,19 @@ import ( type MetricAlertsService service type MetricAlert struct { - ID *string `json:"id"` - Name *string `json:"name"` + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` Environment *string `json:"environment,omitempty"` - DataSet *string `json:"dataset"` - Query *string `json:"query"` - Aggregate *string `json:"aggregate"` - TimeWindow *float64 `json:"timeWindow"` - ThresholdType *int `json:"thresholdType"` - ResolveThreshold *float64 `json:"resolveThreshold"` - Triggers []*MetricAlertTrigger `json:"triggers"` - Projects []string `json:"projects"` - Owner *string `json:"owner"` - DateCreated *time.Time `json:"dateCreated"` + DataSet *string `json:"dataset,omitempty"` + Query *string `json:"query,omitempty"` + Aggregate *string `json:"aggregate,omitempty"` + TimeWindow *float64 `json:"timeWindow,omitempty"` + ThresholdType *int `json:"thresholdType,omitempty"` + ResolveThreshold *float64 `json:"resolveThreshold,omitempty"` + Triggers []*MetricAlertTrigger `json:"triggers,omitempty"` + Projects []string `json:"projects,omitempty"` + Owner *string `json:"owner,omitempty"` + DateCreated *time.Time `json:"dateCreated,omitempty"` } // MetricAlertTrigger represents a metric alert trigger. @@ -34,7 +34,7 @@ type MetricAlertTrigger struct { AlertThreshold *float64 `json:"alertThreshold,omitempty"` ResolveThreshold *float64 `json:"resolveThreshold,omitempty"` DateCreated *time.Time `json:"dateCreated,omitempty"` - Actions []*MetricAlertTriggerAction `json:"actions,omitempty"` + Actions []*MetricAlertTriggerAction `json:"actions"` // Must always be present. } // MetricAlertTriggerAction represents a metric alert trigger action. diff --git a/sentry/metric_alerts_test.go b/sentry/metric_alerts_test.go index 4f8679d..f113c12 100644 --- a/sentry/metric_alerts_test.go +++ b/sentry/metric_alerts_test.go @@ -392,9 +392,9 @@ func TestMetricAlertService_Update(t *testing.T) { "alertThreshold": json.Number("55501"), "resolveThreshold": json.Number("100"), "dateCreated": "2022-04-07T16:46:48.607583Z", + "actions": []interface{}{}, }, }, - "projects": interface{}(nil), "owner": "pump-station:12345", "dateCreated": "2022-04-15T15:06:01.079598Z", }, r) From b886357dc28010fc8ef809e5e93f2074e0ea4e09 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Jun 2022 08:17:46 +0100 Subject: [PATCH 37/40] fix(deps): update module github.com/stretchr/testify to v1.7.5 (#60) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 0db127f..40db085 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.18 require ( github.com/google/go-querystring v1.1.0 github.com/peterhellberg/link v1.1.0 - github.com/stretchr/testify v1.7.4 + github.com/stretchr/testify v1.7.5 ) require ( diff --git a/go.sum b/go.sum index 934c7a6..2f0911f 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8 github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.4 h1:wZRexSlwd7ZXfKINDLsO4r7WBt3gTKONc6K/VesHvHM= github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= +github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= From cd06bb8f98b9f971c00c31e81501d4aa7c69efee Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Jun 2022 21:12:40 +0100 Subject: [PATCH 38/40] fix(deps): update module github.com/stretchr/testify to v1.8.0 (#61) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 40db085..756b26b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.18 require ( github.com/google/go-querystring v1.1.0 github.com/peterhellberg/link v1.1.0 - github.com/stretchr/testify v1.7.5 + github.com/stretchr/testify v1.8.0 ) require ( diff --git a/go.sum b/go.sum index 2f0911f..987b949 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/stretchr/testify v1.7.4 h1:wZRexSlwd7ZXfKINDLsO4r7WBt3gTKONc6K/VesHvH github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= From eb3a516a4eb089d8e993bdd6f81a937b21db7df3 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Tue, 5 Jul 2022 10:08:22 +0930 Subject: [PATCH 39/40] fix: don't use string literals for HTTP methods --- sentry/project_filter.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sentry/project_filter.go b/sentry/project_filter.go index 9a96521..6c5572e 100644 --- a/sentry/project_filter.go +++ b/sentry/project_filter.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" ) // ProjectFilter represents inbounding filters applied to a project. @@ -19,7 +20,7 @@ type ProjectFilterService service // Get the filters. func (s *ProjectFilterService) Get(ctx context.Context, organizationSlug string, projectSlug string) ([]*ProjectFilter, *Response, error) { url := fmt.Sprintf("0/projects/%v/%v/filters/", organizationSlug, projectSlug) - req, err := s.client.NewRequest("GET", url, nil) + req, err := s.client.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, nil, err } @@ -77,7 +78,7 @@ type BrowserExtensionParams struct { func (s *ProjectFilterService) UpdateBrowserExtensions(ctx context.Context, organizationSlug string, projectSlug string, active bool) (*Response, error) { url := fmt.Sprintf("0/projects/%v/%v/filters/browser-extensions/", organizationSlug, projectSlug) params := BrowserExtensionParams{active} - req, err := s.client.NewRequest("PUT", url, params) + req, err := s.client.NewRequest(http.MethodPut, url, params) if err != nil { return nil, err } @@ -95,7 +96,7 @@ func (s *ProjectFilterService) UpdateLegacyBrowser(ctx context.Context, organiza url := fmt.Sprintf("0/projects/%v/%v/filters/legacy-browsers/", organizationSlug, projectSlug) params := LegactBrowserParams{browsers} - req, err := s.client.NewRequest("PUT", url, params) + req, err := s.client.NewRequest(http.MethodPut, url, params) if err != nil { return nil, err } From 0631526d072a76ad479eca76c1eee4f2519cef87 Mon Sep 17 00:00:00 2001 From: Taj Pereira Date: Tue, 5 Jul 2022 10:36:31 +0930 Subject: [PATCH 40/40] fix: add newline between member fields to improve merge conflict resilience --- sentry/sentry.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry/sentry.go b/sentry/sentry.go index 8178545..12ecf91 100644 --- a/sentry/sentry.go +++ b/sentry/sentry.go @@ -61,7 +61,8 @@ type Client struct { ProjectPlugins *ProjectPluginsService Projects *ProjectsService Teams *TeamsService - ProjectFilter *ProjectFilterService + + ProjectFilter *ProjectFilterService } type service struct {