Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,16 @@ client := loops.NewClient("YOUR_API_KEY",
- Contacts — `CreateContact`, `UpdateContact`, `DeleteContact`, `FindContacts`, `CheckContactSuppression`, `RemoveContactSuppression`
- Contact properties — `ListContactProperties`, `CreateContactProperty`
- Mailing lists — `ListMailingLists`
- Audience segments — `GetAudienceSegment`, `ListAudienceSegments`
- Audience segments — `GetAudienceSegment`, `ListAudienceSegments`, `CreateAudienceSegment`
- Events — `SendEvent`
- Event patterns — `ListEventPatterns`, `GetEventPatternByName`, `GetEventPattern`
- Transactional — `SendTransactional`, `ListTransactionals`, `CreateTransactional`, `GetTransactional`, `UpdateTransactional`, `EnsureTransactionalDraft`, `PublishTransactional`
- Transactional groups — `CreateTransactionalGroup`, `GetTransactionalGroup`, `UpdateTransactionalGroup`, `ListTransactionalGroups`
- Email messages — `GetEmailMessage`, `UpdateEmailMessage`, `PreviewEmailMessage`
- Email messages — `GetEmailMessage`, `UpdateEmailMessage`, `PreviewEmailMessage`, `GetEmailMessageGuardian`
- Campaigns — `CreateCampaign`, `UpdateCampaign`, `GetCampaign`, `ListCampaigns`
- Campaign groups — `CreateCampaignGroup`, `GetCampaignGroup`, `UpdateCampaignGroup`, `ListCampaignGroups`
- Components — `GetComponent`, `ListComponents`
- Themes — `GetTheme`, `ListThemes`
- Components — `GetComponent`, `ListComponents`, `CreateComponent`, `UpdateComponent`
- Themes — `GetTheme`, `ListThemes`, `CreateTheme`, `UpdateTheme`
- Uploads — `Upload`, `CreateUpload`, `CompleteUpload`
- Workflows — `ListWorkflows`, `GetWorkflow`, `GetWorkflowNode`

Expand Down
1 change: 1 addition & 0 deletions api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
// APIKeyResponse is returned by [Client.GetAPIKey] and identifies the team
// the API key belongs to.
type APIKeyResponse struct {
Success bool `json:"success"`
TeamName string `json:"teamName"`
}

Expand Down
13 changes: 9 additions & 4 deletions api_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ func TestGetAPIKey(t *testing.T) {
wantAPIErr *APIError
wantErrMsg string
wantTeam string
wantSuccess bool
}{
{
name: "success",
statusCode: http.StatusOK,
body: `{"teamName":"Acme"}`,
wantTeam: "Acme",
name: "success",
statusCode: http.StatusOK,
body: `{"success":true,"teamName":"Acme"}`,
wantTeam: "Acme",
wantSuccess: true,
},
{
name: "unauthorized",
Expand Down Expand Up @@ -84,6 +86,9 @@ func TestGetAPIKey(t *testing.T) {
if result.TeamName != tt.wantTeam {
t.Errorf("TeamName = %q, want %q", result.TeamName, tt.wantTeam)
}
if result.Success != tt.wantSuccess {
t.Errorf("Success = %v, want %v", result.Success, tt.wantSuccess)
}
})
}
}
40 changes: 40 additions & 0 deletions audience_segments.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package loops

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
Expand Down Expand Up @@ -215,6 +216,45 @@ func (c *Client) GetAudienceSegment(id string) (*AudienceSegment, error) {
return &result, nil
}

// CreateAudienceSegmentRequest is the body for [Client.CreateAudienceSegment].
// Name must be unique within the team; Filter must have at least one
// condition.
type CreateAudienceSegmentRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Filter AudienceFilter `json:"filter"`
}

// CreateAudienceSegment creates a new audience segment and returns it.
func (c *Client) CreateAudienceSegment(req CreateAudienceSegmentRequest) (*AudienceSegment, error) {
b, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}

httpReq, err := c.newRequest(http.MethodPost, "/audience-segments", bytes.NewReader(b))
if err != nil {
return nil, err
}

resp, err := c.do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, errorFromResponse(resp)
}

var result AudienceSegment
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &result, nil
}

// ListAudienceSegments returns a single page of audience segments along with
// pagination information. To iterate every page, use [Paginate].
func (c *Client) ListAudienceSegments(params PaginationParams) ([]AudienceSegment, *Pagination, error) {
Expand Down
141 changes: 141 additions & 0 deletions audience_segments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package loops
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -194,6 +195,146 @@ func TestGetAudienceSegment(t *testing.T) {
}
}

const createAudienceSegmentResponse = `{
"id": "seg_new",
"name": "Pro plan",
"description": "Contacts on the pro plan",
"createdAt": "2026-06-01T10:00:00Z",
"updatedAt": "2026-06-01T10:00:00Z",
"filter": {
"match": "all",
"conditions": [
{
"type": "property",
"key": "plan",
"operator": "equals",
"value": "pro"
}
]
}
}`

func TestCreateAudienceSegment(t *testing.T) {
tests := []struct {
name string
req CreateAudienceSegmentRequest
statusCode int
body string
wantAPIErr *APIError
wantErrMsg string
wantBody string
wantID string
}{
{
name: "success",
req: CreateAudienceSegmentRequest{
Name: "Pro plan",
Description: "Contacts on the pro plan",
Filter: AudienceFilter{
Match: "all",
Conditions: []AudienceFilterCondition{
{
Type: AudienceConditionTypeProperty,
Property: &PropertyCondition{
Key: "plan",
Operator: "equals",
Value: &PropertyConditionValue{String: ptr("pro")},
},
},
},
},
},
statusCode: http.StatusOK,
body: createAudienceSegmentResponse,
wantBody: `{"name":"Pro plan","description":"Contacts on the pro plan","filter":{"match":"all","conditions":[{"key":"plan","operator":"equals","type":"property","value":"pro"}]}}`,
wantID: "seg_new",
},
{
name: "bad request",
req: CreateAudienceSegmentRequest{
Name: "Pro plan",
Filter: AudienceFilter{
Match: "all",
Conditions: []AudienceFilterCondition{
{
Type: AudienceConditionTypeProperty,
Property: &PropertyCondition{Key: "plan", Operator: "equals", Value: &PropertyConditionValue{String: ptr("pro")}},
},
},
},
},
statusCode: http.StatusBadRequest,
body: `{"message":"A segment with this name already exists"}`,
wantAPIErr: &APIError{StatusCode: http.StatusBadRequest, Message: "A segment with this name already exists"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotMethod, gotPath, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
buf := new(strings.Builder)
io.Copy(buf, r.Body)
gotBody = buf.String()
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.body))
}))
defer server.Close()

client := NewClient("test-key", WithBaseURL(server.URL))
result, err := client.CreateAudienceSegment(tt.req)

if tt.wantAPIErr != nil {
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.StatusCode != tt.wantAPIErr.StatusCode {
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode)
}
if apiErr.Message != tt.wantAPIErr.Message {
t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message)
}
return
}

if tt.wantErrMsg != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg)
}
if !strings.Contains(err.Error(), tt.wantErrMsg) {
t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg)
}
return
}

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %q, want POST", gotMethod)
}
if gotPath != "/audience-segments" {
t.Errorf("path = %q, want /audience-segments", gotPath)
}
if gotBody != tt.wantBody {
t.Errorf("body = %s, want %s", gotBody, tt.wantBody)
}
if result.ID != tt.wantID {
t.Errorf("ID = %q, want %q", result.ID, tt.wantID)
}
if result.Filter == nil {
t.Fatal("Filter is nil, want non-nil")
}
if result.Filter.Match != "all" {
t.Errorf("Filter.Match = %q, want all", result.Filter.Match)
}
})
}
}

func TestListAudienceSegments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
Expand Down
84 changes: 84 additions & 0 deletions components.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package loops

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
Expand All @@ -15,6 +16,89 @@ type Component struct {
LMX string `json:"lmx"`
}

// CreateComponentRequest is the request body for [Client.CreateComponent].
// Both fields are required.
type CreateComponentRequest struct {
Name string `json:"name"`
LMX string `json:"lmx"`
}

// UpdateComponentRequest is the request body for [Client.UpdateComponent].
// At least one field must be set.
type UpdateComponentRequest struct {
Name string `json:"name,omitempty"`
LMX string `json:"lmx,omitempty"`
}

// UpdateComponentResult is the result of [Client.UpdateComponent]. It embeds
// the updated [Component] and adds AffectedEmailCount, the number of emails
// using this component that were updated by the body change (0 when only the
// name changed).
type UpdateComponentResult struct {
Component
AffectedEmailCount int `json:"affectedEmailCount"`
}

// CreateComponent creates a new component.
func (c *Client) CreateComponent(req CreateComponentRequest) (*Component, error) {
b, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}

httpReq, err := c.newRequest(http.MethodPost, "/components", bytes.NewReader(b))
if err != nil {
return nil, err
}

resp, err := c.do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, errorFromResponse(resp)
}

var result Component
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &result, nil
}

// UpdateComponent updates the component identified by id.
func (c *Client) UpdateComponent(id string, req UpdateComponentRequest) (*UpdateComponentResult, error) {
b, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}

httpReq, err := c.newRequest(http.MethodPost, "/components/"+id, bytes.NewReader(b))
if err != nil {
return nil, err
}

resp, err := c.do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, errorFromResponse(resp)
}

var result UpdateComponentResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &result, nil
}

// GetComponent returns the component identified by id.
func (c *Client) GetComponent(id string) (*Component, error) {
req, err := c.newRequest(http.MethodGet, "/components/"+id, nil)
Expand Down
Loading