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 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..52a09c7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ Then import the packages: ```go import ( "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" ) ``` @@ -30,11 +34,25 @@ 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. + +```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 @@ -49,99 +67,432 @@ 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 := pushpad.Notification { +n := notification.NotificationCreateParams { // 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.Int64(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, 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: 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.StringSlice([]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: pushpad.String("Hi user1"), + UIDs: pushpad.StringSlice([]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: 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 := pushpad.Notification { Body: "New event", UIDs: []string{"user1","user2"}, Tags: []string{"events"} } -res, err := n.Send() +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 := pushpad.Notification { Body: "Example", Tags: []string{"segment1", "segment2"} } -res, err := n.Send() +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 := pushpad.Notification { Body: "Example", Tags: []string{"zip_code:28865 && !optout:local_events || friend_of:Organizer123"} } -res, err := n.Send() +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 := pushpad.Notification { Body: "Hello everybody" } -res, err := n.Send() +n := notification.NotificationCreateParams { + Body: pushpad.String("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`: +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.Count(&subscription.SubscriptionCountParams{}) +fmt.Println(totalCount) // => 100 + +totalCount, err = subscription.Count(&subscription.SubscriptionCountParams{ + UIDs: pushpad.StringSlice([]string{"user1"}), +}) +fmt.Println(totalCount) // => 2 + +totalCount, err = subscription.Count(&subscription.SubscriptionCountParams{ + Tags: pushpad.StringSlice([]string{"sports"}), +}) +fmt.Println(totalCount) // => 10 + +totalCount, err = subscription.Count(&subscription.SubscriptionCountParams{ + Tags: pushpad.StringSlice([]string{"sports && travel"}), +}) +fmt.Println(totalCount) // => 5 + +totalCount, err = subscription.Count(&subscription.SubscriptionCountParams{ + 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 diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..5c1acb5 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,10 @@ +# 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: + +- 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). 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= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..f77d90b --- /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 +} + +// Int64 returns a pointer to an int64 value. +func Int64(value int64) *int64 { + return &value +} + +// Time returns a pointer to a time value. +func Time(value time.Time) *time.Time { + return &value +} + +// StringSlice returns a pointer to a slice of strings. +func StringSlice(value []string) *[]string { + return &value +} diff --git a/http.go b/http.go new file mode 100644 index 0000000..07a4dec --- /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 *int64) (int64, error) { + if projectID != nil && *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..4451f05 --- /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) { + if params == nil { + params = &NotificationListParams{} + } + projectID, err := pushpad.ResolveProjectID(params.ProjectID) + if err != nil { + return nil, err + } + + query := url.Values{} + if params.Page != nil && *params.Page > 0 { + query.Set("page", strconv.FormatInt(*params.Page, 10)) + } + + var notifications []Notification + _, err = pushpad.DoRequest("GET", fmt.Sprintf("/projects/%d/notifications", projectID), query, nil, []int{200}, ¬ifications) + return notifications, err +} + +func Create(params *NotificationCreateParams) (*NotificationCreateResponse, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") + } + 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, params, []int{201}, &response) + if err != nil { + return nil, err + } + return &response, nil +} + +func Send(params *NotificationCreateParams) (*NotificationCreateResponse, error) { + return Create(params) +} + +func Get(notificationID int64, params *NotificationGetParams) (*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 int64, params *NotificationCancelParams) 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..47309f4 --- /dev/null +++ b/notification/api_test.go @@ -0,0 +1,450 @@ +package notification + +import ( + "encoding/json" + "testing" + "time" + + "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: pushpad.Int64(123), Page: pushpad.Int64(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: pushpad.Int64(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: pushpad.Int64(123), Body: pushpad.String("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 != 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) { + 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) + } + + params := NotificationCreateParams{ + 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.Int64(604800), + RequireInteraction: pushpad.Bool(false), + Silent: pushpad.Bool(false), + Urgent: pushpad.Bool(false), + CustomData: pushpad.String(""), + 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.StringSlice([]string{"metric1", "metric2"}), + UIDs: pushpad.StringSlice([]string{"uid0", "uid1", "uidN"}), + Tags: pushpad.StringSlice([]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 != 9876 { + t.Errorf("expected scheduled count 9876, got %d", response.Scheduled) + } +} + +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: pushpad.Int64(123)}) + 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) + } +} + +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: pushpad.Int64(123), Body: pushpad.String("Hello world!")} + res, err := Send(&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: pushpad.String("Hello user1"), UIDs: pushpad.StringSlice([]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"]}` + + 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) + } +} + +func TestNotificationWithTags(t *testing.T) { + n := NotificationCreateParams{Body: pushpad.String("Hello tag1"), Tags: pushpad.StringSlice([]string{"tag1"})} + notificationJSON, err := json.Marshal(n) + + if err != nil { + t.Fatalf("got an error: %s", err) + } + + got := string(notificationJSON) + 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) + } +} + +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, nil) + 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 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 != "A button" { + t.Errorf("expected action title A button, got %v", notification.Actions[0].Title) + } + 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 != "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 != "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.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) + } + 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 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() + + 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, nil); 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 || err.Error() != "pushpad: project ID is required" { + t.Fatalf("expected project ID required error, got %v", err) + } +} diff --git a/notification/models.go b/notification/models.go new file mode 100644 index 0000000..25d4b7e --- /dev/null +++ b/notification/models.go @@ -0,0 +1,90 @@ +package notification + +import "time" + +// NotificationAction represents a notification action button in responses. +type NotificationAction struct { + 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"` + 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"` + 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"` + UIDs []string `json:"uids"` + SendAt time.Time `json:"send_at"` +} + +// 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:"-"` + 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 *[]NotificationActionParams `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,omitempty"` + Tags *[]string `json:"tags,omitempty"` +} + +// NotificationListParams controls notification listing. +type NotificationListParams struct { + ProjectID *int64 + Page *int64 +} + +// NotificationGetParams controls notification fetches. +type NotificationGetParams struct{} + +// NotificationCancelParams controls notification cancels. +type NotificationCancelParams struct{} 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..653fa7c --- /dev/null +++ b/project/api.go @@ -0,0 +1,63 @@ +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(params *ProjectCreateParams) (*Project, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") + } + + var created Project + _, err := pushpad.DoRequest("POST", "/projects", nil, params, []int{201}, &created) + if err != nil { + return nil, err + } + return &created, nil +} + +func Get(projectID int64, params *ProjectGetParams) (*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 int64, 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, params, []int{200}, &project) + if err != nil { + return nil, err + } + return &project, nil +} + +func Delete(projectID int64, params *ProjectDeleteParams) 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..dd7830f --- /dev/null +++ b/project/api_test.go @@ -0,0 +1,247 @@ +package project + +import ( + "encoding/json" + "testing" + "time" + + "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: pushpad.Int64(9), + Name: pushpad.String("New Project"), + Website: pushpad.String("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 TestCreateProjectWithAllFields(t *testing.T) { + defer gock.Off() + + params := ProjectCreateParams{ + 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.Int64(604800), + NotificationsRequireInteract: pushpad.Bool(false), + NotificationsSilent: pushpad.Bool(false), + } + + 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() + + 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{}) + 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) + } +} + +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, nil) + 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 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() + + 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: pushpad.String("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, nil); 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: pushpad.Int64(1), + Name: pushpad.String("Failing Project"), + Website: pushpad.String("https://example.com"), + }) + 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..c90e0ba --- /dev/null +++ b/project/models.go @@ -0,0 +1,49 @@ +package project + +import "time" + +// Project represents a Pushpad project. +type Project struct { + 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,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"` +} + +// 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 *int64 `json:"notifications_ttl,omitempty"` + NotificationsRequireInteract *bool `json:"notifications_require_interaction,omitempty"` + NotificationsSilent *bool `json:"notifications_silent,omitempty"` +} + +// ProjectListParams controls project listing. +type ProjectListParams struct{} + +// ProjectGetParams controls project fetches. +type ProjectGetParams struct{} + +// ProjectDeleteParams controls project deletes. +type ProjectDeleteParams struct{} diff --git a/pushpad.go b/pushpad.go index 862591b..2d2e041 100644 --- a/pushpad.go +++ b/pushpad.go @@ -1,9 +1,10 @@ package pushpad var pushpadAuthToken string -var pushpadProjectID string +var pushpadProjectID int64 -func Configure (authToken string, projectID string) { - pushpadAuthToken = authToken - pushpadProjectID = projectID +// Configure sets the global credentials for API calls. +func Configure(authToken string, projectID int64) { + 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..dfa368f --- /dev/null +++ b/sender/api.go @@ -0,0 +1,63 @@ +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(params *SenderCreateParams) (*Sender, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") + } + + var created Sender + _, err := pushpad.DoRequest("POST", "/senders", nil, params, []int{201}, &created) + if err != nil { + return nil, err + } + return &created, nil +} + +func Get(senderID int64, params *SenderGetParams) (*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 int64, 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, params, []int{200}, &sender) + if err != nil { + return nil, err + } + return &sender, nil +} + +func Delete(senderID int64, params *SenderDeleteParams) 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..05c8a7f --- /dev/null +++ b/sender/api_test.go @@ -0,0 +1,198 @@ +package sender + +import ( + "encoding/json" + "testing" + "time" + + "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: pushpad.String("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 TestCreateSenderWithAllFields(t *testing.T) { + defer gock.Off() + + params := SenderCreateParams{ + Name: pushpad.String("My Sender"), + VAPIDPrivateKey: pushpad.String("-----BEGIN EC PRIVATE KEY----- ..."), + VAPIDPublicKey: pushpad.String("-----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() + + 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{}) + 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) + } +} + +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, nil) + 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 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() + + 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: pushpad.String("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, nil); 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..1d68e4c --- /dev/null +++ b/sender/models.go @@ -0,0 +1,33 @@ +package sender + +import "time" + +// Sender represents a Pushpad sender. +type Sender struct { + 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,omitempty"` + 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{} + +// SenderGetParams controls sender fetches. +type SenderGetParams struct{} + +// SenderDeleteParams controls sender deletes. +type SenderDeleteParams 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..6d076bd --- /dev/null +++ b/subscription/api.go @@ -0,0 +1,152 @@ +package subscription + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/pushpad/pushpad-go" +) + +func List(params *SubscriptionListParams) ([]Subscription, error) { + if params == nil { + params = &SubscriptionListParams{} + } + projectID, err := pushpad.ResolveProjectID(params.ProjectID) + if err != nil { + return nil, err + } + + query := url.Values{} + if params.Page != nil && *params.Page > 0 { + query.Set("page", strconv.FormatInt(*params.Page, 10)) + } + if params.PerPage != nil && *params.PerPage > 0 { + query.Set("per_page", strconv.FormatInt(*params.PerPage, 10)) + } + 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) + } + } + + var subscriptions []Subscription + _, 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("HEAD", fmt.Sprintf("/projects/%d/subscriptions", projectID), query, nil, []int{200}, nil) + if err != nil { + return 0, err + } + + var totalCount int64 + if header := res.Header.Get("X-Total-Count"); header != "" { + if parsed, parseErr := strconv.ParseInt(header, 10, 64); parseErr == nil { + totalCount = parsed + } + } + + return totalCount, nil +} + +func Create(params *SubscriptionCreateParams) (*Subscription, error) { + if params == nil { + return nil, fmt.Errorf("pushpad: params are required") + } + 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, params, []int{201}, &created) + if err != nil { + return nil, err + } + return &created, nil +} + +func Get(subscriptionID int64, params *SubscriptionGetParams) (*Subscription, error) { + if params == nil { + params = &SubscriptionGetParams{} + } + if subscriptionID == 0 { + return nil, fmt.Errorf("pushpad: subscription ID is required") + } + projectID, err := pushpad.ResolveProjectID(params.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 int64, 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(params.ProjectID) + if err != nil { + return nil, err + } + + var subscription Subscription + _, err = pushpad.DoRequest("PATCH", fmt.Sprintf("/projects/%d/subscriptions/%d", projectID, subscriptionID), nil, params, []int{200}, &subscription) + if err != nil { + return nil, err + } + return &subscription, nil +} + +func Delete(subscriptionID int64, params *SubscriptionDeleteParams) error { + if params == nil { + params = &SubscriptionDeleteParams{} + } + if subscriptionID == 0 { + return fmt.Errorf("pushpad: subscription ID is required") + } + projectID, err := pushpad.ResolveProjectID(params.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..347f713 --- /dev/null +++ b/subscription/api_test.go @@ -0,0 +1,277 @@ +package subscription + +import ( + "encoding/json" + "testing" + "time" + + "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: pushpad.Int64(123), + Page: pushpad.Int64(1), + PerPage: pushpad.Int64(20), + UIDs: pushpad.StringSlice([]string{"u1", "u2"}), + Tags: pushpad.StringSlice([]string{"tag1"}), + } + subscriptions, err := List(params) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + 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, err := List(&SubscriptionListParams{ProjectID: pushpad.Int64(123)}) + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + 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"). + 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") + + 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() + + 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: pushpad.Int64(123), Endpoint: pushpad.String("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 TestCreateSubscriptionWithAllFields(t *testing.T) { + defer gock.Off() + + params := SubscriptionCreateParams{ + ProjectID: pushpad.Int64(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.StringSlice([]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() + + 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: pushpad.Int64(123)}) + 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) + } +} + +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: pushpad.Int64(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 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.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)) + } +} + +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) + 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) + } + 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: pushpad.Int64(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..3927b98 --- /dev/null +++ b/subscription/models.go @@ -0,0 +1,59 @@ +package subscription + +import "time" + +// Subscription represents a Pushpad subscription. +type Subscription struct { + 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,omitempty"` + 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 *int64 `json:"-"` + UID *string `json:"uid,omitempty"` + Tags *[]string `json:"tags,omitempty"` +} + +// SubscriptionListParams controls subscription listing. +type SubscriptionListParams struct { + ProjectID *int64 + Page *int64 + PerPage *int64 + UIDs *[]string + Tags *[]string +} + +// SubscriptionCountParams controls subscription counts. +type SubscriptionCountParams struct { + ProjectID *int64 + UIDs *[]string + Tags *[]string +} + +// SubscriptionGetParams controls subscription fetches. +type SubscriptionGetParams struct { + ProjectID *int64 +} + +// SubscriptionDeleteParams controls subscription deletes. +type SubscriptionDeleteParams struct { + ProjectID *int64 +}