diff --git a/README.md b/README.md index 144c543..2370bc2 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,11 @@ View the generated [documentation](https://pkg.go.dev/github.com/mrz1836/go-cust - [ ] **Beta API** (Activities) - [ ] List activities - [ ] **Beta API** (Collections) - - [ ] Create a collection + - [x] Create a collection - [ ] List your collections - [ ] Lookup a collection - [ ] Delete a collection - - [ ] Update a collection + - [x] Update a collection - [ ] Lookup collection contents - [ ] Update the contents of a collection - [ ] **Beta API** (Sender Identities) diff --git a/client.go b/client.go index b5fc545..b30d7c7 100644 --- a/client.go +++ b/client.go @@ -24,6 +24,7 @@ type Client struct { type clientOptions struct { apiURL string // Regional API endpoint (URL) appAPIKey string // App or Beta API key + betaURL string // Regional API endpoint (Beta URL) httpTimeout time.Duration // Default timeout in seconds for GET requests requestTracing bool // If enabled, it will trace the request timing retryCount int // Default retry count for HTTP requests @@ -36,6 +37,7 @@ type clientOptions struct { // region is used for changing the location of the API endpoints type region struct { apiURL string + betaURL string trackURL string } @@ -43,10 +45,12 @@ type region struct { var ( RegionUS = region{ apiURL: "https://api.customer.io", + betaURL: "https://beta-api.customer.io", trackURL: "https://track.customer.io", } RegionEU = region{ apiURL: "https://api-eu.customer.io", + betaURL: "https://beta-api.customer.io", trackURL: "https://track-eu.customer.io", } ) @@ -59,6 +63,7 @@ type ClientOps func(c *clientOptions) func WithRegion(r region) ClientOps { return func(c *clientOptions) { c.apiURL = r.apiURL + c.betaURL = r.betaURL c.trackURL = r.trackURL } } @@ -131,6 +136,7 @@ func defaultClientOptions() (opts *clientOptions) { // Set the default options opts = &clientOptions{ apiURL: RegionUS.apiURL, + betaURL: RegionUS.betaURL, httpTimeout: defaultHTTPTimeout, requestTracing: false, retryCount: defaultRetryCount, diff --git a/client_test.go b/client_test.go index 051d733..2da4815 100644 --- a/client_test.go +++ b/client_test.go @@ -43,11 +43,12 @@ func TestNewClient(t *testing.T) { client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey)) assert.NoError(t, err) assert.NotNil(t, client) - assert.Equal(t, defaultUserAgent, client.options.userAgent) assert.Equal(t, defaultHTTPTimeout, client.options.httpTimeout) assert.Equal(t, defaultRetryCount, client.options.retryCount) + assert.Equal(t, defaultUserAgent, client.options.userAgent) assert.Equal(t, false, client.options.requestTracing) assert.Equal(t, RegionUS.apiURL, client.options.apiURL) + assert.Equal(t, RegionUS.betaURL, client.options.betaURL) assert.Equal(t, RegionUS.trackURL, client.options.trackURL) }) @@ -78,6 +79,7 @@ func TestNewClient(t *testing.T) { client, err := NewClient(WithTrackingKey(testSiteID, testTrackingAPIKey), WithUserAgent("custom user agent")) assert.NotNil(t, client) assert.NoError(t, err) + assert.Equal(t, "custom user agent", client.GetUserAgent()) }) t.Run("custom region (EU)", func(t *testing.T) { @@ -85,6 +87,7 @@ func TestNewClient(t *testing.T) { assert.NotNil(t, client) assert.NoError(t, err) assert.Equal(t, client.options.apiURL, "https://api-eu.customer.io") + assert.Equal(t, client.options.betaURL, "https://beta-api.customer.io") assert.Equal(t, client.options.trackURL, "https://track-eu.customer.io") }) @@ -93,6 +96,7 @@ func TestNewClient(t *testing.T) { assert.NotNil(t, client) assert.NoError(t, err) assert.Equal(t, client.options.apiURL, "https://api.customer.io") + assert.Equal(t, client.options.betaURL, "https://beta-api.customer.io") assert.Equal(t, client.options.trackURL, "https://track.customer.io") }) @@ -136,7 +140,7 @@ func ExampleNewClient() { return } fmt.Printf("loaded client: %s", client.options.userAgent) - // Output:loaded client: go-customerio: v1.3.2 + // Output:loaded client: go-customerio: v1.3.3 } // BenchmarkNewClient benchmarks the method NewClient() diff --git a/collections.go b/collections.go new file mode 100644 index 0000000..e7d2218 --- /dev/null +++ b/collections.go @@ -0,0 +1,88 @@ +package customerio + +import ( + "fmt" + "net/http" +) + +// UpdateCollection will create or update a collection with raw data +// See: https://customer.io/docs/api/#operation/addCollection +// See: https://customer.io/docs/api/#operation/updateCollection +// +// The name of the collection. This is how you'll reference your collection in message. +// Updating the data or url for your collection fully replaces the contents of the collection. +// Data example: {"data":[{"property1":null,"property2":null}]}} +func (c *Client) UpdateCollection(collectionID, collectionName string, items []map[string]interface{}) error { + if collectionName == "" { + return ParamError{Param: "collectionName"} + } + /* + if len(data) > 56000 { // todo: is there a limit? + return errors.New("collection body size limited to 56000") + } + */ + + // Create or Update (if id is given) + var err error + if len(collectionID) == 0 { + _, err = c.request( + http.MethodPost, + fmt.Sprintf("%s/v1/api/collections", c.options.betaURL), + map[string]interface{}{ + "data": items, + "name": collectionName, + }, + ) + } else { + _, err = c.request( + http.MethodPut, + fmt.Sprintf("%s/v1/api/collections/%s", c.options.betaURL, collectionID), + map[string]interface{}{ + "data": items, + "name": collectionName, + }, + ) + } + + return err +} + +// UpdateCollectionViaURL will create or update a collection using a URL to a JSON file +// See: https://customer.io/docs/api/#operation/addCollection +// See: https://customer.io/docs/api/#operation/updateCollection +// +// The name of the collection. This is how you'll reference your collection in message. +// +// The URL where your data is stored. +// If your URL does not include a Content-Type, Customer.io assumes your data is JSON. +// This URL can also be a google sheet that you've shared with cio_share@customer.io. +// Updating the data or url for your collection fully replaces the contents of the collection. +func (c *Client) UpdateCollectionViaURL(collectionID, collectionName string, jsonURL string) error { + if collectionName == "" { + return ParamError{Param: "collectionName"} + } + + // Create or Update (if id is given) + var err error + if len(collectionID) == 0 { + _, err = c.request( + http.MethodPost, + fmt.Sprintf("%s/v1/api/collections", c.options.betaURL), + map[string]interface{}{ + "url": jsonURL, + "name": collectionName, + }, + ) + } else { + _, err = c.request( + http.MethodPut, + fmt.Sprintf("%s/v1/api/collections/%s", c.options.betaURL, collectionID), + map[string]interface{}{ + "url": jsonURL, + "name": collectionName, + }, + ) + } + + return err +} diff --git a/collections_test.go b/collections_test.go new file mode 100644 index 0000000..d1abd40 --- /dev/null +++ b/collections_test.go @@ -0,0 +1,319 @@ +package customerio + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +// TestClient_UpdateCollection will test the method UpdateCollection() +func TestClient_UpdateCollection(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response (create)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewCollection(http.StatusOK) + + err = client.UpdateCollection( + "", + testCollectionName, + []map[string]interface{}{ + { + "item_name": "test_item_1", + "id_field": 1, + "timestamp_field": time.Now().UTC().Unix(), + }, + { + "item_name": "test_item_2", + "id_field": 2, + "timestamp_field": time.Now().UTC().Unix(), + }, + }) + assert.NoError(t, err) + }) + + t.Run("successful response (update)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateCollection(http.StatusOK, testCollectionID) + + err = client.UpdateCollection( + testCollectionID, + testCollectionName, + []map[string]interface{}{ + { + "item_name": "test_item_1", + "id_field": 1, + "timestamp_field": time.Now().UTC().Unix(), + }, + { + "item_name": "test_item_2", + "id_field": 2, + "timestamp_field": time.Now().UTC().Unix(), + }, + }) + assert.NoError(t, err) + }) + + t.Run("missing collection name", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateCollection(http.StatusOK, testCollectionID) + + err = client.UpdateCollection( + testCollectionID, + "", + []map[string]interface{}{ + { + "item_name": "test_item_1", + "id_field": 1, + "timestamp_field": time.Now().UTC().Unix(), + }, + { + "item_name": "test_item_2", + "id_field": 2, + "timestamp_field": time.Now().UTC().Unix(), + }, + }) + assert.Error(t, err) + checkParamError(t, err, "collectionName") + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewCollection(http.StatusUnprocessableEntity) + + err = client.UpdateCollection( + "", + testCollectionName, + []map[string]interface{}{ + { + "item_name": "test_item_1", + "id_field": 1, + "timestamp_field": time.Now().UTC().Unix(), + }, + { + "item_name": "test_item_2", + "id_field": 2, + "timestamp_field": time.Now().UTC().Unix(), + }, + }) + assert.Error(t, err) + }) +} + +// ExampleClient_UpdateCollection example using UpdateCollection() +// +// See more examples in /examples/ +func ExampleClient_UpdateCollection() { + + // Load the client + client, err := newTestClient() + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + + mockNewCollection(http.StatusOK) + + // New collection + err = client.UpdateCollection( + "", + testCollectionName, + []map[string]interface{}{ + { + "item_name": "test_item_1", + "id_field": 1, + "timestamp_field": time.Now().UTC().Unix(), + }, + { + "item_name": "test_item_2", + "id_field": 2, + "timestamp_field": time.Now().UTC().Unix(), + }, + }) + if err != nil { + fmt.Printf("error creating collection: " + err.Error()) + return + } + fmt.Printf("collection created: %s", testCollectionName) + // Output:collection created: test_collection +} + +// BenchmarkClient_UpdateCollection benchmarks the method UpdateCollection() +func BenchmarkClient_UpdateCollection(b *testing.B) { + client, _ := newTestClient() + mockUpdateCollection(http.StatusOK, testCollectionID) + data := []map[string]interface{}{ + { + "item_name": "test_item_1", + "id_field": 1, + "timestamp_field": time.Now().UTC().Unix(), + }, + { + "item_name": "test_item_2", + "id_field": 2, + "timestamp_field": time.Now().UTC().Unix(), + }, + } + for i := 0; i < b.N; i++ { + _ = client.UpdateCollection(testCollectionID, testCollectionName, data) + } +} + +// TestClient_UpdateCollectionViaURL will test the method UpdateCollectionViaURL() +func TestClient_UpdateCollectionViaURL(t *testing.T) { + // t.Parallel() (Cannot run in parallel - issues with overriding the mock client) + + t.Run("successful response (create)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewCollectionViaURL(http.StatusOK) + + err = client.UpdateCollectionViaURL( + "", + testCollectionName, + testCollectionURL, + ) + assert.NoError(t, err) + }) + + t.Run("successful response (update)", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockUpdateCollectionViaURL(http.StatusOK, testCollectionID) + + err = client.UpdateCollectionViaURL( + testCollectionID, + testCollectionName, + testCollectionURL, + ) + assert.NoError(t, err) + }) + + t.Run("missing collection name", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewCollectionViaURL(http.StatusOK) + + err = client.UpdateCollectionViaURL( + "", + "", + testCollectionURL, + ) + assert.Error(t, err) + checkParamError(t, err, "collectionName") + }) + + t.Run("customerIo error", func(t *testing.T) { + client, err := newTestClient() + assert.NoError(t, err) + assert.NotNil(t, client) + + mockNewCollectionViaURL(http.StatusUnprocessableEntity) + + err = client.UpdateCollectionViaURL( + "", + testCollectionName, + testCollectionURL, + ) + assert.Error(t, err) + }) +} + +// ExampleClient_UpdateCollectionViaURL example using UpdateCollectionViaURL() +// +// See more examples in /examples/ +func ExampleClient_UpdateCollectionViaURL() { + + // Load the client + client, err := newTestClient() + if err != nil { + fmt.Printf("error loading client: %s", err.Error()) + return + } + + mockNewCollectionViaURL(http.StatusOK) + + // New collection + err = client.UpdateCollectionViaURL( + "", + testCollectionName, + testCollectionURL, + ) + if err != nil { + fmt.Printf("error creating collection: " + err.Error()) + return + } + fmt.Printf("collection created: %s", testCollectionName) + // Output:collection created: test_collection +} + +// BenchmarkClient_UpdateCollectionViaURL benchmarks the method UpdateCollectionViaURL() +func BenchmarkClient_UpdateCollectionViaURL(b *testing.B) { + client, _ := newTestClient() + mockUpdateCollectionViaURL(http.StatusOK, testCollectionID) + for i := 0; i < b.N; i++ { + _ = client.UpdateCollectionViaURL(testCollectionID, testCollectionName, testCollectionURL) + } +} + +// mockNewCollection is used for mocking the response +func mockNewCollection(statusCode int) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPost, fmt.Sprintf("%sv1/api/collections", testBetaAPIURL), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} + +// mockUpdateCollection is used for mocking the response +func mockUpdateCollection(statusCode int, collectionID string) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPut, fmt.Sprintf("%sv1/api/collections/%s", testBetaAPIURL, collectionID), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} + +// mockNewCollectionViaURL is used for mocking the response +func mockNewCollectionViaURL(statusCode int) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPost, fmt.Sprintf("%sv1/api/collections", testBetaAPIURL), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} + +// mockUpdateCollectionViaURL is used for mocking the response +func mockUpdateCollectionViaURL(statusCode int, collectionID string) { + httpmock.Reset() + httpmock.RegisterResponder(http.MethodPut, fmt.Sprintf("%sv1/api/collections/%s", testBetaAPIURL, collectionID), + httpmock.NewStringResponder( + statusCode, "", + ), + ) +} diff --git a/customerio_test.go b/customerio_test.go index c8dc2dc..23c3b76 100644 --- a/customerio_test.go +++ b/customerio_test.go @@ -2,6 +2,10 @@ package customerio const ( testAppAPIURL = "https://api.customer.io/" + testBetaAPIURL = "https://beta-api.customer.io/" + testCollectionID = "123" + testCollectionName = "test_collection" + testCollectionURL = "https://example.com/some-path/collection.json" testCustomerEmail = "bob@example.com" testCustomerID = "123" testDeviceID = "abcdefghijklmnopqrstuvwxyz" diff --git a/definitions.go b/definitions.go index 69e2cb8..0305245 100644 --- a/definitions.go +++ b/definitions.go @@ -11,7 +11,7 @@ const ( defaultHTTPTimeout = 20 * time.Second // Default timeout for all GET requests in seconds defaultRetryCount = 2 // Default retry count for HTTP requests defaultUserAgent = "go-customerio: " + version // Default user agent - version = "v1.3.2" // CustomerIO version + version = "v1.3.3" // CustomerIO version ) // DevicePlatform is the platform for the customer device diff --git a/examples/new_collection/new_collection.go b/examples/new_collection/new_collection.go new file mode 100644 index 0000000..378767b --- /dev/null +++ b/examples/new_collection/new_collection.go @@ -0,0 +1,41 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking API & App API enabled) + client, err := customerio.NewClient( + customerio.WithAppKey(os.Getenv("APP_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + err = client.UpdateCollection( + "", + "test_collection", + []map[string]interface{}{ + { + "item_name": "test_item_1", + "id_field": 1, + "timestamp_field": time.Now().UTC().Unix(), + }, + { + "item_name": "test_item_2", + "id_field": 2, + "timestamp_field": time.Now().UTC().Unix(), + }, + }) + if err != nil { + log.Fatalln(err.Error()) + } + + log.Println("Collection Added Successfully!") +} diff --git a/examples/update_collection/update_collection.go b/examples/update_collection/update_collection.go new file mode 100644 index 0000000..92a50cc --- /dev/null +++ b/examples/update_collection/update_collection.go @@ -0,0 +1,41 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/mrz1836/go-customerio" +) + +func main() { + + // Load the client (with Tracking API & App API enabled) + client, err := customerio.NewClient( + customerio.WithAppKey(os.Getenv("APP_API_KEY")), + ) + if err != nil { + log.Fatalln(err) + } + + err = client.UpdateCollection( + "123", + "test_collection", + []map[string]interface{}{ + { + "item_name": "test_item_1", + "id_field": 1, + "timestamp_field": time.Now().UTC().Unix(), + }, + { + "item_name": "test_item_2", + "id_field": 2, + "timestamp_field": time.Now().UTC().Unix(), + }, + }) + if err != nil { + log.Fatalln(err.Error()) + } + + log.Println("Collection Updated Successfully!") +}