From c522965c035fa628efb250946e6ba0d769002548 Mon Sep 17 00:00:00 2001 From: Howard Hellyer Date: Mon, 28 Oct 2024 13:27:23 +0000 Subject: [PATCH] Add support for the v1beta1 alerts api. - Add code to call api. - Add testcases. - Add example. --- CHANGELOG.md | 6 + mockns1/alert.go | 153 ++++++++++ mockns1/testcase.go | 9 + rest/_examples/alerts.go | 132 ++++++++ rest/_examples/zones.go | 12 +- rest/alert.go | 207 +++++++++++++ rest/alert_test.go | 562 +++++++++++++++++++++++++++++++++++ rest/client.go | 5 +- rest/model/alerting/alert.go | 46 +++ 9 files changed, 1125 insertions(+), 7 deletions(-) create mode 100644 mockns1/alert.go create mode 100644 rest/_examples/alerts.go create mode 100644 rest/alert.go create mode 100644 rest/alert_test.go create mode 100644 rest/model/alerting/alert.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7557e..4488f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.13.0 (Oct 28th, 2024) + +FEATURES: + +* Adds support for alerts + ## 2.12.2 (October 17th, 2024) BUG FIXES: diff --git a/mockns1/alert.go b/mockns1/alert.go new file mode 100644 index 0000000..a82090b --- /dev/null +++ b/mockns1/alert.go @@ -0,0 +1,153 @@ +package mockns1 + +import ( + "fmt" + "net/http" + + "gopkg.in/ns1/ns1-go.v2/rest/model/alerting" +) + +// Should be identical to rest.alertListResponse +type mockAlertListResponse struct { + Limit *int64 `json:"limit,omitempty"` + Next *string `json:"next,omitempty"` + Results []*alerting.Alert `json:"results"` + TotalResults *int64 `json:"total_results,omitempty"` +} + +const alertPath = "../alerting/v1beta1/alerts" + +// AddAlertListTestCase sets up a test case for the api.Client.Alert.List() +// function +func (s *Service) AddAlertListTestCase( + params string, requestHeaders, responseHeaders http.Header, + response []*alerting.Alert, +) error { + length := int64(len(response)) + next := "" + if length > 0 { + next = *response[length-1].Name + } + listResponse := &mockAlertListResponse{ + + Next: &next, + Results: response, + Limit: &length, + TotalResults: &length, + } + uri := alertPath + if params != "" { + uri = fmt.Sprintf("%s?%s", uri, params) + } + return s.AddTestCase( + http.MethodGet, uri, http.StatusOK, requestHeaders, + responseHeaders, "", listResponse, + ) +} + +// AddAlertGetTestCase sets up a test case for the api.Client.Alerts.Get() +// function +func (s *Service) AddAlertGetTestCase( + id string, + requestHeaders, responseHeaders http.Header, + response *alerting.Alert, +) error { + return s.AddTestCase( + http.MethodGet, fmt.Sprintf("%s/%s", alertPath, id), http.StatusOK, requestHeaders, + responseHeaders, "", response, + ) +} + +// AddAlertCreateTestCase sets up a test case for the api.Client.Alerts.Update() +// function +func (s *Service) AddAlertCreateTestCase( + requestHeaders, responseHeaders http.Header, + request alerting.Alert, + response alerting.Alert, +) error { + return s.AddTestCase( + http.MethodPost, alertPath, http.StatusOK, requestHeaders, + responseHeaders, request, response, + ) +} + +// AddAlertUpdateTestCase sets up a test case for the api.Client.Alerts.Update() +// function +func (s *Service) AddAlertUpdateTestCase( + requestHeaders, responseHeaders http.Header, + request alerting.Alert, + response alerting.Alert, +) error { + return s.AddTestCase( + http.MethodPatch, fmt.Sprintf("%s/%s", alertPath, *request.ID), http.StatusOK, requestHeaders, + responseHeaders, request, response, + ) +} + +// AddAlertReplaceTestCase sets up a test case for the api.Client.Alerts.Update() +// function +func (s *Service) AddAlertReplaceTestCase( + requestHeaders, responseHeaders http.Header, + request alerting.Alert, + response alerting.Alert, +) error { + return s.AddTestCase( + http.MethodPut, fmt.Sprintf("%s/%s", alertPath, *request.ID), http.StatusOK, requestHeaders, + responseHeaders, request, response, + ) +} + +// AddAlertDeleteTestCase sets up a test case for the api.Client.Alerts.Delete() +// function +func (s *Service) AddAlertDeleteTestCase( + id string, + requestHeaders, responseHeaders http.Header, +) error { + return s.AddTestCase( + http.MethodDelete, fmt.Sprintf("%s/%s", alertPath, id), http.StatusNoContent, requestHeaders, + responseHeaders, "", nil, + ) +} + +// AddAlertTestPostTestCase sets up a test case for the api.Client.Alerts.Test() +// function +func (s *Service) AddAlertTestPostTestCase( + id string, + requestHeaders, responseHeaders http.Header, +) error { + return s.AddTestCase( + http.MethodPost, fmt.Sprintf("%s/%s/test", alertPath, id), http.StatusNoContent, requestHeaders, + responseHeaders, "", nil, + ) +} + +// AddAlertFailTestCase sets up a test case for the api.Client.Alerts.*() +// functions that fails. +func (s *Service) AddAlertFailTestCase( + method string, id string, returnStatus int, + requestHeaders, responseHeaders http.Header, + responseBody string, +) error { + path := alertPath + if id != "" { + path = fmt.Sprintf("%s/%s", alertPath, id) + } + return s.AddTestCase( + method, path, returnStatus, + nil, nil, "", responseBody) +} + +func (s *Service) AddAlertFailTestCaseWithReqBody( + method string, id string, returnStatus int, + requestHeaders, responseHeaders http.Header, + requestBody interface{}, + responseBody string, +) error { + path := alertPath + if id != "" { + path = fmt.Sprintf("%s/%s", alertPath, id) + } + return s.AddTestCase( + method, path, returnStatus, + nil, nil, requestBody, responseBody) +} diff --git a/mockns1/testcase.go b/mockns1/testcase.go index 06ef323..fb810b0 100644 --- a/mockns1/testcase.go +++ b/mockns1/testcase.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "github.com/stretchr/testify/assert" @@ -39,6 +40,14 @@ func (s *Service) AddTestCase( uri = "/v1/" + uri } + baseUri, _ := url.Parse("/") + rel, uriErr := url.Parse(uri) + if uriErr != nil { + return fmt.Errorf("could not parse testcase uri") + } + + uri = baseUri.ResolveReference(rel).RequestURI() + if len(params) > 0 { uri = fmt.Sprintf("%s?%s=%s", uri, params[0].Key, params[0].Value) diff --git a/rest/_examples/alerts.go b/rest/_examples/alerts.go new file mode 100644 index 0000000..7697b0d --- /dev/null +++ b/rest/_examples/alerts.go @@ -0,0 +1,132 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" + + api "gopkg.in/ns1/ns1-go.v2/rest" + "gopkg.in/ns1/ns1-go.v2/rest/model/alerting" + "gopkg.in/ns1/ns1-go.v2/rest/model/dns" + "gopkg.in/ns1/ns1-go.v2/rest/model/monitor" +) + +var client *api.Client + +// Helper that initializes rest api client from environment variable. +func init() { + k := os.Getenv("NS1_APIKEY") + if k == "" { + fmt.Println("NS1_APIKEY environment variable is not set, giving up") + } + + httpClient := &http.Client{Timeout: time.Second * 10} + // Adds logging to each http request. + doer := api.Decorate(httpClient, api.Logging(log.New(os.Stdout, "", log.LstdFlags))) + client = api.NewClient(doer, api.SetAPIKey(k)) +} + +func prettyPrint(header string, v interface{}) { + fmt.Println(header) + fmt.Printf("%#v \n", v) + b, _ := json.MarshalIndent(v, "", " ") + fmt.Println(string(b)) +} + +func main() { + alerts, _, err := client.Alerts.List() + if err != nil { + log.Fatal(err) + } + for _, a := range alerts { + prettyPrint("alert:", a) + } + + webhook := monitor.NewWebNotification("test.com/test", map[string]string{}) + webhookList := monitor.NewNotifyList("my webhook list", webhook) + _, err = client.Notifications.Create(webhookList) + if err != nil { + log.Fatal(err) + } + prettyPrint("Webhook NotifyList:", webhookList) + + email := monitor.NewEmailNotification("test@test.com") + emailList := monitor.NewNotifyList("my email list", email) + _, err = client.Notifications.Create(emailList) + if err != nil { + log.Fatal(err) + } + prettyPrint("Email NotifyList:", emailList) + + // Construct/Create a zone. + domain := "myalerttest.com" + + z := dns.NewZone(domain) + z.NxTTL = 3600 + _, err = client.Zones.Create(z) + if err != nil { + // Ignore if zone already exists + if err != api.ErrZoneExists { + log.Fatal(err) + } else { + log.Println("Zone already exists, continuing...") + } + } + + prettyPrint("Zone:", z) + fmt.Printf("Creating alert...\n") + alert := alerting.NewZoneAlert("myalerttest.com - transfer failed", "transfer_failed", []string{webhookList.ID}, []string{domain}) + _, err = client.Alerts.Create(alert) + if err != nil { + if err == api.ErrAlertExists { + // This is fatal as we need the id returned on create. + log.Println("Alert already exists.") + } + log.Fatal(err) + + } + alertID := *alert.ID + + // Pass the id and the field(s) to change on Update. + updatedName := "myalerttest.com - updated" + alertUpdate := &alerting.Alert{ + ID: &alertID, + Name: &updatedName, + NotifierListIds: []string{webhookList.ID, emailList.ID}, + } + _, err = client.Alerts.Update(alertUpdate) + if err != nil { + log.Fatal(err) + } + + prettyPrint("Updated Alert:", alertUpdate) + + // To pass the whole alert object on Replace, retrieve it by ID it first. + alertToReplace, _, err := client.Alerts.Get(alertID) + if err != nil { + log.Fatal(err) + } + + // Replace values in retrieved struct with new values. + // e.g. Change name and clear list. + replacedName := "myalerttest.com - replaced" + alertToReplace.Name = &replacedName + alertToReplace.NotifierListIds = []string{} + + // Pass the whole alert object + _, err = client.Alerts.Replace(alertToReplace) + if err != nil { + log.Fatal(err) + } + + prettyPrint("Replaced Alert:", alertToReplace) + + // Delete the alert. + _, err = client.Alerts.Delete(*alertToReplace.ID) + if err != nil { + log.Fatal(err) + } +} diff --git a/rest/_examples/zones.go b/rest/_examples/zones.go index 78bbbfc..45504e6 100644 --- a/rest/_examples/zones.go +++ b/rest/_examples/zones.go @@ -62,7 +62,7 @@ func main() { } // Add an A record with a single static answer. - orchidRec := dns.NewRecord(domain, "orchid", "A") + orchidRec := dns.NewRecord(domain, "orchid", "A", nil, nil) orchidRec.AddAnswer(dns.NewAv4Answer("2.2.2.2")) _, err = client.Records.Create(orchidRec) if err != nil { @@ -98,7 +98,7 @@ func main() { fmt.Println(string(bRec)) // Add an A record with two static answers. - honeyRec := dns.NewRecord(domain, "honey", "A") + honeyRec := dns.NewRecord(domain, "honey", "A", nil, nil) honeyRec.Answers = []*dns.Answer{ dns.NewAv4Answer("1.2.3.4"), dns.NewAv4Answer("5.6.7.8"), @@ -114,7 +114,7 @@ func main() { } // Add a cname - potRec := dns.NewRecord(domain, "pot", "CNAME") + potRec := dns.NewRecord(domain, "pot", "CNAME", nil, nil) potRec.AddAnswer(dns.NewCNAMEAnswer("honey.test.com")) _, err = client.Records.Create(potRec) if err != nil { @@ -127,7 +127,7 @@ func main() { } // Add a MX with two answers, priority 5 and 10 - mailRec := dns.NewRecord(domain, "mail", "MX") + mailRec := dns.NewRecord(domain, "mail", "MX", nil, nil) mailRec.Answers = []*dns.Answer{ dns.NewMXAnswer(5, "mail1.test.com"), dns.NewMXAnswer(10, "mail2.test.com"), @@ -143,7 +143,7 @@ func main() { } // Add a AAAA, specify ttl of 300 seconds - aaaaRec := dns.NewRecord(domain, "honey6", "AAAA") + aaaaRec := dns.NewRecord(domain, "honey6", "AAAA", nil, nil) aaaaRec.TTL = 300 aaaaRec.AddAnswer(dns.NewAv6Answer("2607:f8b0:4006:806::1010")) _, err = client.Records.Create(aaaaRec) @@ -159,7 +159,7 @@ func main() { // Add an A record using full answer format to specify 2 answers with meta data. // ensure edns-client-subnet is in use, and add two filters: geotarget_country, // and select_first_n, which has a filter config option N set to 1. - bumbleRec := dns.NewRecord(domain, "bumble", "A") + bumbleRec := dns.NewRecord(domain, "bumble", "A", nil, nil) usAns := dns.NewAv4Answer("1.1.1.1") usAns.Meta.Up = false diff --git a/rest/alert.go b/rest/alert.go new file mode 100644 index 0000000..4eafc4b --- /dev/null +++ b/rest/alert.go @@ -0,0 +1,207 @@ +package rest + +import ( + "errors" + "fmt" + "net/http" + + "gopkg.in/ns1/ns1-go.v2/rest/model/alerting" +) + +// AlertsService handles 'alerting/v1/alerts' endpoint. +type AlertsService service + +// The base for the alerting api relative to /v1 +// client.NewRequest will call ResolveReference and remove /v1/../ +const alertingRelativeBase = "../alerting/v1beta1" + +type alertListResponse struct { + Limit *int64 `json:"limit,omitempty"` + Next *string `json:"next,omitempty"` + Results []*alerting.Alert `json:"results"` + TotalResults *int64 `json:"total_results,omitempty"` +} + +// List returns all configured alerts. +// +// NS1 API docs: https://ns1.com/api/#alerts-get +func (s *AlertsService) List() ([]*alerting.Alert, *http.Response, error) { + path := fmt.Sprintf("%s/%s", alertingRelativeBase, "alerts") + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + alertListResp := alertListResponse{} + var resp *http.Response + if s.client.FollowPagination { + resp, err = s.client.DoWithPagination(req, &alertListResp, s.nextAlerts) + } else { + resp, err = s.client.Do(req, &alertListResp) + } + if err != nil { + return nil, resp, err + } + alerts := alertListResp.Results + return alerts, resp, nil +} + +// nextAlerts is a pagination helper than gets and appends another list of alerts +// to the passed alerts. +func (s *AlertsService) nextAlerts(v *interface{}, uri string) (*http.Response, error) { + nextAlerts := &alertListResponse{} + resp, err := s.client.getURI(&nextAlerts, uri) + if err != nil { + return resp, err + } + alertListResp, ok := (*v).(*alertListResponse) + if !ok { + return nil, fmt.Errorf( + "incorrect value for v, expected value of type *[]*alerting.Alert, got: %T", v, + ) + } + alertListResp.Results = append(alertListResp.Results, nextAlerts.Results...) + return resp, nil +} + +// Get returns the details of a specific alert. +// +// NS1 API docs: https://ns1.com/api/#alert-alertid-get +func (s *AlertsService) Get(alertID string) (*alerting.Alert, *http.Response, error) { + path := fmt.Sprintf("%s/%s/%s", alertingRelativeBase, "alerts", alertID) + req, err := s.client.NewRequest("GET", path, nil) + if err != nil { + return nil, nil, err + } + + var alert alerting.Alert + resp, err := s.client.Do(req, &alert) + if err != nil { + switch err.(type) { + case *Error: + if resourceMissingMatch(err.(*Error).Message) { + return nil, resp, ErrAlertMissing + } + } + return nil, resp, err + } + + return &alert, resp, nil +} + +// Create takes a *alerting.Alert and creates a new alert. +// +// NS1 API docs: https://ns1.com/api/#alert-post +func (s *AlertsService) Create(alert *alerting.Alert) (*http.Response, error) { + path := fmt.Sprintf("%s/%s", alertingRelativeBase, "alerts") + req, err := s.client.NewRequest("POST", path, &alert) + if err != nil { + return nil, err + } + + // Update the alerts fields with data from api(ensure consistent) + resp, err := s.client.Do(req, &alert) + if err != nil { + switch err.(type) { + case *Error: + if err.(*Error).Resp.StatusCode == http.StatusConflict { + return resp, ErrAlertExists + } + } + return resp, err + } + + return resp, nil +} + +// Update updates the fields specified in the passed alert object. +// +// NS1 API docs: https://ns1.com/api/#alert-alertid-patch +func (s *AlertsService) Update(alert *alerting.Alert) (*http.Response, error) { + alertID := "" + if alert != nil && alert.ID != nil { + alertID = *alert.ID + } + path := fmt.Sprintf("%s/%s/%s", alertingRelativeBase, "alerts", alertID) + + req, err := s.client.NewRequest("PATCH", path, &alert) + if err != nil { + return nil, err + } + + // Update the alerts fields with data from api(ensure consistent) + resp, err := s.client.Do(req, &alert) + if err != nil { + return resp, err + } + + return resp, nil +} + +// Replace replaces the values in an alert with the values in the passed object. +// +// NS1 API docs: https://ns1.com/api/#alert-alertid-put +func (s *AlertsService) Replace(alert *alerting.Alert) (*http.Response, error) { + alertID := "" + if alert != nil && alert.ID != nil { + alertID = *alert.ID + } + path := fmt.Sprintf("%s/%s/%s", alertingRelativeBase, "alerts", alertID) + + req, err := s.client.NewRequest("PUT", path, &alert) + if err != nil { + return nil, err + } + + // Update the alerts fields with data from api (ensure consistent) + resp, err := s.client.Do(req, &alert) + if err != nil { + return resp, err + } + + return resp, nil +} + +// Delete immediately deletes an existing alert. +// +// NS1 API docs: https://ns1.com/api/#alert-alertid-delete +func (s *AlertsService) Delete(alertID string) (*http.Response, error) { + path := fmt.Sprintf("%s/%s/%s", alertingRelativeBase, "alerts", alertID) + req, err := s.client.NewRequest("DELETE", path, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +// Test an existing alert, triggers notifications for the given alert id. +// +// NS1 API docs: https://ns1.com/api/#alert-alertid-test +func (s *AlertsService) Test(alertID string) (*http.Response, error) { + path := fmt.Sprintf("%s/%s/%s/test", alertingRelativeBase, "alerts", alertID) + req, err := s.client.NewRequest("POST", path, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} + +var ( + // ErrAlertExists bundles POST create error. + ErrAlertExists = errors.New("alert already exists") + + // ErrAlertMissing bundles GET/PUT/PATCH/DELETE error. + ErrAlertMissing = errors.New("alert does not exist") +) diff --git a/rest/alert_test.go b/rest/alert_test.go new file mode 100644 index 0000000..537e6ad --- /dev/null +++ b/rest/alert_test.go @@ -0,0 +1,562 @@ +package rest_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/ns1/ns1-go.v2/mockns1" + "gopkg.in/ns1/ns1-go.v2/rest/model/alerting" + + api "gopkg.in/ns1/ns1-go.v2/rest" +) + +func strPtr(str string) *string { + return &str +} + +func int64Ptr(n int64) *int64 { + return &n +} + +func TestAlert(t *testing.T) { + mock, doer, err := mockns1.New(t) + + require.Nil(t, err) + defer mock.Shutdown() + + client := api.NewClient(doer, api.SetEndpoint("https://"+mock.Address+"/v1/")) + + // Tests for api.Client.View.List() + t.Run("List", func(t *testing.T) { + t.Run("List", func(t *testing.T) { + defer mock.ClearTestCases() + + require.Nil(t, mock.AddAlertListTestCase("", nil, nil, alertList)) + + respAlerts, _, err := client.Alerts.List() + require.Nil(t, err) + require.NotNil(t, respAlerts) + compareAlertLists(t, alertList, respAlerts) + }) + + t.Run("List with pagination", func(t *testing.T) { + defer mock.ClearTestCases() + + linkHeader := http.Header{} + linkHeader.Set("Link", `; rel="next"`) + require.Nil(t, mock.AddAlertListTestCase("", nil, linkHeader, alertList[0:1])) + require.Nil(t, mock.AddAlertListTestCase("next="+*alertList[1].Name, nil, nil, alertList[2:3])) + + respAlerts, _, err := client.Alerts.List() + require.Nil(t, err) + compareAlertLists(t, alertList, respAlerts) + }) + + t.Run("List without pagination", func(t *testing.T) { + defer mock.ClearTestCases() + + require.Nil(t, mock.AddAlertListTestCase("", nil, nil, alertList)) + + respAlerts, _, err := client.Alerts.List() + require.Nil(t, err) + require.NotNil(t, respAlerts) + + compareAlertLists(t, alertList, respAlerts) + }) + }) + + t.Run("Get", func(t *testing.T) { + + t.Run("Success", func(t *testing.T) { + defer mock.ClearTestCases() + + client.FollowPagination = true + alert := alertList[0] + require.Nil(t, mock.AddAlertGetTestCase(*alert.ID, nil, nil, alert)) + + respAlert, _, err := client.Alerts.Get(*alert.ID) + require.Nil(t, err) + require.NotNil(t, respAlert) + compareAlerts(t, alert, respAlert) + }) + t.Run("Not Found", func(t *testing.T) { + defer mock.ClearTestCases() + + require.Nil(t, mock.AddAlertFailTestCase( + http.MethodGet, "abcd-efgh-ijkl", http.StatusNotFound, + nil, nil, `{"message": "test error"}`, + )) + + respAlert, resp, err := client.Alerts.Get("abcd-efgh-ijkl") + require.Nil(t, respAlert) + require.NotNil(t, err) + require.Contains(t, err.Error(), "test error") + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Other Error", func(t *testing.T) { + c := api.NewClient(errorClient{}, api.SetEndpoint("")) + respAlert, resp, err := c.Alerts.Get("abcd-efgh-ijkl") + require.Nil(t, resp) + require.Error(t, err) + require.Nil(t, respAlert) + }) + }) + + t.Run("Create", func(t *testing.T) { + + t.Run("Success", func(t *testing.T) { + defer mock.ClearTestCases() + + alertToCreate := alerting.Alert{ + ID: strPtr("f136f0cd-eca8-4b2b-9b2d-9a6631200d51"), + Data: json.RawMessage(nil), + Name: strPtr("first_alert"), + NotifierListIds: []string{ + "66d07ca6e113eb00014fe257", + "66d07caf8519c000011cdda6", + "6707da567cd4f300012cd7e4", + }, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("transfer_failed"), + ZoneNames: []string{ + "alerttest.com", "alerttest.net", + }, + } + + alertResponse := alertList[0] + + // Pass by value here. alertToCreate will be modified by client.Alerts.Create + require.Nil(t, mock.AddAlertCreateTestCase(nil, nil, alertToCreate, *alertResponse)) + + _, err := client.Alerts.Create(&alertToCreate) + require.Nil(t, err) + // alerttoCreate will be modified by the call to Create and fully populated. + compareAlerts(t, alertResponse, &alertToCreate) + }) + + // TODO Add test for error response when updating fields that can't be changed. + t.Run("Error Duplicate Name", func(t *testing.T) { + defer mock.ClearTestCases() + + alertToCreate := alerting.Alert{ + ID: strPtr("f136f0cd-eca8-4b2b-9b2d-9a6631200d51"), + Data: json.RawMessage(nil), + Name: strPtr("duplicate_alert"), + NotifierListIds: []string{ + "66d07ca6e113eb00014fe257", + "66d07caf8519c000011cdda6", + }, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("transfer_failed"), + ZoneNames: []string{ + "alerttest.com", "alerttest.net", + }, + } + + require.Nil(t, mock.AddAlertFailTestCaseWithReqBody( + http.MethodPost, "", http.StatusConflict, + nil, nil, alertToCreate, `{"message": "test error"}`, + )) + + resp, err := client.Alerts.Create(&alertToCreate) + require.NotNil(t, err) + require.Equal(t, api.ErrAlertExists, err) + require.Equal(t, http.StatusConflict, resp.StatusCode) + }) + + t.Run("Error Bad Alert Object", func(t *testing.T) { + defer mock.ClearTestCases() + + alertToCreate := alerting.Alert{ + ID: strPtr("f136f0cd-eca8-4b2b-9b2d-9a6631200d51"), + Data: json.RawMessage(nil), + Name: strPtr("duplicate_alert"), + NotifierListIds: []string{ + "66d07ca6e113eb00014fe257", + "66d07caf8519c000011cdda6", + }, + RecordIds: []string{}, + Type: strPtr("fakeType"), // Bad alert type + Subtype: strPtr("transfer_failed"), + ZoneNames: []string{ + "alerttest.com", "alerttest.net", + }, + } + + require.Nil(t, mock.AddAlertFailTestCaseWithReqBody( + http.MethodPost, "", http.StatusBadRequest, + nil, nil, alertToCreate, `{"message": "test error"}`, + )) + + resp, err := client.Alerts.Create(&alertToCreate) + require.NotNil(t, err) + require.Contains(t, err.Error(), "test error") + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + }) + + t.Run("Update", func(t *testing.T) { + + t.Run("Success", func(t *testing.T) { + defer mock.ClearTestCases() + + alertUpdate := alerting.Alert{ + ID: strPtr("f136f0cd-eca8-4b2b-9b2d-9a6631200d51"), + Name: strPtr("renamed_alert"), + } + + alertResponse := alerting.Alert{ + ID: strPtr("f136f0cd-eca8-4b2b-9b2d-9a6631200d51"), + CreatedAt: int64Ptr(1728637379), + CreatedBy: strPtr("testapikey"), + Data: json.RawMessage(nil), + Name: strPtr("renamed_alert"), + NotifierListIds: []string{ + "66d07ca6e113eb00014fe257", + "66d07caf8519c000011cdda6", + "6707da567cd4f300012cd7e4", + }, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("transfer_failed"), + UpdatedAt: int64Ptr(1728637379), + UpdatedBy: strPtr("testapikey"), + ZoneNames: []string{ + "alerttest.com", "alerttest.net", + }, + } + + // Pass by value here. alertUpdate will be modified by client.Alerts.Update + require.Nil(t, mock.AddAlertUpdateTestCase(nil, nil, alertUpdate, alertResponse)) + + _, err := client.Alerts.Update(&alertUpdate) + require.Nil(t, err) + // alertUpdate will be modified by the call to Update and fully populated. + compareAlerts(t, &alertResponse, &alertUpdate) + }) + + t.Run("Error", func(t *testing.T) { + defer mock.ClearTestCases() + + alertUpdate := alerting.Alert{ + ID: strPtr("abcd-efgh-ijkl"), + Name: strPtr("renamed_alert"), + } + + require.Nil(t, mock.AddAlertFailTestCaseWithReqBody( + http.MethodPatch, "abcd-efgh-ijkl", http.StatusNotFound, + nil, nil, alertUpdate, `{"message": "test error"}`, + )) + + resp, err := client.Alerts.Update(&alertUpdate) + require.NotNil(t, err) + require.Contains(t, err.Error(), "test error") + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + }) + + t.Run("Replace", func(t *testing.T) { + + t.Run("Success", func(t *testing.T) { + defer mock.ClearTestCases() + + // With replace, the whole alert is passed with the update. + updatedAlert := alerting.Alert{ + ID: strPtr("f136f0cd-eca8-4b2b-9b2d-9a6631200d51"), + CreatedAt: int64Ptr(1728637379), + CreatedBy: strPtr("testapikey"), + Data: json.RawMessage(nil), + Name: strPtr("renamed_alert"), // This would be the change. + NotifierListIds: []string{ + "66d07ca6e113eb00014fe257", + "66d07caf8519c000011cdda6", + "6707da567cd4f300012cd7e4", + }, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("transfer_failed"), + UpdatedAt: int64Ptr(1728637379), + UpdatedBy: strPtr("testapikey"), + ZoneNames: []string{ + "alerttest.com", "alerttest.net", + }, + } + + alertResponse := alerting.Alert{ + ID: strPtr("f136f0cd-eca8-4b2b-9b2d-9a6631200d51"), + CreatedAt: int64Ptr(1728637379), + CreatedBy: strPtr("testapikey"), + Data: json.RawMessage(nil), + Name: strPtr("renamed_alert"), + NotifierListIds: []string{ + "66d07ca6e113eb00014fe257", + "66d07caf8519c000011cdda6", + "6707da567cd4f300012cd7e4", + }, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("transfer_failed"), + UpdatedAt: int64Ptr(1728640000), + UpdatedBy: strPtr("anotherapikey"), + ZoneNames: []string{ + "alerttest.com", "alerttest.net", + }, + } + + // Pass by value here. updatedAlert will be modified by client.Alerts.Replace + require.Nil(t, mock.AddAlertReplaceTestCase(nil, nil, updatedAlert, alertResponse)) + + _, err := client.Alerts.Replace(&updatedAlert) + require.Nil(t, err) + // updatedAlert will be modified by the call to Update and fully populated. + compareAlerts(t, &alertResponse, &updatedAlert) + }) + + t.Run("Error", func(t *testing.T) { + defer mock.ClearTestCases() + + alertUpdate := alerting.Alert{ + ID: strPtr("abcd-efgh-ijkl"), + Name: strPtr("renamed_alert"), + } + + require.Nil(t, mock.AddAlertFailTestCaseWithReqBody( + http.MethodPut, "abcd-efgh-ijkl", http.StatusNotFound, + nil, nil, alertUpdate, `{"message": "test error"}`, + )) + + resp, err := client.Alerts.Replace(&alertUpdate) + require.NotNil(t, err) + require.Contains(t, err.Error(), "test error") + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Error Immutable Field", func(t *testing.T) { + defer mock.ClearTestCases() + + alertUpdate := alerting.Alert{ + ID: strPtr("abcd-efgh-ijkl"), + CreatedBy: strPtr("testUser"), + } + + require.Nil(t, mock.AddAlertFailTestCaseWithReqBody( + http.MethodPut, "abcd-efgh-ijkl", http.StatusConflict, + nil, nil, alertUpdate, `{"message": "test error"}`, + )) + + resp, err := client.Alerts.Replace(&alertUpdate) + require.NotNil(t, err) + require.Contains(t, err.Error(), "test error") + require.Equal(t, http.StatusConflict, resp.StatusCode) + }) + }) + + t.Run("Delete", func(t *testing.T) { + + t.Run("Success", func(t *testing.T) { + defer mock.ClearTestCases() + + client.FollowPagination = true + alert := alertList[0] + require.Nil(t, mock.AddAlertDeleteTestCase(*alert.ID, nil, nil)) + + resp, err := client.Alerts.Delete(*alert.ID) + require.Nil(t, err) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + }) + t.Run("Not Found", func(t *testing.T) { + defer mock.ClearTestCases() + + require.Nil(t, mock.AddAlertFailTestCase( + http.MethodDelete, "abcd-efgh-ijkl", http.StatusNotFound, + nil, nil, `{"message": "test error"}`, + )) + + resp, err := client.Alerts.Delete("abcd-efgh-ijkl") + require.NotNil(t, err) + require.Contains(t, err.Error(), "test error") + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Other Error", func(t *testing.T) { + c := api.NewClient(errorClient{}, api.SetEndpoint("")) + resp, err := c.Alerts.Delete("abcd-efgh-ijkl") + require.Nil(t, resp) + require.Error(t, err) + }) + }) + + t.Run("Test", func(t *testing.T) { + + t.Run("Success", func(t *testing.T) { + defer mock.ClearTestCases() + + client.FollowPagination = true + alert := alertList[0] + require.Nil(t, mock.AddAlertTestPostTestCase(*alert.ID, nil, nil)) + + resp, err := client.Alerts.Test(*alert.ID) + require.Nil(t, err) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + }) + t.Run("Not Found", func(t *testing.T) { + defer mock.ClearTestCases() + + require.Nil(t, mock.AddAlertFailTestCase( + http.MethodPost, "abcd-efgh-ijkl/test", http.StatusNotFound, + nil, nil, `{"message": "test error"}`, + )) + + resp, err := client.Alerts.Test("abcd-efgh-ijkl") + require.NotNil(t, err) + require.Contains(t, err.Error(), "test error") + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("Other Error", func(t *testing.T) { + c := api.NewClient(errorClient{}, api.SetEndpoint("")) + resp, err := c.Alerts.Test("abcd-efgh-ijkl") + require.Nil(t, resp) + require.Error(t, err) + }) + }) +} + +func compareAlertLists(t *testing.T, expected []*alerting.Alert, actual []*alerting.Alert) { + require.Equal(t, len(expected), len(actual)) + for i := range expected { + compareAlerts(t, expected[i], actual[i]) + } +} +func compareAlerts(t *testing.T, expected *alerting.Alert, actual *alerting.Alert) { + require.Equal(t, expected.ID, actual.ID) + require.Equal(t, expected.CreatedAt, actual.CreatedAt) + require.Equal(t, expected.CreatedBy, actual.CreatedBy) + if expected.Data != nil && len(expected.Data) == 0 { + require.Equal(t, json.RawMessage(nil), actual.Data) + } else { + require.Equal(t, expected.Data, actual.Data) + } + require.Equal(t, expected.Name, actual.Name) + require.Equal(t, len(expected.NotifierListIds), len(actual.NotifierListIds)) + for i := range expected.NotifierListIds { + require.Equal(t, expected.NotifierListIds[i], actual.NotifierListIds[i]) + } + require.Equal(t, len(expected.RecordIds), len(actual.RecordIds)) + for i := range expected.RecordIds { + require.Equal(t, expected.RecordIds[i], actual.RecordIds[i]) + } + require.Equal(t, expected.Type, actual.Type) + require.Equal(t, expected.Subtype, actual.Subtype) + require.Equal(t, expected.UpdatedAt, actual.UpdatedAt) + require.Equal(t, expected.UpdatedBy, actual.UpdatedBy) + require.Equal(t, len(expected.ZoneNames), len(actual.ZoneNames)) + for i := range expected.ZoneNames { + require.Equal(t, expected.ZoneNames[i], actual.ZoneNames[i]) + } + +} + +var alertList = []*alerting.Alert{ + { + ID: strPtr("f136f0cd-eca8-4b2b-9b2d-9a6631200d51"), + CreatedAt: int64Ptr(1728637379), + CreatedBy: strPtr("testapikey"), + Data: json.RawMessage(nil), + Name: strPtr("first_alert"), + NotifierListIds: []string{ + "66d07ca6e113eb00014fe257", + "66d07caf8519c000011cdda6", + "6707da567cd4f300012cd7e4", + }, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("transfer_failed"), + UpdatedAt: int64Ptr(1728637379), + UpdatedBy: strPtr("testapikey"), + ZoneNames: []string{ + "alerttest.com", "alerttest.net", + }, + }, + { + ID: strPtr("3a81d9fa-6f03-4baf-83e4-be3f16411c4f"), + CreatedAt: int64Ptr(1728637233), + CreatedBy: strPtr("testapikey"), + Data: json.RawMessage(nil), + Name: strPtr("second_alert"), + NotifierListIds: []string{ + "66d07ca6e113eb00014fe242", + "66d07caf8519c000011cddb7", + "6707da567cd4f300012cd7f9", + }, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("external_primary_failed"), + UpdatedAt: int64Ptr(1728637233), + UpdatedBy: strPtr("testapikey"), + ZoneNames: []string{ + "alerttest2.com", + }, + }, + { + ID: strPtr("3a81d9fa-6f03-4baf-83e4-be3f16411c4f"), + CreatedAt: int64Ptr(1728637233), + CreatedBy: strPtr("testapikey"), + Data: json.RawMessage(nil), + Name: strPtr("third_alert"), + NotifierListIds: []string{ + "66d07ca6e113eb00014fe242", + "6707da567cd4f300012cd7f9", + }, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("external_primary_failed"), + UpdatedAt: int64Ptr(1728637233), + UpdatedBy: strPtr("testapikey"), + ZoneNames: []string{ + "alerttest1.com", "alerttest2.com", + }, + }, + { + ID: strPtr("3a81d9fa-6f03-4baf-83e4-be3f16411c4f"), + CreatedAt: int64Ptr(1728637833), + CreatedBy: strPtr("testapikey"), + Data: json.RawMessage(nil), + Name: strPtr("fourth_alert"), + NotifierListIds: []string{ + "66d07ca6e113eb00014fe257", + "66d07caf8519c000011cdda6", + "6707da567cd4f300012cd7e4", + "66d07ca6e113eb00014fe242", + "66d07caf8519c000011cddb7", + "6707da567cd4f300012cd7f9", + }, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("transfer_failed"), + UpdatedAt: int64Ptr(1728639233), + UpdatedBy: strPtr("testapikey"), + ZoneNames: []string{ + "alerttest2.com", "alerttest3.com", + }, + }, + { + ID: strPtr("e2e64e2b-575b-4669-8a9b-72512e8bdc6f"), + CreatedAt: int64Ptr(1728637836), + CreatedBy: strPtr("testapikey"), + Data: json.RawMessage(nil), + Name: strPtr("fifth_alert - empty lists"), + NotifierListIds: []string{}, + RecordIds: []string{}, + Type: strPtr("zone"), + Subtype: strPtr("transfer_failed"), + UpdatedAt: int64Ptr(1728639236), + UpdatedBy: strPtr("testapikey"), + ZoneNames: []string{}, + }, +} diff --git a/rest/client.go b/rest/client.go index 2ff6730..0d68cdf 100644 --- a/rest/client.go +++ b/rest/client.go @@ -15,7 +15,8 @@ import ( const ( clientVersion = "2.12.2" - defaultEndpoint = "https://api.nsone.net/v1/" + defaultBase = "http://localhost:80" + defaultEndpoint = defaultBase + "/v1/" defaultShouldFollowPagination = true defaultUserAgent = "go-ns1/" + clientVersion @@ -85,6 +86,7 @@ type Client struct { Activity *ActivityService Redirects *RedirectService RedirectCertificates *RedirectCertificateService + Alerts *AlertsService } // NewClient constructs and returns a reference to an instantiated Client. @@ -131,6 +133,7 @@ func NewClient(httpClient Doer, options ...func(*Client)) *Client { c.Activity = (*ActivityService)(&c.common) c.Redirects = (*RedirectService)(&c.common) c.RedirectCertificates = (*RedirectCertificateService)(&c.common) + c.Alerts = (*AlertsService)(&c.common) for _, option := range options { option(c) diff --git a/rest/model/alerting/alert.go b/rest/model/alerting/alert.go new file mode 100644 index 0000000..1c8fcc9 --- /dev/null +++ b/rest/model/alerting/alert.go @@ -0,0 +1,46 @@ +package alerting + +import "encoding/json" + +type Alert struct { + ID *string `json:"id,omitempty"` + CreatedAt *int64 `json:"created_at,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Name *string `json:"name,omitempty"` + NotifierListIds []string `json:"notifier_list_ids"` + RecordIds []string `json:"record_ids"` + Subtype *string `json:"subtype,omitempty"` + Type *string `json:"type,omitempty"` + UpdatedAt *int64 `json:"updated_at,omitempty"` + UpdatedBy *string `json:"updated_by,omitempty"` + ZoneNames []string `json:"zone_names"` +} + +var ( + zoneAlertType string = "zone" + recordAlertType string = "record" +) + +// TODO - Allow zones/notifier lists to be passed as structs? +// - Multiple constructors? +func NewZoneAlert(alertName string, subtype string, notifierListIds []string, zoneNames []string) *Alert { + return &Alert{ + Name: &alertName, + Type: &zoneAlertType, + Subtype: &subtype, + Data: nil, + NotifierListIds: notifierListIds, + ZoneNames: zoneNames, + } +} + +func NewRecordAlert(notifierListIds []string, recordIds []string, subtype string) *Alert { + return &Alert{ + Type: &recordAlertType, + Subtype: &subtype, + Data: nil, + NotifierListIds: notifierListIds, + RecordIds: recordIds, + } +}