diff --git a/README.md b/README.md index caedc0c..0e226da 100644 --- a/README.md +++ b/README.md @@ -258,11 +258,11 @@ In order to send push notifications, we need customer device information. // platform (required) - the platform of the device, currently only accepts 'ios' and 'android' // data (optional) - a ```map[string]interface{}``` of information about the device. // You can pass any key/value pairs that would be useful in your triggers. -// We currently only save 'last_used'. // Your interface{} should be parseable as Json by 'encoding/json'.Marshal if err := track.AddDevice("5", "messaging token", "android", map[string]interface{}{ "last_used": time.Now().Unix(), +"attribute_name": "attribute_value", }); err != nil { // handle error } @@ -288,6 +288,7 @@ if err := track.DeleteDevice("5", "messaging-token"); err != nil { To use the Customer.io [Transactional API](https://customer.io/docs/transactional-api), create an instance of the API client using an [App API key](https://customer.io/docs/managing-credentials#app-api-keys). +## Email Create a `customerio.SendEmailRequest` instance, and then use `(c *customerio.APIClient).SendEmail` to send your message. [Learn more about transactional messages and optional `SendEmailRequest` properties](https://customer.io/docs/transactional-api). You can also send attachments with your message. Use `customerio.SendEmailRequest.Attach` to encode attachments. @@ -335,6 +336,42 @@ if err != nil { fmt.Println(body) ``` +## Push +Create a `customerio.SendPushRequest` instance, and then use `(c *customerio.APIClient).SendPush` to send your message. [Learn more about transactional messages and optional `SendPush` properties](https://customer.io/docs/transactional-api). + +```go +client := customerio.NewAPIClient("", customerio.WithRegion(customerio.RegionUS)); + +request := customerio.SendPushRequest{ + TransactionalMessageID: "3", + MessageData: map[string]interface{}{ + "name": "Person", + "items": map[string]interface{}{ + "name": "shoes", + "price": "59.99", + }, + "products": []interface{}{}, + }, + Identifiers: map[string]string{ + "id": "example1", + }, +} + +// (optional) upsert a particular device for the profile the push is being sent to. +device, err := customerio.NewDevice("device-id", "android", map[string]interface{}{"optional_attr": "value"}) +if err != nil { + // handle error, invalid device params. +} +request.Device = device + +body, err := client.SendPush(context.Background(), &request) +if err != nil { + // handle error +} + +fmt.Println(body) +``` + ## Context Support There are additional API methods that support passing a context that satisfies the `context.Context` interface to allow better control over dispatched requests. For example with sending an event: ```go diff --git a/customerio.go b/customerio.go index 54ba1e7..a6abb14 100644 --- a/customerio.go +++ b/customerio.go @@ -141,60 +141,6 @@ func (c *CustomerIO) DeleteCtx(ctx context.Context, customerID string) error { nil) } -// Delete deletes a customer -func (c *CustomerIO) Delete(customerID string) error { - return c.DeleteCtx(context.Background(), customerID) -} - -// AddDeviceCtx adds a device for a customer -func (c *CustomerIO) AddDeviceCtx(ctx context.Context, customerID string, deviceID string, platform string, data map[string]interface{}) error { - if customerID == "" { - return ParamError{Param: "customerID"} - } - if deviceID == "" { - return ParamError{Param: "deviceID"} - } - if platform == "" { - return ParamError{Param: "platform"} - } - - body := map[string]map[string]interface{}{ - "device": { - "id": deviceID, - "platform": platform, - }, - } - for k, v := range data { - body["device"][k] = v - } - return c.request(ctx, "PUT", - fmt.Sprintf("%s/api/v1/customers/%s/devices", c.URL, url.PathEscape(customerID)), - body) -} - -// AddDevice adds a device for a customer -func (c *CustomerIO) AddDevice(customerID string, deviceID string, platform string, data map[string]interface{}) error { - return c.AddDeviceCtx(context.Background(), customerID, deviceID, platform, data) -} - -// DeleteDeviceCtx deletes a device for a customer -func (c *CustomerIO) DeleteDeviceCtx(ctx context.Context, customerID string, deviceID string) error { - if customerID == "" { - return ParamError{Param: "customerID"} - } - if deviceID == "" { - return ParamError{Param: "deviceID"} - } - return c.request(ctx, "DELETE", - fmt.Sprintf("%s/api/v1/customers/%s/devices/%s", c.URL, url.PathEscape(customerID), url.PathEscape(deviceID)), - nil) -} - -// DeleteDevice deletes a device for a customer -func (c *CustomerIO) DeleteDevice(customerID string, deviceID string) error { - return c.DeleteDeviceCtx(context.Background(), customerID, deviceID) -} - func (c *CustomerIO) auth() string { return base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%v:%v", c.siteID, c.apiKey))) } diff --git a/device.go b/device.go new file mode 100644 index 0000000..ae1474e --- /dev/null +++ b/device.go @@ -0,0 +1,109 @@ +package customerio + +import ( + "context" + "fmt" + "net/url" +) + +type deviceV1 struct { + ID string `json:"id"` + Platform string `json:"platform"` + LastUsed string `json:"last_used,omitempty"` + Attributes map[string]interface{} `json:"attributes"` +} + +type deviceV2 struct { + ID string `json:"token"` + Platform string `json:"platform"` + LastUsed string `json:"last_used,omitempty"` + Attributes map[string]interface{} `json:"attributes"` +} + +func newDeviceV1(deviceID, platform string, data map[string]interface{}) (*deviceV1, error) { + if deviceID == "" { + return nil, ParamError{Param: "deviceID"} + } + if platform == "" { + return nil, ParamError{Param: "platform"} + } + d := &deviceV1{ + ID: deviceID, + Platform: platform, + } + + if len(data) > 0 { + d.Attributes = make(map[string]interface{}) + } + + for k, v := range data { + if k == "last_used" { + d.LastUsed = fmt.Sprintf("%v", v) + continue + } + d.Attributes[k] = v + } + + return d, nil +} + +func NewDevice(deviceID, platform string, data map[string]interface{}) (*deviceV2, error) { + d, err := newDeviceV1(deviceID, platform, data) + if err != nil { + return nil, err + } + return &deviceV2{ + ID: d.ID, + Platform: d.Platform, + Attributes: d.Attributes, + LastUsed: d.LastUsed, + }, nil +} + +// Delete deletes a customer +func (c *CustomerIO) Delete(customerID string) error { + return c.DeleteCtx(context.Background(), customerID) +} + +// AddDeviceCtx adds a device for a customer +func (c *CustomerIO) AddDeviceCtx(ctx context.Context, customerID string, deviceID string, platform string, data map[string]interface{}) error { + if customerID == "" { + return ParamError{Param: "customerID"} + } + + d, err := newDeviceV1(deviceID, platform, data) + if err != nil { + return err + } + + body := map[string]interface{}{ + "device": d, + } + + return c.request(ctx, "PUT", + fmt.Sprintf("%s/api/v1/customers/%s/devices", c.URL, url.PathEscape(customerID)), + body) +} + +// AddDevice adds a device for a customer +func (c *CustomerIO) AddDevice(customerID string, deviceID string, platform string, data map[string]interface{}) error { + return c.AddDeviceCtx(context.Background(), customerID, deviceID, platform, data) +} + +// DeleteDeviceCtx deletes a device for a customer +func (c *CustomerIO) DeleteDeviceCtx(ctx context.Context, customerID string, deviceID string) error { + if customerID == "" { + return ParamError{Param: "customerID"} + } + if deviceID == "" { + return ParamError{Param: "deviceID"} + } + return c.request(ctx, "DELETE", + fmt.Sprintf("%s/api/v1/customers/%s/devices/%s", c.URL, url.PathEscape(customerID), url.PathEscape(deviceID)), + nil) +} + +// DeleteDevice deletes a device for a customer +func (c *CustomerIO) DeleteDevice(customerID string, deviceID string) error { + return c.DeleteDeviceCtx(context.Background(), customerID, deviceID) +} diff --git a/examples/transactional.go b/examples/transactional.go index f60e8c3..7aa9b5a 100644 --- a/examples/transactional.go +++ b/examples/transactional.go @@ -14,7 +14,7 @@ func main() { client := customerio.NewAPIClient("", customerio.WithRegion(customerio.RegionUS)) - req := customerio.SendEmailRequest{ + emailReq := customerio.SendEmailRequest{ Identifiers: map[string]string{ "id": "customer_1", }, @@ -34,14 +34,33 @@ func main() { } defer f.Close() - if err := req.Attach("sample.pdf", f); err != nil { + if err := emailReq.Attach("sample.pdf", f); err != nil { panic(err) } - resp, err := client.SendEmail(ctx, &req) + emailResp, err := client.SendEmail(ctx, &emailReq) if err != nil { panic(err) } - fmt.Println(resp) + fmt.Println(emailResp) + + // Create and configure your transactional messages in the Customer.io UI. + transactionalMessageID := "push_message_id" + + pushReq := customerio.SendPushRequest{ + TransactionalMessageID: transactionalMessageID, + Identifiers: map[string]string{ + "id": "customer_1", + }, + Title: "hello, {{ trigger.name }}", + Message: "hello from the Customer.io {{ trigger.client }} client", + } + + pushResp, err := client.SendPush(ctx, &pushReq) + if err != nil { + panic(err) + } + + fmt.Println(pushResp) } diff --git a/send_email.go b/send_email.go index d1f8d41..02b6600 100644 --- a/send_email.go +++ b/send_email.go @@ -4,10 +4,8 @@ import ( "bytes" "context" "encoding/base64" - "encoding/json" "errors" "io" - "net/http" ) type SendEmailRequest struct { @@ -64,33 +62,12 @@ type SendEmailResponse struct { // SendEmail sends a single transactional email using the Customer.io transactional API func (c *APIClient) SendEmail(ctx context.Context, req *SendEmailRequest) (*SendEmailResponse, error) { - body, statusCode, err := c.doRequest(ctx, "POST", "/v1/send/email", req) + resp, err := c.sendTransactional(ctx, TransactionalTypeEmail, req) if err != nil { return nil, err } - if statusCode != http.StatusOK { - var meta struct { - Meta struct { - Err string `json:"error"` - } `json:"meta"` - } - if err := json.Unmarshal(body, &meta); err != nil { - return nil, &TransactionalError{ - StatusCode: statusCode, - Err: string(body), - } - } - return nil, &TransactionalError{ - StatusCode: statusCode, - Err: meta.Meta.Err, - } - } - - var result SendEmailResponse - if err := json.Unmarshal(body, &result); err != nil { - return nil, err - } - - return &result, nil + return &SendEmailResponse{ + *resp, + }, nil } diff --git a/send_email_test.go b/send_email_test.go index d4cdf02..1dc1e6b 100644 --- a/send_email_test.go +++ b/send_email_test.go @@ -3,7 +3,6 @@ package customerio_test import ( "context" "encoding/json" - "io/ioutil" "net/http" "net/http/httptest" "reflect" @@ -28,32 +27,20 @@ func TestSendEmail(t *testing.T) { }, } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - b, err := ioutil.ReadAll(req.Body) - if err != nil { - t.Error(err) - } - defer req.Body.Close() - + var verify = func(request []byte) { var body customerio.SendEmailRequest - if err := json.Unmarshal(b, &body); err != nil { + if err := json.Unmarshal(request, &body); err != nil { t.Error(err) } if !reflect.DeepEqual(&body, emailRequest) { - t.Errorf("Request differed, want: %#v, got: %#v", emailRequest, body) + t.Errorf("Request differed, want: %#v, got: %#v", request, body) } + } - w.Write([]byte(`{ - "delivery_id": "ABCDEFG", - "queued_at": 1500111111 - }`)) - })) + api, srv := transactionalServer(t, verify) defer srv.Close() - api := customerio.NewAPIClient("myKey") - api.URL = srv.URL - resp, err := api.SendEmail(context.Background(), emailRequest) if err != nil { t.Error(err) @@ -61,8 +48,8 @@ func TestSendEmail(t *testing.T) { expect := &customerio.SendEmailResponse{ TransactionalResponse: customerio.TransactionalResponse{ - DeliveryID: "ABCDEFG", - QueuedAt: time.Unix(1500111111, 0), + DeliveryID: testDeliveryID, + QueuedAt: time.Unix(int64(testQueuedAt), 0), }, } diff --git a/send_push.go b/send_push.go new file mode 100644 index 0000000..814c11e --- /dev/null +++ b/send_push.go @@ -0,0 +1,43 @@ +package customerio + +import ( + "context" + "encoding/json" +) + +type SendPushRequest struct { + MessageData map[string]interface{} `json:"message_data,omitempty"` + TransactionalMessageID string `json:"transactional_message_id,omitempty"` + Identifiers map[string]string `json:"identifiers"` + To string `json:"to,omitempty"` + DisableMessageRetention *bool `json:"disable_message_retention,omitempty"` + SendToUnsubscribed *bool `json:"send_to_unsubscribed,omitempty"` + QueueDraft *bool `json:"queue_draft,omitempty"` + SendAt *int64 `json:"send_at,omitempty"` + Language *string `json:"language,omitempty"` + + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + ImageURL string `json:"image_url,omitempty"` + Link string `json:"link,omitempty"` + CustomData json.RawMessage `json:"custom_data,omitempty"` + CustomPayload json.RawMessage `json:"custom_payload,omitempty"` + Device *deviceV2 `json:"custom_device,omitempty"` + Sound string `json:"sound,omitempty"` +} + +type SendPushResponse struct { + TransactionalResponse +} + +// SendPush sends a single transactional push using the Customer.io transactional API +func (c *APIClient) SendPush(ctx context.Context, req *SendPushRequest) (*SendPushResponse, error) { + resp, err := c.sendTransactional(ctx, TransactionalTypePush, req) + if err != nil { + return nil, err + } + + return &SendPushResponse{ + *resp, + }, nil +} diff --git a/send_push_test.go b/send_push_test.go new file mode 100644 index 0000000..0576c71 --- /dev/null +++ b/send_push_test.go @@ -0,0 +1,61 @@ +package customerio_test + +import ( + "context" + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/customerio/go-customerio/v3" +) + +func TestSendPush(t *testing.T) { + pushRequest := &customerio.SendPushRequest{ + Identifiers: map[string]string{ + "id": "customer_1", + }, + To: "customer@example.com", + Title: "hello, {{ trigger.name }}", + Message: "hello from the Customer.io {{ trigger.client }} client", + MessageData: map[string]interface{}{ + "client": "Go", + "name": "gopher", + }, + } + d, err := customerio.NewDevice("device-id", "ios", map[string]interface{}{"attr1": "value1"}) + if err != nil { + t.Error(err) + } + pushRequest.Device = d + + var verify = func(request []byte) { + var body customerio.SendPushRequest + if err := json.Unmarshal(request, &body); err != nil { + t.Error(err) + } + + if !reflect.DeepEqual(&body, pushRequest) { + t.Errorf("Request differed, want: %#v, got: %#v", request, body) + } + } + + api, srv := transactionalServer(t, verify) + defer srv.Close() + + resp, err := api.SendPush(context.Background(), pushRequest) + if err != nil { + t.Error(err) + } + + expect := &customerio.SendPushResponse{ + TransactionalResponse: customerio.TransactionalResponse{ + DeliveryID: testDeliveryID, + QueuedAt: time.Unix(int64(testQueuedAt), 0), + }, + } + + if !reflect.DeepEqual(resp, expect) { + t.Errorf("Expect: %#v, Got: %#v", expect, resp) + } +} diff --git a/transactional.go b/transactional.go index dcdc978..e8d96b1 100644 --- a/transactional.go +++ b/transactional.go @@ -1,10 +1,65 @@ package customerio import ( + "context" "encoding/json" + "errors" + "fmt" + "net/http" "time" ) +type TransactionalType int + +const ( + TransactionalTypeEmail = 0 + TransactionalTypePush = 1 +) + +var typeToApi = map[TransactionalType]string{ + TransactionalTypeEmail: "email", + TransactionalTypePush: "push", +} + +var ErrInvalidTransactionalMessageType = errors.New("unknown transactional message type") + +func (c *APIClient) sendTransactional(ctx context.Context, typ TransactionalType, req interface{}) (*TransactionalResponse, error) { + api, ok := typeToApi[typ] + if !ok { + return nil, ErrInvalidTransactionalMessageType + } + + body, statusCode, err := c.doRequest(ctx, "POST", fmt.Sprintf("/v1/send/%s", api), req) + if err != nil { + return nil, err + } + + if statusCode != http.StatusOK { + var meta struct { + Meta struct { + Err string `json:"error"` + } `json:"meta"` + } + if err := json.Unmarshal(body, &meta); err != nil { + return nil, &TransactionalError{ + StatusCode: statusCode, + Err: string(body), + } + } + return nil, &TransactionalError{ + StatusCode: statusCode, + Err: meta.Meta.Err, + } + } + + var resp TransactionalResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + + return &resp, nil +} + // TransactionalResponse is a response to the send of a transactional message. type TransactionalResponse struct { // DeliveryID is a unique id for the given message. diff --git a/transactional_test.go b/transactional_test.go new file mode 100644 index 0000000..91135ff --- /dev/null +++ b/transactional_test.go @@ -0,0 +1,38 @@ +package customerio_test + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/customerio/go-customerio/v3" +) + +var ( + testDeliveryID = "ABCDEFG" + testQueuedAt = 1500111111 +) + +func transactionalServer(t *testing.T, verify func(request []byte)) (*customerio.APIClient, *httptest.Server) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + b, err := ioutil.ReadAll(req.Body) + if err != nil { + t.Error(err) + } + defer req.Body.Close() + + verify(b) + + w.Write([]byte(`{ + "delivery_id": "` + testDeliveryID + `", + "queued_at": ` + strconv.Itoa(testQueuedAt) + ` + }`)) + })) + + api := customerio.NewAPIClient("myKey") + api.URL = srv.URL + + return api, srv +} diff --git a/version.go b/version.go index 66828eb..f6bde9f 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package customerio -const Version = "3.4.1" +const Version = "3.5.0"