diff --git a/providers/dns/constellix/constellix.go b/providers/dns/constellix/constellix.go index fd1d4f7a..a9bc2304 100644 --- a/providers/dns/constellix/constellix.go +++ b/providers/dns/constellix/constellix.go @@ -104,22 +104,26 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, fqdn, err) } - domainID, err := d.client.Domains.GetID(dns01.UnFqdn(authZone)) + dom, err := d.client.Domains.GetByName(dns01.UnFqdn(authZone)) if err != nil { - return fmt.Errorf("constellix: failed to get domain ID: %w", err) - } - - records, err := d.client.TxtRecords.GetAll(domainID) - if err != nil { - return fmt.Errorf("constellix: failed to get TXT records: %w", err) + return fmt.Errorf("constellix: failed to get domain: %w", err) } recordName := getRecordName(fqdn, authZone) - record := findRecords(records, recordName) + records, err := d.client.TxtRecords.Search(dom.ID, internal.Exact, recordName) + if err != nil { + return fmt.Errorf("constellix: failed to get TXT records: %w", err) + } + + if len(records) > 1 { + return errors.New("constellix: failed to get TXT records") + } // TXT record entry already existing - if record != nil { + if len(records) == 1 { + record := records[0] + if containsValue(record, value) { return nil } @@ -130,7 +134,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { RoundRobin: append(record.RoundRobin, internal.RecordValue{Value: fmt.Sprintf(`"%s"`, value)}), } - _, err = d.client.TxtRecords.Update(domainID, record.ID, request) + _, err = d.client.TxtRecords.Update(dom.ID, record.ID, request) if err != nil { return fmt.Errorf("constellix: failed to update TXT records: %w", err) } @@ -145,7 +149,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { }, } - _, err = d.client.TxtRecords.Create(domainID, request) + _, err = d.client.TxtRecords.Create(dom.ID, request) if err != nil { return fmt.Errorf("constellix: failed to create TXT record %s: %w", fqdn, err) } @@ -162,30 +166,35 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, fqdn, err) } - domainID, err := d.client.Domains.GetID(dns01.UnFqdn(authZone)) + dom, err := d.client.Domains.GetByName(dns01.UnFqdn(authZone)) if err != nil { - return fmt.Errorf("constellix: failed to get domain ID: %w", err) - } - - records, err := d.client.TxtRecords.GetAll(domainID) - if err != nil { - return fmt.Errorf("constellix: failed to get TXT records: %w", err) + return fmt.Errorf("constellix: failed to get domain: %w", err) } recordName := getRecordName(fqdn, authZone) - record := findRecords(records, recordName) - if record == nil { + records, err := d.client.TxtRecords.Search(dom.ID, internal.Exact, recordName) + if err != nil { + return fmt.Errorf("constellix: failed to get TXT records: %w", err) + } + + if len(records) > 1 { + return errors.New("constellix: failed to get TXT records") + } + + if len(records) == 0 { return nil } + record := records[0] + if !containsValue(record, value) { return nil } // only 1 record value, the whole record must be deleted. if len(record.Value) == 1 { - _, err = d.client.TxtRecords.Delete(domainID, record.ID) + _, err = d.client.TxtRecords.Delete(dom.ID, record.ID) if err != nil { return fmt.Errorf("constellix: failed to delete TXT records: %w", err) } @@ -203,7 +212,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } } - _, err = d.client.TxtRecords.Update(domainID, record.ID, request) + _, err = d.client.TxtRecords.Update(dom.ID, record.ID, request) if err != nil { return fmt.Errorf("constellix: failed to update TXT records: %w", err) } @@ -211,17 +220,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } -func findRecords(records []internal.Record, name string) *internal.Record { - for _, r := range records { - if r.Name == name { - return &r - } - } - - return nil -} - -func containsValue(record *internal.Record, value string) bool { +func containsValue(record internal.Record, value string) bool { for _, val := range record.Value { if val.Value == fmt.Sprintf(`"%s"`, value) { return true diff --git a/providers/dns/constellix/internal/client.go b/providers/dns/constellix/internal/client.go index ba0bf812..5f4db2df 100644 --- a/providers/dns/constellix/internal/client.go +++ b/providers/dns/constellix/internal/client.go @@ -96,11 +96,20 @@ func checkResponse(resp *http.Response) error { data, err := ioutil.ReadAll(resp.Body) if err == nil && data != nil { - msg := APIError{} - if json.Unmarshal(data, &msg) != nil { + msg := &APIError{StatusCode: resp.StatusCode} + + if json.Unmarshal(data, msg) != nil { return fmt.Errorf("API error: status code: %d: %v", resp.StatusCode, string(data)) } - return msg + + switch resp.StatusCode { + case http.StatusNotFound: + return &NotFound{APIError: msg} + case http.StatusBadRequest: + return &BadRequest{APIError: msg} + default: + return msg + } } return fmt.Errorf("API error, status code: %d", resp.StatusCode) diff --git a/providers/dns/constellix/internal/domains.go b/providers/dns/constellix/internal/domains.go index 04bb1df1..c6e2480d 100644 --- a/providers/dns/constellix/internal/domains.go +++ b/providers/dns/constellix/internal/domains.go @@ -1,6 +1,7 @@ package internal import ( + "errors" "fmt" "net/http" @@ -10,48 +11,8 @@ import ( // DomainService API access to Domain. type DomainService service -// GetID for a domain name. -func (s *DomainService) GetID(domainName string) (int64, error) { - params := &PaginationParameters{ - Offset: 0, - Max: 100, - Sort: "name", - Order: "asc", - } - - domains, err := s.GetAll(params) - if err != nil { - return 0, err - } - - for len(domains) > 0 { - for _, domain := range domains { - if domain.Name == domainName { - return domain.ID, nil - } - } - - if params.Max > len(domains) { - break - } - - params = &PaginationParameters{ - Offset: params.Max, - Max: 100, - Sort: "name", - Order: "asc", - } - - domains, err = s.GetAll(params) - if err != nil { - return 0, err - } - } - - return 0, fmt.Errorf("domain not found: %s", domainName) -} - // GetAll domains. +// https://api-docs.constellix.com/?version=latest#484c3f21-d724-4ee4-a6fa-ab22c8eb9e9b func (s *DomainService) GetAll(params *PaginationParameters) ([]Domain, error) { endpoint, err := s.client.createEndpoint(defaultVersion, "domains") if err != nil { @@ -79,3 +40,50 @@ func (s *DomainService) GetAll(params *PaginationParameters) ([]Domain, error) { return domains, nil } + +// GetByName Gets domain by name. +func (s *DomainService) GetByName(domainName string) (Domain, error) { + domains, err := s.Search(Exact, domainName) + if err != nil { + return Domain{}, err + } + + if len(domains) == 0 { + return Domain{}, fmt.Errorf("domain not found: %s", domainName) + } + + if len(domains) > 1 { + return Domain{}, fmt.Errorf("multiple domains found: %v", domains) + } + + return domains[0], nil +} + +// Search searches for a domain by name. +// https://api-docs.constellix.com/?version=latest#3d7b2679-2209-49f3-b011-b7d24e512008 +func (s *DomainService) Search(filter searchFilter, value string) ([]Domain, error) { + endpoint, err := s.client.createEndpoint(defaultVersion, "domains", "search") + if err != nil { + return nil, fmt.Errorf("failed to create request endpoint: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + query := req.URL.Query() + query.Set(string(filter), value) + req.URL.RawQuery = query.Encode() + + var domains []Domain + err = s.client.do(req, &domains) + if err != nil { + var nf *NotFound + if !errors.As(err, &nf) { + return nil, err + } + } + + return domains, nil +} diff --git a/providers/dns/constellix/internal/domains_test.go b/providers/dns/constellix/internal/domains_test.go index 311f00af..db16faec 100644 --- a/providers/dns/constellix/internal/domains_test.go +++ b/providers/dns/constellix/internal/domains_test.go @@ -31,7 +31,7 @@ func TestDomainService_GetAll(t *testing.T) { return } - file, err := os.Open("./fixtures/domains-01.json") + file, err := os.Open("./fixtures/domains-GetAll.json") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -49,23 +49,26 @@ func TestDomainService_GetAll(t *testing.T) { require.NoError(t, err) expected := []Domain{ - {ID: 273302, Name: "lego.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, + {ID: 273301, Name: "aaa.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, + {ID: 273302, Name: "bbb.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, + {ID: 273303, Name: "ccc.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, + {ID: 273304, Name: "ddd.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, } assert.Equal(t, expected, data) } -func TestDomainService_GetID(t *testing.T) { +func TestDomainService_Search(t *testing.T) { client, handler, tearDown := setupAPIMock() defer tearDown() - handler.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) { + handler.HandleFunc("/v1/domains/search", func(rw http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) return } - file, err := os.Open("./fixtures/domains-02.json") + file, err := os.Open("./fixtures/domains-Search.json") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -79,8 +82,12 @@ func TestDomainService_GetID(t *testing.T) { } }) - data, err := client.Domains.GetID("ddd.wtf") + data, err := client.Domains.Search(Exact, "lego.wtf") require.NoError(t, err) - assert.EqualValues(t, 273304, data) + expected := []Domain{ + {ID: 273302, Name: "lego.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, + } + + assert.Equal(t, expected, data) } diff --git a/providers/dns/constellix/internal/fixtures/domains-02.json b/providers/dns/constellix/internal/fixtures/domains-GetAll.json similarity index 100% rename from providers/dns/constellix/internal/fixtures/domains-02.json rename to providers/dns/constellix/internal/fixtures/domains-GetAll.json diff --git a/providers/dns/constellix/internal/fixtures/domains-01.json b/providers/dns/constellix/internal/fixtures/domains-Search.json similarity index 100% rename from providers/dns/constellix/internal/fixtures/domains-01.json rename to providers/dns/constellix/internal/fixtures/domains-Search.json diff --git a/providers/dns/constellix/internal/fixtures/records-01.json b/providers/dns/constellix/internal/fixtures/records-Create.json similarity index 100% rename from providers/dns/constellix/internal/fixtures/records-01.json rename to providers/dns/constellix/internal/fixtures/records-Create.json diff --git a/providers/dns/constellix/internal/fixtures/records-02.json b/providers/dns/constellix/internal/fixtures/records-Get.json similarity index 100% rename from providers/dns/constellix/internal/fixtures/records-02.json rename to providers/dns/constellix/internal/fixtures/records-Get.json diff --git a/providers/dns/constellix/internal/fixtures/records-GetAll.json b/providers/dns/constellix/internal/fixtures/records-GetAll.json new file mode 100644 index 00000000..54f27a9e --- /dev/null +++ b/providers/dns/constellix/internal/fixtures/records-GetAll.json @@ -0,0 +1,25 @@ +[ + { + "id": 3557066, + "type": "TXT", + "recordType": "txt", + "name": "test", + "recordOption": "roundRobin", + "ttl": 300, + "gtdRegion": 1, + "parentId": 273302, + "parent": "domain", + "source": "Domain", + "modifiedTs": 1580908547865, + "value": [ + { + "value": "\"test\"" + } + ], + "roundRobin": [ + { + "value": "\"test\"" + } + ] + } +] \ No newline at end of file diff --git a/providers/dns/constellix/internal/fixtures/records-Search.json b/providers/dns/constellix/internal/fixtures/records-Search.json new file mode 100644 index 00000000..54f27a9e --- /dev/null +++ b/providers/dns/constellix/internal/fixtures/records-Search.json @@ -0,0 +1,25 @@ +[ + { + "id": 3557066, + "type": "TXT", + "recordType": "txt", + "name": "test", + "recordOption": "roundRobin", + "ttl": 300, + "gtdRegion": 1, + "parentId": 273302, + "parent": "domain", + "source": "Domain", + "modifiedTs": 1580908547865, + "value": [ + { + "value": "\"test\"" + } + ], + "roundRobin": [ + { + "value": "\"test\"" + } + ] + } +] \ No newline at end of file diff --git a/providers/dns/constellix/internal/model.go b/providers/dns/constellix/internal/model.go index 955e8a66..47258ac2 100644 --- a/providers/dns/constellix/internal/model.go +++ b/providers/dns/constellix/internal/model.go @@ -1,16 +1,46 @@ package internal import ( + "fmt" "strings" ) +// Search filters +const ( + StartsWith searchFilter = "startswith" + Exact searchFilter = "exact" + EndsWith searchFilter = "endswith" + Contains searchFilter = "contains" +) + +type searchFilter string + +// NotFound Not found error. +type NotFound struct { + *APIError +} + +func (e *NotFound) Unwrap() error { + return e.APIError +} + +// BadRequest Bad request error. +type BadRequest struct { + *APIError +} + +func (e *BadRequest) Unwrap() error { + return e.APIError +} + // APIError is the representation of an API error. type APIError struct { - Errors []string `json:"errors"` + StatusCode int `json:"statusCode"` + Errors []string `json:"errors"` } func (a APIError) Error() string { - return strings.Join(a.Errors, ": ") + return fmt.Sprintf("%d: %s", a.StatusCode, strings.Join(a.Errors, ": ")) } // SuccessMessage is the representation of a success message. diff --git a/providers/dns/constellix/internal/txtrecords.go b/providers/dns/constellix/internal/txtrecords.go index 9968f589..e9df28e6 100644 --- a/providers/dns/constellix/internal/txtrecords.go +++ b/providers/dns/constellix/internal/txtrecords.go @@ -3,6 +3,7 @@ package internal import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -130,3 +131,33 @@ func (s *TxtRecordService) Delete(domainID, recordID int64) (*SuccessMessage, er return msg, nil } + +// Search searches for a TXT record by name. +// https://api-docs.constellix.com/?version=latest#81003e4f-bd3f-413f-a18d-6d9d18f10201 +func (s *TxtRecordService) Search(domainID int64, filter searchFilter, value string) ([]Record, error) { + endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", "search") + if err != nil { + return nil, fmt.Errorf("failed to create request endpoint: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + query := req.URL.Query() + query.Set(string(filter), value) + req.URL.RawQuery = query.Encode() + + var records []Record + + err = s.client.do(req, &records) + if err != nil { + var nf *NotFound + if !errors.As(err, &nf) { + return nil, err + } + } + + return records, nil +} diff --git a/providers/dns/constellix/internal/txtrecords_test.go b/providers/dns/constellix/internal/txtrecords_test.go index 460169dd..a942011d 100644 --- a/providers/dns/constellix/internal/txtrecords_test.go +++ b/providers/dns/constellix/internal/txtrecords_test.go @@ -22,7 +22,7 @@ func TestTxtRecordService_Create(t *testing.T) { return } - file, err := os.Open("./fixtures/records-01.json") + file, err := os.Open("./fixtures/records-Create.json") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -42,7 +42,7 @@ func TestTxtRecordService_Create(t *testing.T) { recordsJSON, err := json.Marshal(records) require.NoError(t, err) - expectedContent, err := ioutil.ReadFile("./fixtures/records-01.json") + expectedContent, err := ioutil.ReadFile("./fixtures/records-Create.json") require.NoError(t, err) assert.JSONEq(t, string(expectedContent), string(recordsJSON)) @@ -58,7 +58,7 @@ func TestTxtRecordService_GetAll(t *testing.T) { return } - file, err := os.Open("./fixtures/records-01.json") + file, err := os.Open("./fixtures/records-GetAll.json") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -78,7 +78,7 @@ func TestTxtRecordService_GetAll(t *testing.T) { recordsJSON, err := json.Marshal(records) require.NoError(t, err) - expectedContent, err := ioutil.ReadFile("./fixtures/records-01.json") + expectedContent, err := ioutil.ReadFile("./fixtures/records-GetAll.json") require.NoError(t, err) assert.JSONEq(t, string(expectedContent), string(recordsJSON)) @@ -94,7 +94,7 @@ func TestTxtRecordService_Get(t *testing.T) { return } - file, err := os.Open("./fixtures/records-02.json") + file, err := os.Open("./fixtures/records-Get.json") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -180,3 +180,39 @@ func TestTxtRecordService_Delete(t *testing.T) { expected := &SuccessMessage{Success: "Record deleted successfully"} assert.Equal(t, expected, msg) } + +func TestTxtRecordService_Search(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/v1/domains/12345/records/txt/search", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + file, err := os.Open("./fixtures/records-Search.json") + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + records, err := client.TxtRecords.Search(12345, Exact, "test") + require.NoError(t, err) + + recordsJSON, err := json.Marshal(records) + require.NoError(t, err) + + expectedContent, err := ioutil.ReadFile("./fixtures/records-Search.json") + require.NoError(t, err) + + assert.JSONEq(t, string(expectedContent), string(recordsJSON)) +}