From 2b9cb521c6bebfca0cb0069da0a31a836e299ffd Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 23 Dec 2025 21:33:28 +0100 Subject: [PATCH 01/28] Add full API --- .gitignore | 1 + README.md | 40 +++++---- http.go | 98 +++++++++++++++++++++ notification.go | 86 ------------------- notification/api.go | 69 +++++++++++++++ notification/api_test.go | 180 +++++++++++++++++++++++++++++++++++++++ notification/models.go | 76 +++++++++++++++++ notification_test.go | 64 -------------- project/api.go | 72 ++++++++++++++++ project/api_test.go | 145 +++++++++++++++++++++++++++++++ project/models.go | 43 ++++++++++ pushpad.go | 9 +- pushpad_test.go | 18 ++-- sender/api.go | 66 ++++++++++++++ sender/api_test.go | 112 ++++++++++++++++++++++++ sender/models.go | 27 ++++++ signature.go | 13 +-- signature_test.go | 14 +-- subscription/api.go | 128 ++++++++++++++++++++++++++++ subscription/api_test.go | 151 ++++++++++++++++++++++++++++++++ subscription/models.go | 52 +++++++++++ 21 files changed, 1271 insertions(+), 193 deletions(-) create mode 100644 .gitignore create mode 100644 http.go delete mode 100644 notification.go create mode 100644 notification/api.go create mode 100644 notification/api_test.go create mode 100644 notification/models.go delete mode 100644 notification_test.go create mode 100644 project/api.go create mode 100644 project/api_test.go create mode 100644 project/models.go create mode 100644 sender/api.go create mode 100644 sender/api_test.go create mode 100644 sender/models.go create mode 100644 subscription/api.go create mode 100644 subscription/api_test.go create mode 100644 subscription/models.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ceddaa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/README.md b/README.md index 1f7637f..ed5aed4 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,12 @@ Then import the packages: ```go import ( "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/notification" ) ``` +Other resources are available in the `subscription`, `project`, and `sender` packages. + ## Getting started First you need to sign up to Pushpad and create a project there. @@ -30,11 +33,11 @@ First you need to sign up to Pushpad and create a project there. Then set your authentication credentials and project: ```go -pushpad.Configure("AUTH_TOKEN", "PROJECT_ID") +pushpad.Configure("AUTH_TOKEN", 123) ``` - `AUTH_TOKEN` can be found in the user account settings. -- `PROJECT_ID` can be found in the project settings. If your application uses multiple projects, you can set the `ProjectID` as an additional field for `Notification`. +- `PROJECT_ID` can be found in the project settings. If your application uses multiple projects, you can pass the `ProjectID` as a param to functions. ## Collecting user subscriptions to push notifications @@ -50,7 +53,10 @@ fmt.Printf("User ID Signature: %s", s) ## Sending push notifications ```go -n := pushpad.Notification { +n := notification.NotificationCreateParams { + // optional, defaults to the project configured via pushpad.Configure + ProjectID: 0, + // required, the main content of the notification Body: "Hello world!", @@ -97,45 +103,45 @@ n := pushpad.Notification { CustomMetrics: []string{"examples", "another_metric"}, // up to 3 metrics per notification } -res, err := n.Send() +res, err := notification.Create(&n) // TARGETING: // You can use UIDs and Tags for sending the notification only to a specific audience... // deliver to a user -n := pushpad.Notification { Body: "Hi user1", UIDs: []string{"user1"} } -res, err := n.Send() +n := notification.NotificationCreateParams { Body: "Hi user1", UIDs: []string{"user1"} } +res, err := notification.Create(&n) // deliver to a group of users -n := pushpad.Notification { Body: "Hi users", UIDs: []string{"user1","user2","user3"} } -res, err := n.Send() +n := notification.NotificationCreateParams { Body: "Hi users", UIDs: []string{"user1","user2","user3"} } +res, err := notification.Create(&n) // deliver to some users only if they have a given preference // e.g. only "users" who have a interested in "events" will be reached -n := pushpad.Notification { Body: "New event", UIDs: []string{"user1","user2"}, Tags: []string{"events"} } -res, err := n.Send() +n := notification.NotificationCreateParams { Body: "New event", UIDs: []string{"user1","user2"}, Tags: []string{"events"} } +res, err := notification.Create(&n) // deliver to segments // e.g. any subscriber that has the tag "segment1" OR "segment2" -n := pushpad.Notification { Body: "Example", Tags: []string{"segment1", "segment2"} } -res, err := n.Send() +n := notification.NotificationCreateParams { Body: "Example", Tags: []string{"segment1", "segment2"} } +res, err := notification.Create(&n) // you can use boolean expressions // they can include parentheses and the operators !, &&, || (from highest to lowest precedence) // https://pushpad.xyz/docs/tags -n := pushpad.Notification { Body: "Example", Tags: []string{"zip_code:28865 && !optout:local_events || friend_of:Organizer123"} } -res, err := n.Send() +n := notification.NotificationCreateParams { Body: "Example", Tags: []string{"zip_code:28865 && !optout:local_events || friend_of:Organizer123"} } +res, err := notification.Create(&n) // deliver to everyone -n := pushpad.Notification { Body: "Hello everybody" } -res, err := n.Send() +n := notification.NotificationCreateParams { Body: "Hello everybody" } +res, err := notification.Create(&n) ``` You can set the default values for most fields in the project settings. See also [the docs](https://pushpad.xyz/docs/rest_api#notifications_api_docs) for more information about notification fields. If you try to send a notification to a user ID, but that user is not subscribed, that ID is simply ignored. -The methods above return a `NotificationResponse struct`: +The methods above return a `NotificationCreateResponse struct`: - `ID` is the id of the notification on Pushpad - `Scheduled` is the estimated reach of the notification (i.e. the number of devices to which the notification will be sent, which can be different from the number of users, since a user may receive notifications on multiple devices) diff --git a/http.go b/http.go new file mode 100644 index 0000000..d9c9b34 --- /dev/null +++ b/http.go @@ -0,0 +1,98 @@ +package pushpad + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const DefaultBaseURL = "https://pushpad.xyz/api/v1" + +// APIError represents a non-2xx API response. +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + if e.Body == "" { + return fmt.Sprintf("pushpad: unexpected status code %d", e.StatusCode) + } + return fmt.Sprintf("pushpad: status %d: %s", e.StatusCode, e.Body) +} + +// ResolveProjectID returns the provided project ID or the configured default project ID. +func ResolveProjectID(projectID int) (int, error) { + if projectID != 0 { + return projectID, nil + } + if pushpadProjectID == 0 { + return 0, fmt.Errorf("pushpad: project ID is required") + } + return pushpadProjectID, nil +} + +// DoRequest performs an HTTP request against the Pushpad API. +func DoRequest(method, path string, query url.Values, body any, okStatuses []int, out any) (*http.Response, error) { + ctx := context.Background() + baseURL := strings.TrimRight(DefaultBaseURL, "/") + endpoint := baseURL + path + if len(query) > 0 { + endpoint += "?" + query.Encode() + } + + var bodyReader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewBuffer(payload) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if pushpadAuthToken != "" { + req.Header.Set("Authorization", "Bearer "+pushpadAuthToken) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + ok := false + for _, code := range okStatuses { + if res.StatusCode == code { + ok = true + break + } + } + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + if !ok { + return res, &APIError{StatusCode: res.StatusCode, Body: string(bodyBytes)} + } + + if out != nil && len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out); err != nil { + return res, err + } + } + + return res, nil +} diff --git a/notification.go b/notification.go deleted file mode 100644 index c2f5273..0000000 --- a/notification.go +++ /dev/null @@ -1,86 +0,0 @@ -package pushpad - -import ( - "fmt" - "bytes" - "io" - "encoding/json" - "net/http" - "time" -) - -type Notification struct { - ProjectID string `json:"-"` - Body string `json:"body"` - Title string `json:"title,omitempty"` - TargetURL string `json:"target_url,omitempty"` - IconURL string `json:"icon_url,omitempty"` - BadgeURL string `json:"badge_url,omitempty"` - ImageURL string `json:"image_url,omitempty"` - TTL int `json:"ttl,omitempty"` - RequireInteraction bool `json:"require_interaction,omitempty"` - Silent bool `json:"silent,omitempty"` - Urgent bool `json:"urgent,omitempty"` - CustomData string `json:"custom_data,omitempty"` - CustomMetrics []string `json:"custom_metrics,omitempty"` - Starred bool `json:"starred,omitempty"` - SendAt *time.Time `json:"send_at,omitempty"` - UIDs []string `json:"uids"` - Tags []string `json:"tags"` -} - -type NotificationResponse struct { - ID int `json:"id"` - Scheduled int `json:"scheduled"` - UIDs []string `json:"uids"` - SendAt time.Time `json:"send_at"` -} - -func (n Notification) Send() (*NotificationResponse, error) { - if n.ProjectID == "" { - n.ProjectID = pushpadProjectID - } - - notificationJSON, err := json.Marshal(n) - - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", "https://pushpad.xyz/api/v1/projects/" + n.ProjectID + "/notifications", bytes.NewBuffer(notificationJSON)) - - if err != nil { - return nil, err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Token token=\"" + pushpadAuthToken + "\"") - - client := &http.Client{} - - res, err := client.Do(req) - - if err != nil { - return nil, err - } - - defer res.Body.Close() - - bodyBytes, err := io.ReadAll(res.Body) - - if err != nil { - return nil, err - } - - bodyString := string(bodyBytes) - - if res.StatusCode != 201 { - return nil, fmt.Errorf("Response was HTTP %d: %s", res.StatusCode, bodyString) - } - - var r *NotificationResponse - json.Unmarshal(bodyBytes, &r) - - return r, nil -} diff --git a/notification/api.go b/notification/api.go new file mode 100644 index 0000000..2dbffae --- /dev/null +++ b/notification/api.go @@ -0,0 +1,69 @@ +package notification + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/pushpad/pushpad-go" +) + +func List(params *NotificationListParams) ([]Notification, error) { + projectID := 0 + if params != nil { + projectID = params.ProjectID + } + projectID, err := pushpad.ResolveProjectID(projectID) + if err != nil { + return nil, err + } + + query := url.Values{} + if params != nil && params.Page > 0 { + query.Set("page", strconv.Itoa(params.Page)) + } + + var notifications []Notification + _, err = pushpad.DoRequest("GET", fmt.Sprintf("/projects/%d/notifications", projectID), query, nil, []int{200}, ¬ifications) + return notifications, err +} + +func Create(notification *NotificationCreateParams) (*NotificationCreateResponse, error) { + if notification == nil { + return nil, fmt.Errorf("pushpad: notification is required") + } + if notification.Body == "" { + return nil, fmt.Errorf("pushpad: notification body is required") + } + projectID, err := pushpad.ResolveProjectID(notification.ProjectID) + if err != nil { + return nil, err + } + + var response NotificationCreateResponse + _, err = pushpad.DoRequest("POST", fmt.Sprintf("/projects/%d/notifications", projectID), nil, notification, []int{201}, &response) + if err != nil { + return nil, err + } + return &response, nil +} + +func Get(notificationID int) (*Notification, error) { + if notificationID == 0 { + return nil, fmt.Errorf("pushpad: notification ID is required") + } + var notification Notification + _, err := pushpad.DoRequest("GET", fmt.Sprintf("/notifications/%d", notificationID), nil, nil, []int{200}, ¬ification) + if err != nil { + return nil, err + } + return ¬ification, nil +} + +func Cancel(notificationID int) error { + if notificationID == 0 { + return fmt.Errorf("pushpad: notification ID is required") + } + _, err := pushpad.DoRequest("DELETE", fmt.Sprintf("/notifications/%d/cancel", notificationID), nil, nil, []int{204}, nil) + return err +} diff --git a/notification/api_test.go b/notification/api_test.go new file mode 100644 index 0000000..1b6ec66 --- /dev/null +++ b/notification/api_test.go @@ -0,0 +1,180 @@ +package notification + +import ( + "encoding/json" + "testing" + + "github.com/h2non/gock" + "github.com/pushpad/pushpad-go" +) + +func TestListNotifications(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects/123/notifications"). + MatchParam("page", "2"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`[{"id":1,"body":"Hi"}]`) + + pushpad.Configure("TOKEN", 0) + notifications, err := List(&NotificationListParams{ProjectID: 123, Page: 2}) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + if notifications[0].ID != 1 { + t.Errorf("expected notification ID 1, got %d", notifications[0].ID) + } +} + +func TestListNotificationsDefaultPage(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects/123/notifications"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`[]`) + + pushpad.Configure("TOKEN", 0) + notifications, err := List(&NotificationListParams{ProjectID: 123}) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if len(notifications) != 0 { + t.Fatalf("expected 0 notifications, got %d", len(notifications)) + } +} + +func TestCreateNotification(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects/123/notifications"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(201). + BodyString(`{"id":99,"scheduled":10}`) + + pushpad.Configure("TOKEN", 0) + response, err := Create(&NotificationCreateParams{ProjectID: 123, Body: "Hello"}) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if response.ID != 99 { + t.Errorf("expected notification ID 99, got %d", response.ID) + } + if response.Scheduled == nil || *response.Scheduled != 10 { + t.Errorf("expected scheduled count 10, got %v", response.Scheduled) + } +} + +func TestCreateNotificationMissingBody(t *testing.T) { + pushpad.Configure("TOKEN", 0) + _, err := Create(&NotificationCreateParams{ProjectID: 123}) + if err == nil { + t.Fatalf("expected error for missing body") + } +} + +func TestNotificationSend(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects/123/notifications"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Accept", "application/json"). + MatchHeader("Authorization", "Bearer AUTH_TOKEN"). + Reply(201). + BodyString("{\"id\": 123456789, \"scheduled\": 98765}") + + pushpad.Configure("AUTH_TOKEN", 0) + + n := NotificationCreateParams{ProjectID: 123, Body: "Hello world!"} + res, err := Create(&n) + + if err != nil { + t.Fatalf("got an error: %s", err) + } + + if res.ID != 123456789 { + t.Errorf("got ID: %d, want ID: 123456789", res.ID) + } +} + +func TestNotificationWithUIDs(t *testing.T) { + n := NotificationCreateParams{Body: "Hello user1", UIDs: []string{"user1"}} + notificationJSON, err := json.Marshal(n) + + if err != nil { + t.Fatalf("got an error: %s", err) + } + + got := string(notificationJSON) + want := `{"body":"Hello user1","uids":["user1"],"tags":null}` + + if got != want { + t.Fatalf("got: %q, want: %q", got, want) + } +} + +func TestNotificationWithTags(t *testing.T) { + n := NotificationCreateParams{Body: "Hello tag1", Tags: []string{"tag1"}} + notificationJSON, err := json.Marshal(n) + + if err != nil { + t.Fatalf("got an error: %s", err) + } + + got := string(notificationJSON) + want := `{"body":"Hello tag1","uids":null,"tags":["tag1"]}` + + if got != want { + t.Fatalf("got: %q, want: %q", got, want) + } +} + +func TestGetNotification(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/notifications/77"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":77,"body":"Hello"}`) + + pushpad.Configure("TOKEN", 123) + notification, err := Get(77) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if notification.ID != 77 { + t.Errorf("expected notification ID 77, got %d", notification.ID) + } +} + +func TestCancelNotification(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Delete("/api/v1/notifications/77/cancel"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(204) + + pushpad.Configure("TOKEN", 123) + if err := Cancel(77); err != nil { + t.Fatalf("expected no error, got %s", err) + } +} + +func TestListNotificationsMissingProjectID(t *testing.T) { + pushpad.Configure("TOKEN", 0) + _, err := List(nil) + if err == nil { + t.Fatalf("expected error for missing project ID") + } +} diff --git a/notification/models.go b/notification/models.go new file mode 100644 index 0000000..bbd9b46 --- /dev/null +++ b/notification/models.go @@ -0,0 +1,76 @@ +package notification + +import "time" + +// NotificationAction represents a notification action button. +type NotificationAction struct { + Title string `json:"title,omitempty"` + TargetURL string `json:"target_url,omitempty"` + Icon string `json:"icon,omitempty"` + Action string `json:"action,omitempty"` +} + +// Notification represents a Pushpad notification. +type Notification struct { + ID int `json:"id,omitempty"` + ProjectID int `json:"project_id,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + TargetURL string `json:"target_url,omitempty"` + IconURL string `json:"icon_url,omitempty"` + BadgeURL string `json:"badge_url,omitempty"` + ImageURL string `json:"image_url,omitempty"` + TTL *int `json:"ttl,omitempty"` + RequireInteraction *bool `json:"require_interaction,omitempty"` + Silent *bool `json:"silent,omitempty"` + Urgent *bool `json:"urgent,omitempty"` + CustomData string `json:"custom_data,omitempty"` + Actions []NotificationAction `json:"actions,omitempty"` + Starred *bool `json:"starred,omitempty"` + SendAt *time.Time `json:"send_at,omitempty"` + CustomMetrics []string `json:"custom_metrics,omitempty"` + UIDs []string `json:"uids"` + Tags []string `json:"tags"` + CreatedAt *time.Time `json:"created_at,omitempty"` + SuccessfullySent *int `json:"successfully_sent_count,omitempty"` + OpenedCount *int `json:"opened_count,omitempty"` + ScheduledCount *int `json:"scheduled_count,omitempty"` + Scheduled *bool `json:"scheduled,omitempty"` + Cancelled *bool `json:"cancelled,omitempty"` +} + +// NotificationCreateParams represents a notification create payload. +type NotificationCreateParams struct { + ProjectID int `json:"-"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + TargetURL string `json:"target_url,omitempty"` + IconURL string `json:"icon_url,omitempty"` + BadgeURL string `json:"badge_url,omitempty"` + ImageURL string `json:"image_url,omitempty"` + TTL *int `json:"ttl,omitempty"` + RequireInteraction *bool `json:"require_interaction,omitempty"` + Silent *bool `json:"silent,omitempty"` + Urgent *bool `json:"urgent,omitempty"` + CustomData string `json:"custom_data,omitempty"` + Actions []NotificationAction `json:"actions,omitempty"` + Starred *bool `json:"starred,omitempty"` + SendAt *time.Time `json:"send_at,omitempty"` + CustomMetrics []string `json:"custom_metrics,omitempty"` + UIDs []string `json:"uids"` + Tags []string `json:"tags"` +} + +// NotificationCreateResponse describes the response to creating a notification. +type NotificationCreateResponse struct { + ID int `json:"id"` + Scheduled *int `json:"scheduled,omitempty"` + UIDs []string `json:"uids,omitempty"` + SendAt *time.Time `json:"send_at,omitempty"` +} + +// NotificationListParams controls notification listing. +type NotificationListParams struct { + ProjectID int + Page int +} diff --git a/notification_test.go b/notification_test.go deleted file mode 100644 index 76c41d7..0000000 --- a/notification_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package pushpad - -import ( - "testing" - "github.com/h2non/gock" - "encoding/json" -) - -func TestNotificationSend(t *testing.T) { - defer gock.Off() - - gock.New("https://pushpad.xyz"). - Post("/api/v1/projects/PROJECT_ID/notifications"). - MatchHeader("Content-Type", "application/json"). - MatchHeader("Accept", "application/json"). - MatchHeader("Authorization", "Token token=\"AUTH_TOKEN\""). - Reply(201). - BodyString("{\"id\": 123456789, \"scheduled\": 98765}") - - Configure("AUTH_TOKEN", "PROJECT_ID") - - n := Notification { Body: "Hello world!" } - res, err := n.Send() - - if err != nil { - t.Errorf("got an error: %s", err) - } - - if res.ID != 123456789 { - t.Errorf("got ID: %d, want ID: 123456789", res.ID) - } -} - -func TestNotificationWithUIDs(t *testing.T) { - n := Notification { Body: "Hello user1", UIDs: []string{"user1"} } - notificationJSON, err := json.Marshal(n) - - if err != nil { - t.Errorf("got an error: %s", err) - } - - got := string(notificationJSON) - want := `{"body":"Hello user1","uids":["user1"],"tags":null}` - - if got != want { - t.Errorf("got: %q, want: %q", got, want) - } -} - -func TestNotificationWithTags(t *testing.T) { - n := Notification { Body: "Hello tag1", Tags: []string{"tag1"} } - notificationJSON, err := json.Marshal(n) - - if err != nil { - t.Errorf("got an error: %s", err) - } - - got := string(notificationJSON) - want := `{"body":"Hello tag1","uids":null,"tags":["tag1"]}` - - if got != want { - t.Errorf("got: %q, want: %q", got, want) - } -} diff --git a/project/api.go b/project/api.go new file mode 100644 index 0000000..0d76366 --- /dev/null +++ b/project/api.go @@ -0,0 +1,72 @@ +package project + +import ( + "fmt" + + "github.com/pushpad/pushpad-go" +) + +func List(params *ProjectListParams) ([]Project, error) { + var projects []Project + _, err := pushpad.DoRequest("GET", "/projects", nil, nil, []int{200}, &projects) + return projects, err +} + +func Create(project *ProjectCreateParams) (*Project, error) { + if project == nil { + return nil, fmt.Errorf("pushpad: project is required") + } + if project.SenderID == 0 { + return nil, fmt.Errorf("pushpad: sender ID is required") + } + if project.Name == "" { + return nil, fmt.Errorf("pushpad: project name is required") + } + if project.Website == "" { + return nil, fmt.Errorf("pushpad: project website is required") + } + + var created Project + _, err := pushpad.DoRequest("POST", "/projects", nil, project, []int{201}, &created) + if err != nil { + return nil, err + } + return &created, nil +} + +func Get(projectID int) (*Project, error) { + if projectID == 0 { + return nil, fmt.Errorf("pushpad: project ID is required") + } + + var project Project + _, err := pushpad.DoRequest("GET", fmt.Sprintf("/projects/%d", projectID), nil, nil, []int{200}, &project) + if err != nil { + return nil, err + } + return &project, nil +} + +func Update(projectID int, update *ProjectUpdateParams) (*Project, error) { + if update == nil { + return nil, fmt.Errorf("pushpad: project update is required") + } + if projectID == 0 { + return nil, fmt.Errorf("pushpad: project ID is required") + } + + var project Project + _, err := pushpad.DoRequest("PATCH", fmt.Sprintf("/projects/%d", projectID), nil, update, []int{200}, &project) + if err != nil { + return nil, err + } + return &project, nil +} + +func Delete(projectID int) error { + if projectID == 0 { + return fmt.Errorf("pushpad: project ID is required") + } + _, err := pushpad.DoRequest("DELETE", fmt.Sprintf("/projects/%d", projectID), nil, nil, []int{202}, nil) + return err +} diff --git a/project/api_test.go b/project/api_test.go new file mode 100644 index 0000000..cc77206 --- /dev/null +++ b/project/api_test.go @@ -0,0 +1,145 @@ +package project + +import ( + "testing" + + "github.com/h2non/gock" + "github.com/pushpad/pushpad-go" +) + +func TestListProjects(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`[{"id":1,"name":"Main"}]`) + + pushpad.Configure("TOKEN", 123) + projects, err := List(nil) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if len(projects) != 1 { + t.Fatalf("expected 1 project, got %d", len(projects)) + } + if projects[0].ID != 1 { + t.Errorf("expected project ID 1, got %d", projects[0].ID) + } +} + +func TestCreateProject(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(201). + BodyString(`{"id":2,"name":"New Project","website":"https://example.com","sender_id":9}`) + + pushpad.Configure("TOKEN", 123) + payload := &ProjectCreateParams{ + SenderID: 9, + Name: "New Project", + Website: "https://example.com", + } + project, err := Create(payload) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if project.ID != 2 { + t.Errorf("expected project ID 2, got %d", project.ID) + } +} + +func TestCreateProjectMissingFields(t *testing.T) { + pushpad.Configure("TOKEN", 123) + _, err := Create(&ProjectCreateParams{}) + if err == nil { + t.Fatalf("expected error for missing fields") + } +} + +func TestGetProject(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects/2"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":2,"name":"New Project"}`) + + pushpad.Configure("TOKEN", 123) + project, err := Get(2) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if project.ID != 2 { + t.Errorf("expected project ID 2, got %d", project.ID) + } +} + +func TestUpdateProject(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Patch("/api/v1/projects/2"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":2,"name":"Updated Project"}`) + + pushpad.Configure("TOKEN", 123) + update := &ProjectUpdateParams{Name: "Updated Project"} + project, err := Update(2, update) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if project.Name != "Updated Project" { + t.Errorf("expected updated name, got %q", project.Name) + } +} + +func TestDeleteProject(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Delete("/api/v1/projects/2"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(202) + + pushpad.Configure("TOKEN", 123) + if err := Delete(2); err != nil { + t.Fatalf("expected no error, got %s", err) + } +} + +func TestAPIErrorOnServerFailure(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(500). + BodyString(`{"error":"boom"}`) + + pushpad.Configure("TOKEN", 123) + _, err := Create(&ProjectCreateParams{ + SenderID: 1, + Name: "Failing Project", + Website: "https://example.com", + }) + if err == nil { + t.Fatalf("expected error") + } + + apiErr, ok := err.(*pushpad.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 500 { + t.Errorf("expected status 500, got %d", apiErr.StatusCode) + } +} diff --git a/project/models.go b/project/models.go new file mode 100644 index 0000000..01e4c9f --- /dev/null +++ b/project/models.go @@ -0,0 +1,43 @@ +package project + +import "time" + +// Project represents a Pushpad project. +type Project struct { + ID int `json:"id,omitempty"` + SenderID int `json:"sender_id,omitempty"` + Name string `json:"name,omitempty"` + Website string `json:"website,omitempty"` + IconURL string `json:"icon_url,omitempty"` + BadgeURL string `json:"badge_url,omitempty"` + NotificationsTTL *int `json:"notifications_ttl,omitempty"` + NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` + NotificationsSilent *bool `json:"notifications_silent,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +// ProjectCreateParams is the payload to create a project. +type ProjectCreateParams struct { + SenderID int `json:"sender_id"` + Name string `json:"name"` + Website string `json:"website"` + IconURL string `json:"icon_url,omitempty"` + BadgeURL string `json:"badge_url,omitempty"` + NotificationsTTL *int `json:"notifications_ttl,omitempty"` + NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` + NotificationsSilent *bool `json:"notifications_silent,omitempty"` +} + +// ProjectUpdateParams is the payload to update a project. +type ProjectUpdateParams struct { + Name string `json:"name,omitempty"` + Website string `json:"website,omitempty"` + IconURL string `json:"icon_url,omitempty"` + BadgeURL string `json:"badge_url,omitempty"` + NotificationsTTL *int `json:"notifications_ttl,omitempty"` + NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` + NotificationsSilent *bool `json:"notifications_silent,omitempty"` +} + +// ProjectListParams controls project listing. +type ProjectListParams struct{} diff --git a/pushpad.go b/pushpad.go index 862591b..b4fe248 100644 --- a/pushpad.go +++ b/pushpad.go @@ -1,9 +1,10 @@ package pushpad var pushpadAuthToken string -var pushpadProjectID string +var pushpadProjectID int -func Configure (authToken string, projectID string) { - pushpadAuthToken = authToken - pushpadProjectID = projectID +// Configure sets the global credentials for API calls. +func Configure(authToken string, projectID int) { + pushpadAuthToken = authToken + pushpadProjectID = projectID } diff --git a/pushpad_test.go b/pushpad_test.go index 3379562..85c8587 100644 --- a/pushpad_test.go +++ b/pushpad_test.go @@ -1,17 +1,17 @@ package pushpad import ( - "testing" + "testing" ) func TestConfigure(t *testing.T) { - Configure("AUTH_TOKEN", "PROJECT_ID") + Configure("AUTH_TOKEN", 123) - if pushpadAuthToken != "AUTH_TOKEN" { - t.Errorf("got %q instead of AUTH_TOKEN", pushpadAuthToken) - } - - if pushpadProjectID != "PROJECT_ID" { - t.Errorf("got %q instead of PROJECT_ID", pushpadProjectID) - } + if pushpadAuthToken != "AUTH_TOKEN" { + t.Errorf("got %q instead of AUTH_TOKEN", pushpadAuthToken) + } + + if pushpadProjectID != 123 { + t.Errorf("got %d instead of project ID 123", pushpadProjectID) + } } diff --git a/sender/api.go b/sender/api.go new file mode 100644 index 0000000..04351b5 --- /dev/null +++ b/sender/api.go @@ -0,0 +1,66 @@ +package sender + +import ( + "fmt" + + "github.com/pushpad/pushpad-go" +) + +func List(params *SenderListParams) ([]Sender, error) { + var senders []Sender + _, err := pushpad.DoRequest("GET", "/senders", nil, nil, []int{200}, &senders) + return senders, err +} + +func Create(sender *SenderCreateParams) (*Sender, error) { + if sender == nil { + return nil, fmt.Errorf("pushpad: sender is required") + } + if sender.Name == "" { + return nil, fmt.Errorf("pushpad: sender name is required") + } + + var created Sender + _, err := pushpad.DoRequest("POST", "/senders", nil, sender, []int{201}, &created) + if err != nil { + return nil, err + } + return &created, nil +} + +func Get(senderID int) (*Sender, error) { + if senderID == 0 { + return nil, fmt.Errorf("pushpad: sender ID is required") + } + + var sender Sender + _, err := pushpad.DoRequest("GET", fmt.Sprintf("/senders/%d", senderID), nil, nil, []int{200}, &sender) + if err != nil { + return nil, err + } + return &sender, nil +} + +func Update(senderID int, update *SenderUpdateParams) (*Sender, error) { + if update == nil { + return nil, fmt.Errorf("pushpad: sender update is required") + } + if senderID == 0 { + return nil, fmt.Errorf("pushpad: sender ID is required") + } + + var sender Sender + _, err := pushpad.DoRequest("PATCH", fmt.Sprintf("/senders/%d", senderID), nil, update, []int{200}, &sender) + if err != nil { + return nil, err + } + return &sender, nil +} + +func Delete(senderID int) error { + if senderID == 0 { + return fmt.Errorf("pushpad: sender ID is required") + } + _, err := pushpad.DoRequest("DELETE", fmt.Sprintf("/senders/%d", senderID), nil, nil, []int{204}, nil) + return err +} diff --git a/sender/api_test.go b/sender/api_test.go new file mode 100644 index 0000000..5f44312 --- /dev/null +++ b/sender/api_test.go @@ -0,0 +1,112 @@ +package sender + +import ( + "testing" + + "github.com/h2non/gock" + "github.com/pushpad/pushpad-go" +) + +func TestListSenders(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/senders"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`[{"id":1,"name":"Sender"}]`) + + pushpad.Configure("TOKEN", 123) + senders, err := List(nil) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if len(senders) != 1 { + t.Fatalf("expected 1 sender, got %d", len(senders)) + } + if senders[0].ID != 1 { + t.Errorf("expected sender ID 1, got %d", senders[0].ID) + } +} + +func TestCreateSender(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/senders"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(201). + BodyString(`{"id":5,"name":"New Sender"}`) + + pushpad.Configure("TOKEN", 123) + sender, err := Create(&SenderCreateParams{Name: "New Sender"}) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if sender.ID != 5 { + t.Errorf("expected sender ID 5, got %d", sender.ID) + } +} + +func TestCreateSenderMissingName(t *testing.T) { + pushpad.Configure("TOKEN", 123) + _, err := Create(&SenderCreateParams{}) + if err == nil { + t.Fatalf("expected error for missing name") + } +} + +func TestGetSender(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/senders/5"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":5,"name":"New Sender"}`) + + pushpad.Configure("TOKEN", 123) + sender, err := Get(5) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if sender.ID != 5 { + t.Errorf("expected sender ID 5, got %d", sender.ID) + } +} + +func TestUpdateSender(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Patch("/api/v1/senders/5"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":5,"name":"Updated Sender"}`) + + pushpad.Configure("TOKEN", 123) + update := &SenderUpdateParams{Name: "Updated Sender"} + sender, err := Update(5, update) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if sender.Name != "Updated Sender" { + t.Errorf("expected updated name, got %q", sender.Name) + } +} + +func TestDeleteSender(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Delete("/api/v1/senders/5"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(204) + + pushpad.Configure("TOKEN", 123) + if err := Delete(5); err != nil { + t.Fatalf("expected no error, got %s", err) + } +} diff --git a/sender/models.go b/sender/models.go new file mode 100644 index 0000000..5b67447 --- /dev/null +++ b/sender/models.go @@ -0,0 +1,27 @@ +package sender + +import "time" + +// Sender represents a Pushpad sender. +type Sender struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + VAPIDPrivateKey string `json:"vapid_private_key,omitempty"` + VAPIDPublicKey string `json:"vapid_public_key,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +// SenderCreateParams is the payload to create a sender. +type SenderCreateParams struct { + Name string `json:"name"` + VAPIDPrivateKey string `json:"vapid_private_key,omitempty"` + VAPIDPublicKey string `json:"vapid_public_key,omitempty"` +} + +// SenderUpdateParams is the payload to update a sender. +type SenderUpdateParams struct { + Name string `json:"name,omitempty"` +} + +// SenderListParams controls sender listing. +type SenderListParams struct{} diff --git a/signature.go b/signature.go index 52f7cab..4552efd 100644 --- a/signature.go +++ b/signature.go @@ -1,13 +1,14 @@ package pushpad import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" ) +// SignatureFor generates the HMAC signature for a user ID using the configured token. func SignatureFor(uid string) string { - h := hmac.New(sha256.New, []byte(pushpadAuthToken)) - h.Write([]byte(uid)) - return hex.EncodeToString(h.Sum(nil)) + h := hmac.New(sha256.New, []byte(pushpadAuthToken)) + h.Write([]byte(uid)) + return hex.EncodeToString(h.Sum(nil)) } diff --git a/signature_test.go b/signature_test.go index f74f304..64d51ed 100644 --- a/signature_test.go +++ b/signature_test.go @@ -1,16 +1,16 @@ package pushpad import ( - "testing" + "testing" ) func TestSignatureFor(t *testing.T) { - Configure("5374d7dfeffa2eb49965624ba7596a09", "123") + Configure("5374d7dfeffa2eb49965624ba7596a09", 123) - got := SignatureFor("user12345") - want := "6627820dab00a1971f2a6d3ff16a5ad8ba4048a02b2d402820afc61aefd0b69f" + got := SignatureFor("user12345") + want := "6627820dab00a1971f2a6d3ff16a5ad8ba4048a02b2d402820afc61aefd0b69f" - if got != want { - t.Errorf("got %q, want %q", got, want) - } + if got != want { + t.Errorf("got %q, want %q", got, want) + } } diff --git a/subscription/api.go b/subscription/api.go new file mode 100644 index 0000000..1e1640c --- /dev/null +++ b/subscription/api.go @@ -0,0 +1,128 @@ +package subscription + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/pushpad/pushpad-go" +) + +func List(params *SubscriptionListParams) ([]Subscription, int, error) { + projectID := 0 + if params != nil { + projectID = params.ProjectID + } + projectID, err := pushpad.ResolveProjectID(projectID) + if err != nil { + return nil, 0, err + } + + query := url.Values{} + if params != nil { + if params.Page > 0 { + query.Set("page", strconv.Itoa(params.Page)) + } + if params.PerPage > 0 { + query.Set("per_page", strconv.Itoa(params.PerPage)) + } + for _, uid := range params.UIDs { + query.Add("uids[]", uid) + } + for _, tag := range params.Tags { + query.Add("tags[]", tag) + } + } + + var subscriptions []Subscription + res, err := pushpad.DoRequest("GET", fmt.Sprintf("/projects/%d/subscriptions", projectID), query, nil, []int{200}, &subscriptions) + if err != nil { + return nil, 0, err + } + + totalCount := 0 + if header := res.Header.Get("X-Total-Count"); header != "" { + if parsed, parseErr := strconv.Atoi(header); parseErr == nil { + totalCount = parsed + } + } + + return subscriptions, totalCount, nil +} + +func Create(subscription *SubscriptionCreateParams) (*Subscription, error) { + if subscription == nil { + return nil, fmt.Errorf("pushpad: subscription is required") + } + if subscription.Endpoint == "" { + return nil, fmt.Errorf("pushpad: subscription endpoint is required") + } + projectID, err := pushpad.ResolveProjectID(subscription.ProjectID) + if err != nil { + return nil, err + } + + var created Subscription + _, err = pushpad.DoRequest("POST", fmt.Sprintf("/projects/%d/subscriptions", projectID), nil, subscription, []int{201}, &created) + if err != nil { + return nil, err + } + return &created, nil +} + +func Get(subscriptionID int, params *SubscriptionGetParams) (*Subscription, error) { + if subscriptionID == 0 { + return nil, fmt.Errorf("pushpad: subscription ID is required") + } + projectID := 0 + if params != nil { + projectID = params.ProjectID + } + projectID, err := pushpad.ResolveProjectID(projectID) + if err != nil { + return nil, err + } + + var subscription Subscription + _, err = pushpad.DoRequest("GET", fmt.Sprintf("/projects/%d/subscriptions/%d", projectID, subscriptionID), nil, nil, []int{200}, &subscription) + if err != nil { + return nil, err + } + return &subscription, nil +} + +func Update(subscriptionID int, update *SubscriptionUpdateParams) (*Subscription, error) { + if update == nil { + return nil, fmt.Errorf("pushpad: subscription update is required") + } + if subscriptionID == 0 { + return nil, fmt.Errorf("pushpad: subscription ID is required") + } + projectID, err := pushpad.ResolveProjectID(update.ProjectID) + if err != nil { + return nil, err + } + + var subscription Subscription + _, err = pushpad.DoRequest("PATCH", fmt.Sprintf("/projects/%d/subscriptions/%d", projectID, subscriptionID), nil, update, []int{200}, &subscription) + if err != nil { + return nil, err + } + return &subscription, nil +} + +func Delete(subscriptionID int, params *SubscriptionDeleteParams) error { + if subscriptionID == 0 { + return fmt.Errorf("pushpad: subscription ID is required") + } + projectID := 0 + if params != nil { + projectID = params.ProjectID + } + projectID, err := pushpad.ResolveProjectID(projectID) + if err != nil { + return err + } + _, err = pushpad.DoRequest("DELETE", fmt.Sprintf("/projects/%d/subscriptions/%d", projectID, subscriptionID), nil, nil, []int{204}, nil) + return err +} diff --git a/subscription/api_test.go b/subscription/api_test.go new file mode 100644 index 0000000..dbabf4b --- /dev/null +++ b/subscription/api_test.go @@ -0,0 +1,151 @@ +package subscription + +import ( + "testing" + + "github.com/h2non/gock" + "github.com/pushpad/pushpad-go" +) + +func TestListSubscriptions(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects/123/subscriptions"). + MatchParam("page", "1"). + MatchParam("per_page", "20"). + MatchParam("uids[]", "u1"). + MatchParam("uids[]", "u2"). + MatchParam("tags[]", "tag1"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + SetHeader("X-Total-Count", "2"). + BodyString(`[{"id":10,"endpoint":"https://example.com/1"},{"id":11,"endpoint":"https://example.com/2"}]`) + + pushpad.Configure("TOKEN", 0) + params := &SubscriptionListParams{ + ProjectID: 123, + Page: 1, + PerPage: 20, + UIDs: []string{"u1", "u2"}, + Tags: []string{"tag1"}, + } + subscriptions, total, err := List(params) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if total != 2 { + t.Errorf("expected total count 2, got %d", total) + } + if len(subscriptions) != 2 { + t.Fatalf("expected 2 subscriptions, got %d", len(subscriptions)) + } + if subscriptions[0].ID != 10 { + t.Errorf("expected subscription ID 10, got %d", subscriptions[0].ID) + } +} + +func TestListSubscriptionsNoOptions(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects/123/subscriptions"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`[]`) + + pushpad.Configure("TOKEN", 0) + subscriptions, total, err := List(&SubscriptionListParams{ProjectID: 123}) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if total != 0 { + t.Errorf("expected total count 0, got %d", total) + } + if len(subscriptions) != 0 { + t.Fatalf("expected 0 subscriptions, got %d", len(subscriptions)) + } +} + +func TestCreateSubscription(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects/123/subscriptions"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(201). + BodyString(`{"id":50,"endpoint":"https://example.com/1"}`) + + pushpad.Configure("TOKEN", 0) + subscription, err := Create(&SubscriptionCreateParams{ProjectID: 123, Endpoint: "https://example.com/1"}) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if subscription.ID != 50 { + t.Errorf("expected subscription ID 50, got %d", subscription.ID) + } +} + +func TestCreateSubscriptionMissingEndpoint(t *testing.T) { + pushpad.Configure("TOKEN", 0) + _, err := Create(&SubscriptionCreateParams{ProjectID: 123}) + if err == nil { + t.Fatalf("expected error for missing endpoint") + } +} + +func TestGetSubscription(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects/123/subscriptions/50"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":50,"endpoint":"https://example.com/1"}`) + + pushpad.Configure("TOKEN", 0) + subscription, err := Get(50, &SubscriptionGetParams{ProjectID: 123}) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if subscription.ID != 50 { + t.Errorf("expected subscription ID 50, got %d", subscription.ID) + } +} + +func TestUpdateSubscription(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Patch("/api/v1/projects/123/subscriptions/50"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":50,"uid":"new-user"}`) + + pushpad.Configure("TOKEN", 0) + uid := "new-user" + update := &SubscriptionUpdateParams{ProjectID: 123, UID: &uid} + subscription, err := Update(50, update) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if subscription.UID != "new-user" { + t.Errorf("expected uid new-user, got %q", subscription.UID) + } +} + +func TestDeleteSubscription(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Delete("/api/v1/projects/123/subscriptions/50"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(204) + + pushpad.Configure("TOKEN", 0) + if err := Delete(50, &SubscriptionDeleteParams{ProjectID: 123}); err != nil { + t.Fatalf("expected no error, got %s", err) + } +} diff --git a/subscription/models.go b/subscription/models.go new file mode 100644 index 0000000..7c45b3d --- /dev/null +++ b/subscription/models.go @@ -0,0 +1,52 @@ +package subscription + +import "time" + +// Subscription represents a Pushpad subscription. +type Subscription struct { + ID int `json:"id,omitempty"` + ProjectID int `json:"project_id,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + P256DH string `json:"p256dh,omitempty"` + Auth string `json:"auth,omitempty"` + UID string `json:"uid,omitempty"` + Tags []string `json:"tags,omitempty"` + LastClickAt *time.Time `json:"last_click_at,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +// SubscriptionCreateParams is the payload to create a subscription. +type SubscriptionCreateParams struct { + ProjectID int `json:"-"` + Endpoint string `json:"endpoint"` + P256DH string `json:"p256dh,omitempty"` + Auth string `json:"auth,omitempty"` + UID string `json:"uid,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +// SubscriptionUpdateParams is the payload to update a subscription. +type SubscriptionUpdateParams struct { + ProjectID int `json:"-"` + UID *string `json:"uid,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +// SubscriptionListParams controls subscription listing. +type SubscriptionListParams struct { + ProjectID int + Page int + PerPage int + UIDs []string + Tags []string +} + +// SubscriptionGetParams controls subscription fetches. +type SubscriptionGetParams struct { + ProjectID int +} + +// SubscriptionDeleteParams controls subscription deletes. +type SubscriptionDeleteParams struct { + ProjectID int +} From 5c855ae889c55da1578341e2748dbe81058bd45e Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 14:37:33 +0100 Subject: [PATCH 02/28] Make sure that all resource methods include a params argument For consistency and to avoid breaking changes in the future --- notification/api.go | 4 ++-- notification/api_test.go | 4 ++-- notification/models.go | 6 ++++++ project/api.go | 4 ++-- project/api_test.go | 4 ++-- project/models.go | 6 ++++++ sender/api.go | 4 ++-- sender/api_test.go | 4 ++-- sender/models.go | 6 ++++++ 9 files changed, 30 insertions(+), 12 deletions(-) diff --git a/notification/api.go b/notification/api.go index 2dbffae..f9c80de 100644 --- a/notification/api.go +++ b/notification/api.go @@ -48,7 +48,7 @@ func Create(notification *NotificationCreateParams) (*NotificationCreateResponse return &response, nil } -func Get(notificationID int) (*Notification, error) { +func Get(notificationID int, params *NotificationGetParams) (*Notification, error) { if notificationID == 0 { return nil, fmt.Errorf("pushpad: notification ID is required") } @@ -60,7 +60,7 @@ func Get(notificationID int) (*Notification, error) { return ¬ification, nil } -func Cancel(notificationID int) error { +func Cancel(notificationID int, params *NotificationCancelParams) error { if notificationID == 0 { return fmt.Errorf("pushpad: notification ID is required") } diff --git a/notification/api_test.go b/notification/api_test.go index 1b6ec66..dec3916 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -148,7 +148,7 @@ func TestGetNotification(t *testing.T) { BodyString(`{"id":77,"body":"Hello"}`) pushpad.Configure("TOKEN", 123) - notification, err := Get(77) + notification, err := Get(77, nil) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -166,7 +166,7 @@ func TestCancelNotification(t *testing.T) { Reply(204) pushpad.Configure("TOKEN", 123) - if err := Cancel(77); err != nil { + if err := Cancel(77, nil); err != nil { t.Fatalf("expected no error, got %s", err) } } diff --git a/notification/models.go b/notification/models.go index bbd9b46..215e392 100644 --- a/notification/models.go +++ b/notification/models.go @@ -74,3 +74,9 @@ type NotificationListParams struct { ProjectID int Page int } + +// NotificationGetParams controls notification fetches. +type NotificationGetParams struct{} + +// NotificationCancelParams controls notification cancels. +type NotificationCancelParams struct{} diff --git a/project/api.go b/project/api.go index 0d76366..ff55434 100644 --- a/project/api.go +++ b/project/api.go @@ -34,7 +34,7 @@ func Create(project *ProjectCreateParams) (*Project, error) { return &created, nil } -func Get(projectID int) (*Project, error) { +func Get(projectID int, params *ProjectGetParams) (*Project, error) { if projectID == 0 { return nil, fmt.Errorf("pushpad: project ID is required") } @@ -63,7 +63,7 @@ func Update(projectID int, update *ProjectUpdateParams) (*Project, error) { return &project, nil } -func Delete(projectID int) error { +func Delete(projectID int, params *ProjectDeleteParams) error { if projectID == 0 { return fmt.Errorf("pushpad: project ID is required") } diff --git a/project/api_test.go b/project/api_test.go index cc77206..74d16d8 100644 --- a/project/api_test.go +++ b/project/api_test.go @@ -72,7 +72,7 @@ func TestGetProject(t *testing.T) { BodyString(`{"id":2,"name":"New Project"}`) pushpad.Configure("TOKEN", 123) - project, err := Get(2) + project, err := Get(2, nil) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -111,7 +111,7 @@ func TestDeleteProject(t *testing.T) { Reply(202) pushpad.Configure("TOKEN", 123) - if err := Delete(2); err != nil { + if err := Delete(2, nil); err != nil { t.Fatalf("expected no error, got %s", err) } } diff --git a/project/models.go b/project/models.go index 01e4c9f..2c0d8ff 100644 --- a/project/models.go +++ b/project/models.go @@ -41,3 +41,9 @@ type ProjectUpdateParams struct { // ProjectListParams controls project listing. type ProjectListParams struct{} + +// ProjectGetParams controls project fetches. +type ProjectGetParams struct{} + +// ProjectDeleteParams controls project deletes. +type ProjectDeleteParams struct{} diff --git a/sender/api.go b/sender/api.go index 04351b5..29249f2 100644 --- a/sender/api.go +++ b/sender/api.go @@ -28,7 +28,7 @@ func Create(sender *SenderCreateParams) (*Sender, error) { return &created, nil } -func Get(senderID int) (*Sender, error) { +func Get(senderID int, params *SenderGetParams) (*Sender, error) { if senderID == 0 { return nil, fmt.Errorf("pushpad: sender ID is required") } @@ -57,7 +57,7 @@ func Update(senderID int, update *SenderUpdateParams) (*Sender, error) { return &sender, nil } -func Delete(senderID int) error { +func Delete(senderID int, params *SenderDeleteParams) error { if senderID == 0 { return fmt.Errorf("pushpad: sender ID is required") } diff --git a/sender/api_test.go b/sender/api_test.go index 5f44312..735f170 100644 --- a/sender/api_test.go +++ b/sender/api_test.go @@ -67,7 +67,7 @@ func TestGetSender(t *testing.T) { BodyString(`{"id":5,"name":"New Sender"}`) pushpad.Configure("TOKEN", 123) - sender, err := Get(5) + sender, err := Get(5, nil) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -106,7 +106,7 @@ func TestDeleteSender(t *testing.T) { Reply(204) pushpad.Configure("TOKEN", 123) - if err := Delete(5); err != nil { + if err := Delete(5, nil); err != nil { t.Fatalf("expected no error, got %s", err) } } diff --git a/sender/models.go b/sender/models.go index 5b67447..5174a99 100644 --- a/sender/models.go +++ b/sender/models.go @@ -25,3 +25,9 @@ type SenderUpdateParams struct { // SenderListParams controls sender listing. type SenderListParams struct{} + +// SenderGetParams controls sender fetches. +type SenderGetParams struct{} + +// SenderDeleteParams controls sender deletes. +type SenderDeleteParams struct{} From 4ac8cd17d760929ff9c0cbe5db4ae17ff5ddcfa2 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 15:00:53 +0100 Subject: [PATCH 03/28] Aligned all *Params function arguments to be named params --- notification/api.go | 14 +++++++------- project/api.go | 26 +++++++++++++------------- sender/api.go | 20 ++++++++++---------- subscription/api.go | 24 ++++++++++++------------ 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/notification/api.go b/notification/api.go index f9c80de..1f69d74 100644 --- a/notification/api.go +++ b/notification/api.go @@ -28,20 +28,20 @@ func List(params *NotificationListParams) ([]Notification, error) { return notifications, err } -func Create(notification *NotificationCreateParams) (*NotificationCreateResponse, error) { - if notification == nil { - return nil, fmt.Errorf("pushpad: notification is required") +func Create(params *NotificationCreateParams) (*NotificationCreateResponse, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") } - if notification.Body == "" { - return nil, fmt.Errorf("pushpad: notification body is required") + if params.Body == "" { + return nil, fmt.Errorf("pushpad: params.Body is required") } - projectID, err := pushpad.ResolveProjectID(notification.ProjectID) + projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { return nil, err } var response NotificationCreateResponse - _, err = pushpad.DoRequest("POST", fmt.Sprintf("/projects/%d/notifications", projectID), nil, notification, []int{201}, &response) + _, err = pushpad.DoRequest("POST", fmt.Sprintf("/projects/%d/notifications", projectID), nil, params, []int{201}, &response) if err != nil { return nil, err } diff --git a/project/api.go b/project/api.go index ff55434..898af83 100644 --- a/project/api.go +++ b/project/api.go @@ -12,22 +12,22 @@ func List(params *ProjectListParams) ([]Project, error) { return projects, err } -func Create(project *ProjectCreateParams) (*Project, error) { - if project == nil { - return nil, fmt.Errorf("pushpad: project is required") +func Create(params *ProjectCreateParams) (*Project, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") } - if project.SenderID == 0 { + if params.SenderID == 0 { return nil, fmt.Errorf("pushpad: sender ID is required") } - if project.Name == "" { - return nil, fmt.Errorf("pushpad: project name is required") + if params.Name == "" { + return nil, fmt.Errorf("pushpad: params.Name is required") } - if project.Website == "" { - return nil, fmt.Errorf("pushpad: project website is required") + if params.Website == "" { + return nil, fmt.Errorf("pushpad: params.Website is required") } var created Project - _, err := pushpad.DoRequest("POST", "/projects", nil, project, []int{201}, &created) + _, err := pushpad.DoRequest("POST", "/projects", nil, params, []int{201}, &created) if err != nil { return nil, err } @@ -47,16 +47,16 @@ func Get(projectID int, params *ProjectGetParams) (*Project, error) { return &project, nil } -func Update(projectID int, update *ProjectUpdateParams) (*Project, error) { - if update == nil { - return nil, fmt.Errorf("pushpad: project update is required") +func Update(projectID int, params *ProjectUpdateParams) (*Project, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") } if projectID == 0 { return nil, fmt.Errorf("pushpad: project ID is required") } var project Project - _, err := pushpad.DoRequest("PATCH", fmt.Sprintf("/projects/%d", projectID), nil, update, []int{200}, &project) + _, err := pushpad.DoRequest("PATCH", fmt.Sprintf("/projects/%d", projectID), nil, params, []int{200}, &project) if err != nil { return nil, err } diff --git a/sender/api.go b/sender/api.go index 29249f2..1b94f11 100644 --- a/sender/api.go +++ b/sender/api.go @@ -12,16 +12,16 @@ func List(params *SenderListParams) ([]Sender, error) { return senders, err } -func Create(sender *SenderCreateParams) (*Sender, error) { - if sender == nil { - return nil, fmt.Errorf("pushpad: sender is required") +func Create(params *SenderCreateParams) (*Sender, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") } - if sender.Name == "" { - return nil, fmt.Errorf("pushpad: sender name is required") + if params.Name == "" { + return nil, fmt.Errorf("pushpad: params.Name is required") } var created Sender - _, err := pushpad.DoRequest("POST", "/senders", nil, sender, []int{201}, &created) + _, err := pushpad.DoRequest("POST", "/senders", nil, params, []int{201}, &created) if err != nil { return nil, err } @@ -41,16 +41,16 @@ func Get(senderID int, params *SenderGetParams) (*Sender, error) { return &sender, nil } -func Update(senderID int, update *SenderUpdateParams) (*Sender, error) { - if update == nil { - return nil, fmt.Errorf("pushpad: sender update is required") +func Update(senderID int, params *SenderUpdateParams) (*Sender, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") } if senderID == 0 { return nil, fmt.Errorf("pushpad: sender ID is required") } var sender Sender - _, err := pushpad.DoRequest("PATCH", fmt.Sprintf("/senders/%d", senderID), nil, update, []int{200}, &sender) + _, err := pushpad.DoRequest("PATCH", fmt.Sprintf("/senders/%d", senderID), nil, params, []int{200}, &sender) if err != nil { return nil, err } diff --git a/subscription/api.go b/subscription/api.go index 1e1640c..48e280f 100644 --- a/subscription/api.go +++ b/subscription/api.go @@ -50,20 +50,20 @@ func List(params *SubscriptionListParams) ([]Subscription, int, error) { return subscriptions, totalCount, nil } -func Create(subscription *SubscriptionCreateParams) (*Subscription, error) { - if subscription == nil { - return nil, fmt.Errorf("pushpad: subscription is required") +func Create(params *SubscriptionCreateParams) (*Subscription, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") } - if subscription.Endpoint == "" { - return nil, fmt.Errorf("pushpad: subscription endpoint is required") + if params.Endpoint == "" { + return nil, fmt.Errorf("pushpad: params.Endpoint is required") } - projectID, err := pushpad.ResolveProjectID(subscription.ProjectID) + projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { return nil, err } var created Subscription - _, err = pushpad.DoRequest("POST", fmt.Sprintf("/projects/%d/subscriptions", projectID), nil, subscription, []int{201}, &created) + _, err = pushpad.DoRequest("POST", fmt.Sprintf("/projects/%d/subscriptions", projectID), nil, params, []int{201}, &created) if err != nil { return nil, err } @@ -91,20 +91,20 @@ func Get(subscriptionID int, params *SubscriptionGetParams) (*Subscription, erro return &subscription, nil } -func Update(subscriptionID int, update *SubscriptionUpdateParams) (*Subscription, error) { - if update == nil { - return nil, fmt.Errorf("pushpad: subscription update is required") +func Update(subscriptionID int, params *SubscriptionUpdateParams) (*Subscription, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") } if subscriptionID == 0 { return nil, fmt.Errorf("pushpad: subscription ID is required") } - projectID, err := pushpad.ResolveProjectID(update.ProjectID) + projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { return nil, err } var subscription Subscription - _, err = pushpad.DoRequest("PATCH", fmt.Sprintf("/projects/%d/subscriptions/%d", projectID, subscriptionID), nil, update, []int{200}, &subscription) + _, err = pushpad.DoRequest("PATCH", fmt.Sprintf("/projects/%d/subscriptions/%d", projectID, subscriptionID), nil, params, []int{200}, &subscription) if err != nil { return nil, err } From 03ebab7d5d132b54e606c8e68a4464a8249814e2 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 15:10:01 +0100 Subject: [PATCH 04/28] Remove unnecessary client-side validations --- notification/api.go | 3 --- project/api.go | 9 --------- sender/api.go | 3 --- subscription/api.go | 3 --- 4 files changed, 18 deletions(-) diff --git a/notification/api.go b/notification/api.go index 1f69d74..707330d 100644 --- a/notification/api.go +++ b/notification/api.go @@ -32,9 +32,6 @@ func Create(params *NotificationCreateParams) (*NotificationCreateResponse, erro if params == nil { return nil, fmt.Errorf("pushpad: params are required") } - if params.Body == "" { - return nil, fmt.Errorf("pushpad: params.Body is required") - } projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { return nil, err diff --git a/project/api.go b/project/api.go index 898af83..dd78b2c 100644 --- a/project/api.go +++ b/project/api.go @@ -16,15 +16,6 @@ func Create(params *ProjectCreateParams) (*Project, error) { if params == nil { return nil, fmt.Errorf("pushpad: params are required") } - if params.SenderID == 0 { - return nil, fmt.Errorf("pushpad: sender ID is required") - } - if params.Name == "" { - return nil, fmt.Errorf("pushpad: params.Name is required") - } - if params.Website == "" { - return nil, fmt.Errorf("pushpad: params.Website is required") - } var created Project _, err := pushpad.DoRequest("POST", "/projects", nil, params, []int{201}, &created) diff --git a/sender/api.go b/sender/api.go index 1b94f11..385daba 100644 --- a/sender/api.go +++ b/sender/api.go @@ -16,9 +16,6 @@ func Create(params *SenderCreateParams) (*Sender, error) { if params == nil { return nil, fmt.Errorf("pushpad: params are required") } - if params.Name == "" { - return nil, fmt.Errorf("pushpad: params.Name is required") - } var created Sender _, err := pushpad.DoRequest("POST", "/senders", nil, params, []int{201}, &created) diff --git a/subscription/api.go b/subscription/api.go index 48e280f..0a87ab0 100644 --- a/subscription/api.go +++ b/subscription/api.go @@ -54,9 +54,6 @@ func Create(params *SubscriptionCreateParams) (*Subscription, error) { if params == nil { return nil, fmt.Errorf("pushpad: params are required") } - if params.Endpoint == "" { - return nil, fmt.Errorf("pushpad: params.Endpoint is required") - } projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { return nil, err From f1ec90d4878cad898792fa5ba09f46a9cc4b924c Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 15:37:29 +0100 Subject: [PATCH 05/28] Set optional params inputs to default empty structs to avoid nil checks --- notification/api.go | 9 ++++----- subscription/api.go | 49 ++++++++++++++++++++------------------------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/notification/api.go b/notification/api.go index 707330d..ee83031 100644 --- a/notification/api.go +++ b/notification/api.go @@ -9,17 +9,16 @@ import ( ) func List(params *NotificationListParams) ([]Notification, error) { - projectID := 0 - if params != nil { - projectID = params.ProjectID + if params == nil { + params = &NotificationListParams{} } - projectID, err := pushpad.ResolveProjectID(projectID) + projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { return nil, err } query := url.Values{} - if params != nil && params.Page > 0 { + if params.Page > 0 { query.Set("page", strconv.Itoa(params.Page)) } diff --git a/subscription/api.go b/subscription/api.go index 0a87ab0..4b380d5 100644 --- a/subscription/api.go +++ b/subscription/api.go @@ -9,29 +9,26 @@ import ( ) func List(params *SubscriptionListParams) ([]Subscription, int, error) { - projectID := 0 - if params != nil { - projectID = params.ProjectID + if params == nil { + params = &SubscriptionListParams{} } - projectID, err := pushpad.ResolveProjectID(projectID) + projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { return nil, 0, err } query := url.Values{} - if params != nil { - if params.Page > 0 { - query.Set("page", strconv.Itoa(params.Page)) - } - if params.PerPage > 0 { - query.Set("per_page", strconv.Itoa(params.PerPage)) - } - for _, uid := range params.UIDs { - query.Add("uids[]", uid) - } - for _, tag := range params.Tags { - query.Add("tags[]", tag) - } + if params.Page > 0 { + query.Set("page", strconv.Itoa(params.Page)) + } + if params.PerPage > 0 { + query.Set("per_page", strconv.Itoa(params.PerPage)) + } + for _, uid := range params.UIDs { + query.Add("uids[]", uid) + } + for _, tag := range params.Tags { + query.Add("tags[]", tag) } var subscriptions []Subscription @@ -68,14 +65,13 @@ func Create(params *SubscriptionCreateParams) (*Subscription, error) { } func Get(subscriptionID int, params *SubscriptionGetParams) (*Subscription, error) { + if params == nil { + params = &SubscriptionGetParams{} + } if subscriptionID == 0 { return nil, fmt.Errorf("pushpad: subscription ID is required") } - projectID := 0 - if params != nil { - projectID = params.ProjectID - } - projectID, err := pushpad.ResolveProjectID(projectID) + projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { return nil, err } @@ -109,14 +105,13 @@ func Update(subscriptionID int, params *SubscriptionUpdateParams) (*Subscription } func Delete(subscriptionID int, params *SubscriptionDeleteParams) error { + if params == nil { + params = &SubscriptionDeleteParams{} + } if subscriptionID == 0 { return fmt.Errorf("pushpad: subscription ID is required") } - projectID := 0 - if params != nil { - projectID = params.ProjectID - } - projectID, err := pushpad.ResolveProjectID(projectID) + projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { return err } From f006ca70ae200ca25cb216ed00e1db4c693919a3 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 18:49:39 +0100 Subject: [PATCH 06/28] Improve tests to check for more specific errors --- notification/api_test.go | 24 ++++++++++++++++++++---- project/api_test.go | 24 ++++++++++++++++++------ sender/api_test.go | 20 ++++++++++++++++++-- subscription/api_test.go | 20 ++++++++++++++++++-- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/notification/api_test.go b/notification/api_test.go index dec3916..7f66742 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -74,10 +74,26 @@ func TestCreateNotification(t *testing.T) { } func TestCreateNotificationMissingBody(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects/123/notifications"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(422). + BodyString(`{"error":"validation error"}`) + pushpad.Configure("TOKEN", 0) _, err := Create(&NotificationCreateParams{ProjectID: 123}) - if err == nil { - t.Fatalf("expected error for missing body") + apiErr, ok := err.(*pushpad.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 422 { + t.Errorf("expected status 422, got %d", apiErr.StatusCode) + } + if apiErr.Body != `{"error":"validation error"}` { + t.Errorf("expected validation error body, got %q", apiErr.Body) } } @@ -174,7 +190,7 @@ func TestCancelNotification(t *testing.T) { func TestListNotificationsMissingProjectID(t *testing.T) { pushpad.Configure("TOKEN", 0) _, err := List(nil) - if err == nil { - t.Fatalf("expected error for missing project ID") + if err == nil || err.Error() != "pushpad: project ID is required" { + t.Fatalf("expected project ID required error, got %v", err) } } diff --git a/project/api_test.go b/project/api_test.go index 74d16d8..ce55c2e 100644 --- a/project/api_test.go +++ b/project/api_test.go @@ -55,10 +55,26 @@ func TestCreateProject(t *testing.T) { } func TestCreateProjectMissingFields(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(422). + BodyString(`{"error":"validation error"}`) + pushpad.Configure("TOKEN", 123) _, err := Create(&ProjectCreateParams{}) - if err == nil { - t.Fatalf("expected error for missing fields") + apiErr, ok := err.(*pushpad.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 422 { + t.Errorf("expected status 422, got %d", apiErr.StatusCode) + } + if apiErr.Body != `{"error":"validation error"}` { + t.Errorf("expected validation error body, got %q", apiErr.Body) } } @@ -131,10 +147,6 @@ func TestAPIErrorOnServerFailure(t *testing.T) { Name: "Failing Project", Website: "https://example.com", }) - if err == nil { - t.Fatalf("expected error") - } - apiErr, ok := err.(*pushpad.APIError) if !ok { t.Fatalf("expected APIError, got %T", err) diff --git a/sender/api_test.go b/sender/api_test.go index 735f170..ca6dd09 100644 --- a/sender/api_test.go +++ b/sender/api_test.go @@ -50,10 +50,26 @@ func TestCreateSender(t *testing.T) { } func TestCreateSenderMissingName(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/senders"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(422). + BodyString(`{"error":"validation error"}`) + pushpad.Configure("TOKEN", 123) _, err := Create(&SenderCreateParams{}) - if err == nil { - t.Fatalf("expected error for missing name") + apiErr, ok := err.(*pushpad.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 422 { + t.Errorf("expected status 422, got %d", apiErr.StatusCode) + } + if apiErr.Body != `{"error":"validation error"}` { + t.Errorf("expected validation error body, got %q", apiErr.Body) } } diff --git a/subscription/api_test.go b/subscription/api_test.go index dbabf4b..6282a41 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -88,10 +88,26 @@ func TestCreateSubscription(t *testing.T) { } func TestCreateSubscriptionMissingEndpoint(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects/123/subscriptions"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(422). + BodyString(`{"error":"validation error"}`) + pushpad.Configure("TOKEN", 0) _, err := Create(&SubscriptionCreateParams{ProjectID: 123}) - if err == nil { - t.Fatalf("expected error for missing endpoint") + apiErr, ok := err.(*pushpad.APIError) + if !ok { + t.Fatalf("expected APIError, got %T", err) + } + if apiErr.StatusCode != 422 { + t.Errorf("expected status 422, got %d", apiErr.StatusCode) + } + if apiErr.Body != `{"error":"validation error"}` { + t.Errorf("expected validation error body, got %q", apiErr.Body) } } From eca2ec63fb76510254dc3ea78767bdadbe4e9b56 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 18:59:04 +0100 Subject: [PATCH 07/28] Add a Send alias for Create in notification/api.go --- notification/api.go | 4 ++++ notification/api_test.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/notification/api.go b/notification/api.go index ee83031..79d4e1f 100644 --- a/notification/api.go +++ b/notification/api.go @@ -44,6 +44,10 @@ func Create(params *NotificationCreateParams) (*NotificationCreateResponse, erro return &response, nil } +func Send(params *NotificationCreateParams) (*NotificationCreateResponse, error) { + return Create(params) +} + func Get(notificationID int, params *NotificationGetParams) (*Notification, error) { if notificationID == 0 { return nil, fmt.Errorf("pushpad: notification ID is required") diff --git a/notification/api_test.go b/notification/api_test.go index 7f66742..48c728b 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -111,7 +111,7 @@ func TestNotificationSend(t *testing.T) { pushpad.Configure("AUTH_TOKEN", 0) n := NotificationCreateParams{ProjectID: 123, Body: "Hello world!"} - res, err := Create(&n) + res, err := Send(&n) if err != nil { t.Fatalf("got an error: %s", err) From 45db0566986e95bdb5858d6a24509e02d40dcbc6 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 19:33:18 +0100 Subject: [PATCH 08/28] Add TestCreate*WithAllFields --- notification/api_test.go | 68 ++++++++++++++++++++++++++++++++++++++++ project/api_test.go | 41 ++++++++++++++++++++++++ sender/api_test.go | 33 +++++++++++++++++++ subscription/api_test.go | 36 +++++++++++++++++++++ 4 files changed, 178 insertions(+) diff --git a/notification/api_test.go b/notification/api_test.go index 48c728b..716fc7d 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -3,6 +3,7 @@ package notification import ( "encoding/json" "testing" + "time" "github.com/h2non/gock" "github.com/pushpad/pushpad-go" @@ -73,6 +74,73 @@ func TestCreateNotification(t *testing.T) { } } +func TestCreateNotificationWithAllFields(t *testing.T) { + defer gock.Off() + + sendAt, err := time.Parse(time.RFC3339Nano, "2016-07-06T10:09:00.000Z") + if err != nil { + t.Fatalf("expected no error parsing send_at, got %s", err) + } + + ttl := 604800 + requireInteraction := false + silent := false + urgent := false + starred := false + params := NotificationCreateParams{ + ProjectID: 123, + Title: "Foo Bar", + Body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + TargetURL: "https://example.com", + IconURL: "https://example.com/assets/icon.png", + BadgeURL: "https://example.com/assets/badge.png", + ImageURL: "https://example.com/assets/image.png", + TTL: &ttl, + RequireInteraction: &requireInteraction, + Silent: &silent, + Urgent: &urgent, + CustomData: "", + Actions: []NotificationAction{ + { + Title: "A button", + TargetURL: "https://example.com/button-link", + Icon: "https://example.com/assets/button-icon.png", + Action: "myActionName", + }, + }, + Starred: &starred, + SendAt: &sendAt, + CustomMetrics: []string{"metric1", "metric2"}, + UIDs: []string{"uid0", "uid1", "uidN"}, + Tags: []string{"tag1", "tagA && !tagB"}, + } + + notificationJSON, err := json.Marshal(params) + if err != nil { + t.Fatalf("got an error: %s", err) + } + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects/123/notifications"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + BodyString(string(notificationJSON)). + Reply(201). + BodyString(`{"id":123456789,"scheduled":9876}`) + + pushpad.Configure("TOKEN", 0) + response, err := Create(¶ms) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if response.ID != 123456789 { + t.Errorf("expected notification ID 123456789, got %d", response.ID) + } + if response.Scheduled == nil || *response.Scheduled != 9876 { + t.Errorf("expected scheduled count 9876, got %v", response.Scheduled) + } +} + func TestCreateNotificationMissingBody(t *testing.T) { defer gock.Off() diff --git a/project/api_test.go b/project/api_test.go index ce55c2e..8265d41 100644 --- a/project/api_test.go +++ b/project/api_test.go @@ -1,6 +1,7 @@ package project import ( + "encoding/json" "testing" "github.com/h2non/gock" @@ -54,6 +55,46 @@ func TestCreateProject(t *testing.T) { } } +func TestCreateProjectWithAllFields(t *testing.T) { + defer gock.Off() + + notificationsTTL := 604800 + requireInteraction := false + silent := false + params := ProjectCreateParams{ + SenderID: 98765, + Name: "My Project", + Website: "https://example.com", + IconURL: "https://example.com/icon.png", + BadgeURL: "https://example.com/badge.png", + NotificationsTTL: ¬ificationsTTL, + NotificationsRequireInteract: &requireInteraction, + NotificationsSilent: &silent, + } + + projectJSON, err := json.Marshal(params) + if err != nil { + t.Fatalf("got an error: %s", err) + } + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + BodyString(string(projectJSON)). + Reply(201). + BodyString(`{"id":12345,"name":"My Project","website":"https://example.com","sender_id":98765}`) + + pushpad.Configure("TOKEN", 123) + project, err := Create(¶ms) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if project.ID != 12345 { + t.Errorf("expected project ID 12345, got %d", project.ID) + } +} + func TestCreateProjectMissingFields(t *testing.T) { defer gock.Off() diff --git a/sender/api_test.go b/sender/api_test.go index ca6dd09..4e96b86 100644 --- a/sender/api_test.go +++ b/sender/api_test.go @@ -1,6 +1,7 @@ package sender import ( + "encoding/json" "testing" "github.com/h2non/gock" @@ -49,6 +50,38 @@ func TestCreateSender(t *testing.T) { } } +func TestCreateSenderWithAllFields(t *testing.T) { + defer gock.Off() + + params := SenderCreateParams{ + Name: "My Sender", + VAPIDPrivateKey: "-----BEGIN EC PRIVATE KEY----- ...", + VAPIDPublicKey: "-----BEGIN PUBLIC KEY----- ...", + } + + senderJSON, err := json.Marshal(params) + if err != nil { + t.Fatalf("got an error: %s", err) + } + + gock.New("https://pushpad.xyz"). + Post("/api/v1/senders"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + BodyString(string(senderJSON)). + Reply(201). + BodyString(`{"id":12345,"name":"My Sender"}`) + + pushpad.Configure("TOKEN", 123) + sender, err := Create(¶ms) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if sender.ID != 12345 { + t.Errorf("expected sender ID 12345, got %d", sender.ID) + } +} + func TestCreateSenderMissingName(t *testing.T) { defer gock.Off() diff --git a/subscription/api_test.go b/subscription/api_test.go index 6282a41..5d5eabb 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -1,6 +1,7 @@ package subscription import ( + "encoding/json" "testing" "github.com/h2non/gock" @@ -87,6 +88,41 @@ func TestCreateSubscription(t *testing.T) { } } +func TestCreateSubscriptionWithAllFields(t *testing.T) { + defer gock.Off() + + params := SubscriptionCreateParams{ + ProjectID: 123, + Endpoint: "https://example.com/push/f7Q1Eyf7EyfAb1", + P256DH: "BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4=", + Auth: "cdKMlhgVeSPzCXZ3V7FtgQ==", + UID: "user1", + Tags: []string{"tag1", "tag2"}, + } + + subscriptionJSON, err := json.Marshal(params) + if err != nil { + t.Fatalf("got an error: %s", err) + } + + gock.New("https://pushpad.xyz"). + Post("/api/v1/projects/123/subscriptions"). + MatchHeader("Content-Type", "application/json"). + MatchHeader("Authorization", "Bearer TOKEN"). + BodyString(string(subscriptionJSON)). + Reply(201). + BodyString(`{"id":12345,"endpoint":"https://example.com/push/f7Q1Eyf7EyfAb1"}`) + + pushpad.Configure("TOKEN", 0) + subscription, err := Create(¶ms) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if subscription.ID != 12345 { + t.Errorf("expected subscription ID 12345, got %d", subscription.ID) + } +} + func TestCreateSubscriptionMissingEndpoint(t *testing.T) { defer gock.Off() From b8482eb71d49eea8384e7b5e4102ec9473075774 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 20:44:47 +0100 Subject: [PATCH 09/28] Use pointers and helper functions for types This is useful to distinguish fields not provided (nil) from zero values --- README.md | 42 ++++++++++++------------- helpers.go | 28 +++++++++++++++++ http.go | 6 ++-- notification/api.go | 4 +-- notification/api_test.go | 67 +++++++++++++++++++--------------------- notification/helpers.go | 6 ++++ notification/models.go | 48 ++++++++++++++-------------- project/api_test.go | 33 +++++++++----------- project/models.go | 30 +++++++++--------- sender/api_test.go | 10 +++--- sender/models.go | 8 ++--- subscription/api.go | 20 +++++++----- subscription/api_test.go | 35 ++++++++++----------- subscription/models.go | 32 +++++++++---------- 14 files changed, 199 insertions(+), 170 deletions(-) create mode 100644 helpers.go create mode 100644 notification/helpers.go diff --git a/README.md b/README.md index ed5aed4..edac99d 100644 --- a/README.md +++ b/README.md @@ -55,52 +55,52 @@ fmt.Printf("User ID Signature: %s", s) ```go n := notification.NotificationCreateParams { // optional, defaults to the project configured via pushpad.Configure - ProjectID: 0, + ProjectID: pushpad.Int(0), // required, the main content of the notification - Body: "Hello world!", + Body: pushpad.String("Hello world!"), // optional, the title of the notification (defaults to your project name) - Title: "Website Name", + Title: pushpad.String("Website Name"), // optional, open this link on notification click (defaults to your project website) - TargetURL: "https://example.com", + TargetURL: pushpad.String("https://example.com"), // optional, the icon of the notification (defaults to the project icon) - IconURL: "https://example.com/assets/icon.png", + IconURL: pushpad.String("https://example.com/assets/icon.png"), // optional, the small icon displayed in the status bar (defaults to the project badge) - BadgeURL: "https://example.com/assets/badge.png", + BadgeURL: pushpad.String("https://example.com/assets/badge.png"), // optional, an image to display in the notification content // see https://pushpad.xyz/docs/sending_images - ImageURL: "https://example.com/assets/image.png", + ImageURL: pushpad.String("https://example.com/assets/image.png"), // optional, drop the notification after this number of seconds if a device is offline - TTL: 604800, + TTL: pushpad.Int(604800), // optional, prevent Chrome on desktop from automatically closing the notification after a few seconds - RequireInteraction: true, + RequireInteraction: pushpad.Bool(true), // optional, enable this option if you want a mute notification without any sound - Silent: false, + Silent: pushpad.Bool(false), // optional, enable this option only for time-sensitive alerts (e.g. incoming phone call) - Urgent: false, + Urgent: pushpad.Bool(false), // optional, a string that is passed as an argument to action button callbacks - CustomData: "123", + CustomData: pushpad.String("123"), // optional, bookmark the notification in the Pushpad dashboard (e.g. to highlight manual notifications) - Starred: true, + Starred: pushpad.Bool(true), // optional, use this option only if you need to create scheduled notifications (max 5 days) // see https://pushpad.xyz/docs/schedule_notifications - SendAt: &sendAtTime, // sendAtTime := time.Date(2022, 12, 25, 0, 0, 0, 0, time.UTC) + SendAt: pushpad.Time(sendAtTime), // sendAtTime := time.Date(2022, 12, 25, 0, 0, 0, 0, time.UTC) // optional, add the notification to custom categories for stats aggregation // see https://pushpad.xyz/docs/monitoring - CustomMetrics: []string{"examples", "another_metric"}, // up to 3 metrics per notification + CustomMetrics: pushpad.Strings([]string{"examples", "another_metric"}), // up to 3 metrics per notification } res, err := notification.Create(&n) @@ -109,31 +109,31 @@ res, err := notification.Create(&n) // You can use UIDs and Tags for sending the notification only to a specific audience... // deliver to a user -n := notification.NotificationCreateParams { Body: "Hi user1", UIDs: []string{"user1"} } +n := notification.NotificationCreateParams { Body: pushpad.String("Hi user1"), UIDs: pushpad.Strings([]string{"user1"}) } res, err := notification.Create(&n) // deliver to a group of users -n := notification.NotificationCreateParams { Body: "Hi users", UIDs: []string{"user1","user2","user3"} } +n := notification.NotificationCreateParams { Body: pushpad.String("Hi users"), UIDs: pushpad.Strings([]string{"user1","user2","user3"}) } res, err := notification.Create(&n) // deliver to some users only if they have a given preference // e.g. only "users" who have a interested in "events" will be reached -n := notification.NotificationCreateParams { Body: "New event", UIDs: []string{"user1","user2"}, Tags: []string{"events"} } +n := notification.NotificationCreateParams { Body: pushpad.String("New event"), UIDs: pushpad.Strings([]string{"user1","user2"}), Tags: pushpad.Strings([]string{"events"}) } res, err := notification.Create(&n) // deliver to segments // e.g. any subscriber that has the tag "segment1" OR "segment2" -n := notification.NotificationCreateParams { Body: "Example", Tags: []string{"segment1", "segment2"} } +n := notification.NotificationCreateParams { Body: pushpad.String("Example"), Tags: pushpad.Strings([]string{"segment1", "segment2"}) } res, err := notification.Create(&n) // you can use boolean expressions // they can include parentheses and the operators !, &&, || (from highest to lowest precedence) // https://pushpad.xyz/docs/tags -n := notification.NotificationCreateParams { Body: "Example", Tags: []string{"zip_code:28865 && !optout:local_events || friend_of:Organizer123"} } +n := notification.NotificationCreateParams { Body: pushpad.String("Example"), Tags: pushpad.Strings([]string{"zip_code:28865 && !optout:local_events || friend_of:Organizer123"}) } res, err := notification.Create(&n) // deliver to everyone -n := notification.NotificationCreateParams { Body: "Hello everybody" } +n := notification.NotificationCreateParams { Body: pushpad.String("Hello everybody") } res, err := notification.Create(&n) ``` diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..ff5886f --- /dev/null +++ b/helpers.go @@ -0,0 +1,28 @@ +package pushpad + +import "time" + +// String returns a pointer to a string value. +func String(value string) *string { + return &value +} + +// Bool returns a pointer to a bool value. +func Bool(value bool) *bool { + return &value +} + +// Int returns a pointer to an int value. +func Int(value int) *int { + return &value +} + +// Time returns a pointer to a time value. +func Time(value time.Time) *time.Time { + return &value +} + +// Strings returns a pointer to a string slice. +func Strings(value []string) *[]string { + return &value +} diff --git a/http.go b/http.go index d9c9b34..f2931e5 100644 --- a/http.go +++ b/http.go @@ -27,9 +27,9 @@ func (e *APIError) Error() string { } // ResolveProjectID returns the provided project ID or the configured default project ID. -func ResolveProjectID(projectID int) (int, error) { - if projectID != 0 { - return projectID, nil +func ResolveProjectID(projectID *int) (int, error) { + if projectID != nil && *projectID != 0 { + return *projectID, nil } if pushpadProjectID == 0 { return 0, fmt.Errorf("pushpad: project ID is required") diff --git a/notification/api.go b/notification/api.go index 79d4e1f..fc07116 100644 --- a/notification/api.go +++ b/notification/api.go @@ -18,8 +18,8 @@ func List(params *NotificationListParams) ([]Notification, error) { } query := url.Values{} - if params.Page > 0 { - query.Set("page", strconv.Itoa(params.Page)) + if params.Page != nil && *params.Page > 0 { + query.Set("page", strconv.Itoa(*params.Page)) } var notifications []Notification diff --git a/notification/api_test.go b/notification/api_test.go index 716fc7d..10770db 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -20,7 +20,7 @@ func TestListNotifications(t *testing.T) { BodyString(`[{"id":1,"body":"Hi"}]`) pushpad.Configure("TOKEN", 0) - notifications, err := List(&NotificationListParams{ProjectID: 123, Page: 2}) + notifications, err := List(&NotificationListParams{ProjectID: pushpad.Int(123), Page: pushpad.Int(2)}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -42,7 +42,7 @@ func TestListNotificationsDefaultPage(t *testing.T) { BodyString(`[]`) pushpad.Configure("TOKEN", 0) - notifications, err := List(&NotificationListParams{ProjectID: 123}) + notifications, err := List(&NotificationListParams{ProjectID: pushpad.Int(123)}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -62,7 +62,7 @@ func TestCreateNotification(t *testing.T) { BodyString(`{"id":99,"scheduled":10}`) pushpad.Configure("TOKEN", 0) - response, err := Create(&NotificationCreateParams{ProjectID: 123, Body: "Hello"}) + response, err := Create(&NotificationCreateParams{ProjectID: pushpad.Int(123), Body: pushpad.String("Hello")}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -82,37 +82,32 @@ func TestCreateNotificationWithAllFields(t *testing.T) { t.Fatalf("expected no error parsing send_at, got %s", err) } - ttl := 604800 - requireInteraction := false - silent := false - urgent := false - starred := false params := NotificationCreateParams{ - ProjectID: 123, - Title: "Foo Bar", - Body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", - TargetURL: "https://example.com", - IconURL: "https://example.com/assets/icon.png", - BadgeURL: "https://example.com/assets/badge.png", - ImageURL: "https://example.com/assets/image.png", - TTL: &ttl, - RequireInteraction: &requireInteraction, - Silent: &silent, - Urgent: &urgent, - CustomData: "", - Actions: []NotificationAction{ - { - Title: "A button", - TargetURL: "https://example.com/button-link", - Icon: "https://example.com/assets/button-icon.png", - Action: "myActionName", + ProjectID: pushpad.Int(123), + Title: pushpad.String("Foo Bar"), + Body: pushpad.String("Lorem ipsum dolor sit amet, consectetur adipiscing elit."), + TargetURL: pushpad.String("https://example.com"), + IconURL: pushpad.String("https://example.com/assets/icon.png"), + BadgeURL: pushpad.String("https://example.com/assets/badge.png"), + ImageURL: pushpad.String("https://example.com/assets/image.png"), + TTL: pushpad.Int(604800), + RequireInteraction: pushpad.Bool(false), + Silent: pushpad.Bool(false), + Urgent: pushpad.Bool(false), + CustomData: pushpad.String(""), + Actions: Actions( + NotificationAction{ + Title: pushpad.String("A button"), + TargetURL: pushpad.String("https://example.com/button-link"), + Icon: pushpad.String("https://example.com/assets/button-icon.png"), + Action: pushpad.String("myActionName"), }, - }, - Starred: &starred, - SendAt: &sendAt, - CustomMetrics: []string{"metric1", "metric2"}, - UIDs: []string{"uid0", "uid1", "uidN"}, - Tags: []string{"tag1", "tagA && !tagB"}, + ), + Starred: pushpad.Bool(false), + SendAt: pushpad.Time(sendAt), + CustomMetrics: pushpad.Strings([]string{"metric1", "metric2"}), + UIDs: pushpad.Strings([]string{"uid0", "uid1", "uidN"}), + Tags: pushpad.Strings([]string{"tag1", "tagA && !tagB"}), } notificationJSON, err := json.Marshal(params) @@ -152,7 +147,7 @@ func TestCreateNotificationMissingBody(t *testing.T) { BodyString(`{"error":"validation error"}`) pushpad.Configure("TOKEN", 0) - _, err := Create(&NotificationCreateParams{ProjectID: 123}) + _, err := Create(&NotificationCreateParams{ProjectID: pushpad.Int(123)}) apiErr, ok := err.(*pushpad.APIError) if !ok { t.Fatalf("expected APIError, got %T", err) @@ -178,7 +173,7 @@ func TestNotificationSend(t *testing.T) { pushpad.Configure("AUTH_TOKEN", 0) - n := NotificationCreateParams{ProjectID: 123, Body: "Hello world!"} + n := NotificationCreateParams{ProjectID: pushpad.Int(123), Body: pushpad.String("Hello world!")} res, err := Send(&n) if err != nil { @@ -191,7 +186,7 @@ func TestNotificationSend(t *testing.T) { } func TestNotificationWithUIDs(t *testing.T) { - n := NotificationCreateParams{Body: "Hello user1", UIDs: []string{"user1"}} + n := NotificationCreateParams{Body: pushpad.String("Hello user1"), UIDs: pushpad.Strings([]string{"user1"})} notificationJSON, err := json.Marshal(n) if err != nil { @@ -207,7 +202,7 @@ func TestNotificationWithUIDs(t *testing.T) { } func TestNotificationWithTags(t *testing.T) { - n := NotificationCreateParams{Body: "Hello tag1", Tags: []string{"tag1"}} + n := NotificationCreateParams{Body: pushpad.String("Hello tag1"), Tags: pushpad.Strings([]string{"tag1"})} notificationJSON, err := json.Marshal(n) if err != nil { diff --git a/notification/helpers.go b/notification/helpers.go new file mode 100644 index 0000000..d64fa1c --- /dev/null +++ b/notification/helpers.go @@ -0,0 +1,6 @@ +package notification + +// Actions returns a pointer to a slice of actions for optional payload fields. +func Actions(actions ...NotificationAction) *[]NotificationAction { + return &actions +} diff --git a/notification/models.go b/notification/models.go index 215e392..a9e2442 100644 --- a/notification/models.go +++ b/notification/models.go @@ -4,10 +4,10 @@ import "time" // NotificationAction represents a notification action button. type NotificationAction struct { - Title string `json:"title,omitempty"` - TargetURL string `json:"target_url,omitempty"` - Icon string `json:"icon,omitempty"` - Action string `json:"action,omitempty"` + Title *string `json:"title,omitempty"` + TargetURL *string `json:"target_url,omitempty"` + Icon *string `json:"icon,omitempty"` + Action *string `json:"action,omitempty"` } // Notification represents a Pushpad notification. @@ -41,24 +41,24 @@ type Notification struct { // NotificationCreateParams represents a notification create payload. type NotificationCreateParams struct { - ProjectID int `json:"-"` - Title string `json:"title,omitempty"` - Body string `json:"body,omitempty"` - TargetURL string `json:"target_url,omitempty"` - IconURL string `json:"icon_url,omitempty"` - BadgeURL string `json:"badge_url,omitempty"` - ImageURL string `json:"image_url,omitempty"` - TTL *int `json:"ttl,omitempty"` - RequireInteraction *bool `json:"require_interaction,omitempty"` - Silent *bool `json:"silent,omitempty"` - Urgent *bool `json:"urgent,omitempty"` - CustomData string `json:"custom_data,omitempty"` - Actions []NotificationAction `json:"actions,omitempty"` - Starred *bool `json:"starred,omitempty"` - SendAt *time.Time `json:"send_at,omitempty"` - CustomMetrics []string `json:"custom_metrics,omitempty"` - UIDs []string `json:"uids"` - Tags []string `json:"tags"` + ProjectID *int `json:"-"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + TargetURL *string `json:"target_url,omitempty"` + IconURL *string `json:"icon_url,omitempty"` + BadgeURL *string `json:"badge_url,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + TTL *int `json:"ttl,omitempty"` + RequireInteraction *bool `json:"require_interaction,omitempty"` + Silent *bool `json:"silent,omitempty"` + Urgent *bool `json:"urgent,omitempty"` + CustomData *string `json:"custom_data,omitempty"` + Actions *[]NotificationAction `json:"actions,omitempty"` + Starred *bool `json:"starred,omitempty"` + SendAt *time.Time `json:"send_at,omitempty"` + CustomMetrics *[]string `json:"custom_metrics,omitempty"` + UIDs *[]string `json:"uids"` + Tags *[]string `json:"tags"` } // NotificationCreateResponse describes the response to creating a notification. @@ -71,8 +71,8 @@ type NotificationCreateResponse struct { // NotificationListParams controls notification listing. type NotificationListParams struct { - ProjectID int - Page int + ProjectID *int + Page *int } // NotificationGetParams controls notification fetches. diff --git a/project/api_test.go b/project/api_test.go index 8265d41..2f78b71 100644 --- a/project/api_test.go +++ b/project/api_test.go @@ -42,9 +42,9 @@ func TestCreateProject(t *testing.T) { pushpad.Configure("TOKEN", 123) payload := &ProjectCreateParams{ - SenderID: 9, - Name: "New Project", - Website: "https://example.com", + SenderID: pushpad.Int(9), + Name: pushpad.String("New Project"), + Website: pushpad.String("https://example.com"), } project, err := Create(payload) if err != nil { @@ -58,18 +58,15 @@ func TestCreateProject(t *testing.T) { func TestCreateProjectWithAllFields(t *testing.T) { defer gock.Off() - notificationsTTL := 604800 - requireInteraction := false - silent := false params := ProjectCreateParams{ - SenderID: 98765, - Name: "My Project", - Website: "https://example.com", - IconURL: "https://example.com/icon.png", - BadgeURL: "https://example.com/badge.png", - NotificationsTTL: ¬ificationsTTL, - NotificationsRequireInteract: &requireInteraction, - NotificationsSilent: &silent, + SenderID: pushpad.Int(98765), + Name: pushpad.String("My Project"), + Website: pushpad.String("https://example.com"), + IconURL: pushpad.String("https://example.com/icon.png"), + BadgeURL: pushpad.String("https://example.com/badge.png"), + NotificationsTTL: pushpad.Int(604800), + NotificationsRequireInteract: pushpad.Bool(false), + NotificationsSilent: pushpad.Bool(false), } projectJSON, err := json.Marshal(params) @@ -149,7 +146,7 @@ func TestUpdateProject(t *testing.T) { BodyString(`{"id":2,"name":"Updated Project"}`) pushpad.Configure("TOKEN", 123) - update := &ProjectUpdateParams{Name: "Updated Project"} + update := &ProjectUpdateParams{Name: pushpad.String("Updated Project")} project, err := Update(2, update) if err != nil { t.Fatalf("expected no error, got %s", err) @@ -184,9 +181,9 @@ func TestAPIErrorOnServerFailure(t *testing.T) { pushpad.Configure("TOKEN", 123) _, err := Create(&ProjectCreateParams{ - SenderID: 1, - Name: "Failing Project", - Website: "https://example.com", + SenderID: pushpad.Int(1), + Name: pushpad.String("Failing Project"), + Website: pushpad.String("https://example.com"), }) apiErr, ok := err.(*pushpad.APIError) if !ok { diff --git a/project/models.go b/project/models.go index 2c0d8ff..4664a2d 100644 --- a/project/models.go +++ b/project/models.go @@ -18,25 +18,25 @@ type Project struct { // ProjectCreateParams is the payload to create a project. type ProjectCreateParams struct { - SenderID int `json:"sender_id"` - Name string `json:"name"` - Website string `json:"website"` - IconURL string `json:"icon_url,omitempty"` - BadgeURL string `json:"badge_url,omitempty"` - NotificationsTTL *int `json:"notifications_ttl,omitempty"` - NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` - NotificationsSilent *bool `json:"notifications_silent,omitempty"` + SenderID *int `json:"sender_id"` + Name *string `json:"name"` + Website *string `json:"website"` + IconURL *string `json:"icon_url,omitempty"` + BadgeURL *string `json:"badge_url,omitempty"` + NotificationsTTL *int `json:"notifications_ttl,omitempty"` + NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` + NotificationsSilent *bool `json:"notifications_silent,omitempty"` } // ProjectUpdateParams is the payload to update a project. type ProjectUpdateParams struct { - Name string `json:"name,omitempty"` - Website string `json:"website,omitempty"` - IconURL string `json:"icon_url,omitempty"` - BadgeURL string `json:"badge_url,omitempty"` - NotificationsTTL *int `json:"notifications_ttl,omitempty"` - NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` - NotificationsSilent *bool `json:"notifications_silent,omitempty"` + Name *string `json:"name,omitempty"` + Website *string `json:"website,omitempty"` + IconURL *string `json:"icon_url,omitempty"` + BadgeURL *string `json:"badge_url,omitempty"` + NotificationsTTL *int `json:"notifications_ttl,omitempty"` + NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` + NotificationsSilent *bool `json:"notifications_silent,omitempty"` } // ProjectListParams controls project listing. diff --git a/sender/api_test.go b/sender/api_test.go index 4e96b86..48fcdce 100644 --- a/sender/api_test.go +++ b/sender/api_test.go @@ -41,7 +41,7 @@ func TestCreateSender(t *testing.T) { BodyString(`{"id":5,"name":"New Sender"}`) pushpad.Configure("TOKEN", 123) - sender, err := Create(&SenderCreateParams{Name: "New Sender"}) + sender, err := Create(&SenderCreateParams{Name: pushpad.String("New Sender")}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -54,9 +54,9 @@ func TestCreateSenderWithAllFields(t *testing.T) { defer gock.Off() params := SenderCreateParams{ - Name: "My Sender", - VAPIDPrivateKey: "-----BEGIN EC PRIVATE KEY----- ...", - VAPIDPublicKey: "-----BEGIN PUBLIC KEY----- ...", + Name: pushpad.String("My Sender"), + VAPIDPrivateKey: pushpad.String("-----BEGIN EC PRIVATE KEY----- ..."), + VAPIDPublicKey: pushpad.String("-----BEGIN PUBLIC KEY----- ..."), } senderJSON, err := json.Marshal(params) @@ -136,7 +136,7 @@ func TestUpdateSender(t *testing.T) { BodyString(`{"id":5,"name":"Updated Sender"}`) pushpad.Configure("TOKEN", 123) - update := &SenderUpdateParams{Name: "Updated Sender"} + update := &SenderUpdateParams{Name: pushpad.String("Updated Sender")} sender, err := Update(5, update) if err != nil { t.Fatalf("expected no error, got %s", err) diff --git a/sender/models.go b/sender/models.go index 5174a99..4cea1f8 100644 --- a/sender/models.go +++ b/sender/models.go @@ -13,14 +13,14 @@ type Sender struct { // SenderCreateParams is the payload to create a sender. type SenderCreateParams struct { - Name string `json:"name"` - VAPIDPrivateKey string `json:"vapid_private_key,omitempty"` - VAPIDPublicKey string `json:"vapid_public_key,omitempty"` + Name *string `json:"name"` + VAPIDPrivateKey *string `json:"vapid_private_key,omitempty"` + VAPIDPublicKey *string `json:"vapid_public_key,omitempty"` } // SenderUpdateParams is the payload to update a sender. type SenderUpdateParams struct { - Name string `json:"name,omitempty"` + Name *string `json:"name,omitempty"` } // SenderListParams controls sender listing. diff --git a/subscription/api.go b/subscription/api.go index 4b380d5..e13397f 100644 --- a/subscription/api.go +++ b/subscription/api.go @@ -18,17 +18,21 @@ func List(params *SubscriptionListParams) ([]Subscription, int, error) { } query := url.Values{} - if params.Page > 0 { - query.Set("page", strconv.Itoa(params.Page)) + if params.Page != nil && *params.Page > 0 { + query.Set("page", strconv.Itoa(*params.Page)) } - if params.PerPage > 0 { - query.Set("per_page", strconv.Itoa(params.PerPage)) + if params.PerPage != nil && *params.PerPage > 0 { + query.Set("per_page", strconv.Itoa(*params.PerPage)) } - for _, uid := range params.UIDs { - query.Add("uids[]", uid) + if params.UIDs != nil { + for _, uid := range *params.UIDs { + query.Add("uids[]", uid) + } } - for _, tag := range params.Tags { - query.Add("tags[]", tag) + if params.Tags != nil { + for _, tag := range *params.Tags { + query.Add("tags[]", tag) + } } var subscriptions []Subscription diff --git a/subscription/api_test.go b/subscription/api_test.go index 5d5eabb..4902525 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -25,11 +25,11 @@ func TestListSubscriptions(t *testing.T) { pushpad.Configure("TOKEN", 0) params := &SubscriptionListParams{ - ProjectID: 123, - Page: 1, - PerPage: 20, - UIDs: []string{"u1", "u2"}, - Tags: []string{"tag1"}, + ProjectID: pushpad.Int(123), + Page: pushpad.Int(1), + PerPage: pushpad.Int(20), + UIDs: pushpad.Strings([]string{"u1", "u2"}), + Tags: pushpad.Strings([]string{"tag1"}), } subscriptions, total, err := List(params) if err != nil { @@ -56,7 +56,7 @@ func TestListSubscriptionsNoOptions(t *testing.T) { BodyString(`[]`) pushpad.Configure("TOKEN", 0) - subscriptions, total, err := List(&SubscriptionListParams{ProjectID: 123}) + subscriptions, total, err := List(&SubscriptionListParams{ProjectID: pushpad.Int(123)}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -79,7 +79,7 @@ func TestCreateSubscription(t *testing.T) { BodyString(`{"id":50,"endpoint":"https://example.com/1"}`) pushpad.Configure("TOKEN", 0) - subscription, err := Create(&SubscriptionCreateParams{ProjectID: 123, Endpoint: "https://example.com/1"}) + subscription, err := Create(&SubscriptionCreateParams{ProjectID: pushpad.Int(123), Endpoint: pushpad.String("https://example.com/1")}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -92,12 +92,12 @@ func TestCreateSubscriptionWithAllFields(t *testing.T) { defer gock.Off() params := SubscriptionCreateParams{ - ProjectID: 123, - Endpoint: "https://example.com/push/f7Q1Eyf7EyfAb1", - P256DH: "BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4=", - Auth: "cdKMlhgVeSPzCXZ3V7FtgQ==", - UID: "user1", - Tags: []string{"tag1", "tag2"}, + ProjectID: pushpad.Int(123), + Endpoint: pushpad.String("https://example.com/push/f7Q1Eyf7EyfAb1"), + P256DH: pushpad.String("BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4="), + Auth: pushpad.String("cdKMlhgVeSPzCXZ3V7FtgQ=="), + UID: pushpad.String("user1"), + Tags: pushpad.Strings([]string{"tag1", "tag2"}), } subscriptionJSON, err := json.Marshal(params) @@ -134,7 +134,7 @@ func TestCreateSubscriptionMissingEndpoint(t *testing.T) { BodyString(`{"error":"validation error"}`) pushpad.Configure("TOKEN", 0) - _, err := Create(&SubscriptionCreateParams{ProjectID: 123}) + _, err := Create(&SubscriptionCreateParams{ProjectID: pushpad.Int(123)}) apiErr, ok := err.(*pushpad.APIError) if !ok { t.Fatalf("expected APIError, got %T", err) @@ -157,7 +157,7 @@ func TestGetSubscription(t *testing.T) { BodyString(`{"id":50,"endpoint":"https://example.com/1"}`) pushpad.Configure("TOKEN", 0) - subscription, err := Get(50, &SubscriptionGetParams{ProjectID: 123}) + subscription, err := Get(50, &SubscriptionGetParams{ProjectID: pushpad.Int(123)}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -177,8 +177,7 @@ func TestUpdateSubscription(t *testing.T) { BodyString(`{"id":50,"uid":"new-user"}`) pushpad.Configure("TOKEN", 0) - uid := "new-user" - update := &SubscriptionUpdateParams{ProjectID: 123, UID: &uid} + update := &SubscriptionUpdateParams{ProjectID: pushpad.Int(123), UID: pushpad.String("new-user")} subscription, err := Update(50, update) if err != nil { t.Fatalf("expected no error, got %s", err) @@ -197,7 +196,7 @@ func TestDeleteSubscription(t *testing.T) { Reply(204) pushpad.Configure("TOKEN", 0) - if err := Delete(50, &SubscriptionDeleteParams{ProjectID: 123}); err != nil { + if err := Delete(50, &SubscriptionDeleteParams{ProjectID: pushpad.Int(123)}); err != nil { t.Fatalf("expected no error, got %s", err) } } diff --git a/subscription/models.go b/subscription/models.go index 7c45b3d..d4ec3b6 100644 --- a/subscription/models.go +++ b/subscription/models.go @@ -17,36 +17,36 @@ type Subscription struct { // SubscriptionCreateParams is the payload to create a subscription. type SubscriptionCreateParams struct { - ProjectID int `json:"-"` - Endpoint string `json:"endpoint"` - P256DH string `json:"p256dh,omitempty"` - Auth string `json:"auth,omitempty"` - UID string `json:"uid,omitempty"` - Tags []string `json:"tags,omitempty"` + ProjectID *int `json:"-"` + Endpoint *string `json:"endpoint"` + P256DH *string `json:"p256dh,omitempty"` + Auth *string `json:"auth,omitempty"` + UID *string `json:"uid,omitempty"` + Tags *[]string `json:"tags,omitempty"` } // SubscriptionUpdateParams is the payload to update a subscription. type SubscriptionUpdateParams struct { - ProjectID int `json:"-"` - UID *string `json:"uid,omitempty"` - Tags []string `json:"tags,omitempty"` + ProjectID *int `json:"-"` + UID *string `json:"uid,omitempty"` + Tags *[]string `json:"tags,omitempty"` } // SubscriptionListParams controls subscription listing. type SubscriptionListParams struct { - ProjectID int - Page int - PerPage int - UIDs []string - Tags []string + ProjectID *int + Page *int + PerPage *int + UIDs *[]string + Tags *[]string } // SubscriptionGetParams controls subscription fetches. type SubscriptionGetParams struct { - ProjectID int + ProjectID *int } // SubscriptionDeleteParams controls subscription deletes. type SubscriptionDeleteParams struct { - ProjectID int + ProjectID *int } From c207afef198c5e5852622d46812fcba828aba0a1 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 21:10:40 +0100 Subject: [PATCH 10/28] Use int64 instead of int Prevent issues on 32-bit architectures with large integers like IDs --- README.md | 4 ++-- helpers.go | 4 ++-- http.go | 2 +- notification/api.go | 6 +++--- notification/api_test.go | 14 +++++++------- notification/models.go | 24 ++++++++++++------------ project/api.go | 6 +++--- project/api_test.go | 8 ++++---- project/models.go | 12 ++++++------ pushpad.go | 4 ++-- sender/api.go | 6 +++--- sender/models.go | 2 +- subscription/api.go | 16 ++++++++-------- subscription/api_test.go | 20 ++++++++++---------- subscription/models.go | 18 +++++++++--------- 15 files changed, 73 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index edac99d..5f6e3ac 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ fmt.Printf("User ID Signature: %s", s) ```go n := notification.NotificationCreateParams { // optional, defaults to the project configured via pushpad.Configure - ProjectID: pushpad.Int(0), + ProjectID: pushpad.Int64(0), // required, the main content of the notification Body: pushpad.String("Hello world!"), @@ -77,7 +77,7 @@ n := notification.NotificationCreateParams { ImageURL: pushpad.String("https://example.com/assets/image.png"), // optional, drop the notification after this number of seconds if a device is offline - TTL: pushpad.Int(604800), + TTL: pushpad.Int64(604800), // optional, prevent Chrome on desktop from automatically closing the notification after a few seconds RequireInteraction: pushpad.Bool(true), diff --git a/helpers.go b/helpers.go index ff5886f..d5e8961 100644 --- a/helpers.go +++ b/helpers.go @@ -12,8 +12,8 @@ func Bool(value bool) *bool { return &value } -// Int returns a pointer to an int value. -func Int(value int) *int { +// Int64 returns a pointer to an int64 value. +func Int64(value int64) *int64 { return &value } diff --git a/http.go b/http.go index f2931e5..07a4dec 100644 --- a/http.go +++ b/http.go @@ -27,7 +27,7 @@ func (e *APIError) Error() string { } // ResolveProjectID returns the provided project ID or the configured default project ID. -func ResolveProjectID(projectID *int) (int, error) { +func ResolveProjectID(projectID *int64) (int64, error) { if projectID != nil && *projectID != 0 { return *projectID, nil } diff --git a/notification/api.go b/notification/api.go index fc07116..4451f05 100644 --- a/notification/api.go +++ b/notification/api.go @@ -19,7 +19,7 @@ func List(params *NotificationListParams) ([]Notification, error) { query := url.Values{} if params.Page != nil && *params.Page > 0 { - query.Set("page", strconv.Itoa(*params.Page)) + query.Set("page", strconv.FormatInt(*params.Page, 10)) } var notifications []Notification @@ -48,7 +48,7 @@ func Send(params *NotificationCreateParams) (*NotificationCreateResponse, error) return Create(params) } -func Get(notificationID int, params *NotificationGetParams) (*Notification, error) { +func Get(notificationID int64, params *NotificationGetParams) (*Notification, error) { if notificationID == 0 { return nil, fmt.Errorf("pushpad: notification ID is required") } @@ -60,7 +60,7 @@ func Get(notificationID int, params *NotificationGetParams) (*Notification, erro return ¬ification, nil } -func Cancel(notificationID int, params *NotificationCancelParams) error { +func Cancel(notificationID int64, params *NotificationCancelParams) error { if notificationID == 0 { return fmt.Errorf("pushpad: notification ID is required") } diff --git a/notification/api_test.go b/notification/api_test.go index 10770db..00cb48c 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -20,7 +20,7 @@ func TestListNotifications(t *testing.T) { BodyString(`[{"id":1,"body":"Hi"}]`) pushpad.Configure("TOKEN", 0) - notifications, err := List(&NotificationListParams{ProjectID: pushpad.Int(123), Page: pushpad.Int(2)}) + notifications, err := List(&NotificationListParams{ProjectID: pushpad.Int64(123), Page: pushpad.Int64(2)}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -42,7 +42,7 @@ func TestListNotificationsDefaultPage(t *testing.T) { BodyString(`[]`) pushpad.Configure("TOKEN", 0) - notifications, err := List(&NotificationListParams{ProjectID: pushpad.Int(123)}) + notifications, err := List(&NotificationListParams{ProjectID: pushpad.Int64(123)}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -62,7 +62,7 @@ func TestCreateNotification(t *testing.T) { BodyString(`{"id":99,"scheduled":10}`) pushpad.Configure("TOKEN", 0) - response, err := Create(&NotificationCreateParams{ProjectID: pushpad.Int(123), Body: pushpad.String("Hello")}) + response, err := Create(&NotificationCreateParams{ProjectID: pushpad.Int64(123), Body: pushpad.String("Hello")}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -83,14 +83,14 @@ func TestCreateNotificationWithAllFields(t *testing.T) { } params := NotificationCreateParams{ - ProjectID: pushpad.Int(123), + ProjectID: pushpad.Int64(123), Title: pushpad.String("Foo Bar"), Body: pushpad.String("Lorem ipsum dolor sit amet, consectetur adipiscing elit."), TargetURL: pushpad.String("https://example.com"), IconURL: pushpad.String("https://example.com/assets/icon.png"), BadgeURL: pushpad.String("https://example.com/assets/badge.png"), ImageURL: pushpad.String("https://example.com/assets/image.png"), - TTL: pushpad.Int(604800), + TTL: pushpad.Int64(604800), RequireInteraction: pushpad.Bool(false), Silent: pushpad.Bool(false), Urgent: pushpad.Bool(false), @@ -147,7 +147,7 @@ func TestCreateNotificationMissingBody(t *testing.T) { BodyString(`{"error":"validation error"}`) pushpad.Configure("TOKEN", 0) - _, err := Create(&NotificationCreateParams{ProjectID: pushpad.Int(123)}) + _, err := Create(&NotificationCreateParams{ProjectID: pushpad.Int64(123)}) apiErr, ok := err.(*pushpad.APIError) if !ok { t.Fatalf("expected APIError, got %T", err) @@ -173,7 +173,7 @@ func TestNotificationSend(t *testing.T) { pushpad.Configure("AUTH_TOKEN", 0) - n := NotificationCreateParams{ProjectID: pushpad.Int(123), Body: pushpad.String("Hello world!")} + n := NotificationCreateParams{ProjectID: pushpad.Int64(123), Body: pushpad.String("Hello world!")} res, err := Send(&n) if err != nil { diff --git a/notification/models.go b/notification/models.go index a9e2442..c08731b 100644 --- a/notification/models.go +++ b/notification/models.go @@ -12,15 +12,15 @@ type NotificationAction struct { // Notification represents a Pushpad notification. type Notification struct { - ID int `json:"id,omitempty"` - ProjectID int `json:"project_id,omitempty"` + ID int64 `json:"id,omitempty"` + ProjectID int64 `json:"project_id,omitempty"` Title string `json:"title,omitempty"` Body string `json:"body,omitempty"` TargetURL string `json:"target_url,omitempty"` IconURL string `json:"icon_url,omitempty"` BadgeURL string `json:"badge_url,omitempty"` ImageURL string `json:"image_url,omitempty"` - TTL *int `json:"ttl,omitempty"` + TTL *int64 `json:"ttl,omitempty"` RequireInteraction *bool `json:"require_interaction,omitempty"` Silent *bool `json:"silent,omitempty"` Urgent *bool `json:"urgent,omitempty"` @@ -32,23 +32,23 @@ type Notification struct { UIDs []string `json:"uids"` Tags []string `json:"tags"` CreatedAt *time.Time `json:"created_at,omitempty"` - SuccessfullySent *int `json:"successfully_sent_count,omitempty"` - OpenedCount *int `json:"opened_count,omitempty"` - ScheduledCount *int `json:"scheduled_count,omitempty"` + SuccessfullySent *int64 `json:"successfully_sent_count,omitempty"` + OpenedCount *int64 `json:"opened_count,omitempty"` + ScheduledCount *int64 `json:"scheduled_count,omitempty"` Scheduled *bool `json:"scheduled,omitempty"` Cancelled *bool `json:"cancelled,omitempty"` } // NotificationCreateParams represents a notification create payload. type NotificationCreateParams struct { - ProjectID *int `json:"-"` + ProjectID *int64 `json:"-"` Title *string `json:"title,omitempty"` Body *string `json:"body,omitempty"` TargetURL *string `json:"target_url,omitempty"` IconURL *string `json:"icon_url,omitempty"` BadgeURL *string `json:"badge_url,omitempty"` ImageURL *string `json:"image_url,omitempty"` - TTL *int `json:"ttl,omitempty"` + TTL *int64 `json:"ttl,omitempty"` RequireInteraction *bool `json:"require_interaction,omitempty"` Silent *bool `json:"silent,omitempty"` Urgent *bool `json:"urgent,omitempty"` @@ -63,16 +63,16 @@ type NotificationCreateParams struct { // NotificationCreateResponse describes the response to creating a notification. type NotificationCreateResponse struct { - ID int `json:"id"` - Scheduled *int `json:"scheduled,omitempty"` + ID int64 `json:"id"` + Scheduled *int64 `json:"scheduled,omitempty"` UIDs []string `json:"uids,omitempty"` SendAt *time.Time `json:"send_at,omitempty"` } // NotificationListParams controls notification listing. type NotificationListParams struct { - ProjectID *int - Page *int + ProjectID *int64 + Page *int64 } // NotificationGetParams controls notification fetches. diff --git a/project/api.go b/project/api.go index dd78b2c..653fa7c 100644 --- a/project/api.go +++ b/project/api.go @@ -25,7 +25,7 @@ func Create(params *ProjectCreateParams) (*Project, error) { return &created, nil } -func Get(projectID int, params *ProjectGetParams) (*Project, error) { +func Get(projectID int64, params *ProjectGetParams) (*Project, error) { if projectID == 0 { return nil, fmt.Errorf("pushpad: project ID is required") } @@ -38,7 +38,7 @@ func Get(projectID int, params *ProjectGetParams) (*Project, error) { return &project, nil } -func Update(projectID int, params *ProjectUpdateParams) (*Project, error) { +func Update(projectID int64, params *ProjectUpdateParams) (*Project, error) { if params == nil { return nil, fmt.Errorf("pushpad: params are required") } @@ -54,7 +54,7 @@ func Update(projectID int, params *ProjectUpdateParams) (*Project, error) { return &project, nil } -func Delete(projectID int, params *ProjectDeleteParams) error { +func Delete(projectID int64, params *ProjectDeleteParams) error { if projectID == 0 { return fmt.Errorf("pushpad: project ID is required") } diff --git a/project/api_test.go b/project/api_test.go index 2f78b71..50b706f 100644 --- a/project/api_test.go +++ b/project/api_test.go @@ -42,7 +42,7 @@ func TestCreateProject(t *testing.T) { pushpad.Configure("TOKEN", 123) payload := &ProjectCreateParams{ - SenderID: pushpad.Int(9), + SenderID: pushpad.Int64(9), Name: pushpad.String("New Project"), Website: pushpad.String("https://example.com"), } @@ -59,12 +59,12 @@ func TestCreateProjectWithAllFields(t *testing.T) { defer gock.Off() params := ProjectCreateParams{ - SenderID: pushpad.Int(98765), + SenderID: pushpad.Int64(98765), Name: pushpad.String("My Project"), Website: pushpad.String("https://example.com"), IconURL: pushpad.String("https://example.com/icon.png"), BadgeURL: pushpad.String("https://example.com/badge.png"), - NotificationsTTL: pushpad.Int(604800), + NotificationsTTL: pushpad.Int64(604800), NotificationsRequireInteract: pushpad.Bool(false), NotificationsSilent: pushpad.Bool(false), } @@ -181,7 +181,7 @@ func TestAPIErrorOnServerFailure(t *testing.T) { pushpad.Configure("TOKEN", 123) _, err := Create(&ProjectCreateParams{ - SenderID: pushpad.Int(1), + SenderID: pushpad.Int64(1), Name: pushpad.String("Failing Project"), Website: pushpad.String("https://example.com"), }) diff --git a/project/models.go b/project/models.go index 4664a2d..f2a4bd8 100644 --- a/project/models.go +++ b/project/models.go @@ -4,13 +4,13 @@ import "time" // Project represents a Pushpad project. type Project struct { - ID int `json:"id,omitempty"` - SenderID int `json:"sender_id,omitempty"` + ID int64 `json:"id,omitempty"` + SenderID int64 `json:"sender_id,omitempty"` Name string `json:"name,omitempty"` Website string `json:"website,omitempty"` IconURL string `json:"icon_url,omitempty"` BadgeURL string `json:"badge_url,omitempty"` - NotificationsTTL *int `json:"notifications_ttl,omitempty"` + NotificationsTTL *int64 `json:"notifications_ttl,omitempty"` NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` NotificationsSilent *bool `json:"notifications_silent,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` @@ -18,12 +18,12 @@ type Project struct { // ProjectCreateParams is the payload to create a project. type ProjectCreateParams struct { - SenderID *int `json:"sender_id"` + SenderID *int64 `json:"sender_id"` Name *string `json:"name"` Website *string `json:"website"` IconURL *string `json:"icon_url,omitempty"` BadgeURL *string `json:"badge_url,omitempty"` - NotificationsTTL *int `json:"notifications_ttl,omitempty"` + NotificationsTTL *int64 `json:"notifications_ttl,omitempty"` NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` NotificationsSilent *bool `json:"notifications_silent,omitempty"` } @@ -34,7 +34,7 @@ type ProjectUpdateParams struct { Website *string `json:"website,omitempty"` IconURL *string `json:"icon_url,omitempty"` BadgeURL *string `json:"badge_url,omitempty"` - NotificationsTTL *int `json:"notifications_ttl,omitempty"` + NotificationsTTL *int64 `json:"notifications_ttl,omitempty"` NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` NotificationsSilent *bool `json:"notifications_silent,omitempty"` } diff --git a/pushpad.go b/pushpad.go index b4fe248..2d2e041 100644 --- a/pushpad.go +++ b/pushpad.go @@ -1,10 +1,10 @@ package pushpad var pushpadAuthToken string -var pushpadProjectID int +var pushpadProjectID int64 // Configure sets the global credentials for API calls. -func Configure(authToken string, projectID int) { +func Configure(authToken string, projectID int64) { pushpadAuthToken = authToken pushpadProjectID = projectID } diff --git a/sender/api.go b/sender/api.go index 385daba..dfa368f 100644 --- a/sender/api.go +++ b/sender/api.go @@ -25,7 +25,7 @@ func Create(params *SenderCreateParams) (*Sender, error) { return &created, nil } -func Get(senderID int, params *SenderGetParams) (*Sender, error) { +func Get(senderID int64, params *SenderGetParams) (*Sender, error) { if senderID == 0 { return nil, fmt.Errorf("pushpad: sender ID is required") } @@ -38,7 +38,7 @@ func Get(senderID int, params *SenderGetParams) (*Sender, error) { return &sender, nil } -func Update(senderID int, params *SenderUpdateParams) (*Sender, error) { +func Update(senderID int64, params *SenderUpdateParams) (*Sender, error) { if params == nil { return nil, fmt.Errorf("pushpad: params are required") } @@ -54,7 +54,7 @@ func Update(senderID int, params *SenderUpdateParams) (*Sender, error) { return &sender, nil } -func Delete(senderID int, params *SenderDeleteParams) error { +func Delete(senderID int64, params *SenderDeleteParams) error { if senderID == 0 { return fmt.Errorf("pushpad: sender ID is required") } diff --git a/sender/models.go b/sender/models.go index 4cea1f8..fa710ce 100644 --- a/sender/models.go +++ b/sender/models.go @@ -4,7 +4,7 @@ import "time" // Sender represents a Pushpad sender. type Sender struct { - ID int `json:"id,omitempty"` + ID int64 `json:"id,omitempty"` Name string `json:"name,omitempty"` VAPIDPrivateKey string `json:"vapid_private_key,omitempty"` VAPIDPublicKey string `json:"vapid_public_key,omitempty"` diff --git a/subscription/api.go b/subscription/api.go index e13397f..c7f9096 100644 --- a/subscription/api.go +++ b/subscription/api.go @@ -8,7 +8,7 @@ import ( "github.com/pushpad/pushpad-go" ) -func List(params *SubscriptionListParams) ([]Subscription, int, error) { +func List(params *SubscriptionListParams) ([]Subscription, int64, error) { if params == nil { params = &SubscriptionListParams{} } @@ -19,10 +19,10 @@ func List(params *SubscriptionListParams) ([]Subscription, int, error) { query := url.Values{} if params.Page != nil && *params.Page > 0 { - query.Set("page", strconv.Itoa(*params.Page)) + query.Set("page", strconv.FormatInt(*params.Page, 10)) } if params.PerPage != nil && *params.PerPage > 0 { - query.Set("per_page", strconv.Itoa(*params.PerPage)) + query.Set("per_page", strconv.FormatInt(*params.PerPage, 10)) } if params.UIDs != nil { for _, uid := range *params.UIDs { @@ -41,9 +41,9 @@ func List(params *SubscriptionListParams) ([]Subscription, int, error) { return nil, 0, err } - totalCount := 0 + var totalCount int64 if header := res.Header.Get("X-Total-Count"); header != "" { - if parsed, parseErr := strconv.Atoi(header); parseErr == nil { + if parsed, parseErr := strconv.ParseInt(header, 10, 64); parseErr == nil { totalCount = parsed } } @@ -68,7 +68,7 @@ func Create(params *SubscriptionCreateParams) (*Subscription, error) { return &created, nil } -func Get(subscriptionID int, params *SubscriptionGetParams) (*Subscription, error) { +func Get(subscriptionID int64, params *SubscriptionGetParams) (*Subscription, error) { if params == nil { params = &SubscriptionGetParams{} } @@ -88,7 +88,7 @@ func Get(subscriptionID int, params *SubscriptionGetParams) (*Subscription, erro return &subscription, nil } -func Update(subscriptionID int, params *SubscriptionUpdateParams) (*Subscription, error) { +func Update(subscriptionID int64, params *SubscriptionUpdateParams) (*Subscription, error) { if params == nil { return nil, fmt.Errorf("pushpad: params are required") } @@ -108,7 +108,7 @@ func Update(subscriptionID int, params *SubscriptionUpdateParams) (*Subscription return &subscription, nil } -func Delete(subscriptionID int, params *SubscriptionDeleteParams) error { +func Delete(subscriptionID int64, params *SubscriptionDeleteParams) error { if params == nil { params = &SubscriptionDeleteParams{} } diff --git a/subscription/api_test.go b/subscription/api_test.go index 4902525..787e927 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -25,9 +25,9 @@ func TestListSubscriptions(t *testing.T) { pushpad.Configure("TOKEN", 0) params := &SubscriptionListParams{ - ProjectID: pushpad.Int(123), - Page: pushpad.Int(1), - PerPage: pushpad.Int(20), + ProjectID: pushpad.Int64(123), + Page: pushpad.Int64(1), + PerPage: pushpad.Int64(20), UIDs: pushpad.Strings([]string{"u1", "u2"}), Tags: pushpad.Strings([]string{"tag1"}), } @@ -56,7 +56,7 @@ func TestListSubscriptionsNoOptions(t *testing.T) { BodyString(`[]`) pushpad.Configure("TOKEN", 0) - subscriptions, total, err := List(&SubscriptionListParams{ProjectID: pushpad.Int(123)}) + subscriptions, total, err := List(&SubscriptionListParams{ProjectID: pushpad.Int64(123)}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -79,7 +79,7 @@ func TestCreateSubscription(t *testing.T) { BodyString(`{"id":50,"endpoint":"https://example.com/1"}`) pushpad.Configure("TOKEN", 0) - subscription, err := Create(&SubscriptionCreateParams{ProjectID: pushpad.Int(123), Endpoint: pushpad.String("https://example.com/1")}) + subscription, err := Create(&SubscriptionCreateParams{ProjectID: pushpad.Int64(123), Endpoint: pushpad.String("https://example.com/1")}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -92,7 +92,7 @@ func TestCreateSubscriptionWithAllFields(t *testing.T) { defer gock.Off() params := SubscriptionCreateParams{ - ProjectID: pushpad.Int(123), + ProjectID: pushpad.Int64(123), Endpoint: pushpad.String("https://example.com/push/f7Q1Eyf7EyfAb1"), P256DH: pushpad.String("BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4="), Auth: pushpad.String("cdKMlhgVeSPzCXZ3V7FtgQ=="), @@ -134,7 +134,7 @@ func TestCreateSubscriptionMissingEndpoint(t *testing.T) { BodyString(`{"error":"validation error"}`) pushpad.Configure("TOKEN", 0) - _, err := Create(&SubscriptionCreateParams{ProjectID: pushpad.Int(123)}) + _, err := Create(&SubscriptionCreateParams{ProjectID: pushpad.Int64(123)}) apiErr, ok := err.(*pushpad.APIError) if !ok { t.Fatalf("expected APIError, got %T", err) @@ -157,7 +157,7 @@ func TestGetSubscription(t *testing.T) { BodyString(`{"id":50,"endpoint":"https://example.com/1"}`) pushpad.Configure("TOKEN", 0) - subscription, err := Get(50, &SubscriptionGetParams{ProjectID: pushpad.Int(123)}) + subscription, err := Get(50, &SubscriptionGetParams{ProjectID: pushpad.Int64(123)}) if err != nil { t.Fatalf("expected no error, got %s", err) } @@ -177,7 +177,7 @@ func TestUpdateSubscription(t *testing.T) { BodyString(`{"id":50,"uid":"new-user"}`) pushpad.Configure("TOKEN", 0) - update := &SubscriptionUpdateParams{ProjectID: pushpad.Int(123), UID: pushpad.String("new-user")} + update := &SubscriptionUpdateParams{ProjectID: pushpad.Int64(123), UID: pushpad.String("new-user")} subscription, err := Update(50, update) if err != nil { t.Fatalf("expected no error, got %s", err) @@ -196,7 +196,7 @@ func TestDeleteSubscription(t *testing.T) { Reply(204) pushpad.Configure("TOKEN", 0) - if err := Delete(50, &SubscriptionDeleteParams{ProjectID: pushpad.Int(123)}); err != nil { + if err := Delete(50, &SubscriptionDeleteParams{ProjectID: pushpad.Int64(123)}); err != nil { t.Fatalf("expected no error, got %s", err) } } diff --git a/subscription/models.go b/subscription/models.go index d4ec3b6..c20fc77 100644 --- a/subscription/models.go +++ b/subscription/models.go @@ -4,8 +4,8 @@ import "time" // Subscription represents a Pushpad subscription. type Subscription struct { - ID int `json:"id,omitempty"` - ProjectID int `json:"project_id,omitempty"` + ID int64 `json:"id,omitempty"` + ProjectID int64 `json:"project_id,omitempty"` Endpoint string `json:"endpoint,omitempty"` P256DH string `json:"p256dh,omitempty"` Auth string `json:"auth,omitempty"` @@ -17,7 +17,7 @@ type Subscription struct { // SubscriptionCreateParams is the payload to create a subscription. type SubscriptionCreateParams struct { - ProjectID *int `json:"-"` + ProjectID *int64 `json:"-"` Endpoint *string `json:"endpoint"` P256DH *string `json:"p256dh,omitempty"` Auth *string `json:"auth,omitempty"` @@ -27,26 +27,26 @@ type SubscriptionCreateParams struct { // SubscriptionUpdateParams is the payload to update a subscription. type SubscriptionUpdateParams struct { - ProjectID *int `json:"-"` + ProjectID *int64 `json:"-"` UID *string `json:"uid,omitempty"` Tags *[]string `json:"tags,omitempty"` } // SubscriptionListParams controls subscription listing. type SubscriptionListParams struct { - ProjectID *int - Page *int - PerPage *int + ProjectID *int64 + Page *int64 + PerPage *int64 UIDs *[]string Tags *[]string } // SubscriptionGetParams controls subscription fetches. type SubscriptionGetParams struct { - ProjectID *int + ProjectID *int64 } // SubscriptionDeleteParams controls subscription deletes. type SubscriptionDeleteParams struct { - ProjectID *int + ProjectID *int64 } From 8f9e98d97ced56e8ce8e14bf9e641cb06046e0be Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 22:17:17 +0100 Subject: [PATCH 11/28] Improve field types in struct used for responses Do not use pointers for fields that are always present in the response and for fields where it is not necessary to distinguish between null and "" (or similar) --- notification/models.go | 24 ++++++++++++------------ project/models.go | 8 ++++---- sender/models.go | 2 +- subscription/models.go | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/notification/models.go b/notification/models.go index c08731b..7f598e0 100644 --- a/notification/models.go +++ b/notification/models.go @@ -20,23 +20,23 @@ type Notification struct { IconURL string `json:"icon_url,omitempty"` BadgeURL string `json:"badge_url,omitempty"` ImageURL string `json:"image_url,omitempty"` - TTL *int64 `json:"ttl,omitempty"` - RequireInteraction *bool `json:"require_interaction,omitempty"` - Silent *bool `json:"silent,omitempty"` - Urgent *bool `json:"urgent,omitempty"` + TTL int64 `json:"ttl,omitempty"` + RequireInteraction bool `json:"require_interaction,omitempty"` + Silent bool `json:"silent,omitempty"` + Urgent bool `json:"urgent,omitempty"` CustomData string `json:"custom_data,omitempty"` Actions []NotificationAction `json:"actions,omitempty"` - Starred *bool `json:"starred,omitempty"` + Starred bool `json:"starred,omitempty"` SendAt *time.Time `json:"send_at,omitempty"` CustomMetrics []string `json:"custom_metrics,omitempty"` UIDs []string `json:"uids"` Tags []string `json:"tags"` - CreatedAt *time.Time `json:"created_at,omitempty"` - SuccessfullySent *int64 `json:"successfully_sent_count,omitempty"` - OpenedCount *int64 `json:"opened_count,omitempty"` - ScheduledCount *int64 `json:"scheduled_count,omitempty"` - Scheduled *bool `json:"scheduled,omitempty"` - Cancelled *bool `json:"cancelled,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + SuccessfullySent int64 `json:"successfully_sent_count,omitempty"` + OpenedCount int64 `json:"opened_count,omitempty"` + ScheduledCount int64 `json:"scheduled_count,omitempty"` + Scheduled bool `json:"scheduled,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` } // NotificationCreateParams represents a notification create payload. @@ -64,7 +64,7 @@ type NotificationCreateParams struct { // NotificationCreateResponse describes the response to creating a notification. type NotificationCreateResponse struct { ID int64 `json:"id"` - Scheduled *int64 `json:"scheduled,omitempty"` + Scheduled int64 `json:"scheduled,omitempty"` UIDs []string `json:"uids,omitempty"` SendAt *time.Time `json:"send_at,omitempty"` } diff --git a/project/models.go b/project/models.go index f2a4bd8..3e37150 100644 --- a/project/models.go +++ b/project/models.go @@ -10,10 +10,10 @@ type Project struct { Website string `json:"website,omitempty"` IconURL string `json:"icon_url,omitempty"` BadgeURL string `json:"badge_url,omitempty"` - NotificationsTTL *int64 `json:"notifications_ttl,omitempty"` - NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` - NotificationsSilent *bool `json:"notifications_silent,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` + NotificationsTTL int64 `json:"notifications_ttl,omitempty"` + NotificationsRequireInteract bool `json:"notifications_require_interaction,omitempty"` + NotificationsSilent bool `json:"notifications_silent,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` } // ProjectCreateParams is the payload to create a project. diff --git a/sender/models.go b/sender/models.go index fa710ce..7f2b8f5 100644 --- a/sender/models.go +++ b/sender/models.go @@ -8,7 +8,7 @@ type Sender struct { Name string `json:"name,omitempty"` VAPIDPrivateKey string `json:"vapid_private_key,omitempty"` VAPIDPublicKey string `json:"vapid_public_key,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` } // SenderCreateParams is the payload to create a sender. diff --git a/subscription/models.go b/subscription/models.go index c20fc77..59963b2 100644 --- a/subscription/models.go +++ b/subscription/models.go @@ -12,7 +12,7 @@ type Subscription struct { UID string `json:"uid,omitempty"` Tags []string `json:"tags,omitempty"` LastClickAt *time.Time `json:"last_click_at,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` } // SubscriptionCreateParams is the payload to create a subscription. From 2bfbd6a1723eac14b2838ba4a03c9ab7e3977f74 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 24 Dec 2025 22:35:52 +0100 Subject: [PATCH 12/28] Add TestGet*WithAllFields --- notification/api_test.go | 121 +++++++++++++++++++++++++++++++++++++-- project/api_test.go | 52 +++++++++++++++++ sender/api_test.go | 37 ++++++++++++ subscription/api_test.go | 54 +++++++++++++++++ 4 files changed, 260 insertions(+), 4 deletions(-) diff --git a/notification/api_test.go b/notification/api_test.go index 00cb48c..7498795 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -69,8 +69,8 @@ func TestCreateNotification(t *testing.T) { if response.ID != 99 { t.Errorf("expected notification ID 99, got %d", response.ID) } - if response.Scheduled == nil || *response.Scheduled != 10 { - t.Errorf("expected scheduled count 10, got %v", response.Scheduled) + if response.Scheduled != 10 { + t.Errorf("expected scheduled count 10, got %d", response.Scheduled) } } @@ -131,8 +131,8 @@ func TestCreateNotificationWithAllFields(t *testing.T) { if response.ID != 123456789 { t.Errorf("expected notification ID 123456789, got %d", response.ID) } - if response.Scheduled == nil || *response.Scheduled != 9876 { - t.Errorf("expected scheduled count 9876, got %v", response.Scheduled) + if response.Scheduled != 9876 { + t.Errorf("expected scheduled count 9876, got %d", response.Scheduled) } } @@ -236,6 +236,119 @@ func TestGetNotification(t *testing.T) { } } +func TestGetNotificationWithAllFields(t *testing.T) { + defer gock.Off() + + sendAt, err := time.Parse(time.RFC3339Nano, "2016-07-06T10:09:00.000Z") + if err != nil { + t.Fatalf("expected no error parsing send_at, got %s", err) + } + + createdAt, err := time.Parse(time.RFC3339Nano, "2016-07-06T10:58:39.192Z") + if err != nil { + t.Fatalf("expected no error parsing created_at, got %s", err) + } + + gock.New("https://pushpad.xyz"). + Get("/api/v1/notifications/123456789"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":123456789,"project_id":123,"title":"Foo Bar","body":"Lorem ipsum dolor sit amet, consectetur adipiscing elit.","target_url":"https://example.com","icon_url":"https://example.com/assets/icon.png","badge_url":"https://example.com/assets/badge.png","image_url":"https://example.com/assets/image.png","ttl":604800,"require_interaction":false,"silent":false,"urgent":false,"custom_data":"","actions":[{"title":"A button","target_url":"https://example.com/button-link","icon":"https://example.com/assets/button-icon.png","action":"myActionName"}],"starred":false,"send_at":"2016-07-06T10:09:00.000Z","custom_metrics":["metric1","metric2"],"uids":["uid0","uid1","uidN"],"tags":["tag1","tagA && !tagB"],"created_at":"2016-07-06T10:58:39.192Z","successfully_sent_count":4,"opened_count":1,"scheduled_count":400,"scheduled":true,"cancelled":false}`) + + pushpad.Configure("TOKEN", 123) + notification, err := Get(123456789, nil) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if notification.ID != 123456789 { + t.Errorf("expected notification ID 123456789, got %d", notification.ID) + } + if notification.ProjectID != 123 { + t.Errorf("expected project ID 123, got %d", notification.ProjectID) + } + if notification.Title != "Foo Bar" { + t.Errorf("expected title Foo Bar, got %q", notification.Title) + } + if notification.Body != "Lorem ipsum dolor sit amet, consectetur adipiscing elit." { + t.Errorf("expected body Lorem ipsum..., got %q", notification.Body) + } + if notification.TargetURL != "https://example.com" { + t.Errorf("expected target_url https://example.com, got %q", notification.TargetURL) + } + if notification.IconURL != "https://example.com/assets/icon.png" { + t.Errorf("expected icon_url https://example.com/assets/icon.png, got %q", notification.IconURL) + } + if notification.BadgeURL != "https://example.com/assets/badge.png" { + t.Errorf("expected badge_url https://example.com/assets/badge.png, got %q", notification.BadgeURL) + } + if notification.ImageURL != "https://example.com/assets/image.png" { + t.Errorf("expected image_url https://example.com/assets/image.png, got %q", notification.ImageURL) + } + if notification.TTL != 604800 { + t.Errorf("expected ttl 604800, got %d", notification.TTL) + } + if notification.RequireInteraction != false { + t.Errorf("expected require_interaction false, got %v", notification.RequireInteraction) + } + if notification.Silent != false { + t.Errorf("expected silent false, got %v", notification.Silent) + } + if notification.Urgent != false { + t.Errorf("expected urgent false, got %v", notification.Urgent) + } + if notification.CustomData != "" { + t.Errorf("expected custom_data empty string, got %q", notification.CustomData) + } + if len(notification.Actions) != 1 { + t.Fatalf("expected 1 action, got %d", len(notification.Actions)) + } + if notification.Actions[0].Title == nil || *notification.Actions[0].Title != "A button" { + t.Errorf("expected action title A button, got %v", notification.Actions[0].Title) + } + if notification.Actions[0].TargetURL == nil || *notification.Actions[0].TargetURL != "https://example.com/button-link" { + t.Errorf("expected action target_url https://example.com/button-link, got %v", notification.Actions[0].TargetURL) + } + if notification.Actions[0].Icon == nil || *notification.Actions[0].Icon != "https://example.com/assets/button-icon.png" { + t.Errorf("expected action icon https://example.com/assets/button-icon.png, got %v", notification.Actions[0].Icon) + } + if notification.Actions[0].Action == nil || *notification.Actions[0].Action != "myActionName" { + t.Errorf("expected action myActionName, got %v", notification.Actions[0].Action) + } + if notification.Starred != false { + t.Errorf("expected starred false, got %v", notification.Starred) + } + if notification.SendAt == nil || !notification.SendAt.Equal(sendAt) { + t.Errorf("expected send_at %s, got %v", sendAt.Format(time.RFC3339Nano), notification.SendAt) + } + if len(notification.CustomMetrics) != 2 || notification.CustomMetrics[0] != "metric1" || notification.CustomMetrics[1] != "metric2" { + t.Errorf("expected custom_metrics [metric1 metric2], got %v", notification.CustomMetrics) + } + if len(notification.UIDs) != 3 || notification.UIDs[0] != "uid0" || notification.UIDs[1] != "uid1" || notification.UIDs[2] != "uidN" { + t.Errorf("expected uids [uid0 uid1 uidN], got %v", notification.UIDs) + } + if len(notification.Tags) != 2 || notification.Tags[0] != "tag1" || notification.Tags[1] != "tagA && !tagB" { + t.Errorf("expected tags [tag1 tagA && !tagB], got %v", notification.Tags) + } + if !notification.CreatedAt.Equal(createdAt) { + t.Errorf("expected created_at %s, got %s", createdAt.Format(time.RFC3339Nano), notification.CreatedAt.Format(time.RFC3339Nano)) + } + if notification.SuccessfullySent != 4 { + t.Errorf("expected successfully_sent_count 4, got %d", notification.SuccessfullySent) + } + if notification.OpenedCount != 1 { + t.Errorf("expected opened_count 1, got %d", notification.OpenedCount) + } + if notification.ScheduledCount != 400 { + t.Errorf("expected scheduled_count 400, got %d", notification.ScheduledCount) + } + if notification.Scheduled != true { + t.Errorf("expected scheduled true, got %v", notification.Scheduled) + } + if notification.Cancelled != false { + t.Errorf("expected cancelled false, got %v", notification.Cancelled) + } +} + func TestCancelNotification(t *testing.T) { defer gock.Off() diff --git a/project/api_test.go b/project/api_test.go index 50b706f..dd7830f 100644 --- a/project/api_test.go +++ b/project/api_test.go @@ -3,6 +3,7 @@ package project import ( "encoding/json" "testing" + "time" "github.com/h2non/gock" "github.com/pushpad/pushpad-go" @@ -135,6 +136,57 @@ func TestGetProject(t *testing.T) { } } +func TestGetProjectWithAllFields(t *testing.T) { + defer gock.Off() + + createdAt, err := time.Parse(time.RFC3339Nano, "2016-07-06T10:58:39.192Z") + if err != nil { + t.Fatalf("expected no error parsing created_at, got %s", err) + } + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects/98765"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":98765,"sender_id":9,"name":"My Project","website":"https://example.com","icon_url":"https://example.com/icon.png","badge_url":"https://example.com/badge.png","notifications_ttl":604800,"notifications_require_interaction":false,"notifications_silent":false,"created_at":"2016-07-06T10:58:39.192Z"}`) + + pushpad.Configure("TOKEN", 123) + project, err := Get(98765, nil) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if project.ID != 98765 { + t.Errorf("expected project ID 98765, got %d", project.ID) + } + if project.SenderID != 9 { + t.Errorf("expected sender ID 9, got %d", project.SenderID) + } + if project.Name != "My Project" { + t.Errorf("expected name My Project, got %q", project.Name) + } + if project.Website != "https://example.com" { + t.Errorf("expected website https://example.com, got %q", project.Website) + } + if project.IconURL != "https://example.com/icon.png" { + t.Errorf("expected icon_url https://example.com/icon.png, got %q", project.IconURL) + } + if project.BadgeURL != "https://example.com/badge.png" { + t.Errorf("expected badge_url https://example.com/badge.png, got %q", project.BadgeURL) + } + if project.NotificationsTTL != 604800 { + t.Errorf("expected notifications_ttl 604800, got %d", project.NotificationsTTL) + } + if project.NotificationsRequireInteract != false { + t.Errorf("expected notifications_require_interaction false, got %v", project.NotificationsRequireInteract) + } + if project.NotificationsSilent != false { + t.Errorf("expected notifications_silent false, got %v", project.NotificationsSilent) + } + if !project.CreatedAt.Equal(createdAt) { + t.Errorf("expected created_at %s, got %s", createdAt.Format(time.RFC3339Nano), project.CreatedAt.Format(time.RFC3339Nano)) + } +} + func TestUpdateProject(t *testing.T) { defer gock.Off() diff --git a/sender/api_test.go b/sender/api_test.go index 48fcdce..05c8a7f 100644 --- a/sender/api_test.go +++ b/sender/api_test.go @@ -3,6 +3,7 @@ package sender import ( "encoding/json" "testing" + "time" "github.com/h2non/gock" "github.com/pushpad/pushpad-go" @@ -125,6 +126,42 @@ func TestGetSender(t *testing.T) { } } +func TestGetSenderWithAllFields(t *testing.T) { + defer gock.Off() + + createdAt, err := time.Parse(time.RFC3339Nano, "2016-07-06T10:58:39.192Z") + if err != nil { + t.Fatalf("expected no error parsing created_at, got %s", err) + } + + gock.New("https://pushpad.xyz"). + Get("/api/v1/senders/98765"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":98765,"name":"My Sender","vapid_private_key":"-----BEGIN EC PRIVATE KEY----- ...","vapid_public_key":"-----BEGIN PUBLIC KEY----- ...","created_at":"2016-07-06T10:58:39.192Z"}`) + + pushpad.Configure("TOKEN", 123) + sender, err := Get(98765, nil) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if sender.ID != 98765 { + t.Errorf("expected sender ID 98765, got %d", sender.ID) + } + if sender.Name != "My Sender" { + t.Errorf("expected name My Sender, got %q", sender.Name) + } + if sender.VAPIDPrivateKey != "-----BEGIN EC PRIVATE KEY----- ..." { + t.Errorf("expected VAPID private key value, got %q", sender.VAPIDPrivateKey) + } + if sender.VAPIDPublicKey != "-----BEGIN PUBLIC KEY----- ..." { + t.Errorf("expected VAPID public key value, got %q", sender.VAPIDPublicKey) + } + if !sender.CreatedAt.Equal(createdAt) { + t.Errorf("expected created_at %s, got %s", createdAt.Format(time.RFC3339Nano), sender.CreatedAt.Format(time.RFC3339Nano)) + } +} + func TestUpdateSender(t *testing.T) { defer gock.Off() diff --git a/subscription/api_test.go b/subscription/api_test.go index 787e927..6b1be97 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -3,6 +3,7 @@ package subscription import ( "encoding/json" "testing" + "time" "github.com/h2non/gock" "github.com/pushpad/pushpad-go" @@ -166,6 +167,59 @@ func TestGetSubscription(t *testing.T) { } } +func TestGetSubscriptionWithAllFields(t *testing.T) { + defer gock.Off() + + lastClickAt, err := time.Parse(time.RFC3339Nano, "2016-07-06T10:09:00.000Z") + if err != nil { + t.Fatalf("expected no error parsing last_click_at, got %s", err) + } + + createdAt, err := time.Parse(time.RFC3339Nano, "2016-07-06T10:58:39.192Z") + if err != nil { + t.Fatalf("expected no error parsing created_at, got %s", err) + } + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects/123/subscriptions/456"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":456,"project_id":123,"endpoint":"https://example.com/push/f7Q1Eyf7EyfAb1","p256dh":"BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4=","auth":"cdKMlhgVeSPzCXZ3V7FtgQ==","uid":"user1","tags":["tag1","tag2"],"last_click_at":"2016-07-06T10:09:00.000Z","created_at":"2016-07-06T10:58:39.192Z"}`) + + pushpad.Configure("TOKEN", 0) + subscription, err := Get(456, &SubscriptionGetParams{ProjectID: pushpad.Int64(123)}) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if subscription.ID != 456 { + t.Errorf("expected subscription ID 456, got %d", subscription.ID) + } + if subscription.ProjectID != 123 { + t.Errorf("expected project ID 123, got %d", subscription.ProjectID) + } + if subscription.Endpoint != "https://example.com/push/f7Q1Eyf7EyfAb1" { + t.Errorf("expected endpoint https://example.com/push/f7Q1Eyf7EyfAb1, got %q", subscription.Endpoint) + } + if subscription.P256DH != "BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4=" { + t.Errorf("expected p256dh value, got %q", subscription.P256DH) + } + if subscription.Auth != "cdKMlhgVeSPzCXZ3V7FtgQ==" { + t.Errorf("expected auth cdKMlhgVeSPzCXZ3V7FtgQ==, got %q", subscription.Auth) + } + if subscription.UID != "user1" { + t.Errorf("expected uid user1, got %q", subscription.UID) + } + if len(subscription.Tags) != 2 || subscription.Tags[0] != "tag1" || subscription.Tags[1] != "tag2" { + t.Errorf("expected tags [tag1 tag2], got %v", subscription.Tags) + } + if subscription.LastClickAt == nil || !subscription.LastClickAt.Equal(lastClickAt) { + t.Errorf("expected last_click_at %s, got %v", lastClickAt.Format(time.RFC3339Nano), subscription.LastClickAt) + } + if !subscription.CreatedAt.Equal(createdAt) { + t.Errorf("expected created_at %s, got %s", createdAt.Format(time.RFC3339Nano), subscription.CreatedAt.Format(time.RFC3339Nano)) + } +} + func TestUpdateSubscription(t *testing.T) { defer gock.Off() From 4145d3cecdefedf92db88b19f43e77116c6ddb8f Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Fri, 26 Dec 2025 15:35:49 +0100 Subject: [PATCH 13/28] Add NotificationActionParams separate from NotificationAction --- notification/api_test.go | 10 +++++----- notification/helpers.go | 2 +- notification/models.go | 20 ++++++++++++++------ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/notification/api_test.go b/notification/api_test.go index 7498795..243f4e9 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -96,7 +96,7 @@ func TestCreateNotificationWithAllFields(t *testing.T) { Urgent: pushpad.Bool(false), CustomData: pushpad.String(""), Actions: Actions( - NotificationAction{ + NotificationActionParams{ Title: pushpad.String("A button"), TargetURL: pushpad.String("https://example.com/button-link"), Icon: pushpad.String("https://example.com/assets/button-icon.png"), @@ -302,16 +302,16 @@ func TestGetNotificationWithAllFields(t *testing.T) { if len(notification.Actions) != 1 { t.Fatalf("expected 1 action, got %d", len(notification.Actions)) } - if notification.Actions[0].Title == nil || *notification.Actions[0].Title != "A button" { + if notification.Actions[0].Title != "A button" { t.Errorf("expected action title A button, got %v", notification.Actions[0].Title) } - if notification.Actions[0].TargetURL == nil || *notification.Actions[0].TargetURL != "https://example.com/button-link" { + if notification.Actions[0].TargetURL != "https://example.com/button-link" { t.Errorf("expected action target_url https://example.com/button-link, got %v", notification.Actions[0].TargetURL) } - if notification.Actions[0].Icon == nil || *notification.Actions[0].Icon != "https://example.com/assets/button-icon.png" { + if notification.Actions[0].Icon != "https://example.com/assets/button-icon.png" { t.Errorf("expected action icon https://example.com/assets/button-icon.png, got %v", notification.Actions[0].Icon) } - if notification.Actions[0].Action == nil || *notification.Actions[0].Action != "myActionName" { + if notification.Actions[0].Action != "myActionName" { t.Errorf("expected action myActionName, got %v", notification.Actions[0].Action) } if notification.Starred != false { diff --git a/notification/helpers.go b/notification/helpers.go index d64fa1c..10a298a 100644 --- a/notification/helpers.go +++ b/notification/helpers.go @@ -1,6 +1,6 @@ package notification // Actions returns a pointer to a slice of actions for optional payload fields. -func Actions(actions ...NotificationAction) *[]NotificationAction { +func Actions(actions ...NotificationActionParams) *[]NotificationActionParams { return &actions } diff --git a/notification/models.go b/notification/models.go index 7f598e0..cb947cf 100644 --- a/notification/models.go +++ b/notification/models.go @@ -2,12 +2,12 @@ package notification import "time" -// NotificationAction represents a notification action button. +// NotificationAction represents a notification action button in responses. type NotificationAction struct { - Title *string `json:"title,omitempty"` - TargetURL *string `json:"target_url,omitempty"` - Icon *string `json:"icon,omitempty"` - Action *string `json:"action,omitempty"` + Title string `json:"title,omitempty"` + TargetURL string `json:"target_url,omitempty"` + Icon string `json:"icon,omitempty"` + Action string `json:"action,omitempty"` } // Notification represents a Pushpad notification. @@ -39,6 +39,14 @@ type Notification struct { Cancelled bool `json:"cancelled,omitempty"` } +// NotificationActionParams represents a notification action button in create payloads. +type NotificationActionParams struct { + Title *string `json:"title,omitempty"` + TargetURL *string `json:"target_url,omitempty"` + Icon *string `json:"icon,omitempty"` + Action *string `json:"action,omitempty"` +} + // NotificationCreateParams represents a notification create payload. type NotificationCreateParams struct { ProjectID *int64 `json:"-"` @@ -53,7 +61,7 @@ type NotificationCreateParams struct { Silent *bool `json:"silent,omitempty"` Urgent *bool `json:"urgent,omitempty"` CustomData *string `json:"custom_data,omitempty"` - Actions *[]NotificationAction `json:"actions,omitempty"` + Actions *[]NotificationActionParams `json:"actions,omitempty"` Starred *bool `json:"starred,omitempty"` SendAt *time.Time `json:"send_at,omitempty"` CustomMetrics *[]string `json:"custom_metrics,omitempty"` From 0914e797c69d7aa87f3ebfdd7c683ea643e41753 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Fri, 26 Dec 2025 15:46:54 +0100 Subject: [PATCH 14/28] Remove the unnecessary Actions helper --- notification/api_test.go | 6 +++--- notification/helpers.go | 6 ------ 2 files changed, 3 insertions(+), 9 deletions(-) delete mode 100644 notification/helpers.go diff --git a/notification/api_test.go b/notification/api_test.go index 243f4e9..33b1a1a 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -95,14 +95,14 @@ func TestCreateNotificationWithAllFields(t *testing.T) { Silent: pushpad.Bool(false), Urgent: pushpad.Bool(false), CustomData: pushpad.String(""), - Actions: Actions( - NotificationActionParams{ + Actions: &[]NotificationActionParams{ + { Title: pushpad.String("A button"), TargetURL: pushpad.String("https://example.com/button-link"), Icon: pushpad.String("https://example.com/assets/button-icon.png"), Action: pushpad.String("myActionName"), }, - ), + }, Starred: pushpad.Bool(false), SendAt: pushpad.Time(sendAt), CustomMetrics: pushpad.Strings([]string{"metric1", "metric2"}), diff --git a/notification/helpers.go b/notification/helpers.go deleted file mode 100644 index 10a298a..0000000 --- a/notification/helpers.go +++ /dev/null @@ -1,6 +0,0 @@ -package notification - -// Actions returns a pointer to a slice of actions for optional payload fields. -func Actions(actions ...NotificationActionParams) *[]NotificationActionParams { - return &actions -} From be0f9af586b80b5520ccec702e39e18c92f5ef71 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Fri, 26 Dec 2025 16:19:05 +0100 Subject: [PATCH 15/28] Update Go version and dependencies --- go.mod | 9 ++++----- go.sum | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 542936e..67a28a7 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,7 @@ module github.com/pushpad/pushpad-go -go 1.19 +go 1.25 -require ( - github.com/h2non/gock v1.2.0 - github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 -) +require github.com/h2non/gock v1.2.0 + +require github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect diff --git a/go.sum b/go.sum index 5c4db69..3cb4c38 100644 --- a/go.sum +++ b/go.sum @@ -2,4 +2,5 @@ github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= From ce9d373c3d929b13b882a7f1850d5df4c43da3ed Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Fri, 26 Dec 2025 17:32:32 +0100 Subject: [PATCH 16/28] For consistency, never use pointers for fields in response struct --- notification/api_test.go | 4 ++-- notification/models.go | 18 +++++++++--------- subscription/api_test.go | 4 ++-- subscription/models.go | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/notification/api_test.go b/notification/api_test.go index 33b1a1a..e8fc04b 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -317,8 +317,8 @@ func TestGetNotificationWithAllFields(t *testing.T) { if notification.Starred != false { t.Errorf("expected starred false, got %v", notification.Starred) } - if notification.SendAt == nil || !notification.SendAt.Equal(sendAt) { - t.Errorf("expected send_at %s, got %v", sendAt.Format(time.RFC3339Nano), notification.SendAt) + if !notification.SendAt.Equal(sendAt) { + t.Errorf("expected send_at %s, got %s", sendAt.Format(time.RFC3339Nano), notification.SendAt.Format(time.RFC3339Nano)) } if len(notification.CustomMetrics) != 2 || notification.CustomMetrics[0] != "metric1" || notification.CustomMetrics[1] != "metric2" { t.Errorf("expected custom_metrics [metric1 metric2], got %v", notification.CustomMetrics) diff --git a/notification/models.go b/notification/models.go index cb947cf..f3f224e 100644 --- a/notification/models.go +++ b/notification/models.go @@ -27,7 +27,7 @@ type Notification struct { CustomData string `json:"custom_data,omitempty"` Actions []NotificationAction `json:"actions,omitempty"` Starred bool `json:"starred,omitempty"` - SendAt *time.Time `json:"send_at,omitempty"` + SendAt time.Time `json:"send_at,omitempty"` CustomMetrics []string `json:"custom_metrics,omitempty"` UIDs []string `json:"uids"` Tags []string `json:"tags"` @@ -39,6 +39,14 @@ type Notification struct { Cancelled bool `json:"cancelled,omitempty"` } +// NotificationCreateResponse describes the response to creating a notification. +type NotificationCreateResponse struct { + ID int64 `json:"id"` + Scheduled int64 `json:"scheduled,omitempty"` + UIDs []string `json:"uids,omitempty"` + SendAt time.Time `json:"send_at,omitempty"` +} + // NotificationActionParams represents a notification action button in create payloads. type NotificationActionParams struct { Title *string `json:"title,omitempty"` @@ -69,14 +77,6 @@ type NotificationCreateParams struct { Tags *[]string `json:"tags"` } -// NotificationCreateResponse describes the response to creating a notification. -type NotificationCreateResponse struct { - ID int64 `json:"id"` - Scheduled int64 `json:"scheduled,omitempty"` - UIDs []string `json:"uids,omitempty"` - SendAt *time.Time `json:"send_at,omitempty"` -} - // NotificationListParams controls notification listing. type NotificationListParams struct { ProjectID *int64 diff --git a/subscription/api_test.go b/subscription/api_test.go index 6b1be97..b538054 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -212,8 +212,8 @@ func TestGetSubscriptionWithAllFields(t *testing.T) { if len(subscription.Tags) != 2 || subscription.Tags[0] != "tag1" || subscription.Tags[1] != "tag2" { t.Errorf("expected tags [tag1 tag2], got %v", subscription.Tags) } - if subscription.LastClickAt == nil || !subscription.LastClickAt.Equal(lastClickAt) { - t.Errorf("expected last_click_at %s, got %v", lastClickAt.Format(time.RFC3339Nano), subscription.LastClickAt) + if !subscription.LastClickAt.Equal(lastClickAt) { + t.Errorf("expected last_click_at %s, got %s", lastClickAt.Format(time.RFC3339Nano), subscription.LastClickAt.Format(time.RFC3339Nano)) } if !subscription.CreatedAt.Equal(createdAt) { t.Errorf("expected created_at %s, got %s", createdAt.Format(time.RFC3339Nano), subscription.CreatedAt.Format(time.RFC3339Nano)) diff --git a/subscription/models.go b/subscription/models.go index 59963b2..62da28c 100644 --- a/subscription/models.go +++ b/subscription/models.go @@ -11,7 +11,7 @@ type Subscription struct { Auth string `json:"auth,omitempty"` UID string `json:"uid,omitempty"` Tags []string `json:"tags,omitempty"` - LastClickAt *time.Time `json:"last_click_at,omitempty"` + LastClickAt time.Time `json:"last_click_at,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` } From 6902f5555eff7c95d695da43e7fb074bbdc04d5a Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Fri, 26 Dec 2025 17:45:42 +0100 Subject: [PATCH 17/28] Use omitempty only for params, but not for struct used for responses --- notification/models.go | 60 +++++++++++++++++++++--------------------- project/models.go | 26 +++++++++--------- sender/models.go | 12 ++++----- subscription/models.go | 20 +++++++------- 4 files changed, 59 insertions(+), 59 deletions(-) diff --git a/notification/models.go b/notification/models.go index f3f224e..b54e31e 100644 --- a/notification/models.go +++ b/notification/models.go @@ -4,47 +4,47 @@ import "time" // NotificationAction represents a notification action button in responses. type NotificationAction struct { - Title string `json:"title,omitempty"` - TargetURL string `json:"target_url,omitempty"` - Icon string `json:"icon,omitempty"` - Action string `json:"action,omitempty"` + Title string `json:"title"` + TargetURL string `json:"target_url"` + Icon string `json:"icon"` + Action string `json:"action"` } // Notification represents a Pushpad notification. type Notification struct { - ID int64 `json:"id,omitempty"` - ProjectID int64 `json:"project_id,omitempty"` - Title string `json:"title,omitempty"` - Body string `json:"body,omitempty"` - TargetURL string `json:"target_url,omitempty"` - IconURL string `json:"icon_url,omitempty"` - BadgeURL string `json:"badge_url,omitempty"` - ImageURL string `json:"image_url,omitempty"` - TTL int64 `json:"ttl,omitempty"` - RequireInteraction bool `json:"require_interaction,omitempty"` - Silent bool `json:"silent,omitempty"` - Urgent bool `json:"urgent,omitempty"` - CustomData string `json:"custom_data,omitempty"` - Actions []NotificationAction `json:"actions,omitempty"` - Starred bool `json:"starred,omitempty"` - SendAt time.Time `json:"send_at,omitempty"` - CustomMetrics []string `json:"custom_metrics,omitempty"` + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + Title string `json:"title"` + Body string `json:"body"` + TargetURL string `json:"target_url"` + IconURL string `json:"icon_url"` + BadgeURL string `json:"badge_url"` + ImageURL string `json:"image_url"` + TTL int64 `json:"ttl"` + RequireInteraction bool `json:"require_interaction"` + Silent bool `json:"silent"` + Urgent bool `json:"urgent"` + CustomData string `json:"custom_data"` + Actions []NotificationAction `json:"actions"` + Starred bool `json:"starred"` + SendAt time.Time `json:"send_at"` + CustomMetrics []string `json:"custom_metrics"` UIDs []string `json:"uids"` Tags []string `json:"tags"` - CreatedAt time.Time `json:"created_at,omitempty"` - SuccessfullySent int64 `json:"successfully_sent_count,omitempty"` - OpenedCount int64 `json:"opened_count,omitempty"` - ScheduledCount int64 `json:"scheduled_count,omitempty"` - Scheduled bool `json:"scheduled,omitempty"` - Cancelled bool `json:"cancelled,omitempty"` + CreatedAt time.Time `json:"created_at"` + SuccessfullySent int64 `json:"successfully_sent_count"` + OpenedCount int64 `json:"opened_count"` + ScheduledCount int64 `json:"scheduled_count"` + Scheduled bool `json:"scheduled"` + Cancelled bool `json:"cancelled"` } // NotificationCreateResponse describes the response to creating a notification. type NotificationCreateResponse struct { ID int64 `json:"id"` - Scheduled int64 `json:"scheduled,omitempty"` - UIDs []string `json:"uids,omitempty"` - SendAt time.Time `json:"send_at,omitempty"` + Scheduled int64 `json:"scheduled"` + UIDs []string `json:"uids"` + SendAt time.Time `json:"send_at"` } // NotificationActionParams represents a notification action button in create payloads. diff --git a/project/models.go b/project/models.go index 3e37150..c90e0ba 100644 --- a/project/models.go +++ b/project/models.go @@ -4,23 +4,23 @@ import "time" // Project represents a Pushpad project. type Project struct { - ID int64 `json:"id,omitempty"` - SenderID int64 `json:"sender_id,omitempty"` - Name string `json:"name,omitempty"` - Website string `json:"website,omitempty"` - IconURL string `json:"icon_url,omitempty"` - BadgeURL string `json:"badge_url,omitempty"` - NotificationsTTL int64 `json:"notifications_ttl,omitempty"` - NotificationsRequireInteract bool `json:"notifications_require_interaction,omitempty"` - NotificationsSilent bool `json:"notifications_silent,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` + ID int64 `json:"id"` + SenderID int64 `json:"sender_id"` + Name string `json:"name"` + Website string `json:"website"` + IconURL string `json:"icon_url"` + BadgeURL string `json:"badge_url"` + NotificationsTTL int64 `json:"notifications_ttl"` + NotificationsRequireInteract bool `json:"notifications_require_interaction"` + NotificationsSilent bool `json:"notifications_silent"` + CreatedAt time.Time `json:"created_at"` } // ProjectCreateParams is the payload to create a project. type ProjectCreateParams struct { - SenderID *int64 `json:"sender_id"` - Name *string `json:"name"` - Website *string `json:"website"` + SenderID *int64 `json:"sender_id,omitempty"` + Name *string `json:"name,omitempty"` + Website *string `json:"website,omitempty"` IconURL *string `json:"icon_url,omitempty"` BadgeURL *string `json:"badge_url,omitempty"` NotificationsTTL *int64 `json:"notifications_ttl,omitempty"` diff --git a/sender/models.go b/sender/models.go index 7f2b8f5..1d68e4c 100644 --- a/sender/models.go +++ b/sender/models.go @@ -4,16 +4,16 @@ import "time" // Sender represents a Pushpad sender. type Sender struct { - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - VAPIDPrivateKey string `json:"vapid_private_key,omitempty"` - VAPIDPublicKey string `json:"vapid_public_key,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + VAPIDPrivateKey string `json:"vapid_private_key"` + VAPIDPublicKey string `json:"vapid_public_key"` + CreatedAt time.Time `json:"created_at"` } // SenderCreateParams is the payload to create a sender. type SenderCreateParams struct { - Name *string `json:"name"` + Name *string `json:"name,omitempty"` VAPIDPrivateKey *string `json:"vapid_private_key,omitempty"` VAPIDPublicKey *string `json:"vapid_public_key,omitempty"` } diff --git a/subscription/models.go b/subscription/models.go index 62da28c..33080a6 100644 --- a/subscription/models.go +++ b/subscription/models.go @@ -4,21 +4,21 @@ import "time" // Subscription represents a Pushpad subscription. type Subscription struct { - ID int64 `json:"id,omitempty"` - ProjectID int64 `json:"project_id,omitempty"` - Endpoint string `json:"endpoint,omitempty"` - P256DH string `json:"p256dh,omitempty"` - Auth string `json:"auth,omitempty"` - UID string `json:"uid,omitempty"` - Tags []string `json:"tags,omitempty"` - LastClickAt time.Time `json:"last_click_at,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + Endpoint string `json:"endpoint"` + P256DH string `json:"p256dh"` + Auth string `json:"auth"` + UID string `json:"uid"` + Tags []string `json:"tags"` + LastClickAt time.Time `json:"last_click_at"` + CreatedAt time.Time `json:"created_at"` } // SubscriptionCreateParams is the payload to create a subscription. type SubscriptionCreateParams struct { ProjectID *int64 `json:"-"` - Endpoint *string `json:"endpoint"` + Endpoint *string `json:"endpoint,omitempty"` P256DH *string `json:"p256dh,omitempty"` Auth *string `json:"auth,omitempty"` UID *string `json:"uid,omitempty"` From fda8c275667bba307bd32da8a14aba6ed8d2d4b8 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Fri, 26 Dec 2025 17:52:31 +0100 Subject: [PATCH 18/28] Add assertion about send_at in notification response being zero value --- notification/api_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/notification/api_test.go b/notification/api_test.go index e8fc04b..1e58b3a 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -72,6 +72,9 @@ func TestCreateNotification(t *testing.T) { if response.Scheduled != 10 { t.Errorf("expected scheduled count 10, got %d", response.Scheduled) } + if !response.SendAt.IsZero() { + t.Errorf("expected send_at to be zero value, got %s", response.SendAt) + } } func TestCreateNotificationWithAllFields(t *testing.T) { From d8fd4f441108706d31339458e66bf252a0c47288 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Fri, 26 Dec 2025 18:06:41 +0100 Subject: [PATCH 19/28] Add test to cover null JSON values mapping to zero values --- notification/api_test.go | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/notification/api_test.go b/notification/api_test.go index 1e58b3a..225010d 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -352,6 +352,49 @@ func TestGetNotificationWithAllFields(t *testing.T) { } } +func TestGetNotificationWithNullFields(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/notifications/88"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + BodyString(`{"id":88,"body":"Hello","image_url":null,"custom_data":null,"actions":null,"send_at":null,"custom_metrics":null,"uids":null,"tags":null,"scheduled_count":null,"scheduled":null}`) + + pushpad.Configure("TOKEN", 123) + notification, err := Get(88, nil) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if notification.ImageURL != "" { + t.Errorf("expected image_url empty string, got %q", notification.ImageURL) + } + if notification.CustomData != "" { + t.Errorf("expected custom_data empty string, got %q", notification.CustomData) + } + if notification.Actions != nil { + t.Errorf("expected actions nil, got %v", notification.Actions) + } + if !notification.SendAt.IsZero() { + t.Errorf("expected send_at zero value, got %s", notification.SendAt) + } + if notification.CustomMetrics != nil { + t.Errorf("expected custom_metrics nil, got %v", notification.CustomMetrics) + } + if notification.UIDs != nil { + t.Errorf("expected uids nil, got %v", notification.UIDs) + } + if notification.Tags != nil { + t.Errorf("expected tags nil, got %v", notification.Tags) + } + if notification.ScheduledCount != 0 { + t.Errorf("expected scheduled_count 0, got %d", notification.ScheduledCount) + } + if notification.Scheduled != false { + t.Errorf("expected scheduled false, got %v", notification.Scheduled) + } +} + func TestCancelNotification(t *testing.T) { defer gock.Off() From 9564d5dfc08262c44bff47c24c29cfd4ef7ac68f Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Fri, 26 Dec 2025 18:18:26 +0100 Subject: [PATCH 20/28] Update CI --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe7b7a0..59c0522 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: - go-version: 1.23 + go-version: 1.25 - name: Build run: go build -v ./... - name: Test From 0c37c709e45dfb9029463bfc304576678ea464fe Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Fri, 26 Dec 2025 18:45:36 +0100 Subject: [PATCH 21/28] Set module version to v1 --- README.md | 8 ++++---- go.mod | 2 +- notification/api.go | 2 +- notification/api_test.go | 2 +- project/api.go | 2 +- project/api_test.go | 2 +- sender/api.go | 2 +- sender/api_test.go | 2 +- subscription/api.go | 2 +- subscription/api_test.go | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5f6e3ac..d8478c9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pushpad - Web Push Notifications -[![Go Reference](https://pkg.go.dev/badge/github.com/pushpad/pushpad-go)](https://pkg.go.dev/github.com/pushpad/pushpad-go) +[![Go Reference](https://pkg.go.dev/badge/github.com/pushpad/pushpad-go)](https://pkg.go.dev/github.com/pushpad/pushpad-go/v1) ![Build Status](https://github.com/pushpad/pushpad-go/workflows/CI/badge.svg) [Pushpad](https://pushpad.xyz) is a service for sending push notifications from websites and web apps. It uses the **Push API**, which is a standard supported by all major browsers (Chrome, Firefox, Opera, Edge, Safari). @@ -12,15 +12,15 @@ The notifications are delivered in real time even when the users are not on your You can get the Go module: ```go -go get github.com/pushpad/pushpad-go +go get github.com/pushpad/pushpad-go/v1 ``` Then import the packages: ```go import ( - "github.com/pushpad/pushpad-go" - "github.com/pushpad/pushpad-go/notification" + "github.com/pushpad/pushpad-go/v1" + "github.com/pushpad/pushpad-go/v1/notification" ) ``` diff --git a/go.mod b/go.mod index 67a28a7..59b8068 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/pushpad/pushpad-go +module github.com/pushpad/pushpad-go/v1 go 1.25 diff --git a/notification/api.go b/notification/api.go index 4451f05..4b25dc4 100644 --- a/notification/api.go +++ b/notification/api.go @@ -5,7 +5,7 @@ import ( "net/url" "strconv" - "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/v1" ) func List(params *NotificationListParams) ([]Notification, error) { diff --git a/notification/api_test.go b/notification/api_test.go index 225010d..d16efac 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/h2non/gock" - "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/v1" ) func TestListNotifications(t *testing.T) { diff --git a/project/api.go b/project/api.go index 653fa7c..3a5ded0 100644 --- a/project/api.go +++ b/project/api.go @@ -3,7 +3,7 @@ package project import ( "fmt" - "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/v1" ) func List(params *ProjectListParams) ([]Project, error) { diff --git a/project/api_test.go b/project/api_test.go index dd7830f..aea2f7e 100644 --- a/project/api_test.go +++ b/project/api_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/h2non/gock" - "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/v1" ) func TestListProjects(t *testing.T) { diff --git a/sender/api.go b/sender/api.go index dfa368f..20b9eea 100644 --- a/sender/api.go +++ b/sender/api.go @@ -3,7 +3,7 @@ package sender import ( "fmt" - "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/v1" ) func List(params *SenderListParams) ([]Sender, error) { diff --git a/sender/api_test.go b/sender/api_test.go index 05c8a7f..01256f0 100644 --- a/sender/api_test.go +++ b/sender/api_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/h2non/gock" - "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/v1" ) func TestListSenders(t *testing.T) { diff --git a/subscription/api.go b/subscription/api.go index c7f9096..e582ee3 100644 --- a/subscription/api.go +++ b/subscription/api.go @@ -5,7 +5,7 @@ import ( "net/url" "strconv" - "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/v1" ) func List(params *SubscriptionListParams) ([]Subscription, int64, error) { diff --git a/subscription/api_test.go b/subscription/api_test.go index b538054..694a9fc 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/h2non/gock" - "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/v1" ) func TestListSubscriptions(t *testing.T) { From 3041735cf5f30baa80a90716c6815a50bbfafa09 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 29 Dec 2025 13:49:30 +0100 Subject: [PATCH 22/28] Rename Strings to StringSlice in helpers --- README.md | 12 ++++++------ helpers.go | 4 ++-- notification/api_test.go | 10 +++++----- subscription/api_test.go | 6 +++--- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d8478c9..2a6cf5c 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ n := notification.NotificationCreateParams { // optional, add the notification to custom categories for stats aggregation // see https://pushpad.xyz/docs/monitoring - CustomMetrics: pushpad.Strings([]string{"examples", "another_metric"}), // up to 3 metrics per notification + CustomMetrics: pushpad.StringSlice([]string{"examples", "another_metric"}), // up to 3 metrics per notification } res, err := notification.Create(&n) @@ -109,27 +109,27 @@ res, err := notification.Create(&n) // You can use UIDs and Tags for sending the notification only to a specific audience... // deliver to a user -n := notification.NotificationCreateParams { Body: pushpad.String("Hi user1"), UIDs: pushpad.Strings([]string{"user1"}) } +n := notification.NotificationCreateParams { Body: pushpad.String("Hi user1"), UIDs: pushpad.StringSlice([]string{"user1"}) } res, err := notification.Create(&n) // deliver to a group of users -n := notification.NotificationCreateParams { Body: pushpad.String("Hi users"), UIDs: pushpad.Strings([]string{"user1","user2","user3"}) } +n := notification.NotificationCreateParams { Body: pushpad.String("Hi users"), UIDs: pushpad.StringSlice([]string{"user1","user2","user3"}) } res, err := notification.Create(&n) // deliver to some users only if they have a given preference // e.g. only "users" who have a interested in "events" will be reached -n := notification.NotificationCreateParams { Body: pushpad.String("New event"), UIDs: pushpad.Strings([]string{"user1","user2"}), Tags: pushpad.Strings([]string{"events"}) } +n := notification.NotificationCreateParams { Body: pushpad.String("New event"), UIDs: pushpad.StringSlice([]string{"user1","user2"}), Tags: pushpad.StringSlice([]string{"events"}) } res, err := notification.Create(&n) // deliver to segments // e.g. any subscriber that has the tag "segment1" OR "segment2" -n := notification.NotificationCreateParams { Body: pushpad.String("Example"), Tags: pushpad.Strings([]string{"segment1", "segment2"}) } +n := notification.NotificationCreateParams { Body: pushpad.String("Example"), Tags: pushpad.StringSlice([]string{"segment1", "segment2"}) } res, err := notification.Create(&n) // you can use boolean expressions // they can include parentheses and the operators !, &&, || (from highest to lowest precedence) // https://pushpad.xyz/docs/tags -n := notification.NotificationCreateParams { Body: pushpad.String("Example"), Tags: pushpad.Strings([]string{"zip_code:28865 && !optout:local_events || friend_of:Organizer123"}) } +n := notification.NotificationCreateParams { Body: pushpad.String("Example"), Tags: pushpad.StringSlice([]string{"zip_code:28865 && !optout:local_events || friend_of:Organizer123"}) } res, err := notification.Create(&n) // deliver to everyone diff --git a/helpers.go b/helpers.go index d5e8961..f77d90b 100644 --- a/helpers.go +++ b/helpers.go @@ -22,7 +22,7 @@ func Time(value time.Time) *time.Time { return &value } -// Strings returns a pointer to a string slice. -func Strings(value []string) *[]string { +// StringSlice returns a pointer to a slice of strings. +func StringSlice(value []string) *[]string { return &value } diff --git a/notification/api_test.go b/notification/api_test.go index d16efac..450d427 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -108,9 +108,9 @@ func TestCreateNotificationWithAllFields(t *testing.T) { }, Starred: pushpad.Bool(false), SendAt: pushpad.Time(sendAt), - CustomMetrics: pushpad.Strings([]string{"metric1", "metric2"}), - UIDs: pushpad.Strings([]string{"uid0", "uid1", "uidN"}), - Tags: pushpad.Strings([]string{"tag1", "tagA && !tagB"}), + CustomMetrics: pushpad.StringSlice([]string{"metric1", "metric2"}), + UIDs: pushpad.StringSlice([]string{"uid0", "uid1", "uidN"}), + Tags: pushpad.StringSlice([]string{"tag1", "tagA && !tagB"}), } notificationJSON, err := json.Marshal(params) @@ -189,7 +189,7 @@ func TestNotificationSend(t *testing.T) { } func TestNotificationWithUIDs(t *testing.T) { - n := NotificationCreateParams{Body: pushpad.String("Hello user1"), UIDs: pushpad.Strings([]string{"user1"})} + n := NotificationCreateParams{Body: pushpad.String("Hello user1"), UIDs: pushpad.StringSlice([]string{"user1"})} notificationJSON, err := json.Marshal(n) if err != nil { @@ -205,7 +205,7 @@ func TestNotificationWithUIDs(t *testing.T) { } func TestNotificationWithTags(t *testing.T) { - n := NotificationCreateParams{Body: pushpad.String("Hello tag1"), Tags: pushpad.Strings([]string{"tag1"})} + n := NotificationCreateParams{Body: pushpad.String("Hello tag1"), Tags: pushpad.StringSlice([]string{"tag1"})} notificationJSON, err := json.Marshal(n) if err != nil { diff --git a/subscription/api_test.go b/subscription/api_test.go index 694a9fc..68704af 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -29,8 +29,8 @@ func TestListSubscriptions(t *testing.T) { ProjectID: pushpad.Int64(123), Page: pushpad.Int64(1), PerPage: pushpad.Int64(20), - UIDs: pushpad.Strings([]string{"u1", "u2"}), - Tags: pushpad.Strings([]string{"tag1"}), + UIDs: pushpad.StringSlice([]string{"u1", "u2"}), + Tags: pushpad.StringSlice([]string{"tag1"}), } subscriptions, total, err := List(params) if err != nil { @@ -98,7 +98,7 @@ func TestCreateSubscriptionWithAllFields(t *testing.T) { P256DH: pushpad.String("BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4="), Auth: pushpad.String("cdKMlhgVeSPzCXZ3V7FtgQ=="), UID: pushpad.String("user1"), - Tags: pushpad.Strings([]string{"tag1", "tag2"}), + Tags: pushpad.StringSlice([]string{"tag1", "tag2"}), } subscriptionJSON, err := json.Marshal(params) From b833a5cae9f7ead7b29dcb935dc3fe8c99d80c22 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 29 Dec 2025 14:07:18 +0100 Subject: [PATCH 23/28] Add omitempty to UIDs and Tags fields in NotificationCreateParams --- notification/api_test.go | 36 ++++++++++++++++++++++++++++++++++-- notification/models.go | 4 ++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/notification/api_test.go b/notification/api_test.go index 450d427..d92cb2f 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -197,7 +197,23 @@ func TestNotificationWithUIDs(t *testing.T) { } got := string(notificationJSON) - want := `{"body":"Hello user1","uids":["user1"],"tags":null}` + want := `{"body":"Hello user1","uids":["user1"]}` + + if got != want { + t.Fatalf("got: %q, want: %q", got, want) + } +} + +func TestNotificationWithEmptyUIDs(t *testing.T) { + n := NotificationCreateParams{Body: pushpad.String("Hello user1"), UIDs: pushpad.StringSlice([]string{})} + notificationJSON, err := json.Marshal(n) + + if err != nil { + t.Fatalf("got an error: %s", err) + } + + got := string(notificationJSON) + want := `{"body":"Hello user1","uids":[]}` if got != want { t.Fatalf("got: %q, want: %q", got, want) @@ -213,7 +229,23 @@ func TestNotificationWithTags(t *testing.T) { } got := string(notificationJSON) - want := `{"body":"Hello tag1","uids":null,"tags":["tag1"]}` + want := `{"body":"Hello tag1","tags":["tag1"]}` + + if got != want { + t.Fatalf("got: %q, want: %q", got, want) + } +} + +func TestNotificationWithEmptyTags(t *testing.T) { + n := NotificationCreateParams{Body: pushpad.String("Hello tag1"), Tags: pushpad.StringSlice([]string{})} + notificationJSON, err := json.Marshal(n) + + if err != nil { + t.Fatalf("got an error: %s", err) + } + + got := string(notificationJSON) + want := `{"body":"Hello tag1","tags":[]}` if got != want { t.Fatalf("got: %q, want: %q", got, want) diff --git a/notification/models.go b/notification/models.go index b54e31e..25d4b7e 100644 --- a/notification/models.go +++ b/notification/models.go @@ -73,8 +73,8 @@ type NotificationCreateParams struct { Starred *bool `json:"starred,omitempty"` SendAt *time.Time `json:"send_at,omitempty"` CustomMetrics *[]string `json:"custom_metrics,omitempty"` - UIDs *[]string `json:"uids"` - Tags *[]string `json:"tags"` + UIDs *[]string `json:"uids,omitempty"` + Tags *[]string `json:"tags,omitempty"` } // NotificationListParams controls notification listing. From 1b4419863807ad56e91e7ccbe4c83027028b2bb2 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 29 Dec 2025 15:01:31 +0100 Subject: [PATCH 24/28] Update README --- README.md | 377 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 361 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2a6cf5c..8a00019 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,12 @@ Then import the packages: import ( "github.com/pushpad/pushpad-go/v1" "github.com/pushpad/pushpad-go/v1/notification" + "github.com/pushpad/pushpad-go/v1/project" + "github.com/pushpad/pushpad-go/v1/sender" + "github.com/pushpad/pushpad-go/v1/subscription" ) ``` -Other resources are available in the `subscription`, `project`, and `sender` packages. - ## Getting started First you need to sign up to Pushpad and create a project there. @@ -39,6 +40,20 @@ pushpad.Configure("AUTH_TOKEN", 123) - `AUTH_TOKEN` can be found in the user account settings. - `PROJECT_ID` can be found in the project settings. If your application uses multiple projects, you can pass the `ProjectID` as a param to functions. +```go +res, err := notification.Create(¬ification.NotificationCreateParams{ + ProjectID: pushpad.Int64(123), + Body: pushpad.String("Your message"), +}) + +notifications, err := notification.List(¬ification.NotificationListParams{ + ProjectID: pushpad.Int64(123), + Page: pushpad.Int64(1), +}) + +// ... +``` + ## Collecting user subscriptions to push notifications You can subscribe the users to your notifications using the Javascript SDK, as described in the [getting started guide](https://pushpad.xyz/docs/pushpad_pro_getting_started). @@ -52,11 +67,10 @@ fmt.Printf("User ID Signature: %s", s) ## Sending push notifications +Use `notification.Create()` (or the `Send()` alias) to create and send a notification: + ```go n := notification.NotificationCreateParams { - // optional, defaults to the project configured via pushpad.Configure - ProjectID: pushpad.Int64(0), - // required, the main content of the notification Body: pushpad.String("Hello world!"), @@ -91,6 +105,17 @@ n := notification.NotificationCreateParams { // optional, a string that is passed as an argument to action button callbacks CustomData: pushpad.String("123"), + // optional, add some action buttons to the notification + // see https://pushpad.xyz/docs/action_buttons + Actions: &[]notification.NotificationActionParams{ + { + Title: pushpad.String("My Button 1"), + TargetURL: pushpad.String("https://example.com/button-link"), // optional + Icon: pushpad.String("https://example.com/assets/button-icon.png"), // optional + Action: pushpad.String("myActionName"), // optional + }, + }, + // optional, bookmark the notification in the Pushpad dashboard (e.g. to highlight manual notifications) Starred: pushpad.Bool(true), @@ -109,31 +134,55 @@ res, err := notification.Create(&n) // You can use UIDs and Tags for sending the notification only to a specific audience... // deliver to a user -n := notification.NotificationCreateParams { Body: pushpad.String("Hi user1"), UIDs: pushpad.StringSlice([]string{"user1"}) } +n := notification.NotificationCreateParams { + Body: pushpad.String("Hi user1"), + UIDs: pushpad.StringSlice([]string{"user1"}) +} res, err := notification.Create(&n) // deliver to a group of users -n := notification.NotificationCreateParams { Body: pushpad.String("Hi users"), UIDs: pushpad.StringSlice([]string{"user1","user2","user3"}) } +n := notification.NotificationCreateParams { + Body: pushpad.String("Hi users"), + UIDs: pushpad.StringSlice([]string{"user1","user2","user3"}) +} res, err := notification.Create(&n) // deliver to some users only if they have a given preference // e.g. only "users" who have a interested in "events" will be reached -n := notification.NotificationCreateParams { Body: pushpad.String("New event"), UIDs: pushpad.StringSlice([]string{"user1","user2"}), Tags: pushpad.StringSlice([]string{"events"}) } +n := notification.NotificationCreateParams { + Body: pushpad.String("New event"), + UIDs: pushpad.StringSlice([]string{"user1","user2"}), + Tags: pushpad.StringSlice([]string{"events"}) +} res, err := notification.Create(&n) // deliver to segments // e.g. any subscriber that has the tag "segment1" OR "segment2" -n := notification.NotificationCreateParams { Body: pushpad.String("Example"), Tags: pushpad.StringSlice([]string{"segment1", "segment2"}) } +n := notification.NotificationCreateParams { + Body: pushpad.String("Example"), + Tags: pushpad.StringSlice([]string{"segment1", "segment2"}) +} res, err := notification.Create(&n) // you can use boolean expressions // they can include parentheses and the operators !, &&, || (from highest to lowest precedence) // https://pushpad.xyz/docs/tags -n := notification.NotificationCreateParams { Body: pushpad.String("Example"), Tags: pushpad.StringSlice([]string{"zip_code:28865 && !optout:local_events || friend_of:Organizer123"}) } +n := notification.NotificationCreateParams { + Body: pushpad.String("Example"), + Tags: pushpad.StringSlice([]string{"zip_code:28865 && !optout:local_events || friend_of:Organizer123"}) +} +res, err := notification.Create(&n) + +n := notification.NotificationCreateParams { + Body: pushpad.String("Example"), + Tags: pushpad.StringSlice([]string{"tag1 && tag2", "tag3"}) // equal to 'tag1 && tag2 || tag3' +} res, err := notification.Create(&n) // deliver to everyone -n := notification.NotificationCreateParams { Body: pushpad.String("Hello everybody") } +n := notification.NotificationCreateParams { + Body: pushpad.String("Hello everybody") +} res, err := notification.Create(&n) ``` @@ -141,13 +190,309 @@ You can set the default values for most fields in the project settings. See also If you try to send a notification to a user ID, but that user is not subscribed, that ID is simply ignored. -The methods above return a `NotificationCreateResponse struct`: +These fields are returned by the API: + +```go +res, err := notification.Create(&n) + +// Notification ID +fmt.Println(res.ID) // => 1000 + +// Estimated number of devices that will receive the notification +// Not available for notifications that use SendAt +fmt.Println(res.Scheduled) // => 5 + +// Available only if you specify some user IDs (UIDs) in the request: +// it indicates which of those users are subscribed to notifications. +// Not available for notifications that use SendAt +fmt.Println(res.UIDs) // => []string{"user1", "user2"} + +// The time when the notification will be sent. +// Available for notifications that use SendAt +fmt.Println(res.SendAt) // => 2025-10-30 10:09:00 +0000 UTC + +// Note: +// when a field is not available in the response, it is set to its zero value +``` + +## Getting push notification data + +You can retrieve data for past notifications: + +```go +n, err := notification.Get(42, nil) + +// get basic attributes +fmt.Println(n.ID) // => 42 +fmt.Println(n.Title) // => Foo Bar +fmt.Println(n.Body) // => Lorem ipsum dolor sit amet, consectetur adipiscing elit. +fmt.Println(n.TargetURL) // => https://example.com +fmt.Println(n.TTL) // => 604800 +fmt.Println(n.RequireInteraction) // => false +fmt.Println(n.Silent) // => false +fmt.Println(n.Urgent) // => false +fmt.Println(n.IconURL) // => https://example.com/assets/icon.png +fmt.Println(n.BadgeURL) // => https://example.com/assets/badge.png +fmt.Println(n.CreatedAt) // => 2025-07-06 10:09:14 +0000 UTC + +// get statistics +fmt.Println(n.ScheduledCount) // => 1 +fmt.Println(n.SuccessfullySent) // => 4 +fmt.Println(n.OpenedCount) // => 2 +``` + +Or for multiple notifications of a project at once: + +```go +notifications, err := notification.List(¬ification.NotificationListParams{ + Page: pushpad.Int64(1), +}) + +// same attributes as for single notification in example above +fmt.Println(notifications[0].ID) // => 42 +fmt.Println(notifications[0].Title) // => Foo Bar +``` + +The REST API paginates the result set. You can pass a `Page` parameter to get the full list in multiple requests. + +```go +notifications, err := notification.List(¬ification.NotificationListParams{ + Page: pushpad.Int64(2), +}) +``` + +## Scheduled notifications + +You can create scheduled notifications that will be sent in the future: + +```go +sendAt := time.Now().UTC().Add(60 * time.Second) + +scheduled, err := notification.Create(¬ification.NotificationCreateParams{ + Body: pushpad.String("This notification will be sent after 60 seconds"), + SendAt: pushpad.Time(sendAt), +}) +``` + +You can also cancel a scheduled notification: + +```go +err := notification.Cancel(scheduled.ID, nil) +``` + +## Getting subscription count + +You can retrieve the number of subscriptions for a given project, optionally filtered by `Tags` or `UIDs`: + +```go +_, totalCount, err := subscription.List(&subscription.SubscriptionListParams{}) +fmt.Println(totalCount) // => 100 + +_, totalCount, err = subscription.List(&subscription.SubscriptionListParams{ + UIDs: pushpad.StringSlice([]string{"user1"}), +}) +fmt.Println(totalCount) // => 2 + +_, totalCount, err = subscription.List(&subscription.SubscriptionListParams{ + Tags: pushpad.StringSlice([]string{"sports"}), +}) +fmt.Println(totalCount) // => 10 + +_, totalCount, err = subscription.List(&subscription.SubscriptionListParams{ + Tags: pushpad.StringSlice([]string{"sports && travel"}), +}) +fmt.Println(totalCount) // => 5 + +_, totalCount, err = subscription.List(&subscription.SubscriptionListParams{ + UIDs: pushpad.StringSlice([]string{"user1"}), + Tags: pushpad.StringSlice([]string{"sports && travel"}), +}) +fmt.Println(totalCount) // => 1 +``` + +## Getting push subscription data + +You can retrieve the subscriptions for a given project, optionally filtered by `Tags` or `UIDs`: + +```go +subscriptions, _, err := subscription.List(&subscription.SubscriptionListParams{}) + +subscriptions, _, err = subscription.List(&subscription.SubscriptionListParams{ + UIDs: pushpad.StringSlice([]string{"user1"}), +}) + +subscriptions, _, err = subscription.List(&subscription.SubscriptionListParams{ + Tags: pushpad.StringSlice([]string{"sports"}), +}) + +subscriptions, _, err = subscription.List(&subscription.SubscriptionListParams{ + Tags: pushpad.StringSlice([]string{"sports && travel"}), +}) + +subscriptions, _, err = subscription.List(&subscription.SubscriptionListParams{ + UIDs: pushpad.StringSlice([]string{"user1"}), + Tags: pushpad.StringSlice([]string{"sports && travel"}), +}) +``` + +The REST API paginates the result set. You can pass `Page` and `PerPage` parameters to get the full list in multiple requests. + +```go +subscriptions, _, err := subscription.List(&subscription.SubscriptionListParams{ + Page: pushpad.Int64(2), +}) +``` + +You can also retrieve the data of a specific subscription if you already know its id: + +```go +subscription.Get(123, nil) +``` + +## Updating push subscription data + +Usually you add data, like user IDs and tags, to the push subscriptions using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference) in the frontend. + +However you can also update the subscription data from your server: + +```go +subscriptions, _, err := subscription.List(&subscription.SubscriptionListParams{ + UIDs: pushpad.StringSlice([]string{"user1"}), +}) + +for _, s := range subscriptions { + // update the user ID associated to the push subscription + _, err = subscription.Update(s.ID, &subscription.SubscriptionUpdateParams{ + UID: pushpad.String("myuser1"), + }) + + // update the tags associated to the push subscription + tags := append([]string{}, s.Tags...) + tags = append(tags, "another_tag") + _, err = subscription.Update(s.ID, &subscription.SubscriptionUpdateParams{ + Tags: pushpad.StringSlice(tags), + }) +} +``` + +## Importing push subscriptions + +If you need to [import](https://pushpad.xyz/docs/import) some existing push subscriptions (from another service to Pushpad, or from your backups) or if you simply need to create some test data, you can use this method: + +```go +createdSubscription, err := subscription.Create(&subscription.SubscriptionCreateParams{ + Endpoint: pushpad.String("https://example.com/push/f7Q1Eyf7EyfAb1"), + P256DH: pushpad.String("BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4="), + Auth: pushpad.String("cdKMlhgVeSPzCXZ3V7FtgQ=="), + UID: pushpad.String("exampleUid"), + Tags: pushpad.StringSlice([]string{"exampleTag1", "exampleTag2"}), +}) +``` + +Please note that this is not the standard way to collect subscriptions on Pushpad: usually you subscribe the users to the notifications using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference) in the frontend. + +## Deleting push subscriptions + +Usually you unsubscribe a user from push notifications using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference) in the frontend (recommended). + +However you can also delete the subscriptions using this library. Be careful, the subscriptions are permanently deleted! + +```go +err := subscription.Delete(id, nil) +``` + +## Managing projects + +Projects are usually created manually from the Pushpad dashboard. However you can also create projects from code if you need advanced automation or if you manage [many different domains](https://pushpad.xyz/docs/multiple_domains). + +```go +createdProject, err := project.Create(&project.ProjectCreateParams{ + // required attributes + SenderID: pushpad.Int64(123), + Name: pushpad.String("My project"), + Website: pushpad.String("https://example.com"), + + // optional configurations + IconURL: pushpad.String("https://example.com/icon.png"), + BadgeURL: pushpad.String("https://example.com/badge.png"), + NotificationsTTL: pushpad.Int64(604800), + NotificationsRequireInteract: pushpad.Bool(false), + NotificationsSilent: pushpad.Bool(false), +}) +``` + +You can also find, update and delete projects: + +```go +projects, err := project.List(nil) +for _, p := range projects { + fmt.Printf("Project %d: %s\n", p.ID, p.Name) +} + +existingProject, err := project.Get(123, nil) + +updatedProject, err := project.Update(existingProject.ID, &project.ProjectUpdateParams{ + Name: pushpad.String("The New Project Name"), +}) + +err = project.Delete(existingProject.ID, nil) +``` + +## Managing senders + +Senders are usually created manually from the Pushpad dashboard. However you can also create senders from code. + +```go +createdSender, err := sender.Create(&sender.SenderCreateParams{ + // required attributes + Name: pushpad.String("My sender"), + + // optional configurations + // do not include these fields if you want to generate them automatically + VAPIDPrivateKey: pushpad.String("-----BEGIN EC PRIVATE KEY----- ..."), + VAPIDPublicKey: pushpad.String("-----BEGIN PUBLIC KEY----- ..."), +}) +``` + +You can also find, update and delete senders: + +```go +senders, err := sender.List(nil) +for _, s := range senders { + fmt.Printf("Sender %d: %s\n", s.ID, s.Name) +} + +existingSender, err := sender.Get(987, nil) + +updatedSender, err := sender.Update(existingSender.ID, &sender.SenderUpdateParams{ + Name: pushpad.String("The New Sender Name"), +}) + +err = sender.Delete(existingSender.ID, nil) +``` + +## Error handling + +API requests can return errors, described by a `pushpad.APIError` that exposes the HTTP status code and response body. Network issues and other errors return a generic error. + +```go +n := notification.NotificationCreateParams{Body: pushpad.String("Hello")} +_, err := notification.Create(&n) +if err != nil { + var apiErr *pushpad.APIError + if errors.As(err, &apiErr) { // HTTP error from the API + fmt.Println(apiErr.StatusCode, apiErr.Body) + } else { // network error or other errors + fmt.Println(err) + } +} +``` -- `ID` is the id of the notification on Pushpad -- `Scheduled` is the estimated reach of the notification (i.e. the number of devices to which the notification will be sent, which can be different from the number of users, since a user may receive notifications on multiple devices) -- `UIDs` (only when the `UIDs` field is set) are the user IDs that will be actually reached by the notification because they are subscribed to your notifications. For example if you send a notification to `{"uid1", "uid2", "uid3"}`, but only `"uid1"` is subscribed, you will get `{"uid1"}` in response. Note that if a user has unsubscribed after the last notification sent to him, he may still be reported for one time as subscribed (this is due to the way the W3C Push API works). -- `SendAt` is present only for scheduled notifications. The fields `Scheduled` and `UIDs` are not available in this case. +## Documentation +- Pushpad REST API reference: https://pushpad.xyz/docs/rest_api +- Getting started guide (for collecting subscriptions): https://pushpad.xyz/docs/pushpad_pro_getting_started +- JavaScript SDK reference (frontend): https://pushpad.xyz/docs/javascript_sdk_reference ## License From e64d49dfe409d5caf9e09e1b0d1af2b5a998e394 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 29 Dec 2025 15:28:43 +0100 Subject: [PATCH 25/28] Separate List from Count in subscriptions --- README.md | 24 ++++++++++++------------ subscription/api.go | 38 +++++++++++++++++++++++++++++++++----- subscription/api_test.go | 38 ++++++++++++++++++++++++++++++-------- subscription/models.go | 7 +++++++ 4 files changed, 82 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 8a00019..8edbef4 100644 --- a/README.md +++ b/README.md @@ -285,25 +285,25 @@ err := notification.Cancel(scheduled.ID, nil) You can retrieve the number of subscriptions for a given project, optionally filtered by `Tags` or `UIDs`: ```go -_, totalCount, err := subscription.List(&subscription.SubscriptionListParams{}) +totalCount, err := subscription.Count(&subscription.SubscriptionCountParams{}) fmt.Println(totalCount) // => 100 -_, totalCount, err = subscription.List(&subscription.SubscriptionListParams{ +totalCount, err = subscription.Count(&subscription.SubscriptionCountParams{ UIDs: pushpad.StringSlice([]string{"user1"}), }) fmt.Println(totalCount) // => 2 -_, totalCount, err = subscription.List(&subscription.SubscriptionListParams{ +totalCount, err = subscription.Count(&subscription.SubscriptionCountParams{ Tags: pushpad.StringSlice([]string{"sports"}), }) fmt.Println(totalCount) // => 10 -_, totalCount, err = subscription.List(&subscription.SubscriptionListParams{ +totalCount, err = subscription.Count(&subscription.SubscriptionCountParams{ Tags: pushpad.StringSlice([]string{"sports && travel"}), }) fmt.Println(totalCount) // => 5 -_, totalCount, err = subscription.List(&subscription.SubscriptionListParams{ +totalCount, err = subscription.Count(&subscription.SubscriptionCountParams{ UIDs: pushpad.StringSlice([]string{"user1"}), Tags: pushpad.StringSlice([]string{"sports && travel"}), }) @@ -315,21 +315,21 @@ fmt.Println(totalCount) // => 1 You can retrieve the subscriptions for a given project, optionally filtered by `Tags` or `UIDs`: ```go -subscriptions, _, err := subscription.List(&subscription.SubscriptionListParams{}) +subscriptions, err := subscription.List(&subscription.SubscriptionListParams{}) -subscriptions, _, err = subscription.List(&subscription.SubscriptionListParams{ +subscriptions, err = subscription.List(&subscription.SubscriptionListParams{ UIDs: pushpad.StringSlice([]string{"user1"}), }) -subscriptions, _, err = subscription.List(&subscription.SubscriptionListParams{ +subscriptions, err = subscription.List(&subscription.SubscriptionListParams{ Tags: pushpad.StringSlice([]string{"sports"}), }) -subscriptions, _, err = subscription.List(&subscription.SubscriptionListParams{ +subscriptions, err = subscription.List(&subscription.SubscriptionListParams{ Tags: pushpad.StringSlice([]string{"sports && travel"}), }) -subscriptions, _, err = subscription.List(&subscription.SubscriptionListParams{ +subscriptions, err = subscription.List(&subscription.SubscriptionListParams{ UIDs: pushpad.StringSlice([]string{"user1"}), Tags: pushpad.StringSlice([]string{"sports && travel"}), }) @@ -338,7 +338,7 @@ subscriptions, _, err = subscription.List(&subscription.SubscriptionListParams{ The REST API paginates the result set. You can pass `Page` and `PerPage` parameters to get the full list in multiple requests. ```go -subscriptions, _, err := subscription.List(&subscription.SubscriptionListParams{ +subscriptions, err := subscription.List(&subscription.SubscriptionListParams{ Page: pushpad.Int64(2), }) ``` @@ -356,7 +356,7 @@ Usually you add data, like user IDs and tags, to the push subscriptions using th However you can also update the subscription data from your server: ```go -subscriptions, _, err := subscription.List(&subscription.SubscriptionListParams{ +subscriptions, err := subscription.List(&subscription.SubscriptionListParams{ UIDs: pushpad.StringSlice([]string{"user1"}), }) diff --git a/subscription/api.go b/subscription/api.go index e582ee3..c4fb49d 100644 --- a/subscription/api.go +++ b/subscription/api.go @@ -8,13 +8,13 @@ import ( "github.com/pushpad/pushpad-go/v1" ) -func List(params *SubscriptionListParams) ([]Subscription, int64, error) { +func List(params *SubscriptionListParams) ([]Subscription, error) { if params == nil { params = &SubscriptionListParams{} } projectID, err := pushpad.ResolveProjectID(params.ProjectID) if err != nil { - return nil, 0, err + return nil, err } query := url.Values{} @@ -36,9 +36,37 @@ func List(params *SubscriptionListParams) ([]Subscription, int64, error) { } var subscriptions []Subscription - res, err := pushpad.DoRequest("GET", fmt.Sprintf("/projects/%d/subscriptions", projectID), query, nil, []int{200}, &subscriptions) + _, err = pushpad.DoRequest("GET", fmt.Sprintf("/projects/%d/subscriptions", projectID), query, nil, []int{200}, &subscriptions) + if err != nil { + return nil, err + } + + return subscriptions, nil +} + +func Count(params *SubscriptionCountParams) (int64, error) { + if params == nil { + params = &SubscriptionCountParams{} + } + projectID, err := pushpad.ResolveProjectID(params.ProjectID) + if err != nil { + return 0, err + } + + query := url.Values{} + if params.UIDs != nil { + for _, uid := range *params.UIDs { + query.Add("uids[]", uid) + } + } + if params.Tags != nil { + for _, tag := range *params.Tags { + query.Add("tags[]", tag) + } + } + res, err := pushpad.DoRequest("GET", fmt.Sprintf("/projects/%d/subscriptions", projectID), query, nil, []int{200}, nil) if err != nil { - return nil, 0, err + return 0, err } var totalCount int64 @@ -48,7 +76,7 @@ func List(params *SubscriptionListParams) ([]Subscription, int64, error) { } } - return subscriptions, totalCount, nil + return totalCount, nil } func Create(params *SubscriptionCreateParams) (*Subscription, error) { diff --git a/subscription/api_test.go b/subscription/api_test.go index 68704af..c380948 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -32,13 +32,10 @@ func TestListSubscriptions(t *testing.T) { UIDs: pushpad.StringSlice([]string{"u1", "u2"}), Tags: pushpad.StringSlice([]string{"tag1"}), } - subscriptions, total, err := List(params) + subscriptions, err := List(params) if err != nil { t.Fatalf("expected no error, got %s", err) } - if total != 2 { - t.Errorf("expected total count 2, got %d", total) - } if len(subscriptions) != 2 { t.Fatalf("expected 2 subscriptions, got %d", len(subscriptions)) } @@ -57,18 +54,43 @@ func TestListSubscriptionsNoOptions(t *testing.T) { BodyString(`[]`) pushpad.Configure("TOKEN", 0) - subscriptions, total, err := List(&SubscriptionListParams{ProjectID: pushpad.Int64(123)}) + subscriptions, err := List(&SubscriptionListParams{ProjectID: pushpad.Int64(123)}) if err != nil { t.Fatalf("expected no error, got %s", err) } - if total != 0 { - t.Errorf("expected total count 0, got %d", total) - } if len(subscriptions) != 0 { t.Fatalf("expected 0 subscriptions, got %d", len(subscriptions)) } } +func TestCountSubscriptions(t *testing.T) { + defer gock.Off() + + gock.New("https://pushpad.xyz"). + Get("/api/v1/projects/123/subscriptions"). + MatchParam("uids[]", "u1"). + MatchParam("uids[]", "u2"). + MatchParam("tags[]", "tag1"). + MatchHeader("Authorization", "Bearer TOKEN"). + Reply(200). + SetHeader("X-Total-Count", "2"). + BodyString(`[]`) + + pushpad.Configure("TOKEN", 0) + params := &SubscriptionCountParams{ + ProjectID: pushpad.Int64(123), + UIDs: pushpad.StringSlice([]string{"u1", "u2"}), + Tags: pushpad.StringSlice([]string{"tag1"}), + } + total, err := Count(params) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + if total != 2 { + t.Errorf("expected total count 2, got %d", total) + } +} + func TestCreateSubscription(t *testing.T) { defer gock.Off() diff --git a/subscription/models.go b/subscription/models.go index 33080a6..3927b98 100644 --- a/subscription/models.go +++ b/subscription/models.go @@ -41,6 +41,13 @@ type SubscriptionListParams struct { Tags *[]string } +// SubscriptionCountParams controls subscription counts. +type SubscriptionCountParams struct { + ProjectID *int64 + UIDs *[]string + Tags *[]string +} + // SubscriptionGetParams controls subscription fetches. type SubscriptionGetParams struct { ProjectID *int64 From 1d8f066f3d365a39f33038d629b399d5c5d4972e Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 29 Dec 2025 15:32:03 +0100 Subject: [PATCH 26/28] Use HEAD instead of GET for Count --- subscription/api.go | 2 +- subscription/api_test.go | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/subscription/api.go b/subscription/api.go index c4fb49d..2355190 100644 --- a/subscription/api.go +++ b/subscription/api.go @@ -64,7 +64,7 @@ func Count(params *SubscriptionCountParams) (int64, error) { query.Add("tags[]", tag) } } - res, err := pushpad.DoRequest("GET", fmt.Sprintf("/projects/%d/subscriptions", projectID), query, nil, []int{200}, nil) + res, err := pushpad.DoRequest("HEAD", fmt.Sprintf("/projects/%d/subscriptions", projectID), query, nil, []int{200}, nil) if err != nil { return 0, err } diff --git a/subscription/api_test.go b/subscription/api_test.go index c380948..9e6ddb4 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -67,14 +67,13 @@ func TestCountSubscriptions(t *testing.T) { defer gock.Off() gock.New("https://pushpad.xyz"). - Get("/api/v1/projects/123/subscriptions"). + Head("/api/v1/projects/123/subscriptions"). MatchParam("uids[]", "u1"). MatchParam("uids[]", "u2"). MatchParam("tags[]", "tag1"). MatchHeader("Authorization", "Bearer TOKEN"). Reply(200). - SetHeader("X-Total-Count", "2"). - BodyString(`[]`) + SetHeader("X-Total-Count", "2") pushpad.Configure("TOKEN", 0) params := &SubscriptionCountParams{ From 4476825192fa8efeb8f82061b77e333e96e220a0 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 29 Dec 2025 16:12:23 +0100 Subject: [PATCH 27/28] Add UPGRADING.md --- UPGRADING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 UPGRADING.md diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..701e19a --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,12 @@ +# Upgrading to version 1.x + +This version is a major rewrite of the library and adds support for the full REST API, including Notifications, Subscriptions, Projects and Senders. + +This version has some breaking changes: + +- Module and packages include the version in their name. Use `github.com/pushpad/pushpad-go/v1` + instead of `github.com/pushpad/pushpad-go`. +- When you call `pushpad.Configure` the `projectID` argument is now a `int64` instead of a `string`. +- `pushpad.Notification` is now used only for some API responses, but not for API requests. If you want to create / send a notification, use `notification.Create(¬ificationCreateParams)`. +- All fields in `notification.NotificationCreateParams` are pointers, and you can use helpers like `pushpad.String`, `pushpad.StringSlice`, `pushpad.Int64`, etc. to create the pointers easily. For example, when you create the params for a notification, use `Body: pushpad.String("Hello")` instead of `Body: "Hello"`. +- The response to the creation of a notification is now a `NotificationCreateResponse struct` instead of `NotificationResponse struct` (the only difference is the `struct` name and the use of `int64` instead of `int` for some fields). From 1b0d8f64974c0454193fd2a646b81d343afb5c4f Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 29 Dec 2025 17:22:25 +0100 Subject: [PATCH 28/28] v1 must not include the version suffix in the module path --- README.md | 14 +++++++------- UPGRADING.md | 2 -- go.mod | 2 +- notification/api.go | 2 +- notification/api_test.go | 2 +- project/api.go | 2 +- project/api_test.go | 2 +- sender/api.go | 2 +- sender/api_test.go | 2 +- subscription/api.go | 2 +- subscription/api_test.go | 2 +- 11 files changed, 16 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8edbef4..52a09c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pushpad - Web Push Notifications -[![Go Reference](https://pkg.go.dev/badge/github.com/pushpad/pushpad-go)](https://pkg.go.dev/github.com/pushpad/pushpad-go/v1) +[![Go Reference](https://pkg.go.dev/badge/github.com/pushpad/pushpad-go)](https://pkg.go.dev/github.com/pushpad/pushpad-go) ![Build Status](https://github.com/pushpad/pushpad-go/workflows/CI/badge.svg) [Pushpad](https://pushpad.xyz) is a service for sending push notifications from websites and web apps. It uses the **Push API**, which is a standard supported by all major browsers (Chrome, Firefox, Opera, Edge, Safari). @@ -12,18 +12,18 @@ The notifications are delivered in real time even when the users are not on your You can get the Go module: ```go -go get github.com/pushpad/pushpad-go/v1 +go get github.com/pushpad/pushpad-go ``` Then import the packages: ```go import ( - "github.com/pushpad/pushpad-go/v1" - "github.com/pushpad/pushpad-go/v1/notification" - "github.com/pushpad/pushpad-go/v1/project" - "github.com/pushpad/pushpad-go/v1/sender" - "github.com/pushpad/pushpad-go/v1/subscription" + "github.com/pushpad/pushpad-go" + "github.com/pushpad/pushpad-go/notification" + "github.com/pushpad/pushpad-go/project" + "github.com/pushpad/pushpad-go/sender" + "github.com/pushpad/pushpad-go/subscription" ) ``` diff --git a/UPGRADING.md b/UPGRADING.md index 701e19a..5c1acb5 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -4,8 +4,6 @@ This version is a major rewrite of the library and adds support for the full RES This version has some breaking changes: -- Module and packages include the version in their name. Use `github.com/pushpad/pushpad-go/v1` - instead of `github.com/pushpad/pushpad-go`. - When you call `pushpad.Configure` the `projectID` argument is now a `int64` instead of a `string`. - `pushpad.Notification` is now used only for some API responses, but not for API requests. If you want to create / send a notification, use `notification.Create(¬ificationCreateParams)`. - All fields in `notification.NotificationCreateParams` are pointers, and you can use helpers like `pushpad.String`, `pushpad.StringSlice`, `pushpad.Int64`, etc. to create the pointers easily. For example, when you create the params for a notification, use `Body: pushpad.String("Hello")` instead of `Body: "Hello"`. diff --git a/go.mod b/go.mod index 59b8068..67a28a7 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/pushpad/pushpad-go/v1 +module github.com/pushpad/pushpad-go go 1.25 diff --git a/notification/api.go b/notification/api.go index 4b25dc4..4451f05 100644 --- a/notification/api.go +++ b/notification/api.go @@ -5,7 +5,7 @@ import ( "net/url" "strconv" - "github.com/pushpad/pushpad-go/v1" + "github.com/pushpad/pushpad-go" ) func List(params *NotificationListParams) ([]Notification, error) { diff --git a/notification/api_test.go b/notification/api_test.go index d92cb2f..47309f4 100644 --- a/notification/api_test.go +++ b/notification/api_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/h2non/gock" - "github.com/pushpad/pushpad-go/v1" + "github.com/pushpad/pushpad-go" ) func TestListNotifications(t *testing.T) { diff --git a/project/api.go b/project/api.go index 3a5ded0..653fa7c 100644 --- a/project/api.go +++ b/project/api.go @@ -3,7 +3,7 @@ package project import ( "fmt" - "github.com/pushpad/pushpad-go/v1" + "github.com/pushpad/pushpad-go" ) func List(params *ProjectListParams) ([]Project, error) { diff --git a/project/api_test.go b/project/api_test.go index aea2f7e..dd7830f 100644 --- a/project/api_test.go +++ b/project/api_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/h2non/gock" - "github.com/pushpad/pushpad-go/v1" + "github.com/pushpad/pushpad-go" ) func TestListProjects(t *testing.T) { diff --git a/sender/api.go b/sender/api.go index 20b9eea..dfa368f 100644 --- a/sender/api.go +++ b/sender/api.go @@ -3,7 +3,7 @@ package sender import ( "fmt" - "github.com/pushpad/pushpad-go/v1" + "github.com/pushpad/pushpad-go" ) func List(params *SenderListParams) ([]Sender, error) { diff --git a/sender/api_test.go b/sender/api_test.go index 01256f0..05c8a7f 100644 --- a/sender/api_test.go +++ b/sender/api_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/h2non/gock" - "github.com/pushpad/pushpad-go/v1" + "github.com/pushpad/pushpad-go" ) func TestListSenders(t *testing.T) { diff --git a/subscription/api.go b/subscription/api.go index 2355190..6d076bd 100644 --- a/subscription/api.go +++ b/subscription/api.go @@ -5,7 +5,7 @@ import ( "net/url" "strconv" - "github.com/pushpad/pushpad-go/v1" + "github.com/pushpad/pushpad-go" ) func List(params *SubscriptionListParams) ([]Subscription, error) { diff --git a/subscription/api_test.go b/subscription/api_test.go index 9e6ddb4..347f713 100644 --- a/subscription/api_test.go +++ b/subscription/api_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/h2non/gock" - "github.com/pushpad/pushpad-go/v1" + "github.com/pushpad/pushpad-go" ) func TestListSubscriptions(t *testing.T) {