Skip to content

Commit 833d91f

Browse files
authored
feat: openapi spec v1.22.1 (#23)
1 parent 69ef47d commit 833d91f

8 files changed

Lines changed: 428 additions & 4 deletions

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ client := loops.NewClient("YOUR_API_KEY",
6060
- Audience segments — `GetAudienceSegment`, `ListAudienceSegments`, `CreateAudienceSegment`
6161
- Events — `SendEvent`
6262
- Event patterns — `ListEventPatterns`, `GetEventPatternByName`, `GetEventPattern`
63-
- Transactional — `SendTransactional`, `ListTransactionals`, `CreateTransactional`, `GetTransactional`, `UpdateTransactional`, `EnsureTransactionalDraft`, `PublishTransactional`
63+
- Transactional — `SendTransactional`, `ListTransactionals`, `CreateTransactional`, `GetTransactional`, `UpdateTransactional`, `EnsureTransactionalDraft`, `PublishTransactional`, `GetTransactionalMetrics`
6464
- Transactional groups — `CreateTransactionalGroup`, `GetTransactionalGroup`, `UpdateTransactionalGroup`, `ListTransactionalGroups`
6565
- Email messages — `GetEmailMessage`, `UpdateEmailMessage`, `PreviewEmailMessage`, `GetEmailMessageGuardian`
66-
- Campaigns — `CreateCampaign`, `UpdateCampaign`, `GetCampaign`, `ListCampaigns`
66+
- Campaigns — `CreateCampaign`, `UpdateCampaign`, `GetCampaign`, `ListCampaigns`, `GetCampaignMetrics`
6767
- Campaign groups — `CreateCampaignGroup`, `GetCampaignGroup`, `UpdateCampaignGroup`, `ListCampaignGroups`
6868
- Components — `GetComponent`, `ListComponents`, `CreateComponent`, `UpdateComponent`
6969
- Themes — `GetTheme`, `ListThemes`, `CreateTheme`, `UpdateTheme`
7070
- Uploads — `Upload`, `CreateUpload`, `CompleteUpload`
71-
- Workflows — `ListWorkflows`, `GetWorkflow`, `GetWorkflowNode`, `CreateWorkflow`, `UpdateWorkflow`, `DeleteWorkflow`, `ChangeWorkflowMailingList`, `CreateWorkflowNode`, `UpdateWorkflowNode`, `AddWorkflowBranch`, `RerouteNodeConnection`, `DeleteWorkflowNode`, `DeleteWorkflowNodeRecursive`
71+
- Workflows — `ListWorkflows`, `GetWorkflow`, `GetWorkflowNode`, `CreateWorkflow`, `UpdateWorkflow`, `DeleteWorkflow`, `ChangeWorkflowMailingList`, `CreateWorkflowNode`, `UpdateWorkflowNode`, `AddWorkflowBranch`, `RerouteNodeConnection`, `DeleteWorkflowNode`, `DeleteWorkflowNodeRecursive`, `GetWorkflowNodeMetrics`
7272

7373
Full reference: [pkg.go.dev/github.com/loops-so/loops-go](https://pkg.go.dev/github.com/loops-so/loops-go).
7474

‎campaigns.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,44 @@ func (c *Client) ListCampaigns(params PaginationParams) ([]Campaign, *Pagination
276276

277277
return result.Data, &result.Pagination, nil
278278
}
279+
280+
// EmailMetrics holds all-time email engagement counters, as returned by
281+
// [Client.GetCampaignMetrics] and [Client.GetWorkflowNodeMetrics]. Opens and
282+
// Clicks count sends, not events: a send opened five times counts once. A
283+
// recipient who received the email twice is counted once per send.
284+
type EmailMetrics struct {
285+
Sends int `json:"sends"`
286+
Opens int `json:"opens"`
287+
Clicks int `json:"clicks"`
288+
Unsubscribes int `json:"unsubscribes"`
289+
SpamReports int `json:"spamReports"`
290+
HardBounces int `json:"hardBounces"`
291+
SoftBounces int `json:"softBounces"`
292+
}
293+
294+
// GetCampaignMetrics returns all-time email engagement metrics for the
295+
// campaign identified by id. The server responds with a 400 error when the
296+
// campaign has not been sent yet or has no email message.
297+
func (c *Client) GetCampaignMetrics(id string) (*EmailMetrics, error) {
298+
req, err := c.newRequest(http.MethodGet, "/campaigns/"+id+"/metrics", nil)
299+
if err != nil {
300+
return nil, err
301+
}
302+
303+
resp, err := c.do(req)
304+
if err != nil {
305+
return nil, err
306+
}
307+
defer resp.Body.Close()
308+
309+
if resp.StatusCode != http.StatusOK {
310+
return nil, errorFromResponse(resp)
311+
}
312+
313+
var result EmailMetrics
314+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
315+
return nil, fmt.Errorf("failed to decode response: %w", err)
316+
}
317+
318+
return &result, nil
319+
}

‎campaigns_test.go‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,3 +682,105 @@ func TestUpdateCampaign_NullableFields(t *testing.T) {
682682
t.Errorf("name should not be sent, got %v", body["name"])
683683
}
684684
}
685+
686+
func TestGetCampaignMetrics(t *testing.T) {
687+
want := EmailMetrics{
688+
Sends: 4210,
689+
Opens: 1922,
690+
Clicks: 301,
691+
Unsubscribes: 12,
692+
SpamReports: 1,
693+
HardBounces: 9,
694+
SoftBounces: 13,
695+
}
696+
697+
tests := []struct {
698+
name string
699+
id string
700+
statusCode int
701+
body string
702+
wantAPIErr *APIError
703+
wantErrMsg string
704+
}{
705+
{
706+
name: "success",
707+
id: "cmp_abc123",
708+
statusCode: http.StatusOK,
709+
body: `{"sends":4210,"opens":1922,"clicks":301,"unsubscribes":12,"spamReports":1,"hardBounces":9,"softBounces":13}`,
710+
},
711+
{
712+
name: "not found",
713+
id: "cmp_missing",
714+
statusCode: http.StatusNotFound,
715+
body: `{"message":"Campaign not found."}`,
716+
wantAPIErr: &APIError{StatusCode: http.StatusNotFound, Message: "Campaign not found."},
717+
},
718+
{
719+
name: "invalid id",
720+
id: "bad",
721+
statusCode: http.StatusBadRequest,
722+
body: `{"message":"Invalid campaignId"}`,
723+
wantAPIErr: &APIError{StatusCode: http.StatusBadRequest, Message: "Invalid campaignId"},
724+
},
725+
{
726+
name: "invalid json",
727+
id: "cmp_abc123",
728+
statusCode: http.StatusOK,
729+
body: `not json`,
730+
wantErrMsg: "failed to decode response",
731+
},
732+
}
733+
734+
for _, tt := range tests {
735+
t.Run(tt.name, func(t *testing.T) {
736+
var gotMethod, gotPath string
737+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
738+
gotMethod = r.Method
739+
gotPath = r.URL.Path
740+
w.WriteHeader(tt.statusCode)
741+
w.Write([]byte(tt.body))
742+
}))
743+
defer server.Close()
744+
745+
client := NewClient("test-key", WithBaseURL(server.URL))
746+
result, err := client.GetCampaignMetrics(tt.id)
747+
748+
if tt.wantAPIErr != nil {
749+
var apiErr *APIError
750+
if !errors.As(err, &apiErr) {
751+
t.Fatalf("expected *APIError, got %T: %v", err, err)
752+
}
753+
if apiErr.StatusCode != tt.wantAPIErr.StatusCode {
754+
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode)
755+
}
756+
if apiErr.Message != tt.wantAPIErr.Message {
757+
t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message)
758+
}
759+
return
760+
}
761+
762+
if tt.wantErrMsg != "" {
763+
if err == nil {
764+
t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg)
765+
}
766+
if !strings.Contains(err.Error(), tt.wantErrMsg) {
767+
t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg)
768+
}
769+
return
770+
}
771+
772+
if err != nil {
773+
t.Fatalf("unexpected error: %v", err)
774+
}
775+
if gotMethod != http.MethodGet {
776+
t.Errorf("method = %q, want GET", gotMethod)
777+
}
778+
if wantPath := "/campaigns/" + tt.id + "/metrics"; gotPath != wantPath {
779+
t.Errorf("path = %q, want %q", gotPath, wantPath)
780+
}
781+
if *result != want {
782+
t.Errorf("metrics = %+v, want %+v", *result, want)
783+
}
784+
})
785+
}
786+
}

‎transactional_emails.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,40 @@ func (c *Client) ListTransactionals(params PaginationParams) ([]Transactional, *
233233

234234
return result.Data, &result.Pagination, nil
235235
}
236+
237+
// TransactionalMetrics holds all-time delivery counters for a transactional
238+
// email, as returned by [Client.GetTransactionalMetrics]. Counters are
239+
// totalled across every published version of the email.
240+
type TransactionalMetrics struct {
241+
Sends int `json:"sends"`
242+
Deliveries int `json:"deliveries"`
243+
SpamReports int `json:"spamReports"`
244+
HardBounces int `json:"hardBounces"`
245+
SoftBounces int `json:"softBounces"`
246+
}
247+
248+
// GetTransactionalMetrics returns all-time delivery metrics for the
249+
// transactional email identified by id.
250+
func (c *Client) GetTransactionalMetrics(id string) (*TransactionalMetrics, error) {
251+
req, err := c.newRequest(http.MethodGet, "/transactional-emails/"+id+"/metrics", nil)
252+
if err != nil {
253+
return nil, err
254+
}
255+
256+
resp, err := c.do(req)
257+
if err != nil {
258+
return nil, err
259+
}
260+
defer resp.Body.Close()
261+
262+
if resp.StatusCode != http.StatusOK {
263+
return nil, errorFromResponse(resp)
264+
}
265+
266+
var result TransactionalMetrics
267+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
268+
return nil, fmt.Errorf("failed to decode response: %w", err)
269+
}
270+
271+
return &result, nil
272+
}

‎transactional_emails_test.go‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,3 +715,103 @@ func TestListTransactionals_QueryParams(t *testing.T) {
715715
})
716716
}
717717
}
718+
719+
func TestGetTransactionalMetrics(t *testing.T) {
720+
want := TransactionalMetrics{
721+
Sends: 4210,
722+
Deliveries: 4175,
723+
SpamReports: 1,
724+
HardBounces: 9,
725+
SoftBounces: 13,
726+
}
727+
728+
tests := []struct {
729+
name string
730+
id string
731+
statusCode int
732+
body string
733+
wantAPIErr *APIError
734+
wantErrMsg string
735+
}{
736+
{
737+
name: "success",
738+
id: "tx_abc123",
739+
statusCode: http.StatusOK,
740+
body: `{"sends":4210,"deliveries":4175,"spamReports":1,"hardBounces":9,"softBounces":13}`,
741+
},
742+
{
743+
name: "not found",
744+
id: "tx_missing",
745+
statusCode: http.StatusNotFound,
746+
body: `{"message":"Transactional email not found."}`,
747+
wantAPIErr: &APIError{StatusCode: http.StatusNotFound, Message: "Transactional email not found."},
748+
},
749+
{
750+
name: "invalid id",
751+
id: "bad",
752+
statusCode: http.StatusBadRequest,
753+
body: `{"message":"Invalid transactionalId"}`,
754+
wantAPIErr: &APIError{StatusCode: http.StatusBadRequest, Message: "Invalid transactionalId"},
755+
},
756+
{
757+
name: "invalid json",
758+
id: "tx_abc123",
759+
statusCode: http.StatusOK,
760+
body: `not json`,
761+
wantErrMsg: "failed to decode response",
762+
},
763+
}
764+
765+
for _, tt := range tests {
766+
t.Run(tt.name, func(t *testing.T) {
767+
var gotMethod, gotPath string
768+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
769+
gotMethod = r.Method
770+
gotPath = r.URL.Path
771+
w.WriteHeader(tt.statusCode)
772+
w.Write([]byte(tt.body))
773+
}))
774+
defer server.Close()
775+
776+
client := NewClient("test-key", WithBaseURL(server.URL))
777+
result, err := client.GetTransactionalMetrics(tt.id)
778+
779+
if tt.wantAPIErr != nil {
780+
var apiErr *APIError
781+
if !errors.As(err, &apiErr) {
782+
t.Fatalf("expected *APIError, got %T: %v", err, err)
783+
}
784+
if apiErr.StatusCode != tt.wantAPIErr.StatusCode {
785+
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode)
786+
}
787+
if apiErr.Message != tt.wantAPIErr.Message {
788+
t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message)
789+
}
790+
return
791+
}
792+
793+
if tt.wantErrMsg != "" {
794+
if err == nil {
795+
t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg)
796+
}
797+
if !strings.Contains(err.Error(), tt.wantErrMsg) {
798+
t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg)
799+
}
800+
return
801+
}
802+
803+
if err != nil {
804+
t.Fatalf("unexpected error: %v", err)
805+
}
806+
if gotMethod != http.MethodGet {
807+
t.Errorf("method = %q, want GET", gotMethod)
808+
}
809+
if wantPath := "/transactional-emails/" + tt.id + "/metrics"; gotPath != wantPath {
810+
t.Errorf("path = %q, want %q", gotPath, wantPath)
811+
}
812+
if *result != want {
813+
t.Errorf("metrics = %+v, want %+v", *result, want)
814+
}
815+
})
816+
}
817+
}

‎version.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import "runtime/debug"
1010
var Version = readVersion()
1111

1212
// SpecVersion is the Loops OpenAPI spec version this SDK is built against.
13-
const SpecVersion = "1.21.14"
13+
const SpecVersion = "1.22.1"
1414

1515
const modulePath = "github.com/loops-so/loops-go"
1616

‎workflows.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,35 @@ func (c *Client) GetWorkflowNode(workflowID, nodeID string) (*WorkflowNodeWithRe
825825
return &result, nil
826826
}
827827

828+
// GetWorkflowNodeMetrics returns all-time email engagement metrics for the
829+
// SendEmailAction node identified by nodeID in the workflow identified by
830+
// workflowID. The server responds with a 400 error for nodes of any other
831+
// type or for a node with no email message. A SendEmailAction node that has
832+
// not sent anything yet reports every counter as 0.
833+
func (c *Client) GetWorkflowNodeMetrics(workflowID, nodeID string) (*EmailMetrics, error) {
834+
req, err := c.newRequest(http.MethodGet, "/workflows/"+workflowID+"/nodes/"+nodeID+"/metrics", nil)
835+
if err != nil {
836+
return nil, err
837+
}
838+
839+
resp, err := c.do(req)
840+
if err != nil {
841+
return nil, err
842+
}
843+
defer resp.Body.Close()
844+
845+
if resp.StatusCode != http.StatusOK {
846+
return nil, errorFromResponse(resp)
847+
}
848+
849+
var result EmailMetrics
850+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
851+
return nil, fmt.Errorf("failed to decode response: %w", err)
852+
}
853+
854+
return &result, nil
855+
}
856+
828857
// WorkflowMutationNode is the detailed node returned by workflow mutations
829858
// (create, update, add-branch). Unlike [WorkflowNode] it omits workflowId and
830859
// its variant fields differ. Exactly one variant pointer is set; TypeName is

0 commit comments

Comments
 (0)