diff --git a/cmd/root.go b/cmd/root.go index fd9ce77..fa458c8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,8 +19,14 @@ var teamFlag string var debugFlag bool func newAPIClient(cfg *config.Config) *api.Client { - return api.NewClient(cfg.EndpointURL, cfg.APIKey, cfg.Debug). - WithUserAgent("loops-cli/" + version) + opts := []api.Option{ + api.WithBaseURL(cfg.EndpointURL), + api.WithUserAgent("loops-cli/" + version), + } + if cfg.Debug { + opts = append(opts, api.WithLogger(os.Stderr)) + } + return api.NewClient(cfg.APIKey, opts...) } func loadConfig() (*config.Config, error) { diff --git a/internal/api/api_key_test.go b/internal/api/api_key_test.go index 44dacac..5696662 100644 --- a/internal/api/api_key_test.go +++ b/internal/api/api_key_test.go @@ -51,7 +51,7 @@ func TestGetAPIKey(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) result, err := client.GetAPIKey() if tt.wantAPIErr != nil { diff --git a/internal/api/campaigns_test.go b/internal/api/campaigns_test.go index 17612ec..2a4a356 100644 --- a/internal/api/campaigns_test.go +++ b/internal/api/campaigns_test.go @@ -88,7 +88,7 @@ func TestCreateCampaign(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) resp, err := client.CreateCampaign(CreateCampaignRequest{Name: "Spring Launch"}) if tt.wantAPIErr != nil { @@ -141,7 +141,7 @@ func TestCreateCampaign_RequestBody(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) if _, err := client.CreateCampaign(CreateCampaignRequest{Name: "Spring"}); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -205,7 +205,7 @@ func TestUpdateCampaign(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) result, err := client.UpdateCampaign("cmp_abc123", UpdateCampaignRequest{Name: "Renamed"}) if tt.wantAPIErr != nil { @@ -261,7 +261,7 @@ func TestUpdateCampaign_RequestBodyAndPath(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) if _, err := client.UpdateCampaign("cmp_abc123", UpdateCampaignRequest{Name: "Renamed"}); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -337,7 +337,7 @@ func TestGetCampaign(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) result, err := client.GetCampaign(tt.id) if tt.wantAPIErr != nil { @@ -429,7 +429,7 @@ func TestListCampaigns(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) campaigns, pagination, err := client.ListCampaigns(PaginationParams{}) if tt.wantAPIErr != nil { @@ -476,7 +476,7 @@ func TestListCampaigns_ResponseData(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) campaigns, _, err := client.ListCampaigns(PaginationParams{}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -526,7 +526,7 @@ func TestListCampaigns_QueryParams(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) client.ListCampaigns(tt.params) if gotPerPage != tt.wantPerPage { diff --git a/internal/api/client.go b/internal/api/client.go index 8411e6b..90b3a5d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -7,13 +7,13 @@ import ( "io" "math/rand/v2" "net/http" - "os" "time" ) const ( - maxRetries = 2 - baseDelay = 500 * time.Millisecond + DefaultBaseURL = "https://app.loops.so/api/v1" + maxRetries = 2 + baseDelay = 500 * time.Millisecond ) var sleep = time.Sleep @@ -35,22 +35,27 @@ type Client struct { baseURL string apiKey string httpClient *http.Client - debug bool + logger io.Writer userAgent string } -func NewClient(baseURL, apiKey string, debug bool) *Client { - return &Client{ - baseURL: baseURL, +type Option func(*Client) + +func WithBaseURL(u string) Option { return func(c *Client) { c.baseURL = u } } +func WithUserAgent(ua string) Option { return func(c *Client) { c.userAgent = ua } } +func WithLogger(w io.Writer) Option { return func(c *Client) { c.logger = w } } +func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.httpClient = h } } + +func NewClient(apiKey string, opts ...Option) *Client { + c := &Client{ + baseURL: DefaultBaseURL, apiKey: apiKey, httpClient: &http.Client{Timeout: 5 * time.Second}, - debug: debug, userAgent: "loops-go/dev", } -} - -func (c *Client) WithUserAgent(ua string) *Client { - c.userAgent = ua + for _, opt := range opts { + opt(c) + } return c } @@ -73,20 +78,20 @@ func errorFromResponse(resp *http.Response) *APIError { func (c *Client) logResponse(resp *http.Response) { raw, err := io.ReadAll(resp.Body) if err != nil { - fmt.Fprintf(os.Stderr, "[debug] Response: %s (body read failed: %v)\n", resp.Status, err) + fmt.Fprintf(c.logger, "[debug] Response: %s (body read failed: %v)\n", resp.Status, err) resp.Body = io.NopCloser(bytes.NewReader(nil)) return } resp.Body = io.NopCloser(bytes.NewReader(raw)) - fmt.Fprintf(os.Stderr, "[debug] Response: %s (%d bytes)\n", resp.Status, len(raw)) + fmt.Fprintf(c.logger, "[debug] Response: %s (%d bytes)\n", resp.Status, len(raw)) if len(raw) == 0 { return } var pretty bytes.Buffer if json.Indent(&pretty, raw, "", " ") == nil { - fmt.Fprintf(os.Stderr, "[debug] Body:\n%s\n", pretty.String()) + fmt.Fprintf(c.logger, "[debug] Body:\n%s\n", pretty.String()) } else { - fmt.Fprintf(os.Stderr, "[debug] Body: %s\n", raw) + fmt.Fprintf(c.logger, "[debug] Body: %s\n", raw) } } @@ -116,7 +121,7 @@ func (c *Client) do(req *http.Request) (*http.Response, error) { continue } if !isRetryable(resp.StatusCode) { - if c.debug { + if c.logger != nil { c.logResponse(resp) } return resp, nil @@ -135,7 +140,7 @@ func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, url := fmt.Sprintf("%s%s", c.baseURL, path) var bodyBytes []byte - if body != nil && c.debug { + if body != nil && c.logger != nil { var err error bodyBytes, err = io.ReadAll(body) if err != nil { @@ -154,18 +159,18 @@ func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, req.Header.Set("Content-Type", "application/json") } - if c.debug { - fmt.Fprintf(os.Stderr, "[debug] %s %s\n", method, url) - fmt.Fprintf(os.Stderr, "[debug] Authorization: Bearer [REDACTED]\n") + if c.logger != nil { + fmt.Fprintf(c.logger, "[debug] %s %s\n", method, url) + fmt.Fprintf(c.logger, "[debug] Authorization: Bearer [REDACTED]\n") if req.Header.Get("Content-Type") != "" { - fmt.Fprintf(os.Stderr, "[debug] Content-Type: %s\n", req.Header.Get("Content-Type")) + fmt.Fprintf(c.logger, "[debug] Content-Type: %s\n", req.Header.Get("Content-Type")) } if len(bodyBytes) > 0 { var pretty bytes.Buffer if json.Indent(&pretty, bodyBytes, "", " ") == nil { - fmt.Fprintf(os.Stderr, "[debug] Body:\n%s\n", pretty.String()) + fmt.Fprintf(c.logger, "[debug] Body:\n%s\n", pretty.String()) } else { - fmt.Fprintf(os.Stderr, "[debug] Body: %s\n", bodyBytes) + fmt.Fprintf(c.logger, "[debug] Body: %s\n", bodyBytes) } } } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 9ec52e9..bd39be0 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -29,7 +29,7 @@ func TestDo_RetryResetsBody(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) req, _ := client.newRequest(http.MethodPost, "/", bytes.NewReader([]byte(`{"hello":"world"}`))) resp, err := client.do(req) if err != nil { @@ -48,7 +48,7 @@ func TestDo_RetryResetsBody(t *testing.T) { } func TestNewRequest(t *testing.T) { - client := NewClient("https://example.com/api/v1", "test-key", false) + client := NewClient("test-key", WithBaseURL("https://example.com/api/v1")) tests := []struct { name string @@ -88,7 +88,7 @@ func TestNewRequest(t *testing.T) { } func TestWithUserAgent(t *testing.T) { - client := NewClient("https://example.com/api/v1", "test-key", false).WithUserAgent("loops-cli/1.2.3") + client := NewClient("test-key", WithBaseURL("https://example.com/api/v1"), WithUserAgent("loops-cli/1.2.3")) req, err := client.newRequest(http.MethodGet, "/api-key", nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -99,7 +99,7 @@ func TestWithUserAgent(t *testing.T) { } func TestNewRequest_InvalidURL(t *testing.T) { - client := NewClient("://bad-url", "test-key", false) + client := NewClient("test-key", WithBaseURL("://bad-url")) _, err := client.newRequest(http.MethodGet, "/path", nil) if err == nil { t.Error("expected error for invalid URL, got nil") @@ -221,7 +221,7 @@ func TestDo_Retries(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) req, _ := client.newRequest(http.MethodGet, "/", nil) resp, err := client.do(req) if err != nil { diff --git a/internal/api/contact_properties_test.go b/internal/api/contact_properties_test.go index 15cf8d1..848b547 100644 --- a/internal/api/contact_properties_test.go +++ b/internal/api/contact_properties_test.go @@ -68,7 +68,7 @@ func TestListContactProperties(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) props, err := client.ListContactProperties(tt.customOnly) if tt.wantQuery != "" && gotQuery != tt.wantQuery { @@ -147,7 +147,7 @@ func TestCreateContactProperty(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) err := client.CreateContactProperty("age", "number") if tt.wantBody != nil { @@ -186,7 +186,7 @@ func TestListContactProperties_ResponseData(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) props, err := client.ListContactProperties(false) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/api/contacts_test.go b/internal/api/contacts_test.go index d329097..a63b464 100644 --- a/internal/api/contacts_test.go +++ b/internal/api/contacts_test.go @@ -115,7 +115,7 @@ func TestCreateContact(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) id, err := client.CreateContact(tt.req) if tt.wantBody != nil { @@ -210,7 +210,7 @@ func TestDeleteContact(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) err := client.DeleteContact(tt.email, tt.userID) if tt.wantBody != nil { @@ -323,7 +323,7 @@ func TestUpdateContact(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) err := client.UpdateContact(tt.req) if tt.wantBody != nil { @@ -433,7 +433,7 @@ func TestCheckContactSuppression(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) result, err := client.CheckContactSuppression(tt.email, tt.userID) if tt.wantQuery != "" && gotQuery != tt.wantQuery { @@ -549,7 +549,7 @@ func TestRemoveContactSuppression(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) result, err := client.RemoveContactSuppression(tt.email, tt.userID) if tt.wantQuery != "" && gotQuery != tt.wantQuery { @@ -650,7 +650,7 @@ func TestFindContacts(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) contacts, err := client.FindContacts(tt.params) if tt.wantQuery != "" && gotQuery != tt.wantQuery { diff --git a/internal/api/email_messages_test.go b/internal/api/email_messages_test.go index 7c736ea..3071c4e 100644 --- a/internal/api/email_messages_test.go +++ b/internal/api/email_messages_test.go @@ -74,7 +74,7 @@ func TestGetEmailMessage(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) result, err := client.GetEmailMessage(tt.id) if tt.wantAPIErr != nil { @@ -186,7 +186,7 @@ func TestUpdateEmailMessage(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) req := UpdateEmailMessageRequest{ EmailMessageFields: EmailMessageFields{Subject: "Updated"}, Set: map[string]bool{"subject": true}, @@ -296,7 +296,7 @@ func TestUpdateEmailMessage_RequestBody(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) if _, err := client.UpdateEmailMessage("em_abc123", tt.req); err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/api/events_test.go b/internal/api/events_test.go index 882591c..1a426e8 100644 --- a/internal/api/events_test.go +++ b/internal/api/events_test.go @@ -49,7 +49,7 @@ func TestSendEvent(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) err := client.SendEvent(SendEventRequest{ Email: "test@example.com", EventName: "signup", @@ -176,7 +176,7 @@ func TestSendEvent_RequestBody(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) client.SendEvent(tt.req) for key, want := range tt.wantPresent { @@ -258,7 +258,7 @@ func TestSendEvent_IdempotencyKey(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) client.SendEvent(SendEventRequest{ Email: "a@b.com", EventName: "click", diff --git a/internal/api/lists_test.go b/internal/api/lists_test.go index dbea837..74a56ec 100644 --- a/internal/api/lists_test.go +++ b/internal/api/lists_test.go @@ -51,7 +51,7 @@ func TestListMailingLists(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) lists, err := client.ListMailingLists() if tt.wantAPIErr != nil { @@ -95,7 +95,7 @@ func TestListMailingLists_ResponseData(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) lists, err := client.ListMailingLists() if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/api/transactional_test.go b/internal/api/transactional_test.go index 844922c..566ba7c 100644 --- a/internal/api/transactional_test.go +++ b/internal/api/transactional_test.go @@ -84,7 +84,7 @@ func TestListTransactional(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) emails, pagination, err := client.ListTransactional(PaginationParams{}) if tt.wantAPIErr != nil { @@ -131,7 +131,7 @@ func TestListTransactional_ResponseData(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) emails, pagination, err := client.ListTransactional(PaginationParams{}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -194,7 +194,7 @@ func TestListTransactional_QueryParams(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) client.ListTransactional(tt.params) if gotPerPage != tt.wantPerPage { @@ -248,7 +248,7 @@ func TestSendTransactional(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) err := client.SendTransactional(SendTransactionalRequest{ Email: "test@example.com", TransactionalID: "abc123", @@ -316,7 +316,7 @@ func TestSendTransactional_IdempotencyKey(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) client.SendTransactional(SendTransactionalRequest{ Email: "a@b.com", TransactionalID: "abc", @@ -376,7 +376,7 @@ func TestSendTransactional_RequestBody(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, "test-key", false) + client := NewClient("test-key", WithBaseURL(server.URL)) client.SendTransactional(tt.req) if got.Email != tt.wantEmail {