Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom properties #4

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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