From c594eff1696a97d4b333429a0e6a3c851b389da1 Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Fri, 10 Jan 2025 23:37:31 -0300 Subject: [PATCH] feat: Implement broadcasts endpoints (#48) --- broadcasts.go | 243 ++++++++++++++++++++++++++++++++++++++++ broadcasts_test.go | 249 +++++++++++++++++++++++++++++++++++++++++ errors.go | 6 + examples/broadcasts.go | 87 ++++++++++++++ resend.go | 17 ++- 5 files changed, 596 insertions(+), 6 deletions(-) create mode 100644 broadcasts.go create mode 100644 broadcasts_test.go create mode 100644 examples/broadcasts.go diff --git a/broadcasts.go b/broadcasts.go new file mode 100644 index 0000000..ea4bdf7 --- /dev/null +++ b/broadcasts.go @@ -0,0 +1,243 @@ +package resend + +import ( + "context" + "errors" + "net/http" +) + +type SendBroadcastRequest struct { + BroadcastId string `json:"broadcast_id"` + + //Schedule email to be sent later. The date should be in language natural (e.g.: in 1 min) + // or ISO 8601 format (e.g: 2024-08-05T11:52:01.858Z). + ScheduledAt string `json:"scheduled_at"` +} + +type CreateBroadcastRequest struct { + AudienceId string `json:"audience_id"` + From string `json:"from"` + Subject string `json:"subject"` + ReplyTo []string `json:"reply_to"` + Html string `json:"html"` + Text string `json:"text"` + Name string `json:"name"` +} + +type CreateBroadcastResponse struct { + Id string `json:"id"` +} + +type SendBroadcastResponse struct { + Id string `json:"id"` +} + +type RemoveBroadcastResponse struct { + Object string `json:"object"` + Id string `json:"id"` + Deleted bool `json:"deleted"` +} + +type ListBroadcastsResponse struct { + Object string `json:"object"` + Data []Broadcast `json:"data"` +} + +type Broadcast struct { + Object string `json:"object"` + Id string `json:"id"` + Name string `json:"name"` + AudienceId string `json:"audience_id"` + From string `json:"from"` + Subject string `json:"subject"` + ReplyTo []string `json:"reply_to"` + PreviewText string `json:"preview_text"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + ScheduledAt string `json:"scheduled_at"` + SentAt string `json:"sent_at"` +} + +type BroadcastsSvc interface { + CreateWithContext(ctx context.Context, params *CreateBroadcastRequest) (CreateBroadcastResponse, error) + Create(params *CreateBroadcastRequest) (CreateBroadcastResponse, error) + + ListWithContext(ctx context.Context) (ListBroadcastsResponse, error) + List() (ListBroadcastsResponse, error) + + GetWithContext(ctx context.Context, broadcastId string) (Broadcast, error) + Get(broadcastId string) (Broadcast, error) + + SendWithContext(ctx context.Context, params *SendBroadcastRequest) (SendBroadcastResponse, error) + Send(params *SendBroadcastRequest) (SendBroadcastResponse, error) + + RemoveWithContext(ctx context.Context, broadcastId string) (RemoveBroadcastResponse, error) + Remove(broadcastId string) (RemoveBroadcastResponse, error) +} + +type BroadcastsSvcImpl struct { + client *Client +} + +// CreateWithContext creates a new Broadcast based on the given params +// https://resend.com/docs/api-reference/broadcasts/create-broadcast +func (s *BroadcastsSvcImpl) CreateWithContext(ctx context.Context, params *CreateBroadcastRequest) (CreateBroadcastResponse, error) { + path := "/broadcasts" + + if params.AudienceId == "" { + return CreateBroadcastResponse{}, errors.New("[ERROR]: AudienceId cannot be empty") + } + + if params.From == "" { + return CreateBroadcastResponse{}, errors.New("[ERROR]: From cannot be empty") + } + + if params.Subject == "" { + return CreateBroadcastResponse{}, errors.New("[ERROR]: Subject cannot be empty") + } + + // Prepare request + req, err := s.client.NewRequest(ctx, http.MethodPost, path, params) + if err != nil { + return CreateBroadcastResponse{}, ErrFailedToCreateBroadcastCreateRequest + } + + // Build response recipient obj + broadcastResp := new(CreateBroadcastResponse) + + // Send Request + _, err = s.client.Perform(req, broadcastResp) + + if err != nil { + return CreateBroadcastResponse{}, err + } + + return *broadcastResp, nil +} + +// Create creates a new Broadcast based on the given params +func (s *BroadcastsSvcImpl) Create(params *CreateBroadcastRequest) (CreateBroadcastResponse, error) { + return s.CreateWithContext(context.Background(), params) +} + +// GetWithContext Retrieve a single broadcast. +// https://resend.com/docs/api-reference/broadcasts/get-broadcast +func (s *BroadcastsSvcImpl) GetWithContext(ctx context.Context, broadcastId string) (Broadcast, error) { + + if broadcastId == "" { + return Broadcast{}, errors.New("[ERROR]: broadcastId cannot be empty") + } + + path := "broadcasts/" + broadcastId + + // Prepare request + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return Broadcast{}, errors.New("[ERROR]: Failed to create Broadcast.Get request") + } + + broadcast := new(Broadcast) + + // Send Request + _, err = s.client.Perform(req, broadcast) + + if err != nil { + return Broadcast{}, err + } + + return *broadcast, nil +} + +// Get retrieves a single broadcast. +func (s *BroadcastsSvcImpl) Get(broadcastId string) (Broadcast, error) { + return s.GetWithContext(context.Background(), broadcastId) +} + +// SendWithContext Sends broadcasts to your audience. +// https://resend.com/docs/api-reference/broadcasts/send-broadcast +func (s *BroadcastsSvcImpl) SendWithContext(ctx context.Context, params *SendBroadcastRequest) (SendBroadcastResponse, error) { + if params.BroadcastId == "" { + return SendBroadcastResponse{}, errors.New("[ERROR]: BroadcastId cannot be empty") + } + + path := "/broadcasts/" + params.BroadcastId + "/send" + + // Prepare request + req, err := s.client.NewRequest(ctx, http.MethodPost, path, params) + if err != nil { + return SendBroadcastResponse{}, ErrFailedToCreateBroadcastSendRequest + } + + // Build response recipient obj + broadcastResp := new(SendBroadcastResponse) + + // Send Request + _, err = s.client.Perform(req, broadcastResp) + + if err != nil { + return SendBroadcastResponse{}, err + } + + return *broadcastResp, nil +} + +// Send sends broadcasts to your audience. +func (s *BroadcastsSvcImpl) Send(params *SendBroadcastRequest) (SendBroadcastResponse, error) { + return s.SendWithContext(context.Background(), params) +} + +// RemoveWithContext removes a given broadcast by id +// https://resend.com/docs/api-reference/broadcasts/delete-broadcast +func (s *BroadcastsSvcImpl) RemoveWithContext(ctx context.Context, broadcastId string) (RemoveBroadcastResponse, error) { + path := "broadcasts/" + broadcastId + + // Prepare request + req, err := s.client.NewRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return RemoveBroadcastResponse{}, errors.New("[ERROR]: Failed to create Broadcast.Remove request") + } + + resp := new(RemoveBroadcastResponse) + + // Send Request + _, err = s.client.Perform(req, resp) + + if err != nil { + return RemoveBroadcastResponse{}, err + } + + return *resp, nil +} + +// Remove removes a given broadcast entry by id +func (s *BroadcastsSvcImpl) Remove(broadcastId string) (RemoveBroadcastResponse, error) { + return s.RemoveWithContext(context.Background(), broadcastId) +} + +// ListWithContext returns the list of all broadcasts +// https://resend.com/docs/api-reference/broadcasts/list-broadcasts +func (s *BroadcastsSvcImpl) ListWithContext(ctx context.Context) (ListBroadcastsResponse, error) { + path := "broadcasts" + + // Prepare request + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return ListBroadcastsResponse{}, errors.New("[ERROR]: Failed to create Broadcasts.List request") + } + + broadcasts := new(ListBroadcastsResponse) + + // Send Request + _, err = s.client.Perform(req, broadcasts) + + if err != nil { + return ListBroadcastsResponse{}, err + } + + return *broadcasts, nil +} + +// List returns the list of all broadcasts +func (s *BroadcastsSvcImpl) List() (ListBroadcastsResponse, error) { + return s.ListWithContext(context.Background()) +} diff --git a/broadcasts_test.go b/broadcasts_test.go new file mode 100644 index 0000000..aac7bfd --- /dev/null +++ b/broadcasts_test.go @@ -0,0 +1,249 @@ +package resend + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateBroadcast(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/broadcasts", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + var ret interface{} + ret = ` + { + "id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" + }` + + fmt.Fprint(w, ret) + }) + + req := &CreateBroadcastRequest{ + Name: "New Broadcast", + AudienceId: "709d076c-2bb1-4be6-94ed-3f8f32622db6", + From: "hi@example.com", + Subject: "Hello, world!", + } + resp, err := client.Broadcasts.Create(req) + if err != nil { + t.Errorf("Broadcasts.Create returned error: %v", err) + } + assert.Equal(t, resp.Id, "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794") +} + +func TestCreateBroadcastValidations(t *testing.T) { + setup() + defer teardown() + + req1 := &CreateBroadcastRequest{ + Name: "New Broadcast", + AudienceId: "709d076c-2bb1-4be6-94ed-3f8f32622db6", + From: "", + } + _, err := client.Broadcasts.Create(req1) + assert.NotNil(t, err) + if err != nil { + assert.Equal(t, err.Error(), "[ERROR]: From cannot be empty") + } + + req2 := &CreateBroadcastRequest{ + Name: "New Broadcast", + From: "hi@example.com", + } + _, err = client.Broadcasts.Create(req2) + assert.NotNil(t, err) + if err != nil { + assert.Equal(t, err.Error(), "[ERROR]: AudienceId cannot be empty") + } + + req3 := &CreateBroadcastRequest{ + Name: "New Broadcast", + From: "hi@example.com", + AudienceId: "709d076c-2bb1-4be6-94ed-3f8f32622db6", + Subject: "", + } + _, err = client.Broadcasts.Create(req3) + assert.NotNil(t, err) + if err != nil { + assert.Equal(t, err.Error(), "[ERROR]: Subject cannot be empty") + } +} + +func TestGetBroadcast(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/broadcasts/559ac32e-9ef5-46fb-82a1-b76b840c0f7b", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + ret := ` + { + "object": "broadcast", + "id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b", + "name": "Announcements", + "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "from": "Acme ", + "subject": "hello world", + "reply_to": null, + "preview_text": "Check out our latest announcements", + "status": "draft", + "created_at": "2024-12-01T19:32:22.980Z", + "scheduled_at": null, + "sent_at": null + }` + + fmt.Fprint(w, ret) + }) + + b, err := client.Broadcasts.Get("559ac32e-9ef5-46fb-82a1-b76b840c0f7b") + if err != nil { + t.Errorf("Broadcast.Get returned error: %v", err) + } + + assert.Equal(t, b.Id, "559ac32e-9ef5-46fb-82a1-b76b840c0f7b") + assert.Equal(t, b.Object, "broadcast") + assert.Equal(t, b.Name, "Announcements") + assert.Equal(t, b.AudienceId, "78261eea-8f8b-4381-83c6-79fa7120f1cf") + assert.Equal(t, b.From, "Acme ") + assert.Equal(t, b.Subject, "hello world") + assert.Equal(t, b.PreviewText, "Check out our latest announcements") + assert.Equal(t, b.Status, "draft") + assert.Equal(t, b.CreatedAt, "2024-12-01T19:32:22.980Z") +} + +func TestGetBroadcastValidations(t *testing.T) { + setup() + defer teardown() + + _, err := client.Broadcasts.Get("") + assert.NotNil(t, err) + if err != nil { + assert.Equal(t, err.Error(), "[ERROR]: broadcastId cannot be empty") + } +} + +func TestSendBroadcast(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/broadcasts/559ac32e-9ef5-46fb-82a1-b76b840c0f7b/send", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + ret := ` + { + "id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794" + }` + + fmt.Fprint(w, ret) + }) + + req := &SendBroadcastRequest{ + BroadcastId: "559ac32e-9ef5-46fb-82a1-b76b840c0f7b", + } + + b, err := client.Broadcasts.Send(req) + if err != nil { + t.Errorf("Broadcast.Send returned error: %v", err) + } + + assert.Equal(t, b.Id, "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794") +} + +func TestSendBroadcastValidations(t *testing.T) { + setup() + defer teardown() + + req1 := &SendBroadcastRequest{ + BroadcastId: "", + } + + _, err := client.Broadcasts.Send(req1) + assert.NotNil(t, err) + if err != nil { + assert.Equal(t, err.Error(), "[ERROR]: BroadcastId cannot be empty") + } +} + +func TestRemoveBroadcast(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/broadcasts/b6d24b8e-af0b-4c3c-be0c-359bbd97381e", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodDelete) + w.WriteHeader(http.StatusOK) + + var ret interface{} + ret = ` + { + "object": "broadcast", + "id": "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "deleted": true + }` + + fmt.Fprint(w, ret) + }) + + deleted, err := client.Broadcasts.Remove("b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + if err != nil { + t.Errorf("Broadcasts.Remove returned error: %v", err) + } + assert.True(t, deleted.Deleted) + assert.Equal(t, deleted.Id, "b6d24b8e-af0b-4c3c-be0c-359bbd97381e") + assert.Equal(t, deleted.Object, "broadcast") +} + +func TestListBroadcasts(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/broadcasts", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + w.WriteHeader(http.StatusOK) + + ret := ` + { + "object": "list", + "data": [ + { + "id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794", + "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "status": "draft", + "created_at": "2024-11-01T15:13:31.723Z", + "scheduled_at": null, + "sent_at": null + }, + { + "id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b", + "audience_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", + "status": "sent", + "created_at": "2024-12-01T19:32:22.980Z", + "scheduled_at": "2024-12-02T19:32:22.980Z", + "sent_at": "2024-12-02T19:32:22.980Z" + } + ] + }` + + fmt.Fprint(w, ret) + }) + + broadcasts, err := client.Broadcasts.List() + if err != nil { + t.Errorf("Broadcasts.List returned error: %v", err) + } + + assert.Equal(t, len(broadcasts.Data), 2) + assert.Equal(t, broadcasts.Object, "list") + +} diff --git a/errors.go b/errors.go index a363f78..3764e08 100644 --- a/errors.go +++ b/errors.go @@ -2,6 +2,12 @@ package resend import "errors" +// BroadcastsSvc errors +var ( + ErrFailedToCreateBroadcastSendRequest = errors.New("[ERROR]: Failed to create Broadcasts.Send request") + ErrFailedToCreateBroadcastCreateRequest = errors.New("[ERROR]: Failed to create Broadcasts.Create request") +) + // ApiKeySvc errors var ( ErrFailedToCreateApiKeysCreateRequest = errors.New("[ERROR]: Failed to create ApiKeys.Create request") diff --git a/examples/broadcasts.go b/examples/broadcasts.go new file mode 100644 index 0000000..138af23 --- /dev/null +++ b/examples/broadcasts.go @@ -0,0 +1,87 @@ +package examples + +import ( + "context" + "fmt" + "os" + + "github.com/resend/resend-go/v2" +) + +func broadcastsExample() { + ctx := context.TODO() + apiKey := os.Getenv("RESEND_API_KEY") + + client := resend.NewClient(apiKey) + + // Create Broadcast + params := &resend.CreateBroadcastRequest{ + AudienceId: "78b8d3bc-a55a-45a3-aee6-6ec0a5e13d7e", + From: "onboarding@resend.dev", + Html: "

Hello, world!

", + Name: "Test Broadcast", + Subject: "Hello, world!", + } + + broadcast, err := client.Broadcasts.CreateWithContext(ctx, params) + + if err != nil { + panic(err) + } + fmt.Println("Created broadcast with entry id: " + broadcast.Id) + + // Get Broadcast + retrievedBroadcast, err := client.Broadcasts.GetWithContext(ctx, broadcast.Id) + if err != nil { + panic(err) + } + + fmt.Println("ID: " + retrievedBroadcast.Id) + fmt.Println("Name: " + retrievedBroadcast.Name) + fmt.Println("Audience ID: " + retrievedBroadcast.AudienceId) + fmt.Println("From: " + retrievedBroadcast.From) + fmt.Println("Subject: " + retrievedBroadcast.Subject) + fmt.Println("Preview Text: " + retrievedBroadcast.PreviewText) + fmt.Println("Status: " + retrievedBroadcast.Status) + fmt.Println("Created At: " + retrievedBroadcast.CreatedAt) + fmt.Println("Scheduled At: " + retrievedBroadcast.ScheduledAt) + fmt.Println("Sent At: " + retrievedBroadcast.SentAt) + + // Send Broadcast + sendParams := &resend.SendBroadcastRequest{ + BroadcastId: broadcast.Id, + } + + sendResponse, err := client.Broadcasts.SendWithContext(ctx, sendParams) + if err != nil { + panic(err) + } + fmt.Println("Sent broadcast with entry id: " + sendResponse.Id) + + // List Broadcasts + + listResponse, err := client.Broadcasts.ListWithContext(ctx) + if err != nil { + panic(err) + } + + for _, b := range listResponse.Data { + fmt.Println("ID: " + b.Id) + fmt.Println("Name: " + b.Name) + fmt.Println("Audience ID: " + b.AudienceId) + fmt.Println("From: " + b.From) + fmt.Println("Subject: " + b.Subject) + fmt.Println("Preview Text: " + b.PreviewText) + fmt.Println("Status: " + b.Status) + fmt.Println("Created At: " + b.CreatedAt) + fmt.Println("Scheduled At: " + b.ScheduledAt) + fmt.Println("Sent At: " + b.SentAt) + } + + // Remove Broadcast (Only Draft Broadcasts can be deleted) + // removeResponse, err := client.Broadcasts.RemoveWithContext(ctx, broadcast.Id) + // if err != nil { + // panic(err) + // } + // fmt.Println("Deleted broadcast with entry id: " + removeResponse.Id) +} diff --git a/resend.go b/resend.go index a0d03d9..b8f3664 100644 --- a/resend.go +++ b/resend.go @@ -42,12 +42,13 @@ type Client struct { headers map[string]string // Services - Emails EmailsSvc - Batch BatchSvc - ApiKeys ApiKeysSvc - Domains DomainsSvc - Audiences AudiencesSvc - Contacts ContactsSvc + Emails EmailsSvc + Batch BatchSvc + ApiKeys ApiKeysSvc + Domains DomainsSvc + Audiences AudiencesSvc + Contacts ContactsSvc + Broadcasts BroadcastsSvc } // NewClient is the default client constructor @@ -72,6 +73,7 @@ func NewCustomClient(httpClient *http.Client, apiKey string) *Client { c.Domains = &DomainsSvcImpl{client: c} c.Audiences = &AudiencesSvcImpl{client: c} c.Contacts = &ContactsSvcImpl{client: c} + c.Broadcasts = &BroadcastsSvcImpl{client: c} c.ApiKey = apiKey c.headers = make(map[string]string) @@ -157,6 +159,8 @@ func handleError(resp *http.Response) error { } else { r.Message = resp.Status } + + // TODO: replace this with a new ResendError type return errors.New("[ERROR]: " + r.Message) default: // Tries to parse `message` attr from error @@ -172,6 +176,7 @@ func handleError(resp *http.Response) error { } if r.Message != "" { + // TODO: replace this with a new ResendError type return errors.New("[ERROR]: " + r.Message) } return errors.New("[ERROR]: Unknown Error")