diff --git a/cmd/contacts.go b/cmd/contacts.go index 8296db1..d443d9e 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -1,8 +1,13 @@ package cmd import ( + "encoding/json" "fmt" + "math" + "regexp" "strconv" + "strings" + "unicode" "github.com/loops-so/cli/internal/api" "github.com/loops-so/cli/internal/cmdutil" @@ -121,26 +126,76 @@ var contactsFindCmd = &cobra.Command{ return nil } - w := newTableWriter(cmd.OutOrStdout()) - fmt.Fprintln(w, "USER ID\tEMAIL\tFIRST NAME\tLAST NAME\tSUBSCRIBED\tSOURCE\tUSER GROUP\tOPT-IN STATUS") - for _, c := range contacts { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - deref(c.UserID), - c.Email, - deref(c.FirstName), - deref(c.LastName), - strconv.FormatBool(c.Subscribed), - c.Source, - c.UserGroup, - deref(c.OptInStatus), - ) + for i, c := range contacts { + props := c.Properties() + keys := c.PropertyKeys() + labels := make([]string, len(keys)) + maxLabelWidth := 0 + for i, key := range keys { + label := addSpacesToCamelCase(key, false) + labels[i] = label + if len(label) > maxLabelWidth { + maxLabelWidth = len(label) + } + } + for idx, key := range keys { + padding := maxLabelWidth - len(labels[idx]) + 1 + fmt.Fprintf(cmd.OutOrStdout(), "%s:%*s%s\n", labels[idx], padding, "", formatContactTableValue(props[key])) + } + if i < len(contacts)-1 { + fmt.Fprintln(cmd.OutOrStdout()) + } } - w.Flush() return nil }, } +var camelCaseMatcher = regexp.MustCompile("([A-Z])") + +func formatContactTableValue(value any) string { + switch v := value.(type) { + case nil: + return "null" + case string: + return v + case bool: + return strconv.FormatBool(v) + case float64: + if math.Trunc(v) == v { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'f', -1, 64) + default: + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) + } +} + +func addSpacesToCamelCase(value string, lowerCase bool) string { + withSpaces := strings.TrimSpace(camelCaseMatcher.ReplaceAllString(value, " $1")) + if withSpaces == "" { + return withSpaces + } + + runes := []rune(withSpaces) + runes[0] = unicode.ToUpper(runes[0]) + result := string(runes) + + if !lowerCase { + return result + } + + parts := strings.Fields(result) + for i := range parts { + parts[i] = strings.ToLower(parts[i]) + } + return strings.Join(parts, " ") +} + // create type contactCreateResult struct { diff --git a/cmd/contacts_find_test.go b/cmd/contacts_find_test.go index 4b6609c..4428b97 100644 --- a/cmd/contacts_find_test.go +++ b/cmd/contacts_find_test.go @@ -1,7 +1,9 @@ package cmd import ( + "encoding/json" "net/http" + "reflect" "testing" "github.com/loops-so/cli/internal/api" @@ -68,3 +70,84 @@ func TestRunContactsFind(t *testing.T) { } }) } + +func TestContactPropertyKeys(t *testing.T) { + var contacts []api.Contact + body := `[{"id":"cnt_abc123","email":"bob@example.com","subscribed":true,"mailingLists":{},"thisKey":"thisValue","plan":"pro","score":42,"isActive":false}]` + if err := json.Unmarshal([]byte(body), &contacts); err != nil { + t.Fatalf("failed to decode contact test fixture: %v", err) + } + + got := contacts[0].PropertyKeys() + want := []string{ + "id", + "email", + "subscribed", + "mailingLists", + "thisKey", + "plan", + "score", + "isActive", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("PropertyKeys() = %v, want %v", got, want) + } + + props := contacts[0].Properties() + if props["thisKey"] != "thisValue" { + t.Fatalf("thisKey = %v, want thisValue", props["thisKey"]) + } + if props["plan"] != "pro" { + t.Fatalf("plan = %v, want pro", props["plan"]) + } + if props["score"] != float64(42) { + t.Fatalf("score = %v, want 42", props["score"]) + } + if props["isActive"] != false { + t.Fatalf("isActive = %v, want false", props["isActive"]) + } +} + +func TestFormatContactTableValue(t *testing.T) { + tests := []struct { + name string + value any + want string + }{ + {name: "nil", value: nil, want: "null"}, + {name: "string", value: "abc", want: "abc"}, + {name: "bool", value: true, want: "true"}, + {name: "whole number", value: float64(42), want: "42"}, + {name: "decimal", value: float64(42.5), want: "42.5"}, + {name: "object", value: map[string]any{"a": "b"}, want: `{"a":"b"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatContactTableValue(tt.value); got != tt.want { + t.Fatalf("formatContactTableValue(%v) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} + +func TestAddSpacesToCamelCase(t *testing.T) { + tests := []struct { + name string + input string + lowerCase bool + want string + }{ + {name: "camel case", input: "firstName", want: "First Name"}, + {name: "single word", input: "email", want: "Email"}, + {name: "lowercase words", input: "optInStatus", lowerCase: true, want: "opt in status"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := addSpacesToCamelCase(tt.input, tt.lowerCase); got != tt.want { + t.Fatalf("addSpacesToCamelCase(%q, %v) = %q, want %q", tt.input, tt.lowerCase, got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod index 9af60d0..733ef83 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/loops-so/cli -go 1.26.1 +go 1.26.2 require ( github.com/spf13/cobra v1.10.2 diff --git a/internal/api/contacts.go b/internal/api/contacts.go index 45785e8..06e063c 100644 --- a/internal/api/contacts.go +++ b/internal/api/contacts.go @@ -18,6 +18,150 @@ type Contact struct { UserID *string `json:"userId"` MailingLists map[string]bool `json:"mailingLists"` OptInStatus *string `json:"optInStatus"` + properties map[string]any + propertyKeys []string +} + +func (c *Contact) UnmarshalJSON(data []byte) error { + type contactAlias Contact + var alias contactAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + + var props map[string]any + if err := json.Unmarshal(data, &props); err != nil { + return err + } + keys, err := jsonObjectKeysInOrder(data) + if err != nil { + return err + } + + *c = Contact(alias) + c.properties = props + c.propertyKeys = keys + return nil +} + +func (c Contact) MarshalJSON() ([]byte, error) { + if len(c.properties) > 0 && len(c.propertyKeys) > 0 { + var buf bytes.Buffer + buf.WriteByte('{') + for i, key := range c.propertyKeys { + if i > 0 { + buf.WriteByte(',') + } + keyJSON, err := json.Marshal(key) + if err != nil { + return nil, err + } + valJSON, err := json.Marshal(c.properties[key]) + if err != nil { + return nil, err + } + buf.Write(keyJSON) + buf.WriteByte(':') + buf.Write(valJSON) + } + buf.WriteByte('}') + return buf.Bytes(), nil + } + + if len(c.properties) > 0 { + return json.Marshal(c.properties) + } + + props := c.Properties() + return json.Marshal(props) +} + +func (c Contact) Properties() map[string]any { + if len(c.properties) > 0 { + clone := make(map[string]any, len(c.properties)) + for k, v := range c.properties { + clone[k] = v + } + return clone + } + + props := map[string]any{ + "id": c.ID, + "email": c.Email, + "source": c.Source, + "subscribed": c.Subscribed, + "userGroup": c.UserGroup, + "mailingLists": c.MailingLists, + } + if c.FirstName != nil { + props["firstName"] = *c.FirstName + } else { + props["firstName"] = nil + } + if c.LastName != nil { + props["lastName"] = *c.LastName + } else { + props["lastName"] = nil + } + if c.UserID != nil { + props["userId"] = *c.UserID + } else { + props["userId"] = nil + } + if c.OptInStatus != nil { + props["optInStatus"] = *c.OptInStatus + } else { + props["optInStatus"] = nil + } + + return props +} + +func (c Contact) PropertyKeys() []string { + if len(c.propertyKeys) > 0 { + keys := make([]string, len(c.propertyKeys)) + copy(keys, c.propertyKeys) + return keys + } + keys := make([]string, 0, len(c.properties)) + for key := range c.properties { + keys = append(keys, key) + } + return keys +} + +func jsonObjectKeysInOrder(data []byte) ([]string, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + tok, err := dec.Token() + if err != nil { + return nil, err + } + delim, ok := tok.(json.Delim) + if !ok || delim != '{' { + return nil, fmt.Errorf("expected JSON object") + } + + keys := make([]string, 0) + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, err + } + key, ok := keyTok.(string) + if !ok { + return nil, fmt.Errorf("expected JSON object key") + } + keys = append(keys, key) + + var ignored json.RawMessage + if err := dec.Decode(&ignored); err != nil { + return nil, err + } + } + if _, err := dec.Token(); err != nil { + return nil, err + } + return keys, nil } type CreateContactRequest struct { diff --git a/internal/api/contacts_test.go b/internal/api/contacts_test.go index d329097..c764fdf 100644 --- a/internal/api/contacts_test.go +++ b/internal/api/contacts_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "encoding/json" "errors" "net/http" @@ -600,6 +601,7 @@ func TestFindContacts(t *testing.T) { wantErrMsg string wantCount int wantQuery string + wantProps map[string]any }{ { name: "success by email", @@ -617,6 +619,15 @@ func TestFindContacts(t *testing.T) { wantCount: 1, wantQuery: "userId=user_123", }, + { + name: "preserves custom properties", + params: FindContactParams{Email: "bob@example.com"}, + statusCode: http.StatusOK, + body: `[{"id":"cnt_abc123","email":"bob@example.com","subscribed":true,"mailingLists":{},"thisKey":"thisValue"}]`, + wantCount: 1, + wantQuery: "email=bob%40example.com", + wantProps: map[string]any{"thisKey": "thisValue"}, + }, { name: "empty result", params: FindContactParams{Email: "none@example.com"}, @@ -687,6 +698,37 @@ func TestFindContacts(t *testing.T) { if len(contacts) != tt.wantCount { t.Errorf("len(contacts) = %d, want %d", len(contacts), tt.wantCount) } + if tt.wantProps != nil { + props := contacts[0].Properties() + for k, v := range tt.wantProps { + if props[k] != v { + t.Errorf("contact property %q = %v, want %v", k, props[k], v) + } + } + } }) } } + +func TestContactMarshalJSON_PreservesKeyOrder(t *testing.T) { + input := `{"id":"cnt_abc123","email":"bob@example.com","firstName":"Bob","lastName":"Smith","source":"api","subscribed":true,"userGroup":"default","userId":"user_123","thisKey":"thisValue","mailingLists":{},"optInStatus":null}` + + var contact Contact + if err := json.Unmarshal([]byte(input), &contact); err != nil { + t.Fatalf("failed to unmarshal contact: %v", err) + } + + out, err := json.Marshal(contact) + if err != nil { + t.Fatalf("failed to marshal contact: %v", err) + } + + var compactIn bytes.Buffer + if err := json.Compact(&compactIn, []byte(input)); err != nil { + t.Fatalf("failed to compact input json: %v", err) + } + + if string(out) != compactIn.String() { + t.Fatalf("marshal output order changed:\n got: %s\n want: %s", string(out), compactIn.String()) + } +}