Skip to content

Commit

Permalink
Add support for custom properties (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasbindreiter authored Nov 18, 2024
1 parent 736a3df commit b16e50f
Show file tree
Hide file tree
Showing 11 changed files with 217 additions and 42 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ contactID, err := client.CreateContact(ctx, &loops.Contact{
LastName: loops.String("Armstrong"),
UserGroup: loops.String("Astronauts"),
Subscribed: true,
// custom user defined properties for contacts
CustomProperties: map[string]interface{}{
"role": "Astronaut",
},
})
if err != nil {
slog.Error("failed to create contact", slog.Any("error", err.Error()))
Expand Down
27 changes: 22 additions & 5 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func newRecordTestClient(t *testing.T, recordingFile string) *Client { //nolint:
recorder, err := httpreplay.NewRecorder(path.Join(TestdataDir(), recordingFile), nil)
require.NoError(t, err)
t.Cleanup(func() { _ = recorder.Close() })
client, err := NewClient(WithAPIKey("LOOPS_API_KEY"), WithHTTPClient(recorder.Client()))
client, err := NewClient(WithAPIKey("90b73b0acdfbe5526bdd5af254fc56ca"), WithHTTPClient(recorder.Client()))
require.NoError(t, err)
return client
}
Expand All @@ -51,9 +51,12 @@ func TestCreateContact(t *testing.T) {
LastName: String("User"),
UserID: String("user_123"),
Subscribed: true,
CustomProperties: map[string]interface{}{
"companyRole": "Developer",
},
})
require.NoError(t, err)
assert.Equal(t, "cm3n47oh7010hpt4megzzqujt", contactID)
assert.Equal(t, "cm3n4kiua02c0t839btycnwe1", contactID)
}

func TestUpdateContact(t *testing.T) {
Expand All @@ -66,7 +69,7 @@ func TestUpdateContact(t *testing.T) {
Subscribed: true,
})
require.NoError(t, err)
assert.Equal(t, "cm3n47oh7010hpt4megzzqujt", contactID)
assert.Equal(t, "cm3n4kiua02c0t839btycnwe1", contactID)
}

func TestFindContact(t *testing.T) {
Expand All @@ -75,11 +78,18 @@ func TestFindContact(t *testing.T) {
Email: String("new-test-mail@example.com"),
})
require.NoError(t, err)
assert.Equal(t, "cm3n47oh7010hpt4megzzqujt", contact.ID)
assert.Equal(t, "cm3n4kiua02c0t839btycnwe1", contact.ID)
assert.Equal(t, "new-test-mail@example.com", contact.Email)
assert.Equal(t, "Test", *contact.FirstName)
assert.Equal(t, "User", *contact.LastName)
assert.Equal(t, "user_123", *contact.UserID)

companyRole, ok := contact.CustomProperties["companyRole"]
assert.True(t, ok)
companyRoleStr, ok := companyRole.(string)
assert.True(t, ok)
assert.Equal(t, "Developer", companyRoleStr)

assert.True(t, contact.Subscribed)
}

Expand All @@ -89,11 +99,18 @@ func TestFindContactByID(t *testing.T) {
UserID: String("user_123"),
})
require.NoError(t, err)
assert.Equal(t, "cm3n47oh7010hpt4megzzqujt", contact.ID)
assert.Equal(t, "cm3n4kiua02c0t839btycnwe1", contact.ID)
assert.Equal(t, "new-test-mail@example.com", contact.Email)
assert.Equal(t, "Test", *contact.FirstName)
assert.Equal(t, "User", *contact.LastName)
assert.Equal(t, "user_123", *contact.UserID)

companyRole, ok := contact.CustomProperties["companyRole"]
assert.True(t, ok)
companyRoleStr, ok := companyRole.(string)
assert.True(t, ok)
assert.Equal(t, "Developer", companyRoleStr)

assert.True(t, contact.Subscribed)
}

Expand Down
3 changes: 3 additions & 0 deletions examples/contact-crud/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ func main() {
FirstName: loops.String("Neil"),
LastName: loops.String("Armstrong"),
Subscribed: true,
CustomProperties: map[string]interface{}{ // custom user defined properties for contacts
"role": "Astronaut",
},
})
if err != nil {
slog.Error("failed to create contact", slog.Any("error", err.Error()))
Expand Down
109 changes: 108 additions & 1 deletion models.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package loops

import (
"encoding/json"
"errors"
)

// String returns a pointer to the string value passed in.
func String(v string) *string {
return &v
Expand All @@ -24,7 +29,109 @@ type Contact struct {
// A unique user ID (for example, from an external application).
UserID *string `json:"userId,omitempty"`
// Mailing lists the contact is subscribed to.
MailingLists map[string]interface{} `json:"mailingLists,omitempty"`
MailingLists map[string]bool `json:"mailingLists,omitempty"`
// Custom properties for the contact.
CustomProperties map[string]interface{} `json:"-"` // there is no "customProperties", we need to inline add them to the json
}

// MarshalJSON overrides the default json marshaller to add custom properties inline to the root object
func (c *Contact) MarshalJSON() ([]byte, error) {
data := map[string]interface{}{
"id": c.ID,
"email": c.Email,
"subscribed": c.Subscribed,
}
if c.FirstName != nil {
data["firstName"] = *c.FirstName
}
if c.LastName != nil {
data["lastName"] = *c.LastName
}
if c.Source != nil {
data["source"] = *c.Source
}
if c.UserGroup != nil {
data["userGroup"] = *c.UserGroup
}
if c.UserID != nil {
data["userId"] = *c.UserID
}
if c.MailingLists != nil {
data["mailingLists"] = c.MailingLists
}
for k, v := range c.CustomProperties {
data[k] = v
}
return json.Marshal(data)
}

// UnmarshalJSON overrides the default json unmarshaller to add custom properties inline to the root object
func (c *Contact) UnmarshalJSON(data []byte) error {
values := map[string]interface{}{}
if err := json.Unmarshal(data, &values); err != nil {
return err
}

if id, ok := values["id"].(string); ok {
c.ID = id
delete(values, "id")
} else {
return errors.New("missing or invalid 'id' field")
}

if email, ok := values["email"].(string); ok {
c.Email = email
delete(values, "email")
} else {
return errors.New("missing or invalid 'email' field")
}

if subscribed, ok := values["subscribed"].(bool); ok {
c.Subscribed = subscribed
delete(values, "subscribed")
} else {
return errors.New("missing or invalid 'subscribed' field")
}

if firstName, ok := values["firstName"].(string); ok {
c.FirstName = &firstName
delete(values, "firstName")
}

if lastName, ok := values["lastName"].(string); ok {
c.LastName = &lastName
delete(values, "lastName")
}

if source, ok := values["source"].(string); ok {
c.Source = &source
delete(values, "source")
}

if userGroup, ok := values["userGroup"].(string); ok {
c.UserGroup = &userGroup
delete(values, "userGroup")
}

if userID, ok := values["userId"].(string); ok {
c.UserID = &userID
delete(values, "userId")
}

mailingLists, ok := values["mailingLists"].(map[string]interface{})
if ok {
c.MailingLists = make(map[string]bool)
for k, v := range mailingLists {
c.MailingLists[k] = v.(bool)
}
delete(values, "mailingLists")
}

c.CustomProperties = make(map[string]interface{})
for k, v := range values {
c.CustomProperties[k] = v
}
return nil
}

type ContactIdentifier struct {
Expand Down
44 changes: 44 additions & 0 deletions models_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package loops

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestContactMarshalJSONCustomPropertiesInlined(t *testing.T) {
c := Contact{
ID: "123",
Email: "test@example.com",
Subscribed: true,
MailingLists: map[string]bool{
"list_123": true,
},
CustomProperties: map[string]interface{}{
"favoriteColor": "blue",
},
}
data, err := json.Marshal(&c)
require.NoError(t, err)
assert.JSONEq(t, `{"id":"123","email":"test@example.com","subscribed":true,"favoriteColor":"blue","mailingLists":{"list_123":true}}`, string(data))
}

func TestContactUnmarshalJSONCustomPropertiesInlined(t *testing.T) {
c := Contact{}

data := []byte(`{"id":"123","email":"test@example.com","subscribed":true,"favoriteColor":"blue","firstName":"John","lastName":"Doe","mailingLists":{"list_123":true}}`)
err := json.Unmarshal(data, &c)
require.NoError(t, err)
assert.Equal(t, "123", c.ID)
assert.Equal(t, "test@example.com", c.Email)
assert.True(t, c.Subscribed)
assert.Equal(t, "blue", c.CustomProperties["favoriteColor"])
assert.Equal(t, "John", *c.FirstName)
assert.Equal(t, "Doe", *c.LastName)
require.Len(t, c.MailingLists, 1)
list123, ok := c.MailingLists["list_123"]
assert.True(t, ok)
assert.True(t, list123)
}
16 changes: 8 additions & 8 deletions testdata/create-contact.replay.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
},
"Entries": [
{
"ID": "9482f4654381a270",
"ID": "f9c686557906d4a1",
"Request": {
"Method": "POST",
"URL": "https://app.loops.so/api/v1/contacts/create",
Expand All @@ -39,15 +39,15 @@
"gzip"
],
"Content-Length": [
"103"
"137"
],
"User-Agent": [
"Go-http-client/1.1"
]
},
"MediaType": "application/json",
"BodyParts": [
"eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJmaXJzdE5hbWUiOiJUZXN0IiwibGFzdE5hbWUiOiJVc2VyIiwic3Vic2NyaWJlZCI6dHJ1ZSwidXNlcklkIjoidXNlcl8xMjMifQ=="
"eyJjb21wYW55Um9sZSI6IkRldmVsb3BlciIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImZpcnN0TmFtZSI6IlRlc3QiLCJpZCI6IiIsImxhc3ROYW1lIjoiVXNlciIsInN1YnNjcmliZWQiOnRydWUsInVzZXJJZCI6InVzZXJfMTIzIn0="
]
},
"Response": {
Expand Down Expand Up @@ -75,7 +75,7 @@
"DYNAMIC"
],
"Cf-Ray": [
"8e489d0f8fc85a60-VIE"
"8e48dffe6dce5aaf-VIE"
],
"Content-Encoding": [
"gzip"
Expand All @@ -87,10 +87,10 @@
"application/json; charset=utf-8"
],
"Date": [
"Mon, 18 Nov 2024 14:22:37 GMT"
"Mon, 18 Nov 2024 15:08:18 GMT"
],
"Etag": [
"W/\"zbn3650bvy1d\""
"W/\"oai0fa27xa1d\""
],
"Permissions-Policy": [
"accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()"
Expand Down Expand Up @@ -120,10 +120,10 @@
"MISS"
],
"X-Vercel-Id": [
"fra1::iad1::7f9km-1731939755479-0306be4457e8"
"fra1::iad1::p46k7-1731942497167-d5c57a669dd8"
]
},
"Body": "H4sIAAAAAAAAA6pWKi5NTk4tLlayKikqTdVRykxRslJKzjXOMzHPzzA3MDTIKCgxyU1Nr6oqLM0qUaoFAAAA//8DAP/EpzQxAAAA"
"Body": "H4sIAAAAAAAAA6pWKi5NTk4tLlayKikqTdVRykxRslJKzjXOM8nOLE00MEo2KLEwtkwqqUzOK081VKoFAAAA//8DAKOElQ0xAAAA"
}
}
]
Expand Down
8 changes: 4 additions & 4 deletions testdata/delete-contact.replay.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
},
"Entries": [
{
"ID": "28c142b3cdee20e4",
"ID": "024bf65228970fa0",
"Request": {
"Method": "POST",
"URL": "https://app.loops.so/api/v1/contacts/delete",
Expand Down Expand Up @@ -75,7 +75,7 @@
"DYNAMIC"
],
"Cf-Ray": [
"8e48a52b1a7a5c11-VIE"
"8e48e25f7eabc2f5-VIE"
],
"Content-Length": [
"45"
Expand All @@ -87,7 +87,7 @@
"application/json; charset=utf-8"
],
"Date": [
"Mon, 18 Nov 2024 14:28:08 GMT"
"Mon, 18 Nov 2024 15:09:55 GMT"
],
"Etag": [
"\"ah8yi3hmwo19\""
Expand Down Expand Up @@ -123,7 +123,7 @@
"MISS"
],
"X-Vercel-Id": [
"fra1::iad1::6vtpk-1731940087677-5b958085fa1a"
"fra1::iad1::tlxlc-1731942594542-0551c093e4b8"
]
},
"Body": "eyJzdWNjZXNzIjp0cnVlLCJtZXNzYWdlIjoiQ29udGFjdCBkZWxldGVkLiJ9"
Expand Down
Loading

0 comments on commit b16e50f

Please sign in to comment.