diff --git a/forwardemail/accounts.go b/forwardemail/accounts.go index e62a9d3..2dfb3e0 100644 --- a/forwardemail/accounts.go +++ b/forwardemail/accounts.go @@ -5,7 +5,9 @@ package forwardemail import ( + "context" "encoding/json" + "fmt" "time" ) @@ -26,22 +28,27 @@ type Account struct { } // GetAccount retrieves the authenticated user's account information from the Forward Email API. -func (c *Client) GetAccount() (*Account, error) { - req, err := c.newRequest("GET", "/v1/account") - if err != nil { +func (c *Client) GetAccount(ctx context.Context) (*Account, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { return nil, err } + req, err := c.newRequest(ctx, "GET", "/v1/account", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for GetAccount: %w", err) + } + res, err := c.doRequest(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to fetch account: %w", err) } var item Account - - err = json.Unmarshal(res, &item) - if err != nil { - return nil, err + if err := json.Unmarshal(res, &item); err != nil { + return nil, fmt.Errorf("failed to parse account response: %w", err) } return &item, nil diff --git a/forwardemail/accounts_test.go b/forwardemail/accounts_test.go index d73ca59..07f5b07 100644 --- a/forwardemail/accounts_test.go +++ b/forwardemail/accounts_test.go @@ -4,6 +4,7 @@ package forwardemail import ( + "context" "fmt" "net/http" "net/http/httptest" @@ -61,12 +62,9 @@ func TestClient_GetAccount(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.GetAccount() + got, _ := c.GetAccount(context.Background()) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } diff --git a/forwardemail/aliases.go b/forwardemail/aliases.go index aa89544..2de1e26 100644 --- a/forwardemail/aliases.go +++ b/forwardemail/aliases.go @@ -4,9 +4,9 @@ package forwardemail import ( + "context" "encoding/json" "fmt" - "io" "net/url" "strconv" "strings" @@ -39,55 +39,89 @@ type AliasParameters struct { } // GetAliases retrieves all email aliases for the specified domain. -func (c *Client) GetAliases(domain string) ([]Alias, error) { - req, err := c.newRequest("GET", fmt.Sprintf("/v1/domains/%s/aliases", domain)) - if err != nil { +func (c *Client) GetAliases(ctx context.Context, domain string) ([]Alias, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { return nil, err } + if strings.TrimSpace(domain) == "" { + return nil, ErrEmptyDomain + } + + encodedDomain := url.PathEscape(domain) + + req, err := c.newRequest(ctx, "GET", fmt.Sprintf("/v1/domains/%s/aliases", encodedDomain), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for GetAliases: %w", err) + } res, err := c.doRequest(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to fetch aliases: %w", err) } var items []Alias - - err = json.Unmarshal(res, &items) - if err != nil { - return nil, err + if err := json.Unmarshal(res, &items); err != nil { + return nil, fmt.Errorf("failed to parse aliases response: %w", err) } return items, nil } // GetAlias retrieves a specific email alias by name for the specified domain. -func (c *Client) GetAlias(domain, alias string) (*Alias, error) { - req, err := c.newRequest("GET", fmt.Sprintf("/v1/domains/%s/aliases/%s", domain, alias)) - if err != nil { +func (c *Client) GetAlias(ctx context.Context, domain, alias string) (*Alias, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { return nil, err } + if strings.TrimSpace(domain) == "" { + return nil, ErrEmptyDomain + } + if strings.TrimSpace(alias) == "" { + return nil, ErrEmptyAlias + } + + encodedDomain := url.PathEscape(domain) + encodedAlias := url.PathEscape(alias) + + req, err := c.newRequest(ctx, "GET", fmt.Sprintf("/v1/domains/%s/aliases/%s", encodedDomain, encodedAlias), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for GetAlias: %w", err) + } res, err := c.doRequest(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to fetch alias: %w", err) } var item Alias - - err = json.Unmarshal(res, &item) - if err != nil { - return nil, err + if err := json.Unmarshal(res, &item); err != nil { + return nil, fmt.Errorf("failed to parse alias response: %w", err) } return &item, nil } // CreateAlias creates a new email alias for the specified domain with the given parameters. -func (c *Client) CreateAlias(domain, alias string, parameters AliasParameters) (*Alias, error) { - req, err := c.newRequest("POST", fmt.Sprintf("/v1/domains/%s/aliases", domain)) - if err != nil { +func (c *Client) CreateAlias(ctx context.Context, domain, alias string, parameters AliasParameters) (*Alias, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { return nil, err } + if strings.TrimSpace(domain) == "" { + return nil, ErrEmptyDomain + } + if strings.TrimSpace(alias) == "" { + return nil, ErrEmptyAlias + } + + encodedDomain := url.PathEscape(domain) params := url.Values{} params.Add("name", alias) @@ -115,30 +149,43 @@ func (c *Client) CreateAlias(domain, alias string, parameters AliasParameters) ( } } - req.Body = io.NopCloser(strings.NewReader(params.Encode())) + req, err := c.newRequest(ctx, "POST", fmt.Sprintf("/v1/domains/%s/aliases", encodedDomain), strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request for CreateAlias: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := c.doRequest(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create alias: %w", err) } var item Alias - - err = json.Unmarshal(res, &item) - if err != nil { - return nil, err + if err := json.Unmarshal(res, &item); err != nil { + return nil, fmt.Errorf("failed to parse create alias response: %w", err) } return &item, nil } // UpdateAlias updates an existing email alias with new parameters for the specified domain. -func (c *Client) UpdateAlias(domain, alias string, parameters AliasParameters) (*Alias, error) { - req, err := c.newRequest("PUT", fmt.Sprintf("/v1/domains/%s/aliases/%s", domain, alias)) - if err != nil { +func (c *Client) UpdateAlias(ctx context.Context, domain, alias string, parameters AliasParameters) (*Alias, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { return nil, err } + if strings.TrimSpace(domain) == "" { + return nil, ErrEmptyDomain + } + if strings.TrimSpace(alias) == "" { + return nil, ErrEmptyAlias + } + + encodedDomain := url.PathEscape(domain) + encodedAlias := url.PathEscape(alias) params := url.Values{} params.Add("name", alias) @@ -166,34 +213,52 @@ func (c *Client) UpdateAlias(domain, alias string, parameters AliasParameters) ( } } - req.Body = io.NopCloser(strings.NewReader(params.Encode())) + req, err := c.newRequest(ctx, "PUT", fmt.Sprintf("/v1/domains/%s/aliases/%s", encodedDomain, encodedAlias), strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request for UpdateAlias: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := c.doRequest(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to update alias: %w", err) } var item Alias - - err = json.Unmarshal(res, &item) - if err != nil { - return nil, err + if err := json.Unmarshal(res, &item); err != nil { + return nil, fmt.Errorf("failed to parse update alias response: %w", err) } return &item, nil } // DeleteAlias removes an email alias from the specified domain. -func (c *Client) DeleteAlias(domain, alias string) error { - req, err := c.newRequest("DELETE", fmt.Sprintf("/v1/domains/%s/aliases/%s", domain, alias)) - if err != nil { +func (c *Client) DeleteAlias(ctx context.Context, domain, alias string) error { + if ctx == nil { + return ErrNilContext + } + if err := ctx.Err(); err != nil { return err } + if strings.TrimSpace(domain) == "" { + return ErrEmptyDomain + } + if strings.TrimSpace(alias) == "" { + return ErrEmptyAlias + } + + encodedDomain := url.PathEscape(domain) + encodedAlias := url.PathEscape(alias) + + req, err := c.newRequest(ctx, "DELETE", fmt.Sprintf("/v1/domains/%s/aliases/%s", encodedDomain, encodedAlias), nil) + if err != nil { + return fmt.Errorf("failed to create request for DeleteAlias: %w", err) + } _, err = c.doRequest(req) if err != nil { - return err + return fmt.Errorf("failed to delete alias: %w", err) } return nil diff --git a/forwardemail/aliases_test.go b/forwardemail/aliases_test.go index c2dcdb2..26225fe 100644 --- a/forwardemail/aliases_test.go +++ b/forwardemail/aliases_test.go @@ -4,6 +4,7 @@ package forwardemail import ( + "context" "errors" "fmt" "net/http" @@ -28,6 +29,10 @@ func TestClient_GetAlias(t *testing.T) { }{ { name: "no data", + req: request{ + domain: "stark.com", + alias: "tony", + }, }, { name: "ok", @@ -91,12 +96,9 @@ func TestClient_GetAlias(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.GetAlias(tt.req.domain, tt.req.alias) + got, _ := c.GetAlias(context.Background(), tt.req.domain, tt.req.alias) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } @@ -117,6 +119,9 @@ func TestClient_GetAliases(t *testing.T) { }{ { name: "no data", + req: request{ + domain: "stark.com", + }, }, { name: "ok", @@ -227,12 +232,9 @@ func TestClient_GetAliases(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.GetAliases(tt.req.domain) + got, _ := c.GetAliases(context.Background(), tt.req.domain) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } @@ -255,6 +257,10 @@ func TestClient_CreateAlias(t *testing.T) { }{ { name: "no data", + req: request{ + domain: "stark.com", + alias: "tony", + }, }, { name: "ok", @@ -325,12 +331,9 @@ func TestClient_CreateAlias(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.CreateAlias(tt.req.domain, tt.req.alias, tt.req.params) + got, _ := c.CreateAlias(context.Background(), tt.req.domain, tt.req.alias, tt.req.params) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } @@ -353,6 +356,10 @@ func TestClient_UpdateAlias(t *testing.T) { }{ { name: "no data", + req: request{ + domain: "stark.com", + alias: "tony", + }, }, { name: "ok", @@ -424,12 +431,9 @@ func TestClient_UpdateAlias(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.UpdateAlias(tt.req.domain, tt.req.alias, tt.req.params) + got, _ := c.UpdateAlias(context.Background(), tt.req.domain, tt.req.alias, tt.req.params) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } @@ -455,12 +459,20 @@ func TestClient_DeleteAlias(t *testing.T) { }{ { name: "ok", + req: request{ + domain: "stark.com", + alias: "tony", + }, res: response{ code: http.StatusNoContent, }, }, { name: "not ok", + req: request{ + domain: "stark.com", + alias: "tony", + }, res: response{ code: http.StatusInternalServerError, body: "oh no", @@ -477,18 +489,16 @@ func TestClient_DeleteAlias(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got := c.DeleteAlias(tt.req.domain, tt.req.alias) + got := c.DeleteAlias(context.Background(), tt.req.domain, tt.req.alias) if tt.wantError { if got == nil { t.Fatal("expected error, got nil") } - if !errors.Is(got, ErrRequestFailure) { - t.Fatalf("expected error to wrap ErrRequestFailure, got %v", got) + var apiErr *APIError + if !errors.As(got, &apiErr) { + t.Fatalf("expected error to wrap *APIError, got %v", got) } } else if got != nil { t.Fatalf("expected no error, got %v", got) diff --git a/forwardemail/client.go b/forwardemail/client.go index 633d4b9..e68d61f 100644 --- a/forwardemail/client.go +++ b/forwardemail/client.go @@ -4,6 +4,7 @@ package forwardemail import ( + "context" "fmt" "io" "net/http" @@ -14,53 +15,92 @@ const ( forwardemailAPIURL = "https://api.forwardemail.net" ) -// ClientOptions contains configuration options for creating a new Forward Email API client. -type ClientOptions struct { - APIKey string - APIURL string -} - // Client is the main client for interacting with the Forward Email API. type Client struct { - APIKey string - APIURL string - - HTTPClient *http.Client + apiKey string + apiURL string + httpClient *http.Client } -// NewClient returns a new Forward Email API Client. -func NewClient(options ClientOptions) (*Client, error) { - apiURL := forwardemailAPIURL - if options.APIURL != "" { - apiURL = options.APIURL - } +// Option configures a Client. They are produced by With* helpers. +type Option func(*Client) + +// WithHTTPClient lets callers supply their own *http.Client (for custom +// timeouts, proxies, tracing, etc.) +func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.httpClient = h } } + +// WithAPIURL lets callers override the default Forward Email API base URL. +func WithAPIURL(u string) Option { return func(c *Client) { c.apiURL = u } } - if options.APIKey == "" { +// NewClient returns a new Forward Email API Client. +func NewClient(apiKey string, opts ...Option) (*Client, error) { + if apiKey == "" { return nil, fmt.Errorf("%w", ErrMissingAPIKey) } c := &Client{ - APIKey: options.APIKey, - APIURL: apiURL, - HTTPClient: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + apiURL: forwardemailAPIURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } + + for _, opt := range opts { + opt(c) + } + + if err := c.validate(); err != nil { + return nil, err } return c, nil } -func (c *Client) newRequest(method, path string) (*http.Request, error) { - req, err := http.NewRequest(method, c.APIURL+path, http.NoBody) +// validate checks that the client is properly configured. +func (c *Client) validate() error { + if c.httpClient == nil { + return ErrNilHTTPClient + } + return nil +} + +// validateRequest validates the request parameters and client state. +func (c *Client) validateRequest(ctx context.Context, method, path string) error { + if ctx == nil { + return ErrNilContext + } + if err := ctx.Err(); err != nil { + return err + } + if method == "" { + return ErrEmptyMethod + } + if path == "" { + return ErrEmptyPath + } + return nil +} + +func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + if err := c.validateRequest(ctx, method, path); err != nil { + return nil, err + } + + if body == nil { + body = http.NoBody + } + + req, err := http.NewRequestWithContext(ctx, method, c.apiURL+path, body) if err != nil { return nil, err } - req.SetBasicAuth(c.APIKey, "") + req.SetBasicAuth(c.apiKey, "") return req, nil } func (c *Client) doRequest(req *http.Request) ([]byte, error) { - res, err := c.HTTPClient.Do(req) + res, err := c.httpClient.Do(req) if err != nil { return nil, err } @@ -68,14 +108,15 @@ func (c *Client) doRequest(req *http.Request) ([]byte, error) { defer func() { _ = res.Body.Close() }() + body, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusNoContent { - return body, err + if res.StatusCode >= 200 && res.StatusCode < 300 { + return body, nil } - return nil, fmt.Errorf("%w: %s", ErrRequestFailure, body) + return nil, &APIError{StatusCode: res.StatusCode, Body: body} } diff --git a/forwardemail/client_test.go b/forwardemail/client_test.go index 0c6682f..abe96b3 100644 --- a/forwardemail/client_test.go +++ b/forwardemail/client_test.go @@ -8,59 +8,49 @@ import ( "net/http" "testing" "time" - - "github.com/google/go-cmp/cmp" ) func TestNewClient(t *testing.T) { tests := []struct { name string - options ClientOptions - want *Client + apiKey string + opts []Option + wantURL string wantError error }{ { - name: "empty options returns error", - options: ClientOptions{}, - want: nil, + name: "empty api key returns error", + apiKey: "", wantError: ErrMissingAPIKey, }, { - name: "with api key", - options: ClientOptions{ - APIKey: "4e4d6c332b6fe62a63afe56171fd3725", - }, - want: &Client{ - APIKey: "4e4d6c332b6fe62a63afe56171fd3725", - APIURL: "https://api.forwardemail.net", - HTTPClient: &http.Client{Timeout: 30 * time.Second}, - }, + name: "with api key uses default URL", + apiKey: "4e4d6c332b6fe62a63afe56171fd3725", + wantURL: "https://api.forwardemail.net", }, { - name: "with api url but no api key returns error", - options: ClientOptions{ - APIURL: "https://google.com", - }, - want: nil, - wantError: ErrMissingAPIKey, + name: "with custom URL", + apiKey: "4e4d6c332b6fe62a63afe56171fd3725", + opts: []Option{WithAPIURL("https://google.com")}, + wantURL: "https://google.com", + }, + { + name: "nil http client returns error", + apiKey: "4e4d6c332b6fe62a63afe56171fd3725", + opts: []Option{WithHTTPClient(nil)}, + wantError: ErrNilHTTPClient, }, { - name: "with everything at once", - options: ClientOptions{ - APIKey: "4e4d6c332b6fe62a63afe56171fd3725", - APIURL: "https://google.com", - }, - want: &Client{ - APIKey: "4e4d6c332b6fe62a63afe56171fd3725", - APIURL: "https://google.com", - HTTPClient: &http.Client{Timeout: 30 * time.Second}, - }, + name: "with custom http client", + apiKey: "4e4d6c332b6fe62a63afe56171fd3725", + opts: []Option{WithHTTPClient(&http.Client{Timeout: 10 * time.Second})}, + wantURL: "https://api.forwardemail.net", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewClient(tt.options) + got, err := NewClient(tt.apiKey, tt.opts...) if tt.wantError != nil { if !errors.Is(err, tt.wantError) { t.Fatalf("expected error %v, got %v", tt.wantError, err) @@ -70,8 +60,14 @@ func TestNewClient(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if diff := cmp.Diff(tt.want, got); diff != "" { - t.Fatalf("values are not the same %s", diff) + if got.apiURL != tt.wantURL { + t.Fatalf("expected apiURL %q, got %q", tt.wantURL, got.apiURL) + } + if got.apiKey != tt.apiKey { + t.Fatalf("expected apiKey %q, got %q", tt.apiKey, got.apiKey) + } + if got.httpClient == nil { + t.Fatal("expected non-nil httpClient") } }) } diff --git a/forwardemail/domains.go b/forwardemail/domains.go index 605f666..2df5ab6 100644 --- a/forwardemail/domains.go +++ b/forwardemail/domains.go @@ -4,9 +4,9 @@ package forwardemail import ( + "context" "encoding/json" "fmt" - "io" "net/url" "strconv" "strings" @@ -46,55 +46,75 @@ type DomainParameters struct { } // GetDomains retrieves all domains associated with the authenticated account. -func (c *Client) GetDomains() ([]Domain, error) { - req, err := c.newRequest("GET", "/v1/domains") - if err != nil { +func (c *Client) GetDomains(ctx context.Context) ([]Domain, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { return nil, err } + req, err := c.newRequest(ctx, "GET", "/v1/domains", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for GetDomains: %w", err) + } + res, err := c.doRequest(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to fetch domains: %w", err) } var items []Domain - - err = json.Unmarshal(res, &items) - if err != nil { - return nil, err + if err := json.Unmarshal(res, &items); err != nil { + return nil, fmt.Errorf("failed to parse domains response: %w", err) } return items, nil } // GetDomain retrieves a specific domain by name. -func (c *Client) GetDomain(name string) (*Domain, error) { - req, err := c.newRequest("GET", fmt.Sprintf("/v1/domains/%s", name)) - if err != nil { +func (c *Client) GetDomain(ctx context.Context, name string) (*Domain, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { return nil, err } + if strings.TrimSpace(name) == "" { + return nil, ErrEmptyDomainName + } + + encodedName := url.PathEscape(name) + + req, err := c.newRequest(ctx, "GET", fmt.Sprintf("/v1/domains/%s", encodedName), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for GetDomain: %w", err) + } res, err := c.doRequest(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to fetch domain: %w", err) } var item Domain - - err = json.Unmarshal(res, &item) - if err != nil { - return nil, err + if err := json.Unmarshal(res, &item); err != nil { + return nil, fmt.Errorf("failed to parse domain response: %w", err) } return &item, nil } // CreateDomain adds a new domain to the account with the specified configuration parameters. -func (c *Client) CreateDomain(name string, parameters DomainParameters) (*Domain, error) { - req, err := c.newRequest("POST", "/v1/domains") - if err != nil { +func (c *Client) CreateDomain(ctx context.Context, name string, parameters DomainParameters) (*Domain, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { return nil, err } + if strings.TrimSpace(name) == "" { + return nil, ErrEmptyDomainName + } params := url.Values{} params.Add("domain", name) @@ -111,30 +131,39 @@ func (c *Client) CreateDomain(name string, parameters DomainParameters) (*Domain } } - req.Body = io.NopCloser(strings.NewReader(params.Encode())) + req, err := c.newRequest(ctx, "POST", "/v1/domains", strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request for CreateDomain: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := c.doRequest(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create domain: %w", err) } var item Domain - - err = json.Unmarshal(res, &item) - if err != nil { - return nil, err + if err := json.Unmarshal(res, &item); err != nil { + return nil, fmt.Errorf("failed to parse create domain response: %w", err) } return &item, nil } // UpdateDomain modifies an existing domain's configuration parameters. -func (c *Client) UpdateDomain(name string, parameters DomainParameters) (*Domain, error) { - req, err := c.newRequest("PUT", fmt.Sprintf("/v1/domains/%s", name)) - if err != nil { +func (c *Client) UpdateDomain(ctx context.Context, name string, parameters DomainParameters) (*Domain, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { return nil, err } + if strings.TrimSpace(name) == "" { + return nil, ErrEmptyDomainName + } + + encodedName := url.PathEscape(name) params := url.Values{} params.Add("domain", name) @@ -151,34 +180,48 @@ func (c *Client) UpdateDomain(name string, parameters DomainParameters) (*Domain } } - req.Body = io.NopCloser(strings.NewReader(params.Encode())) + req, err := c.newRequest(ctx, "PUT", fmt.Sprintf("/v1/domains/%s", encodedName), strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request for UpdateDomain: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := c.doRequest(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to update domain: %w", err) } var item Domain - - err = json.Unmarshal(res, &item) - if err != nil { - return nil, err + if err := json.Unmarshal(res, &item); err != nil { + return nil, fmt.Errorf("failed to parse update domain response: %w", err) } return &item, nil } // DeleteDomain removes a domain from the account. -func (c *Client) DeleteDomain(name string) error { - req, err := c.newRequest("DELETE", fmt.Sprintf("/v1/domains/%s", name)) - if err != nil { +func (c *Client) DeleteDomain(ctx context.Context, name string) error { + if ctx == nil { + return ErrNilContext + } + if err := ctx.Err(); err != nil { return err } + if strings.TrimSpace(name) == "" { + return ErrEmptyDomainName + } + + encodedName := url.PathEscape(name) + + req, err := c.newRequest(ctx, "DELETE", fmt.Sprintf("/v1/domains/%s", encodedName), nil) + if err != nil { + return fmt.Errorf("failed to create request for DeleteDomain: %w", err) + } _, err = c.doRequest(req) if err != nil { - return err + return fmt.Errorf("failed to delete domain: %w", err) } return nil diff --git a/forwardemail/domains_test.go b/forwardemail/domains_test.go index f725d36..fc7d6c5 100644 --- a/forwardemail/domains_test.go +++ b/forwardemail/domains_test.go @@ -4,6 +4,7 @@ package forwardemail import ( + "context" "errors" "fmt" "net/http" @@ -21,7 +22,8 @@ func TestClient_GetDomain(t *testing.T) { want *Domain }{ { - name: "no data", + name: "no data", + domain: "stark.com", }, { name: "ok", @@ -76,12 +78,9 @@ func TestClient_GetDomain(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.GetDomain(tt.domain) + got, _ := c.GetDomain(context.Background(), tt.domain) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } @@ -194,12 +193,9 @@ func TestClient_GetDomains(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.GetDomains() + got, _ := c.GetDomains(context.Background()) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } @@ -216,7 +212,8 @@ func TestClient_CreateDomain(t *testing.T) { want *Domain }{ { - name: "no data", + name: "no data", + domain: "stark.com", }, { name: "ok", @@ -278,12 +275,9 @@ func TestClient_CreateDomain(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.CreateDomain(tt.domain, tt.parameters) + got, _ := c.CreateDomain(context.Background(), tt.domain, tt.parameters) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } @@ -300,7 +294,8 @@ func TestClient_UpdateDomain(t *testing.T) { want *Domain }{ { - name: "no data", + name: "no data", + domain: "stark.com", }, { name: "ok", @@ -362,12 +357,9 @@ func TestClient_UpdateDomain(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.UpdateDomain(tt.domain, tt.parameters) + got, _ := c.UpdateDomain(context.Background(), tt.domain, tt.parameters) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } @@ -388,13 +380,15 @@ func TestClient_DeleteDomain(t *testing.T) { wantError bool }{ { - name: "ok", + name: "ok", + domain: "stark.com", resp: response{ code: http.StatusNoContent, }, }, { - name: "not ok", + name: "not ok", + domain: "stark.com", resp: response{ code: http.StatusInternalServerError, body: "oh no", @@ -411,18 +405,16 @@ func TestClient_DeleteDomain(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got := c.DeleteDomain(tt.domain) + got := c.DeleteDomain(context.Background(), tt.domain) if tt.wantError { if got == nil { t.Fatal("expected error, got nil") } - if !errors.Is(got, ErrRequestFailure) { - t.Fatalf("expected error to wrap ErrRequestFailure, got %v", got) + var apiErr *APIError + if !errors.As(got, &apiErr) { + t.Fatalf("expected error to wrap *APIError, got %v", got) } } else if got != nil { t.Fatalf("expected no error, got %v", got) diff --git a/forwardemail/errors.go b/forwardemail/errors.go index 012e43e..538d12b 100644 --- a/forwardemail/errors.go +++ b/forwardemail/errors.go @@ -3,11 +3,43 @@ package forwardemail -import "errors" +import ( + "errors" + "fmt" + "net/http" +) var ( // ErrRequestFailure is returned when an API request fails. ErrRequestFailure = errors.New("failed to complete request") // ErrMissingAPIKey is returned when no API key is provided. ErrMissingAPIKey = errors.New("no API key provided") + // ErrNilContext is returned when a nil context is passed to an API method. + ErrNilContext = errors.New("context cannot be nil") + // ErrEmptyMethod is returned when an empty HTTP method is passed to newRequest. + ErrEmptyMethod = errors.New("HTTP method cannot be empty") + // ErrEmptyPath is returned when an empty request path is passed to newRequest. + ErrEmptyPath = errors.New("request path cannot be empty") + // ErrNilHTTPClient is returned when the client has a nil HTTPClient. + ErrNilHTTPClient = errors.New("HTTP client cannot be nil") + // ErrEmptyDomain is returned when a domain parameter is empty. + ErrEmptyDomain = errors.New("domain cannot be empty") + // ErrEmptyAlias is returned when an alias parameter is empty. + ErrEmptyAlias = errors.New("alias cannot be empty") + // ErrEmptyDomainName is returned when a domain name parameter is empty. + ErrEmptyDomainName = errors.New("domain name cannot be empty") + // ErrEmptyEmail is returned when an email parameter is empty. + ErrEmptyEmail = errors.New("email cannot be empty") + // ErrEmptyGroup is returned when a group parameter is empty. + ErrEmptyGroup = errors.New("group cannot be empty") ) + +// APIError represents an error response from the Forward Email API. +type APIError struct { + StatusCode int + Body []byte +} + +func (e *APIError) Error() string { + return fmt.Sprintf("forwardemail: API error %d: %s", e.StatusCode, http.StatusText(e.StatusCode)) +} diff --git a/forwardemail/invites.go b/forwardemail/invites.go index 97af5d0..f52ed75 100644 --- a/forwardemail/invites.go +++ b/forwardemail/invites.go @@ -4,11 +4,11 @@ package forwardemail import ( - "bytes" + "context" "encoding/json" "fmt" - "io" "net/url" + "strings" "time" ) @@ -23,28 +23,44 @@ type Invite struct { } // CreateDomainInvite sends an invitation to a user to join a domain with the specified group permissions. -func (c *Client) CreateDomainInvite(domain, email, group string) (*Invite, error) { - req, err := c.newRequest("POST", fmt.Sprintf("/v1/domains/%s/invites", domain)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) +func (c *Client) CreateDomainInvite(ctx context.Context, domain, email, group string) (*Invite, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { + return nil, err + } + if strings.TrimSpace(domain) == "" { + return nil, ErrEmptyDomain + } + if strings.TrimSpace(email) == "" { + return nil, ErrEmptyEmail + } + if strings.TrimSpace(group) == "" { + return nil, ErrEmptyGroup } + encodedDomain := url.PathEscape(domain) + params := url.Values{} params.Add("email", email) params.Add("group", group) - req.Body = io.NopCloser(bytes.NewBufferString(params.Encode())) + req, err := c.newRequest(ctx, "POST", fmt.Sprintf("/v1/domains/%s/invites", encodedDomain), strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request for CreateDomainInvite: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res, err := c.doRequest(req) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return nil, fmt.Errorf("failed to create domain invite: %w", err) } var item Invite - err = json.Unmarshal(res, &item) - if err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + if err := json.Unmarshal(res, &item); err != nil { + return nil, fmt.Errorf("failed to parse domain invite response: %w", err) } return &item, nil diff --git a/forwardemail/invites_test.go b/forwardemail/invites_test.go index fa90d5d..799f23d 100644 --- a/forwardemail/invites_test.go +++ b/forwardemail/invites_test.go @@ -4,6 +4,7 @@ package forwardemail import ( + "context" "fmt" "net/http" "net/http/httptest" @@ -64,12 +65,9 @@ func TestClient_CreateDomainInvite(t *testing.T) { })) defer svr.Close() - c, _ := NewClient(ClientOptions{ - APIKey: "test-key", - APIURL: svr.URL, - }) + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) - got, _ := c.CreateDomainInvite(tt.domain, tt.email, tt.group) + got, _ := c.CreateDomainInvite(context.Background(), tt.domain, tt.email, tt.group) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("values are not the same %s", diff) } diff --git a/go.mod b/go.mod index 843f8f5..18fefe6 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/forwardemail/forwardemail-api-go go 1.25.6 -require github.com/google/go-cmp v0.6.0 +require github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 5a8d551..40e761a 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/tools/tools.go b/tools/tools.go index b87aa4c..d0b8660 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -4,7 +4,13 @@ package tools import ( _ "github.com/hashicorp/copywrite" + _ "mvdan.cc/gofumpt" + _ "golang.org/x/tools/cmd/goimports" + _ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" ) // Generate copyright headers //go:generate go run github.com/hashicorp/copywrite headers -d .. --config ../.copywrite.hcl + +// Run linters +//go:generate go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint run --config ../.golangci.yml ../...