From c90c61df8eac92c2bd8b1aa2422bc8d9a4c24314 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Sun, 15 Mar 2020 17:17:17 +0100 Subject: [PATCH] Share the client code between Selectel and VScale. (#1085) --- .../internal => internal/selectel}/client.go | 74 ++---- .../dns/internal/selectel/client_test.go | 208 +++++++++++++++++ .../internal/selectel/fixtures/domains.json | 4 + .../dns/internal/selectel/fixtures/error.json | 5 + .../selectel/fixtures/list_records.json | 26 +++ providers/dns/internal/selectel/models.go | 30 +++ providers/dns/selectel/selectel.go | 21 +- providers/dns/vscale/internal/client.go | 219 ------------------ providers/dns/vscale/vscale.go | 22 +- 9 files changed, 308 insertions(+), 301 deletions(-) rename providers/dns/{selectel/internal => internal/selectel}/client.go (70%) create mode 100644 providers/dns/internal/selectel/client_test.go create mode 100644 providers/dns/internal/selectel/fixtures/domains.json create mode 100644 providers/dns/internal/selectel/fixtures/error.json create mode 100644 providers/dns/internal/selectel/fixtures/list_records.json create mode 100644 providers/dns/internal/selectel/models.go delete mode 100644 providers/dns/vscale/internal/client.go diff --git a/providers/dns/selectel/internal/client.go b/providers/dns/internal/selectel/client.go similarity index 70% rename from providers/dns/selectel/internal/client.go rename to providers/dns/internal/selectel/client.go index dc5f721a..bbcff800 100644 --- a/providers/dns/selectel/internal/client.go +++ b/providers/dns/internal/selectel/client.go @@ -1,4 +1,4 @@ -package internal +package selectel import ( "bytes" @@ -9,60 +9,25 @@ import ( "strings" ) -// Domain represents domain name. -type Domain struct { - ID int `json:"id,omitempty"` - Name string `json:"name,omitempty"` -} - -// Record represents DNS record. -type Record struct { - ID int `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` // Record type (SOA, NS, A/AAAA, CNAME, SRV, MX, TXT, SPF) - TTL int `json:"ttl,omitempty"` - Email string `json:"email,omitempty"` // Email of domain's admin (only for SOA records) - Content string `json:"content,omitempty"` // Record content (not for SRV) -} - -// APIError API error message -type APIError struct { - Description string `json:"error"` - Code int `json:"code"` - Field string `json:"field"` -} - -func (a APIError) Error() string { - return fmt.Sprintf("API error: %d - %s - %s", a.Code, a.Description, a.Field) -} - -// ClientOpts represents options to init client. -type ClientOpts struct { - BaseURL string - Token string - UserAgent string - HTTPClient *http.Client -} +// Base URL for the Selectel/VScale DNS services. +const ( + DefaultSelectelBaseURL = "https://api.selectel.ru/domains/v1" + DefaultVScaleBaseURL = "https://api.vscale.io/v1/domains" +) // Client represents DNS client. type Client struct { - baseURL string + BaseURL string + HTTPClient *http.Client token string - userAgent string - httpClient *http.Client } // NewClient returns a client instance. -func NewClient(opts ClientOpts) *Client { - if opts.HTTPClient == nil { - opts.HTTPClient = &http.Client{} - } - +func NewClient(token string) *Client { return &Client{ - token: opts.Token, - baseURL: opts.BaseURL, - httpClient: opts.HTTPClient, - userAgent: opts.UserAgent, + token: token, + BaseURL: DefaultVScaleBaseURL, + HTTPClient: &http.Client{}, } } @@ -79,14 +44,13 @@ func (c *Client) GetDomainByName(domainName string) (*Domain, error) { domain := &Domain{} resp, err := c.do(req, domain) if err != nil { - switch { - case resp.StatusCode == http.StatusNotFound && strings.Count(domainName, ".") > 1: + if resp != nil && resp.StatusCode == http.StatusNotFound && strings.Count(domainName, ".") > 1 { // Look up for the next sub domain subIndex := strings.Index(domainName, ".") return c.GetDomainByName(domainName[subIndex+1:]) - default: - return nil, err } + + return nil, err } return domain, nil @@ -110,14 +74,14 @@ func (c *Client) AddRecord(domainID int, body Record) (*Record, error) { } // ListRecords returns list records for specific domain. -func (c *Client) ListRecords(domainID int) ([]*Record, error) { +func (c *Client) ListRecords(domainID int) ([]Record, error) { uri := fmt.Sprintf("/%d/records/", domainID) req, err := c.newRequest(http.MethodGet, uri, nil) if err != nil { return nil, err } - var records []*Record + var records []Record _, err = c.do(req, &records) if err != nil { return nil, err @@ -147,7 +111,7 @@ func (c *Client) newRequest(method, uri string, body interface{}) (*http.Request } } - req, err := http.NewRequest(method, c.baseURL+uri, buf) + req, err := http.NewRequest(method, c.BaseURL+uri, buf) if err != nil { return nil, fmt.Errorf("failed to create new http request with error: %w", err) } @@ -160,7 +124,7 @@ func (c *Client) newRequest(method, uri string, body interface{}) (*http.Request } func (c *Client) do(req *http.Request, to interface{}) (*http.Response, error) { - resp, err := c.httpClient.Do(req) + resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed with error: %w", err) } diff --git a/providers/dns/internal/selectel/client_test.go b/providers/dns/internal/selectel/client_test.go new file mode 100644 index 00000000..056d4259 --- /dev/null +++ b/providers/dns/internal/selectel/client_test.go @@ -0,0 +1,208 @@ +package selectel + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_ListRecords(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + fixture := "./fixtures/list_records.json" + + err := writeResponse(rw, fixture) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client := NewClient("token") + client.BaseURL = server.URL + + records, err := client.ListRecords(123) + require.NoError(t, err) + + expected := []Record{ + {ID: 123, Name: "example.com", Type: "TXT", TTL: 60, Email: "email@example.com", Content: "txttxttxtA"}, + {ID: 1234, Name: "example.org", Type: "TXT", TTL: 60, Email: "email@example.org", Content: "txttxttxtB"}, + {ID: 12345, Name: "example.net", Type: "TXT", TTL: 60, Email: "email@example.net", Content: "txttxttxtC"}, + } + + assert.Equal(t, expected, records) +} + +func TestClient_ListRecords_error(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + rw.WriteHeader(http.StatusUnauthorized) + err := writeResponse(rw, "./fixtures/error.json") + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client := NewClient("token") + client.BaseURL = server.URL + + records, err := client.ListRecords(123) + + assert.EqualError(t, err, "request failed with status code 401: API error: 400 - error description - field that the error occurred in") + assert.Nil(t, records) +} + +func TestClient_GetDomainByName(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/sub.sub.example.org", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + rw.WriteHeader(http.StatusNotFound) + }) + + mux.HandleFunc("/sub.example.org", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + rw.WriteHeader(http.StatusNotFound) + }) + + mux.HandleFunc("/example.org", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + fixture := "./fixtures/domains.json" + + err := writeResponse(rw, fixture) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client := NewClient("token") + client.BaseURL = server.URL + + domain, err := client.GetDomainByName("sub.sub.example.org") + require.NoError(t, err) + + expected := &Domain{ + ID: 123, + Name: "example.org", + } + + assert.Equal(t, expected, domain) +} + +func TestClient_AddRecord(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/123/records/", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + rec := Record{} + + err := json.NewDecoder(req.Body).Decode(&rec) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + rec.ID = 456 + + err = json.NewEncoder(rw).Encode(rec) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + client := NewClient("token") + client.BaseURL = server.URL + + record, err := client.AddRecord(123, Record{ + Name: "example.org", + Type: "TXT", + TTL: 60, + Email: "email@example.org", + Content: "txttxttxttxt", + }) + + require.NoError(t, err) + + expected := &Record{ + ID: 456, + Name: "example.org", + Type: "TXT", + TTL: 60, + Email: "email@example.org", + Content: "txttxttxttxt", + } + + assert.Equal(t, expected, record) +} + +func TestClient_DeleteRecord(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodDelete { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + }) + + client := NewClient("token") + client.BaseURL = server.URL + + err := client.DeleteRecord(123, 456) + require.NoError(t, err) +} + +func writeResponse(rw io.Writer, filename string) error { + file, err := os.Open(filename) + if err != nil { + return err + } + + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + return err +} diff --git a/providers/dns/internal/selectel/fixtures/domains.json b/providers/dns/internal/selectel/fixtures/domains.json new file mode 100644 index 00000000..7d0f4fd1 --- /dev/null +++ b/providers/dns/internal/selectel/fixtures/domains.json @@ -0,0 +1,4 @@ +{ + "id": 123, + "name": "example.org" +} \ No newline at end of file diff --git a/providers/dns/internal/selectel/fixtures/error.json b/providers/dns/internal/selectel/fixtures/error.json new file mode 100644 index 00000000..5be9350b --- /dev/null +++ b/providers/dns/internal/selectel/fixtures/error.json @@ -0,0 +1,5 @@ +{ + "error": "error description", + "code": 400, + "field": "field that the error occurred in" +} \ No newline at end of file diff --git a/providers/dns/internal/selectel/fixtures/list_records.json b/providers/dns/internal/selectel/fixtures/list_records.json new file mode 100644 index 00000000..4149fd4c --- /dev/null +++ b/providers/dns/internal/selectel/fixtures/list_records.json @@ -0,0 +1,26 @@ +[ + { + "id": 123, + "name": "example.com", + "type": "TXT", + "ttl": 60, + "email": "email@example.com", + "content": "txttxttxtA" + }, + { + "id": 1234, + "name": "example.org", + "type": "TXT", + "ttl": 60, + "email": "email@example.org", + "content": "txttxttxtB" + }, + { + "id": 12345, + "name": "example.net", + "type": "TXT", + "ttl": 60, + "email": "email@example.net", + "content": "txttxttxtC" + } +] diff --git a/providers/dns/internal/selectel/models.go b/providers/dns/internal/selectel/models.go new file mode 100644 index 00000000..0332d00e --- /dev/null +++ b/providers/dns/internal/selectel/models.go @@ -0,0 +1,30 @@ +package selectel + +import "fmt" + +// Domain represents domain name. +type Domain struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// Record represents DNS record. +type Record struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` // Record type (SOA, NS, A/AAAA, CNAME, SRV, MX, TXT, SPF) + TTL int `json:"ttl,omitempty"` + Email string `json:"email,omitempty"` // Email of domain's admin (only for SOA records) + Content string `json:"content,omitempty"` // Record content (not for SRV) +} + +// APIError API error message +type APIError struct { + Description string `json:"error"` + Code int `json:"code"` + Field string `json:"field"` +} + +func (a APIError) Error() string { + return fmt.Sprintf("API error: %d - %s - %s", a.Code, a.Description, a.Field) +} diff --git a/providers/dns/selectel/selectel.go b/providers/dns/selectel/selectel.go index 3810d9e0..53fd02e2 100644 --- a/providers/dns/selectel/selectel.go +++ b/providers/dns/selectel/selectel.go @@ -11,13 +11,10 @@ import ( "github.com/go-acme/lego/v3/challenge/dns01" "github.com/go-acme/lego/v3/platform/config/env" - "github.com/go-acme/lego/v3/providers/dns/selectel/internal" + "github.com/go-acme/lego/v3/providers/dns/internal/selectel" ) -const ( - defaultBaseURL = "https://api.selectel.ru/domains/v1" - minTTL = 60 -) +const minTTL = 60 // Environment variables names. const ( @@ -45,7 +42,7 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL), + BaseURL: env.GetOrDefaultString(EnvBaseURL, selectel.DefaultSelectelBaseURL), TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), @@ -58,7 +55,7 @@ func NewDefaultConfig() *Config { // DNSProvider is an implementation of the challenge.Provider interface. type DNSProvider struct { config *Config - client *internal.Client + client *selectel.Client } // NewDNSProvider returns a DNSProvider instance configured for Selectel Domains API. @@ -89,11 +86,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("selectel: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } - client := internal.NewClient(internal.ClientOpts{ - BaseURL: config.BaseURL, - Token: config.Token, - HTTPClient: config.HTTPClient, - }) + client := selectel.NewClient(config.Token) + client.BaseURL = config.BaseURL + client.HTTPClient = config.HTTPClient return &DNSProvider{config: config, client: client}, nil } @@ -113,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("selectel: %w", err) } - txtRecord := internal.Record{ + txtRecord := selectel.Record{ Type: "TXT", TTL: d.config.TTL, Name: fqdn, diff --git a/providers/dns/vscale/internal/client.go b/providers/dns/vscale/internal/client.go deleted file mode 100644 index e169a262..00000000 --- a/providers/dns/vscale/internal/client.go +++ /dev/null @@ -1,219 +0,0 @@ -package internal - -import ( - "bytes" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "strings" -) - -// Domain represents domain name. -type Domain struct { - ID int `json:"id,omitempty"` - Name string `json:"name,omitempty"` -} - -// Record represents DNS record. -type Record struct { - ID int `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` // Record type (SOA, NS, A/AAAA, CNAME, SRV, MX, TXT, SPF) - TTL int `json:"ttl,omitempty"` - Email string `json:"email,omitempty"` // Email of domain's admin (only for SOA records) - Content string `json:"content,omitempty"` // Record content (not for SRV) -} - -// APIError API error message -type APIError struct { - Description string `json:"error"` - Code int `json:"code"` - Field string `json:"field"` -} - -func (a APIError) Error() string { - return fmt.Sprintf("API error: %d - %s - %s", a.Code, a.Description, a.Field) -} - -// ClientOpts represents options to init client. -type ClientOpts struct { - BaseURL string - Token string - UserAgent string - HTTPClient *http.Client -} - -// Client represents DNS client. -type Client struct { - baseURL string - token string - userAgent string - httpClient *http.Client -} - -// NewClient returns a client instance. -func NewClient(opts ClientOpts) *Client { - if opts.HTTPClient == nil { - opts.HTTPClient = &http.Client{} - } - - return &Client{ - token: opts.Token, - baseURL: opts.BaseURL, - httpClient: opts.HTTPClient, - userAgent: opts.UserAgent, - } -} - -// GetDomainByName gets Domain object by its name. If `domainName` level > 2 and there is -// no such domain on the account - it'll recursively search for the first -// which is exists in Vscale Domains API. -func (c *Client) GetDomainByName(domainName string) (*Domain, error) { - uri := fmt.Sprintf("/%s", domainName) - req, err := c.newRequest(http.MethodGet, uri, nil) - if err != nil { - return nil, err - } - - domain := &Domain{} - resp, err := c.do(req, domain) - if err != nil { - switch { - case resp.StatusCode == http.StatusNotFound && strings.Count(domainName, ".") > 1: - // Look up for the next sub domain - subIndex := strings.Index(domainName, ".") - return c.GetDomainByName(domainName[subIndex+1:]) - default: - return nil, err - } - } - - return domain, nil -} - -// AddRecord adds Record for given domain. -func (c *Client) AddRecord(domainID int, body Record) (*Record, error) { - uri := fmt.Sprintf("/%d/records/", domainID) - req, err := c.newRequest(http.MethodPost, uri, body) - if err != nil { - return nil, err - } - - record := &Record{} - _, err = c.do(req, record) - if err != nil { - return nil, err - } - - return record, nil -} - -// ListRecords returns list records for specific domain. -func (c *Client) ListRecords(domainID int) ([]*Record, error) { - uri := fmt.Sprintf("/%d/records/", domainID) - req, err := c.newRequest(http.MethodGet, uri, nil) - if err != nil { - return nil, err - } - - var records []*Record - _, err = c.do(req, &records) - if err != nil { - return nil, err - } - return records, nil -} - -// DeleteRecord deletes specific record. -func (c *Client) DeleteRecord(domainID, recordID int) error { - uri := fmt.Sprintf("/%d/records/%d", domainID, recordID) - req, err := c.newRequest(http.MethodDelete, uri, nil) - if err != nil { - return err - } - - _, err = c.do(req, nil) - return err -} - -func (c *Client) newRequest(method, uri string, body interface{}) (*http.Request, error) { - buf := new(bytes.Buffer) - - if body != nil { - err := json.NewEncoder(buf).Encode(body) - if err != nil { - return nil, fmt.Errorf("failed to encode request body with error: %w", err) - } - } - - req, err := http.NewRequest(method, c.baseURL+uri, buf) - if err != nil { - return nil, fmt.Errorf("failed to create new http request with error: %w", err) - } - - req.Header.Add("X-Token", c.token) - req.Header.Add("Content-Type", "application/json") - req.Header.Add("Accept", "application/json") - - return req, nil -} - -func (c *Client) do(req *http.Request, to interface{}) (*http.Response, error) { - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed with error: %w", err) - } - - err = checkResponse(resp) - if err != nil { - return resp, err - } - - if to != nil { - if err = unmarshalBody(resp, to); err != nil { - return resp, err - } - } - - return resp, nil -} - -func checkResponse(resp *http.Response) error { - if resp.StatusCode >= http.StatusBadRequest { - if resp.Body == nil { - return fmt.Errorf("request failed with status code %d and empty body", resp.StatusCode) - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return err - } - defer resp.Body.Close() - - apiError := APIError{} - err = json.Unmarshal(body, &apiError) - if err != nil { - return fmt.Errorf("request failed with status code %d, response body: %s", resp.StatusCode, string(body)) - } - - return fmt.Errorf("request failed with status code %d: %w", resp.StatusCode, apiError) - } - - return nil -} - -func unmarshalBody(resp *http.Response, to interface{}) error { - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return err - } - defer resp.Body.Close() - - err = json.Unmarshal(body, to) - if err != nil { - return fmt.Errorf("unmarshaling error: %w: %s", err, string(body)) - } - - return nil -} diff --git a/providers/dns/vscale/vscale.go b/providers/dns/vscale/vscale.go index 6fbec551..84f424d6 100644 --- a/providers/dns/vscale/vscale.go +++ b/providers/dns/vscale/vscale.go @@ -10,15 +10,11 @@ import ( "time" "github.com/go-acme/lego/v3/challenge/dns01" - "github.com/go-acme/lego/v3/providers/dns/vscale/internal" - "github.com/go-acme/lego/v3/platform/config/env" + "github.com/go-acme/lego/v3/providers/dns/internal/selectel" ) -const ( - defaultBaseURL = "https://api.vscale.io/v1/domains" - minTTL = 60 -) +const minTTL = 60 // Environment variables names. const ( @@ -46,7 +42,7 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL), + BaseURL: env.GetOrDefaultString(EnvBaseURL, selectel.DefaultVScaleBaseURL), TTL: env.GetOrDefaultInt(EnvTTL, minTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), @@ -59,7 +55,7 @@ func NewDefaultConfig() *Config { // DNSProvider is an implementation of the challenge.Provider interface. type DNSProvider struct { config *Config - client *internal.Client + client *selectel.Client } // NewDNSProvider returns a DNSProvider instance configured for Vscale Domains API. @@ -90,11 +86,9 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("vscale: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } - client := internal.NewClient(internal.ClientOpts{ - BaseURL: config.BaseURL, - Token: config.Token, - HTTPClient: config.HTTPClient, - }) + client := selectel.NewClient(config.Token) + client.BaseURL = config.BaseURL + client.HTTPClient = config.HTTPClient return &DNSProvider{config: config, client: client}, nil } @@ -114,7 +108,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("vscale: %w", err) } - txtRecord := internal.Record{ + txtRecord := selectel.Record{ Type: "TXT", TTL: d.config.TTL, Name: fqdn,