diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea46852 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# exponent + +Send push notifications to Expo apps using Golang + +## Documentation + +[![Go Reference](https://pkg.go.dev/badge/github.com/9ssi7/exponent.svg)](https://pkg.go.dev/github.com/9ssi7/exponent) + +## Installation +``` +go get github.com/9ssi7/exponent +``` + +## Usage +```go +package main + +import ( + "context" + "time" + + "github.com/9ssi7/exponent" +) + +func main() { + c := exponent.NewClient(exponent.WithAccessToken("your-access-token")) + + tkn := exponent.MustParseToken("ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + res, err := c.PublishSingle(ctx, &exponent.Message{ + To: []*exponent.Token{tkn}, + Body: "This is a test notification", + Data: exponent.Data{"withSome": "data"}, + Sound: "default", + Title: "Notification Title", + Priority: exponent.DefaultPriority, + }) + + if err != nil { + panic(err) + } + + if res.IsOk() { + println("Notification sent successfully") + } else { + println("Notification failed") + } +} + +``` + +## Contributing + +We welcome contributions! Please see our [Contribution Guidelines](CONTRIBUTING.md) for details. + +## License + +This project is licensed under the Apache License. See [LICENSE](LICENSE) for more details. \ No newline at end of file diff --git a/client.go b/client.go new file mode 100644 index 0000000..ab2f67e --- /dev/null +++ b/client.go @@ -0,0 +1,103 @@ +package exponent + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" +) + +type Client struct { + cnf *Config +} + +func NewClient(opts ...Option) *Client { + c := &Config{} + for _, opt := range opts { + opt(c) + } + withDefaults(c) + return &Client{c} +} + +// Publish sends a single push notification +// @param push_message: A PushMessage object +// @return an array of PushResponse objects which contains the results. +// @return error if any requests failed +func (c *Client) PublishSingle(ctx context.Context, msg *Message) (*MessageResponse, error) { + responses, err := c.publish(ctx, []*Message{msg}) + if err != nil { + return nil, err + } + return responses[0], nil +} + +// PublishMultiple sends multiple push notifications at once +// @param push_messages: An array of PushMessage objects. +// @return an array of PushResponse objects which contains the results. +// @return error if the request failed +func (c *Client) Publish(ctx context.Context, msgs []*Message) ([]*MessageResponse, error) { + return c.publish(ctx, msgs) +} + +func (c *Client) publish(ctx context.Context, msgs []*Message) ([]*MessageResponse, error) { + // Validate the messages + for _, message := range msgs { + if len(message.To) == 0 { + return nil, errors.New("no recipients") + } + for _, recipient := range message.To { + if recipient == nil || *recipient == "" { + return nil, errors.New("invalid push token") + } + } + } + url := fmt.Sprintf("%s%s/push/send", c.cnf.Host, c.cnf.ApiURL) + jsonBytes, err := json.Marshal(msgs) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBytes)) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + if c.cnf.AcessToken != "" { + req.Header.Add("Authorization", "Bearer "+c.cnf.AcessToken) + } + resp, err := c.cnf.HttpClient.Do(req) + if err != nil { + return nil, err + } + if err = checkStatus(resp); err != nil { + return nil, err + } + var r *Response + err = json.NewDecoder(resp.Body).Decode(&r) + if err != nil { + return nil, err + } + if r.Errors != nil { + return nil, errors.New("invalid request") + } + if r.Data == nil { + return nil, NewServerError("invalid server response", resp, r, nil) + } + if len(msgs) != len(r.Data) { + errMsg := fmt.Sprintf("mismatched response length. Expected %d receipts but only received %d", len(msgs), len(r.Data)) + return nil, NewServerError(errMsg, resp, r, nil) + } + for i := range r.Data { + r.Data[i].MessageItem = msgs[i] + } + return r.Data, nil +} + +func checkStatus(resp *http.Response) error { + if resp.StatusCode >= http.StatusOK && resp.StatusCode <= 299 { + return nil + } + return fmt.Errorf("invalid response (%d %s)", resp.StatusCode, resp.Status) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b417c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/9ssi7/exponent + +go 1.23.0 diff --git a/option.go b/option.go new file mode 100644 index 0000000..3a6bec5 --- /dev/null +++ b/option.go @@ -0,0 +1,48 @@ +package exponent + +import "net/http" + +type Config struct { + Host string + ApiURL string + AcessToken string + HttpClient *http.Client +} + +type Option func(*Config) + +func WithHost(host string) Option { + return func(c *Config) { + c.Host = host + } +} + +func WithApiURL(apiURL string) Option { + return func(c *Config) { + c.ApiURL = apiURL + } +} + +func WithAccessToken(accessToken string) Option { + return func(c *Config) { + c.AcessToken = accessToken + } +} + +func WithHttpClient(httpClient *http.Client) Option { + return func(c *Config) { + c.HttpClient = httpClient + } +} + +func withDefaults(c *Config) { + if c.Host == "" { + c.Host = "https://exp.host" + } + if c.ApiURL == "" { + c.ApiURL = "/--/api/v2" + } + if c.HttpClient == nil { + c.HttpClient = &http.Client{} + } +} diff --git a/push.go b/push.go new file mode 100644 index 0000000..5f8e3fa --- /dev/null +++ b/push.go @@ -0,0 +1,136 @@ +package exponent + +import ( + "errors" + "net/http" + "strings" +) + +type Priority string +type Data map[string]string +type ErrorMsg string +type Token string + +const ( + // NormalPriority is a priority used in PushMessage + NormalPriority Priority = "normal" + // HighPriority is a priority used in PushMessage + HighPriority Priority = "high" + // DefaultPriority is the standard priority used in PushMessage + DefaultPriority Priority = "default" + + // ErrorMsgDeviceNotRegistered indicates the token is invalid + ErrorMsgDeviceNotRegistered ErrorMsg = "DeviceNotRegistered" + // ErrorMsgTooBig indicates the message went over payload size of 4096 bytes + ErrorMsgTooBig ErrorMsg = "MessageTooBig" + // ErrorMsgRateExceeded indicates messages have been sent too frequently + ErrorMsgRateExceeded ErrorMsg = "MessageRateExceeded" + // ErrMsgMalformedToken is returned if a token does not start with 'ExponentPushToken' + ErrMsgMalformedToken ErrorMsg = "token should start with ExponentPushToken" +) + +// ParseToken returns a token and may return an error if the input token is invalid +func ParseToken(token string) (*Token, error) { + if !strings.HasPrefix(token, "ExponentPushToken") { + return nil, errors.New(string(ErrMsgMalformedToken)) + } + tkn := Token(token) + return &tkn, nil +} + +func MustParseToken(token string) *Token { + tkn, _ := ParseToken(token) + return tkn +} + +func IsPushTokenValid(token string) bool { + _, err := ParseToken(token) + return err == nil +} + +// is an object that describes a push notification request. +type Message struct { + // An Expo push token or an array of Expo push tokens specifying the recipient(s) of this message. + To []*Token `json:"to"` + // The title to display in the notification. On iOS, this is displayed only on Apple Watch. + Title string `json:"title,omitempty"` + // The message to display in the notification. + Body string `json:"body"` + // A dict of extra data to pass inside of the push notification. The total notification payload must be at most 4096 bytes. + Data Data `json:"data,omitempty"` + // A sound to play when the recipient receives this notification. + // Specify "default" to play the device's default notification sound, or omit this field to play no sound. + Sound string `json:"sound,omitempty"` + // The number of seconds for which the message may be kept around for redelivery if it hasn't been delivered yet. Defaults to 0. + TTL int `json:"ttl,omitempty"` + // UNIX timestamp for when this message expires. It has the same effect as ttl, and is just an absolute timestamp instead of a relative one. + Expiration int64 `json:"expiration,omitempty"` + // Delivery priority of the message. Use the *Priority constants specified above. + Priority Priority `json:"priority,omitempty"` + // An integer representing the unread notification count. + // This currently only affects iOS. Specify 0 to clear the badge count. + Badge int `json:"badge,omitempty"` + // ID of the Notification Channel through which to display this notification on Android devices. + ChannelID string `json:"channelId,omitempty"` +} + +// Response is the HTTP response returned from an Expo publish HTTP request +type Response struct { + Data []*MessageResponse `json:"data"` + Errors []Data `json:"errors"` +} + +// PushResponse is a wrapper class for a push notification response. +// A successful single push notification: +// +// {'status': 'ok'} +// +// An invalid push token +// +// {'status': 'error', +// 'message': '"adsf" is not a registered push notification recipient'} +type MessageResponse struct { + MessageItem *Message + ID string `json:"id"` + Status string `json:"status"` + Message string `json:"message"` + Details Data `json:"details"` +} + +func (r *MessageResponse) IsOk() bool { + return r.Status == "ok" +} + +// ServerError is raised when the push token server is not behaving as expected +// For example, invalid push notification arguments result in a different +// style of error. Instead of a "data" array containing errors per +// notification, an "error" array is returned. +// {"errors": [ +// +// {"code": "API_ERROR", +// "message": "child \"to\" fails because [\"to\" must be a string]. \"value\" must be an array." +// } +// +// ]} +type ServerError struct { + Message string + Response *http.Response + ResponseData *Response + Errors []Data +} + +// NewServerError creates a new PushServerError object +func NewServerError(message string, response *http.Response, + responseData *Response, + errors []Data) *ServerError { + return &ServerError{ + Message: message, + Response: response, + ResponseData: responseData, + Errors: errors, + } +} + +func (e *ServerError) Error() string { + return e.Message +}