From 8648209ca707b38d44e4ad53a7d2e1c64fb69cc8 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 31 May 2016 10:57:12 -0600 Subject: [PATCH 001/152] return verbose messages as part of the Response instead of printing them out --- common.go | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/common.go b/common.go index 820988d..0239608 100644 --- a/common.go +++ b/common.go @@ -8,6 +8,7 @@ import ( "fmt" "io/ioutil" "net/http" + "net/http/httputil" "regexp" "strings" @@ -55,9 +56,11 @@ func NewConfig(m map[string]string) (*Config, error) { // Response contains information about the last HTTP response. // Helpful when an error message doesn't necessarily give the complete picture. +// Also contains any messages emitted as a result of the Verbose config option. type Response struct { HTTP *http.Response Body []byte + Verbose map[string]string Results map[string]interface{} `json:"results,omitempty"` Errors []Error `json:"errors,omitempty"` } @@ -156,20 +159,22 @@ func (c *Client) HttpDelete(url string) (*Response, error) { } func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error) { - req, err := http.NewRequest(method, urlStr, bytes.NewBuffer(data)) if err != nil { - if c.Config.Verbose { - fmt.Println("Error: %s", err) - } return nil, err } + + ares := &Response{} + if c.Config.Verbose && ares.Verbose == nil { + ares.Verbose = map[string]string{} + } if data != nil { req.Header.Set("Content-Type", "application/json") if c.Config.Verbose { - fmt.Printf("URL: %s %s\n", method, urlStr) - fmt.Printf("Will Post: %s\n", string(data)) + ares.Verbose["http_method"] = method + ares.Verbose["http_uri"] = urlStr + ares.Verbose["http_postdata"] = string(data) } } @@ -188,24 +193,23 @@ func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error } if c.Config.Verbose { - fmt.Println("Request: ", req) + reqBytes, err := httputil.DumpRequestOut(req, false) + if err != nil { + return ares, err + } + ares.Verbose["http_requestdump"] = string(reqBytes) } res, err := c.Client.Do(req) - ares := &Response{HTTP: res} + ares.HTTP = res if c.Config.Verbose { + ares.Verbose["http_status"] = ares.HTTP.Status + bodyBytes, err := httputil.DumpResponse(res, true) if err != nil { - fmt.Println("Error: ", err) - } else { - fmt.Println("Server Response: ", ares.HTTP.Status) - bodyBytes, err := ares.ReadBody() - if err != nil { - fmt.Println("Error: ", err) - } else { - fmt.Println("Body: ", string(bodyBytes)) - } + return ares, err } + ares.Verbose["http_responsedump"] = string(bodyBytes) } return ares, err From dbd54d61f0739d77de57c63489870608a1de76f7 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 31 May 2016 10:58:16 -0600 Subject: [PATCH 002/152] dump out http request headers, and full response when `--httpdump` is present --- cmd/sparks/sparks.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/cmd/sparks/sparks.go b/cmd/sparks/sparks.go index c12a1ce..c638c94 100644 --- a/cmd/sparks/sparks.go +++ b/cmd/sparks/sparks.go @@ -53,6 +53,7 @@ var inline = flag.Bool("inline-css", false, "automatically inline css") var dryrun = flag.Bool("dry-run", false, "dump json that would be sent to server") var url = flag.String("url", "", "base url for api requests (optional)") var help = flag.Bool("help", false, "display a help message") +var httpDump = flag.Bool("httpdump", false, "dump out http request and response") func main() { flag.Parse() @@ -86,6 +87,9 @@ func main() { } cfg.BaseUrl = *url } + if *httpDump { + cfg.Verbose = true + } var sparky sp.Client err := sparky.Init(cfg) @@ -264,5 +268,20 @@ func main() { log.Fatal(err) } - log.Printf("HTTP [%s] TX %s\n", req.HTTP.Status, id) + if *httpDump { + if reqDump, ok := req.Verbose["http_requestdump"]; ok { + os.Stdout.Write([]byte(reqDump)) + } else { + os.Stdout.Write([]byte("*** No request dump available! ***\n\n")) + } + + if resDump, ok := req.Verbose["http_responsedump"]; ok { + os.Stdout.Write([]byte(resDump)) + os.Stdout.Write([]byte("\n")) + } else { + os.Stdout.Write([]byte("*** No response dump available! ***\n")) + } + } else { + log.Printf("HTTP [%s] TX %s\n", req.HTTP.Status, id) + } } From 99a45c5ea4556f32feb30bddd2aa57f4f3fabed7 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 31 May 2016 10:59:54 -0600 Subject: [PATCH 003/152] set `http_method` and `http_uri` even when not sending any post data --- common.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/common.go b/common.go index 0239608..f4bf075 100644 --- a/common.go +++ b/common.go @@ -165,15 +165,17 @@ func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error } ares := &Response{} - if c.Config.Verbose && ares.Verbose == nil { - ares.Verbose = map[string]string{} + if c.Config.Verbose { + if ares.Verbose == nil { + ares.Verbose = map[string]string{} + } + ares.Verbose["http_method"] = method + ares.Verbose["http_uri"] = urlStr } if data != nil { req.Header.Set("Content-Type", "application/json") if c.Config.Verbose { - ares.Verbose["http_method"] = method - ares.Verbose["http_uri"] = urlStr ares.Verbose["http_postdata"] = string(data) } } From 0a4c0662cdb8b03a4c1094521dc6d58f19a568fb Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 1 Jun 2016 10:43:35 -0600 Subject: [PATCH 004/152] "api key missing" should be non-fatal in dry run mode --- cmd/sparks/sparks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/sparks/sparks.go b/cmd/sparks/sparks.go index c638c94..3b94c8a 100644 --- a/cmd/sparks/sparks.go +++ b/cmd/sparks/sparks.go @@ -68,7 +68,7 @@ func main() { } apiKey := os.Getenv("SPARKPOST_API_KEY") - if strings.TrimSpace(apiKey) == "" { + if strings.TrimSpace(apiKey) == "" && *dryrun == false { log.Fatal("FATAL: API key not found in environment!\n") } From 55f217e584a8e0f98aaad5aef97cb13e00125f48 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 1 Jun 2016 10:56:49 -0600 Subject: [PATCH 005/152] switch from `Contains` to `HasPrefix` for path vs string content check --- cmd/sparks/sparks.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/sparks/sparks.go b/cmd/sparks/sparks.go index 3b94c8a..24d0bcd 100644 --- a/cmd/sparks/sparks.go +++ b/cmd/sparks/sparks.go @@ -103,7 +103,7 @@ func main() { } if hasHtml { - if strings.Contains(*htmlFlag, "/") { + if strings.HasPrefix(*htmlFlag, "/") || strings.HasPrefix(*htmlFlag, "./") { // read file to get html htmlBytes, err := ioutil.ReadFile(*htmlFlag) if err != nil { @@ -117,7 +117,7 @@ func main() { } if hasText { - if strings.Contains(*textFlag, "/") { + if strings.HasPrefix(*textFlag, "/") || strings.HasPrefix(*textFlag, "./") { // read file to get text textBytes, err := ioutil.ReadFile(*textFlag) if err != nil { @@ -173,7 +173,7 @@ func main() { var subJson *json.RawMessage if hasSubs { var subsBytes []byte - if strings.Contains(*subsFlag, "/") { + if strings.HasPrefix(*subsFlag, "/") || strings.HasPrefix(*subsFlag, "./") { // read file to get substitution data subsBytes, err = ioutil.ReadFile(*subsFlag) if err != nil { From 58e48574f268df819866b1288b23e216673d7e60 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 1 Jun 2016 23:30:43 -0600 Subject: [PATCH 006/152] notes on the sparks tool --- README.rst | 2 ++ cmd/sparks/README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 cmd/sparks/README.md diff --git a/README.rst b/README.rst index 367a60b..ba36f24 100644 --- a/README.rst +++ b/README.rst @@ -91,8 +91,10 @@ Documentation ------------- * `SparkPost API Reference`_ +* `Example code - sparks`_ .. _SparkPost API Reference: https://www.sparkpost.com/api +.. _Example code - sparks: cmd/sparks/README.md Contribute ---------- diff --git a/cmd/sparks/README.md b/cmd/sparks/README.md new file mode 100644 index 0000000..282aef5 --- /dev/null +++ b/cmd/sparks/README.md @@ -0,0 +1,46 @@ +# sparks + +`sparks` is a command-line tool for sending email using the [SparkPost API](https://developers.sparkpost.com/api/). + +### Why does this exist? + +I've found this tool useful for testing and troubleshooting, and for making sure that reported issues with `gosparkpost` get squashed. It also has handy working example code showing how to use the `gosparkpost` library to do various things, like using inline images, adding attachments, and managing cc/bcc recipients. + +### Why is it called `sparks`? + +It's similar in function to [swaks](http://www.jetmore.org/john/code/swaks/), which is a handy SMTP tool: `Swiss Army Knife for SMTP`, and `sparks` sounded better than `swaksp`, `swakapi` or `apisak`, etc. + +### Installation + + $ go get git@github.com:SparkPost/gosparkpost + $ cd $GOPATH/src/github.com/SparkPost/gosparkpost/cmd/sparks + $ go build && go install + +### Config + + $ export SPARKPOST_API_KEY=0000000000000000000000000000000000000000 + +### Examples + +HTML content with inline image, dumping request and response HTTP headers, and response body. + + $ sparks -from img@sp.example.com -html $HOME/test/image.html \ + -img image/jpg:hi:$HOME/test/hi.jpg -subject 'hello!' \ + -to me@example.com.sink.sparkpostmail.com -httpdump + +HTML content with attachment. + + $ sparks -from att@sp.example.com -html 'Did you get that thing I sent you?' \ + -to you@example.com.sink.sparkpostmail.com -subject 'that thing' \ + -attach image/jpg:thing.jpg:$HOME/test/thing.jpg + +Text content with cc and bcc, but don't send it. +The output that would be sent is pretty printed using the handy JSON tool `jq`. + + $ sparks -from cc@sp.example.com -text 'This is an ambiguous notification' \ + -subject 'ambiguous notification' \ + -to me@example.com.sink.sparkpostmail.com \ + -cc you@example.com.sink.sparkpostmail.com \ + -bcc thing1@example.com.sink.sparkpostmail.com \ + -bcc thing2@example.com.sink.sparkpostmail.com \ + -dry-run | jq . From a74c027b5ef10ddfab8d797e3f505c1be5a0734b Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 3 Jun 2016 10:29:05 -0600 Subject: [PATCH 007/152] example code for cc/bcc --- examples/cc/cc.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 examples/cc/cc.go diff --git a/examples/cc/cc.go b/examples/cc/cc.go new file mode 100644 index 0000000..e37480f --- /dev/null +++ b/examples/cc/cc.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + sp "github.com/SparkPost/gosparkpost" +) + +func main() { + to := []string{ + "to1@test.com.sink.sparkpostmail.com", + "to2@test.com.sink.sparkpostmail.com", + } + headerTo := strings.Join(to, ",") + + cc := []string{ + "cc1@test.com.sink.sparkpostmail.com", + "cc2@test.com.sink.sparkpostmail.com", + } + + bcc := []string{ + "bcc1@test.com.sink.sparkpostmail.com", + "bcc2@test.com.sink.sparkpostmail.com", + } + + content := sp.Content{ + From: "test@example.com", + Subject: "cc/bcc example message", + Text: "This is a cc/bcc example", + } + + tx := &sp.Transmission{ + Recipients: []sp.Recipient{}, + } + + if len(to) > 0 { + for _, t := range to { + tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ + Address: sp.Address{Email: t, HeaderTo: headerTo}, + }) + } + } + + if len(cc) > 0 { + for _, c := range cc { + tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ + Address: sp.Address{Email: c, HeaderTo: headerTo}, + }) + } + // add cc header to content + if content.Headers == nil { + content.Headers = map[string]string{} + } + content.Headers["cc"] = strings.Join(cc, ",") + } + + if len(bcc) > 0 { + for _, b := range bcc { + tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ + Address: sp.Address{Email: b, HeaderTo: headerTo}, + }) + } + } + + tx.Content = content + + jsonBytes, err := json.Marshal(tx) + if err != nil { + panic(err) + } + fmt.Fprintln(os.Stdout, string(jsonBytes)) + +} From 8d228e46c149eff934019a602177dbdb7bf7f14a Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 3 Jun 2016 10:38:07 -0600 Subject: [PATCH 008/152] update doc links in main readme; update title in `sparks` summary; add readme to examples section --- README.rst | 6 ++++-- cmd/sparks/README.md | 2 +- examples/README.md | 10 ++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 examples/README.md diff --git a/README.rst b/README.rst index ba36f24..d8a7f72 100644 --- a/README.rst +++ b/README.rst @@ -91,10 +91,12 @@ Documentation ------------- * `SparkPost API Reference`_ -* `Example code - sparks`_ +* `Code samples`_ +* `Command-line tool: sparks`_ .. _SparkPost API Reference: https://www.sparkpost.com/api -.. _Example code - sparks: cmd/sparks/README.md +.. _Code samples: examples/README.md +.. _Command-line tool\: sparks: cmd/sparks/README.md Contribute ---------- diff --git a/cmd/sparks/README.md b/cmd/sparks/README.md index 282aef5..4c70521 100644 --- a/cmd/sparks/README.md +++ b/cmd/sparks/README.md @@ -20,7 +20,7 @@ It's similar in function to [swaks](http://www.jetmore.org/john/code/swaks/), wh $ export SPARKPOST_API_KEY=0000000000000000000000000000000000000000 -### Examples +### Usage Examples HTML content with inline image, dumping request and response HTTP headers, and response body. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..414312d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,10 @@ +# Example Code + +Short snippets of code showing how to do various things. +Feel free to submit your own examples! + +### Cc and Bcc + +It's a bit confusing getting this set up for the first time. Here's a snippet that shows how it's done. See also [sparks](cmd/sparks/sparks.go) for an example that will send mail, instead of just printing out JSON. + +[Cc and Bcc example](cc/cc.go) From 7658e7e0aaa7d0c7060e644cdba7f29c2c036db0 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 3 Jun 2016 12:41:46 -0600 Subject: [PATCH 009/152] more exposition to go with cc/bcc code example --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 414312d..c83b4bd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,6 @@ Feel free to submit your own examples! ### Cc and Bcc -It's a bit confusing getting this set up for the first time. Here's a snippet that shows how it's done. See also [sparks](cmd/sparks/sparks.go) for an example that will send mail, instead of just printing out JSON. +Mail clients usually set up the details of `Cc` and `Bcc` for you, so thinking about it in terms of individual emails to be sent can be a bit of an adjustment. Here's a snippet that shows how it's done. See also [sparks](cmd/sparks/sparks.go) for an example that will send mail, instead of just printing out JSON. [Cc and Bcc example](cc/cc.go) From fee1ff8bb3e38d96c8999e2ee61f113520f53973 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 3 Jun 2016 12:59:28 -0600 Subject: [PATCH 010/152] more on the "why" of cc and bcc --- examples/README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/examples/README.md b/examples/README.md index c83b4bd..8cb7aaf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,3 +8,59 @@ Feel free to submit your own examples! Mail clients usually set up the details of `Cc` and `Bcc` for you, so thinking about it in terms of individual emails to be sent can be a bit of an adjustment. Here's a snippet that shows how it's done. See also [sparks](cmd/sparks/sparks.go) for an example that will send mail, instead of just printing out JSON. [Cc and Bcc example](cc/cc.go) + +### Overview + +Replicating mail clients' `Cc` and `Bcc` behavior with SparkPost is easier to reason about if you focus on the individual recipients - everyone that gets an email when you click send - and what the message needs to look like for them. Everyone who gets a message must currently have their own `Recipient` within the `Transmission` that's sent to SparkPost. + +Here's the output of the example linked above, which generates a message with 2 recipients in the `To`, 2 in the `Cc`, and 2 in the `Bcc`. Notice we're setting `header_to` in each `Recipient`, and it's always the same. We're also setting the `Cc` header w/in `content`, which is also always the same. + + { + "recipients": [ + { + "address": { + "email": "to1@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "to2@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "cc1@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "cc2@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "bcc1@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "bcc2@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + } + ], + "content": { + "text": "This is a cc/bcc example", + "subject": "cc/bcc example message", + "from": "test@example.com", + "headers": { + "cc": "cc1@test.com.sink.sparkpostmail.com,cc2@test.com.sink.sparkpostmail.com" + } + } + } + From e7c319787082613ef1196f8548ea15115bf6eba8 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 5 Jul 2016 15:37:23 -0600 Subject: [PATCH 011/152] helper for parsing messages sent through sparkpost --- helpers/loadmsg/loadmsg.go | 64 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 helpers/loadmsg/loadmsg.go diff --git a/helpers/loadmsg/loadmsg.go b/helpers/loadmsg/loadmsg.go new file mode 100644 index 0000000..864ee79 --- /dev/null +++ b/helpers/loadmsg/loadmsg.go @@ -0,0 +1,64 @@ +package loadmsg + +import ( + "encoding/base64" + "net/mail" + "os" + "strconv" + "strings" + + "github.com/buger/jsonparser" +) + +type Message struct { + Filename string + File *os.File + Message *mail.Message + Json []byte + CustID int + Recipient []byte +} + +func (m *Message) Load() error { + var err error + + m.File, err = os.Open(m.Filename) + if err != nil { + return err + } + + m.Message, err = mail.ReadMessage(m.File) + if err != nil { + return err + } + + b64hdr := strings.Replace(m.Message.Header.Get("X-MSFBL"), " ", "", -1) + + if strings.Index(b64hdr, "|") >= 0 { + // Everything before the pipe is an encoded HMAC + // TODO: verify contents using HMAC + b64hdr = strings.Split(b64hdr, "|")[1] + } + + m.Json, err = base64.StdEncoding.DecodeString(b64hdr) + if err != nil { + return err + } + + var cid []byte + cid, _, _, err = jsonparser.Get(m.Json, "customer_id") + if err != nil { + return err + } + m.CustID, err = strconv.Atoi(string(cid)) + if err != nil { + return err + } + + m.Recipient, _, _, err = jsonparser.Get(m.Json, "r") + if err != nil { + return err + } + + return nil +} From 1c095b02774c24ac85575a5af24ac6479702587b Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 5 Jul 2016 15:39:10 -0600 Subject: [PATCH 012/152] factor out to `loadmsg` library --- cmd/fblgen/arf.go | 2 +- cmd/fblgen/fblgen.go | 53 ++++++++------------------------------------ 2 files changed, 10 insertions(+), 45 deletions(-) diff --git a/cmd/fblgen/arf.go b/cmd/fblgen/arf.go index aa5310e..f477e2c 100644 --- a/cmd/fblgen/arf.go +++ b/cmd/fblgen/arf.go @@ -7,7 +7,7 @@ import ( ) var ArfFormat string = `From: <%s> -Date: Thu, 8 Mar 2005 17:40:36 EDT +Date: Mon, 02 Jan 2006 15:04:05 MST Subject: FW: Earn money To: <%s> MIME-Version: 1.0 diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index 7d470a1..d6e5343 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -1,7 +1,6 @@ package main import ( - "encoding/base64" "flag" "fmt" "log" @@ -10,8 +9,9 @@ import ( "net/smtp" "os" "regexp" - "strconv" "strings" + + "github.com/SparkPost/gosparkpost/helpers/loadmsg" ) var filename = flag.String("file", "", "path to email with a text/html part") @@ -34,57 +34,22 @@ func main() { log.Fatal("--file is required") } - fh, err := os.Open(*filename) - if err != nil { - log.Fatal(err) - } - - msg, err := mail.ReadMessage(fh) + msg := loadmsg.Message{Filename: *filename} + err := msg.Load() if err != nil { log.Fatal(err) } - b64hdr := strings.Replace(msg.Header.Get("X-MSFBL"), " ", "", -1) + b64hdr := msg.Message.Header.Get("X-MSFBL") if verbose == true { log.Printf("X-MSFBL: %s\n", b64hdr) } - var dec []byte - b64 := base64.StdEncoding - if strings.Index(b64hdr, "|") >= 0 { - // Everything before the pipe is an encoded hmac - // TODO: verify contents using hmac - encs := strings.Split(b64hdr, "|") - dec, err = b64.DecodeString(encs[1]) - if err != nil { - log.Fatal(err) - } - } else { - dec, err = b64.DecodeString(b64hdr) - if err != nil { - log.Fatal(err) - } - } - - cidMatches := cidPattern.FindSubmatch(dec) - if cidMatches == nil || len(cidMatches) < 2 { - log.Fatalf("No key \"customer_id\" in X-MSFBL header:\n%s\n", string(dec)) - } - cid, err := strconv.Atoi(string(cidMatches[1])) - if err != nil { - log.Fatal(err) - } - - toMatches := toPattern.FindSubmatch(dec) - if toMatches == nil || len(toMatches) < 2 { - log.Fatalf("No key \"r\" (recipient) in X-MSFBL header:\n%s\n", string(dec)) - } - if verbose == true { - log.Printf("Decoded FBL (cid=%d): %s\n", cid, string(dec)) + log.Printf("Decoded FBL (cid=%d): %s\n", msg.CustID, string(msg.Json)) } - returnPath := msg.Header.Get("Return-Path") + returnPath := msg.Message.Header.Get("Return-Path") if fblAddress != nil && *fblAddress != "" { returnPath = *fblAddress } @@ -108,8 +73,8 @@ func main() { } // from/to are opposite here, since we're simulating a reply - fblFrom := string(toMatches[1]) - arf := BuildArf(fblFrom, fblTo, b64hdr, cid) + fblFrom := string(msg.Recipient) + arf := BuildArf(fblFrom, fblTo, b64hdr, msg.CustID) if dumpArf != nil && *dumpArf == true { fmt.Fprintf(os.Stdout, "%s", arf) From 0f0f1c87a19a712462b42332a354bc04e8b8c951 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 5 Jul 2016 15:40:03 -0600 Subject: [PATCH 013/152] remove unused patterns --- cmd/fblgen/fblgen.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index d6e5343..1bbc007 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -8,7 +8,6 @@ import ( "net/mail" "net/smtp" "os" - "regexp" "strings" "github.com/SparkPost/gosparkpost/helpers/loadmsg" @@ -20,9 +19,6 @@ var send = flag.Bool("send", false, "send fbl report") var fblAddress = flag.String("fblto", "", "where to deliver the fbl report") var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") -var cidPattern *regexp.Regexp = regexp.MustCompile(`"customer_id"\s*:\s*"(\d+)"`) -var toPattern *regexp.Regexp = regexp.MustCompile(`"r"\s*:\s*"([^"\s]+)"`) - func main() { flag.Parse() var verbose bool From dca5784d250d9e40c76c88250c8c9092ce926415 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 5 Jul 2016 15:43:38 -0600 Subject: [PATCH 014/152] fix copy/pasted cmd line help - don't need a text/html part --- cmd/fblgen/fblgen.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index 1bbc007..5b9d602 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -13,7 +13,7 @@ import ( "github.com/SparkPost/gosparkpost/helpers/loadmsg" ) -var filename = flag.String("file", "", "path to email with a text/html part") +var filename = flag.String("file", "", "path to raw email") var dumpArf = flag.Bool("arf", false, "dump out multipart/report message") var send = flag.Bool("send", false, "send fbl report") var fblAddress = flag.String("fblto", "", "where to deliver the fbl report") From 3295fd64f82b85cf3f94644f655658864f2258aa Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 5 Jul 2016 15:47:57 -0600 Subject: [PATCH 015/152] let flag package do these checks --- cmd/fblgen/fblgen.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index 5b9d602..4517fc1 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -22,11 +22,11 @@ var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") func main() { flag.Parse() var verbose bool - if verboseOpt != nil && *verboseOpt == true { + if *verboseOpt == true { verbose = true } - if filename == nil || strings.TrimSpace(*filename) == "" { + if *filename == "" { log.Fatal("--file is required") } From 6e26e243ffd1ceee5f8df3ac9d0c2131efa39bec Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 5 Jul 2016 15:59:10 -0600 Subject: [PATCH 016/152] cache cleaned-up version of X-MSFBL header; remove checks that `flag` takes care of --- cmd/fblgen/fblgen.go | 13 ++++++------- helpers/loadmsg/loadmsg.go | 9 +++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index 4517fc1..7e8ae16 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -36,9 +36,8 @@ func main() { log.Fatal(err) } - b64hdr := msg.Message.Header.Get("X-MSFBL") if verbose == true { - log.Printf("X-MSFBL: %s\n", b64hdr) + log.Printf("X-MSFBL: %s\n", msg.MSFBL) } if verbose == true { @@ -46,7 +45,7 @@ func main() { } returnPath := msg.Message.Header.Get("Return-Path") - if fblAddress != nil && *fblAddress != "" { + if *fblAddress != "" { returnPath = *fblAddress } fblAddr, err := mail.ParseAddress(returnPath) @@ -61,7 +60,7 @@ func main() { fblDomain := fblAddr.Address[atIdx:] fblTo := fmt.Sprintf("fbl@%s", fblDomain) if verbose == true { - if fblAddress != nil && *fblAddress != "" { + if *fblAddress != "" { log.Printf("Got domain [%s] from --fblto\n", fblDomain) } else { log.Printf("Got domain [%s] from Return-Path header\n", fblDomain) @@ -70,9 +69,9 @@ func main() { // from/to are opposite here, since we're simulating a reply fblFrom := string(msg.Recipient) - arf := BuildArf(fblFrom, fblTo, b64hdr, msg.CustID) + arf := BuildArf(fblFrom, fblTo, msg.MSFBL, msg.CustID) - if dumpArf != nil && *dumpArf == true { + if *dumpArf == true { fmt.Fprintf(os.Stdout, "%s", arf) } @@ -88,7 +87,7 @@ func main() { } smtpHost := fmt.Sprintf("%s:smtp", mxs[0].Host) - if send != nil && *send == true { + if *send == true { log.Printf("Sending FBL from [%s] to [%s] via [%s]...\n", fblFrom, fblTo, smtpHost) err = smtp.SendMail(smtpHost, nil, fblFrom, []string{fblTo}, []byte(arf)) diff --git a/helpers/loadmsg/loadmsg.go b/helpers/loadmsg/loadmsg.go index 864ee79..e683aca 100644 --- a/helpers/loadmsg/loadmsg.go +++ b/helpers/loadmsg/loadmsg.go @@ -14,6 +14,7 @@ type Message struct { Filename string File *os.File Message *mail.Message + MSFBL string Json []byte CustID int Recipient []byte @@ -32,15 +33,15 @@ func (m *Message) Load() error { return err } - b64hdr := strings.Replace(m.Message.Header.Get("X-MSFBL"), " ", "", -1) + m.MSFBL = strings.Replace(m.Message.Header.Get("X-MSFBL"), " ", "", -1) - if strings.Index(b64hdr, "|") >= 0 { + if strings.Index(m.MSFBL, "|") >= 0 { // Everything before the pipe is an encoded HMAC // TODO: verify contents using HMAC - b64hdr = strings.Split(b64hdr, "|")[1] + m.MSFBL = strings.Split(m.MSFBL, "|")[1] } - m.Json, err = base64.StdEncoding.DecodeString(b64hdr) + m.Json, err = base64.StdEncoding.DecodeString(m.MSFBL) if err != nil { return err } From bd24f68d5c6cdadb71ecf5efc1113770b6a90c52 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 5 Jul 2016 16:17:34 -0600 Subject: [PATCH 017/152] auto-parse return-path header --- cmd/fblgen/fblgen.go | 14 ++++---------- helpers/loadmsg/loadmsg.go | 34 +++++++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index 7e8ae16..a4a952b 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "net" - "net/mail" "net/smtp" "os" "strings" @@ -44,20 +43,15 @@ func main() { log.Printf("Decoded FBL (cid=%d): %s\n", msg.CustID, string(msg.Json)) } - returnPath := msg.Message.Header.Get("Return-Path") if *fblAddress != "" { - returnPath = *fblAddress - } - fblAddr, err := mail.ParseAddress(returnPath) - if err != nil { - log.Fatal(err) + msg.SetReturnPath(*fblAddress) } - atIdx := strings.Index(fblAddr.Address, "@") + 1 + atIdx := strings.Index(msg.ReturnPath.Address, "@") + 1 if atIdx < 0 { - log.Fatalf("Unsupported Return-Path header [%s]\n", returnPath) + log.Fatalf("Unsupported Return-Path header [%s]\n", msg.ReturnPath.Address) } - fblDomain := fblAddr.Address[atIdx:] + fblDomain := msg.ReturnPath.Address[atIdx:] fblTo := fmt.Sprintf("fbl@%s", fblDomain) if verbose == true { if *fblAddress != "" { diff --git a/helpers/loadmsg/loadmsg.go b/helpers/loadmsg/loadmsg.go index e683aca..f581d16 100644 --- a/helpers/loadmsg/loadmsg.go +++ b/helpers/loadmsg/loadmsg.go @@ -2,6 +2,7 @@ package loadmsg import ( "encoding/base64" + "fmt" "net/mail" "os" "strconv" @@ -11,13 +12,14 @@ import ( ) type Message struct { - Filename string - File *os.File - Message *mail.Message - MSFBL string - Json []byte - CustID int - Recipient []byte + Filename string + File *os.File + Message *mail.Message + MSFBL string + Json []byte + CustID int + Recipient []byte + ReturnPath *mail.Address } func (m *Message) Load() error { @@ -33,6 +35,13 @@ func (m *Message) Load() error { return err } + if m.ReturnPath == nil { + err = m.SetReturnPath(m.Message.Header.Get("Return-Path")) + if err != nil { + return err + } + } + m.MSFBL = strings.Replace(m.Message.Header.Get("X-MSFBL"), " ", "", -1) if strings.Index(m.MSFBL, "|") >= 0 { @@ -63,3 +72,14 @@ func (m *Message) Load() error { return nil } + +func (m *Message) SetReturnPath(addr string) (err error) { + if !strings.Contains(addr, "@") { + return fmt.Errorf("Unsupported Return-Path header: no @") + } + m.ReturnPath, err = mail.ParseAddress(addr) + if err != nil { + return err + } + return nil +} From 237cd488681adca70e6f0342f9c6254585846960 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 6 Jul 2016 09:39:49 -0600 Subject: [PATCH 018/152] tool to inject a code 10 out-of-band bounce, which will auto-add the recipient to the suppression list --- cmd/oobgen/oob.go | 68 ++++++++++++++++++++++++++++++++++++ cmd/oobgen/oobgen.go | 82 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 cmd/oobgen/oob.go create mode 100644 cmd/oobgen/oobgen.go diff --git a/cmd/oobgen/oob.go b/cmd/oobgen/oob.go new file mode 100644 index 0000000..e340f77 --- /dev/null +++ b/cmd/oobgen/oob.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "strings" + "time" +) + +var OobFormat string = `From: %s +Date: Mon, 02 Jan 2006 15:04:05 MST +Subject: Returned mail: see transcript for details +Auto-Submitted: auto-generated (failure) +To: %s +Content-Type: multipart/report; report-type=delivery-status; + boundary="%s" + +This is a MIME-encapsulated message + +--%s + +The original message was received at Mon, 02 Jan 2006 15:04:05 -0700 +from example.com.sink.sparkpostmail.com [52.41.116.105] + + ----- The following addresses had permanent fatal errors ----- +<%s> + (reason: 550 5.0.0 <%s>... User unknown) + + ----- Transcript of session follows ----- +... while talking to %s: +>>> DATA +<<< 550 5.0.0 <%s>... User unknown +550 5.1.1 <%s>... User unknown +<<< 503 5.0.0 Need RCPT (recipient) + +--%s +Content-Type: message/delivery-status + +Reporting-MTA: dns; %s +Received-From-MTA: DNS; %s +Arrival-Date: Mon, 02 Jan 2006 15:04:05 MST + +Final-Recipient: RFC822; %s +Action: failed +Status: 5.0.0 +Remote-MTA: DNS; %s +Diagnostic-Code: SMTP; 550 5.0.0 <%s>... User unknown +Last-Attempt-Date: Mon, 02 Jan 2006 15:04:05 MST + +--%s +Content-Type: message/rfc822 + +%s + +--%s-- +` + +func BuildOob(from, to, rawMsg string) string { + boundary := fmt.Sprintf("_----%d===_61/00-25439-267B0055", time.Now().Unix()) + fromDomain := from[strings.Index(from, "@")+1:] + toDomain := to[strings.Index(to, "@")+1:] + msg := fmt.Sprintf(OobFormat, + from, to, boundary, + boundary, to, to, toDomain, to, to, + boundary, toDomain, fromDomain, to, toDomain, to, + boundary, rawMsg, + boundary) + return msg +} diff --git a/cmd/oobgen/oobgen.go b/cmd/oobgen/oobgen.go new file mode 100644 index 0000000..7b6aa11 --- /dev/null +++ b/cmd/oobgen/oobgen.go @@ -0,0 +1,82 @@ +package main + +import ( + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "net/mail" + "net/smtp" + "strings" + + "github.com/SparkPost/gosparkpost/helpers/loadmsg" +) + +var filename = flag.String("file", "", "path to raw email") +var send = flag.Bool("send", false, "send fbl report") +var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") + +func main() { + flag.Parse() + var verbose bool + if *verboseOpt == true { + verbose = true + } + + if *filename == "" { + log.Fatal("--file is required") + } + + msg := loadmsg.Message{Filename: *filename} + err := msg.Load() + if err != nil { + log.Fatal(err) + } + + if verbose == true { + log.Printf("Return-Path: %s\n", msg.ReturnPath) + } + + fileBytes, err := ioutil.ReadFile(*filename) + if err != nil { + log.Fatal(err) + } + + // from/to are opposite here, since we're simulating a reply + to := msg.ReturnPath.Address + from, err := mail.ParseAddress(msg.Message.Header.Get("From")) + if err != nil { + log.Fatal(err) + } + oob := BuildOob(to, from.Address, string(fileBytes)) + + atIdx := strings.Index(msg.ReturnPath.Address, "@") + 1 + msgDomain := msg.ReturnPath.Address[atIdx:] + mxs, err := net.LookupMX(msgDomain) + if err != nil { + log.Fatal(err) + } + if mxs == nil || len(mxs) <= 0 { + log.Fatal("No MXs for [%s]\n", msgDomain) + } + if verbose == true { + log.Printf("Got MX [%s] for [%s]\n", mxs[0].Host, msgDomain) + } + smtpHost := fmt.Sprintf("%s:smtp", mxs[0].Host) + + if *send == true { + log.Printf("Sending OOB from [%s] to [%s] via [%s]...\n", + from, to, smtpHost) + err = smtp.SendMail(smtpHost, nil, from.Address, []string{to}, []byte(oob)) + if err != nil { + log.Fatal(err) + } + log.Printf("Sent.\n") + } else { + if verbose == true { + log.Printf("Would send OOB from [%s] to [%s] via [%s]...\n", + from.Address, to, smtpHost) + } + } +} From 0a5131b665a9f0b74053f7b3ef208275e761ad4f Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 7 Jul 2016 16:08:45 -0600 Subject: [PATCH 019/152] remove overly verbose messages that don't add a lot; standardize output for (fbl|oob)gen --- cmd/fblgen/fblgen.go | 12 ++---------- cmd/oobgen/oob.go | 1 + cmd/oobgen/oobgen.go | 17 ++++++++++------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index a4a952b..235d9e0 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -35,14 +35,6 @@ func main() { log.Fatal(err) } - if verbose == true { - log.Printf("X-MSFBL: %s\n", msg.MSFBL) - } - - if verbose == true { - log.Printf("Decoded FBL (cid=%d): %s\n", msg.CustID, string(msg.Json)) - } - if *fblAddress != "" { msg.SetReturnPath(*fblAddress) } @@ -57,7 +49,7 @@ func main() { if *fblAddress != "" { log.Printf("Got domain [%s] from --fblto\n", fblDomain) } else { - log.Printf("Got domain [%s] from Return-Path header\n", fblDomain) + log.Printf("Got domain [%s] from Return-Path\n", fblDomain) } } @@ -91,7 +83,7 @@ func main() { log.Printf("Sent.\n") } else { if verbose == true { - log.Printf("Would send FBL from [%s] to [%s] via [%s]...\n", + log.Printf("Would send FBL from [%s] to [%s] via [%s]\n", fblFrom, fblTo, smtpHost) } } diff --git a/cmd/oobgen/oob.go b/cmd/oobgen/oob.go index e340f77..6025f73 100644 --- a/cmd/oobgen/oob.go +++ b/cmd/oobgen/oob.go @@ -6,6 +6,7 @@ import ( "time" ) +// FIXME: allow swapping out the error message var OobFormat string = `From: %s Date: Mon, 02 Jan 2006 15:04:05 MST Subject: Returned mail: see transcript for details diff --git a/cmd/oobgen/oobgen.go b/cmd/oobgen/oobgen.go index 7b6aa11..626f4d3 100644 --- a/cmd/oobgen/oobgen.go +++ b/cmd/oobgen/oobgen.go @@ -34,8 +34,13 @@ func main() { log.Fatal(err) } + atIdx := strings.Index(msg.ReturnPath.Address, "@") + if atIdx < 0 { + log.Fatalf("Unsupported Return-Path header [%s]\n", msg.ReturnPath.Address) + } + oobDomain := msg.ReturnPath.Address[atIdx+1:] if verbose == true { - log.Printf("Return-Path: %s\n", msg.ReturnPath) + log.Printf("Got domain [%s] from Return-Path\n", oobDomain) } fileBytes, err := ioutil.ReadFile(*filename) @@ -51,17 +56,15 @@ func main() { } oob := BuildOob(to, from.Address, string(fileBytes)) - atIdx := strings.Index(msg.ReturnPath.Address, "@") + 1 - msgDomain := msg.ReturnPath.Address[atIdx:] - mxs, err := net.LookupMX(msgDomain) + mxs, err := net.LookupMX(oobDomain) if err != nil { log.Fatal(err) } if mxs == nil || len(mxs) <= 0 { - log.Fatal("No MXs for [%s]\n", msgDomain) + log.Fatal("No MXs for [%s]\n", oobDomain) } if verbose == true { - log.Printf("Got MX [%s] for [%s]\n", mxs[0].Host, msgDomain) + log.Printf("Got MX [%s] for [%s]\n", mxs[0].Host, oobDomain) } smtpHost := fmt.Sprintf("%s:smtp", mxs[0].Host) @@ -75,7 +78,7 @@ func main() { log.Printf("Sent.\n") } else { if verbose == true { - log.Printf("Would send OOB from [%s] to [%s] via [%s]...\n", + log.Printf("Would send OOB from [%s] to [%s] via [%s]\n", from.Address, to, smtpHost) } } From 116cf7d52cb2ded368a0db58e6411be593ae5fad Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 8 Jul 2016 11:18:22 -0600 Subject: [PATCH 020/152] move the +1 to the slice (pedantic) --- cmd/fblgen/fblgen.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index 235d9e0..66ee1cc 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -39,11 +39,11 @@ func main() { msg.SetReturnPath(*fblAddress) } - atIdx := strings.Index(msg.ReturnPath.Address, "@") + 1 + atIdx := strings.Index(msg.ReturnPath.Address, "@") if atIdx < 0 { log.Fatalf("Unsupported Return-Path header [%s]\n", msg.ReturnPath.Address) } - fblDomain := msg.ReturnPath.Address[atIdx:] + fblDomain := msg.ReturnPath.Address[atIdx+1:] fblTo := fmt.Sprintf("fbl@%s", fblDomain) if verbose == true { if *fblAddress != "" { From ec8d228a8d96b83be398fd384dee5311bd5bf8d7 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 8 Jul 2016 11:37:51 -0600 Subject: [PATCH 021/152] readme index for `cmd` container directory --- cmd/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 cmd/README.md diff --git a/cmd/README.md b/cmd/README.md new file mode 100644 index 0000000..1da6c44 --- /dev/null +++ b/cmd/README.md @@ -0,0 +1,21 @@ +## Tools for SparkPost and/or Email + +### [fblgen](./fblgen/) + +Generate and optionally send an FBL report in response to an email sent through SparkPost. + +### [mimedump](./mimedump/) + +Extract the HTML part of a saved email message. + +### [oobgen](./oobgen/) + +Generate and optionally send an out-of-band (OOB) bounce from an email with full headers. + +### [qp](./qp/) + +Encode or decode quoted-printable data. Inspired by the `base64` command-line tool, supports the same long options. + +### [sparks](./sparks/) + +Send email through SparkPost from the command line. From 2979f8d1992a8cd2dc2f467eb12ee2b40ef170e1 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 8 Jul 2016 12:27:31 -0600 Subject: [PATCH 022/152] readme for fblgen --- cmd/fblgen/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 cmd/fblgen/README.md diff --git a/cmd/fblgen/README.md b/cmd/fblgen/README.md new file mode 100644 index 0000000..b7a0544 --- /dev/null +++ b/cmd/fblgen/README.md @@ -0,0 +1,24 @@ +## fblgen + +Testing your response to FBL reports doesn't have to involve waiting for an angry/lazy recipient to click "This is Spam". +Here's how to send an FBL report in response to a message sent via SparkPost: + + $ ./fblgen --file ./test.eml --verbose + Got domain [sparkpostmail.com] from Return-Path + Got MX [smtp.sparkpostmail.com.] for [sparkpostmail.com] + Would send FBL from [test@sp.example.com] to [fbl@sparkpostmail.com] via [smtp.sparkpostmail.com.:smtp] + +Note that this command (once you've added the `--send` flag) will attempt to connect from your local machine to the MX listed above. +It's entirely possible that there will be something blocking that port, for example a firewall, or your residential ISP. +Here are [two](http://nc110.sourceforge.net/) [ways](https://nmap.org/ncat/) to check whether that's the case. +Whichever command you run should return in under a second. +If there's a successful connection, you're good to go. + + $ nc -vz -w 3 smtp.sparkpostmail.com 25 + $ Date: Fri, 8 Jul 2016 12:29:37 -0600 Subject: [PATCH 023/152] readme for oobgen --- cmd/oobgen/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 cmd/oobgen/README.md diff --git a/cmd/oobgen/README.md b/cmd/oobgen/README.md new file mode 100644 index 0000000..c1d2179 --- /dev/null +++ b/cmd/oobgen/README.md @@ -0,0 +1,24 @@ +## oobgen + +Testing your response to out-of-band (OOB) bounces doesn't have to involve waiting for a remote mail server. +Here's how to send an OOB bounce in response to a message sent via SparkPost: + + $ ./oobgen --file ./test.eml --verbose + Got domain [sparkpostmail.com] from Return-Path + Got MX [smtp.sparkpostmail.com.] for [sparkpostmail.com] + Would send OOB from [test@sp.example.com] to [verp@sparkpostmail.com] via [smtp.sparkpostmail.com.:smtp] + +Note that this command (once you've added the `--send` flag) will attempt to connect from your local machine to the MX listed above. +It's entirely possible that there will be something blocking that port, for example a firewall, or your residential ISP. +Here are [two](http://nc110.sourceforge.net/) [ways](https://nmap.org/ncat/) to check whether that's the case. +Whichever command you run should return in under a second. +If there's a successful connection, you're good to go. + + $ nc -vz -w 3 smtp.sparkpostmail.com 25 + $ Date: Fri, 8 Jul 2016 12:31:27 -0600 Subject: [PATCH 024/152] reword OOB a bit; add "save to local file" note --- cmd/fblgen/README.md | 2 +- cmd/oobgen/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/fblgen/README.md b/cmd/fblgen/README.md index b7a0544..d970cc3 100644 --- a/cmd/fblgen/README.md +++ b/cmd/fblgen/README.md @@ -1,7 +1,7 @@ ## fblgen Testing your response to FBL reports doesn't have to involve waiting for an angry/lazy recipient to click "This is Spam". -Here's how to send an FBL report in response to a message sent via SparkPost: +Here's how to send an FBL report in response to a message sent via SparkPost, and saved (with full headers) to a local file: $ ./fblgen --file ./test.eml --verbose Got domain [sparkpostmail.com] from Return-Path diff --git a/cmd/oobgen/README.md b/cmd/oobgen/README.md index c1d2179..1c51d52 100644 --- a/cmd/oobgen/README.md +++ b/cmd/oobgen/README.md @@ -1,7 +1,7 @@ ## oobgen -Testing your response to out-of-band (OOB) bounces doesn't have to involve waiting for a remote mail server. -Here's how to send an OOB bounce in response to a message sent via SparkPost: +Testing your response to out-of-band (OOB) bounces doesn't have to involve waiting for one to be sent to you. +Here's how to send an OOB bounce in response to a message sent via SparkPost, and saved (with full headers) to a local file: $ ./oobgen --file ./test.eml --verbose Got domain [sparkpostmail.com] from Return-Path From 2a1f4730a86ddcfaf3b472cda3220500af41cbf8 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 27 Jul 2016 09:39:34 -0600 Subject: [PATCH 025/152] allow From structs to be used as Content.From --- templates.go | 3 +++ templates_test.go | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/templates.go b/templates.go index f7cddc5..4d84a2e 100644 --- a/templates.go +++ b/templates.go @@ -74,6 +74,9 @@ type PreviewOptions struct { func ParseFrom(from interface{}) (f From, err error) { // handle the allowed types switch fromVal := from.(type) { + case From: + f = fromVal + case string: // simple string value if fromVal == "" { err = fmt.Errorf("Content.From may not be empty") diff --git a/templates_test.go b/templates_test.go index 028dc43..9c04609 100644 --- a/templates_test.go +++ b/templates_test.go @@ -8,6 +8,25 @@ import ( "github.com/SparkPost/gosparkpost/test" ) +func TestTemplateValidation(t *testing.T) { + from := sp.From{"a@b.com", "A B"} + f, err := sp.ParseFrom(from) + if err != nil { + t.Error(err) + return + } + if f.Email != from.Email { + t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", + from.Email, f.Email)) + return + } + if f.Name != from.Name { + t.Error(fmt.Errorf("expected name [%s] didn't match actual [%s]", + from.Name, f.Name)) + return + } +} + func TestTemplates(t *testing.T) { if true { // Temporarily disable test so TravisCI reports build success instead of test failure. From 6d52512da523624cb8d99c7d218a375dce6f7130 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 27 Jul 2016 09:53:43 -0600 Subject: [PATCH 026/152] tests for the other handled cases --- templates_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/templates_test.go b/templates_test.go index 9c04609..836f3e7 100644 --- a/templates_test.go +++ b/templates_test.go @@ -9,22 +9,96 @@ import ( ) func TestTemplateValidation(t *testing.T) { - from := sp.From{"a@b.com", "A B"} - f, err := sp.ParseFrom(from) + fromStruct := sp.From{"a@b.com", "A B"} + f, err := sp.ParseFrom(fromStruct) if err != nil { t.Error(err) return } - if f.Email != from.Email { + if fromStruct.Email != f.Email { t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", - from.Email, f.Email)) + fromStruct.Email, f.Email)) return } - if f.Name != from.Name { + if fromStruct.Name != f.Name { t.Error(fmt.Errorf("expected name [%s] didn't match actual [%s]", - from.Name, f.Name)) + fromStruct.Name, f.Name)) return } + + fromString := "a@b.com" + f, err = sp.ParseFrom(fromString) + if err != nil { + t.Error(err) + return + } + if fromString != f.Email { + t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", + fromString, f.Email)) + return + } + if "" != f.Name { + t.Error(fmt.Errorf("expected name to be blank")) + return + } + + fromMap1 := map[string]interface{}{ + "name": "A B", + "email": "a@b.com", + } + f, err = sp.ParseFrom(fromMap1) + if err != nil { + t.Error(err) + return + } + // ParseFrom will bail if these aren't strings + fromString, _ = fromMap1["email"].(string) + if fromString != f.Email { + t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", + fromString, f.Email)) + return + } + nameString, _ := fromMap1["name"].(string) + if nameString != f.Name { + t.Error(fmt.Errorf("expected name [%s] didn't match actual [%s]", + nameString, f.Name)) + return + } + + fromMap1["name"] = 1 + f, err = sp.ParseFrom(fromMap1) + if err == nil { + t.Error(fmt.Errorf("failed to detect non-string name")) + return + } + + fromMap2 := map[string]string{ + "name": "A B", + "email": "a@b.com", + } + f, err = sp.ParseFrom(fromMap2) + if err != nil { + t.Error(err) + return + } + if fromMap2["email"] != f.Email { + t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", + fromMap2["email"], f.Email)) + return + } + if fromMap2["name"] != f.Name { + t.Error(fmt.Errorf("expected name [%s] didn't match actual [%s]", + fromMap2["name"], f.Name)) + return + } + + fromBytes := []byte("a@b.com") + f, err = sp.ParseFrom(fromBytes) + if err == nil { + t.Error(fmt.Errorf("failed to detect unsupported type")) + return + } + } func TestTemplates(t *testing.T) { From 9ccb3043a99e386f3dac6a37bc65c453c1f402f4 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 27 Jul 2016 09:56:57 -0600 Subject: [PATCH 027/152] support for using an Address in Content.From (ignores Address.HeaderTo) --- templates.go | 4 ++++ templates_test.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/templates.go b/templates.go index 4d84a2e..2627d84 100644 --- a/templates.go +++ b/templates.go @@ -77,6 +77,10 @@ func ParseFrom(from interface{}) (f From, err error) { case From: f = fromVal + case Address: + f.Email = fromVal.Email + f.Name = fromVal.Name + case string: // simple string value if fromVal == "" { err = fmt.Errorf("Content.From may not be empty") diff --git a/templates_test.go b/templates_test.go index 836f3e7..f7d30ab 100644 --- a/templates_test.go +++ b/templates_test.go @@ -26,6 +26,23 @@ func TestTemplateValidation(t *testing.T) { return } + addrStruct := sp.Address{"a@b.com", "A B", "c@d.com"} + f, err = sp.ParseFrom(addrStruct) + if err != nil { + t.Error(err) + return + } + if addrStruct.Email != f.Email { + t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", + addrStruct.Email, f.Email)) + return + } + if addrStruct.Name != f.Name { + t.Error(fmt.Errorf("expected name [%s] didn't match actual [%s]", + addrStruct.Name, f.Name)) + return + } + fromString := "a@b.com" f, err = sp.ParseFrom(fromString) if err != nil { From 6de9e60e02eec1d610594d18cb275012525be69c Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 28 Jul 2016 15:52:12 -0600 Subject: [PATCH 028/152] pull out dkim headers; wrap errors --- helpers/loadmsg/loadmsg.go | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/helpers/loadmsg/loadmsg.go b/helpers/loadmsg/loadmsg.go index f581d16..c7080ba 100644 --- a/helpers/loadmsg/loadmsg.go +++ b/helpers/loadmsg/loadmsg.go @@ -2,13 +2,13 @@ package loadmsg import ( "encoding/base64" - "fmt" "net/mail" "os" "strconv" "strings" "github.com/buger/jsonparser" + "github.com/pkg/errors" ) type Message struct { @@ -16,6 +16,7 @@ type Message struct { File *os.File Message *mail.Message MSFBL string + DKIM []string Json []byte CustID int Recipient []byte @@ -27,23 +28,32 @@ func (m *Message) Load() error { m.File, err = os.Open(m.Filename) if err != nil { - return err + return errors.Wrap(err, "opening file") } m.Message, err = mail.ReadMessage(m.File) if err != nil { - return err + return errors.Wrap(err, "parsing message") } if m.ReturnPath == nil { err = m.SetReturnPath(m.Message.Header.Get("Return-Path")) if err != nil { - return err + return errors.Wrap(err, "setting return path") } } + for _, hdr := range m.Message.Header["Dkim-Signature"] { + m.DKIM = append(m.DKIM, strings.Replace(hdr, " ", "", -1)) + } + m.MSFBL = strings.Replace(m.Message.Header.Get("X-MSFBL"), " ", "", -1) + if m.MSFBL == "" { + // early return if there isn't a MSFBL header + return nil + } + if strings.Index(m.MSFBL, "|") >= 0 { // Everything before the pipe is an encoded HMAC // TODO: verify contents using HMAC @@ -52,22 +62,22 @@ func (m *Message) Load() error { m.Json, err = base64.StdEncoding.DecodeString(m.MSFBL) if err != nil { - return err + return errors.Wrap(err, "decoding fbl") } var cid []byte cid, _, _, err = jsonparser.Get(m.Json, "customer_id") if err != nil { - return err + return errors.Wrap(err, "getting customer_id") } m.CustID, err = strconv.Atoi(string(cid)) if err != nil { - return err + return errors.Wrap(err, "int-ifying customer_id") } m.Recipient, _, _, err = jsonparser.Get(m.Json, "r") if err != nil { - return err + return errors.Wrap(err, "getting recipient") } return nil @@ -75,11 +85,11 @@ func (m *Message) Load() error { func (m *Message) SetReturnPath(addr string) (err error) { if !strings.Contains(addr, "@") { - return fmt.Errorf("Unsupported Return-Path header: no @") + return errors.Errorf("Unsupported Return-Path header: no @") } m.ReturnPath, err = mail.ParseAddress(addr) if err != nil { - return err + return errors.Wrap(err, "parsing return path") } return nil } From c55ddf6adab9b16bf89d51321551913b0b27ac2c Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 28 Jul 2016 16:47:00 -0600 Subject: [PATCH 029/152] remove dkim from message loading helper --- helpers/loadmsg/loadmsg.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/helpers/loadmsg/loadmsg.go b/helpers/loadmsg/loadmsg.go index c7080ba..0c7dd6d 100644 --- a/helpers/loadmsg/loadmsg.go +++ b/helpers/loadmsg/loadmsg.go @@ -16,7 +16,6 @@ type Message struct { File *os.File Message *mail.Message MSFBL string - DKIM []string Json []byte CustID int Recipient []byte @@ -43,10 +42,6 @@ func (m *Message) Load() error { } } - for _, hdr := range m.Message.Header["Dkim-Signature"] { - m.DKIM = append(m.DKIM, strings.Replace(hdr, " ", "", -1)) - } - m.MSFBL = strings.Replace(m.Message.Header.Get("X-MSFBL"), " ", "", -1) if m.MSFBL == "" { From ada010147889a5bbb55757e90f2b9bacd62986dc Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 11 Oct 2016 17:10:57 -0600 Subject: [PATCH 030/152] wire up testing against a local server; make suppression methods return the response; remove import aliasing; update some comments --- common.go | 4 +- suppression_list.go | 63 ++++++++++------------ suppression_list_test.go | 112 +++++++++++++++++++++++++++++++++++++++ test_server.go | 44 +++++++++++++++ 4 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 suppression_list_test.go create mode 100644 test_server.go diff --git a/common.go b/common.go index f4bf075..e9ed5ff 100644 --- a/common.go +++ b/common.go @@ -25,7 +25,7 @@ type Config struct { Verbose bool } -// Client contains connection and authentication information. +// Client contains connection, configuration, and authentication information. // Specifying your own http.Client gives you lots of control over how connections are made. type Client struct { Config *Config @@ -120,7 +120,7 @@ func (api *Client) Init(cfg *Config) error { } // SetHeader adds additional HTTP headers for every API request made from client. -// Usefull to set subaccount X-MSYS-SUBACCOUNT header and etc. +// Useful to set subaccount X-MSYS-SUBACCOUNT header and etc. func (c *Client) SetHeader(header string, value string) { c.headers[header] = value } diff --git a/suppression_list.go b/suppression_list.go index 36e6a33..f2dee3d 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -3,7 +3,7 @@ package gosparkpost import ( "encoding/json" "fmt" - URL "net/url" + "net/url" ) // https://developers.sparkpost.com/api/#/reference/suppression-list @@ -19,6 +19,7 @@ type SuppressionEntry struct { Transactional bool `json:"transactional,omitempty"` NonTransactional bool `json:"non_transactional,omitempty"` Source string `json:"source,omitempty"` + Type string `json:type,omitempty` Description string `json:"description,omitempty"` Updated string `json:"updated,omitempty"` Created string `json:"created,omitempty"` @@ -29,28 +30,26 @@ type SuppressionListWrapper struct { Recipients []SuppressionEntry `json:"recipients,omitempty"` } -func (c *Client) SuppressionList() (*SuppressionListWrapper, error) { +func (c *Client) SuppressionList() (*SuppressionListWrapper, *Response, error) { path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - - return doSuppressionRequest(c, finalUrl) + return suppressionGet(c, c.Config.BaseUrl+path) } -func (c *Client) SuppressionRetrieve(recipientEmail string) (*SuppressionListWrapper, error) { +func (c *Client) SuppressionRetrieve(recipientEmail string) (*SuppressionListWrapper, *Response, error) { path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) - return doSuppressionRequest(c, finalUrl) + return suppressionGet(c, finalUrl) } -func (c *Client) SuppressionSearch(parameters map[string]string) (*SuppressionListWrapper, error) { +func (c *Client) SuppressionSearch(parameters map[string]string) (*SuppressionListWrapper, *Response, error) { var finalUrl string path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) if parameters == nil || len(parameters) == 0 { finalUrl = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { - params := URL.Values{} + params := url.Values{} for k, v := range parameters { params.Add(k, v) } @@ -58,7 +57,7 @@ func (c *Client) SuppressionSearch(parameters map[string]string) (*SuppressionLi finalUrl = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, params.Encode()) } - return doSuppressionRequest(c, finalUrl) + return suppressionGet(c, finalUrl) } func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err error) { @@ -67,58 +66,54 @@ func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err er res, err = c.HttpDelete(finalUrl) if err != nil { - return nil, err + return res, err } if res.HTTP.StatusCode >= 200 && res.HTTP.StatusCode <= 299 { - return + return res, err } else if len(res.Errors) > 0 { // handle common errors err = res.PrettyError("SuppressionEntry", "delete") if err != nil { - return nil, err + return res, err } err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } - return + return res, err } -func (c *Client) SuppressionInsertOrUpdate(entries []SuppressionEntry) (err error) { +func (c *Client) SuppressionInsertOrUpdate(entries []SuppressionEntry) (*Response, error) { if entries == nil { - err = fmt.Errorf("send `entries` cannot be nil here") - return + return nil, fmt.Errorf("send `entries` cannot be nil here") } path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - list := SuppressionListWrapper{nil, entries} - return c.send(finalUrl, list) - + return suppressionPut(c, c.Config.BaseUrl+path, list) } -func (c *Client) send(finalUrl string, recipients SuppressionListWrapper) (err error) { +func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrapper) (*Response, error) { jsonBytes, err := json.Marshal(recipients) if err != nil { - return + return nil, err } res, err := c.HttpPut(finalUrl, jsonBytes) if err != nil { - return + return res, err } if err = res.AssertJson(); err != nil { - return + return res, err } err = res.ParseResponse() if err != nil { - return + return res, err } if res.HTTP.StatusCode == 200 { @@ -127,31 +122,31 @@ func (c *Client) send(finalUrl string, recipients SuppressionListWrapper) (err e // handle common errors err = res.PrettyError("Transmission", "create") if err != nil { - return + return res, err } err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } - return + return res, err } -func doSuppressionRequest(c *Client, finalUrl string) (*SuppressionListWrapper, error) { +func suppressionGet(c *Client, finalUrl string) (*SuppressionListWrapper, *Response, error) { // Send off our request res, err := c.HttpGet(finalUrl) if err != nil { - return nil, err + return nil, res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return nil, res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return nil, res, err } // Parse expected response structure @@ -159,8 +154,8 @@ func doSuppressionRequest(c *Client, finalUrl string) (*SuppressionListWrapper, err = json.Unmarshal(bodyBytes, &resMap) if err != nil { - return nil, err + return nil, res, err } - return &resMap, err + return &resMap, res, err } diff --git a/suppression_list_test.go b/suppression_list_test.go new file mode 100644 index 0000000..d4e5a26 --- /dev/null +++ b/suppression_list_test.go @@ -0,0 +1,112 @@ +package gosparkpost + +import ( + "fmt" + "net/http" + "testing" +) + +var combinedSuppressionList string = `{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ] +}` + +var separateSuppressionList string = `{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "non_transactional": true, + "source": "Manually Added", + "type": "non_transactional", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + }, + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "source": "Bounce Rule", + "type": "transactional", + "description": "550: 550-5.1.1 Invalid Recipient", + "created": "2015-10-15T12:00:00+00:00", + "updated": "2015-10-15T12:00:00+00:00" + } + ], + "links": [], + "total_count": 2 +}` + +// Test parsing of combined suppression list results +func TestSuppression_Get_combinedList(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + path := fmt.Sprintf(suppressionListsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.Write([]byte(combinedSuppressionList)) + }) + + // hit our local handler + s, res, err := testClient.SuppressionList() + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if s.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if len(s.Results) != 1 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(s.Results), 1) + } else if s.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", s.Results[0].Recipient) + } +} + +// Test parsing of separate suppression list results +func TestSuppression_Get_separateList(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + path := fmt.Sprintf(suppressionListsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.Write([]byte(separateSuppressionList)) + }) + + // hit our local handler + s, res, err := testClient.SuppressionList() + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if s.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if len(s.Results) != 2 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(s.Results), 2) + } else if s.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", s.Results[0].Recipient) + } +} diff --git a/test_server.go b/test_server.go new file mode 100644 index 0000000..c69d7ab --- /dev/null +++ b/test_server.go @@ -0,0 +1,44 @@ +package gosparkpost + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +var ( + testMux *http.ServeMux + testClient *Client + testServer *httptest.Server +) + +func testSetup(t *testing.T) { + // spin up a test server + testMux = http.NewServeMux() + testServer = httptest.NewTLSServer(testMux) + // our client configured to hit the https test server with self-signed cert + tx := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + testClient = &Client{Client: &http.Client{Transport: tx}} + testClient.Config = &Config{Verbose: true} + testUrl, err := url.Parse(testServer.URL) + if err != nil { + t.Fatalf("Test server url parsing failed: %v", err) + } + testClient.Config.BaseUrl = testUrl.String() + err = testClient.Init(testClient.Config) + if err != nil { + t.Fatalf("Test client init failed: %v", err) + } +} + +func testTeardown() { + testServer.Close() +} + +func testMethod(t *testing.T, r *http.Request, want string) { + if got := r.Method; got != want { + t.Fatalf("Request method: %v, want %v", got, want) + } +} From fae41d6e615ad4dfe5ba88dab01ab852d8ba7d3a Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 12 Oct 2016 11:10:33 -0600 Subject: [PATCH 031/152] `Response.Results` needs to be an `interface{}`, not `map[string]interface{}` since suppressions returns an array of results --- common.go | 4 ++-- recipient_lists.go | 8 ++++++-- subaccounts.go | 8 ++++++-- templates.go | 6 +++++- transmissions.go | 6 +++++- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/common.go b/common.go index e9ed5ff..73fa200 100644 --- a/common.go +++ b/common.go @@ -61,8 +61,8 @@ type Response struct { HTTP *http.Response Body []byte Verbose map[string]string - Results map[string]interface{} `json:"results,omitempty"` - Errors []Error `json:"errors,omitempty"` + Results interface{} `json:"results,omitempty"` + Errors []Error `json:"errors,omitempty"` } // Error mirrors the error format returned by SparkPost APIs. diff --git a/recipient_lists.go b/recipient_lists.go index ddb8640..c819cf4 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -175,9 +175,13 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons if res.HTTP.StatusCode == 200 { var ok bool - id, ok = res.Results["id"].(string) + var results map[string]interface{} + if results, ok = res.Results.(map[string]interface{}); !ok { + return id, res, fmt.Errorf("Unexpected response to Recipient List creation (results)") + } + id, ok = results["id"].(string) if !ok { - err = fmt.Errorf("Unexpected response to Recipient List creation") + return id, res, fmt.Errorf("Unexpected response to Recipient List creation (id)") } } else if len(res.Errors) > 0 { diff --git a/subaccounts.go b/subaccounts.go index 9d188de..38254a3 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -81,12 +81,16 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { if res.HTTP.StatusCode == 200 { var ok bool - f, ok := res.Results["subaccount_id"].(float64) + var results map[string]interface{} + if results, ok = res.Results.(map[string]interface{}); !ok { + return res, fmt.Errorf("Unexpected response to Subaccount creation (results)") + } + f, ok := results["subaccount_id"].(float64) if !ok { err = fmt.Errorf("Unexpected response to Subaccount creation") } s.ID = int(f) - s.ShortKey, ok = res.Results["short_key"].(string) + s.ShortKey, ok = results["short_key"].(string) if !ok { err = fmt.Errorf("Unexpected response to Subaccount creation") } diff --git a/templates.go b/templates.go index 2627d84..e3537b2 100644 --- a/templates.go +++ b/templates.go @@ -219,7 +219,11 @@ func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err erro if res.HTTP.StatusCode == 200 { var ok bool - id, ok = res.Results["id"].(string) + var results map[string]interface{} + if results, ok = res.Results.(map[string]interface{}); !ok { + return id, res, fmt.Errorf("Unexpected response to Template creation (results)") + } + id, ok = results["id"].(string) if !ok { err = fmt.Errorf("Unexpected response to Template creation") } diff --git a/transmissions.go b/transmissions.go index b58607c..87615cf 100644 --- a/transmissions.go +++ b/transmissions.go @@ -230,7 +230,11 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { if res.HTTP.StatusCode == 200 { var ok bool - id, ok = res.Results["id"].(string) + var results map[string]interface{} + if results, ok = res.Results.(map[string]interface{}); !ok { + return id, res, fmt.Errorf("Unexpected response to Transmission creation (results)") + } + id, ok = results["id"].(string) if !ok { err = fmt.Errorf("Unexpected response to Transmission creation") } From 8a626d2d6d886886832ef9b6fd673c60ad5ee683 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 12 Oct 2016 11:20:30 -0600 Subject: [PATCH 032/152] parse out any error responses from GET requests --- suppression_list.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/suppression_list.go b/suppression_list.go index f2dee3d..9fe617c 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -143,6 +143,11 @@ func suppressionGet(c *Client, finalUrl string) (*SuppressionListWrapper, *Respo return nil, res, err } + err = res.ParseResponse() + if err != nil { + return nil, res, err + } + // Get the Content bodyBytes, err := res.ReadBody() if err != nil { From 43b46265d29070d923bb8877e1a50e86de006a80 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 12 Oct 2016 11:21:56 -0600 Subject: [PATCH 033/152] test 404 suppression response; add helper for test error cases that dumps the verbose messages collected in our Response --- suppression_list_test.go | 91 +++++++++++++++++++++++++++++----------- test_server.go | 7 ++++ 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/suppression_list_test.go b/suppression_list_test.go index d4e5a26..ecdf5bd 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -6,6 +6,47 @@ import ( "testing" ) +var suppressionNotFound string = `{ + "errors": [ + { + "message": "Recipient could not be found" + } + ] +}` + +// Test parsing of "not found" case +func TestSuppression_Get_notFound(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + path := fmt.Sprintf(suppressionListsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusNotFound) // 404 + w.Write([]byte(suppressionNotFound)) + }) + + // hit our local handler + s, res, err := testClient.SuppressionList() + if err != nil { + testFailVerbose(t, res, "SuppressionList GET returned error: %v", err) + } + + // basic content test + if s.Results != nil { + testFailVerbose(t, res, "SuppressionList GET returned non-nil Results (error expected)") + } else if len(s.Results) != 0 { + testFailVerbose(t, res, "SuppressionList GET returned %d results, expected %d", len(s.Results), 0) + } else if len(res.Errors) != 1 { + testFailVerbose(t, res, "SuppressionList GET returned %d errors, expected %d", len(res.Errors), 1) + } else if res.Errors[0].Message != "Recipient could not be found" { + testFailVerbose(t, res, "SuppressionList GET Unmarshal error; saw [%v] expected [%v]", + res.Errors[0].Message, "Recipient could not be found") + } +} + var combinedSuppressionList string = `{ "results": [ { @@ -20,31 +61,6 @@ var combinedSuppressionList string = `{ ] }` -var separateSuppressionList string = `{ - "results": [ - { - "recipient": "rcpt_1@example.com", - "non_transactional": true, - "source": "Manually Added", - "type": "non_transactional", - "description": "User requested to not receive any non-transactional emails.", - "created": "2016-01-01T12:00:00+00:00", - "updated": "2016-01-01T12:00:00+00:00" - }, - { - "recipient": "rcpt_1@example.com", - "transactional": true, - "source": "Bounce Rule", - "type": "transactional", - "description": "550: 550-5.1.1 Invalid Recipient", - "created": "2015-10-15T12:00:00+00:00", - "updated": "2015-10-15T12:00:00+00:00" - } - ], - "links": [], - "total_count": 2 -}` - // Test parsing of combined suppression list results func TestSuppression_Get_combinedList(t *testing.T) { testSetup(t) @@ -78,6 +94,31 @@ func TestSuppression_Get_combinedList(t *testing.T) { } } +var separateSuppressionList string = `{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "non_transactional": true, + "source": "Manually Added", + "type": "non_transactional", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + }, + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "source": "Bounce Rule", + "type": "transactional", + "description": "550: 550-5.1.1 Invalid Recipient", + "created": "2015-10-15T12:00:00+00:00", + "updated": "2015-10-15T12:00:00+00:00" + } + ], + "links": [], + "total_count": 2 +}` + // Test parsing of separate suppression list results func TestSuppression_Get_separateList(t *testing.T) { testSetup(t) diff --git a/test_server.go b/test_server.go index c69d7ab..f19b2eb 100644 --- a/test_server.go +++ b/test_server.go @@ -42,3 +42,10 @@ func testMethod(t *testing.T, r *http.Request, want string) { t.Fatalf("Request method: %v, want %v", got, want) } } + +func testFailVerbose(t *testing.T, res *Response, fmt string, args ...interface{}) { + for _, e := range res.Verbose { + t.Error(e) + } + t.Fatalf(fmt, args...) +} From 202ab86738a245b13bf1b499b4e39a1681dc084c Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 28 Oct 2016 15:19:50 -0600 Subject: [PATCH 034/152] add `--rfc822` option, takes a string or path to file; avoid sending `"substitution_data":null} --- cmd/sparks/sparks.go | 51 +++++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/cmd/sparks/sparks.go b/cmd/sparks/sparks.go index 24d0bcd..4da33a9 100644 --- a/cmd/sparks/sparks.go +++ b/cmd/sparks/sparks.go @@ -47,6 +47,7 @@ var from = flag.String("from", "default@sparkpostbox.com", "where the mail came var subject = flag.String("subject", "", "email subject") var htmlFlag = flag.String("html", "", "string/filename containing html content") var textFlag = flag.String("text", "", "string/filename containing text content") +var rfc822Flag = flag.String("rfc822", "", "string/filename containing raw message") var subsFlag = flag.String("subs", "", "string/filename containing substitution data (json object)") var sendDelay = flag.String("send-delay", "", "delay delivery the specified amount of time") var inline = flag.Bool("inline-css", false, "automatically inline css") @@ -74,9 +75,13 @@ func main() { hasHtml := strings.TrimSpace(*htmlFlag) != "" hasText := strings.TrimSpace(*textFlag) != "" + hasRFC822 := strings.TrimSpace(*rfc822Flag) != "" hasSubs := strings.TrimSpace(*subsFlag) != "" - if !hasHtml && !hasText { + // rfc822 must be specified by itself, i.e. no text or html + if hasRFC822 && (hasHtml || hasText) { + log.Fatal("FATAL: --rfc822 cannot be combined with --html or --text!\n") + } else if !hasRFC822 && !hasHtml && !hasText { log.Fatal("FATAL: must specify one of --html or --text!\n") } @@ -102,6 +107,23 @@ func main() { Subject: *subject, } + if hasRFC822 { + // these are pulled from the raw message + content.From = nil + content.Subject = "" + if strings.HasPrefix(*rfc822Flag, "/") || strings.HasPrefix(*rfc822Flag, "./") { + // read file to get raw message + rfc822Bytes, err := ioutil.ReadFile(*rfc822Flag) + if err != nil { + log.Fatal(err) + } + content.EmailRFC822 = string(rfc822Bytes) + } else { + // raw message string passed on command line + content.EmailRFC822 = *rfc822Flag + } + } + if hasHtml { if strings.HasPrefix(*htmlFlag, "/") || strings.HasPrefix(*htmlFlag, "./") { // read file to get html @@ -194,18 +216,20 @@ func main() { tx.Recipients = []sp.Recipient{} for _, r := range to { - tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ - Address: sp.Address{Email: r, HeaderTo: headerTo}, - SubstitutionData: subJson, - }) + var recip sp.Recipient = sp.Recipient{Address: sp.Address{Email: r, HeaderTo: headerTo}} + if hasSubs { + recip.SubstitutionData = subJson + } + tx.Recipients = append(tx.Recipients.([]sp.Recipient), recip) } if len(cc) > 0 { for _, r := range cc { - tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ - Address: sp.Address{Email: r, HeaderTo: headerTo}, - SubstitutionData: subJson, - }) + var recip sp.Recipient = sp.Recipient{Address: sp.Address{Email: r, HeaderTo: headerTo}} + if hasSubs { + recip.SubstitutionData = subJson + } + tx.Recipients = append(tx.Recipients.([]sp.Recipient), recip) } if content.Headers == nil { content.Headers = map[string]string{} @@ -215,10 +239,11 @@ func main() { if len(bcc) > 0 { for _, r := range bcc { - tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ - Address: sp.Address{Email: r, HeaderTo: headerTo}, - SubstitutionData: subJson, - }) + var recip sp.Recipient = sp.Recipient{Address: sp.Address{Email: r, HeaderTo: headerTo}} + if hasSubs { + recip.SubstitutionData = subJson + } + tx.Recipients = append(tx.Recipients.([]sp.Recipient), recip) } } From 1166eb1ae4980751d2190a6d6aabcac04f05d30c Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 14 Nov 2016 17:24:11 -0700 Subject: [PATCH 035/152] start work to support public event docs endpoint --- event_docs.go | 23 + event_docs_test.go | 47 + test/event-docs.json | 1930 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 2000 insertions(+) create mode 100644 event_docs.go create mode 100644 event_docs_test.go create mode 100644 test/event-docs.json diff --git a/event_docs.go b/event_docs.go new file mode 100644 index 0000000..a2853f2 --- /dev/null +++ b/event_docs.go @@ -0,0 +1,23 @@ +package gosparkpost + +var eventDocumentationFormat = "/api/v%d/webhooks/events/documentation" + +type EventDocumentationResponse struct { + Results map[string]*EventGroup `json:"results,omitempty"` +} + +type EventGroup struct { + Name string + Events map[string]EventField + Description string `json:"description"` + DisplayName string `json:"display_name"` +} + +type EventField struct { + Description string `json:"description"` + SampleValue string `json:"sampleValue"` +} + +func (c *Client) EventDocumentation() (g *EventGroup, res *Response, err error) { + return nil, nil, nil +} diff --git a/event_docs_test.go b/event_docs_test.go new file mode 100644 index 0000000..d669243 --- /dev/null +++ b/event_docs_test.go @@ -0,0 +1,47 @@ +package gosparkpost + +import ( + "fmt" + "io/ioutil" + "net/http" + "testing" +) + +const eventDocumentationFile = "test/event-docs.json" + +var eventDocumentationBytes []byte + +func init() { + eventDocumentationBytes, err := ioutil.ReadFile(eventDocumentationFile) + if err != nil { + panic(err) + } +} + +func TestEventDocs_Get_parse(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + path := fmt.Sprintf(eventDocumentationFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.Write(eventDocumentationBytes) + }) + + // hit our local handler + w, res, err := testClient.EventDocumentation() + if err != nil { + t.Errorf("EventDocumentation GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if w.Results == nil { + t.Error("EventDocumentation GET returned nil Results") + } +} diff --git a/test/event-docs.json b/test/event-docs.json new file mode 100644 index 0000000..cf02b82 --- /dev/null +++ b/test/event-docs.json @@ -0,0 +1,1930 @@ +{ + "results": { + "message_event": { + "events": { + "bounce": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "bounce" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "device_token": { + "description": "Token of the device / application targeted by this PUSH notification message. Applies only when delv_method is gcm or apn.", + "sampleValue": "45c19189783f867973f6e6a5cca60061ffe4fa77c547150563a1192fa9847f8a" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "sms_coding": { + "description": "Data encoding used in the SMS message", + "sampleValue": "ASCII" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Remote MTA has permanently rejected a message.", + "display_name": "Bounce" + }, + "delivery": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "delivery" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "device_token": { + "description": "Token of the device / application targeted by this PUSH notification message. Applies only when delv_method is gcm or apn.", + "sampleValue": "45c19189783f867973f6e6a5cca60061ffe4fa77c547150563a1192fa9847f8a" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "sms_coding": { + "description": "Data encoding used in the SMS message", + "sampleValue": "ASCII" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_remoteids": { + "description": "The message ID(s) in the response, assigned by the remote server/SMSC", + "sampleValue": [ + "0000", + "0001", + "0002", + "0003", + "0004" + ] + }, + "sms_segments": { + "description": "Segment number of the log line for large messages sent through multiple SMSes", + "sampleValue": 5 + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Remote MTA acknowledged receipt of a message.", + "display_name": "Delivery" + }, + "injection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "injection" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "sms_coding": { + "description": "Data encoding used in the SMS message", + "sampleValue": "ASCII" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_segments": { + "description": "Segment number of the log line for large messages sent through multiple SMSes", + "sampleValue": 5 + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "sms_text": { + "description": "The SMS message payload (in the character set specified in sms_coding)", + "sampleValue": "lol" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Message is received by or injected into SparkPost.", + "display_name": "Injection" + }, + "sms_status": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "sms_status" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "dr_latency": { + "description": "Delivery report latency; interval between message submission and receipt", + "sampleValue": "0.02" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_remoteids": { + "description": "The message ID(s) in the response, assigned by the remote server/SMSC", + "sampleValue": [ + "0000", + "0001", + "0002", + "0003", + "0004" + ] + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "sms_text": { + "description": "The SMS message payload (in the character set specified in sms_coding)", + "sampleValue": "lol" + }, + "stat_type": { + "description": "Status type in an SMS status event", + "sampleValue": "SMSC Delivery" + }, + "stat_state": { + "description": "Status value in an SMS status event", + "sampleValue": "Delivered" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + } + }, + "description": "SMPP/SMS message produced a status log output", + "display_name": "SMS Status" + }, + "spam_complaint": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "spam_complaint" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "fbtype": { + "description": "Type of spam report entered against this message (see [RFC 5965 § 7.3](http://tools.ietf.org/html/rfc5965#section-7.3))", + "sampleValue": "abuse" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "report_by": { + "description": "Address of the entity reporting this message as spam", + "sampleValue": "server.email.com" + }, + "report_to": { + "description": "Address to which this spam report is to be delivered", + "sampleValue": "abuse.example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "user_str": { + "description": "If configured, additional message log information, in user-defined format", + "sampleValue": "Additional Example Information" + } + }, + "description": "Message was classified as spam by the recipient.", + "display_name": "Spam Complaint" + }, + "out_of_band": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "out_of_band" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "device_token": { + "description": "Token of the device / application targeted by this PUSH notification message. Applies only when delv_method is gcm or apn.", + "sampleValue": "45c19189783f867973f6e6a5cca60061ffe4fa77c547150563a1192fa9847f8a" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + } + }, + "description": "Remote MTA initially reported acceptance of a message, but it has since asynchronously reported that the message was not delivered.", + "display_name": "Out of Band" + }, + "policy_rejection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "policy_rejection" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "remote_addr": { + "description": "IP address of the host from which SparkPost received this message", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + } + }, + "description": "Due to policy, SparkPost rejected a message or failed to generate a message.", + "display_name": "Policy Rejection" + }, + "delay": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "delay" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "device_token": { + "description": "Token of the device / application targeted by this PUSH notification message. Applies only when delv_method is gcm or apn.", + "sampleValue": "45c19189783f867973f6e6a5cca60061ffe4fa77c547150563a1192fa9847f8a" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "sms_coding": { + "description": "Data encoding used in the SMS message", + "sampleValue": "ASCII" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Remote MTA has temporarily rejected a message.", + "display_name": "Delay" + } + }, + "description": "Message events describe the life cycle of a message including injection, delivery, and disposition.", + "display_name": "Message Events" + }, + "track_event": { + "events": { + "click": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "click" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "target_link_name": { + "description": "Name of the link for which a click event was generated", + "sampleValue": "Example Link Name" + }, + "target_link_url": { + "description": "URL of the link for which a click event was generated", + "sampleValue": "http://example.com" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "user_agent": { + "description": "Value of the browser's User-Agent header", + "sampleValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36" + }, + "geo_ip": { + "description": "Geographic location based on the IP address, including latitude, longitude, city, country, and region", + "sampleValue": { + "country": "US", + "region": "MD", + "city": "Columbia", + "latitude": 39.1749, + "longitude": -76.8375 + } + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "Recipient clicked a tracked link in a message, thus prompting a redirect through the SparkPost click-tracking server to the link's destination.", + "display_name": "Click" + }, + "open": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "open" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "user_agent": { + "description": "Value of the browser's User-Agent header", + "sampleValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36" + }, + "geo_ip": { + "description": "Geographic location based on the IP address, including latitude, longitude, city, country, and region", + "sampleValue": { + "country": "US", + "region": "MD", + "city": "Columbia", + "latitude": 39.1749, + "longitude": -76.8375 + } + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "Recipient opened a message in a mail client, thus rendering a tracking pixel.", + "display_name": "Open" + } + }, + "description": "Engagement events describe the behavior of a recipient with respect to the message sent.", + "display_name": "Engagement Events" + }, + "gen_event": { + "events": { + "generation_failure": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "generation_failure" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_subs": { + "description": "Substitutions applied to the template to construct this message", + "sampleValue": { + "country": "US", + "gender": "Female" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Message generation failed for an intended recipient.", + "display_name": "Generation Failure" + }, + "generation_rejection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "generation_rejection" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_subs": { + "description": "Substitutions applied to the template to construct this message", + "sampleValue": { + "country": "US", + "gender": "Female" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + } + }, + "description": "SparkPost rejected message generation due to policy.", + "display_name": "Generation Rejection" + } + }, + "description": "Generation events provide insight into message generation failures or rejections.", + "display_name": "Generation Events" + }, + "unsubscribe_event": { + "events": { + "list_unsubscribe": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "list_unsubscribe" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "mailfrom": { + "description": "Envelope mailfrom of the original email", + "sampleValue": "recipient@example.com" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "User clicked the 'unsubscribe' button on an email client.", + "display_name": "List Unsubscribe" + }, + "link_unsubscribe": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "link_unsubscribe" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "mailfrom": { + "description": "Envelope mailfrom of the original email", + "sampleValue": "recipient@example.com" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "user_agent": { + "description": "Value of the browser's User-Agent header", + "sampleValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "User clicked a hyperlink in a received email.", + "display_name": "Link Unsubscribe" + } + }, + "description": "Unsubscribe events provide insight into the action the user performed to become unsubscribed.", + "display_name": "Unsubscribe Events" + }, + "relay_event": { + "events": { + "relay_injection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_injection" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + } + }, + "description": "Relayed message is received by or injected into SparkPost.", + "display_name": "Relay Injection" + }, + "relay_rejection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_rejection" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "remote_addr": { + "description": "IP address of the host from which SparkPost received this message", + "sampleValue": "127.0.0.1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + } + }, + "description": "SparkPost rejected a relayed message or failed to generate a relayed message.", + "display_name": "Relay Rejection" + }, + "relay_delivery": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_delivery" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "Remote HTTP Endpoint acknowledged receipt of a relayed message.", + "display_name": "Relay Delivery" + }, + "relay_tempfail": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_tempfail" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + } + }, + "description": "Remote HTTP Endpoint has failed to accept a relayed message.", + "display_name": "Relay Temporary Failure" + }, + "relay_permfail": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_permfail" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + } + }, + "description": "Relayed message has reached the maximum retry threshold and will be removed from the system.", + "display_name": "Relay Permanent Failure" + } + }, + "description": "Relay events describe the life cycle of an inbound message including injection, delivery, and disposition.", + "display_name": "Relay Events" + } + } +} From 7fa857563762c3efa09051d474549264fe270668 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 15 Nov 2016 09:44:52 -0700 Subject: [PATCH 036/152] support for webhook event documentation endpoint --- event_docs.go | 68 ++++++++++++++++++++++++++++++++++++++++------ event_docs_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/event_docs.go b/event_docs.go index a2853f2..0dd834f 100644 --- a/event_docs.go +++ b/event_docs.go @@ -1,23 +1,73 @@ package gosparkpost -var eventDocumentationFormat = "/api/v%d/webhooks/events/documentation" +import ( + "encoding/json" + "fmt" -type EventDocumentationResponse struct { - Results map[string]*EventGroup `json:"results,omitempty"` -} + "github.com/pkg/errors" +) + +var eventDocumentationFormat = "/api/v%d/webhooks/events/documentation" type EventGroup struct { Name string - Events map[string]EventField + Events map[string]EventMeta Description string `json:"description"` DisplayName string `json:"display_name"` } +type EventMeta struct { + Name string + Fields map[string]EventField `json:"event"` + Description string `json:"description"` + DisplayName string `json:"display_name"` +} + type EventField struct { - Description string `json:"description"` - SampleValue string `json:"sampleValue"` + Description string `json:"description"` + SampleValue interface{} `json:"sampleValue"` } -func (c *Client) EventDocumentation() (g *EventGroup, res *Response, err error) { - return nil, nil, nil +func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, err error) { + path := fmt.Sprintf(eventDocumentationFormat, c.Config.ApiVersion) + res, err = c.HttpGet(c.Config.BaseUrl + path) + if err != nil { + return nil, nil, err + } + + if err = res.AssertJson(); err != nil { + return nil, res, err + } + + if res.HTTP.StatusCode == 200 { + var body []byte + var ok bool + body, err = res.ReadBody() + if err != nil { + return nil, res, err + } + + var results map[string]map[string]*EventGroup + var groups map[string]*EventGroup + if err = json.Unmarshal(body, &results); err != nil { + return nil, res, err + } else if groups, ok = results["results"]; ok { + return groups, res, err + } + return nil, res, errors.New("Unexpected response format") + } else { + err = res.ParseResponse() + if err != nil { + return nil, res, err + } + if len(res.Errors) > 0 { + err = res.PrettyError("EventDocumentation", "retrieve") + if err != nil { + return nil, res, err + } + } + return nil, res, errors.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + } + + return nil, res, err } diff --git a/event_docs_test.go b/event_docs_test.go index d669243..83d071d 100644 --- a/event_docs_test.go +++ b/event_docs_test.go @@ -10,9 +10,42 @@ import ( const eventDocumentationFile = "test/event-docs.json" var eventDocumentationBytes []byte +var eventGroups = []string{"track", "gen", "unsubscribe", "relay", "message"} +var eventGroupMap = map[string]map[string]int{ + "track_event": { + "click": 22, + "open": 20, + }, + "gen_event": { + "generation_failure": 21, + "generation_rejection": 23, + }, + "unsubscribe_event": { + "list_unsubscribe": 18, + "link_unsubscribe": 19, + }, + "relay_event": { + "relay_permfail": 15, + "relay_injection": 12, + "relay_rejection": 14, + "relay_delivery": 12, + "relay_tempfail": 15, + }, + "message_event": { + "spam_complaint": 24, + "out_of_band": 21, + "policy_rejection": 23, + "delay": 38, + "bounce": 37, + "delivery": 36, + "injection": 31, + "sms_status": 22, + }, +} func init() { - eventDocumentationBytes, err := ioutil.ReadFile(eventDocumentationFile) + var err error + eventDocumentationBytes, err = ioutil.ReadFile(eventDocumentationFile) if err != nil { panic(err) } @@ -31,7 +64,7 @@ func TestEventDocs_Get_parse(t *testing.T) { }) // hit our local handler - w, res, err := testClient.EventDocumentation() + groups, res, err := testClient.EventDocumentation() if err != nil { t.Errorf("EventDocumentation GET returned error: %v", err) for _, e := range res.Verbose { @@ -41,7 +74,34 @@ func TestEventDocs_Get_parse(t *testing.T) { } // basic content test - if w.Results == nil { - t.Error("EventDocumentation GET returned nil Results") + if len(groups) == 0 { + testFailVerbose(t, res, "EventDocumentation GET returned 0 EventGroups") + } else { + // check the top level event data + eventGroupsSeen := make(map[string]bool, len(eventGroups)) + for _, etype := range eventGroups { + eventGroupsSeen[etype+"_event"] = false + } + + for gname, v := range groups { + eventGroupsSeen[gname] = true + if _, ok := eventGroupMap[gname]; !ok { + t.Fatalf("expected group [%s] not present in response", gname) + } + for ename, efields := range v.Events { + if fieldCount, ok := eventGroupMap[gname][ename]; !ok { + t.Fatalf("expected event [%s] not present in [%s]", ename, gname) + if fieldCount != len(efields.Fields) { + t.Fatalf("saw %d fields for %s, expected %d", len(efields.Fields), ename, fieldCount) + } + } + } + } + + for gname, seen := range eventGroupsSeen { + if !seen { + t.Fatalf("expected message type [%s] not returned", gname) + } + } } } From 3592454a82b9996c870a9019853216e62ff34cac Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 15 Nov 2016 10:17:47 -0700 Subject: [PATCH 037/152] explicitly tag EventGroup.Events for json parsing --- event_docs.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/event_docs.go b/event_docs.go index 0dd834f..3e20bcc 100644 --- a/event_docs.go +++ b/event_docs.go @@ -11,9 +11,9 @@ var eventDocumentationFormat = "/api/v%d/webhooks/events/documentation" type EventGroup struct { Name string - Events map[string]EventMeta - Description string `json:"description"` - DisplayName string `json:"display_name"` + Events map[string]EventMeta `json:"events"` + Description string `json:"description"` + DisplayName string `json:"display_name"` } type EventMeta struct { From f5abc79ab2ca43d1a84937ddf4c5c408554714ac Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 7 Dec 2016 11:38:21 -0700 Subject: [PATCH 038/152] add headers to Transmission object; accept headers in shared http helpers; fix test compile (still skipped); change parameters for: 1) Client.Transmission (accept a Transmission, lookup using its ID, and fill it out if found) 2) Client.TransmissionDelete (accept a Transmission, lookup using its ID) 3) Client.Transmissions (accept a Transmission, lookup using CampaignID and ID=TemplateID) --- common.go | 31 +++++++++------- deliverability-metrics.go | 2 +- event_docs.go | 2 +- message_events.go | 6 ++-- recipient_lists.go | 4 +-- subaccounts.go | 8 ++--- suppression_list.go | 6 ++-- templates.go | 10 +++--- transmissions.go | 76 ++++++++++++++++++++++----------------- transmissions_test.go | 9 ++--- webhooks.go | 2 +- 11 files changed, 87 insertions(+), 69 deletions(-) diff --git a/common.go b/common.go index 73fa200..b9a413b 100644 --- a/common.go +++ b/common.go @@ -133,32 +133,32 @@ func (c *Client) RemoveHeader(header string) { // HttpPost sends a Post request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPost(url string, data []byte) (*Response, error) { - return c.DoRequest("POST", url, data) +func (c *Client) HttpPost(url string, data []byte, h map[string]string) (*Response, error) { + return c.DoRequest("POST", url, data, h) } // HttpGet sends a Get request to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpGet(url string) (*Response, error) { - return c.DoRequest("GET", url, nil) +func (c *Client) HttpGet(url string, h map[string]string) (*Response, error) { + return c.DoRequest("GET", url, nil, h) } // HttpPut sends a Put request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPut(url string, data []byte) (*Response, error) { - return c.DoRequest("PUT", url, data) +func (c *Client) HttpPut(url string, data []byte, h map[string]string) (*Response, error) { + return c.DoRequest("PUT", url, data, h) } // HttpDelete sends a Delete request to the provided url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpDelete(url string) (*Response, error) { - return c.DoRequest("DELETE", url, nil) +func (c *Client) HttpDelete(url string, h map[string]string) (*Response, error) { + return c.DoRequest("DELETE", url, nil, h) } -func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error) { +func (c *Client) DoRequest(method, urlStr string, data []byte, h map[string]string) (*Response, error) { req, err := http.NewRequest(method, urlStr, bytes.NewBuffer(data)) if err != nil { return nil, err @@ -183,15 +183,20 @@ func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error // TODO: set User-Agent based on gosparkpost version and possibly git's short hash req.Header.Set("User-Agent", "GoSparkPost v0.1") + if c.Config.ApiKey != "" { + req.Header.Set("Authorization", c.Config.ApiKey) + } else { + req.Header.Add("Authorization", "Basic "+basicAuth(c.Config.Username, c.Config.Password)) + } + // Forward additional headers set in client to request for header, value := range c.headers { req.Header.Set(header, value) } - if c.Config.ApiKey != "" { - req.Header.Set("Authorization", c.Config.ApiKey) - } else { - req.Header.Add("Authorization", "Basic "+basicAuth(c.Config.Username, c.Config.Password)) + // Forward additional headers set on request data object + for header, value := range h { + req.Header.Set(header, value) } if c.Config.Verbose { diff --git a/deliverability-metrics.go b/deliverability-metrics.go index ece5961..234ff79 100644 --- a/deliverability-metrics.go +++ b/deliverability-metrics.go @@ -91,7 +91,7 @@ func (c *Client) MetricEventAsString(e *DeliverabilityMetricItem) string { func doMetricsRequest(c *Client, finalUrl string) (*DeliverabilityMetricEventsWrapper, error) { // Send off our request - res, err := c.HttpGet(finalUrl) + res, err := c.HttpGet(finalUrl, nil) if err != nil { return nil, err } diff --git a/event_docs.go b/event_docs.go index 3e20bcc..f25c145 100644 --- a/event_docs.go +++ b/event_docs.go @@ -30,7 +30,7 @@ type EventField struct { func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, err error) { path := fmt.Sprintf(eventDocumentationFormat, c.Config.ApiVersion) - res, err = c.HttpGet(c.Config.BaseUrl + path) + res, err = c.HttpGet(c.Config.BaseUrl+path, nil) if err != nil { return nil, nil, err } diff --git a/message_events.go b/message_events.go index 4215c88..7648fde 100644 --- a/message_events.go +++ b/message_events.go @@ -44,7 +44,7 @@ func (c *Client) MessageEvents(params map[string]string) (*EventsPage, error) { } // Send off our request - res, err := c.HttpGet(url.String()) + res, err := c.HttpGet(url.String(), nil) if err != nil { return nil, err } @@ -77,7 +77,7 @@ func (events *EventsPage) Next() (*EventsPage, error) { } // Send off our request - res, err := events.client.HttpGet(events.client.Config.BaseUrl + events.nextPage) + res, err := events.client.HttpGet(events.client.Config.BaseUrl+events.nextPage, nil) if err != nil { return nil, err } @@ -169,7 +169,7 @@ func (c *Client) EventSamples(types *[]string) (*events.Events, error) { } // Send off our request - res, err := c.HttpGet(url.String()) + res, err := c.HttpGet(url.String(), nil) if err != nil { return nil, err } diff --git a/recipient_lists.go b/recipient_lists.go index c819cf4..30c1c6d 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -159,7 +159,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(url, jsonBytes, nil) if err != nil { return } @@ -206,7 +206,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url) + res, err := c.HttpGet(url, nil) if err != nil { return nil, nil, err } diff --git a/subaccounts.go b/subaccounts.go index 38254a3..9acf0fd 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -65,7 +65,7 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(url, jsonBytes, nil) if err != nil { return } @@ -145,7 +145,7 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, s.ID) - res, err = c.HttpPut(url, jsonBytes) + res, err = c.HttpPut(url, jsonBytes, nil) if err != nil { return } @@ -184,7 +184,7 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpGet(url) + res, err = c.HttpGet(url, nil) if err != nil { return } @@ -232,7 +232,7 @@ func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err err func (c *Client) Subaccount(id int) (subaccount *Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%d", c.Config.BaseUrl, path, id) - res, err = c.HttpGet(u) + res, err = c.HttpGet(u, nil) if err != nil { return } diff --git a/suppression_list.go b/suppression_list.go index 9fe617c..1543ee4 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -64,7 +64,7 @@ func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err er path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) - res, err = c.HttpDelete(finalUrl) + res, err = c.HttpDelete(finalUrl, nil) if err != nil { return res, err } @@ -102,7 +102,7 @@ func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrappe return nil, err } - res, err := c.HttpPut(finalUrl, jsonBytes) + res, err := c.HttpPut(finalUrl, jsonBytes, nil) if err != nil { return res, err } @@ -133,7 +133,7 @@ func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrappe func suppressionGet(c *Client, finalUrl string) (*SuppressionListWrapper, *Response, error) { // Send off our request - res, err := c.HttpGet(finalUrl) + res, err := c.HttpGet(finalUrl, nil) if err != nil { return nil, res, err } diff --git a/templates.go b/templates.go index e3537b2..0c66f73 100644 --- a/templates.go +++ b/templates.go @@ -203,7 +203,7 @@ func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err erro path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(url, jsonBytes, nil) if err != nil { return } @@ -266,7 +266,7 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, t.Published) - res, err = c.HttpPut(url, jsonBytes) + res, err = c.HttpPut(url, jsonBytes, nil) if err != nil { return } @@ -305,7 +305,7 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { func (c *Client) Templates() ([]Template, *Response, error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url) + res, err := c.HttpGet(url, nil) if err != nil { return nil, nil, err } @@ -354,7 +354,7 @@ func (c *Client) TemplateDelete(id string) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err = c.HttpDelete(url) + res, err = c.HttpDelete(url, nil) if err != nil { return } @@ -406,7 +406,7 @@ func (c *Client) TemplatePreview(id string, payload *PreviewOptions) (res *Respo path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s/preview", c.Config.BaseUrl, path, id) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(url, jsonBytes, nil) if err != nil { return } diff --git a/transmissions.go b/transmissions.go index 87615cf..8b00dfd 100644 --- a/transmissions.go +++ b/transmissions.go @@ -29,6 +29,8 @@ type Transmission struct { NumGenerated *int `json:"num_generated,omitempty"` NumFailedGeneration *int `json:"num_failed_generation,omitempty"` NumInvalidRecipients *int `json:"num_invalid_recipients,omitempty"` + + Headers map[string]string `json:"-"` } type RFC3339 time.Time @@ -214,7 +216,7 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(u, jsonBytes) + res, err = c.HttpPost(u, jsonBytes, t.Headers) if err != nil { return } @@ -252,72 +254,79 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { return } -// Retrieve accepts a Transmission.ID and retrieves the corresponding object. -func (c *Client) Transmission(id string) (*Transmission, *Response, error) { - if nonDigit.MatchString(id) { - return nil, nil, fmt.Errorf("id may only contain digits") +// Retrieve accepts a Transmission, looks up the record using its ID, and fills out the provided object. +func (c *Client) Transmission(t *Transmission) (*Response, error) { + if nonDigit.MatchString(t.ID) { + return nil, fmt.Errorf("id may only contain digits") } path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) - u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err := c.HttpGet(u) + u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) + res, err := c.HttpGet(u, t.Headers) if err != nil { - return nil, nil, err + return nil, err } if err = res.AssertJson(); err != nil { - return nil, res, err + return res, err } if res.HTTP.StatusCode == 200 { var body []byte body, err = res.ReadBody() if err != nil { - return nil, res, err + return res, err } // Unwrap the returned Transmission - tmp := map[string]map[string]Transmission{} + tmp := map[string]map[string]json.RawMessage{} if err = json.Unmarshal(body, &tmp); err != nil { - return nil, res, err + return res, err } else if results, ok := tmp["results"]; ok { - if tr, ok := results["transmission"]; ok { - return &tr, res, nil + if raw, ok := results["transmission"]; ok { + if err = json.Unmarshal(raw, t); err != nil { + return res, err + } + return res, nil } else { - return nil, res, fmt.Errorf("Unexpected results structure in response") + return res, fmt.Errorf("Unexpected results structure in response") } } - return nil, res, fmt.Errorf("Unexpected response to Transmission.Retrieve") + return res, fmt.Errorf("Unexpected response to Transmission.Retrieve") } else { err = res.ParseResponse() if err != nil { - return nil, res, err + return res, err } if len(res.Errors) > 0 { err = res.PrettyError("Transmission", "retrieve") if err != nil { - return nil, res, err + return res, err } } - return nil, res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + return res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } - return nil, res, err + return res, err } // Delete attempts to remove the Transmission with the specified id. // Only Transmissions which are scheduled for future generation may be deleted. -func (c *Client) TransmissionDelete(id string) (*Response, error) { - if id == "" { +func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { + if t == nil { + // Delete nothing? Done! + return nil, nil + } else if t.ID == "" { return nil, fmt.Errorf("Delete called with blank id") } - if nonDigit.MatchString(id) { + if nonDigit.MatchString(t.ID) { return nil, fmt.Errorf("Transmissions.Delete: id may only contain digits") } path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) - u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err := c.HttpDelete(u) + u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) + // TODO: take a Transmission object, pull out the ID and use Headers + res, err := c.HttpDelete(u, t.Headers) if err != nil { return nil, err } @@ -347,16 +356,19 @@ func (c *Client) TransmissionDelete(id string) (*Response, error) { } // List returns Transmission summary information for matching Transmissions. -// To skip filtering by campaign or template id, use a nil param. -func (c *Client) Transmissions(campaignID, templateID *string) ([]Transmission, *Response, error) { +// Filtering by CampaignID (t.CampaignID) and TemplateID (t.ID) is supported. +func (c *Client) Transmissions(t *Transmission) ([]Transmission, *Response, error) { + if t == nil { + return nil, nil, nil + } // If a query parameter is present and empty, that searches for blank IDs, as opposed // to when it is omitted entirely, which returns everything. qp := make([]string, 0, 2) - if campaignID != nil { - qp = append(qp, fmt.Sprintf("campaign_id=%s", url.QueryEscape(*campaignID))) + if t.CampaignID != "" { + qp = append(qp, fmt.Sprintf("campaign_id=%s", url.QueryEscape(t.CampaignID))) } - if templateID != nil { - qp = append(qp, fmt.Sprintf("template_id=%s", url.QueryEscape(*templateID))) + if t.ID != "" { + qp = append(qp, fmt.Sprintf("template_id=%s", url.QueryEscape(t.ID))) } qstr := "" @@ -366,7 +378,7 @@ func (c *Client) Transmissions(campaignID, templateID *string) ([]Transmission, path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, qstr) - res, err := c.HttpGet(u) + res, err := c.HttpGet(u, t.Headers) if err != nil { return nil, nil, err } diff --git a/transmissions_test.go b/transmissions_test.go index c0c20c6..a196d8a 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -31,8 +31,7 @@ func TestTransmissions(t *testing.T) { return } - campaignID := "msys_smoke" - tlist, res, err := client.Transmissions(&campaignID, nil) + tlist, res, err := client.Transmissions(&sp.Transmission{CampaignID: "msys_smoke"}) if err != nil { t.Error(err) return @@ -77,8 +76,10 @@ func TestTransmissions(t *testing.T) { } t.Errorf("Transmission created with id [%s]", id) + T.ID = id - tr, res, err := client.Transmission(id) + tr := &sp.Transmission{ID: id} + res, err = client.Transmission(tr) if err != nil { t.Error(err) return @@ -99,7 +100,7 @@ func TestTransmissions(t *testing.T) { } } - res, err = client.TransmissionDelete(id) + res, err = client.TransmissionDelete(tr) if err != nil { t.Error(err) return diff --git a/webhooks.go b/webhooks.go index 9470a7c..2388968 100644 --- a/webhooks.go +++ b/webhooks.go @@ -167,7 +167,7 @@ func doWebhookStatusRequest(c *Client, finalUrl string) (*WebhookStatusWrapper, func doRequest(c *Client, finalUrl string) ([]byte, error) { // Send off our request - res, err := c.HttpGet(finalUrl) + res, err := c.HttpGet(finalUrl, nil) if err != nil { return nil, err } From 7ad1298763ba920f354d0eb10d51c9ee93faea2c Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 7 Dec 2016 11:58:38 -0700 Subject: [PATCH 039/152] handle nil responses properly --- test_server.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test_server.go b/test_server.go index f19b2eb..ca57066 100644 --- a/test_server.go +++ b/test_server.go @@ -44,8 +44,10 @@ func testMethod(t *testing.T, r *http.Request, want string) { } func testFailVerbose(t *testing.T, res *Response, fmt string, args ...interface{}) { - for _, e := range res.Verbose { - t.Error(e) + if res != nil { + for _, e := range res.Verbose { + t.Error(e) + } } t.Fatalf(fmt, args...) } From 7e8825b48ef22a2cfa1158e4a88a1f2c29db1efd Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 9 Dec 2016 15:38:04 -0700 Subject: [PATCH 040/152] add a couple transmission tests; move tests into their own test-specific package; export api path format strings for use in test package --- test_server.go => common_test.go | 12 +++-- event_docs.go | 4 +- event_docs_test.go | 6 ++- suppression_list.go | 12 ++--- suppression_list_test.go | 10 ++-- transmissions.go | 10 ++-- transmissions_test.go | 84 ++++++++++++++++++++++++++++++++ 7 files changed, 114 insertions(+), 24 deletions(-) rename test_server.go => common_test.go (77%) diff --git a/test_server.go b/common_test.go similarity index 77% rename from test_server.go rename to common_test.go index ca57066..9b9616e 100644 --- a/test_server.go +++ b/common_test.go @@ -1,4 +1,4 @@ -package gosparkpost +package gosparkpost_test import ( "crypto/tls" @@ -6,11 +6,13 @@ import ( "net/http/httptest" "net/url" "testing" + + sp "github.com/SparkPost/gosparkpost" ) var ( testMux *http.ServeMux - testClient *Client + testClient *sp.Client testServer *httptest.Server ) @@ -20,8 +22,8 @@ func testSetup(t *testing.T) { testServer = httptest.NewTLSServer(testMux) // our client configured to hit the https test server with self-signed cert tx := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} - testClient = &Client{Client: &http.Client{Transport: tx}} - testClient.Config = &Config{Verbose: true} + testClient = &sp.Client{Client: &http.Client{Transport: tx}} + testClient.Config = &sp.Config{Verbose: true} testUrl, err := url.Parse(testServer.URL) if err != nil { t.Fatalf("Test server url parsing failed: %v", err) @@ -43,7 +45,7 @@ func testMethod(t *testing.T, r *http.Request, want string) { } } -func testFailVerbose(t *testing.T, res *Response, fmt string, args ...interface{}) { +func testFailVerbose(t *testing.T, res *sp.Response, fmt string, args ...interface{}) { if res != nil { for _, e := range res.Verbose { t.Error(e) diff --git a/event_docs.go b/event_docs.go index f25c145..61032ae 100644 --- a/event_docs.go +++ b/event_docs.go @@ -7,7 +7,7 @@ import ( "github.com/pkg/errors" ) -var eventDocumentationFormat = "/api/v%d/webhooks/events/documentation" +var EventDocumentationFormat = "/api/v%d/webhooks/events/documentation" type EventGroup struct { Name string @@ -29,7 +29,7 @@ type EventField struct { } func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, err error) { - path := fmt.Sprintf(eventDocumentationFormat, c.Config.ApiVersion) + path := fmt.Sprintf(EventDocumentationFormat, c.Config.ApiVersion) res, err = c.HttpGet(c.Config.BaseUrl+path, nil) if err != nil { return nil, nil, err diff --git a/event_docs_test.go b/event_docs_test.go index 83d071d..fc347d9 100644 --- a/event_docs_test.go +++ b/event_docs_test.go @@ -1,10 +1,12 @@ -package gosparkpost +package gosparkpost_test import ( "fmt" "io/ioutil" "net/http" "testing" + + sp "github.com/SparkPost/gosparkpost" ) const eventDocumentationFile = "test/event-docs.json" @@ -56,7 +58,7 @@ func TestEventDocs_Get_parse(t *testing.T) { defer testTeardown() // set up the response handler - path := fmt.Sprintf(eventDocumentationFormat, testClient.Config.ApiVersion) + path := fmt.Sprintf(sp.EventDocumentationFormat, testClient.Config.ApiVersion) testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") w.Header().Set("Content-Type", "application/json; charset=utf8") diff --git a/suppression_list.go b/suppression_list.go index 1543ee4..4165778 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -7,7 +7,7 @@ import ( ) // https://developers.sparkpost.com/api/#/reference/suppression-list -var suppressionListsPathFormat = "/api/v%d/suppression-list" +var SuppressionListsPathFormat = "/api/v%d/suppression-list" type SuppressionEntry struct { // Email is used when list is stored @@ -31,12 +31,12 @@ type SuppressionListWrapper struct { } func (c *Client) SuppressionList() (*SuppressionListWrapper, *Response, error) { - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) return suppressionGet(c, c.Config.BaseUrl+path) } func (c *Client) SuppressionRetrieve(recipientEmail string) (*SuppressionListWrapper, *Response, error) { - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) return suppressionGet(c, finalUrl) @@ -44,7 +44,7 @@ func (c *Client) SuppressionRetrieve(recipientEmail string) (*SuppressionListWra func (c *Client) SuppressionSearch(parameters map[string]string) (*SuppressionListWrapper, *Response, error) { var finalUrl string - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) if parameters == nil || len(parameters) == 0 { finalUrl = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) @@ -61,7 +61,7 @@ func (c *Client) SuppressionSearch(parameters map[string]string) (*SuppressionLi } func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err error) { - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) res, err = c.HttpDelete(finalUrl, nil) @@ -90,7 +90,7 @@ func (c *Client) SuppressionInsertOrUpdate(entries []SuppressionEntry) (*Respons return nil, fmt.Errorf("send `entries` cannot be nil here") } - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) list := SuppressionListWrapper{nil, entries} return suppressionPut(c, c.Config.BaseUrl+path, list) diff --git a/suppression_list_test.go b/suppression_list_test.go index ecdf5bd..9f37e9e 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -1,9 +1,11 @@ -package gosparkpost +package gosparkpost_test import ( "fmt" "net/http" "testing" + + sp "github.com/SparkPost/gosparkpost" ) var suppressionNotFound string = `{ @@ -20,7 +22,7 @@ func TestSuppression_Get_notFound(t *testing.T) { defer testTeardown() // set up the response handler - path := fmt.Sprintf(suppressionListsPathFormat, testClient.Config.ApiVersion) + path := fmt.Sprintf(sp.SuppressionListsPathFormat, testClient.Config.ApiVersion) testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") w.Header().Set("Content-Type", "application/json; charset=utf8") @@ -67,7 +69,7 @@ func TestSuppression_Get_combinedList(t *testing.T) { defer testTeardown() // set up the response handler - path := fmt.Sprintf(suppressionListsPathFormat, testClient.Config.ApiVersion) + path := fmt.Sprintf(sp.SuppressionListsPathFormat, testClient.Config.ApiVersion) testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") w.Header().Set("Content-Type", "application/json; charset=utf8") @@ -125,7 +127,7 @@ func TestSuppression_Get_separateList(t *testing.T) { defer testTeardown() // set up the response handler - path := fmt.Sprintf(suppressionListsPathFormat, testClient.Config.ApiVersion) + path := fmt.Sprintf(sp.SuppressionListsPathFormat, testClient.Config.ApiVersion) testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { testMethod(t, r, "GET") w.Header().Set("Content-Type", "application/json; charset=utf8") diff --git a/transmissions.go b/transmissions.go index 8b00dfd..a64d68b 100644 --- a/transmissions.go +++ b/transmissions.go @@ -10,7 +10,7 @@ import ( ) // https://www.sparkpost.com/api#/reference/transmissions -var transmissionsPathFormat = "/api/v%d/transmissions" +var TransmissionsPathFormat = "/api/v%d/transmissions" // Transmission is the JSON structure accepted by and returned from the SparkPost Transmissions API. type Transmission struct { @@ -214,7 +214,7 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { return } - path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err = c.HttpPost(u, jsonBytes, t.Headers) if err != nil { @@ -259,7 +259,7 @@ func (c *Client) Transmission(t *Transmission) (*Response, error) { if nonDigit.MatchString(t.ID) { return nil, fmt.Errorf("id may only contain digits") } - path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) res, err := c.HttpGet(u, t.Headers) if err != nil { @@ -323,7 +323,7 @@ func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { return nil, fmt.Errorf("Transmissions.Delete: id may only contain digits") } - path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) // TODO: take a Transmission object, pull out the ID and use Headers res, err := c.HttpDelete(u, t.Headers) @@ -375,7 +375,7 @@ func (c *Client) Transmissions(t *Transmission) ([]Transmission, *Response, erro if len(qp) > 0 { qstr = strings.Join(qp, "&") } - path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, qstr) res, err := c.HttpGet(u, t.Headers) diff --git a/transmissions_test.go b/transmissions_test.go index a196d8a..aaab7a3 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -1,12 +1,96 @@ package gosparkpost_test import ( + "encoding/json" + "fmt" + "net/http" "testing" sp "github.com/SparkPost/gosparkpost" "github.com/SparkPost/gosparkpost/test" ) +var transmissionSuccess string = `{ + "results": { + "total_rejected_recipients": 0, + "total_accepted_recipients": 1, + "id": "11111111111111111" + } +}` + +func TestTransmissions_Post_Success(t *testing.T) { + testSetup(t) + defer testTeardown() + + path := fmt.Sprintf(sp.TransmissionsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(transmissionSuccess)) + }) + + tx := &sp.Transmission{ + CampaignID: "Post_Success", + ReturnPath: "returnpath@example.com", + Recipients: []string{"recipient1@example.com"}, + Content: sp.Content{ + Subject: "this is a test message", + HTML: "

TEST

", + From: map[string]string{"name": "test", "email": "from@example.com"}, + }, + Metadata: map[string]interface{}{"shoe_size": 9}, + } + id, res, err := testClient.Send(tx) + if err != nil { + testFailVerbose(t, res, "Transmission POST returned error: %v", err) + } + + if id != "11111111111111111" { + testFailVerbose(t, res, "Unexpected value for id! (expected: 11111111111111111, saw: %s)", id) + } +} + +func TestTransmissions_ByID_Success(t *testing.T) { + testSetup(t) + defer testTeardown() + + tx := &sp.Transmission{ + CampaignID: "Post_Success", + ReturnPath: "returnpath@example.com", + Recipients: []string{"recipient1@example.com"}, + Content: sp.Content{ + Subject: "this is a test message", + HTML: "

TEST

", + From: map[string]string{"name": "test", "email": "from@example.com"}, + }, + Metadata: map[string]interface{}{"shoe_size": 9}, + } + txBody := map[string]map[string]*sp.Transmission{"results": {"transmission": tx}} + txBytes, err := json.Marshal(txBody) + if err != nil { + t.Error(err) + } + + path := fmt.Sprintf(sp.TransmissionsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path+"/42", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write(txBytes) + }) + + tx1 := &sp.Transmission{ID: "42"} + res, err := testClient.Transmission(tx1) + if err != nil { + testFailVerbose(t, res, "Transmission GET failed") + } + + if tx1.CampaignID != tx.CampaignID { + testFailVerbose(t, res, "CampaignIDs do not match") + } +} + func TestTransmissions(t *testing.T) { if true { // Temporarily disable test so TravisCI reports build success instead of test failure. From 8358fc794f72a2923bb9bb011d3e2463a212a296 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 9 Dec 2016 15:51:41 -0700 Subject: [PATCH 041/152] test that headers are sent when set on transmission objects --- transmissions_test.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/transmissions_test.go b/transmissions_test.go index aaab7a3..818e880 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" sp "github.com/SparkPost/gosparkpost" @@ -51,6 +52,35 @@ func TestTransmissions_Post_Success(t *testing.T) { } } +func TestTransmissions_Delete_Headers(t *testing.T) { + testSetup(t) + defer testTeardown() + + path := fmt.Sprintf(sp.TransmissionsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path+"/42", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write([]byte("{}")) + }) + + tx := &sp.Transmission{ID: "42", Headers: map[string]string{"X-Foo": "bar"}} + res, err := testClient.TransmissionDelete(tx) + if err != nil { + testFailVerbose(t, res, "Transmission DELETE failed") + } + + var reqDump string + var ok bool + if reqDump, ok = res.Verbose["http_requestdump"]; !ok { + testFailVerbose(t, res, "HTTP Request unavailable") + } + + if !strings.Contains(reqDump, "X-Foo: bar") { + testFailVerbose(t, res, "Header set on Transmission not sent") + } +} + func TestTransmissions_ByID_Success(t *testing.T) { testSetup(t) defer testTeardown() From f7bccc910ed145c83c6b809f4e43c7d429a20bf5 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 9 Dec 2016 16:48:19 -0700 Subject: [PATCH 042/152] revert passing headers directly --- common.go | 23 +++++++++-------------- deliverability-metrics.go | 2 +- event_docs.go | 2 +- message_events.go | 6 +++--- recipient_lists.go | 4 ++-- subaccounts.go | 8 ++++---- suppression_list.go | 6 +++--- templates.go | 10 +++++----- transmissions.go | 32 +++++++++++++------------------- transmissions_test.go | 11 +++++++---- webhooks.go | 2 +- 11 files changed, 49 insertions(+), 57 deletions(-) diff --git a/common.go b/common.go index b9a413b..04e8a52 100644 --- a/common.go +++ b/common.go @@ -133,32 +133,32 @@ func (c *Client) RemoveHeader(header string) { // HttpPost sends a Post request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPost(url string, data []byte, h map[string]string) (*Response, error) { - return c.DoRequest("POST", url, data, h) +func (c *Client) HttpPost(url string, data []byte) (*Response, error) { + return c.DoRequest("POST", url, data) } // HttpGet sends a Get request to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpGet(url string, h map[string]string) (*Response, error) { - return c.DoRequest("GET", url, nil, h) +func (c *Client) HttpGet(url string) (*Response, error) { + return c.DoRequest("GET", url, nil) } // HttpPut sends a Put request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPut(url string, data []byte, h map[string]string) (*Response, error) { - return c.DoRequest("PUT", url, data, h) +func (c *Client) HttpPut(url string, data []byte) (*Response, error) { + return c.DoRequest("PUT", url, data) } // HttpDelete sends a Delete request to the provided url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpDelete(url string, h map[string]string) (*Response, error) { - return c.DoRequest("DELETE", url, nil, h) +func (c *Client) HttpDelete(url string) (*Response, error) { + return c.DoRequest("DELETE", url, nil) } -func (c *Client) DoRequest(method, urlStr string, data []byte, h map[string]string) (*Response, error) { +func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error) { req, err := http.NewRequest(method, urlStr, bytes.NewBuffer(data)) if err != nil { return nil, err @@ -194,11 +194,6 @@ func (c *Client) DoRequest(method, urlStr string, data []byte, h map[string]stri req.Header.Set(header, value) } - // Forward additional headers set on request data object - for header, value := range h { - req.Header.Set(header, value) - } - if c.Config.Verbose { reqBytes, err := httputil.DumpRequestOut(req, false) if err != nil { diff --git a/deliverability-metrics.go b/deliverability-metrics.go index 234ff79..ece5961 100644 --- a/deliverability-metrics.go +++ b/deliverability-metrics.go @@ -91,7 +91,7 @@ func (c *Client) MetricEventAsString(e *DeliverabilityMetricItem) string { func doMetricsRequest(c *Client, finalUrl string) (*DeliverabilityMetricEventsWrapper, error) { // Send off our request - res, err := c.HttpGet(finalUrl, nil) + res, err := c.HttpGet(finalUrl) if err != nil { return nil, err } diff --git a/event_docs.go b/event_docs.go index 61032ae..df2833e 100644 --- a/event_docs.go +++ b/event_docs.go @@ -30,7 +30,7 @@ type EventField struct { func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, err error) { path := fmt.Sprintf(EventDocumentationFormat, c.Config.ApiVersion) - res, err = c.HttpGet(c.Config.BaseUrl+path, nil) + res, err = c.HttpGet(c.Config.BaseUrl + path) if err != nil { return nil, nil, err } diff --git a/message_events.go b/message_events.go index 7648fde..4215c88 100644 --- a/message_events.go +++ b/message_events.go @@ -44,7 +44,7 @@ func (c *Client) MessageEvents(params map[string]string) (*EventsPage, error) { } // Send off our request - res, err := c.HttpGet(url.String(), nil) + res, err := c.HttpGet(url.String()) if err != nil { return nil, err } @@ -77,7 +77,7 @@ func (events *EventsPage) Next() (*EventsPage, error) { } // Send off our request - res, err := events.client.HttpGet(events.client.Config.BaseUrl+events.nextPage, nil) + res, err := events.client.HttpGet(events.client.Config.BaseUrl + events.nextPage) if err != nil { return nil, err } @@ -169,7 +169,7 @@ func (c *Client) EventSamples(types *[]string) (*events.Events, error) { } // Send off our request - res, err := c.HttpGet(url.String(), nil) + res, err := c.HttpGet(url.String()) if err != nil { return nil, err } diff --git a/recipient_lists.go b/recipient_lists.go index 30c1c6d..c819cf4 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -159,7 +159,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes, nil) + res, err = c.HttpPost(url, jsonBytes) if err != nil { return } @@ -206,7 +206,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url, nil) + res, err := c.HttpGet(url) if err != nil { return nil, nil, err } diff --git a/subaccounts.go b/subaccounts.go index 9acf0fd..38254a3 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -65,7 +65,7 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes, nil) + res, err = c.HttpPost(url, jsonBytes) if err != nil { return } @@ -145,7 +145,7 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, s.ID) - res, err = c.HttpPut(url, jsonBytes, nil) + res, err = c.HttpPut(url, jsonBytes) if err != nil { return } @@ -184,7 +184,7 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpGet(url, nil) + res, err = c.HttpGet(url) if err != nil { return } @@ -232,7 +232,7 @@ func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err err func (c *Client) Subaccount(id int) (subaccount *Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%d", c.Config.BaseUrl, path, id) - res, err = c.HttpGet(u, nil) + res, err = c.HttpGet(u) if err != nil { return } diff --git a/suppression_list.go b/suppression_list.go index 4165778..b66087c 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -64,7 +64,7 @@ func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err er path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) - res, err = c.HttpDelete(finalUrl, nil) + res, err = c.HttpDelete(finalUrl) if err != nil { return res, err } @@ -102,7 +102,7 @@ func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrappe return nil, err } - res, err := c.HttpPut(finalUrl, jsonBytes, nil) + res, err := c.HttpPut(finalUrl, jsonBytes) if err != nil { return res, err } @@ -133,7 +133,7 @@ func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrappe func suppressionGet(c *Client, finalUrl string) (*SuppressionListWrapper, *Response, error) { // Send off our request - res, err := c.HttpGet(finalUrl, nil) + res, err := c.HttpGet(finalUrl) if err != nil { return nil, res, err } diff --git a/templates.go b/templates.go index 0c66f73..e3537b2 100644 --- a/templates.go +++ b/templates.go @@ -203,7 +203,7 @@ func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err erro path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes, nil) + res, err = c.HttpPost(url, jsonBytes) if err != nil { return } @@ -266,7 +266,7 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, t.Published) - res, err = c.HttpPut(url, jsonBytes, nil) + res, err = c.HttpPut(url, jsonBytes) if err != nil { return } @@ -305,7 +305,7 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { func (c *Client) Templates() ([]Template, *Response, error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url, nil) + res, err := c.HttpGet(url) if err != nil { return nil, nil, err } @@ -354,7 +354,7 @@ func (c *Client) TemplateDelete(id string) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err = c.HttpDelete(url, nil) + res, err = c.HttpDelete(url) if err != nil { return } @@ -406,7 +406,7 @@ func (c *Client) TemplatePreview(id string, payload *PreviewOptions) (res *Respo path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s/preview", c.Config.BaseUrl, path, id) - res, err = c.HttpPost(url, jsonBytes, nil) + res, err = c.HttpPost(url, jsonBytes) if err != nil { return } diff --git a/transmissions.go b/transmissions.go index a64d68b..9726dad 100644 --- a/transmissions.go +++ b/transmissions.go @@ -216,7 +216,7 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(u, jsonBytes, t.Headers) + res, err = c.HttpPost(u, jsonBytes) if err != nil { return } @@ -261,7 +261,7 @@ func (c *Client) Transmission(t *Transmission) (*Response, error) { } path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) - res, err := c.HttpGet(u, t.Headers) + res, err := c.HttpGet(u) if err != nil { return nil, err } @@ -312,21 +312,18 @@ func (c *Client) Transmission(t *Transmission) (*Response, error) { // Delete attempts to remove the Transmission with the specified id. // Only Transmissions which are scheduled for future generation may be deleted. -func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { - if t == nil { - // Delete nothing? Done! - return nil, nil - } else if t.ID == "" { +func (c *Client) TransmissionDelete(id string) (*Response, error) { + if id == "" { return nil, fmt.Errorf("Delete called with blank id") } - if nonDigit.MatchString(t.ID) { + if nonDigit.MatchString(id) { return nil, fmt.Errorf("Transmissions.Delete: id may only contain digits") } path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) - u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) + u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) // TODO: take a Transmission object, pull out the ID and use Headers - res, err := c.HttpDelete(u, t.Headers) + res, err := c.HttpDelete(u) if err != nil { return nil, err } @@ -357,18 +354,15 @@ func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { // List returns Transmission summary information for matching Transmissions. // Filtering by CampaignID (t.CampaignID) and TemplateID (t.ID) is supported. -func (c *Client) Transmissions(t *Transmission) ([]Transmission, *Response, error) { - if t == nil { - return nil, nil, nil - } +func (c *Client) Transmissions(campaignID, templateID string) ([]Transmission, *Response, error) { // If a query parameter is present and empty, that searches for blank IDs, as opposed // to when it is omitted entirely, which returns everything. qp := make([]string, 0, 2) - if t.CampaignID != "" { - qp = append(qp, fmt.Sprintf("campaign_id=%s", url.QueryEscape(t.CampaignID))) + if campaignID != "" { + qp = append(qp, fmt.Sprintf("campaign_id=%s", url.QueryEscape(campaignID))) } - if t.ID != "" { - qp = append(qp, fmt.Sprintf("template_id=%s", url.QueryEscape(t.ID))) + if templateID != "" { + qp = append(qp, fmt.Sprintf("template_id=%s", url.QueryEscape(templateID))) } qstr := "" @@ -378,7 +372,7 @@ func (c *Client) Transmissions(t *Transmission) ([]Transmission, *Response, erro path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, qstr) - res, err := c.HttpGet(u, t.Headers) + res, err := c.HttpGet(u) if err != nil { return nil, nil, err } diff --git a/transmissions_test.go b/transmissions_test.go index 818e880..d74aa22 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -53,6 +53,10 @@ func TestTransmissions_Post_Success(t *testing.T) { } func TestTransmissions_Delete_Headers(t *testing.T) { + if true { + return + } + testSetup(t) defer testTeardown() @@ -64,8 +68,7 @@ func TestTransmissions_Delete_Headers(t *testing.T) { w.Write([]byte("{}")) }) - tx := &sp.Transmission{ID: "42", Headers: map[string]string{"X-Foo": "bar"}} - res, err := testClient.TransmissionDelete(tx) + res, err := testClient.TransmissionDelete("42") if err != nil { testFailVerbose(t, res, "Transmission DELETE failed") } @@ -145,7 +148,7 @@ func TestTransmissions(t *testing.T) { return } - tlist, res, err := client.Transmissions(&sp.Transmission{CampaignID: "msys_smoke"}) + tlist, res, err := client.Transmissions("msys_smoke", "") if err != nil { t.Error(err) return @@ -214,7 +217,7 @@ func TestTransmissions(t *testing.T) { } } - res, err = client.TransmissionDelete(tr) + res, err = client.TransmissionDelete(id) if err != nil { t.Error(err) return diff --git a/webhooks.go b/webhooks.go index 2388968..9470a7c 100644 --- a/webhooks.go +++ b/webhooks.go @@ -167,7 +167,7 @@ func doWebhookStatusRequest(c *Client, finalUrl string) (*WebhookStatusWrapper, func doRequest(c *Client, finalUrl string) ([]byte, error) { // Send off our request - res, err := c.HttpGet(finalUrl, nil) + res, err := c.HttpGet(finalUrl) if err != nil { return nil, err } From 90c3b4572dc699b51395e52cf236bc48698286c3 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 9 Dec 2016 17:42:56 -0700 Subject: [PATCH 043/152] accept a context instead of a headers object in Http* and DoRequest methods, defaulting to context.Background(); look for a http.Header struct under the context key "http.Header", apply its values to the outgoing request; update methods in `transmission.go` to use Transmission.Context if present; use `context.TODO()` everywhere else for now --- common.go | 41 +++++++++++++++++++++++++++++---------- deliverability-metrics.go | 3 ++- event_docs.go | 3 ++- message_events.go | 7 ++++--- recipient_lists.go | 5 +++-- subaccounts.go | 9 +++++---- suppression_list.go | 7 ++++--- templates.go | 11 ++++++----- transmissions.go | 33 +++++++++++++++++-------------- transmissions_test.go | 18 ++++++++++------- webhooks.go | 3 ++- 11 files changed, 88 insertions(+), 52 deletions(-) diff --git a/common.go b/common.go index 04e8a52..d844ca1 100644 --- a/common.go +++ b/common.go @@ -2,6 +2,7 @@ package gosparkpost import ( "bytes" + "context" "crypto/tls" "encoding/base64" "encoding/json" @@ -133,32 +134,32 @@ func (c *Client) RemoveHeader(header string) { // HttpPost sends a Post request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPost(url string, data []byte) (*Response, error) { - return c.DoRequest("POST", url, data) +func (c *Client) HttpPost(url string, data []byte, ctx context.Context) (*Response, error) { + return c.DoRequest("POST", url, data, ctx) } // HttpGet sends a Get request to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpGet(url string) (*Response, error) { - return c.DoRequest("GET", url, nil) +func (c *Client) HttpGet(url string, ctx context.Context) (*Response, error) { + return c.DoRequest("GET", url, nil, ctx) } // HttpPut sends a Put request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPut(url string, data []byte) (*Response, error) { - return c.DoRequest("PUT", url, data) +func (c *Client) HttpPut(url string, data []byte, ctx context.Context) (*Response, error) { + return c.DoRequest("PUT", url, data, ctx) } // HttpDelete sends a Delete request to the provided url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpDelete(url string) (*Response, error) { - return c.DoRequest("DELETE", url, nil) +func (c *Client) HttpDelete(url string, ctx context.Context) (*Response, error) { + return c.DoRequest("DELETE", url, nil, ctx) } -func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error) { +func (c *Client) DoRequest(method, urlStr string, data []byte, ctx context.Context) (*Response, error) { req, err := http.NewRequest(method, urlStr, bytes.NewBuffer(data)) if err != nil { return nil, err @@ -185,7 +186,7 @@ func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error if c.Config.ApiKey != "" { req.Header.Set("Authorization", c.Config.ApiKey) - } else { + } else if c.Config.Username != "" { req.Header.Add("Authorization", "Basic "+basicAuth(c.Config.Username, c.Config.Password)) } @@ -194,6 +195,26 @@ func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error req.Header.Set(header, value) } + if ctx == nil { + ctx = context.Background() + } + // set any headers provided in context + if header, ok := ctx.Value("http.Header").(http.Header); ok { + for key, vals := range map[string][]string(header) { + if len(vals) >= 1 { + // replace existing headers, default, or from Client.headers + req.Header.Set(key, vals[0]) + } + if len(vals) > 2 { + for _, val := range vals[1:] { + // allow setting multiple values because why not + req.Header.Add(key, val) + } + } + } + } + req = req.WithContext(ctx) + if c.Config.Verbose { reqBytes, err := httputil.DumpRequestOut(req, false) if err != nil { diff --git a/deliverability-metrics.go b/deliverability-metrics.go index ece5961..9cc5730 100644 --- a/deliverability-metrics.go +++ b/deliverability-metrics.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" @@ -91,7 +92,7 @@ func (c *Client) MetricEventAsString(e *DeliverabilityMetricItem) string { func doMetricsRequest(c *Client, finalUrl string) (*DeliverabilityMetricEventsWrapper, error) { // Send off our request - res, err := c.HttpGet(finalUrl) + res, err := c.HttpGet(finalUrl, context.TODO()) if err != nil { return nil, err } diff --git a/event_docs.go b/event_docs.go index df2833e..ef56551 100644 --- a/event_docs.go +++ b/event_docs.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" @@ -30,7 +31,7 @@ type EventField struct { func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, err error) { path := fmt.Sprintf(EventDocumentationFormat, c.Config.ApiVersion) - res, err = c.HttpGet(c.Config.BaseUrl + path) + res, err = c.HttpGet(c.Config.BaseUrl+path, context.TODO()) if err != nil { return nil, nil, err } diff --git a/message_events.go b/message_events.go index 4215c88..1e4dc69 100644 --- a/message_events.go +++ b/message_events.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "errors" "fmt" @@ -44,7 +45,7 @@ func (c *Client) MessageEvents(params map[string]string) (*EventsPage, error) { } // Send off our request - res, err := c.HttpGet(url.String()) + res, err := c.HttpGet(url.String(), context.TODO()) if err != nil { return nil, err } @@ -77,7 +78,7 @@ func (events *EventsPage) Next() (*EventsPage, error) { } // Send off our request - res, err := events.client.HttpGet(events.client.Config.BaseUrl + events.nextPage) + res, err := events.client.HttpGet(events.client.Config.BaseUrl+events.nextPage, context.TODO()) if err != nil { return nil, err } @@ -169,7 +170,7 @@ func (c *Client) EventSamples(types *[]string) (*events.Events, error) { } // Send off our request - res, err := c.HttpGet(url.String()) + res, err := c.HttpGet(url.String(), context.TODO()) if err != nil { return nil, err } diff --git a/recipient_lists.go b/recipient_lists.go index c819cf4..2f74c8a 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" "reflect" @@ -159,7 +160,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(url, jsonBytes, context.TODO()) if err != nil { return } @@ -206,7 +207,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url) + res, err := c.HttpGet(url, context.TODO()) if err != nil { return nil, nil, err } diff --git a/subaccounts.go b/subaccounts.go index 38254a3..ac42497 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" ) @@ -65,7 +66,7 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(url, jsonBytes, context.TODO()) if err != nil { return } @@ -145,7 +146,7 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, s.ID) - res, err = c.HttpPut(url, jsonBytes) + res, err = c.HttpPut(url, jsonBytes, context.TODO()) if err != nil { return } @@ -184,7 +185,7 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpGet(url) + res, err = c.HttpGet(url, context.TODO()) if err != nil { return } @@ -232,7 +233,7 @@ func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err err func (c *Client) Subaccount(id int) (subaccount *Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%d", c.Config.BaseUrl, path, id) - res, err = c.HttpGet(u) + res, err = c.HttpGet(u, context.TODO()) if err != nil { return } diff --git a/suppression_list.go b/suppression_list.go index b66087c..134b47f 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" "net/url" @@ -64,7 +65,7 @@ func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err er path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) - res, err = c.HttpDelete(finalUrl) + res, err = c.HttpDelete(finalUrl, context.TODO()) if err != nil { return res, err } @@ -102,7 +103,7 @@ func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrappe return nil, err } - res, err := c.HttpPut(finalUrl, jsonBytes) + res, err := c.HttpPut(finalUrl, jsonBytes, context.TODO()) if err != nil { return res, err } @@ -133,7 +134,7 @@ func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrappe func suppressionGet(c *Client, finalUrl string) (*SuppressionListWrapper, *Response, error) { // Send off our request - res, err := c.HttpGet(finalUrl) + res, err := c.HttpGet(finalUrl, context.TODO()) if err != nil { return nil, res, err } diff --git a/templates.go b/templates.go index e3537b2..704a0eb 100644 --- a/templates.go +++ b/templates.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" "reflect" @@ -203,7 +204,7 @@ func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err erro path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(url, jsonBytes, context.TODO()) if err != nil { return } @@ -266,7 +267,7 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, t.Published) - res, err = c.HttpPut(url, jsonBytes) + res, err = c.HttpPut(url, jsonBytes, context.TODO()) if err != nil { return } @@ -305,7 +306,7 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { func (c *Client) Templates() ([]Template, *Response, error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url) + res, err := c.HttpGet(url, context.TODO()) if err != nil { return nil, nil, err } @@ -354,7 +355,7 @@ func (c *Client) TemplateDelete(id string) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err = c.HttpDelete(url) + res, err = c.HttpDelete(url, context.TODO()) if err != nil { return } @@ -406,7 +407,7 @@ func (c *Client) TemplatePreview(id string, payload *PreviewOptions) (res *Respo path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s/preview", c.Config.BaseUrl, path, id) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(url, jsonBytes, context.TODO()) if err != nil { return } diff --git a/transmissions.go b/transmissions.go index 9726dad..7cde979 100644 --- a/transmissions.go +++ b/transmissions.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" "net/url" @@ -30,7 +31,7 @@ type Transmission struct { NumFailedGeneration *int `json:"num_failed_generation,omitempty"` NumInvalidRecipients *int `json:"num_invalid_recipients,omitempty"` - Headers map[string]string `json:"-"` + Context context.Context `json:"-"` } type RFC3339 time.Time @@ -216,7 +217,7 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(u, jsonBytes) + res, err = c.HttpPost(u, jsonBytes, t.Context) if err != nil { return } @@ -261,7 +262,7 @@ func (c *Client) Transmission(t *Transmission) (*Response, error) { } path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) - res, err := c.HttpGet(u) + res, err := c.HttpGet(u, t.Context) if err != nil { return nil, err } @@ -312,18 +313,20 @@ func (c *Client) Transmission(t *Transmission) (*Response, error) { // Delete attempts to remove the Transmission with the specified id. // Only Transmissions which are scheduled for future generation may be deleted. -func (c *Client) TransmissionDelete(id string) (*Response, error) { - if id == "" { +func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { + if t == nil { + return nil, nil + } + if t.ID == "" { return nil, fmt.Errorf("Delete called with blank id") } - if nonDigit.MatchString(id) { + if nonDigit.MatchString(t.ID) { return nil, fmt.Errorf("Transmissions.Delete: id may only contain digits") } path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) - u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - // TODO: take a Transmission object, pull out the ID and use Headers - res, err := c.HttpDelete(u) + u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) + res, err := c.HttpDelete(u, t.Context) if err != nil { return nil, err } @@ -354,15 +357,15 @@ func (c *Client) TransmissionDelete(id string) (*Response, error) { // List returns Transmission summary information for matching Transmissions. // Filtering by CampaignID (t.CampaignID) and TemplateID (t.ID) is supported. -func (c *Client) Transmissions(campaignID, templateID string) ([]Transmission, *Response, error) { +func (c *Client) Transmissions(t *Transmission) ([]Transmission, *Response, error) { // If a query parameter is present and empty, that searches for blank IDs, as opposed // to when it is omitted entirely, which returns everything. qp := make([]string, 0, 2) - if campaignID != "" { - qp = append(qp, fmt.Sprintf("campaign_id=%s", url.QueryEscape(campaignID))) + if t.CampaignID != "" { + qp = append(qp, fmt.Sprintf("campaign_id=%s", url.QueryEscape(t.CampaignID))) } - if templateID != "" { - qp = append(qp, fmt.Sprintf("template_id=%s", url.QueryEscape(templateID))) + if t.ID != "" { + qp = append(qp, fmt.Sprintf("template_id=%s", url.QueryEscape(t.ID))) } qstr := "" @@ -372,7 +375,7 @@ func (c *Client) Transmissions(campaignID, templateID string) ([]Transmission, * path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, qstr) - res, err := c.HttpGet(u) + res, err := c.HttpGet(u, t.Context) if err != nil { return nil, nil, err } diff --git a/transmissions_test.go b/transmissions_test.go index d74aa22..0fe8cfd 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -1,6 +1,7 @@ package gosparkpost_test import ( + "context" "encoding/json" "fmt" "net/http" @@ -53,10 +54,6 @@ func TestTransmissions_Post_Success(t *testing.T) { } func TestTransmissions_Delete_Headers(t *testing.T) { - if true { - return - } - testSetup(t) defer testTeardown() @@ -68,7 +65,13 @@ func TestTransmissions_Delete_Headers(t *testing.T) { w.Write([]byte("{}")) }) - res, err := testClient.TransmissionDelete("42") + header := http.Header{} + header.Add("X-Foo", "bar") + tx := &sp.Transmission{ + ID: "42", + Context: context.WithValue(context.Background(), "http.Header", header), + } + res, err := testClient.TransmissionDelete(tx) if err != nil { testFailVerbose(t, res, "Transmission DELETE failed") } @@ -148,7 +151,7 @@ func TestTransmissions(t *testing.T) { return } - tlist, res, err := client.Transmissions("msys_smoke", "") + tlist, res, err := client.Transmissions(&sp.Transmission{CampaignID: "msys_smoke"}) if err != nil { t.Error(err) return @@ -217,7 +220,8 @@ func TestTransmissions(t *testing.T) { } } - res, err = client.TransmissionDelete(id) + tx1 := &sp.Transmission{ID: id} + res, err = client.TransmissionDelete(tx1) if err != nil { t.Error(err) return diff --git a/webhooks.go b/webhooks.go index 9470a7c..b5ffa6b 100644 --- a/webhooks.go +++ b/webhooks.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" @@ -167,7 +168,7 @@ func doWebhookStatusRequest(c *Client, finalUrl string) (*WebhookStatusWrapper, func doRequest(c *Client, finalUrl string) ([]byte, error) { // Send off our request - res, err := c.HttpGet(finalUrl) + res, err := c.HttpGet(finalUrl, context.TODO()) if err != nil { return nil, err } From f9901ee611b86210075a477de3489c89b4529ef5 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 9 Dec 2016 17:48:25 -0700 Subject: [PATCH 044/152] bump travis reqs to 1.7 for context package --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 92823df..dfdbf77 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,3 @@ language: go go: - - 1.5 + - 1.7 From 192f3f006d338da653a19296972dea6a96f41208 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 12 Dec 2016 10:38:46 -0700 Subject: [PATCH 045/152] change to more conventional argument order (context first) --- common.go | 18 +++++++++--------- deliverability-metrics.go | 2 +- event_docs.go | 2 +- message_events.go | 6 +++--- recipient_lists.go | 4 ++-- subaccounts.go | 8 ++++---- suppression_list.go | 6 +++--- templates.go | 10 +++++----- transmissions.go | 8 ++++---- webhooks.go | 2 +- 10 files changed, 33 insertions(+), 33 deletions(-) diff --git a/common.go b/common.go index d844ca1..b9b1abb 100644 --- a/common.go +++ b/common.go @@ -134,32 +134,32 @@ func (c *Client) RemoveHeader(header string) { // HttpPost sends a Post request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPost(url string, data []byte, ctx context.Context) (*Response, error) { - return c.DoRequest("POST", url, data, ctx) +func (c *Client) HttpPost(ctx context.Context, url string, data []byte) (*Response, error) { + return c.DoRequest(ctx, "POST", url, data) } // HttpGet sends a Get request to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpGet(url string, ctx context.Context) (*Response, error) { - return c.DoRequest("GET", url, nil, ctx) +func (c *Client) HttpGet(ctx context.Context, url string) (*Response, error) { + return c.DoRequest(ctx, "GET", url, nil) } // HttpPut sends a Put request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPut(url string, data []byte, ctx context.Context) (*Response, error) { - return c.DoRequest("PUT", url, data, ctx) +func (c *Client) HttpPut(ctx context.Context, url string, data []byte) (*Response, error) { + return c.DoRequest(ctx, "PUT", url, data) } // HttpDelete sends a Delete request to the provided url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpDelete(url string, ctx context.Context) (*Response, error) { - return c.DoRequest("DELETE", url, nil, ctx) +func (c *Client) HttpDelete(ctx context.Context, url string) (*Response, error) { + return c.DoRequest(ctx, "DELETE", url, nil) } -func (c *Client) DoRequest(method, urlStr string, data []byte, ctx context.Context) (*Response, error) { +func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []byte) (*Response, error) { req, err := http.NewRequest(method, urlStr, bytes.NewBuffer(data)) if err != nil { return nil, err diff --git a/deliverability-metrics.go b/deliverability-metrics.go index 9cc5730..e2de541 100644 --- a/deliverability-metrics.go +++ b/deliverability-metrics.go @@ -92,7 +92,7 @@ func (c *Client) MetricEventAsString(e *DeliverabilityMetricItem) string { func doMetricsRequest(c *Client, finalUrl string) (*DeliverabilityMetricEventsWrapper, error) { // Send off our request - res, err := c.HttpGet(finalUrl, context.TODO()) + res, err := c.HttpGet(context.TODO(), finalUrl) if err != nil { return nil, err } diff --git a/event_docs.go b/event_docs.go index ef56551..5102a7b 100644 --- a/event_docs.go +++ b/event_docs.go @@ -31,7 +31,7 @@ type EventField struct { func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, err error) { path := fmt.Sprintf(EventDocumentationFormat, c.Config.ApiVersion) - res, err = c.HttpGet(c.Config.BaseUrl+path, context.TODO()) + res, err = c.HttpGet(context.TODO(), c.Config.BaseUrl+path) if err != nil { return nil, nil, err } diff --git a/message_events.go b/message_events.go index 1e4dc69..6e2e491 100644 --- a/message_events.go +++ b/message_events.go @@ -45,7 +45,7 @@ func (c *Client) MessageEvents(params map[string]string) (*EventsPage, error) { } // Send off our request - res, err := c.HttpGet(url.String(), context.TODO()) + res, err := c.HttpGet(context.TODO(), url.String()) if err != nil { return nil, err } @@ -78,7 +78,7 @@ func (events *EventsPage) Next() (*EventsPage, error) { } // Send off our request - res, err := events.client.HttpGet(events.client.Config.BaseUrl+events.nextPage, context.TODO()) + res, err := events.client.HttpGet(context.TODO(), events.client.Config.BaseUrl+events.nextPage) if err != nil { return nil, err } @@ -170,7 +170,7 @@ func (c *Client) EventSamples(types *[]string) (*events.Events, error) { } // Send off our request - res, err := c.HttpGet(url.String(), context.TODO()) + res, err := c.HttpGet(context.TODO(), url.String()) if err != nil { return nil, err } diff --git a/recipient_lists.go b/recipient_lists.go index 2f74c8a..7bf7e27 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -160,7 +160,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes, context.TODO()) + res, err = c.HttpPost(context.TODO(), url, jsonBytes) if err != nil { return } @@ -207,7 +207,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url, context.TODO()) + res, err := c.HttpGet(context.TODO(), url) if err != nil { return nil, nil, err } diff --git a/subaccounts.go b/subaccounts.go index ac42497..48362a2 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -66,7 +66,7 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes, context.TODO()) + res, err = c.HttpPost(context.TODO(), url, jsonBytes) if err != nil { return } @@ -146,7 +146,7 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, s.ID) - res, err = c.HttpPut(url, jsonBytes, context.TODO()) + res, err = c.HttpPut(context.TODO(), url, jsonBytes) if err != nil { return } @@ -185,7 +185,7 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpGet(url, context.TODO()) + res, err = c.HttpGet(context.TODO(), url) if err != nil { return } @@ -233,7 +233,7 @@ func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err err func (c *Client) Subaccount(id int) (subaccount *Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%d", c.Config.BaseUrl, path, id) - res, err = c.HttpGet(u, context.TODO()) + res, err = c.HttpGet(context.TODO(), u) if err != nil { return } diff --git a/suppression_list.go b/suppression_list.go index 134b47f..f2bca2c 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -65,7 +65,7 @@ func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err er path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) - res, err = c.HttpDelete(finalUrl, context.TODO()) + res, err = c.HttpDelete(context.TODO(), finalUrl) if err != nil { return res, err } @@ -103,7 +103,7 @@ func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrappe return nil, err } - res, err := c.HttpPut(finalUrl, jsonBytes, context.TODO()) + res, err := c.HttpPut(context.TODO(), finalUrl, jsonBytes) if err != nil { return res, err } @@ -134,7 +134,7 @@ func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrappe func suppressionGet(c *Client, finalUrl string) (*SuppressionListWrapper, *Response, error) { // Send off our request - res, err := c.HttpGet(finalUrl, context.TODO()) + res, err := c.HttpGet(context.TODO(), finalUrl) if err != nil { return nil, res, err } diff --git a/templates.go b/templates.go index 704a0eb..3df7d04 100644 --- a/templates.go +++ b/templates.go @@ -204,7 +204,7 @@ func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err erro path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes, context.TODO()) + res, err = c.HttpPost(context.TODO(), url, jsonBytes) if err != nil { return } @@ -267,7 +267,7 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, t.Published) - res, err = c.HttpPut(url, jsonBytes, context.TODO()) + res, err = c.HttpPut(context.TODO(), url, jsonBytes) if err != nil { return } @@ -306,7 +306,7 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { func (c *Client) Templates() ([]Template, *Response, error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url, context.TODO()) + res, err := c.HttpGet(context.TODO(), url) if err != nil { return nil, nil, err } @@ -355,7 +355,7 @@ func (c *Client) TemplateDelete(id string) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err = c.HttpDelete(url, context.TODO()) + res, err = c.HttpDelete(context.TODO(), url) if err != nil { return } @@ -407,7 +407,7 @@ func (c *Client) TemplatePreview(id string, payload *PreviewOptions) (res *Respo path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s/preview", c.Config.BaseUrl, path, id) - res, err = c.HttpPost(url, jsonBytes, context.TODO()) + res, err = c.HttpPost(context.TODO(), url, jsonBytes) if err != nil { return } diff --git a/transmissions.go b/transmissions.go index 7cde979..8a03b77 100644 --- a/transmissions.go +++ b/transmissions.go @@ -217,7 +217,7 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(u, jsonBytes, t.Context) + res, err = c.HttpPost(t.Context, u, jsonBytes) if err != nil { return } @@ -262,7 +262,7 @@ func (c *Client) Transmission(t *Transmission) (*Response, error) { } path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) - res, err := c.HttpGet(u, t.Context) + res, err := c.HttpGet(t.Context, u) if err != nil { return nil, err } @@ -326,7 +326,7 @@ func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) - res, err := c.HttpDelete(u, t.Context) + res, err := c.HttpDelete(t.Context, u) if err != nil { return nil, err } @@ -375,7 +375,7 @@ func (c *Client) Transmissions(t *Transmission) ([]Transmission, *Response, erro path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, qstr) - res, err := c.HttpGet(u, t.Context) + res, err := c.HttpGet(t.Context, u) if err != nil { return nil, nil, err } diff --git a/webhooks.go b/webhooks.go index b5ffa6b..96f2d73 100644 --- a/webhooks.go +++ b/webhooks.go @@ -168,7 +168,7 @@ func doWebhookStatusRequest(c *Client, finalUrl string) (*WebhookStatusWrapper, func doRequest(c *Client, finalUrl string) ([]byte, error) { // Send off our request - res, err := c.HttpGet(finalUrl, context.TODO()) + res, err := c.HttpGet(context.TODO(), finalUrl) if err != nil { return nil, err } From fcda5bce9714d47238d8db603892a9e6ec1dcf96 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 12 Dec 2016 11:10:23 -0700 Subject: [PATCH 046/152] refactor for context passing; return response objects --- deliverability-metrics.go | 67 +++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/deliverability-metrics.go b/deliverability-metrics.go index e2de541..f5fd27a 100644 --- a/deliverability-metrics.go +++ b/deliverability-metrics.go @@ -9,7 +9,7 @@ import ( ) // https://www.sparkpost.com/api#/reference/message-events -var deliverabilityMetricPathFormat = "/api/v%d/metrics/deliverability" +var DeliverabilityMetricPathFormat = "/api/v%d/metrics/deliverability" type DeliverabilityMetricItem struct { CountInjected int `json:"count_injected"` @@ -51,77 +51,68 @@ type DeliverabilityMetricItem struct { BindingGroup string `json:"binding_group,omitempty"` } -type DeliverabilityMetricEventsWrapper struct { - Results []*DeliverabilityMetricItem `json:"results,omitempty"` - TotalCount int `json:"total_count,omitempty"` - Links []map[string]string `json:"links,omitempty"` - Errors []interface{} `json:"errors,omitempty"` - //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} +type DeliverabilityMetrics struct { + Results []DeliverabilityMetricItem `json:"results,omitempty"` + TotalCount int `json:"total_count,omitempty"` + Links []map[string]string `json:"links,omitempty"` + Errors []interface{} `json:"errors,omitempty"` + + ExtraPath string `json:"-"` + Params map[string]string `json:"-"` + Context context.Context `json:"-"` } // https://developers.sparkpost.com/api/#/reference/metrics/deliverability-metrics-by-domain -func (c *Client) QueryDeliverabilityMetrics(extraPath string, parameters map[string]string) (*DeliverabilityMetricEventsWrapper, error) { - +func (c *Client) QueryDeliverabilityMetrics(dm *DeliverabilityMetrics) (*Response, error) { var finalUrl string - path := fmt.Sprintf(deliverabilityMetricPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(DeliverabilityMetricPathFormat, c.Config.ApiVersion) - if extraPath != "" { - path = fmt.Sprintf("%s/%s", path, extraPath) + if dm.ExtraPath != "" { + path = fmt.Sprintf("%s/%s", path, dm.ExtraPath) } - //log.Printf("Path: %s", path) - - if parameters == nil || len(parameters) == 0 { + if dm.Params == nil || len(dm.Params) == 0 { finalUrl = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { params := URL.Values{} - for k, v := range parameters { + for k, v := range dm.Params { params.Add(k, v) } finalUrl = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, params.Encode()) } - return doMetricsRequest(c, finalUrl) -} - -func (c *Client) MetricEventAsString(e *DeliverabilityMetricItem) string { - - return fmt.Sprintf("domain: %s, [%v]", e.Domain, e) + return dm.doMetricsRequest(c, finalUrl) } -func doMetricsRequest(c *Client, finalUrl string) (*DeliverabilityMetricEventsWrapper, error) { +func (dm *DeliverabilityMetrics) doMetricsRequest(c *Client, finalUrl string) (*Response, error) { // Send off our request - res, err := c.HttpGet(context.TODO(), finalUrl) + res, err := c.HttpGet(dm.Context, finalUrl) if err != nil { - return nil, err + return res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return res, err } - // Get the Content - bodyBytes, err := res.ReadBody() + err = res.ParseResponse() if err != nil { - return nil, err + return res, err } - /*// DEBUG - err = iou.WriteFile("./events.json", bodyBytes, 0644) + // Get the Content + bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return res, err } - */ // Parse expected response structure - var resMap DeliverabilityMetricEventsWrapper - err = json.Unmarshal(bodyBytes, &resMap) - + err = json.Unmarshal(bodyBytes, dm) if err != nil { - return nil, err + return res, err } - return &resMap, err + return res, nil } From b65463016c7b83e2b7e6032c84882d298c1a5efa Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 12 Dec 2016 11:11:06 -0700 Subject: [PATCH 047/152] rename for consistency --- deliverability-metrics.go => deliverability_metrics.go | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename deliverability-metrics.go => deliverability_metrics.go (100%) diff --git a/deliverability-metrics.go b/deliverability_metrics.go similarity index 100% rename from deliverability-metrics.go rename to deliverability_metrics.go From ab9a6d578781dcafadd760381342ce373133e8e3 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 12 Dec 2016 11:50:07 -0700 Subject: [PATCH 048/152] use core mime package to parse content-type header; save context with errors --- common.go | 47 ++++++++++++++++--------- deliverability_metrics.go => metrics.go | 0 2 files changed, 30 insertions(+), 17 deletions(-) rename deliverability_metrics.go => metrics.go (100%) diff --git a/common.go b/common.go index b9b1abb..9dafb07 100644 --- a/common.go +++ b/common.go @@ -8,12 +8,14 @@ import ( "encoding/json" "fmt" "io/ioutil" + "mime" "net/http" "net/http/httputil" "regexp" "strings" certifi "github.com/certifi/gocertifi" + "github.com/pkg/errors" ) // Config includes all information necessary to make an API request. @@ -43,13 +45,13 @@ func NewConfig(m map[string]string) (*Config, error) { if baseurl, ok := m["baseurl"]; ok { c.BaseUrl = baseurl } else { - return nil, fmt.Errorf("BaseUrl is required for api config") + return nil, errors.New("BaseUrl is required for api config") } if apikey, ok := m["apikey"]; ok { c.ApiKey = apikey } else { - return nil, fmt.Errorf("ApiKey is required for api config") + return nil, errors.New("ApiKey is required for api config") } return c, nil @@ -78,7 +80,7 @@ type Error struct { func (e Error) Json() (string, error) { jsonBytes, err := json.Marshal(e) if err != nil { - return "", err + return "", errors.Wrap(err, "marshaling json") } return string(jsonBytes), nil } @@ -90,7 +92,7 @@ func (api *Client) Init(cfg *Config) error { if cfg.BaseUrl == "" { cfg.BaseUrl = "https://api.sparkpost.com" } else if !strings.HasPrefix(cfg.BaseUrl, "https://") { - return fmt.Errorf("API base url must be https!") + return errors.New("API base url must be https!") } if cfg.ApiVersion == 0 { cfg.ApiVersion = 1 @@ -105,7 +107,7 @@ func (api *Client) Init(cfg *Config) error { // load Mozilla cert pool pool, err := certifi.CACerts() if err != nil { - return err + return errors.Wrap(err, "loading certifi cert pool") } // configure transport using Mozilla cert pool @@ -162,7 +164,7 @@ func (c *Client) HttpDelete(ctx context.Context, url string) (*Response, error) func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []byte) (*Response, error) { req, err := http.NewRequest(method, urlStr, bytes.NewBuffer(data)) if err != nil { - return nil, err + return nil, errors.Wrap(err, "building request") } ares := &Response{} @@ -218,7 +220,7 @@ func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []by if c.Config.Verbose { reqBytes, err := httputil.DumpRequestOut(req, false) if err != nil { - return ares, err + return ares, errors.Wrap(err, "saving request") } ares.Verbose["http_requestdump"] = string(reqBytes) } @@ -228,14 +230,18 @@ func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []by if c.Config.Verbose { ares.Verbose["http_status"] = ares.HTTP.Status - bodyBytes, err := httputil.DumpResponse(res, true) + bodyBytes, dumpErr := httputil.DumpResponse(res, true) if err != nil { - return ares, err + ares.Verbose["http_responsedump_err"] = dumpErr.Error() + } else { + ares.Verbose["http_responsedump"] = string(bodyBytes) } - ares.Verbose["http_responsedump"] = string(bodyBytes) } - return ares, err + if err != nil { + return ares, errors.Wrap(err, "error response") + } + return ares, nil } func basicAuth(username, password string) string { @@ -255,8 +261,11 @@ func (r *Response) ReadBody() ([]byte, error) { defer r.HTTP.Body.Close() bodyBytes, err := ioutil.ReadAll(r.HTTP.Body) + if err != nil { + return bodyBytes, errors.Wrap(err, "reading http body") + } r.Body = bodyBytes - return bodyBytes, err + return bodyBytes, nil } // ParseResponse pulls info from JSON http responses into api.Response object. @@ -269,7 +278,7 @@ func (r *Response) ParseResponse() error { err = json.Unmarshal(body, r) if err != nil { - return fmt.Errorf("Failed to parse API response: [%s]\n%s", err, string(body)) + return errors.Wrap(err, "parsing api response") } return nil @@ -278,12 +287,16 @@ func (r *Response) ParseResponse() error { // AssertJson returns an error if the provided HTTP response isn't JSON. func (r *Response) AssertJson() error { if r.HTTP == nil { - return fmt.Errorf("AssertJson got nil http.Response") + return errors.New("AssertJson got nil http.Response") + } + ctype := r.HTTP.Header.Get("Content-Type") + mediaType, _, err := mime.ParseMediaType(ctype) + if err != nil { + return errors.Wrap(err, "parsing content-type") } - ctype := strings.ToLower(r.HTTP.Header.Get("Content-Type")) // allow things like "application/json; charset=utf-8" in addition to the bare content type - if !strings.HasPrefix(ctype, "application/json") { - return fmt.Errorf("Expected json, got [%s] with code %d", ctype, r.HTTP.StatusCode) + if mediaType != "application/json" { + return fmt.Errorf("Expected json, got [%s] with code %d", mediaType, r.HTTP.StatusCode) } return nil } diff --git a/deliverability_metrics.go b/metrics.go similarity index 100% rename from deliverability_metrics.go rename to metrics.go From 4041f0bd3c8de344254646cf633cabb77896b7fa Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 14 Dec 2016 14:07:54 -0500 Subject: [PATCH 049/152] simplify type names for deliverability metrics calls --- metrics.go => del_metrics.go | 42 +++++++++++++------------- del_metrics_test.go | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 21 deletions(-) rename metrics.go => del_metrics.go (77%) create mode 100644 del_metrics_test.go diff --git a/metrics.go b/del_metrics.go similarity index 77% rename from metrics.go rename to del_metrics.go index f5fd27a..798c6a0 100644 --- a/metrics.go +++ b/del_metrics.go @@ -4,14 +4,14 @@ import ( "context" "encoding/json" "fmt" + "net/url" - URL "net/url" + "github.com/pkg/errors" ) -// https://www.sparkpost.com/api#/reference/message-events -var DeliverabilityMetricPathFormat = "/api/v%d/metrics/deliverability" +var MetricsPathFormat = "/api/v%d/metrics/deliverability" -type DeliverabilityMetricItem struct { +type MetricItem struct { CountInjected int `json:"count_injected"` CountBounce int `json:"count_bounce,omitempty"` CountRejected int `json:"count_rejected,omitempty"` @@ -51,11 +51,11 @@ type DeliverabilityMetricItem struct { BindingGroup string `json:"binding_group,omitempty"` } -type DeliverabilityMetrics struct { - Results []DeliverabilityMetricItem `json:"results,omitempty"` - TotalCount int `json:"total_count,omitempty"` - Links []map[string]string `json:"links,omitempty"` - Errors []interface{} `json:"errors,omitempty"` +type Metrics struct { + Results []MetricItem `json:"results,omitempty"` + TotalCount int `json:"total_count,omitempty"` + Links []map[string]string `json:"links,omitempty"` + Errors []interface{} `json:"errors,omitempty"` ExtraPath string `json:"-"` Params map[string]string `json:"-"` @@ -63,31 +63,31 @@ type DeliverabilityMetrics struct { } // https://developers.sparkpost.com/api/#/reference/metrics/deliverability-metrics-by-domain -func (c *Client) QueryDeliverabilityMetrics(dm *DeliverabilityMetrics) (*Response, error) { +func (c *Client) QueryMetrics(m *Metrics) (*Response, error) { var finalUrl string - path := fmt.Sprintf(DeliverabilityMetricPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(MetricsPathFormat, c.Config.ApiVersion) - if dm.ExtraPath != "" { - path = fmt.Sprintf("%s/%s", path, dm.ExtraPath) + if m.ExtraPath != "" { + path = fmt.Sprintf("%s/%s", path, m.ExtraPath) } - if dm.Params == nil || len(dm.Params) == 0 { + if m.Params == nil || len(m.Params) == 0 { finalUrl = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { - params := URL.Values{} - for k, v := range dm.Params { + params := url.Values{} + for k, v := range m.Params { params.Add(k, v) } finalUrl = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, params.Encode()) } - return dm.doMetricsRequest(c, finalUrl) + return m.doMetricsRequest(c, finalUrl) } -func (dm *DeliverabilityMetrics) doMetricsRequest(c *Client, finalUrl string) (*Response, error) { +func (m *Metrics) doMetricsRequest(c *Client, finalUrl string) (*Response, error) { // Send off our request - res, err := c.HttpGet(dm.Context, finalUrl) + res, err := c.HttpGet(m.Context, finalUrl) if err != nil { return res, err } @@ -109,9 +109,9 @@ func (dm *DeliverabilityMetrics) doMetricsRequest(c *Client, finalUrl string) (* } // Parse expected response structure - err = json.Unmarshal(bodyBytes, dm) + err = json.Unmarshal(bodyBytes, m) if err != nil { - return res, err + return res, errors.Wrap(err, "unmarshaling response") } return res, nil diff --git a/del_metrics_test.go b/del_metrics_test.go new file mode 100644 index 0000000..10b3253 --- /dev/null +++ b/del_metrics_test.go @@ -0,0 +1,58 @@ +package gosparkpost_test + +import ( + "fmt" + "net/http" + "testing" + + sp "github.com/SparkPost/gosparkpost" +) + +// The "links" section is snipped for brevity +var delMetricsBaseNoArgs string = `{ + "errors": [ + { + "message": "from is required", + "param": "from" + }, + { + "message": "from must be in the format YYYY-MM-DDTHH:MM", + "param": "from" + }, + { + "message": "from must be before to", + "param": "from" + } + ], + "links": [ + { + "href": "/api/v1/metrics/deliverability", + "method": "GET", + "rel": "deliverability" + }, + { + "href": "/api/v1/metrics/deliverability/watched-domain", + "method": "GET", + "rel": "watched-domain" + } + ] +}` + +func TestMetrics_Get_noArgsError(t *testing.T) { + testSetup(t) + defer testTeardown() + + path := fmt.Sprintf(sp.MetricsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(delMetricsBaseNoArgs)) + }) + + m := &sp.Metrics{} + res, err := testClient.QueryMetrics(m) + if err != nil { + testFailVerbose(t, res, "Metrics GET returned error: %+v", err) + } +} From 6f076e8e83d7c717c59bcb764dca84d8487ba77f Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 5 Jan 2017 11:34:44 -0700 Subject: [PATCH 050/152] standardize path format to not include host; return result; test parsing of empty results --- message_events.go | 24 +++++++++++++----------- message_events_test.go | 30 +++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/message_events.go b/message_events.go index 6e2e491..b557d02 100644 --- a/message_events.go +++ b/message_events.go @@ -14,8 +14,8 @@ import ( // https://www.sparkpost.com/api#/reference/message-events var ( ErrEmptyPage = errors.New("empty page") - messageEventsPathFormat = "%s/api/v%d/message-events" - messageEventsSamplesPathFormat = "%s/api/v%d/message-events/events/samples" + MessageEventsPathFormat = "/api/v%d/message-events" + MessageEventsSamplesPathFormat = "/api/v%d/message-events/events/samples" ) type EventsPage struct { @@ -30,10 +30,11 @@ type EventsPage struct { } // https://developers.sparkpost.com/api/#/reference/message-events/events-samples/search-for-message-events -func (c *Client) MessageEvents(params map[string]string) (*EventsPage, error) { - url, err := url.Parse(fmt.Sprintf(messageEventsPathFormat, c.Config.BaseUrl, c.Config.ApiVersion)) +func (c *Client) MessageEvents(params map[string]string) (*EventsPage, *Response, error) { + path := fmt.Sprintf(MessageEventsPathFormat, c.Config.ApiVersion) + url, err := url.Parse(c.Config.BaseUrl + path) if err != nil { - return nil, err + return nil, nil, err } if len(params) > 0 { @@ -47,29 +48,29 @@ func (c *Client) MessageEvents(params map[string]string) (*EventsPage, error) { // Send off our request res, err := c.HttpGet(context.TODO(), url.String()) if err != nil { - return nil, err + return nil, res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return nil, res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return nil, res, err } var eventsPage EventsPage err = json.Unmarshal(bodyBytes, &eventsPage) if err != nil { - return nil, err + return nil, res, err } eventsPage.client = c - return &eventsPage, nil + return &eventsPage, res, nil } func (events *EventsPage) Next() (*EventsPage, error) { @@ -148,7 +149,8 @@ func (ep *EventsPage) UnmarshalJSON(data []byte) error { // Samples requests a list of example event data. func (c *Client) EventSamples(types *[]string) (*events.Events, error) { - url, err := url.Parse(fmt.Sprintf(messageEventsSamplesPathFormat, c.Config.BaseUrl, c.Config.ApiVersion)) + path := fmt.Sprintf(MessageEventsSamplesPathFormat, c.Config.ApiVersion) + url, err := url.Parse(c.Config.BaseUrl + path) if err != nil { return nil, err } diff --git a/message_events_test.go b/message_events_test.go index f910d6c..086d5d0 100644 --- a/message_events_test.go +++ b/message_events_test.go @@ -2,6 +2,7 @@ package gosparkpost_test import ( "fmt" + "net/http" "testing" sp "github.com/SparkPost/gosparkpost" @@ -9,6 +10,33 @@ import ( "github.com/SparkPost/gosparkpost/test" ) +var msgEventsEmpty string = `{ + "links": [], + "results": [], + "total_count": 0 +}` + +func TestMsgEvents_Get_Empty(t *testing.T) { + testSetup(t) + defer testTeardown() + + path := fmt.Sprintf(sp.MessageEventsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(msgEventsEmpty)) + }) + + _, res, err := testClient.MessageEvents(map[string]string{ + "from": "1970-01-01T00:00", + "events": "injection", + }) + if err != nil { + testFailVerbose(t, res, "Message Events GET returned error: %v", err) + } +} + func TestMessageEvents(t *testing.T) { if true { // Temporarily disable test so TravisCI reports build success instead of test failure. @@ -36,7 +64,7 @@ func TestMessageEvents(t *testing.T) { params := map[string]string{ "per_page": "10", } - eventsPage, err := client.MessageEvents(params) + eventsPage, _, err := client.MessageEvents(params) if err != nil { t.Error(err) return From 24ff064d0d36c76152c1ed785348f26b2294de58 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 20 Jan 2017 15:43:46 -0700 Subject: [PATCH 051/152] assert that we have the expected number of errors --- del_metrics_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/del_metrics_test.go b/del_metrics_test.go index 10b3253..7065390 100644 --- a/del_metrics_test.go +++ b/del_metrics_test.go @@ -55,4 +55,8 @@ func TestMetrics_Get_noArgsError(t *testing.T) { if err != nil { testFailVerbose(t, res, "Metrics GET returned error: %+v", err) } + + if len(m.Errors) != 3 { + testFailVerbose(t, res, "Expected 3 errors, got %d", len(m.Errors)) + } } From e7b25587d764f35f366a7db673edf05421087e3f Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 20 Jan 2017 15:45:48 -0700 Subject: [PATCH 052/152] consistently return `*Response` from message events and webhooks calls; remove `net/url` aliasing --- message_events.go | 30 +++++++++--------- message_events_test.go | 6 ++-- webhooks.go | 71 ++++++++++++++++++------------------------ 3 files changed, 48 insertions(+), 59 deletions(-) diff --git a/message_events.go b/message_events.go index b557d02..4899788 100644 --- a/message_events.go +++ b/message_events.go @@ -73,37 +73,37 @@ func (c *Client) MessageEvents(params map[string]string) (*EventsPage, *Response return &eventsPage, res, nil } -func (events *EventsPage) Next() (*EventsPage, error) { +func (events *EventsPage) Next() (*EventsPage, *Response, error) { if events.nextPage == "" { - return nil, ErrEmptyPage + return nil, nil, ErrEmptyPage } // Send off our request res, err := events.client.HttpGet(context.TODO(), events.client.Config.BaseUrl+events.nextPage) if err != nil { - return nil, err + return nil, res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return nil, res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return nil, res, err } var eventsPage EventsPage err = json.Unmarshal(bodyBytes, &eventsPage) if err != nil { - return nil, err + return nil, res, err } eventsPage.client = events.client - return &eventsPage, nil + return &eventsPage, res, nil } func (ep *EventsPage) UnmarshalJSON(data []byte) error { @@ -148,11 +148,11 @@ func (ep *EventsPage) UnmarshalJSON(data []byte) error { } // Samples requests a list of example event data. -func (c *Client) EventSamples(types *[]string) (*events.Events, error) { +func (c *Client) EventSamples(types *[]string) (*events.Events, *Response, error) { path := fmt.Sprintf(MessageEventsSamplesPathFormat, c.Config.ApiVersion) url, err := url.Parse(c.Config.BaseUrl + path) if err != nil { - return nil, err + return nil, nil, err } // Filter out types. @@ -160,7 +160,7 @@ func (c *Client) EventSamples(types *[]string) (*events.Events, error) { // validate types for _, etype := range *types { if !events.ValidEventType(etype) { - return nil, fmt.Errorf("Invalid event type [%s]", etype) + return nil, nil, fmt.Errorf("Invalid event type [%s]", etype) } } @@ -174,27 +174,27 @@ func (c *Client) EventSamples(types *[]string) (*events.Events, error) { // Send off our request res, err := c.HttpGet(context.TODO(), url.String()) if err != nil { - return nil, err + return nil, res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return nil, res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return nil, res, err } var events events.Events err = json.Unmarshal(bodyBytes, &events) if err != nil { - return nil, err + return nil, res, err } - return &events, nil + return &events, res, nil } // ParseEvents function is left only for backward-compatibility. Events are parsed by events pkg. diff --git a/message_events_test.go b/message_events_test.go index 086d5d0..992d9eb 100644 --- a/message_events_test.go +++ b/message_events_test.go @@ -97,7 +97,7 @@ func TestMessageEvents(t *testing.T) { } } - eventsPage, err = eventsPage.Next() + eventsPage, _, err = eventsPage.Next() if err != nil && err != sp.ErrEmptyPage { t.Error(err) } else { @@ -131,7 +131,7 @@ func TestAllEventsSamples(t *testing.T) { return } - e, err := client.EventSamples(nil) + e, _, err := client.EventSamples(nil) if err != nil { t.Error(err) return @@ -190,7 +190,7 @@ func TestFilteredEventsSamples(t *testing.T) { } types := []string{"open", "click", "bounce"} - e, err := client.EventSamples(&types) + e, _, err := client.EventSamples(&types) if err != nil { t.Error(err) return diff --git a/webhooks.go b/webhooks.go index 96f2d73..b33cbea 100644 --- a/webhooks.go +++ b/webhooks.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" - URL "net/url" + "net/url" ) // https://www.sparkpost.com/api#/reference/message-events @@ -71,60 +71,49 @@ type WebhookStatusWrapper struct { //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} } -func buildUrl(c *Client, url string, parameters map[string]string) string { - +func buildUrl(c *Client, path string, parameters map[string]string) string { if parameters == nil || len(parameters) == 0 { - url = fmt.Sprintf("%s%s", c.Config.BaseUrl, url) + path = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { - params := URL.Values{} + params := url.Values{} for k, v := range parameters { params.Add(k, v) } - url = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, url, params.Encode()) + path = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, params.Encode()) } - return url + return path } // https://developers.sparkpost.com/api/#/reference/webhooks/batch-status/retrieve-status-information -func (c *Client) WebhookStatus(id string, parameters map[string]string) (*WebhookStatusWrapper, error) { - - var finalUrl string +func (c *Client) WebhookStatus(id string, parameters map[string]string) (*WebhookStatusWrapper, *Response, error) { path := fmt.Sprintf(webhookStatusPathFormat, c.Config.ApiVersion, id) - - finalUrl = buildUrl(c, path, parameters) + finalUrl := buildUrl(c, path, parameters) return doWebhookStatusRequest(c, finalUrl) } // https://developers.sparkpost.com/api/#/reference/webhooks/retrieve/retrieve-webhook-details -func (c *Client) QueryWebhook(id string, parameters map[string]string) (*WebhookQueryWrapper, error) { - - var finalUrl string +func (c *Client) QueryWebhook(id string, parameters map[string]string) (*WebhookQueryWrapper, *Response, error) { path := fmt.Sprintf(webhookQueryPathFormat, c.Config.ApiVersion, id) - - finalUrl = buildUrl(c, path, parameters) + finalUrl := buildUrl(c, path, parameters) return doWebhooksQueryRequest(c, finalUrl) } // https://developers.sparkpost.com/api/#/reference/webhooks/list/list-all-webhooks -func (c *Client) ListWebhooks(parameters map[string]string) (*WebhookListWrapper, error) { - - var finalUrl string +func (c *Client) ListWebhooks(parameters map[string]string) (*WebhookListWrapper, *Response, error) { path := fmt.Sprintf(webhookListPathFormat, c.Config.ApiVersion) - - finalUrl = buildUrl(c, path, parameters) + finalUrl := buildUrl(c, path, parameters) return doWebhooksListRequest(c, finalUrl) } -func doWebhooksListRequest(c *Client, finalUrl string) (*WebhookListWrapper, error) { - - bodyBytes, err := doRequest(c, finalUrl) +func doWebhooksListRequest(c *Client, finalUrl string) (*WebhookListWrapper, *Response, error) { + bodyBytes, res, err := doRequest(c, finalUrl) if err != nil { - return nil, err + return nil, res, err } // Parse expected response structure @@ -132,57 +121,57 @@ func doWebhooksListRequest(c *Client, finalUrl string) (*WebhookListWrapper, err err = json.Unmarshal(bodyBytes, &resMap) if err != nil { - return nil, err + return nil, res, err } - return &resMap, err + return &resMap, res, err } -func doWebhooksQueryRequest(c *Client, finalUrl string) (*WebhookQueryWrapper, error) { - bodyBytes, err := doRequest(c, finalUrl) +func doWebhooksQueryRequest(c *Client, finalUrl string) (*WebhookQueryWrapper, *Response, error) { + bodyBytes, res, err := doRequest(c, finalUrl) // Parse expected response structure var resMap WebhookQueryWrapper err = json.Unmarshal(bodyBytes, &resMap) if err != nil { - return nil, err + return nil, res, err } - return &resMap, err + return &resMap, res, err } -func doWebhookStatusRequest(c *Client, finalUrl string) (*WebhookStatusWrapper, error) { - bodyBytes, err := doRequest(c, finalUrl) +func doWebhookStatusRequest(c *Client, finalUrl string) (*WebhookStatusWrapper, *Response, error) { + bodyBytes, res, err := doRequest(c, finalUrl) // Parse expected response structure var resMap WebhookStatusWrapper err = json.Unmarshal(bodyBytes, &resMap) if err != nil { - return nil, err + return nil, res, err } - return &resMap, err + return &resMap, res, err } -func doRequest(c *Client, finalUrl string) ([]byte, error) { +func doRequest(c *Client, finalUrl string) ([]byte, *Response, error) { // Send off our request res, err := c.HttpGet(context.TODO(), finalUrl) if err != nil { - return nil, err + return nil, res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return nil, res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return nil, res, err } - return bodyBytes, err + return bodyBytes, res, err } From 3dd37b333a9fbedd2dbec21168f59a8fa0ae75ae Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 20 Jan 2017 16:39:14 -0700 Subject: [PATCH 053/152] move params and context into `EventPage`; pass context through to http helpers; remove named error for empty page; capture errors when unmarshaling json into custom type; update tests to match new calling conventions --- message_events.go | 60 ++++++++++++++++++++++-------------------- message_events_test.go | 23 ++++++++-------- 2 files changed, 44 insertions(+), 39 deletions(-) diff --git a/message_events.go b/message_events.go index 4899788..2921e1a 100644 --- a/message_events.go +++ b/message_events.go @@ -3,7 +3,6 @@ package gosparkpost import ( "context" "encoding/json" - "errors" "fmt" "net/url" "strings" @@ -13,7 +12,6 @@ import ( // https://www.sparkpost.com/api#/reference/message-events var ( - ErrEmptyPage = errors.New("empty page") MessageEventsPathFormat = "/api/v%d/message-events" MessageEventsSamplesPathFormat = "/api/v%d/message-events/events/samples" ) @@ -23,63 +21,67 @@ type EventsPage struct { Events events.Events TotalCount int - nextPage string - prevPage string - firstPage string - lastPage string + Errors []interface{} + + NextPage string + PrevPage string + FirstPage string + LastPage string + + Params map[string]string `json:"-"` + Context context.Context `json:"-"` } // https://developers.sparkpost.com/api/#/reference/message-events/events-samples/search-for-message-events -func (c *Client) MessageEvents(params map[string]string) (*EventsPage, *Response, error) { +func (c *Client) MessageEventsSearch(ep *EventsPage) (*Response, error) { path := fmt.Sprintf(MessageEventsPathFormat, c.Config.ApiVersion) url, err := url.Parse(c.Config.BaseUrl + path) if err != nil { - return nil, nil, err + return nil, err } - if len(params) > 0 { + if len(ep.Params) > 0 { q := url.Query() - for k, v := range params { + for k, v := range ep.Params { q.Add(k, v) } url.RawQuery = q.Encode() } // Send off our request - res, err := c.HttpGet(context.TODO(), url.String()) + res, err := c.HttpGet(ep.Context, url.String()) if err != nil { - return nil, res, err + return res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, res, err + return res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, res, err + return res, err } - var eventsPage EventsPage - err = json.Unmarshal(bodyBytes, &eventsPage) + err = json.Unmarshal(bodyBytes, ep) if err != nil { - return nil, res, err + return res, err } - eventsPage.client = c + ep.client = c - return &eventsPage, res, nil + return res, nil } -func (events *EventsPage) Next() (*EventsPage, *Response, error) { - if events.nextPage == "" { - return nil, nil, ErrEmptyPage +func (ep *EventsPage) Next() (*EventsPage, *Response, error) { + if ep.NextPage == "" { + return nil, nil, nil } // Send off our request - res, err := events.client.HttpGet(context.TODO(), events.client.Config.BaseUrl+events.nextPage) + res, err := ep.client.HttpGet(ep.Context, ep.client.Config.BaseUrl+ep.NextPage) if err != nil { return nil, res, err } @@ -101,7 +103,7 @@ func (events *EventsPage) Next() (*EventsPage, *Response, error) { return nil, res, err } - eventsPage.client = events.client + eventsPage.client = ep.client return &eventsPage, res, nil } @@ -118,6 +120,7 @@ func (ep *EventsPage) UnmarshalJSON(data []byte) error { Href string `json:"href"` Rel string `json:"rel"` } `json:"links,omitempty"` + Errors []interface{} `json:"errors,omitempty"` } err := json.Unmarshal(data, &resultsWrapper) if err != nil { @@ -129,18 +132,19 @@ func (ep *EventsPage) UnmarshalJSON(data []byte) error { return err } + ep.Errors = resultsWrapper.Errors ep.TotalCount = resultsWrapper.TotalCount for _, link := range resultsWrapper.Links { switch link.Rel { case "next": - ep.nextPage = link.Href + ep.NextPage = link.Href case "previous": - ep.prevPage = link.Href + ep.PrevPage = link.Href case "first": - ep.firstPage = link.Href + ep.FirstPage = link.Href case "last": - ep.lastPage = link.Href + ep.LastPage = link.Href } } diff --git a/message_events_test.go b/message_events_test.go index 992d9eb..d489180 100644 --- a/message_events_test.go +++ b/message_events_test.go @@ -28,10 +28,11 @@ func TestMsgEvents_Get_Empty(t *testing.T) { w.Write([]byte(msgEventsEmpty)) }) - _, res, err := testClient.MessageEvents(map[string]string{ + ep := &sp.EventsPage{Params: map[string]string{ "from": "1970-01-01T00:00", "events": "injection", - }) + }} + res, err := testClient.MessageEventsSearch(ep) if err != nil { testFailVerbose(t, res, "Message Events GET returned error: %v", err) } @@ -61,20 +62,20 @@ func TestMessageEvents(t *testing.T) { return } - params := map[string]string{ + ep := &sp.EventsPage{Params: map[string]string{ "per_page": "10", - } - eventsPage, _, err := client.MessageEvents(params) + }} + _, err = client.MessageEventsSearch(ep) if err != nil { t.Error(err) return } - if len(eventsPage.Events) == 0 { + if len(ep.Events) == 0 { t.Error("expected non-empty result") } - for _, ev := range eventsPage.Events { + for _, ev := range ep.Events { switch event := ev.(type) { case *events.Click, *events.Open, *events.GenerationFailure, *events.GenerationRejection, *events.ListUnsubscribe, *events.LinkUnsubscribe, *events.PolicyRejection, @@ -97,11 +98,11 @@ func TestMessageEvents(t *testing.T) { } } - eventsPage, _, err = eventsPage.Next() - if err != nil && err != sp.ErrEmptyPage { + ep, _, err = ep.Next() + if err != nil { t.Error(err) - } else { - if len(eventsPage.Events) == 0 { + } else if ep != nil { + if len(ep.Events) == 0 { t.Error("expected non-empty result") } } From 82cebdb4900d29d2838342850764625e97e9ed07 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 20 Jan 2017 17:05:01 -0700 Subject: [PATCH 054/152] refactor webhooks to pass along context --- webhooks.go | 103 ++++++++++++++++++++++++---------------------------- 1 file changed, 48 insertions(+), 55 deletions(-) diff --git a/webhooks.go b/webhooks.go index b33cbea..6823662 100644 --- a/webhooks.go +++ b/webhooks.go @@ -9,9 +9,9 @@ import ( ) // https://www.sparkpost.com/api#/reference/message-events -var webhookListPathFormat = "/api/v%d/webhooks" -var webhookQueryPathFormat = "/api/v%d/webhooks/%s" -var webhookStatusPathFormat = "/api/v%d/webhooks/%s/batch-status" +var WebhookListPathFormat = "/api/v%d/webhooks" +var WebhookQueryPathFormat = "/api/v%d/webhooks/%s" +var WebhookStatusPathFormat = "/api/v%d/webhooks/%s/batch-status" type WebhookItem struct { ID string `json:"id,omitempty"` @@ -48,26 +48,34 @@ type WebhookItem struct { type WebhookStatus struct { BatchID string `json:"batch_id,omitempty"` - Ts string `json:"ts,omitempty"` + Timestamp string `json:"ts,omitempty"` Attempts int `json:"attempts,omitempty"` ResponseCode string `json:"response_code,omitempty"` } +type WebhookCommon struct { + Errors []interface{} `json:"errors,omitempty"` + Params map[string]string `json:"-"` + Context context.Context `json:"-"` +} + type WebhookListWrapper struct { Results []*WebhookItem `json:"results,omitempty"` - Errors []interface{} `json:"errors,omitempty"` + WebhookCommon //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} } type WebhookQueryWrapper struct { - Results *WebhookItem `json:"results,omitempty"` - Errors []interface{} `json:"errors,omitempty"` + ID string `json:"-"` + Results *WebhookItem `json:"results,omitempty"` + WebhookCommon //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} } type WebhookStatusWrapper struct { + ID string `json:"-"` Results []*WebhookStatus `json:"results,omitempty"` - Errors []interface{} `json:"errors,omitempty"` + WebhookCommon //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} } @@ -87,77 +95,62 @@ func buildUrl(c *Client, path string, parameters map[string]string) string { } // https://developers.sparkpost.com/api/#/reference/webhooks/batch-status/retrieve-status-information -func (c *Client) WebhookStatus(id string, parameters map[string]string) (*WebhookStatusWrapper, *Response, error) { - path := fmt.Sprintf(webhookStatusPathFormat, c.Config.ApiVersion, id) - finalUrl := buildUrl(c, path, parameters) - - return doWebhookStatusRequest(c, finalUrl) -} - -// https://developers.sparkpost.com/api/#/reference/webhooks/retrieve/retrieve-webhook-details -func (c *Client) QueryWebhook(id string, parameters map[string]string) (*WebhookQueryWrapper, *Response, error) { - path := fmt.Sprintf(webhookQueryPathFormat, c.Config.ApiVersion, id) - finalUrl := buildUrl(c, path, parameters) - - return doWebhooksQueryRequest(c, finalUrl) -} - -// https://developers.sparkpost.com/api/#/reference/webhooks/list/list-all-webhooks -func (c *Client) ListWebhooks(parameters map[string]string) (*WebhookListWrapper, *Response, error) { - path := fmt.Sprintf(webhookListPathFormat, c.Config.ApiVersion) - finalUrl := buildUrl(c, path, parameters) - - return doWebhooksListRequest(c, finalUrl) -} +func (c *Client) WebhookStatus(s *WebhookStatusWrapper) (*Response, error) { + path := fmt.Sprintf(WebhookStatusPathFormat, c.Config.ApiVersion, s.ID) + finalUrl := buildUrl(c, path, s.Params) -func doWebhooksListRequest(c *Client, finalUrl string) (*WebhookListWrapper, *Response, error) { - bodyBytes, res, err := doRequest(c, finalUrl) + bodyBytes, res, err := doRequest(c, finalUrl, s.Context) if err != nil { - return nil, res, err + return res, err } - // Parse expected response structure - var resMap WebhookListWrapper - err = json.Unmarshal(bodyBytes, &resMap) - + err = json.Unmarshal(bodyBytes, s) if err != nil { - return nil, res, err + return res, err } - return &resMap, res, err + return res, err } -func doWebhooksQueryRequest(c *Client, finalUrl string) (*WebhookQueryWrapper, *Response, error) { - bodyBytes, res, err := doRequest(c, finalUrl) +// https://developers.sparkpost.com/api/#/reference/webhooks/retrieve/retrieve-webhook-details +func (c *Client) QueryWebhook(q *WebhookQueryWrapper) (*Response, error) { + path := fmt.Sprintf(WebhookQueryPathFormat, c.Config.ApiVersion, q.ID) + finalUrl := buildUrl(c, path, q.Params) - // Parse expected response structure - var resMap WebhookQueryWrapper - err = json.Unmarshal(bodyBytes, &resMap) + bodyBytes, res, err := doRequest(c, finalUrl, q.Context) + if err != nil { + return res, err + } + err = json.Unmarshal(bodyBytes, q) if err != nil { - return nil, res, err + return res, err } - return &resMap, res, err + return res, err } -func doWebhookStatusRequest(c *Client, finalUrl string) (*WebhookStatusWrapper, *Response, error) { - bodyBytes, res, err := doRequest(c, finalUrl) +// https://developers.sparkpost.com/api/#/reference/webhooks/list/list-all-webhooks +func (c *Client) ListWebhooks(l *WebhookListWrapper) (*Response, error) { + path := fmt.Sprintf(WebhookListPathFormat, c.Config.ApiVersion) + finalUrl := buildUrl(c, path, l.Params) - // Parse expected response structure - var resMap WebhookStatusWrapper - err = json.Unmarshal(bodyBytes, &resMap) + bodyBytes, res, err := doRequest(c, finalUrl, l.Context) + if err != nil { + return res, err + } + err = json.Unmarshal(bodyBytes, l) if err != nil { - return nil, res, err + return res, err } - return &resMap, res, err + return res, err } -func doRequest(c *Client, finalUrl string) ([]byte, *Response, error) { +func doRequest(c *Client, finalUrl string, ctx context.Context) ([]byte, *Response, error) { // Send off our request - res, err := c.HttpGet(context.TODO(), finalUrl) + res, err := c.HttpGet(ctx, finalUrl) if err != nil { return nil, res, err } From b62e45dcf33aaae07bf870436ee4ed8fe22047bc Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 13 Feb 2017 09:07:29 -0700 Subject: [PATCH 055/152] change sandbox to boolean --- transmissions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transmissions.go b/transmissions.go index 87615cf..0c0017c 100644 --- a/transmissions.go +++ b/transmissions.go @@ -46,7 +46,7 @@ type TxOptions struct { TmplOptions StartTime *RFC3339 `json:"start_time,omitempty"` - Sandbox string `json:"sandbox,omitempty"` + Sandbox bool `json:"sandbox,omitempty"` SkipSuppression string `json:"skip_suppression,omitempty"` InlineCSS bool `json:"inline_css,omitempty"` } From e730c4b5241a699cd1209e9b3b99d05f5423e1fe Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 17 Feb 2017 15:51:28 -0700 Subject: [PATCH 056/152] finish up context work for event docs endpoint --- event_docs.go | 31 ++++++++++++++++++------------- event_docs_test.go | 5 ++++- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/event_docs.go b/event_docs.go index 5102a7b..1faf886 100644 --- a/event_docs.go +++ b/event_docs.go @@ -10,6 +10,12 @@ import ( var EventDocumentationFormat = "/api/v%d/webhooks/events/documentation" +type EventGroups struct { + Groups map[string]*EventGroup `json:"groups"` + + Context context.Context `json:"-"` +} + type EventGroup struct { Name string Events map[string]EventMeta `json:"events"` @@ -29,15 +35,15 @@ type EventField struct { SampleValue interface{} `json:"sampleValue"` } -func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, err error) { +func (c *Client) EventDocumentation(eg *EventGroups) (res *Response, err error) { path := fmt.Sprintf(EventDocumentationFormat, c.Config.ApiVersion) res, err = c.HttpGet(context.TODO(), c.Config.BaseUrl+path) if err != nil { - return nil, nil, err + return nil, err } if err = res.AssertJson(); err != nil { - return nil, res, err + return res, err } if res.HTTP.StatusCode == 200 { @@ -45,30 +51,29 @@ func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, var ok bool body, err = res.ReadBody() if err != nil { - return nil, res, err + return res, err } var results map[string]map[string]*EventGroup - var groups map[string]*EventGroup if err = json.Unmarshal(body, &results); err != nil { - return nil, res, err - } else if groups, ok = results["results"]; ok { - return groups, res, err + return res, err + } else if eg.Groups, ok = results["results"]; ok { + return res, err } - return nil, res, errors.New("Unexpected response format") + return res, errors.New("Unexpected response format") } else { err = res.ParseResponse() if err != nil { - return nil, res, err + return res, err } if len(res.Errors) > 0 { err = res.PrettyError("EventDocumentation", "retrieve") if err != nil { - return nil, res, err + return res, err } } - return nil, res, errors.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + return res, errors.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } - return nil, res, err + return res, err } diff --git a/event_docs_test.go b/event_docs_test.go index fc347d9..c363c98 100644 --- a/event_docs_test.go +++ b/event_docs_test.go @@ -1,6 +1,7 @@ package gosparkpost_test import ( + "context" "fmt" "io/ioutil" "net/http" @@ -66,7 +67,8 @@ func TestEventDocs_Get_parse(t *testing.T) { }) // hit our local handler - groups, res, err := testClient.EventDocumentation() + egroups := &sp.EventGroups{Context: context.Background()} + res, err := testClient.EventDocumentation(egroups) if err != nil { t.Errorf("EventDocumentation GET returned error: %v", err) for _, e := range res.Verbose { @@ -74,6 +76,7 @@ func TestEventDocs_Get_parse(t *testing.T) { } return } + groups := egroups.Groups // basic content test if len(groups) == 0 { From bc3a6aa8d847a7a242161de33c2f56bab9d62852 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 17 Feb 2017 16:09:49 -0700 Subject: [PATCH 057/152] refactor so context is a separate parameter --- transmissions.go | 38 ++++++++++++++++++++++++++++---------- transmissions_test.go | 8 +++----- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/transmissions.go b/transmissions.go index 8a03b77..cdf3971 100644 --- a/transmissions.go +++ b/transmissions.go @@ -30,8 +30,6 @@ type Transmission struct { NumGenerated *int `json:"num_generated,omitempty"` NumFailedGeneration *int `json:"num_failed_generation,omitempty"` NumInvalidRecipients *int `json:"num_invalid_recipients,omitempty"` - - Context context.Context `json:"-"` } type RFC3339 time.Time @@ -196,10 +194,15 @@ func (t *Transmission) Validate() error { return nil } -// Create accepts a populated Transmission object, performs basic sanity +// Send accepts a populated Transmission object, performs basic sanity // checks on it, and performs an API call against the configured endpoint. // Calling this function can cause email to be sent, if used correctly. func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { + return c.SendContext(context.Background(), t) +} + +// SendContext does the same thing as Send, and in addition it accepts a context from the caller. +func (c *Client) SendContext(ctx context.Context, t *Transmission) (id string, res *Response, err error) { if t == nil { err = fmt.Errorf("Create called with nil Transmission") return @@ -217,7 +220,7 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(t.Context, u, jsonBytes) + res, err = c.HttpPost(ctx, u, jsonBytes) if err != nil { return } @@ -255,14 +258,19 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { return } -// Retrieve accepts a Transmission, looks up the record using its ID, and fills out the provided object. +// Transmission accepts a Transmission, looks up the record using its ID, and fills out the provided object. func (c *Client) Transmission(t *Transmission) (*Response, error) { + return c.TransmissionContext(context.Background(), t) +} + +// TransmissionContext is the same as Transmission, and it allows the caller to pass in a context. +func (c *Client) TransmissionContext(ctx context.Context, t *Transmission) (*Response, error) { if nonDigit.MatchString(t.ID) { return nil, fmt.Errorf("id may only contain digits") } path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) - res, err := c.HttpGet(t.Context, u) + res, err := c.HttpGet(ctx, u) if err != nil { return nil, err } @@ -311,9 +319,14 @@ func (c *Client) Transmission(t *Transmission) (*Response, error) { return res, err } -// Delete attempts to remove the Transmission with the specified id. +// TransmissionDelete attempts to remove the Transmission with the specified id. // Only Transmissions which are scheduled for future generation may be deleted. func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { + return c.TransmissionDeleteContext(context.Background(), t) +} + +// TransmissionDeleteContext is the same as TransmissionDelete, and it allows the caller to provide a context. +func (c *Client) TransmissionDeleteContext(ctx context.Context, t *Transmission) (*Response, error) { if t == nil { return nil, nil } @@ -326,7 +339,7 @@ func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) - res, err := c.HttpDelete(t.Context, u) + res, err := c.HttpDelete(ctx, u) if err != nil { return nil, err } @@ -355,9 +368,14 @@ func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { return res, nil } -// List returns Transmission summary information for matching Transmissions. +// Transmissions returns Transmission summary information for matching Transmissions. // Filtering by CampaignID (t.CampaignID) and TemplateID (t.ID) is supported. func (c *Client) Transmissions(t *Transmission) ([]Transmission, *Response, error) { + return c.TransmissionsContext(context.Background(), t) +} + +// TransmissionsContext is the same as Transmissions, and it allows the caller to provide a context. +func (c *Client) TransmissionsContext(ctx context.Context, t *Transmission) ([]Transmission, *Response, error) { // If a query parameter is present and empty, that searches for blank IDs, as opposed // to when it is omitted entirely, which returns everything. qp := make([]string, 0, 2) @@ -375,7 +393,7 @@ func (c *Client) Transmissions(t *Transmission) ([]Transmission, *Response, erro path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, qstr) - res, err := c.HttpGet(t.Context, u) + res, err := c.HttpGet(ctx, u) if err != nil { return nil, nil, err } diff --git a/transmissions_test.go b/transmissions_test.go index 0fe8cfd..b2d3b4a 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -67,11 +67,9 @@ func TestTransmissions_Delete_Headers(t *testing.T) { header := http.Header{} header.Add("X-Foo", "bar") - tx := &sp.Transmission{ - ID: "42", - Context: context.WithValue(context.Background(), "http.Header", header), - } - res, err := testClient.TransmissionDelete(tx) + ctx := context.WithValue(context.Background(), "http.Header", header) + tx := &sp.Transmission{ID: "42"} + res, err := testClient.TransmissionDeleteContext(ctx, tx) if err != nil { testFailVerbose(t, res, "Transmission DELETE failed") } From 32ec7649fa0fb1e3ce9a61973f1e69795be73f35 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 17 Feb 2017 16:15:08 -0700 Subject: [PATCH 058/152] webhooks: break context out into its own param --- webhooks.go | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/webhooks.go b/webhooks.go index 6823662..5be984f 100644 --- a/webhooks.go +++ b/webhooks.go @@ -54,29 +54,25 @@ type WebhookStatus struct { } type WebhookCommon struct { - Errors []interface{} `json:"errors,omitempty"` - Params map[string]string `json:"-"` - Context context.Context `json:"-"` + Errors []interface{} `json:"errors,omitempty"` + Params map[string]string `json:"-"` } type WebhookListWrapper struct { Results []*WebhookItem `json:"results,omitempty"` WebhookCommon - //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} } type WebhookQueryWrapper struct { ID string `json:"-"` Results *WebhookItem `json:"results,omitempty"` WebhookCommon - //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} } type WebhookStatusWrapper struct { ID string `json:"-"` Results []*WebhookStatus `json:"results,omitempty"` WebhookCommon - //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} } func buildUrl(c *Client, path string, parameters map[string]string) string { @@ -96,10 +92,14 @@ func buildUrl(c *Client, path string, parameters map[string]string) string { // https://developers.sparkpost.com/api/#/reference/webhooks/batch-status/retrieve-status-information func (c *Client) WebhookStatus(s *WebhookStatusWrapper) (*Response, error) { + return c.WebhookStatusContext(context.Background(), s) +} + +func (c *Client) WebhookStatusContext(ctx context.Context, s *WebhookStatusWrapper) (*Response, error) { path := fmt.Sprintf(WebhookStatusPathFormat, c.Config.ApiVersion, s.ID) finalUrl := buildUrl(c, path, s.Params) - bodyBytes, res, err := doRequest(c, finalUrl, s.Context) + bodyBytes, res, err := doRequest(c, finalUrl, ctx) if err != nil { return res, err } @@ -114,10 +114,14 @@ func (c *Client) WebhookStatus(s *WebhookStatusWrapper) (*Response, error) { // https://developers.sparkpost.com/api/#/reference/webhooks/retrieve/retrieve-webhook-details func (c *Client) QueryWebhook(q *WebhookQueryWrapper) (*Response, error) { + return c.QueryWebhookContext(context.Background(), q) +} + +func (c *Client) QueryWebhookContext(ctx context.Context, q *WebhookQueryWrapper) (*Response, error) { path := fmt.Sprintf(WebhookQueryPathFormat, c.Config.ApiVersion, q.ID) finalUrl := buildUrl(c, path, q.Params) - bodyBytes, res, err := doRequest(c, finalUrl, q.Context) + bodyBytes, res, err := doRequest(c, finalUrl, ctx) if err != nil { return res, err } @@ -131,11 +135,15 @@ func (c *Client) QueryWebhook(q *WebhookQueryWrapper) (*Response, error) { } // https://developers.sparkpost.com/api/#/reference/webhooks/list/list-all-webhooks -func (c *Client) ListWebhooks(l *WebhookListWrapper) (*Response, error) { +func (c *Client) Webhooks(l *WebhookListWrapper) (*Response, error) { + return c.WebhooksContext(context.Background(), l) +} + +func (c *Client) WebhooksContext(ctx context.Context, l *WebhookListWrapper) (*Response, error) { path := fmt.Sprintf(WebhookListPathFormat, c.Config.ApiVersion) finalUrl := buildUrl(c, path, l.Params) - bodyBytes, res, err := doRequest(c, finalUrl, l.Context) + bodyBytes, res, err := doRequest(c, finalUrl, ctx) if err != nil { return res, err } From f1e700a2ed1e2df84c79d851dc6c3d610f7e4d71 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 17 Feb 2017 16:23:35 -0700 Subject: [PATCH 059/152] templates: break context out into its own param --- templates.go | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/templates.go b/templates.go index 3df7d04..2be63c3 100644 --- a/templates.go +++ b/templates.go @@ -184,9 +184,14 @@ func (t *Template) SetHeaders(headers map[string]string) { t.Content.Headers = headers } -// Create accepts a populated Template object, validates its Contents, +// TemplateCreate accepts a populated Template object, validates its Contents, // and performs an API call against the configured endpoint. func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err error) { + return c.TemplateCreateContext(context.Background(), t) +} + +// TemplateCreateContext is the same as TemplateCreate, and it allows the caller to provide a context. +func (c *Client) TemplateCreateContext(ctx context.Context, t *Template) (id string, res *Response, err error) { if t == nil { err = fmt.Errorf("Create called with nil Template") return @@ -204,7 +209,7 @@ func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err erro path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(context.TODO(), url, jsonBytes) + res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { return } @@ -247,8 +252,13 @@ func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err erro return } -// Update updates a draft/published template with the specified id +// TemplateUpdate updates a draft/published template with the specified id func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { + return c.TemplateUpdateContext(context.Background(), t) +} + +// TemplateUpdateContext is the same as TemplateUpdate, and it allows the caller to provide a context +func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *Response, err error) { if t.ID == "" { err = fmt.Errorf("Update called with blank id") return @@ -267,7 +277,7 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, t.Published) - res, err = c.HttpPut(context.TODO(), url, jsonBytes) + res, err = c.HttpPut(ctx, url, jsonBytes) if err != nil { return } @@ -302,11 +312,16 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { return } -// List returns metadata for all Templates in the system. +// Templates returns metadata for all Templates in the system. func (c *Client) Templates() ([]Template, *Response, error) { + return c.TemplatesContext(context.Background()) +} + +// TemplatesContext is the same as Templates, and it allows the caller to provide a context +func (c *Client) TemplatesContext(ctx context.Context) ([]Template, *Response, error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(context.TODO(), url) + res, err := c.HttpGet(ctx, url) if err != nil { return nil, nil, err } @@ -346,8 +361,13 @@ func (c *Client) Templates() ([]Template, *Response, error) { return nil, res, err } -// Delete removes the Template with the specified id. +// TemplateDelete removes the Template with the specified id. func (c *Client) TemplateDelete(id string) (res *Response, err error) { + return c.TemplateDeleteContext(context.Background(), id) +} + +// TemplateDeleteContext is the same as TemplateDelete, and it allows the caller to provide a context +func (c *Client) TemplateDeleteContext(ctx context.Context, id string) (res *Response, err error) { if id == "" { err = fmt.Errorf("Delete called with blank id") return @@ -355,7 +375,7 @@ func (c *Client) TemplateDelete(id string) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err = c.HttpDelete(context.TODO(), url) + res, err = c.HttpDelete(ctx, url) if err != nil { return } @@ -390,7 +410,13 @@ func (c *Client) TemplateDelete(id string) (res *Response, err error) { return } +// TemplatePreview renders and returns the output of a template using the provided substitution data. func (c *Client) TemplatePreview(id string, payload *PreviewOptions) (res *Response, err error) { + return c.TemplatePreviewContext(context.Background(), id, payload) +} + +// TemplatePreviewContext is the same as TemplatePreview, and it allows the caller to provide a context +func (c *Client) TemplatePreviewContext(ctx context.Context, id string, payload *PreviewOptions) (res *Response, err error) { if id == "" { err = fmt.Errorf("Preview called with blank id") return @@ -407,7 +433,7 @@ func (c *Client) TemplatePreview(id string, payload *PreviewOptions) (res *Respo path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s/preview", c.Config.BaseUrl, path, id) - res, err = c.HttpPost(context.TODO(), url, jsonBytes) + res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { return } From 0ef5fb072d6741d1ca851c6990c4154ce67c568f Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 17 Feb 2017 16:43:28 -0700 Subject: [PATCH 060/152] suppression: break context out into its own param --- suppression_list.go | 57 ++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/suppression_list.go b/suppression_list.go index f2bca2c..42b2419 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -32,40 +32,56 @@ type SuppressionListWrapper struct { } func (c *Client) SuppressionList() (*SuppressionListWrapper, *Response, error) { + return c.SuppressionListContext(context.Background()) +} + +func (c *Client) SuppressionListContext(ctx context.Context) (*SuppressionListWrapper, *Response, error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - return suppressionGet(c, c.Config.BaseUrl+path) + return c.suppressionGet(ctx, c.Config.BaseUrl+path) } -func (c *Client) SuppressionRetrieve(recipientEmail string) (*SuppressionListWrapper, *Response, error) { +func (c *Client) SuppressionRetrieve(email string) (*SuppressionListWrapper, *Response, error) { + return c.SuppressionRetrieveContext(context.Background(), email) +} + +func (c *Client) SuppressionRetrieveContext(ctx context.Context, email string) (*SuppressionListWrapper, *Response, error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) + finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) + + return c.suppressionGet(ctx, finalUrl) +} - return suppressionGet(c, finalUrl) +func (c *Client) SuppressionSearch(params map[string]string) (*SuppressionListWrapper, *Response, error) { + return c.SuppressionSearchContext(context.Background(), params) } -func (c *Client) SuppressionSearch(parameters map[string]string) (*SuppressionListWrapper, *Response, error) { +func (c *Client) SuppressionSearchContext(ctx context.Context, params map[string]string) (*SuppressionListWrapper, *Response, error) { var finalUrl string path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - if parameters == nil || len(parameters) == 0 { + if params == nil || len(params) == 0 { finalUrl = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { - params := url.Values{} - for k, v := range parameters { - params.Add(k, v) + args := url.Values{} + for k, v := range params { + args.Add(k, v) } - finalUrl = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, params.Encode()) + finalUrl = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, args.Encode()) } - return suppressionGet(c, finalUrl) + return c.suppressionGet(ctx, finalUrl) +} + +func (c *Client) SuppressionDelete(email string) (res *Response, err error) { + return c.SuppressionDeleteContext(context.Background(), email) } -func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err error) { +func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (res *Response, err error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) + finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) - res, err = c.HttpDelete(context.TODO(), finalUrl) + res, err = c.HttpDelete(ctx, finalUrl) if err != nil { return res, err } @@ -88,21 +104,18 @@ func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err er func (c *Client) SuppressionInsertOrUpdate(entries []SuppressionEntry) (*Response, error) { if entries == nil { - return nil, fmt.Errorf("send `entries` cannot be nil here") + return nil, fmt.Errorf("`entries` cannot be nil") } path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) list := SuppressionListWrapper{nil, entries} - return suppressionPut(c, c.Config.BaseUrl+path, list) -} - -func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrapper) (*Response, error) { - jsonBytes, err := json.Marshal(recipients) + jsonBytes, err := json.Marshal(list) if err != nil { return nil, err } + finalUrl := c.Config.BaseUrl + path res, err := c.HttpPut(context.TODO(), finalUrl, jsonBytes) if err != nil { return res, err @@ -132,9 +145,9 @@ func suppressionPut(c *Client, finalUrl string, recipients SuppressionListWrappe return res, err } -func suppressionGet(c *Client, finalUrl string) (*SuppressionListWrapper, *Response, error) { +func (c *Client) suppressionGet(ctx context.Context, finalUrl string) (*SuppressionListWrapper, *Response, error) { // Send off our request - res, err := c.HttpGet(context.TODO(), finalUrl) + res, err := c.HttpGet(ctx, finalUrl) if err != nil { return nil, res, err } From 2848654f9dc64d7603867ac4d9617e2be69249a5 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 17 Feb 2017 16:48:31 -0700 Subject: [PATCH 061/152] subaccounts: break context out into its own param --- subaccounts.go | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/subaccounts.go b/subaccounts.go index 48362a2..6de967c 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -34,9 +34,14 @@ type Subaccount struct { ComplianceStatus string `json:"compliance_status,omitempty"` } -// Create accepts a populated Subaccount object, validates it, +// SubaccountCreate accepts a populated Subaccount object, validates it, // and performs an API call against the configured endpoint. func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { + return c.SubaccountCreateContext(context.Background(), s) +} + +// SubaccountCreateContext is the same as SubaccountCreate, and it allows the caller to pass in a context +func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (res *Response, err error) { // enforce required parameters if s == nil { err = fmt.Errorf("Create called with nil Subaccount") @@ -66,7 +71,7 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(context.TODO(), url, jsonBytes) + res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { return } @@ -114,10 +119,15 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { return } -// Update updates a subaccount with the specified id. +// SubaccountUpdate updates a subaccount with the specified id. // Actually it will marshal and send all the subaccount fields, but that must not be a problem, // as fields not supposed for update will be omitted func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { + return c.SubaccountUpdateContext(context.Background(), s) +} + +// SubaccountUpdateContext is the same as SubaccountUpdate, and it allows the caller to provide a context +func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (res *Response, err error) { if s.ID == 0 { err = fmt.Errorf("Subaccount Update called with zero id") } else if len(s.Name) > 1024 { @@ -146,7 +156,7 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, s.ID) - res, err = c.HttpPut(context.TODO(), url, jsonBytes) + res, err = c.HttpPut(ctx, url, jsonBytes) if err != nil { return } @@ -181,11 +191,16 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { return } -// List returns metadata for all Templates in the system. +// Subaccounts returns metadata for all Templates in the system. func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err error) { + return c.SubaccountsContext(context.Background()) +} + +// SubaccountsContext is the same as Subaccounts, and it allows the caller to provide a context +func (c *Client) SubaccountsContext(ctx context.Context) (subaccounts []Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpGet(context.TODO(), url) + res, err = c.HttpGet(ctx, url) if err != nil { return } @@ -230,10 +245,16 @@ func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err err return } +// Subaccount looks up a subaccount by its id func (c *Client) Subaccount(id int) (subaccount *Subaccount, res *Response, err error) { + return c.SubaccountContext(context.Background(), id) +} + +// SubaccountContext is the same as Subaccount, and it accepts a context.Context +func (c *Client) SubaccountContext(ctx context.Context, id int) (subaccount *Subaccount, res *Response, err error) { path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%d", c.Config.BaseUrl, path, id) - res, err = c.HttpGet(context.TODO(), u) + res, err = c.HttpGet(ctx, u) if err != nil { return } From f7d4b41a6c696a9c91bf00fbf235c4d8d667cc98 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 17 Feb 2017 16:50:51 -0700 Subject: [PATCH 062/152] recipient lists: break context out into its own param --- recipient_lists.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/recipient_lists.go b/recipient_lists.go index 7bf7e27..b65ee67 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -140,9 +140,14 @@ func (r Recipient) Validate() error { return nil } -// Create accepts a populated RecipientList object, validates it, +// RecipientListCreate accepts a populated RecipientList object, validates it, // and performs an API call against the configured endpoint. func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Response, err error) { + return c.RecipientListCreateContext(context.Background(), rl) +} + +// RecipientListCreateContext is the same as RecipientListCreate, and it accepts a context.Context +func (c *Client) RecipientListCreateContext(ctx context.Context, rl *RecipientList) (id string, res *Response, err error) { if rl == nil { err = fmt.Errorf("Create called with nil RecipientList") return @@ -160,7 +165,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(context.TODO(), url, jsonBytes) + res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { return } @@ -204,10 +209,16 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons return } +// RecipientLists returns all recipient lists func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { + return c.RecipientListsContext(context.Background()) +} + +// RecipientListsContext is the same as RecipientLists, and it accepts a context.Context +func (c *Client) RecipientListsContext(ctx context.Context) (*[]RecipientList, *Response, error) { path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(context.TODO(), url) + res, err := c.HttpGet(ctx, url) if err != nil { return nil, nil, err } From cd2de25e468bb955c157ebca527aca1e8931c2f8 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 17 Feb 2017 17:08:46 -0700 Subject: [PATCH 063/152] the rest of the "context in its own param" changes --- del_metrics.go | 11 +++++++---- event_docs.go | 37 ++++++++++++++++++------------------- event_docs_test.go | 5 +---- message_events.go | 27 +++++++++++++++++++++------ suppression_list.go | 10 ++++++++-- 5 files changed, 55 insertions(+), 35 deletions(-) diff --git a/del_metrics.go b/del_metrics.go index 798c6a0..bd258d0 100644 --- a/del_metrics.go +++ b/del_metrics.go @@ -59,11 +59,14 @@ type Metrics struct { ExtraPath string `json:"-"` Params map[string]string `json:"-"` - Context context.Context `json:"-"` } // https://developers.sparkpost.com/api/#/reference/metrics/deliverability-metrics-by-domain func (c *Client) QueryMetrics(m *Metrics) (*Response, error) { + return c.QueryMetricsContext(context.Background(), m) +} + +func (c *Client) QueryMetricsContext(ctx context.Context, m *Metrics) (*Response, error) { var finalUrl string path := fmt.Sprintf(MetricsPathFormat, c.Config.ApiVersion) @@ -82,12 +85,12 @@ func (c *Client) QueryMetrics(m *Metrics) (*Response, error) { finalUrl = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, params.Encode()) } - return m.doMetricsRequest(c, finalUrl) + return m.doMetricsRequest(ctx, c, finalUrl) } -func (m *Metrics) doMetricsRequest(c *Client, finalUrl string) (*Response, error) { +func (m *Metrics) doMetricsRequest(ctx context.Context, c *Client, finalUrl string) (*Response, error) { // Send off our request - res, err := c.HttpGet(m.Context, finalUrl) + res, err := c.HttpGet(ctx, finalUrl) if err != nil { return res, err } diff --git a/event_docs.go b/event_docs.go index 1faf886..b9efdda 100644 --- a/event_docs.go +++ b/event_docs.go @@ -10,12 +10,6 @@ import ( var EventDocumentationFormat = "/api/v%d/webhooks/events/documentation" -type EventGroups struct { - Groups map[string]*EventGroup `json:"groups"` - - Context context.Context `json:"-"` -} - type EventGroup struct { Name string Events map[string]EventMeta `json:"events"` @@ -35,15 +29,19 @@ type EventField struct { SampleValue interface{} `json:"sampleValue"` } -func (c *Client) EventDocumentation(eg *EventGroups) (res *Response, err error) { +func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, err error) { + return c.EventDocumentationContext(context.Background()) +} + +func (c *Client) EventDocumentationContext(ctx context.Context) (g map[string]*EventGroup, res *Response, err error) { path := fmt.Sprintf(EventDocumentationFormat, c.Config.ApiVersion) - res, err = c.HttpGet(context.TODO(), c.Config.BaseUrl+path) + res, err = c.HttpGet(ctx, c.Config.BaseUrl+path) if err != nil { - return nil, err + return nil, nil, err } if err = res.AssertJson(); err != nil { - return res, err + return nil, res, err } if res.HTTP.StatusCode == 200 { @@ -51,29 +49,30 @@ func (c *Client) EventDocumentation(eg *EventGroups) (res *Response, err error) var ok bool body, err = res.ReadBody() if err != nil { - return res, err + return nil, res, err } var results map[string]map[string]*EventGroup + var groups map[string]*EventGroup if err = json.Unmarshal(body, &results); err != nil { - return res, err - } else if eg.Groups, ok = results["results"]; ok { - return res, err + return nil, res, err + } else if groups, ok = results["results"]; ok { + return groups, res, err } - return res, errors.New("Unexpected response format") + return nil, res, errors.New("Unexpected response format") } else { err = res.ParseResponse() if err != nil { - return res, err + return nil, res, err } if len(res.Errors) > 0 { err = res.PrettyError("EventDocumentation", "retrieve") if err != nil { - return res, err + return nil, res, err } } - return res, errors.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + return nil, res, errors.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } - return res, err + return nil, res, err } diff --git a/event_docs_test.go b/event_docs_test.go index c363c98..fc347d9 100644 --- a/event_docs_test.go +++ b/event_docs_test.go @@ -1,7 +1,6 @@ package gosparkpost_test import ( - "context" "fmt" "io/ioutil" "net/http" @@ -67,8 +66,7 @@ func TestEventDocs_Get_parse(t *testing.T) { }) // hit our local handler - egroups := &sp.EventGroups{Context: context.Background()} - res, err := testClient.EventDocumentation(egroups) + groups, res, err := testClient.EventDocumentation() if err != nil { t.Errorf("EventDocumentation GET returned error: %v", err) for _, e := range res.Verbose { @@ -76,7 +74,6 @@ func TestEventDocs_Get_parse(t *testing.T) { } return } - groups := egroups.Groups // basic content test if len(groups) == 0 { diff --git a/message_events.go b/message_events.go index 2921e1a..47b5a43 100644 --- a/message_events.go +++ b/message_events.go @@ -28,12 +28,16 @@ type EventsPage struct { FirstPage string LastPage string - Params map[string]string `json:"-"` - Context context.Context `json:"-"` + Params map[string]string `json:"-"` } // https://developers.sparkpost.com/api/#/reference/message-events/events-samples/search-for-message-events func (c *Client) MessageEventsSearch(ep *EventsPage) (*Response, error) { + return c.MessageEventsSearchContext(context.Background(), ep) +} + +// MessageEventsSearchContext is the same as MessageEventsSearch, and it accepts a context.Context +func (c *Client) MessageEventsSearchContext(ctx context.Context, ep *EventsPage) (*Response, error) { path := fmt.Sprintf(MessageEventsPathFormat, c.Config.ApiVersion) url, err := url.Parse(c.Config.BaseUrl + path) if err != nil { @@ -49,7 +53,7 @@ func (c *Client) MessageEventsSearch(ep *EventsPage) (*Response, error) { } // Send off our request - res, err := c.HttpGet(ep.Context, url.String()) + res, err := c.HttpGet(ctx, url.String()) if err != nil { return res, err } @@ -75,13 +79,19 @@ func (c *Client) MessageEventsSearch(ep *EventsPage) (*Response, error) { return res, nil } +// Next returns the next page of results from a previous MessageEventsSearch call func (ep *EventsPage) Next() (*EventsPage, *Response, error) { + return ep.NextContext(context.Background()) +} + +// NextContext is the same as Next, and it accepts a context.Context +func (ep *EventsPage) NextContext(ctx context.Context) (*EventsPage, *Response, error) { if ep.NextPage == "" { return nil, nil, nil } // Send off our request - res, err := ep.client.HttpGet(ep.Context, ep.client.Config.BaseUrl+ep.NextPage) + res, err := ep.client.HttpGet(ctx, ep.client.Config.BaseUrl+ep.NextPage) if err != nil { return nil, res, err } @@ -151,8 +161,13 @@ func (ep *EventsPage) UnmarshalJSON(data []byte) error { return nil } -// Samples requests a list of example event data. +// EventSamples requests a list of example event data. func (c *Client) EventSamples(types *[]string) (*events.Events, *Response, error) { + return c.EventSamplesContext(context.Background(), types) +} + +// EventSamplesContext is the same as EventSamples, and it accepts a context.Context +func (c *Client) EventSamplesContext(ctx context.Context, types *[]string) (*events.Events, *Response, error) { path := fmt.Sprintf(MessageEventsSamplesPathFormat, c.Config.ApiVersion) url, err := url.Parse(c.Config.BaseUrl + path) if err != nil { @@ -176,7 +191,7 @@ func (c *Client) EventSamples(types *[]string) (*events.Events, *Response, error } // Send off our request - res, err := c.HttpGet(context.TODO(), url.String()) + res, err := c.HttpGet(ctx, url.String()) if err != nil { return nil, res, err } diff --git a/suppression_list.go b/suppression_list.go index 42b2419..d5e0abb 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -102,7 +102,13 @@ func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (re return res, err } -func (c *Client) SuppressionInsertOrUpdate(entries []SuppressionEntry) (*Response, error) { +// SuppressionUpsert adds an entry to the suppression, or updates the existing entry +func (c *Client) SuppressionUpsert(entries []SuppressionEntry) (*Response, error) { + return c.SuppressionUpsertContext(context.Background(), entries) +} + +// SuppressionUpsertContext is the same as SuppressionUpsert, and it accepts a context.Context +func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []SuppressionEntry) (*Response, error) { if entries == nil { return nil, fmt.Errorf("`entries` cannot be nil") } @@ -116,7 +122,7 @@ func (c *Client) SuppressionInsertOrUpdate(entries []SuppressionEntry) (*Respons } finalUrl := c.Config.BaseUrl + path - res, err := c.HttpPut(context.TODO(), finalUrl, jsonBytes) + res, err := c.HttpPut(ctx, finalUrl, jsonBytes) if err != nil { return res, err } From 2af402ed67af64c1cb97296da73cfc28d85a4829 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 7 Mar 2017 16:33:28 -0700 Subject: [PATCH 064/152] update docs on concurrency wrt Client (and Client.headers); update API docs link --- README.rst | 2 +- common.go | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index d8a7f72..f0e8c95 100644 --- a/README.rst +++ b/README.rst @@ -94,7 +94,7 @@ Documentation * `Code samples`_ * `Command-line tool: sparks`_ -.. _SparkPost API Reference: https://www.sparkpost.com/api +.. _SparkPost API Reference: https://developers.sparkpost.com/api .. _Code samples: examples/README.md .. _Command-line tool\: sparks: cmd/sparks/README.md diff --git a/common.go b/common.go index 9dafb07..94192e6 100644 --- a/common.go +++ b/common.go @@ -30,6 +30,7 @@ type Config struct { // Client contains connection, configuration, and authentication information. // Specifying your own http.Client gives you lots of control over how connections are made. +// Clients are safe for concurrent (read-only) reuse by multiple goroutines. type Client struct { Config *Config Client *http.Client @@ -124,11 +125,13 @@ func (api *Client) Init(cfg *Config) error { // SetHeader adds additional HTTP headers for every API request made from client. // Useful to set subaccount X-MSYS-SUBACCOUNT header and etc. +// All calls to SetHeader must happen before Client is exposed to possible concurrent use. func (c *Client) SetHeader(header string, value string) { c.headers[header] = value } -// Removes header set in SetHeader function +// RemoveHeader removes a header set in SetHeader function +// All calls to RemoveHeader must happen before Client is exposed to possible concurrent use. func (c *Client) RemoveHeader(header string) { delete(c.headers, header) } From dcba9f8419ce23b87e653460b25d54d3c2c32941 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 7 Mar 2017 17:01:17 -0700 Subject: [PATCH 065/152] coveralls config for travis --- .travis.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.travis.yml b/.travis.yml index dfdbf77..74b2ae6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,8 @@ language: go +sudo: false go: - 1.7 +before_install: + go get github.com/mattn/goveralls +script: + - $HOME/gopath/bin/goveralls -service=travis-ci From 40868a776f1668d17e7cd91cf5f556ba75603494 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 7 Mar 2017 17:12:39 -0700 Subject: [PATCH 066/152] pull flag variables into main to make goveralls not explode --- cmd/fblgen/fblgen.go | 12 ++++++------ cmd/mimedump/mimedump.go | 4 ++-- cmd/oobgen/oobgen.go | 8 ++++---- cmd/sparks/sparks.go | 42 +++++++++++++++++++--------------------- 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index 66ee1cc..9ae087a 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -12,13 +12,13 @@ import ( "github.com/SparkPost/gosparkpost/helpers/loadmsg" ) -var filename = flag.String("file", "", "path to raw email") -var dumpArf = flag.Bool("arf", false, "dump out multipart/report message") -var send = flag.Bool("send", false, "send fbl report") -var fblAddress = flag.String("fblto", "", "where to deliver the fbl report") -var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") - func main() { + var filename = flag.String("file", "", "path to raw email") + var dumpArf = flag.Bool("arf", false, "dump out multipart/report message") + var send = flag.Bool("send", false, "send fbl report") + var fblAddress = flag.String("fblto", "", "where to deliver the fbl report") + var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") + flag.Parse() var verbose bool if *verboseOpt == true { diff --git a/cmd/mimedump/mimedump.go b/cmd/mimedump/mimedump.go index a1fdbcb..c352653 100644 --- a/cmd/mimedump/mimedump.go +++ b/cmd/mimedump/mimedump.go @@ -12,9 +12,9 @@ import ( mime "github.com/jhillyerd/go.enmime" ) -var filename = flag.String("file", "", "path to email with a text/html part") - func main() { + var filename = flag.String("file", "", "path to email with a text/html part") + flag.Parse() if filename == nil || strings.TrimSpace(*filename) == "" { diff --git a/cmd/oobgen/oobgen.go b/cmd/oobgen/oobgen.go index 626f4d3..d256b86 100644 --- a/cmd/oobgen/oobgen.go +++ b/cmd/oobgen/oobgen.go @@ -13,11 +13,11 @@ import ( "github.com/SparkPost/gosparkpost/helpers/loadmsg" ) -var filename = flag.String("file", "", "path to raw email") -var send = flag.Bool("send", false, "send fbl report") -var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") - func main() { + var filename = flag.String("file", "", "path to raw email") + var send = flag.Bool("send", false, "send fbl report") + var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") + flag.Parse() var verbose bool if *verboseOpt == true { diff --git a/cmd/sparks/sparks.go b/cmd/sparks/sparks.go index 4da33a9..64e35ed 100644 --- a/cmd/sparks/sparks.go +++ b/cmd/sparks/sparks.go @@ -27,36 +27,34 @@ func (s *Strings) Set(value string) error { return nil } -var to Strings -var cc Strings -var bcc Strings -var headers Strings -var images Strings -var attachments Strings - -func init() { +func main() { + var to Strings + var cc Strings + var bcc Strings + var headers Strings + var images Strings + var attachments Strings + flag.Var(&to, "to", "where the mail goes to") flag.Var(&cc, "cc", "carbon copy this address") flag.Var(&bcc, "bcc", "blind carbon copy this address") flag.Var(&headers, "header", "custom header for your content") flag.Var(&images, "img", "mimetype:cid:path for image to include") flag.Var(&attachments, "attach", "mimetype:name:path for file to attach") -} -var from = flag.String("from", "default@sparkpostbox.com", "where the mail came from") -var subject = flag.String("subject", "", "email subject") -var htmlFlag = flag.String("html", "", "string/filename containing html content") -var textFlag = flag.String("text", "", "string/filename containing text content") -var rfc822Flag = flag.String("rfc822", "", "string/filename containing raw message") -var subsFlag = flag.String("subs", "", "string/filename containing substitution data (json object)") -var sendDelay = flag.String("send-delay", "", "delay delivery the specified amount of time") -var inline = flag.Bool("inline-css", false, "automatically inline css") -var dryrun = flag.Bool("dry-run", false, "dump json that would be sent to server") -var url = flag.String("url", "", "base url for api requests (optional)") -var help = flag.Bool("help", false, "display a help message") -var httpDump = flag.Bool("httpdump", false, "dump out http request and response") + var from = flag.String("from", "default@sparkpostbox.com", "where the mail came from") + var subject = flag.String("subject", "", "email subject") + var htmlFlag = flag.String("html", "", "string/filename containing html content") + var textFlag = flag.String("text", "", "string/filename containing text content") + var rfc822Flag = flag.String("rfc822", "", "string/filename containing raw message") + var subsFlag = flag.String("subs", "", "string/filename containing substitution data (json object)") + var sendDelay = flag.String("send-delay", "", "delay delivery the specified amount of time") + var inline = flag.Bool("inline-css", false, "automatically inline css") + var dryrun = flag.Bool("dry-run", false, "dump json that would be sent to server") + var url = flag.String("url", "", "base url for api requests (optional)") + var help = flag.Bool("help", false, "display a help message") + var httpDump = flag.Bool("httpdump", false, "dump out http request and response") -func main() { flag.Parse() if *help { From d16927d8792862cc8c4959ad1334b5f7f8f0015d Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 7 Mar 2017 17:24:17 -0700 Subject: [PATCH 067/152] build status badge for master --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index f0e8c95..0cf8428 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,10 @@ SparkPost Go API client ======================= +.. image:: https://travis-ci.org/SparkPost/gosparkpost.svg?branch=master + :target: https://travis-ci.org/SparkPost/gosparkpost + :alt: Build Status + .. image:: http://slack.sparkpost.com/badge.svg :target: http://slack.sparkpost.com :alt: Slack Community From c9422fcc6a589f8886c374319b07c1c1b155cc79 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 7 Mar 2017 22:00:57 -0700 Subject: [PATCH 068/152] tests for NewConfig --- common_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/common_test.go b/common_test.go index 9b9616e..ebf5386 100644 --- a/common_test.go +++ b/common_test.go @@ -8,6 +8,7 @@ import ( "testing" sp "github.com/SparkPost/gosparkpost" + "github.com/pkg/errors" ) var ( @@ -53,3 +54,28 @@ func testFailVerbose(t *testing.T, res *sp.Response, fmt string, args ...interfa } t.Fatalf(fmt, args...) } + +var newConfigTests = []struct { + in map[string]string + cfg *sp.Config + err error +}{ + {map[string]string{}, nil, errors.New("BaseUrl is required for api config")}, + {map[string]string{"baseurl": "http://example.com"}, nil, errors.New("ApiKey is required for api config")}, + {map[string]string{"baseurl": "http://example.com", "apikey": "foo"}, &sp.Config{BaseUrl: "http://example.com", ApiKey: "foo"}, nil}, +} + +func TestNewConfig(t *testing.T) { + var cfg *sp.Config + var err error + for idx, test := range newConfigTests { + cfg, err = sp.NewConfig(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("NewConfig[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("NewConfig[%d] => err %q, want %q", idx, err, test.err) + } else if cfg == nil && test.cfg != nil || cfg != nil && test.cfg == nil { + t.Errorf("NewConfig[%d] => cfg %v, want %v", idx, cfg, test.cfg) + } + } +} From 00dd2eddf6bb76e57ae6b048f09e8eac196814d8 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 14 Mar 2017 22:47:34 -0600 Subject: [PATCH 069/152] remove gocertifi, callers can use it if they need to; more tests --- common.go | 30 ++++------------------------- common_test.go | 52 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/common.go b/common.go index 94192e6..107d0d7 100644 --- a/common.go +++ b/common.go @@ -3,10 +3,8 @@ package gosparkpost import ( "bytes" "context" - "crypto/tls" "encoding/base64" "encoding/json" - "fmt" "io/ioutil" "mime" "net/http" @@ -14,7 +12,6 @@ import ( "regexp" "strings" - certifi "github.com/certifi/gocertifi" "github.com/pkg/errors" ) @@ -101,25 +98,6 @@ func (api *Client) Init(cfg *Config) error { api.Config = cfg api.headers = make(map[string]string) - if api.Client == nil { - // Ran into an issue where USERTrust was not recognized on OSX. - // The rest of this block was the fix. - - // load Mozilla cert pool - pool, err := certifi.CACerts() - if err != nil { - return errors.Wrap(err, "loading certifi cert pool") - } - - // configure transport using Mozilla cert pool - transport := &http.Transport{ - TLSClientConfig: &tls.Config{RootCAs: pool}, - } - - // configure http client using transport - api.Client = &http.Client{Transport: transport} - } - return nil } @@ -299,7 +277,7 @@ func (r *Response) AssertJson() error { } // allow things like "application/json; charset=utf-8" in addition to the bare content type if mediaType != "application/json" { - return fmt.Errorf("Expected json, got [%s] with code %d", mediaType, r.HTTP.StatusCode) + return errors.Errorf("Expected json, got [%s] with code %d", mediaType, r.HTTP.StatusCode) } return nil } @@ -313,12 +291,12 @@ func (r *Response) PrettyError(noun, verb string) error { } code := r.HTTP.StatusCode if code == 404 { - return fmt.Errorf("%s does not exist, %s failed.", noun, verb) + return errors.Errorf("%s does not exist, %s failed.", noun, verb) } else if code == 401 { - return fmt.Errorf("%s %s failed, permission denied. Check your API key.", noun, verb) + return errors.Errorf("%s %s failed, permission denied. Check your API key.", noun, verb) } else if code == 403 { // This is what happens if an endpoint URL gets typo'd. - return fmt.Errorf("%s %s failed. Are you using the right API path?", noun, verb) + return errors.Errorf("%s %s failed. Are you using the right API path?", noun, verb) } return nil } diff --git a/common_test.go b/common_test.go index ebf5386..ac3f489 100644 --- a/common_test.go +++ b/common_test.go @@ -66,10 +66,8 @@ var newConfigTests = []struct { } func TestNewConfig(t *testing.T) { - var cfg *sp.Config - var err error for idx, test := range newConfigTests { - cfg, err = sp.NewConfig(test.in) + cfg, err := sp.NewConfig(test.in) if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("NewConfig[%d] => err %q, want %q", idx, err, test.err) } else if err != nil && err.Error() != test.err.Error() { @@ -79,3 +77,51 @@ func TestNewConfig(t *testing.T) { } } } + +func TestJson(t *testing.T) { + var e = &sp.Error{Message: "This is fine."} + var exp = `{"message":"This is fine.","code":"","description":""}` + str, err := e.Json() + if err != nil { + t.Errorf("*Error.Json() => err %v, want nil", err) + } else if str != exp { + t.Errorf("*Error.Json => %q, want %q", str, exp) + } +} + +var initTests = []struct { + api *sp.Client + cfg *sp.Config + out *sp.Config + err error +}{ + {&sp.Client{}, &sp.Config{BaseUrl: ""}, &sp.Config{BaseUrl: "https://api.sparkpost.com"}, nil}, + {&sp.Client{}, &sp.Config{BaseUrl: "http://api.sparkpost.com"}, nil, errors.New("API base url must be https!")}, +} + +func TestInit(t *testing.T) { + for idx, test := range initTests { + err := test.api.Init(test.cfg) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Init[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("NewConfig[%d] => err %q, want %q", idx, err, test.err) + } else if test.out != nil && test.api.Config.BaseUrl != test.out.BaseUrl { + t.Errorf("Init[%d] => BaseUrl %q, want %q", idx, test.api.Config.BaseUrl, test.out.BaseUrl) + } + } +} + +/* // either make headers public or can it entirely in favor of context +func TestSetHeader(t *testing.T) { + var val string + var ok bool + cl := &sp.Client{} + cl.SetHeader("X-Foo", "Bar") + if val, ok = cl.headers["X-Foo"]; !ok { + t.Errorf("SetHeader => nil, want %q", "Bar") + } else if val != "Bar" { + t.Errorf("SetHeader => %q, want %q", val, "Bar") + } +} +*/ From 865d7e84d4b15dbfc7d1e9e75d413dccccb48576 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 10:35:03 -0600 Subject: [PATCH 070/152] switch `TmplOptions` to `*bool` since they should be omitted if unspecified --- templates.go | 6 +++--- templates_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/templates.go b/templates.go index 2be63c3..006af33 100644 --- a/templates.go +++ b/templates.go @@ -60,9 +60,9 @@ type From struct { // Options specifies settings to apply to this Template. // These settings may be overridden in the Transmission API call. type TmplOptions struct { - OpenTracking bool `json:"open_tracking,omitempty"` - ClickTracking bool `json:"click_tracking,omitempty"` - Transactional bool `json:"transactional,omitempty"` + OpenTracking *bool `json:"open_tracking,omitempty"` + ClickTracking *bool `json:"click_tracking,omitempty"` + Transactional *bool `json:"transactional,omitempty"` } // Preview options contains the required subsitution_data object to diff --git a/templates_test.go b/templates_test.go index f7d30ab..1f327ea 100644 --- a/templates_test.go +++ b/templates_test.go @@ -1,6 +1,8 @@ package gosparkpost_test import ( + "bytes" + "encoding/json" "fmt" "testing" @@ -118,6 +120,40 @@ func TestTemplateValidation(t *testing.T) { } +// Assert that options are actually ... optional, +// and that unspecified options don't default to their zero values. +func TestTemplateOptions(t *testing.T) { + var te *sp.Template + var to *sp.TmplOptions + var jsonb []byte + var err error + var tx bool + + te = &sp.Template{} + to = &sp.TmplOptions{Transactional: &tx} + te.Options = to + tx = true + + jsonb, err = json.Marshal(te) + if err != nil { + t.Fatal(err) + } + + if !bytes.Contains(jsonb, []byte(`"options":{"transactional":true}`)) { + t.Fatal("expected transactional option to be false") + } + + tx = false + jsonb, err = json.Marshal(te) + if err != nil { + t.Fatal(err) + } + + if !bytes.Contains(jsonb, []byte(`"options":{"transactional":false}`)) { + t.Fatalf("expected transactional option to be false:\n%s", string(jsonb)) + } +} + func TestTemplates(t *testing.T) { if true { // Temporarily disable test so TravisCI reports build success instead of test failure. From 261f10856f9a1c291a70b1b8e63e68fde445f1d5 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 10:45:20 -0600 Subject: [PATCH 071/152] switch `bool`s in `TxOptions` to `*bool` so they are not present in the generated json unless explicitly specified --- transmissions.go | 4 ++-- transmissions_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/transmissions.go b/transmissions.go index 84a1dac..a578765 100644 --- a/transmissions.go +++ b/transmissions.go @@ -47,9 +47,9 @@ type TxOptions struct { TmplOptions StartTime *RFC3339 `json:"start_time,omitempty"` - Sandbox bool `json:"sandbox,omitempty"` + Sandbox *bool `json:"sandbox,omitempty"` SkipSuppression string `json:"skip_suppression,omitempty"` - InlineCSS bool `json:"inline_css,omitempty"` + InlineCSS *bool `json:"inline_css,omitempty"` } // ParseRecipients asserts that Transmission.Recipients is valid. diff --git a/transmissions_test.go b/transmissions_test.go index b2d3b4a..c04fa87 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -1,6 +1,7 @@ package gosparkpost_test import ( + "bytes" "context" "encoding/json" "fmt" @@ -228,3 +229,35 @@ func TestTransmissions(t *testing.T) { t.Errorf("Delete returned HTTP %s\n%s\n", res.HTTP.Status, res.Body) } + +// Assert that options are actually ... optional, +// and that unspecified options don't default to their zero values. +func TestTransmissionOptions(t *testing.T) { + var jsonb []byte + var err error + var opt bool + tx := &sp.Transmission{} + to := &sp.TxOptions{InlineCSS: &opt} + + tx.Options = to + opt = true + + jsonb, err = json.Marshal(tx) + if err != nil { + t.Fatal(err) + } + + if !bytes.Contains(jsonb, []byte(`"options":{"inline_css":true}`)) { + t.Fatalf("expected inline_css option to be false:\n%s", string(jsonb)) + } + + opt = false + jsonb, err = json.Marshal(tx) + if err != nil { + t.Fatal(err) + } + + if !bytes.Contains(jsonb, []byte(`"options":{"inline_css":false}`)) { + t.Fatalf("expected inline_css option to be false:\n%s", string(jsonb)) + } +} From 69f552086a4259ec09a70cfbf7e9d1611e774c04 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 10:45:43 -0600 Subject: [PATCH 072/152] reorganize template test a bit, no functional change --- templates_test.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/templates_test.go b/templates_test.go index 1f327ea..3720d8a 100644 --- a/templates_test.go +++ b/templates_test.go @@ -123,16 +123,15 @@ func TestTemplateValidation(t *testing.T) { // Assert that options are actually ... optional, // and that unspecified options don't default to their zero values. func TestTemplateOptions(t *testing.T) { - var te *sp.Template - var to *sp.TmplOptions var jsonb []byte var err error - var tx bool + var opt bool + + te := &sp.Template{} + to := &sp.TmplOptions{Transactional: &opt} - te = &sp.Template{} - to = &sp.TmplOptions{Transactional: &tx} te.Options = to - tx = true + opt = true jsonb, err = json.Marshal(te) if err != nil { @@ -143,7 +142,7 @@ func TestTemplateOptions(t *testing.T) { t.Fatal("expected transactional option to be false") } - tx = false + opt = false jsonb, err = json.Marshal(te) if err != nil { t.Fatal(err) From 67af04d0f7fa079d9c84aea722696876a210b532 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 10:51:07 -0600 Subject: [PATCH 073/152] switch `sparks` command line tool to use new option convention --- cmd/sparks/sparks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/sparks/sparks.go b/cmd/sparks/sparks.go index 64e35ed..177c1d6 100644 --- a/cmd/sparks/sparks.go +++ b/cmd/sparks/sparks.go @@ -274,7 +274,7 @@ func main() { if tx.Options == nil { tx.Options = &sp.TxOptions{} } - tx.Options.InlineCSS = true + tx.Options.InlineCSS = inline } if *dryrun != false { From e9e0fcb153b9411b07c10451c4ac00e22a45c922 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 11:01:57 -0600 Subject: [PATCH 074/152] test the zero value first; fix up copy/paste inconsistencies --- templates_test.go | 10 ++++------ transmissions_test.go | 11 +++++------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/templates_test.go b/templates_test.go index 3720d8a..3beff56 100644 --- a/templates_test.go +++ b/templates_test.go @@ -129,27 +129,25 @@ func TestTemplateOptions(t *testing.T) { te := &sp.Template{} to := &sp.TmplOptions{Transactional: &opt} - te.Options = to - opt = true jsonb, err = json.Marshal(te) if err != nil { t.Fatal(err) } - if !bytes.Contains(jsonb, []byte(`"options":{"transactional":true}`)) { + if !bytes.Contains(jsonb, []byte(`"options":{"transactional":false}`)) { t.Fatal("expected transactional option to be false") } - opt = false + opt = true jsonb, err = json.Marshal(te) if err != nil { t.Fatal(err) } - if !bytes.Contains(jsonb, []byte(`"options":{"transactional":false}`)) { - t.Fatalf("expected transactional option to be false:\n%s", string(jsonb)) + if !bytes.Contains(jsonb, []byte(`"options":{"transactional":true}`)) { + t.Fatalf("expected transactional option to be true:\n%s", string(jsonb)) } } diff --git a/transmissions_test.go b/transmissions_test.go index c04fa87..43fe7fe 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -236,28 +236,27 @@ func TestTransmissionOptions(t *testing.T) { var jsonb []byte var err error var opt bool + tx := &sp.Transmission{} to := &sp.TxOptions{InlineCSS: &opt} - tx.Options = to - opt = true jsonb, err = json.Marshal(tx) if err != nil { t.Fatal(err) } - if !bytes.Contains(jsonb, []byte(`"options":{"inline_css":true}`)) { + if !bytes.Contains(jsonb, []byte(`"options":{"inline_css":false}`)) { t.Fatalf("expected inline_css option to be false:\n%s", string(jsonb)) } - opt = false + opt = true jsonb, err = json.Marshal(tx) if err != nil { t.Fatal(err) } - if !bytes.Contains(jsonb, []byte(`"options":{"inline_css":false}`)) { - t.Fatalf("expected inline_css option to be false:\n%s", string(jsonb)) + if !bytes.Contains(jsonb, []byte(`"options":{"inline_css":true}`)) { + t.Fatalf("expected inline_css option to be true:\n%s", string(jsonb)) } } From 79e5f771a3a2263c0452b4c917a4fd83124dc732 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 12:41:01 -0600 Subject: [PATCH 075/152] remove unreachable code --- common.go | 10 ++++------ common_test.go | 6 ++---- transmissions_test.go | 5 +---- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/common.go b/common.go index 107d0d7..6acb481 100644 --- a/common.go +++ b/common.go @@ -75,12 +75,10 @@ type Error struct { Line int `json:"line,omitempty"` } -func (e Error) Json() (string, error) { - jsonBytes, err := json.Marshal(e) - if err != nil { - return "", errors.Wrap(err, "marshaling json") - } - return string(jsonBytes), nil +func (e Error) Json() string { + // safe to ignore errors when Marshaling a constant type + jsonb, _ := json.Marshal(e) + return string(jsonb) } // Init pulls together everything necessary to make an API request. diff --git a/common_test.go b/common_test.go index ac3f489..9b3b47b 100644 --- a/common_test.go +++ b/common_test.go @@ -81,10 +81,8 @@ func TestNewConfig(t *testing.T) { func TestJson(t *testing.T) { var e = &sp.Error{Message: "This is fine."} var exp = `{"message":"This is fine.","code":"","description":""}` - str, err := e.Json() - if err != nil { - t.Errorf("*Error.Json() => err %v, want nil", err) - } else if str != exp { + str := e.Json() + if str != exp { t.Errorf("*Error.Json => %q, want %q", str, exp) } } diff --git a/transmissions_test.go b/transmissions_test.go index b2d3b4a..1a6d308 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -207,10 +207,7 @@ func TestTransmissions(t *testing.T) { t.Errorf("Retrieve returned HTTP %s\n", res.HTTP.Status) if len(res.Errors) > 0 { for _, e := range res.Errors { - json, err := e.Json() - if err != nil { - t.Error(err) - } + json := e.Json() t.Errorf("%s\n", json) } } else { From 23ba808cdef8b3a5c3f8784a671f6539ee549838 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 12:42:58 -0600 Subject: [PATCH 076/152] make headers set on `Client` public, as the well-defined `http.Headers` type; test that expected headers are sent --- common.go | 25 ++++++++----------------- common_test.go | 14 -------------- transmissions_test.go | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 31 deletions(-) diff --git a/common.go b/common.go index 6acb481..f5cac83 100644 --- a/common.go +++ b/common.go @@ -28,10 +28,12 @@ type Config struct { // Client contains connection, configuration, and authentication information. // Specifying your own http.Client gives you lots of control over how connections are made. // Clients are safe for concurrent (read-only) reuse by multiple goroutines. +// Headers is useful to set subaccount (X-MSYS-SUBACCOUNT header) and any other custom headers. +// All calls to SetHeader must happen before Client is exposed to possible concurrent use. type Client struct { Config *Config Client *http.Client - headers map[string]string + Headers *http.Header } var nonDigit *regexp.Regexp = regexp.MustCompile(`\D`) @@ -94,24 +96,11 @@ func (api *Client) Init(cfg *Config) error { cfg.ApiVersion = 1 } api.Config = cfg - api.headers = make(map[string]string) + api.Headers = &http.Header{} return nil } -// SetHeader adds additional HTTP headers for every API request made from client. -// Useful to set subaccount X-MSYS-SUBACCOUNT header and etc. -// All calls to SetHeader must happen before Client is exposed to possible concurrent use. -func (c *Client) SetHeader(header string, value string) { - c.headers[header] = value -} - -// RemoveHeader removes a header set in SetHeader function -// All calls to RemoveHeader must happen before Client is exposed to possible concurrent use. -func (c *Client) RemoveHeader(header string) { - delete(c.headers, header) -} - // HttpPost sends a Post request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. @@ -172,8 +161,10 @@ func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []by } // Forward additional headers set in client to request - for header, value := range c.headers { - req.Header.Set(header, value) + for header, values := range map[string][]string(*c.Headers) { + for _, value := range values { + req.Header.Add(header, value) + } } if ctx == nil { diff --git a/common_test.go b/common_test.go index 9b3b47b..20662f8 100644 --- a/common_test.go +++ b/common_test.go @@ -109,17 +109,3 @@ func TestInit(t *testing.T) { } } } - -/* // either make headers public or can it entirely in favor of context -func TestSetHeader(t *testing.T) { - var val string - var ok bool - cl := &sp.Client{} - cl.SetHeader("X-Foo", "Bar") - if val, ok = cl.headers["X-Foo"]; !ok { - t.Errorf("SetHeader => nil, want %q", "Bar") - } else if val != "Bar" { - t.Errorf("SetHeader => %q, want %q", val, "Bar") - } -} -*/ diff --git a/transmissions_test.go b/transmissions_test.go index 1a6d308..5cde93a 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -32,6 +32,9 @@ func TestTransmissions_Post_Success(t *testing.T) { w.Write([]byte(transmissionSuccess)) }) + testClient.Headers.Add("X-Foo", "foo") + testClient.Headers.Add("X-Bar", "bar") + testClient.Headers.Del("X-Bar") tx := &sp.Transmission{ CampaignID: "Post_Success", ReturnPath: "returnpath@example.com", @@ -51,6 +54,19 @@ func TestTransmissions_Post_Success(t *testing.T) { if id != "11111111111111111" { testFailVerbose(t, res, "Unexpected value for id! (expected: 11111111111111111, saw: %s)", id) } + + var reqDump string + var ok bool + if reqDump, ok = res.Verbose["http_requestdump"]; !ok { + testFailVerbose(t, res, "HTTP Request unavailable") + } + + if !strings.Contains(reqDump, "X-Foo: foo") { + testFailVerbose(t, res, "Header set on Client not sent") + } + if strings.Contains(reqDump, "X-Bar: bar") { + testFailVerbose(t, res, "Header set on Client should not have been sent") + } } func TestTransmissions_Delete_Headers(t *testing.T) { From bb1ea9b497708fe4f627ab9bb766ec7d227d6744 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 12:46:54 -0600 Subject: [PATCH 077/152] explicit test for interaction between client and context headers (context wins) --- transmissions_test.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/transmissions_test.go b/transmissions_test.go index 5cde93a..b4a1706 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -32,9 +32,15 @@ func TestTransmissions_Post_Success(t *testing.T) { w.Write([]byte(transmissionSuccess)) }) + // set some headers on the client testClient.Headers.Add("X-Foo", "foo") testClient.Headers.Add("X-Bar", "bar") - testClient.Headers.Del("X-Bar") + testClient.Headers.Add("X-Baz", "baz") + testClient.Headers.Del("X-Baz") + // override one of the headers using a context + header := http.Header{} + header.Add("X-Foo", "bar") + ctx := context.WithValue(context.Background(), "http.Header", header) tx := &sp.Transmission{ CampaignID: "Post_Success", ReturnPath: "returnpath@example.com", @@ -46,7 +52,8 @@ func TestTransmissions_Post_Success(t *testing.T) { }, Metadata: map[string]interface{}{"shoe_size": 9}, } - id, res, err := testClient.Send(tx) + // send using the client and the context + id, res, err := testClient.SendContext(ctx, tx) if err != nil { testFailVerbose(t, res, "Transmission POST returned error: %v", err) } @@ -61,10 +68,13 @@ func TestTransmissions_Post_Success(t *testing.T) { testFailVerbose(t, res, "HTTP Request unavailable") } - if !strings.Contains(reqDump, "X-Foo: foo") { + if !strings.Contains(reqDump, "X-Foo: bar") { + testFailVerbose(t, res, "Header set on Client not sent") + } + if !strings.Contains(reqDump, "X-Bar: bar") { testFailVerbose(t, res, "Header set on Client not sent") } - if strings.Contains(reqDump, "X-Bar: bar") { + if strings.Contains(reqDump, "X-Baz: baz") { testFailVerbose(t, res, "Header set on Client should not have been sent") } } From 517774d03992b82192bca656ac135861ca65cfa5 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 13:34:34 -0600 Subject: [PATCH 078/152] test that bad request methods are rejected --- common_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/common_test.go b/common_test.go index 20662f8..0ad513f 100644 --- a/common_test.go +++ b/common_test.go @@ -87,6 +87,16 @@ func TestJson(t *testing.T) { } } +func TestDoRequest_BadMethod(t *testing.T) { + testSetup(t) + defer testTeardown() + + _, err := testClient.DoRequest(nil, "💩", "", nil) + if err == nil { + t.Fatalf("bogus request method should fail") + } +} + var initTests = []struct { api *sp.Client cfg *sp.Config From f584ea634be531b9010dc7b1dd744cd6af07a2ea Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 13:59:10 -0600 Subject: [PATCH 079/152] simplify header assignment; remove disabled test --- common.go | 14 ++---- transmissions_test.go | 110 ++++-------------------------------------- 2 files changed, 13 insertions(+), 111 deletions(-) diff --git a/common.go b/common.go index f5cac83..b5785d7 100644 --- a/common.go +++ b/common.go @@ -173,15 +173,9 @@ func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []by // set any headers provided in context if header, ok := ctx.Value("http.Header").(http.Header); ok { for key, vals := range map[string][]string(header) { - if len(vals) >= 1 { - // replace existing headers, default, or from Client.headers - req.Header.Set(key, vals[0]) - } - if len(vals) > 2 { - for _, val := range vals[1:] { - // allow setting multiple values because why not - req.Header.Add(key, val) - } + req.Header.Del(key) + for _, val := range vals { + req.Header.Add(key, val) } } } @@ -201,7 +195,7 @@ func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []by if c.Config.Verbose { ares.Verbose["http_status"] = ares.HTTP.Status bodyBytes, dumpErr := httputil.DumpResponse(res, true) - if err != nil { + if dumpErr != nil { ares.Verbose["http_responsedump_err"] = dumpErr.Error() } else { ares.Verbose["http_responsedump"] = string(bodyBytes) diff --git a/transmissions_test.go b/transmissions_test.go index b4a1706..0389618 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -9,7 +9,6 @@ import ( "testing" sp "github.com/SparkPost/gosparkpost" - "github.com/SparkPost/gosparkpost/test" ) var transmissionSuccess string = `{ @@ -32,8 +31,10 @@ func TestTransmissions_Post_Success(t *testing.T) { w.Write([]byte(transmissionSuccess)) }) + testClient.Config.ApiKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" // set some headers on the client testClient.Headers.Add("X-Foo", "foo") + testClient.Headers.Add("X-Foo", "baz") testClient.Headers.Add("X-Bar", "bar") testClient.Headers.Add("X-Baz", "baz") testClient.Headers.Del("X-Baz") @@ -91,6 +92,9 @@ func TestTransmissions_Delete_Headers(t *testing.T) { w.Write([]byte("{}")) }) + testClient.Config.Username = "testuser" + testClient.Config.Password = "testpass" + header := http.Header{} header.Add("X-Foo", "bar") ctx := context.WithValue(context.Background(), "http.Header", header) @@ -146,108 +150,12 @@ func TestTransmissions_ByID_Success(t *testing.T) { testFailVerbose(t, res, "Transmission GET failed") } - if tx1.CampaignID != tx.CampaignID { - testFailVerbose(t, res, "CampaignIDs do not match") - } -} - -func TestTransmissions(t *testing.T) { - if true { - // Temporarily disable test so TravisCI reports build success instead of test failure. - return - } - - cfgMap, err := test.LoadConfig() - if err != nil { - t.Error(err) - return - } - cfg, err := sp.NewConfig(cfgMap) + res, err = testClient.TransmissionContext(nil, tx1) if err != nil { - t.Error(err) - return - } - - var client sp.Client - err = client.Init(cfg) - if err != nil { - t.Error(err) - return - } - - tlist, res, err := client.Transmissions(&sp.Transmission{CampaignID: "msys_smoke"}) - if err != nil { - t.Error(err) - return - } - t.Errorf("List: %d, %d entries", res.HTTP.StatusCode, len(tlist)) - for _, tr := range tlist { - t.Errorf("%s: %s", tr.ID, tr.CampaignID) - } - - // TODO: 404 from Transmission Create could mean either - // Recipient List or Content wasn't found - open doc ticket - // to make error message more specific - - T := &sp.Transmission{ - CampaignID: "msys_smoke", - ReturnPath: "dgray@messagesystems.com", - Recipients: []string{"dgray@messagesystems.com", "dgray@sparkpost.com"}, - // Single-recipient Transmissions are transient - Retrieve will 404 - //Recipients: []string{"dgray@messagesystems.com"}, - Content: sp.Content{ - Subject: "this is a test message", - HTML: "this is the HTML body of the test message", - From: map[string]string{ - "name": "Dave Gray", - "email": "dgray@messagesystems.com", - }, - }, - Metadata: map[string]interface{}{ - "binding": "example", - }, - } - err = T.Validate() - if err != nil { - t.Error(err) - return - } - - id, _, err := client.Send(T) - if err != nil { - t.Error(err) - return - } - - t.Errorf("Transmission created with id [%s]", id) - T.ID = id - - tr := &sp.Transmission{ID: id} - res, err = client.Transmission(tr) - if err != nil { - t.Error(err) - return - } - - if res != nil { - t.Errorf("Retrieve returned HTTP %s\n", res.HTTP.Status) - if len(res.Errors) > 0 { - for _, e := range res.Errors { - json := e.Json() - t.Errorf("%s\n", json) - } - } else { - t.Errorf("Transmission retrieved: %s=%s\n", tr.ID, tr.State) - } + testFailVerbose(t, res, "Transmission GET failed") } - tx1 := &sp.Transmission{ID: id} - res, err = client.TransmissionDelete(tx1) - if err != nil { - t.Error(err) - return + if tx1.CampaignID != tx.CampaignID { + testFailVerbose(t, res, "CampaignIDs do not match") } - - t.Errorf("Delete returned HTTP %s\n%s\n", res.HTTP.Status, res.Body) - } From 6669a2deaee9fb9eab5a5360f3b387c065c00676 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 14:31:41 -0600 Subject: [PATCH 080/152] test "empty string" error case; remove disabled test --- templates_test.go | 73 ++++------------------------------------------- 1 file changed, 5 insertions(+), 68 deletions(-) diff --git a/templates_test.go b/templates_test.go index f7d30ab..6002c53 100644 --- a/templates_test.go +++ b/templates_test.go @@ -5,7 +5,6 @@ import ( "testing" sp "github.com/SparkPost/gosparkpost" - "github.com/SparkPost/gosparkpost/test" ) func TestTemplateValidation(t *testing.T) { @@ -58,6 +57,11 @@ func TestTemplateValidation(t *testing.T) { t.Error(fmt.Errorf("expected name to be blank")) return } + fromString = "" + _, err = sp.ParseFrom(fromString) + if err == nil { + t.Error(fmt.Errorf("Content.From should not be allowed!")) + } fromMap1 := map[string]interface{}{ "name": "A B", @@ -117,70 +121,3 @@ func TestTemplateValidation(t *testing.T) { } } - -func TestTemplates(t *testing.T) { - if true { - // Temporarily disable test so TravisCI reports build success instead of test failure. - // NOTE: need travis to set sparkpost base urls etc, or mock http request - return - } - - cfgMap, err := test.LoadConfig() - if err != nil { - t.Error(err) - return - } - cfg, err := sp.NewConfig(cfgMap) - if err != nil { - t.Error(err) - return - } - - var client sp.Client - err = client.Init(cfg) - if err != nil { - t.Error(err) - return - } - - tlist, _, err := client.Templates() - if err != nil { - t.Error(err) - return - } - t.Logf("templates listed: %+v", tlist) - - content := sp.Content{ - Subject: "this is a test template", - // NB: deliberate syntax error - //Text: "text part of the test template {{a}", - Text: "text part of the test template", - From: map[string]string{ - "name": "test name", - "email": "test@email.com", - }, - } - template := &sp.Template{Content: content, Name: "test template"} - - id, _, err := client.TemplateCreate(template) - if err != nil { - t.Error(err) - return - } - fmt.Printf("Created Template with id=%s\n", id) - - d := map[string]interface{}{} - res, err := client.TemplatePreview(id, &sp.PreviewOptions{d}) - if err != nil { - t.Error(err) - return - } - fmt.Printf("Preview Template with id=%s and response %+v\n", id, res) - - _, err = client.TemplateDelete(id) - if err != nil { - t.Error(err) - return - } - fmt.Printf("Deleted Template with id=%s\n", id) -} From c95e80165793669a920fe9767e731606b14a94a6 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 15:34:42 -0600 Subject: [PATCH 081/152] convert `Content.From` validation to a table test; start on `Template` validation --- templates_test.go | 163 ++++++++++++++++------------------------------ 1 file changed, 56 insertions(+), 107 deletions(-) diff --git a/templates_test.go b/templates_test.go index 6002c53..6dcebad 100644 --- a/templates_test.go +++ b/templates_test.go @@ -5,119 +5,68 @@ import ( "testing" sp "github.com/SparkPost/gosparkpost" + "github.com/pkg/errors" ) -func TestTemplateValidation(t *testing.T) { - fromStruct := sp.From{"a@b.com", "A B"} - f, err := sp.ParseFrom(fromStruct) - if err != nil { - t.Error(err) - return - } - if fromStruct.Email != f.Email { - t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", - fromStruct.Email, f.Email)) - return - } - if fromStruct.Name != f.Name { - t.Error(fmt.Errorf("expected name [%s] didn't match actual [%s]", - fromStruct.Name, f.Name)) - return - } - - addrStruct := sp.Address{"a@b.com", "A B", "c@d.com"} - f, err = sp.ParseFrom(addrStruct) - if err != nil { - t.Error(err) - return - } - if addrStruct.Email != f.Email { - t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", - addrStruct.Email, f.Email)) - return - } - if addrStruct.Name != f.Name { - t.Error(fmt.Errorf("expected name [%s] didn't match actual [%s]", - addrStruct.Name, f.Name)) - return - } - - fromString := "a@b.com" - f, err = sp.ParseFrom(fromString) - if err != nil { - t.Error(err) - return - } - if fromString != f.Email { - t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", - fromString, f.Email)) - return - } - if "" != f.Name { - t.Error(fmt.Errorf("expected name to be blank")) - return - } - fromString = "" - _, err = sp.ParseFrom(fromString) - if err == nil { - t.Error(fmt.Errorf("Content.From should not be allowed!")) - } +var templateFromValidationTests = []struct { + in interface{} + err error + out sp.From +}{ + {sp.From{"a@b.com", "A B"}, nil, sp.From{"a@b.com", "A B"}}, + {sp.Address{"a@b.com", "A B", "c@d.com"}, nil, sp.From{"a@b.com", "A B"}}, + {"a@b.com", nil, sp.From{"a@b.com", ""}}, + {[]byte("a@b.com"), errors.New("unsupported Content.From value type [[]uint8]"), sp.From{"", ""}}, + {"", errors.New("Content.From may not be empty"), sp.From{"", ""}}, + {map[string]interface{}{"name": "A B", "email": "a@b.com"}, nil, sp.From{"a@b.com", "A B"}}, + {map[string]interface{}{"name": 1, "email": "a@b.com"}, errors.New("strings are required for all Content.From values"), + sp.From{"a@b.com", ""}}, + {map[string]string{"name": "A B", "email": "a@b.com"}, nil, sp.From{"a@b.com", "A B"}}, +} - fromMap1 := map[string]interface{}{ - "name": "A B", - "email": "a@b.com", - } - f, err = sp.ParseFrom(fromMap1) - if err != nil { - t.Error(err) - return - } - // ParseFrom will bail if these aren't strings - fromString, _ = fromMap1["email"].(string) - if fromString != f.Email { - t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", - fromString, f.Email)) - return - } - nameString, _ := fromMap1["name"].(string) - if nameString != f.Name { - t.Error(fmt.Errorf("expected name [%s] didn't match actual [%s]", - nameString, f.Name)) - return +func TestTemplateFromValidation(t *testing.T) { + for idx, test := range templateFromValidationTests { + f, err := sp.ParseFrom(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("ParseFrom[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("ParseFrom[%d] => err %q, want %q", idx, err, test.err) + } else if f.Email != test.out.Email { + t.Errorf("ParseFrom[%d] => Email %q, want %q", idx, f.Email, test.out.Email) + } else if f.Name != test.out.Name { + t.Errorf("ParseFrom[%d] => Name %q, want %q", idx, f.Name, test.out.Name) + } } +} - fromMap1["name"] = 1 - f, err = sp.ParseFrom(fromMap1) - if err == nil { - t.Error(fmt.Errorf("failed to detect non-string name")) - return - } +var templateValidationTests = []struct { + te *sp.Template + err error + cmp func(te *sp.Template) bool +}{ + {nil, errors.New("Can't Validate a nil Template"), nil}, + {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), nil}, + {&sp.Template{Content: sp.Content{Subject: "s"}}, errors.New("Template requires either Content.HTML or Content.Text"), nil}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: ""}}, + errors.New("Content.From may not be empty"), nil, + }, - fromMap2 := map[string]string{ - "name": "A B", - "email": "a@b.com", - } - f, err = sp.ParseFrom(fromMap2) - if err != nil { - t.Error(err) - return - } - if fromMap2["email"] != f.Email { - t.Error(fmt.Errorf("expected email [%s] didn't match actual [%s]", - fromMap2["email"], f.Email)) - return - } - if fromMap2["name"] != f.Name { - t.Error(fmt.Errorf("expected name [%s] didn't match actual [%s]", - fromMap2["name"], f.Name)) - return - } + { + &sp.Template{Content: sp.Content{EmailRFC822: "From:foo@example.com\r\n", Subject: "removeme"}}, + nil, + func(te *sp.Template) bool { return te.Content.Subject == "" }, + }, +} - fromBytes := []byte("a@b.com") - f, err = sp.ParseFrom(fromBytes) - if err == nil { - t.Error(fmt.Errorf("failed to detect unsupported type")) - return +func TestTemplateValidation(t *testing.T) { + for idx, test := range templateValidationTests { + err := test.te.Validate() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Template.Validate[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Template.Validate[%d] => err %q, want %q", idx, err, test.err) + } else if test.cmp != nil && test.cmp(test.te) == false { + t.Errorf("Template.Validate[%d] => failed post-condition check for %q", test.te) + } } - } From 9af2843bd047c2e4941c7151add9ac5959777ff1 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 15:50:26 -0600 Subject: [PATCH 082/152] finish `Template.Validation()` tests --- templates_test.go | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/templates_test.go b/templates_test.go index 6dcebad..dc324e2 100644 --- a/templates_test.go +++ b/templates_test.go @@ -1,7 +1,7 @@ package gosparkpost_test import ( - "fmt" + "strings" "testing" sp "github.com/SparkPost/gosparkpost" @@ -48,8 +48,40 @@ var templateValidationTests = []struct { {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), nil}, {&sp.Template{Content: sp.Content{Subject: "s"}}, errors.New("Template requires either Content.HTML or Content.Text"), nil}, {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: ""}}, - errors.New("Content.From may not be empty"), nil, - }, + errors.New("Content.From may not be empty"), nil}, + + {&sp.Template{ID: strings.Repeat("id", 33), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Template id may not be longer than 64 bytes"), nil}, + {&sp.Template{Name: strings.Repeat("name", 257), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Template name may not be longer than 1024 bytes"), nil}, + {&sp.Template{Description: strings.Repeat("desc", 257), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Template description may not be longer than 1024 bytes"), nil}, + + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + Attachments: []sp.Attachment{{Filename: strings.Repeat("f", 256)}}, + }}, + errors.Errorf("Attachment name length must be <= 255: [%s]", strings.Repeat("f", 256)), nil}, + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + Attachments: []sp.Attachment{{B64Data: "\r\n"}}, + }}, + errors.New("Attachment data may not contain line breaks [\\r\\n]"), nil}, + + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + InlineImages: []sp.InlineImage{{Filename: strings.Repeat("f", 256)}}, + }}, + errors.Errorf("InlineImage name length must be <= 255: [%s]", strings.Repeat("f", 256)), nil}, + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + InlineImages: []sp.InlineImage{{B64Data: "\r\n"}}, + }}, + errors.New("InlineImage data may not contain line breaks [\\r\\n]"), nil}, { &sp.Template{Content: sp.Content{EmailRFC822: "From:foo@example.com\r\n", Subject: "removeme"}}, From e78c76ee17c9941ae6ef24597fc61c3af366f0b0 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 15:53:55 -0600 Subject: [PATCH 083/152] add a nil input test because i was curious ok --- templates_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/templates_test.go b/templates_test.go index dc324e2..0869524 100644 --- a/templates_test.go +++ b/templates_test.go @@ -16,6 +16,7 @@ var templateFromValidationTests = []struct { {sp.From{"a@b.com", "A B"}, nil, sp.From{"a@b.com", "A B"}}, {sp.Address{"a@b.com", "A B", "c@d.com"}, nil, sp.From{"a@b.com", "A B"}}, {"a@b.com", nil, sp.From{"a@b.com", ""}}, + {nil, errors.New("unsupported Content.From value type [%!s()]"), sp.From{"", ""}}, {[]byte("a@b.com"), errors.New("unsupported Content.From value type [[]uint8]"), sp.From{"", ""}}, {"", errors.New("Content.From may not be empty"), sp.From{"", ""}}, {map[string]interface{}{"name": "A B", "email": "a@b.com"}, nil, sp.From{"a@b.com", "A B"}}, From f2be1f7a880079535ebda7af6a5f272878bd6202 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 22:02:24 -0600 Subject: [PATCH 084/152] export path format; fix copy/paste path error --- subaccounts.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/subaccounts.go b/subaccounts.go index 6de967c..40e6aa7 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -7,7 +7,7 @@ import ( ) // https://www.sparkpost.com/api#/reference/subaccounts -var subaccountsPathFormat = "/api/v%d/subaccounts" +var SubaccountsPathFormat = "/api/v%d/subaccounts" var availableGrants = []string{ "smtp/inject", "sending_domains/manage", @@ -69,7 +69,7 @@ func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (re return } - path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { @@ -153,7 +153,7 @@ func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (re return } - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, s.ID) res, err = c.HttpPut(ctx, url, jsonBytes) @@ -198,7 +198,7 @@ func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err err // SubaccountsContext is the same as Subaccounts, and it allows the caller to provide a context func (c *Client) SubaccountsContext(ctx context.Context) (subaccounts []Subaccount, res *Response, err error) { - path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err = c.HttpGet(ctx, url) if err != nil { @@ -252,7 +252,7 @@ func (c *Client) Subaccount(id int) (subaccount *Subaccount, res *Response, err // SubaccountContext is the same as Subaccount, and it accepts a context.Context func (c *Client) SubaccountContext(ctx context.Context, id int) (subaccount *Subaccount, res *Response, err error) { - path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%d", c.Config.BaseUrl, path, id) res, err = c.HttpGet(ctx, u) if err != nil { From 7545fb560458e248b22baaceaa3a99e55a6513a3 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 22:02:53 -0600 Subject: [PATCH 085/152] export templates path format --- templates.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/templates.go b/templates.go index 2be63c3..ee5b828 100644 --- a/templates.go +++ b/templates.go @@ -10,7 +10,7 @@ import ( ) // https://www.sparkpost.com/api#/reference/templates -var templatesPathFormat = "/api/v%d/templates" +var TemplatesPathFormat = "/api/v%d/templates" // Template is the JSON structure accepted by and returned from the SparkPost Templates API. // It's mostly metadata at this level - see Content and Options for more detail. @@ -207,7 +207,7 @@ func (c *Client) TemplateCreateContext(ctx context.Context, t *Template) (id str return } - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { @@ -274,7 +274,7 @@ func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *R return } - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, t.Published) res, err = c.HttpPut(ctx, url, jsonBytes) @@ -319,7 +319,7 @@ func (c *Client) Templates() ([]Template, *Response, error) { // TemplatesContext is the same as Templates, and it allows the caller to provide a context func (c *Client) TemplatesContext(ctx context.Context) ([]Template, *Response, error) { - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err := c.HttpGet(ctx, url) if err != nil { @@ -373,7 +373,7 @@ func (c *Client) TemplateDeleteContext(ctx context.Context, id string) (res *Res return } - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) res, err = c.HttpDelete(ctx, url) if err != nil { @@ -431,7 +431,7 @@ func (c *Client) TemplatePreviewContext(ctx context.Context, id string, payload return } - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s/preview", c.Config.BaseUrl, path, id) res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { From fe11f2fb60e2104eec675636388a4632b2db0fbf Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 30 Mar 2017 22:24:46 -0600 Subject: [PATCH 086/152] start on test coverage for TemplateCreate --- templates.go | 6 ++---- templates_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/templates.go b/templates.go index ee5b828..bbc8e6d 100644 --- a/templates.go +++ b/templates.go @@ -202,10 +202,8 @@ func (c *Client) TemplateCreateContext(ctx context.Context, t *Template) (id str return } - jsonBytes, err := json.Marshal(t) - if err != nil { - return - } + // A Template that makes it past Validate() will always Marshal + jsonBytes, _ := json.Marshal(t) path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) diff --git a/templates_test.go b/templates_test.go index 0869524..3710c07 100644 --- a/templates_test.go +++ b/templates_test.go @@ -1,6 +1,8 @@ package gosparkpost_test import ( + "fmt" + "net/http" "strings" "testing" @@ -103,3 +105,51 @@ func TestTemplateValidation(t *testing.T) { } } } + +var templatePostSuccessTests = []struct { + in *sp.Template + err error + status int + json string + id string +}{ + {nil, errors.New("Create called with nil Template"), 0, "", ""}, + {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), 0, "", ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Unexpected response to Template creation (results)"), + 200, `{"foo":{"id":"new-template"}}`, ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Unexpected response to Template creation"), + 200, `{"results":{"ID":"new-template"}}`, ""}, + + {&sp.Template{Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, + errors.New("3000: substitution language syntax error in template content\nError while compiling header Subject: substitution statement missing ending '}}'"), + 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`, ""}, + + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, + 200, `{"results":{"id":"new-template"}}`, "new-template"}, +} + +func TestTemplateCreate(t *testing.T) { + for idx, test := range templatePostSuccessTests { + testSetup(t) + defer testTeardown() + + path := fmt.Sprintf(sp.TemplatesPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(test.status) + w.Write([]byte(test.json)) + }) + + id, _, err := testClient.TemplateCreate(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplateCreate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplateCreate[%d] => err %q want %q", idx, err, test.err) + } else if id != test.id { + t.Errorf("TemplateCreate[%d] => id %q want %q", idx, id, test.id) + } + } +} From ed0dfbf7ac93bca95196fa607abe01e722acc4cf Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Fri, 31 Mar 2017 09:20:11 -0500 Subject: [PATCH 087/152] Adds support of additional content in suppression list response wrapper. Also: * Add comments to all public functions * Fixes some non standard variable naming schemes *Url->*URL * fixed typo where JSON decl was missing quotes --- suppression_list.go | 46 ++++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/suppression_list.go b/suppression_list.go index d5e0abb..e70b4f9 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -7,9 +7,10 @@ import ( "net/url" ) -// https://developers.sparkpost.com/api/#/reference/suppression-list +// SuppressionListsPathFormat https://developers.sparkpost.com/api/#/reference/suppression-list var SuppressionListsPathFormat = "/api/v%d/suppression-list" +// SuppressionEntry is an entry of the suppression list type SuppressionEntry struct { // Email is used when list is stored Email string `json:"email,omitempty"` @@ -20,68 +21,82 @@ type SuppressionEntry struct { Transactional bool `json:"transactional,omitempty"` NonTransactional bool `json:"non_transactional,omitempty"` Source string `json:"source,omitempty"` - Type string `json:type,omitempty` + Type string `json:"type,omitempty"` Description string `json:"description,omitempty"` Updated string `json:"updated,omitempty"` Created string `json:"created,omitempty"` } +// SuppressionListWrapper wraps suppression entries and response meta information type SuppressionListWrapper struct { Results []*SuppressionEntry `json:"results,omitempty"` Recipients []SuppressionEntry `json:"recipients,omitempty"` + TotalCount int `json:"total_count,omitempty"` + Links []struct { + Href string `json:"href"` + Rel string `json:"rel"` + } `json:"links,omitempty"` } +// SuppressionList retrieves the accounts suppression list func (c *Client) SuppressionList() (*SuppressionListWrapper, *Response, error) { return c.SuppressionListContext(context.Background()) } +// SuppressionListContext retrieves the accounts suppression list func (c *Client) SuppressionListContext(ctx context.Context) (*SuppressionListWrapper, *Response, error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) return c.suppressionGet(ctx, c.Config.BaseUrl+path) } +// SuppressionRetrieve fetches suppression entry for a specific email address func (c *Client) SuppressionRetrieve(email string) (*SuppressionListWrapper, *Response, error) { return c.SuppressionRetrieveContext(context.Background(), email) } +//SuppressionRetrieveContext fetches suppression entry for a specific email address func (c *Client) SuppressionRetrieveContext(ctx context.Context, email string) (*SuppressionListWrapper, *Response, error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) + finalURL := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) - return c.suppressionGet(ctx, finalUrl) + return c.suppressionGet(ctx, finalURL) } +// SuppressionSearch search for suppression entries func (c *Client) SuppressionSearch(params map[string]string) (*SuppressionListWrapper, *Response, error) { return c.SuppressionSearchContext(context.Background(), params) } +// SuppressionSearchContext search for suppression entries func (c *Client) SuppressionSearchContext(ctx context.Context, params map[string]string) (*SuppressionListWrapper, *Response, error) { - var finalUrl string + var finalURL string path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) if params == nil || len(params) == 0 { - finalUrl = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) + finalURL = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { args := url.Values{} for k, v := range params { args.Add(k, v) } - finalUrl = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, args.Encode()) + finalURL = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, args.Encode()) } - return c.suppressionGet(ctx, finalUrl) + return c.suppressionGet(ctx, finalURL) } +// SuppressionDelete deletes an entry from the suppression list func (c *Client) SuppressionDelete(email string) (res *Response, err error) { return c.SuppressionDeleteContext(context.Background(), email) } +// SuppressionDeleteContext deletes an entry from the suppression list func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (res *Response, err error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) + finalURL := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) - res, err = c.HttpDelete(ctx, finalUrl) + res, err = c.HttpDelete(ctx, finalURL) if err != nil { return res, err } @@ -114,15 +129,15 @@ func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []Suppres } path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - list := SuppressionListWrapper{nil, entries} + list := SuppressionListWrapper{nil, entries, 0, nil} jsonBytes, err := json.Marshal(list) if err != nil { return nil, err } - finalUrl := c.Config.BaseUrl + path - res, err := c.HttpPut(ctx, finalUrl, jsonBytes) + finalURL := c.Config.BaseUrl + path + res, err := c.HttpPut(ctx, finalURL, jsonBytes) if err != nil { return res, err } @@ -151,9 +166,10 @@ func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []Suppres return res, err } -func (c *Client) suppressionGet(ctx context.Context, finalUrl string) (*SuppressionListWrapper, *Response, error) { +// Wraps call to server and unmarshals response +func (c *Client) suppressionGet(ctx context.Context, finalURL string) (*SuppressionListWrapper, *Response, error) { // Send off our request - res, err := c.HttpGet(ctx, finalUrl) + res, err := c.HttpGet(ctx, finalURL) if err != nil { return nil, res, err } From a8890784ac1c66f0e440710110a04baac43bb38d Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Fri, 31 Mar 2017 10:22:34 -0500 Subject: [PATCH 088/152] Adds unit tests for suppression list response wrapper meta fields. --- suppression_list_test.go | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/suppression_list_test.go b/suppression_list_test.go index 9f37e9e..2346024 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -153,3 +153,70 @@ func TestSuppression_Get_separateList(t *testing.T) { t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", s.Results[0].Recipient) } } + +var suppressionListCursor string = `{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + { + "href": "The_HREF_Value1", + "rel": "first" + }, + { + "href": "The_HREF_Value2", + "rel": "next" + } + ], + "total_count": 44 +}` + +// Test parsing of separate suppression list results +func TestSuppression_links(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + path := fmt.Sprintf(sp.SuppressionListsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.Write([]byte(suppressionListCursor)) + }) + + // hit our local handler + s, res, err := testClient.SuppressionList() + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if s.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if s.TotalCount != 44 { + t.Errorf("SuppressionList GET returned %d results, expected %d", s.TotalCount, 44) + } else if len(s.Links) != 2 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(s.Links), 2) + } else if s.Links[0].Href != "The_HREF_Value1" { + t.Error("SuppressionList GET returned invalid link[0].Href") + } else if s.Links[1].Href != "The_HREF_Value2" { + t.Error("SuppressionList GET returned invalid link[1].Href") + } else if s.Links[0].Rel != "first" { + t.Error("SuppressionList GET returned invalid s.Links[0].Rel") + } else if s.Links[1].Rel != "next" { + t.Error("SuppressionList GET returned invalid s.Links[1].Rel") + } + +} From efab7148d394211f860d4b0a5a9a61649675522e Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 31 Mar 2017 09:37:18 -0600 Subject: [PATCH 089/152] fix comment; remove convenience method --- common.go | 2 +- templates.go | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/common.go b/common.go index b5785d7..b5c6e67 100644 --- a/common.go +++ b/common.go @@ -29,7 +29,7 @@ type Config struct { // Specifying your own http.Client gives you lots of control over how connections are made. // Clients are safe for concurrent (read-only) reuse by multiple goroutines. // Headers is useful to set subaccount (X-MSYS-SUBACCOUNT header) and any other custom headers. -// All calls to SetHeader must happen before Client is exposed to possible concurrent use. +// All changes to Headers must happen before Client is exposed to possible concurrent use. type Client struct { Config *Config Client *http.Client diff --git a/templates.go b/templates.go index bbc8e6d..45cd717 100644 --- a/templates.go +++ b/templates.go @@ -179,11 +179,6 @@ func (t *Template) Validate() error { return nil } -// SetHeaders is a convenience method which sets Template.Content.Headers to the provided map. -func (t *Template) SetHeaders(headers map[string]string) { - t.Content.Headers = headers -} - // TemplateCreate accepts a populated Template object, validates its Contents, // and performs an API call against the configured endpoint. func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err error) { From c328628087c64f6e095489595436b8571741c5a6 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 31 Mar 2017 11:39:00 -0600 Subject: [PATCH 090/152] add missing Transmission options; linting changes --- templates.go | 8 ++++---- transmissions.go | 27 ++++++++++++++++----------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/templates.go b/templates.go index 006af33..36a6721 100644 --- a/templates.go +++ b/templates.go @@ -57,7 +57,7 @@ type From struct { Name string } -// Options specifies settings to apply to this Template. +// TmplOptions specifies settings to apply to this Template. // These settings may be overridden in the Transmission API call. type TmplOptions struct { OpenTracking *bool `json:"open_tracking,omitempty"` @@ -65,7 +65,7 @@ type TmplOptions struct { Transactional *bool `json:"transactional,omitempty"` } -// Preview options contains the required subsitution_data object to +// PreviewOptions contains the required subsitution_data object to // preview a template type PreviewOptions struct { SubstitutionData map[string]interface{} `json:"substitution_data"` @@ -342,7 +342,7 @@ func (c *Client) TemplatesContext(ctx context.Context) ([]Template, *Response, e } else if list, ok := tlist["results"]; ok { return list, res, nil } - return nil, res, fmt.Errorf("Unexpected response to Template list") + err = fmt.Errorf("Unexpected response to Template list") } else { err = res.ParseResponse() @@ -355,7 +355,7 @@ func (c *Client) TemplatesContext(ctx context.Context) ([]Template, *Response, e return nil, res, err } } - return nil, res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } return nil, res, err diff --git a/transmissions.go b/transmissions.go index a578765..8fdabc5 100644 --- a/transmissions.go +++ b/transmissions.go @@ -10,7 +10,7 @@ import ( "time" ) -// https://www.sparkpost.com/api#/reference/transmissions +// TransmissionsPathFormat https://www.sparkpost.com/api#/reference/transmissions var TransmissionsPathFormat = "/api/v%d/transmissions" // Transmission is the JSON structure accepted by and returned from the SparkPost Transmissions API. @@ -32,8 +32,10 @@ type Transmission struct { NumInvalidRecipients *int `json:"num_invalid_recipients,omitempty"` } +// RFC3339 formats time.Time values as expected by the SparkPost API type RFC3339 time.Time +// MarshalJSON applies RFC3339 formatting func (r *RFC3339) MarshalJSON() ([]byte, error) { if r == nil { return json.Marshal(nil) @@ -41,14 +43,16 @@ func (r *RFC3339) MarshalJSON() ([]byte, error) { return json.Marshal(time.Time(*r).Format(time.RFC3339)) } -// Options specifies settings to apply to this Transmission. +// TxOptions specifies settings to apply to this Transmission. // If not specified, and present in TmplOptions, those values will be used. type TxOptions struct { TmplOptions StartTime *RFC3339 `json:"start_time,omitempty"` + Transactional *bool `json:"transactional,omitempty"` Sandbox *bool `json:"sandbox,omitempty"` - SkipSuppression string `json:"skip_suppression,omitempty"` + SkipSuppression *bool `json:"skip_suppression,omitempty"` + IPPool string `json:"ip_pool,omitempty"` InlineCSS *bool `json:"inline_css,omitempty"` } @@ -72,7 +76,7 @@ func ParseRecipients(recips interface{}) (ra *[]Recipient, err error) { return case map[string]string: - for k, _ := range rVal { + for k := range rVal { if strings.EqualFold(k, "list_id") { return } @@ -137,7 +141,7 @@ func ParseContent(content interface{}) (err error) { return fmt.Errorf("Transmission.Content objects must contain a key `template_id`") case map[string]string: - for k, _ := range rVal { + for k := range rVal { if strings.EqualFold(k, "template_id") { return nil } @@ -296,11 +300,10 @@ func (c *Client) TransmissionContext(ctx context.Context, t *Transmission) (*Res return res, err } return res, nil - } else { - return res, fmt.Errorf("Unexpected results structure in response") } + return res, fmt.Errorf("Unexpected results structure in response") } - return res, fmt.Errorf("Unexpected response to Transmission.Retrieve") + err = fmt.Errorf("Unexpected response to Transmission.Retrieve") } else { err = res.ParseResponse() @@ -313,7 +316,7 @@ func (c *Client) TransmissionContext(ctx context.Context, t *Transmission) (*Res return res, err } } - return res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } return res, err @@ -414,7 +417,7 @@ func (c *Client) TransmissionsContext(ctx context.Context, t *Transmission) ([]T } else if list, ok := tlist["results"]; ok { return list, res, nil } - return nil, res, fmt.Errorf("Unexpected response to Transmission list") + err = fmt.Errorf("Unexpected response to Transmission list") } else { err = res.ParseResponse() @@ -427,6 +430,8 @@ func (c *Client) TransmissionsContext(ctx context.Context, t *Transmission) ([]T return nil, res, err } } - return nil, res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } + + return nil, res, err } From 1d184d8daaa80c4f9ae44bcc49588aa7e4f295d1 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Fri, 31 Mar 2017 12:48:55 -0500 Subject: [PATCH 091/152] Adds code coverage to README for Issue #11 --- README.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.rst b/README.rst index 0cf8428..5a12a1f 100644 --- a/README.rst +++ b/README.rst @@ -14,9 +14,14 @@ SparkPost Go API client :target: https://travis-ci.org/SparkPost/gosparkpost :alt: Build Status +.. image:: https://coveralls.io/repos/SparkPost/gosparkpost/badge.svg?branch=master&service=github + :target: https://coveralls.io/github/SparkPost/gosparkpost?branch=master + :alt: Code Coverage + .. image:: http://slack.sparkpost.com/badge.svg :target: http://slack.sparkpost.com :alt: Slack Community + The official Go package for using the SparkPost API. From a2492466b3f97f1f27776929a376342f569b881b Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 31 Mar 2017 12:10:04 -0600 Subject: [PATCH 092/152] exclude standalone tools from code coverage report --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 74b2ae6..cf9d85e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,4 +5,4 @@ go: before_install: go get github.com/mattn/goveralls script: - - $HOME/gopath/bin/goveralls -service=travis-ci + - $HOME/gopath/bin/goveralls -service=travis-ci -ignore '(^|/)cmd/' From 4a73c435e8a3daa06a5abd1de3e3378f9d5137a8 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 31 Mar 2017 12:13:40 -0600 Subject: [PATCH 093/152] another try at path pattern --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index cf9d85e..c25dd9b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,4 +5,4 @@ go: before_install: go get github.com/mattn/goveralls script: - - $HOME/gopath/bin/goveralls -service=travis-ci -ignore '(^|/)cmd/' + - $HOME/gopath/bin/goveralls -service=travis-ci -ignore '/cmd/' From d8a818a3b290843120e968b2d471e4e1a191ca31 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Fri, 31 Mar 2017 13:16:12 -0500 Subject: [PATCH 094/152] Changes based on feedback from @yargevad --- suppression_list.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/suppression_list.go b/suppression_list.go index e70b4f9..5a925ec 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -10,7 +10,8 @@ import ( // SuppressionListsPathFormat https://developers.sparkpost.com/api/#/reference/suppression-list var SuppressionListsPathFormat = "/api/v%d/suppression-list" -// SuppressionEntry is an entry of the suppression list +// SuppressionEntry stores a recipient’s opt-out preferences. It is a list of recipient email addresses to which you do NOT want to send email. +// https://developers.sparkpost.com/api/suppression-list.html#header-list-entry-attributes type SuppressionEntry struct { // Email is used when list is stored Email string `json:"email,omitempty"` @@ -38,23 +39,27 @@ type SuppressionListWrapper struct { } `json:"links,omitempty"` } -// SuppressionList retrieves the accounts suppression list +// SuppressionList retrieves the account's suppression list. +// Suppression lists larger than 10,000 entries will need to use cursor to retrieve more results. +// See https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get func (c *Client) SuppressionList() (*SuppressionListWrapper, *Response, error) { return c.SuppressionListContext(context.Background()) } -// SuppressionListContext retrieves the accounts suppression list +// SuppressionListContext retrieves the account's suppression list func (c *Client) SuppressionListContext(ctx context.Context) (*SuppressionListWrapper, *Response, error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) return c.suppressionGet(ctx, c.Config.BaseUrl+path) } -// SuppressionRetrieve fetches suppression entry for a specific email address +// SuppressionRetrieve retrieves the suppression status for a specific recipient by specifying the recipient’s email address +// // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-retrieve,-delete,-insert-or-update-get func (c *Client) SuppressionRetrieve(email string) (*SuppressionListWrapper, *Response, error) { return c.SuppressionRetrieveContext(context.Background(), email) } -//SuppressionRetrieveContext fetches suppression entry for a specific email address +//SuppressionRetrieveContext retrieves the suppression status for a specific recipient by specifying the recipient’s email address +// // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-retrieve,-delete,-insert-or-update-get func (c *Client) SuppressionRetrieveContext(ctx context.Context, email string) (*SuppressionListWrapper, *Response, error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) finalURL := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) @@ -62,12 +67,14 @@ func (c *Client) SuppressionRetrieveContext(ctx context.Context, email string) ( return c.suppressionGet(ctx, finalURL) } -// SuppressionSearch search for suppression entries +// SuppressionSearch search for suppression entries. For a list of parameters see +// https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get func (c *Client) SuppressionSearch(params map[string]string) (*SuppressionListWrapper, *Response, error) { return c.SuppressionSearchContext(context.Background(), params) } -// SuppressionSearchContext search for suppression entries +// SuppressionSearchContext search for suppression entries. For a list of parameters see +// https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get func (c *Client) SuppressionSearchContext(ctx context.Context, params map[string]string) (*SuppressionListWrapper, *Response, error) { var finalURL string path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) From b87652e4f66199f62ed6235ec871593d4efe58fe Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 31 Mar 2017 12:17:15 -0600 Subject: [PATCH 095/152] anchor at the beginning of the relative path... third try's the charm? --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index c25dd9b..d138be4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,4 +5,4 @@ go: before_install: go get github.com/mattn/goveralls script: - - $HOME/gopath/bin/goveralls -service=travis-ci -ignore '/cmd/' + - $HOME/gopath/bin/goveralls -service=travis-ci -ignore '^cmd/' From 6f8cce508fd6186a859f27d437bf65d527081ba2 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 31 Mar 2017 12:41:23 -0600 Subject: [PATCH 096/152] take note, apparently the fourth time is the charm. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d138be4..7212ea6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,4 +5,4 @@ go: before_install: go get github.com/mattn/goveralls script: - - $HOME/gopath/bin/goveralls -service=travis-ci -ignore '^cmd/' + - $HOME/gopath/bin/goveralls -service=travis-ci -ignore 'cmd/*/*.go' From f8be189f81d92e613cd25207359b7165725962ee Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 31 Mar 2017 12:49:04 -0600 Subject: [PATCH 097/152] exclude another ~100 lines of example and cmd-helper code --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7212ea6..4cbcf43 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,4 +5,4 @@ go: before_install: go get github.com/mattn/goveralls script: - - $HOME/gopath/bin/goveralls -service=travis-ci -ignore 'cmd/*/*.go' + - $HOME/gopath/bin/goveralls -service=travis-ci -ignore 'cmd/*/*.go' -ignore 'examples/*/*.go' -ignore 'helpers/*/*.go' From 068860e3c538e22d177ee5e32ca772985f0b0890 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 31 Mar 2017 12:50:55 -0600 Subject: [PATCH 098/152] multiple patterns should be comma-separated --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 4cbcf43..469dc83 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,4 +5,4 @@ go: before_install: go get github.com/mattn/goveralls script: - - $HOME/gopath/bin/goveralls -service=travis-ci -ignore 'cmd/*/*.go' -ignore 'examples/*/*.go' -ignore 'helpers/*/*.go' + - $HOME/gopath/bin/goveralls -service=travis-ci -ignore 'cmd/*/*.go,examples/*/*.go,helpers/*/*.go' From 03188548d729452378524788bab32e7ccf2b5f0f Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Tue, 4 Apr 2017 10:39:16 -0500 Subject: [PATCH 099/152] Extracts test JSON to files to simplify test code. --- suppression_list.go | 59 +++++++------ suppression_list_test.go | 98 +++++----------------- test_data/suppress_list_simple.json | 0 test_data/suppression_combined.json | 13 +++ test_data/suppression_cursor.json | 24 ++++++ test_data/suppression_not_found_error.json | 7 ++ test_data/suppression_seperate_lists.json | 24 ++++++ 7 files changed, 124 insertions(+), 101 deletions(-) create mode 100644 test_data/suppress_list_simple.json create mode 100644 test_data/suppression_combined.json create mode 100644 test_data/suppression_cursor.json create mode 100644 test_data/suppression_not_found_error.json create mode 100644 test_data/suppression_seperate_lists.json diff --git a/suppression_list.go b/suppression_list.go index 5a925ec..62fdb6d 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -28,54 +28,61 @@ type SuppressionEntry struct { Created string `json:"created,omitempty"` } -// SuppressionListWrapper wraps suppression entries and response meta information -type SuppressionListWrapper struct { +// SuppressionPage wraps suppression entries and response meta information +type SuppressionPage struct { + client *Client + Results []*SuppressionEntry `json:"results,omitempty"` Recipients []SuppressionEntry `json:"recipients,omitempty"` - TotalCount int `json:"total_count,omitempty"` - Links []struct { + Errors []interface{} + + TotalCount int `json:"total_count,omitempty"` + + Links []struct { Href string `json:"href"` Rel string `json:"rel"` } `json:"links,omitempty"` + + Params map[string]string `json:"-"` } // SuppressionList retrieves the account's suppression list. // Suppression lists larger than 10,000 entries will need to use cursor to retrieve more results. // See https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get -func (c *Client) SuppressionList() (*SuppressionListWrapper, *Response, error) { - return c.SuppressionListContext(context.Background()) +func (c *Client) SuppressionList(sp *SuppressionPage) (*Response, error) { + return c.SuppressionListContext(context.Background(), sp) } // SuppressionListContext retrieves the account's suppression list -func (c *Client) SuppressionListContext(ctx context.Context) (*SuppressionListWrapper, *Response, error) { +func (c *Client) SuppressionListContext(ctx context.Context, sp *SuppressionPage) (*Response, error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - return c.suppressionGet(ctx, c.Config.BaseUrl+path) + return c.suppressionGet(ctx, c.Config.BaseUrl+path, sp) } // SuppressionRetrieve retrieves the suppression status for a specific recipient by specifying the recipient’s email address // // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-retrieve,-delete,-insert-or-update-get -func (c *Client) SuppressionRetrieve(email string) (*SuppressionListWrapper, *Response, error) { - return c.SuppressionRetrieveContext(context.Background(), email) +func (c *Client) SuppressionRetrieve(email string, sp *SuppressionPage) (*Response, error) { + return c.SuppressionRetrieveContext(context.Background(), email, sp) } //SuppressionRetrieveContext retrieves the suppression status for a specific recipient by specifying the recipient’s email address // // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-retrieve,-delete,-insert-or-update-get -func (c *Client) SuppressionRetrieveContext(ctx context.Context, email string) (*SuppressionListWrapper, *Response, error) { +func (c *Client) SuppressionRetrieveContext(ctx context.Context, email string, sp *SuppressionPage) (*Response, error) { path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) finalURL := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) - return c.suppressionGet(ctx, finalURL) + return c.suppressionGet(ctx, finalURL, sp) } // SuppressionSearch search for suppression entries. For a list of parameters see // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get -func (c *Client) SuppressionSearch(params map[string]string) (*SuppressionListWrapper, *Response, error) { - return c.SuppressionSearchContext(context.Background(), params) +func (c *Client) SuppressionSearch(params map[string]string, sp *SuppressionPage) (*Response, error) { + return c.SuppressionSearchContext(context.Background(), params, sp) } // SuppressionSearchContext search for suppression entries. For a list of parameters see // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get -func (c *Client) SuppressionSearchContext(ctx context.Context, params map[string]string) (*SuppressionListWrapper, *Response, error) { +func (c *Client) SuppressionSearchContext(ctx context.Context, params map[string]string, sp *SuppressionPage) (*Response, error) { var finalURL string path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) @@ -90,7 +97,7 @@ func (c *Client) SuppressionSearchContext(ctx context.Context, params map[string finalURL = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, args.Encode()) } - return c.suppressionGet(ctx, finalURL) + return c.suppressionGet(ctx, finalURL, sp) } // SuppressionDelete deletes an entry from the suppression list @@ -136,7 +143,7 @@ func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []Suppres } path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - list := SuppressionListWrapper{nil, entries, 0, nil} + list := SuppressionPage{} jsonBytes, err := json.Marshal(list) if err != nil { @@ -174,36 +181,36 @@ func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []Suppres } // Wraps call to server and unmarshals response -func (c *Client) suppressionGet(ctx context.Context, finalURL string) (*SuppressionListWrapper, *Response, error) { +func (c *Client) suppressionGet(ctx context.Context, finalURL string, sp *SuppressionPage) (*Response, error) { // Send off our request res, err := c.HttpGet(ctx, finalURL) if err != nil { - return nil, res, err + return res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, res, err + return res, err } err = res.ParseResponse() if err != nil { - return nil, res, err + return res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, res, err + return res, err } // Parse expected response structure - var resMap SuppressionListWrapper - err = json.Unmarshal(bodyBytes, &resMap) + // var resMap SuppressionListWrapper + err = json.Unmarshal(bodyBytes, sp) if err != nil { - return nil, res, err + return res, err } - return &resMap, res, err + return res, err } diff --git a/suppression_list_test.go b/suppression_list_test.go index 2346024..7cb3728 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -2,19 +2,14 @@ package gosparkpost_test import ( "fmt" + "io/ioutil" "net/http" "testing" sp "github.com/SparkPost/gosparkpost" ) -var suppressionNotFound string = `{ - "errors": [ - { - "message": "Recipient could not be found" - } - ] -}` +var suppressionNotFound = loadTestFile("test_data/suppression_not_found_error.json") // Test parsing of "not found" case func TestSuppression_Get_notFound(t *testing.T) { @@ -31,37 +26,27 @@ func TestSuppression_Get_notFound(t *testing.T) { }) // hit our local handler - s, res, err := testClient.SuppressionList() + suppressionPage := &sp.SuppressionPage{} + + res, err := testClient.SuppressionList(suppressionPage) if err != nil { testFailVerbose(t, res, "SuppressionList GET returned error: %v", err) } // basic content test - if s.Results != nil { + if suppressionPage.Results != nil { testFailVerbose(t, res, "SuppressionList GET returned non-nil Results (error expected)") - } else if len(s.Results) != 0 { - testFailVerbose(t, res, "SuppressionList GET returned %d results, expected %d", len(s.Results), 0) - } else if len(res.Errors) != 1 { - testFailVerbose(t, res, "SuppressionList GET returned %d errors, expected %d", len(res.Errors), 1) - } else if res.Errors[0].Message != "Recipient could not be found" { + } else if len(suppressionPage.Results) != 0 { + testFailVerbose(t, res, "SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 0) + } else if len(suppressionPage.Errors) != 1 { + testFailVerbose(t, res, "SuppressionList GET returned %d errors, expected %d", len(suppressionPage.Errors), 1) + } else if suppressionPage.Errors[0].Message != "Recipient could not be found" { testFailVerbose(t, res, "SuppressionList GET Unmarshal error; saw [%v] expected [%v]", res.Errors[0].Message, "Recipient could not be found") } } -var combinedSuppressionList string = `{ - "results": [ - { - "recipient": "rcpt_1@example.com", - "transactional": true, - "non_transactional": true, - "source": "Manually Added", - "description": "User requested to not receive any non-transactional emails.", - "created": "2016-01-01T12:00:00+00:00", - "updated": "2016-01-01T12:00:00+00:00" - } - ] -}` +var combinedSuppressionList = loadTestFile("test_data/suppression_combined.json") // Test parsing of combined suppression list results func TestSuppression_Get_combinedList(t *testing.T) { @@ -96,30 +81,7 @@ func TestSuppression_Get_combinedList(t *testing.T) { } } -var separateSuppressionList string = `{ - "results": [ - { - "recipient": "rcpt_1@example.com", - "non_transactional": true, - "source": "Manually Added", - "type": "non_transactional", - "description": "User requested to not receive any non-transactional emails.", - "created": "2016-01-01T12:00:00+00:00", - "updated": "2016-01-01T12:00:00+00:00" - }, - { - "recipient": "rcpt_1@example.com", - "transactional": true, - "source": "Bounce Rule", - "type": "transactional", - "description": "550: 550-5.1.1 Invalid Recipient", - "created": "2015-10-15T12:00:00+00:00", - "updated": "2015-10-15T12:00:00+00:00" - } - ], - "links": [], - "total_count": 2 -}` +var separateSuppressionList = loadTestFile("test_data/suppression_seperate_lists.json") // Test parsing of separate suppression list results func TestSuppression_Get_separateList(t *testing.T) { @@ -154,30 +116,7 @@ func TestSuppression_Get_separateList(t *testing.T) { } } -var suppressionListCursor string = `{ - "results": [ - { - "recipient": "rcpt_1@example.com", - "transactional": true, - "non_transactional": true, - "source": "Manually Added", - "description": "User requested to not receive any non-transactional emails.", - "created": "2016-01-01T12:00:00+00:00", - "updated": "2016-01-01T12:00:00+00:00" - } - ], - "links": [ - { - "href": "The_HREF_Value1", - "rel": "first" - }, - { - "href": "The_HREF_Value2", - "rel": "next" - } - ], - "total_count": 44 -}` +var suppressionListCursor = loadTestFile("test_data/suppression_cursor.json") // Test parsing of separate suppression list results func TestSuppression_links(t *testing.T) { @@ -220,3 +159,12 @@ func TestSuppression_links(t *testing.T) { } } + +func loadTestFile(fileToLoad string) string { + b, err := ioutil.ReadFile(fileToLoad) + if err != nil { + fmt.Print(err) + } + + return string(b) +} diff --git a/test_data/suppress_list_simple.json b/test_data/suppress_list_simple.json new file mode 100644 index 0000000..e69de29 diff --git a/test_data/suppression_combined.json b/test_data/suppression_combined.json new file mode 100644 index 0000000..f2df11b --- /dev/null +++ b/test_data/suppression_combined.json @@ -0,0 +1,13 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ] +} \ No newline at end of file diff --git a/test_data/suppression_cursor.json b/test_data/suppression_cursor.json new file mode 100644 index 0000000..b802b55 --- /dev/null +++ b/test_data/suppression_cursor.json @@ -0,0 +1,24 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + { + "href": "The_HREF_Value1", + "rel": "first" + }, + { + "href": "The_HREF_Value2", + "rel": "next" + } + ], + "total_count": 44 +} \ No newline at end of file diff --git a/test_data/suppression_not_found_error.json b/test_data/suppression_not_found_error.json new file mode 100644 index 0000000..61951a1 --- /dev/null +++ b/test_data/suppression_not_found_error.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "message": "Recipient could not be found" + } + ] +} \ No newline at end of file diff --git a/test_data/suppression_seperate_lists.json b/test_data/suppression_seperate_lists.json new file mode 100644 index 0000000..f9f78be --- /dev/null +++ b/test_data/suppression_seperate_lists.json @@ -0,0 +1,24 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "non_transactional": true, + "source": "Manually Added", + "type": "non_transactional", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + }, + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "source": "Bounce Rule", + "type": "transactional", + "description": "550: 550-5.1.1 Invalid Recipient", + "created": "2015-10-15T12:00:00+00:00", + "updated": "2015-10-15T12:00:00+00:00" + } + ], + "links": [], + "total_count": 2 +} \ No newline at end of file From f4393f95d1cf6bd35d99b20e8deaef703e345d86 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Tue, 4 Apr 2017 17:19:29 -0500 Subject: [PATCH 100/152] Adding suppression list cursor abstractions and more unit tests for suppression list --- .gitignore | 3 + common_test.go | 11 ++ suppression_list.go | 58 +++++++- suppression_list_test.go | 183 ++++++++++++++++--------- test_data/suppress_list_simple.json | 0 test_data/suppression_cursor.json | 11 +- test_data/suppression_page1.json | 28 ++++ test_data/suppression_page2.json | 32 +++++ test_data/suppression_pageLast.json | 28 ++++ test_data/suppression_single_page.json | 17 +++ 10 files changed, 300 insertions(+), 71 deletions(-) delete mode 100644 test_data/suppress_list_simple.json create mode 100644 test_data/suppression_page1.json create mode 100644 test_data/suppression_page2.json create mode 100644 test_data/suppression_pageLast.json create mode 100644 test_data/suppression_single_page.json diff --git a/.gitignore b/.gitignore index 210eb8f..b7282fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ **/.*.swp +.vscode + +**/*.test diff --git a/common_test.go b/common_test.go index 9b9616e..fe3325b 100644 --- a/common_test.go +++ b/common_test.go @@ -2,6 +2,8 @@ package gosparkpost_test import ( "crypto/tls" + "fmt" + "io/ioutil" "net/http" "net/http/httptest" "net/url" @@ -53,3 +55,12 @@ func testFailVerbose(t *testing.T, res *sp.Response, fmt string, args ...interfa } t.Fatalf(fmt, args...) } + +func loadTestFile(fileToLoad string) string { + b, err := ioutil.ReadFile(fileToLoad) + if err != nil { + fmt.Print(err) + } + + return string(b) +} diff --git a/suppression_list.go b/suppression_list.go index 62fdb6d..2da7e75 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -34,10 +34,17 @@ type SuppressionPage struct { Results []*SuppressionEntry `json:"results,omitempty"` Recipients []SuppressionEntry `json:"recipients,omitempty"` - Errors []interface{} + Errors []struct { + Message string `json:"message,omitempty"` + } `json:"errors,omitempty"` TotalCount int `json:"total_count,omitempty"` + NextPage string + PrevPage string + FirstPage string + LastPage string + Links []struct { Href string `json:"href"` Rel string `json:"rel"` @@ -76,21 +83,21 @@ func (c *Client) SuppressionRetrieveContext(ctx context.Context, email string, s // SuppressionSearch search for suppression entries. For a list of parameters see // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get -func (c *Client) SuppressionSearch(params map[string]string, sp *SuppressionPage) (*Response, error) { - return c.SuppressionSearchContext(context.Background(), params, sp) +func (c *Client) SuppressionSearch(sp *SuppressionPage) (*Response, error) { + return c.SuppressionSearchContext(context.Background(), sp) } // SuppressionSearchContext search for suppression entries. For a list of parameters see // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get -func (c *Client) SuppressionSearchContext(ctx context.Context, params map[string]string, sp *SuppressionPage) (*Response, error) { +func (c *Client) SuppressionSearchContext(ctx context.Context, sp *SuppressionPage) (*Response, error) { var finalURL string path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - if params == nil || len(params) == 0 { + if sp.Params == nil || len(sp.Params) == 0 { finalURL = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { args := url.Values{} - for k, v := range params { + for k, v := range sp.Params { args.Add(k, v) } @@ -100,6 +107,25 @@ func (c *Client) SuppressionSearchContext(ctx context.Context, params map[string return c.suppressionGet(ctx, finalURL, sp) } +// Next returns the next page of results from a previous MessageEventsSearch call +func (sp *SuppressionPage) Next() (*SuppressionPage, *Response, error) { + return sp.NextContext(context.Background()) +} + +// NextContext is the same as Next, and it accepts a context.Context +func (sp *SuppressionPage) NextContext(ctx context.Context) (*SuppressionPage, *Response, error) { + if sp.NextPage == "" { + return nil, nil, nil + } + + suppressionPage := &SuppressionPage{} + suppressionPage.client = sp.client + finalURL := fmt.Sprintf("%s", sp.client.Config.BaseUrl+sp.NextPage) + res, err := sp.client.suppressionGet(ctx, finalURL, suppressionPage) + + return suppressionPage, res, err +} + // SuppressionDelete deletes an entry from the suppression list func (c *Client) SuppressionDelete(email string) (res *Response, err error) { return c.SuppressionDeleteContext(context.Background(), email) @@ -182,6 +208,7 @@ func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []Suppres // Wraps call to server and unmarshals response func (c *Client) suppressionGet(ctx context.Context, finalURL string, sp *SuppressionPage) (*Response, error) { + // Send off our request res, err := c.HttpGet(ctx, finalURL) if err != nil { @@ -205,9 +232,26 @@ func (c *Client) suppressionGet(ctx context.Context, finalURL string, sp *Suppre } // Parse expected response structure - // var resMap SuppressionListWrapper err = json.Unmarshal(bodyBytes, sp) + // For usage convenience parse out common links + for _, link := range sp.Links { + switch link.Rel { + case "next": + sp.NextPage = link.Href + case "previous": + sp.PrevPage = link.Href + case "first": + sp.FirstPage = link.Href + case "last": + sp.LastPage = link.Href + } + } + + if sp.client == nil { + sp.client = c + } + if err != nil { return res, err } diff --git a/suppression_list_test.go b/suppression_list_test.go index 7cb3728..609fcb8 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -2,32 +2,23 @@ package gosparkpost_test import ( "fmt" - "io/ioutil" "net/http" "testing" sp "github.com/SparkPost/gosparkpost" ) -var suppressionNotFound = loadTestFile("test_data/suppression_not_found_error.json") - // Test parsing of "not found" case func TestSuppression_Get_notFound(t *testing.T) { testSetup(t) defer testTeardown() // set up the response handler - path := fmt.Sprintf(sp.SuppressionListsPathFormat, testClient.Config.ApiVersion) - testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - w.Header().Set("Content-Type", "application/json; charset=utf8") - w.WriteHeader(http.StatusNotFound) // 404 - w.Write([]byte(suppressionNotFound)) - }) + var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") + mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) // hit our local handler suppressionPage := &sp.SuppressionPage{} - res, err := testClient.SuppressionList(suppressionPage) if err != nil { testFailVerbose(t, res, "SuppressionList GET returned error: %v", err) @@ -46,23 +37,18 @@ func TestSuppression_Get_notFound(t *testing.T) { } } -var combinedSuppressionList = loadTestFile("test_data/suppression_combined.json") - // Test parsing of combined suppression list results func TestSuppression_Get_combinedList(t *testing.T) { testSetup(t) defer testTeardown() // set up the response handler - path := fmt.Sprintf(sp.SuppressionListsPathFormat, testClient.Config.ApiVersion) - testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - w.Header().Set("Content-Type", "application/json; charset=utf8") - w.Write([]byte(combinedSuppressionList)) - }) + var mockResponse = loadTestFile("test_data/suppression_combined.json") + mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) // hit our local handler - s, res, err := testClient.SuppressionList() + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) if err != nil { t.Errorf("SuppressionList GET returned error: %v", err) for _, e := range res.Verbose { @@ -72,32 +58,27 @@ func TestSuppression_Get_combinedList(t *testing.T) { } // basic content test - if s.Results == nil { + if suppressionPage.Results == nil { t.Error("SuppressionList GET returned nil Results") - } else if len(s.Results) != 1 { - t.Errorf("SuppressionList GET returned %d results, expected %d", len(s.Results), 1) - } else if s.Results[0].Recipient != "rcpt_1@example.com" { - t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", s.Results[0].Recipient) + } else if len(suppressionPage.Results) != 1 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 1) + } else if suppressionPage.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", suppressionPage.Results[0].Recipient) } } -var separateSuppressionList = loadTestFile("test_data/suppression_seperate_lists.json") - // Test parsing of separate suppression list results func TestSuppression_Get_separateList(t *testing.T) { testSetup(t) defer testTeardown() // set up the response handler - path := fmt.Sprintf(sp.SuppressionListsPathFormat, testClient.Config.ApiVersion) - testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - w.Header().Set("Content-Type", "application/json; charset=utf8") - w.Write([]byte(separateSuppressionList)) - }) + var mockResponse = loadTestFile("test_data/suppression_seperate_lists.json") + mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) // hit our local handler - s, res, err := testClient.SuppressionList() + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) if err != nil { t.Errorf("SuppressionList GET returned error: %v", err) for _, e := range res.Verbose { @@ -107,32 +88,27 @@ func TestSuppression_Get_separateList(t *testing.T) { } // basic content test - if s.Results == nil { + if suppressionPage.Results == nil { t.Error("SuppressionList GET returned nil Results") - } else if len(s.Results) != 2 { - t.Errorf("SuppressionList GET returned %d results, expected %d", len(s.Results), 2) - } else if s.Results[0].Recipient != "rcpt_1@example.com" { - t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", s.Results[0].Recipient) + } else if len(suppressionPage.Results) != 2 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 2) + } else if suppressionPage.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", suppressionPage.Results[0].Recipient) } } -var suppressionListCursor = loadTestFile("test_data/suppression_cursor.json") - -// Test parsing of separate suppression list results +// Tests that links are generally parsed properly func TestSuppression_links(t *testing.T) { testSetup(t) defer testTeardown() // set up the response handler - path := fmt.Sprintf(sp.SuppressionListsPathFormat, testClient.Config.ApiVersion) - testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - w.Header().Set("Content-Type", "application/json; charset=utf8") - w.Write([]byte(suppressionListCursor)) - }) + var mockResponse = loadTestFile("test_data/suppression_cursor.json") + mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) // hit our local handler - s, res, err := testClient.SuppressionList() + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) if err != nil { t.Errorf("SuppressionList GET returned error: %v", err) for _, e := range res.Verbose { @@ -142,29 +118,112 @@ func TestSuppression_links(t *testing.T) { } // basic content test - if s.Results == nil { + if suppressionPage.Results == nil { t.Error("SuppressionList GET returned nil Results") - } else if s.TotalCount != 44 { - t.Errorf("SuppressionList GET returned %d results, expected %d", s.TotalCount, 44) - } else if len(s.Links) != 2 { - t.Errorf("SuppressionList GET returned %d results, expected %d", len(s.Links), 2) - } else if s.Links[0].Href != "The_HREF_Value1" { + } else if suppressionPage.TotalCount != 44 { + t.Errorf("SuppressionList GET returned %d results, expected %d", suppressionPage.TotalCount, 44) + } else if len(suppressionPage.Links) != 4 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Links), 2) + } else if suppressionPage.Links[0].Href != "The_HREF_first" { t.Error("SuppressionList GET returned invalid link[0].Href") - } else if s.Links[1].Href != "The_HREF_Value2" { + } else if suppressionPage.Links[1].Href != "The_HREF_next" { t.Error("SuppressionList GET returned invalid link[1].Href") - } else if s.Links[0].Rel != "first" { + } else if suppressionPage.Links[0].Rel != "first" { t.Error("SuppressionList GET returned invalid s.Links[0].Rel") - } else if s.Links[1].Rel != "next" { + } else if suppressionPage.Links[1].Rel != "next" { t.Error("SuppressionList GET returned invalid s.Links[1].Rel") } + // Check convenience links + if suppressionPage.FirstPage != "The_HREF_first" { + t.Errorf("Unexpected FirstPage value: %s", suppressionPage.FirstPage) + } else if suppressionPage.LastPage != "The_HREF_last" { + t.Errorf("Unexpected LastPage value: %s", suppressionPage.LastPage) + } else if suppressionPage.PrevPage != "The_HREF_previous" { + t.Errorf("Unexpected PrevPage value: %s", suppressionPage.PrevPage) + } else if suppressionPage.NextPage != "The_HREF_next" { + t.Errorf("Unexpected NextPage value: %s", suppressionPage.NextPage) + } + +} + +func TestSuppression_Empty_NextPage(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile("test_data/suppression_single_page.json") + mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + nextResponse, res, err := suppressionPage.Next() + + if nextResponse != nil { + t.Errorf("nextResponse should be nil but was: %v", nextResponse) + } else if res != nil { + t.Errorf("Response should be nil but was: %v", res) + } else if err != nil { + t.Errorf("Error should be nil but was: %v", err) + } } -func loadTestFile(fileToLoad string) string { - b, err := ioutil.ReadFile(fileToLoad) +// +func TestSuppression_NextPage(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile("test_data/suppression_page1.json") + mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) + + mockResponse = loadTestFile("test_data/suppression_page2.json") + mockRestBuilder(t, "/test_data/suppression_page2.json", mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) if err != nil { - fmt.Print(err) + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + if suppressionPage.NextPage != "/test_data/suppression_page2.json" { + t.Errorf("Unexpected NextPage value: %s", suppressionPage.NextPage) } - return string(b) + nextResponse, res, err := suppressionPage.Next() + + if nextResponse.NextPage != "/test_data/suppression_pageLast.json" { + t.Errorf("Unexpected NextPage value: %s", nextResponse.NextPage) + } +} + +func mockRestBuilderFormat(t *testing.T, pathFormat string, mockResponse string) { + path := fmt.Sprintf(pathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.Write([]byte(mockResponse)) + }) +} + +func mockRestBuilder(t *testing.T, path string, mockResponse string) { + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.Write([]byte(mockResponse)) + }) } diff --git a/test_data/suppress_list_simple.json b/test_data/suppress_list_simple.json deleted file mode 100644 index e69de29..0000000 diff --git a/test_data/suppression_cursor.json b/test_data/suppression_cursor.json index b802b55..0e79b7d 100644 --- a/test_data/suppression_cursor.json +++ b/test_data/suppression_cursor.json @@ -12,12 +12,19 @@ ], "links": [ { - "href": "The_HREF_Value1", + "href": "The_HREF_first", "rel": "first" }, { - "href": "The_HREF_Value2", + "href": "The_HREF_next", "rel": "next" + },{ + "href": "The_HREF_previous", + "rel": "previous" + }, + { + "href": "The_HREF_last", + "rel": "last" } ], "total_count": 44 diff --git a/test_data/suppression_page1.json b/test_data/suppression_page1.json new file mode 100644 index 0000000..716cd9a --- /dev/null +++ b/test_data/suppression_page1.json @@ -0,0 +1,28 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + { + "href": "/test_data/suppression_page1.json", + "rel": "first" + }, + { + "href": "/test_data/suppression_page2.json", + "rel": "next" + }, + { + "href": "/test_data/suppression_pageLast.json", + "rel": "last" + } + ], + "total_count": 3 +} \ No newline at end of file diff --git a/test_data/suppression_page2.json b/test_data/suppression_page2.json new file mode 100644 index 0000000..94bd59d --- /dev/null +++ b/test_data/suppression_page2.json @@ -0,0 +1,32 @@ +{ + "results": [ + { + "recipient": "rcpt_2@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + { + "href": "/test_data/suppression_page1.json", + "rel": "first" + }, + { + "href": "/test_data/suppression_pageLast.json", + "rel": "next" + }, + { + "href": "/test_data/suppression_page1.json", + "rel": "previous" + }, + { + "href": "/test_data/suppression_pageLast.json", + "rel": "last" + } + ], + "total_count": 3 +} \ No newline at end of file diff --git a/test_data/suppression_pageLast.json b/test_data/suppression_pageLast.json new file mode 100644 index 0000000..64680c1 --- /dev/null +++ b/test_data/suppression_pageLast.json @@ -0,0 +1,28 @@ +{ + "results": [ + { + "recipient": "rcpt_3@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + { + "href": "/test_data/suppression_page1.json", + "rel": "first" + }, + { + "href": "/test_data/suppression_page2.json", + "rel": "previous" + }, + { + "href": "/test_data/suppression_pageLast.json", + "rel": "last" + } + ], + "total_count": 3 +} \ No newline at end of file diff --git a/test_data/suppression_single_page.json b/test_data/suppression_single_page.json new file mode 100644 index 0000000..8ee9c21 --- /dev/null +++ b/test_data/suppression_single_page.json @@ -0,0 +1,17 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + + ], + "total_count": 1 +} \ No newline at end of file From a630d710d3da64add7932ac229e3c0a1fd9feef1 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Tue, 4 Apr 2017 22:34:20 -0500 Subject: [PATCH 101/152] Adding more unit tests for suppression list --- suppression_list_test.go | 61 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/suppression_list_test.go b/suppression_list_test.go index 609fcb8..9e940ff 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -37,6 +37,61 @@ func TestSuppression_Get_notFound(t *testing.T) { } } +func TestSuppression_Error_Bad_Path(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") + mockRestBuilderFormat(t, "/bad/path", mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) + if err.Error() != "Expected json, got [text/plain] with code 404" { + testFailVerbose(t, res, "SuppressionList GET returned error: %v", err) + } else if res.HTTP.StatusCode != 404 { + testFailVerbose(t, res, "Expected a 404 error: %v", res) + } + +} + +func TestSuppression_Error_Bad_JSON(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, "ThisIsBadJSON") + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + + // Bad JSON should generate an Error + res, err := testClient.SuppressionList(suppressionPage) + if err == nil { + testFailVerbose(t, res, "Expected an error due to bad JSON: %v", err) + } + +} + +func TestSuppression_Error_Wrong_JSON(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, "{\"errors\":\"\"") + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + + // Bad JSON should generate an Error + res, err := testClient.SuppressionList(suppressionPage) + if err == nil { + testFailVerbose(t, res, "Expected an error due to bad JSON: %v", err) + } + +} + // Test parsing of combined suppression list results func TestSuppression_Get_combinedList(t *testing.T) { testSetup(t) @@ -213,11 +268,7 @@ func TestSuppression_NextPage(t *testing.T) { func mockRestBuilderFormat(t *testing.T, pathFormat string, mockResponse string) { path := fmt.Sprintf(pathFormat, testClient.Config.ApiVersion) - testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - w.Header().Set("Content-Type", "application/json; charset=utf8") - w.Write([]byte(mockResponse)) - }) + mockRestBuilder(t, path, mockResponse) } func mockRestBuilder(t *testing.T, path string, mockResponse string) { From fe84652a4a15cf71831c38c9ffe4ae43804cf00b Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Wed, 5 Apr 2017 10:30:59 -0500 Subject: [PATCH 102/152] Adds tests for suppression list upsert and delete and fixes some minor bugs in lib --- suppression_list.go | 14 +- suppression_list_test.go | 213 +++++++++++++++++++-- test_data/suppression_delete_no_email.json | 7 + test_data/suppression_entry_simple.json | 9 + 4 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 test_data/suppression_delete_no_email.json create mode 100644 test_data/suppression_entry_simple.json diff --git a/suppression_list.go b/suppression_list.go index 2da7e75..7b3ffda 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -133,6 +133,11 @@ func (c *Client) SuppressionDelete(email string) (res *Response, err error) { // SuppressionDeleteContext deletes an entry from the suppression list func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (res *Response, err error) { + if email == "" { + err = fmt.Errorf("Deleting a suppression entry requires an email address") + return nil, err + } + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) finalURL := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) @@ -141,6 +146,11 @@ func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (re return res, err } + // If there are errors the response has JSON otherwise it is empty + if res.AssertJson != nil { + res.ParseResponse() + } + if res.HTTP.StatusCode >= 200 && res.HTTP.StatusCode <= 299 { return res, err @@ -169,9 +179,9 @@ func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []Suppres } path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - list := SuppressionPage{} + suppressionPage := SuppressionPage{} - jsonBytes, err := json.Marshal(list) + jsonBytes, err := json.Marshal(suppressionPage) if err != nil { return nil, err } diff --git a/suppression_list_test.go b/suppression_list_test.go index 9e940ff..1a797e7 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -15,7 +15,7 @@ func TestSuppression_Get_notFound(t *testing.T) { // set up the response handler var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") - mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -43,7 +43,7 @@ func TestSuppression_Error_Bad_Path(t *testing.T) { // set up the response handler var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") - mockRestBuilderFormat(t, "/bad/path", mockResponse) + mockRestBuilderFormat(t, "GET", "/bad/path", mockResponse) // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -61,7 +61,7 @@ func TestSuppression_Error_Bad_JSON(t *testing.T) { defer testTeardown() // set up the response handler - mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, "ThisIsBadJSON") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, "ThisIsBadJSON") // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -79,7 +79,7 @@ func TestSuppression_Error_Wrong_JSON(t *testing.T) { defer testTeardown() // set up the response handler - mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, "{\"errors\":\"\"") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, "{\"errors\":\"\"") // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -99,7 +99,7 @@ func TestSuppression_Get_combinedList(t *testing.T) { // set up the response handler var mockResponse = loadTestFile("test_data/suppression_combined.json") - mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -129,7 +129,7 @@ func TestSuppression_Get_separateList(t *testing.T) { // set up the response handler var mockResponse = loadTestFile("test_data/suppression_seperate_lists.json") - mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -159,7 +159,7 @@ func TestSuppression_links(t *testing.T) { // set up the response handler var mockResponse = loadTestFile("test_data/suppression_cursor.json") - mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -208,7 +208,7 @@ func TestSuppression_Empty_NextPage(t *testing.T) { // set up the response handler var mockResponse = loadTestFile("test_data/suppression_single_page.json") - mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -239,10 +239,10 @@ func TestSuppression_NextPage(t *testing.T) { // set up the response handler var mockResponse = loadTestFile("test_data/suppression_page1.json") - mockRestBuilderFormat(t, sp.SuppressionListsPathFormat, mockResponse) + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) mockResponse = loadTestFile("test_data/suppression_page2.json") - mockRestBuilder(t, "/test_data/suppression_page2.json", mockResponse) + mockRestBuilder(t, "GET", "/test_data/suppression_page2.json", mockResponse) // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -266,15 +266,196 @@ func TestSuppression_NextPage(t *testing.T) { } } -func mockRestBuilderFormat(t *testing.T, pathFormat string, mockResponse string) { +func TestClient_SuppressionUpsert_nil_entry(t *testing.T) { + testSetup(t) + defer testTeardown() + + response, err := testClient.SuppressionUpsert(nil) + if response != nil { + t.Errorf("Expected nil response object but got: %v", response) + } else if err == nil { + t.Errorf("Expected an error") + } +} + +func TestClient_SuppressionUpsert_bad_json(t *testing.T) { + testSetup(t) + defer testTeardown() + + var mockResponse = "{bad json}" + mockRestBuilderFormat(t, "PUT", sp.SuppressionListsPathFormat, mockResponse) + + entry := sp.SuppressionEntry{ + Recipient: "john.doe@domain.com", + Description: "entry description", + Source: "manually created", + Type: "non_transactional", + Created: "2016-05-02T16:29:56+00:00", + Updated: "2016-05-02T16:29:56+00:00", + NonTransactional: true, + } + + entries := []sp.SuppressionEntry{ + entry, + } + + response, err := testClient.SuppressionUpsert(entries) + if response == nil { + t.Errorf("Expected a response") + } else if err == nil { + t.Errorf("Expected an error") + } +} + +func TestClient_SuppressionUpsert_1_entry(t *testing.T) { + testSetup(t) + defer testTeardown() + + var mockResponse = "{}" + mockRestBuilderFormat(t, "PUT", sp.SuppressionListsPathFormat, mockResponse) + + entry := sp.SuppressionEntry{ + Recipient: "john.doe@domain.com", + Description: "entry description", + Source: "manually created", + Type: "non_transactional", + Created: "2016-05-02T16:29:56+00:00", + Updated: "2016-05-02T16:29:56+00:00", + NonTransactional: true, + } + + entries := []sp.SuppressionEntry{ + entry, + } + + response, err := testClient.SuppressionUpsert(entries) + if response == nil { + t.Errorf("Expected a response") + } else if err != nil { + t.Errorf("Did not expect an error: %v", err) + } +} + +func TestClient_SuppressionUpsert_error_response(t *testing.T) { + testSetup(t) + defer testTeardown() + + var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") + status := http.StatusBadRequest + mockRestResponseBuilderFormat(t, "PUT", status, sp.SuppressionListsPathFormat, mockResponse) + + entry := sp.SuppressionEntry{ + Recipient: "john.doe@domain.com", + Description: "entry description", + Source: "manually created", + Type: "non_transactional", + Created: "2016-05-02T16:29:56+00:00", + Updated: "2016-05-02T16:29:56+00:00", + NonTransactional: true, + } + + entries := []sp.SuppressionEntry{ + entry, + } + + response, err := testClient.SuppressionUpsert(entries) + if response == nil { + t.Errorf("Expected a response") + } else if err == nil { + t.Errorf("Expected an error with the HTTP status code") + } + + if response.HTTP.StatusCode != status { + testFailVerbose(t, response, "Expected HTTP status code %d but got %d", status, response.HTTP.StatusCode) + } else if len(response.Errors) != 1 { + testFailVerbose(t, response, "SuppressionUpsert PUT returned %d errors, expected %d", len(response.Errors), 1) + } else if response.Errors[0].Message != "Recipient could not be found" { + testFailVerbose(t, response, "SuppressionUpsert PUT Unmarshal error; saw [%v] expected [%v]", + response.Errors[0].Message, "Recipient could not be found") + } +} + +func TestClient_Suppression_Delete_nil_email(t *testing.T) { + testSetup(t) + defer testTeardown() + + status := http.StatusNotFound + mockRestResponseBuilderFormat(t, "DELETE", status, sp.SuppressionListsPathFormat+"/", "") + + _, err := testClient.SuppressionDelete("") + if err == nil { + t.Errorf("Expected an error indicating an email address is required") + } +} + +func TestClient_Suppression_Delete(t *testing.T) { + testSetup(t) + defer testTeardown() + + email := "test@test.com" + status := http.StatusNoContent + mockRestResponseBuilderFormat(t, "DELETE", status, sp.SuppressionListsPathFormat+"/"+email, "") + + response, err := testClient.SuppressionDelete(email) + if err != nil { + t.Errorf("Did not expect an error") + } + + if response.HTTP.StatusCode != status { + testFailVerbose(t, response, "Expected HTTP status code %d but got %d", status, response.HTTP.StatusCode) + } +} + +func TestClient_Suppression_Delete_Errors(t *testing.T) { + testSetup(t) + defer testTeardown() + + email := "test@test.com" + status := http.StatusBadRequest + var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") + mockRestResponseBuilderFormat(t, "DELETE", status, sp.SuppressionListsPathFormat+"/"+email, mockResponse) + + response, err := testClient.SuppressionDelete(email) + if err == nil { + t.Errorf("Expected an error") + } + + if response.HTTP.StatusCode != status { + testFailVerbose(t, response, "Expected HTTP status code %d but got %d", status, response.HTTP.StatusCode) + } else if len(response.Errors) != 1 { + testFailVerbose(t, response, "SuppressionDelete DELETE returned %d errors, expected %d", len(response.Errors), 1) + } else if response.Errors[0].Message != "Recipient could not be found" { + testFailVerbose(t, response, "SuppressionDelete DELETE Unmarshal error; saw [%v] expected [%v]", + response.Errors[0].Message, "Recipient could not be found") + } +} + +///////////////////// +// Internal Helpers +///////////////////// + +func mockRestBuilderFormat(t *testing.T, method string, pathFormat string, mockResponse string) { + mockRestResponseBuilderFormat(t, method, http.StatusOK, pathFormat, mockResponse) +} + +func mockRestBuilder(t *testing.T, method string, path string, mockResponse string) { + mockRestResponseBuilder(t, method, http.StatusOK, path, mockResponse) +} + +func mockRestResponseBuilderFormat(t *testing.T, method string, status int, pathFormat string, mockResponse string) { path := fmt.Sprintf(pathFormat, testClient.Config.ApiVersion) - mockRestBuilder(t, path, mockResponse) + mockRestResponseBuilder(t, method, status, path, mockResponse) } -func mockRestBuilder(t *testing.T, path string, mockResponse string) { +func mockRestResponseBuilder(t *testing.T, method string, status int, path string, mockResponse string) { testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - w.Header().Set("Content-Type", "application/json; charset=utf8") - w.Write([]byte(mockResponse)) + testMethod(t, r, method) + if mockResponse != "" { + w.Header().Set("Content-Type", "application/json; charset=utf8") + } + w.WriteHeader(status) + if mockResponse != "" { + w.Write([]byte(mockResponse)) + } }) } diff --git a/test_data/suppression_delete_no_email.json b/test_data/suppression_delete_no_email.json new file mode 100644 index 0000000..160ba98 --- /dev/null +++ b/test_data/suppression_delete_no_email.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "message": "Resource could not be found" + } + ] +} diff --git a/test_data/suppression_entry_simple.json b/test_data/suppression_entry_simple.json new file mode 100644 index 0000000..7347a13 --- /dev/null +++ b/test_data/suppression_entry_simple.json @@ -0,0 +1,9 @@ + { + "recipient": "john.doe@domain.com", + "description": "entry description", + "source": "manually created", + "type": "non_transactional", + "created": "2016-05-02T16:29:56+00:00", + "updated": "2016-05-02T17:20:50+00:00", + "non_transactional": true + } \ No newline at end of file From 463e39e467b89965d67184cec39e3c610c641a1c Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Wed, 5 Apr 2017 11:11:58 -0500 Subject: [PATCH 103/152] Adds tests for suppression list retrieve --- suppression_list_test.go | 129 ++++++++++++++++++++++++ test_data/suppression_entry_simple.json | 16 +-- test_data/suppression_retrieve.json | 15 +++ 3 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 test_data/suppression_retrieve.json diff --git a/suppression_list_test.go b/suppression_list_test.go index 1a797e7..f6762ed 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -5,9 +5,26 @@ import ( "net/http" "testing" + "encoding/json" + sp "github.com/SparkPost/gosparkpost" ) +func TestUnmarshal_SupressionEvent(t *testing.T) { + testSetup(t) + defer testTeardown() + + var suppressionEventString = loadTestFile("test_data/suppression_entry_simple.json") + + suppressionEntry := &sp.SuppressionEntry{} + err := json.Unmarshal([]byte(suppressionEventString), suppressionEntry) + if err != nil { + testFailVerbose(t, nil, "Unmarshal SuppressionEntry returned error: %v", err) + } + + verifySuppressionEnty(t, suppressionEntry) +} + // Test parsing of "not found" case func TestSuppression_Get_notFound(t *testing.T) { testSetup(t) @@ -37,6 +54,35 @@ func TestSuppression_Get_notFound(t *testing.T) { } } +func TestSuppression_Retrieve(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile("test_data/suppression_retrieve.json") + status := http.StatusOK + email := "john.doe@domain.com" + mockRestResponseBuilderFormat(t, "GET", status, sp.SuppressionListsPathFormat+"/"+email, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionRetrieve(email, suppressionPage) + if err != nil { + testFailVerbose(t, res, "SuppressionList retrieve returned error: %v", err) + } else if res == nil { + testFailVerbose(t, res, "SuppressionList retrieve expected an HTTP response") + } + + if len(suppressionPage.Results) != 1 { + testFailVerbose(t, res, "SuppressionList retrieve expected 1 result: %v", suppressionPage) + } else if suppressionPage.TotalCount != 1 { + testFailVerbose(t, res, "SuppressionList retrieve expected 1 result: %v", suppressionPage) + } + + verifySuppressionEnty(t, suppressionPage.Results[0]) + +} + func TestSuppression_Error_Bad_Path(t *testing.T) { testSetup(t) defer testTeardown() @@ -266,6 +312,71 @@ func TestSuppression_NextPage(t *testing.T) { } } +// Test parsing of combined suppression list results +func TestSuppression_Search_combinedList(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile("test_data/suppression_combined.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionSearch(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if suppressionPage.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if len(suppressionPage.Results) != 1 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 1) + } else if suppressionPage.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", suppressionPage.Results[0].Recipient) + } +} + +// Test parsing of combined suppression list results +func TestSuppression_Search_params(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile("test_data/suppression_combined.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + parameters := map[string]string{ + "from": "1970-01-01T00:00", + } + suppressionPage.Params = parameters + + res, err := testClient.SuppressionSearch(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if suppressionPage.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if len(suppressionPage.Results) != 1 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 1) + } else if suppressionPage.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", suppressionPage.Results[0].Recipient) + } +} + func TestClient_SuppressionUpsert_nil_entry(t *testing.T) { testSetup(t) defer testTeardown() @@ -434,6 +545,24 @@ func TestClient_Suppression_Delete_Errors(t *testing.T) { // Internal Helpers ///////////////////// +func verifySuppressionEnty(t *testing.T, suppressionEntry *sp.SuppressionEntry) { + if suppressionEntry.Recipient != "john.doe@domain.com" { + testFailVerbose(t, nil, "Unexpected Recipient: %s", suppressionEntry.Recipient) + } else if suppressionEntry.Description != "entry description" { + testFailVerbose(t, nil, "Unexpected Description: %s", suppressionEntry.Description) + } else if suppressionEntry.Source != "manually created" { + testFailVerbose(t, nil, "Unexpected Source: %s", suppressionEntry.Source) + } else if suppressionEntry.Type != "non_transactional" { + testFailVerbose(t, nil, "Unexpected Type: %s", suppressionEntry.Type) + } else if suppressionEntry.Created != "2016-05-02T16:29:56+00:00" { + testFailVerbose(t, nil, "Unexpected Created: %s", suppressionEntry.Created) + } else if suppressionEntry.Updated != "2016-05-02T17:20:50+00:00" { + testFailVerbose(t, nil, "Unexpected Updated: %s", suppressionEntry.Updated) + } else if suppressionEntry.NonTransactional != true { + testFailVerbose(t, nil, "Unexpected NonTransactional value") + } +} + func mockRestBuilderFormat(t *testing.T, method string, pathFormat string, mockResponse string) { mockRestResponseBuilderFormat(t, method, http.StatusOK, pathFormat, mockResponse) } diff --git a/test_data/suppression_entry_simple.json b/test_data/suppression_entry_simple.json index 7347a13..6c60713 100644 --- a/test_data/suppression_entry_simple.json +++ b/test_data/suppression_entry_simple.json @@ -1,9 +1,9 @@ { - "recipient": "john.doe@domain.com", - "description": "entry description", - "source": "manually created", - "type": "non_transactional", - "created": "2016-05-02T16:29:56+00:00", - "updated": "2016-05-02T17:20:50+00:00", - "non_transactional": true - } \ No newline at end of file + "recipient": "john.doe@domain.com", + "description": "entry description", + "source": "manually created", + "type": "non_transactional", + "created": "2016-05-02T16:29:56+00:00", + "updated": "2016-05-02T17:20:50+00:00", + "non_transactional": true +} \ No newline at end of file diff --git a/test_data/suppression_retrieve.json b/test_data/suppression_retrieve.json new file mode 100644 index 0000000..3b63fd2 --- /dev/null +++ b/test_data/suppression_retrieve.json @@ -0,0 +1,15 @@ +{ + "results": [ + { + "recipient": "john.doe@domain.com", + "description": "entry description", + "source": "manually created", + "type": "non_transactional", + "created": "2016-05-02T16:29:56+00:00", + "updated": "2016-05-02T17:20:50+00:00", + "non_transactional": true + } + ], + "links": [], + "total_count": 1 +} \ No newline at end of file From 846a700ca8a38e775d3b230dc4266589b461ec62 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Wed, 5 Apr 2017 17:06:59 -0500 Subject: [PATCH 104/152] Make failure to load test data fail the test fast --- common_test.go | 6 +++--- suppression_list_test.go | 28 ++++++++++++++-------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/common_test.go b/common_test.go index fe3325b..de91de3 100644 --- a/common_test.go +++ b/common_test.go @@ -2,7 +2,6 @@ package gosparkpost_test import ( "crypto/tls" - "fmt" "io/ioutil" "net/http" "net/http/httptest" @@ -56,10 +55,11 @@ func testFailVerbose(t *testing.T, res *sp.Response, fmt string, args ...interfa t.Fatalf(fmt, args...) } -func loadTestFile(fileToLoad string) string { +func loadTestFile(t *testing.T, fileToLoad string) string { b, err := ioutil.ReadFile(fileToLoad) + if err != nil { - fmt.Print(err) + t.Fatalf("Failed to load test data: %v", err) } return string(b) diff --git a/suppression_list_test.go b/suppression_list_test.go index f6762ed..ac4e946 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -14,7 +14,7 @@ func TestUnmarshal_SupressionEvent(t *testing.T) { testSetup(t) defer testTeardown() - var suppressionEventString = loadTestFile("test_data/suppression_entry_simple.json") + var suppressionEventString = loadTestFile(t, "test_data/suppression_entry_simple.json") suppressionEntry := &sp.SuppressionEntry{} err := json.Unmarshal([]byte(suppressionEventString), suppressionEntry) @@ -31,7 +31,7 @@ func TestSuppression_Get_notFound(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") + var mockResponse = loadTestFile(t, "test_data/suppression_not_found_error.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -59,7 +59,7 @@ func TestSuppression_Retrieve(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_retrieve.json") + var mockResponse = loadTestFile(t, "test_data/suppression_retrieve.json") status := http.StatusOK email := "john.doe@domain.com" mockRestResponseBuilderFormat(t, "GET", status, sp.SuppressionListsPathFormat+"/"+email, mockResponse) @@ -88,7 +88,7 @@ func TestSuppression_Error_Bad_Path(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") + var mockResponse = loadTestFile(t, "test_data/suppression_not_found_error.json") mockRestBuilderFormat(t, "GET", "/bad/path", mockResponse) // hit our local handler @@ -144,7 +144,7 @@ func TestSuppression_Get_combinedList(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_combined.json") + var mockResponse = loadTestFile(t, "test_data/suppression_combined.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -174,7 +174,7 @@ func TestSuppression_Get_separateList(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_seperate_lists.json") + var mockResponse = loadTestFile(t, "test_data/suppression_seperate_lists.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -204,7 +204,7 @@ func TestSuppression_links(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_cursor.json") + var mockResponse = loadTestFile(t, "test_data/suppression_cursor.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -253,7 +253,7 @@ func TestSuppression_Empty_NextPage(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_single_page.json") + var mockResponse = loadTestFile(t, "test_data/suppression_single_page.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -284,10 +284,10 @@ func TestSuppression_NextPage(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_page1.json") + var mockResponse = loadTestFile(t, "test_data/suppression_page1.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) - mockResponse = loadTestFile("test_data/suppression_page2.json") + mockResponse = loadTestFile(t, "test_data/suppression_page2.json") mockRestBuilder(t, "GET", "/test_data/suppression_page2.json", mockResponse) // hit our local handler @@ -318,7 +318,7 @@ func TestSuppression_Search_combinedList(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_combined.json") + var mockResponse = loadTestFile(t, "test_data/suppression_combined.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -348,7 +348,7 @@ func TestSuppression_Search_params(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile("test_data/suppression_combined.json") + var mockResponse = loadTestFile(t, "test_data/suppression_combined.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -451,7 +451,7 @@ func TestClient_SuppressionUpsert_error_response(t *testing.T) { testSetup(t) defer testTeardown() - var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") + var mockResponse = loadTestFile(t, "test_data/suppression_not_found_error.json") status := http.StatusBadRequest mockRestResponseBuilderFormat(t, "PUT", status, sp.SuppressionListsPathFormat, mockResponse) @@ -523,7 +523,7 @@ func TestClient_Suppression_Delete_Errors(t *testing.T) { email := "test@test.com" status := http.StatusBadRequest - var mockResponse = loadTestFile("test_data/suppression_not_found_error.json") + var mockResponse = loadTestFile(t, "test_data/suppression_not_found_error.json") mockRestResponseBuilderFormat(t, "DELETE", status, sp.SuppressionListsPathFormat+"/"+email, mockResponse) response, err := testClient.SuppressionDelete(email) From 731aa14b53bc2d74457421159f41c216a2f2fe72 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Wed, 5 Apr 2017 17:14:51 -0500 Subject: [PATCH 105/152] Fixes mis-usage of `res.AssertJson()` --- suppression_list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suppression_list.go b/suppression_list.go index 7b3ffda..cd0700d 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -147,7 +147,7 @@ func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (re } // If there are errors the response has JSON otherwise it is empty - if res.AssertJson != nil { + if res.AssertJson() == nil { res.ParseResponse() } From 0eb757bdf9a2066ba468dacb75a7aa82c4494d27 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 5 Apr 2017 17:04:38 -0600 Subject: [PATCH 106/152] start on TemplateUpdate tests --- templates.go | 23 ++++++++++++----------- templates_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/templates.go b/templates.go index ab621bd..7cfdb77 100644 --- a/templates.go +++ b/templates.go @@ -252,6 +252,11 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { // TemplateUpdateContext is the same as TemplateUpdate, and it allows the caller to provide a context func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *Response, err error) { + if t == nil { + err = fmt.Errorf("Update called with nil Template") + return + } + if t.ID == "" { err = fmt.Errorf("Update called with blank id") return @@ -262,10 +267,8 @@ func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *R return } - jsonBytes, err := json.Marshal(t) - if err != nil { - return - } + // A Template that makes it past Validate() will always Marshal + jsonBytes, _ := json.Marshal(t) path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, t.Published) @@ -275,13 +278,11 @@ func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *R return } - if err = res.AssertJson(); err != nil { - return - } - - err = res.ParseResponse() - if err != nil { - return + if res.AssertJson() == nil { + err = res.ParseResponse() + if err != nil { + return + } } if res.HTTP.StatusCode == 200 { diff --git a/templates_test.go b/templates_test.go index f9e52dc..42d108f 100644 --- a/templates_test.go +++ b/templates_test.go @@ -186,3 +186,41 @@ func TestTemplateCreate(t *testing.T) { } } } + +var templateUpdateTests = []struct { + in *sp.Template + err error + status int + json string +}{ + {nil, errors.New("Update called with nil Template"), 0, ""}, + {&sp.Template{ID: ""}, errors.New("Update called with blank id"), 0, ""}, + {&sp.Template{ID: "id", Content: sp.Content{}}, errors.New("Template requires a non-empty Content.Subject"), 0, ""}, + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, 200, ""}, +} + +func TestTemplateUpdate(t *testing.T) { + for idx, test := range templateUpdateTests { + testSetup(t) + defer testTeardown() + + id := "" + if test.in != nil { + id = test.in.ID + } + path := fmt.Sprintf(sp.TemplatesPathFormat+"/"+id, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(test.status) + w.Write([]byte(test.json)) + }) + + _, err := testClient.TemplateUpdate(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) + } + } +} From f4ef4c99d4574e683608ef02fd1f88f2148b358a Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Wed, 5 Apr 2017 18:32:47 -0500 Subject: [PATCH 107/152] Fixes suppression upsert that could serialize too many fields. --- suppression_list.go | 22 +++++++++++++++++---- suppression_list_test.go | 42 ++++++++++++++-------------------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/suppression_list.go b/suppression_list.go index cd0700d..3c60f29 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -28,6 +28,15 @@ type SuppressionEntry struct { Created string `json:"created,omitempty"` } +// WritableSuppressionEntry stores a recipient’s opt-out preferences. It is a list of recipient email addresses to which you do NOT want to send email. +// https://developers.sparkpost.com/api/suppression-list.html#suppression-list-bulk-insert-update-put +type WritableSuppressionEntry struct { + // Recipient is used when a list is returned + Recipient string `json:"recipient,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` +} + // SuppressionPage wraps suppression entries and response meta information type SuppressionPage struct { client *Client @@ -168,20 +177,25 @@ func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (re } // SuppressionUpsert adds an entry to the suppression, or updates the existing entry -func (c *Client) SuppressionUpsert(entries []SuppressionEntry) (*Response, error) { +func (c *Client) SuppressionUpsert(entries []WritableSuppressionEntry) (*Response, error) { return c.SuppressionUpsertContext(context.Background(), entries) } // SuppressionUpsertContext is the same as SuppressionUpsert, and it accepts a context.Context -func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []SuppressionEntry) (*Response, error) { +func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []WritableSuppressionEntry) (*Response, error) { if entries == nil { return nil, fmt.Errorf("`entries` cannot be nil") } path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - suppressionPage := SuppressionPage{} - jsonBytes, err := json.Marshal(suppressionPage) + type EntriesWrapper struct { + Recipients []WritableSuppressionEntry `json:"recipients,omitempty"` + } + + entriesWrapper := EntriesWrapper{entries} + + jsonBytes, err := json.Marshal(entriesWrapper) if err != nil { return nil, err } diff --git a/suppression_list_test.go b/suppression_list_test.go index ac4e946..4221ee0 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -396,17 +396,13 @@ func TestClient_SuppressionUpsert_bad_json(t *testing.T) { var mockResponse = "{bad json}" mockRestBuilderFormat(t, "PUT", sp.SuppressionListsPathFormat, mockResponse) - entry := sp.SuppressionEntry{ - Recipient: "john.doe@domain.com", - Description: "entry description", - Source: "manually created", - Type: "non_transactional", - Created: "2016-05-02T16:29:56+00:00", - Updated: "2016-05-02T16:29:56+00:00", - NonTransactional: true, + entry := sp.WritableSuppressionEntry{ + Recipient: "john.doe@domain.com", + Description: "entry description", + Type: "non_transactional", } - entries := []sp.SuppressionEntry{ + entries := []sp.WritableSuppressionEntry{ entry, } @@ -425,17 +421,13 @@ func TestClient_SuppressionUpsert_1_entry(t *testing.T) { var mockResponse = "{}" mockRestBuilderFormat(t, "PUT", sp.SuppressionListsPathFormat, mockResponse) - entry := sp.SuppressionEntry{ - Recipient: "john.doe@domain.com", - Description: "entry description", - Source: "manually created", - Type: "non_transactional", - Created: "2016-05-02T16:29:56+00:00", - Updated: "2016-05-02T16:29:56+00:00", - NonTransactional: true, + entry := sp.WritableSuppressionEntry{ + Recipient: "john.doe@domain.com", + Description: "entry description", + Type: "non_transactional", } - entries := []sp.SuppressionEntry{ + entries := []sp.WritableSuppressionEntry{ entry, } @@ -455,17 +447,13 @@ func TestClient_SuppressionUpsert_error_response(t *testing.T) { status := http.StatusBadRequest mockRestResponseBuilderFormat(t, "PUT", status, sp.SuppressionListsPathFormat, mockResponse) - entry := sp.SuppressionEntry{ - Recipient: "john.doe@domain.com", - Description: "entry description", - Source: "manually created", - Type: "non_transactional", - Created: "2016-05-02T16:29:56+00:00", - Updated: "2016-05-02T16:29:56+00:00", - NonTransactional: true, + entry := sp.WritableSuppressionEntry{ + Recipient: "john.doe@domain.com", + Description: "entry description", + Type: "non_transactional", } - entries := []sp.SuppressionEntry{ + entries := []sp.WritableSuppressionEntry{ entry, } From f0bb2aa359500baed4b51376dade92042763f9f1 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Wed, 5 Apr 2017 18:34:52 -0500 Subject: [PATCH 108/152] More changes from code review. --- suppression_list.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/suppression_list.go b/suppression_list.go index 3c60f29..439aa2a 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -257,6 +257,9 @@ func (c *Client) suppressionGet(ctx context.Context, finalURL string, sp *Suppre // Parse expected response structure err = json.Unmarshal(bodyBytes, sp) + if err != nil { + return res, err + } // For usage convenience parse out common links for _, link := range sp.Links { @@ -276,9 +279,5 @@ func (c *Client) suppressionGet(ctx context.Context, finalURL string, sp *Suppre sp.client = c } - if err != nil { - return res, err - } - - return res, err + return res, nil } From 7e9daa087660d605c63928b4debd59e1d24850ac Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Wed, 5 Apr 2017 19:26:49 -0500 Subject: [PATCH 109/152] Adds suppression upsert verification of json PUT --- common_test.go | 20 +++++++++++++ suppression_list_test.go | 29 ++++++++++++++++++- .../suppression_entry_simple_request.json | 9 ++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 test_data/suppression_entry_simple_request.json diff --git a/common_test.go b/common_test.go index de91de3..6212082 100644 --- a/common_test.go +++ b/common_test.go @@ -2,10 +2,13 @@ package gosparkpost_test import ( "crypto/tls" + "encoding/json" + "fmt" "io/ioutil" "net/http" "net/http/httptest" "net/url" + "reflect" "testing" sp "github.com/SparkPost/gosparkpost" @@ -64,3 +67,20 @@ func loadTestFile(t *testing.T, fileToLoad string) string { return string(b) } + +func AreEqualJSON(s1, s2 string) (bool, error) { + var o1 interface{} + var o2 interface{} + + var err error + err = json.Unmarshal([]byte(s1), &o1) + if err != nil { + return false, fmt.Errorf("Error mashalling string 1 :: %s", err.Error()) + } + err = json.Unmarshal([]byte(s2), &o2) + if err != nil { + return false, fmt.Errorf("Error mashalling string 2 :: %s", err.Error()) + } + + return reflect.DeepEqual(o1, o2), nil +} diff --git a/suppression_list_test.go b/suppression_list_test.go index 4221ee0..82aadad 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -2,6 +2,7 @@ package gosparkpost_test import ( "fmt" + "io/ioutil" "net/http" "testing" @@ -418,8 +419,9 @@ func TestClient_SuppressionUpsert_1_entry(t *testing.T) { testSetup(t) defer testTeardown() + var expectedRequest = loadTestFile(t, "test_data/suppression_entry_simple_request.json") var mockResponse = "{}" - mockRestBuilderFormat(t, "PUT", sp.SuppressionListsPathFormat, mockResponse) + mockRestRequestResponseBuilderFormat(t, "PUT", http.StatusOK, sp.SuppressionListsPathFormat, expectedRequest, mockResponse) entry := sp.WritableSuppressionEntry{ Recipient: "john.doe@domain.com", @@ -565,7 +567,32 @@ func mockRestResponseBuilderFormat(t *testing.T, method string, status int, path } func mockRestResponseBuilder(t *testing.T, method string, status int, path string, mockResponse string) { + mockRestRequestResponseBuilder(t, method, status, path, "", mockResponse) +} + +func mockRestRequestResponseBuilderFormat(t *testing.T, method string, status int, pathFormat string, expectedBody string, mockResponse string) { + path := fmt.Sprintf(pathFormat, testClient.Config.ApiVersion) + mockRestRequestResponseBuilder(t, method, status, path, expectedBody, mockResponse) +} + +func mockRestRequestResponseBuilder(t *testing.T, method string, status int, path string, expectedBody string, mockResponse string) { testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + if expectedBody != "" { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + testFailVerbose(t, nil, "error: %v", err) + } + + ok, err := AreEqualJSON(expectedBody, string(body[:])) + if err != nil { + testFailVerbose(t, nil, "error: %v", err) + } + + if !ok { + testFailVerbose(t, nil, "Request did not match expected. \nExpected: \n%s\n\nActual:\n%s\n\n", err) + } + } + testMethod(t, r, method) if mockResponse != "" { w.Header().Set("Content-Type", "application/json; charset=utf8") diff --git a/test_data/suppression_entry_simple_request.json b/test_data/suppression_entry_simple_request.json new file mode 100644 index 0000000..ea570a1 --- /dev/null +++ b/test_data/suppression_entry_simple_request.json @@ -0,0 +1,9 @@ +{ + "recipients": [ + { + "recipient": "john.doe@domain.com", + "type": "non_transactional", + "description": "entry description" + } + ] +} From a7394328fb55486442b75cfca030a310a67b8aab Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 5 Apr 2017 19:11:44 -0600 Subject: [PATCH 110/152] don't try to unmarshal an empty response, even if content-type is `application/json` --- common.go | 4 ++++ templates.go | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common.go b/common.go index b5c6e67..b9cb126 100644 --- a/common.go +++ b/common.go @@ -239,6 +239,10 @@ func (r *Response) ParseResponse() error { if err != nil { return err } + // Don't try to unmarshal an empty response + if bytes.Compare(body, []byte("")) == 0 { + return nil + } err = json.Unmarshal(body, r) if err != nil { diff --git a/templates.go b/templates.go index 7cfdb77..874c3a0 100644 --- a/templates.go +++ b/templates.go @@ -279,8 +279,7 @@ func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *R } if res.AssertJson() == nil { - err = res.ParseResponse() - if err != nil { + if err = res.ParseResponse(); err != nil { return } } From 7b90cd7f810985eff552313f0207657c60ea9436 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 5 Apr 2017 23:40:29 -0600 Subject: [PATCH 111/152] refactor errors and template error handling to be simpler --- common.go | 12 ++++++++---- common_test.go | 8 ++++---- templates.go | 31 ++++--------------------------- templates_test.go | 19 +++++++++++++++++-- 4 files changed, 33 insertions(+), 37 deletions(-) diff --git a/common.go b/common.go index b9cb126..9e9615a 100644 --- a/common.go +++ b/common.go @@ -65,11 +65,14 @@ type Response struct { Body []byte Verbose map[string]string Results interface{} `json:"results,omitempty"` - Errors []Error `json:"errors,omitempty"` + Errors SPErrors `json:"errors,omitempty"` } -// Error mirrors the error format returned by SparkPost APIs. -type Error struct { +// SPErrors is the plural of SPError +type SPErrors []SPError + +// SPError mirrors the error format returned by SparkPost APIs. +type SPError struct { Message string `json:"message"` Code string `json:"code"` Description string `json:"description"` @@ -77,7 +80,8 @@ type Error struct { Line int `json:"line,omitempty"` } -func (e Error) Json() string { +// Error satisfies the builtin Error interface +func (e SPErrors) Error() string { // safe to ignore errors when Marshaling a constant type jsonb, _ := json.Marshal(e) return string(jsonb) diff --git a/common_test.go b/common_test.go index 0ad513f..4edc953 100644 --- a/common_test.go +++ b/common_test.go @@ -79,11 +79,11 @@ func TestNewConfig(t *testing.T) { } func TestJson(t *testing.T) { - var e = &sp.Error{Message: "This is fine."} - var exp = `{"message":"This is fine.","code":"","description":""}` - str := e.Json() + var e = sp.SPErrors([]sp.SPError{{Message: "This is fine."}}) + var exp = `[{"message":"This is fine.","code":"","description":""}]` + str := e.Error() if str != exp { - t.Errorf("*Error.Json => %q, want %q", str, exp) + t.Errorf("*SPError.Stringify => %q, want %q", str, exp) } } diff --git a/templates.go b/templates.go index 874c3a0..e0888e4 100644 --- a/templates.go +++ b/templates.go @@ -226,20 +226,8 @@ func (c *Client) TemplateCreateContext(ctx context.Context, t *Template) (id str if !ok { err = fmt.Errorf("Unexpected response to Template creation") } - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Template", "create") - if err != nil { - return - } - - if res.HTTP.StatusCode == 422 { // template syntax error - eobj := res.Errors[0] - err = fmt.Errorf("%s: %s\n%s", eobj.Code, eobj.Message, eobj.Description) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + } else { + err = res.Errors } return @@ -287,19 +275,8 @@ func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *R if res.HTTP.StatusCode == 200 { return - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Template", "update") - if err != nil { - return - } - - // handle template-specific ones - if res.HTTP.StatusCode == 409 { - err = fmt.Errorf("Template with id [%s] is in use by msg generation", t.ID) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + } else { + err = res.Errors } return diff --git a/templates_test.go b/templates_test.go index 42d108f..3fbd2b4 100644 --- a/templates_test.go +++ b/templates_test.go @@ -156,7 +156,12 @@ var templatePostSuccessTests = []struct { 200, `{"results":{"ID":"new-template"}}`, ""}, {&sp.Template{Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, - errors.New("3000: substitution language syntax error in template content\nError while compiling header Subject: substitution statement missing ending '}}'"), + sp.SPErrors([]sp.SPError{{ + Message: "substitution language syntax error in template content", + Description: "Error while compiling header Subject: substitution statement missing ending '}}'", + Code: "3000", + Part: "Header:Subject", + }}), 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`, ""}, {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, @@ -197,6 +202,15 @@ var templateUpdateTests = []struct { {&sp.Template{ID: ""}, errors.New("Update called with blank id"), 0, ""}, {&sp.Template{ID: "id", Content: sp.Content{}}, errors.New("Template requires a non-empty Content.Subject"), 0, ""}, {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, 200, ""}, + + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, + sp.SPErrors([]sp.SPError{{ + Message: "substitution language syntax error in template content", + Description: "Error while compiling header Subject: substitution statement missing ending '}}'", + Code: "3000", + Part: "Header:Subject", + }}), + 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`}, } func TestTemplateUpdate(t *testing.T) { @@ -216,10 +230,11 @@ func TestTemplateUpdate(t *testing.T) { w.Write([]byte(test.json)) }) - _, err := testClient.TemplateUpdate(test.in) + res, err := testClient.TemplateUpdate(test.in) if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) } else if err != nil && err.Error() != test.err.Error() { + t.Logf("%+v", res) t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) } } From f0176e8df44b6c8c5f6b307a5f3c08b289aecfc7 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 6 Apr 2017 00:16:08 -0600 Subject: [PATCH 112/152] update somm i --- templates.go | 21 +++++++-------------- templates_test.go | 29 ++++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/templates.go b/templates.go index e0888e4..b5ff196 100644 --- a/templates.go +++ b/templates.go @@ -266,17 +266,16 @@ func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *R return } - if res.AssertJson() == nil { - if err = res.ParseResponse(); err != nil { - return - } + if err = res.AssertJson(); err != nil { + return } if res.HTTP.StatusCode == 200 { return - } else { - err = res.Errors + if err = res.ParseResponse(); err == nil { + err = res.Errors + } } return @@ -290,7 +289,7 @@ func (c *Client) Templates() ([]Template, *Response, error) { // TemplatesContext is the same as Templates, and it allows the caller to provide a context func (c *Client) TemplatesContext(ctx context.Context) ([]Template, *Response, error) { path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) + url := c.Config.BaseUrl + path res, err := c.HttpGet(ctx, url) if err != nil { return nil, nil, err @@ -319,13 +318,7 @@ func (c *Client) TemplatesContext(ctx context.Context) ([]Template, *Response, e if err != nil { return nil, res, err } - if len(res.Errors) > 0 { - err = res.PrettyError("Template", "list") - if err != nil { - return nil, res, err - } - } - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = res.Errors } return nil, res, err diff --git a/templates_test.go b/templates_test.go index 3fbd2b4..fa214a7 100644 --- a/templates_test.go +++ b/templates_test.go @@ -139,7 +139,7 @@ func TestTemplateValidation(t *testing.T) { } } -var templatePostSuccessTests = []struct { +var templateCreateTests = []struct { in *sp.Template err error status int @@ -154,6 +154,9 @@ var templatePostSuccessTests = []struct { {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, errors.New("Unexpected response to Template creation"), 200, `{"results":{"ID":"new-template"}}`, ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("parsing api response: unexpected end of JSON input"), + 200, `{"results":{"ID":"new-template"}`, ""}, {&sp.Template{Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, sp.SPErrors([]sp.SPError{{ @@ -169,7 +172,7 @@ var templatePostSuccessTests = []struct { } func TestTemplateCreate(t *testing.T) { - for idx, test := range templatePostSuccessTests { + for idx, test := range templateCreateTests { testSetup(t) defer testTeardown() @@ -201,7 +204,7 @@ var templateUpdateTests = []struct { {nil, errors.New("Update called with nil Template"), 0, ""}, {&sp.Template{ID: ""}, errors.New("Update called with blank id"), 0, ""}, {&sp.Template{ID: "id", Content: sp.Content{}}, errors.New("Template requires a non-empty Content.Subject"), 0, ""}, - {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, 200, ""}, + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, {&sp.Template{ID: "id", Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, sp.SPErrors([]sp.SPError{{ @@ -211,6 +214,8 @@ var templateUpdateTests = []struct { Part: "Header:Subject", }}), 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`}, + + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, 200, ""}, } func TestTemplateUpdate(t *testing.T) { @@ -230,12 +235,26 @@ func TestTemplateUpdate(t *testing.T) { w.Write([]byte(test.json)) }) - res, err := testClient.TemplateUpdate(test.in) + _, err := testClient.TemplateUpdate(test.in) if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) } else if err != nil && err.Error() != test.err.Error() { - t.Logf("%+v", res) + t.Logf("%+v") t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) } } } + +var templatesTests = []struct { + err error + status int + json string +}{} + +func TestTemplates(t *testing.T) { + for idx, test := range templatesTests { + testSetup(t) + defer testTeardown() + + } +} From 2be1daae8114d4cdd12c2e0703c33dc8e53a5e7d Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 6 Apr 2017 12:44:01 -0600 Subject: [PATCH 113/152] first `Client.Templates` test; move test table definitions inside test functions --- templates_test.go | 264 ++++++++++++++++++++++++---------------------- 1 file changed, 135 insertions(+), 129 deletions(-) diff --git a/templates_test.go b/templates_test.go index fa214a7..8a2eb63 100644 --- a/templates_test.go +++ b/templates_test.go @@ -12,25 +12,23 @@ import ( "github.com/pkg/errors" ) -var templateFromValidationTests = []struct { - in interface{} - err error - out sp.From -}{ - {sp.From{"a@b.com", "A B"}, nil, sp.From{"a@b.com", "A B"}}, - {sp.Address{"a@b.com", "A B", "c@d.com"}, nil, sp.From{"a@b.com", "A B"}}, - {"a@b.com", nil, sp.From{"a@b.com", ""}}, - {nil, errors.New("unsupported Content.From value type [%!s()]"), sp.From{"", ""}}, - {[]byte("a@b.com"), errors.New("unsupported Content.From value type [[]uint8]"), sp.From{"", ""}}, - {"", errors.New("Content.From may not be empty"), sp.From{"", ""}}, - {map[string]interface{}{"name": "A B", "email": "a@b.com"}, nil, sp.From{"a@b.com", "A B"}}, - {map[string]interface{}{"name": 1, "email": "a@b.com"}, errors.New("strings are required for all Content.From values"), - sp.From{"a@b.com", ""}}, - {map[string]string{"name": "A B", "email": "a@b.com"}, nil, sp.From{"a@b.com", "A B"}}, -} - func TestTemplateFromValidation(t *testing.T) { - for idx, test := range templateFromValidationTests { + for idx, test := range []struct { + in interface{} + err error + out sp.From + }{ + {sp.From{"a@b.com", "A B"}, nil, sp.From{"a@b.com", "A B"}}, + {sp.Address{"a@b.com", "A B", "c@d.com"}, nil, sp.From{"a@b.com", "A B"}}, + {"a@b.com", nil, sp.From{"a@b.com", ""}}, + {nil, errors.New("unsupported Content.From value type [%!s()]"), sp.From{"", ""}}, + {[]byte("a@b.com"), errors.New("unsupported Content.From value type [[]uint8]"), sp.From{"", ""}}, + {"", errors.New("Content.From may not be empty"), sp.From{"", ""}}, + {map[string]interface{}{"name": "A B", "email": "a@b.com"}, nil, sp.From{"a@b.com", "A B"}}, + {map[string]interface{}{"name": 1, "email": "a@b.com"}, errors.New("strings are required for all Content.From values"), + sp.From{"a@b.com", ""}}, + {map[string]string{"name": "A B", "email": "a@b.com"}, nil, sp.From{"a@b.com", "A B"}}, + } { f, err := sp.ParseFrom(test.in) if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("ParseFrom[%d] => err %q, want %q", idx, err, test.err) @@ -75,59 +73,57 @@ func TestTemplateOptions(t *testing.T) { } } -var templateValidationTests = []struct { - te *sp.Template - err error - cmp func(te *sp.Template) bool -}{ - {nil, errors.New("Can't Validate a nil Template"), nil}, - {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), nil}, - {&sp.Template{Content: sp.Content{Subject: "s"}}, errors.New("Template requires either Content.HTML or Content.Text"), nil}, - {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: ""}}, - errors.New("Content.From may not be empty"), nil}, +func TestTemplateValidation(t *testing.T) { + for idx, test := range []struct { + te *sp.Template + err error + cmp func(te *sp.Template) bool + }{ + {nil, errors.New("Can't Validate a nil Template"), nil}, + {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), nil}, + {&sp.Template{Content: sp.Content{Subject: "s"}}, errors.New("Template requires either Content.HTML or Content.Text"), nil}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: ""}}, + errors.New("Content.From may not be empty"), nil}, - {&sp.Template{ID: strings.Repeat("id", 33), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, - errors.New("Template id may not be longer than 64 bytes"), nil}, - {&sp.Template{Name: strings.Repeat("name", 257), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, - errors.New("Template name may not be longer than 1024 bytes"), nil}, - {&sp.Template{Description: strings.Repeat("desc", 257), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, - errors.New("Template description may not be longer than 1024 bytes"), nil}, + {&sp.Template{ID: strings.Repeat("id", 33), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Template id may not be longer than 64 bytes"), nil}, + {&sp.Template{Name: strings.Repeat("name", 257), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Template name may not be longer than 1024 bytes"), nil}, + {&sp.Template{Description: strings.Repeat("desc", 257), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Template description may not be longer than 1024 bytes"), nil}, - {&sp.Template{ - Content: sp.Content{ - Subject: "s", HTML: "h", From: "f", - Attachments: []sp.Attachment{{Filename: strings.Repeat("f", 256)}}, - }}, - errors.Errorf("Attachment name length must be <= 255: [%s]", strings.Repeat("f", 256)), nil}, - {&sp.Template{ - Content: sp.Content{ - Subject: "s", HTML: "h", From: "f", - Attachments: []sp.Attachment{{B64Data: "\r\n"}}, - }}, - errors.New("Attachment data may not contain line breaks [\\r\\n]"), nil}, + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + Attachments: []sp.Attachment{{Filename: strings.Repeat("f", 256)}}, + }}, + errors.Errorf("Attachment name length must be <= 255: [%s]", strings.Repeat("f", 256)), nil}, + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + Attachments: []sp.Attachment{{B64Data: "\r\n"}}, + }}, + errors.New("Attachment data may not contain line breaks [\\r\\n]"), nil}, - {&sp.Template{ - Content: sp.Content{ - Subject: "s", HTML: "h", From: "f", - InlineImages: []sp.InlineImage{{Filename: strings.Repeat("f", 256)}}, - }}, - errors.Errorf("InlineImage name length must be <= 255: [%s]", strings.Repeat("f", 256)), nil}, - {&sp.Template{ - Content: sp.Content{ - Subject: "s", HTML: "h", From: "f", - InlineImages: []sp.InlineImage{{B64Data: "\r\n"}}, - }}, - errors.New("InlineImage data may not contain line breaks [\\r\\n]"), nil}, + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + InlineImages: []sp.InlineImage{{Filename: strings.Repeat("f", 256)}}, + }}, + errors.Errorf("InlineImage name length must be <= 255: [%s]", strings.Repeat("f", 256)), nil}, + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + InlineImages: []sp.InlineImage{{B64Data: "\r\n"}}, + }}, + errors.New("InlineImage data may not contain line breaks [\\r\\n]"), nil}, - { - &sp.Template{Content: sp.Content{EmailRFC822: "From:foo@example.com\r\n", Subject: "removeme"}}, - nil, - func(te *sp.Template) bool { return te.Content.Subject == "" }, - }, -} - -func TestTemplateValidation(t *testing.T) { - for idx, test := range templateValidationTests { + { + &sp.Template{Content: sp.Content{EmailRFC822: "From:foo@example.com\r\n", Subject: "removeme"}}, + nil, + func(te *sp.Template) bool { return te.Content.Subject == "" }, + }, + } { err := test.te.Validate() if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("Template.Validate[%d] => err %q, want %q", idx, err, test.err) @@ -139,40 +135,38 @@ func TestTemplateValidation(t *testing.T) { } } -var templateCreateTests = []struct { - in *sp.Template - err error - status int - json string - id string -}{ - {nil, errors.New("Create called with nil Template"), 0, "", ""}, - {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), 0, "", ""}, - {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, - errors.New("Unexpected response to Template creation (results)"), - 200, `{"foo":{"id":"new-template"}}`, ""}, - {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, - errors.New("Unexpected response to Template creation"), - 200, `{"results":{"ID":"new-template"}}`, ""}, - {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, - errors.New("parsing api response: unexpected end of JSON input"), - 200, `{"results":{"ID":"new-template"}`, ""}, - - {&sp.Template{Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, - sp.SPErrors([]sp.SPError{{ - Message: "substitution language syntax error in template content", - Description: "Error while compiling header Subject: substitution statement missing ending '}}'", - Code: "3000", - Part: "Header:Subject", - }}), - 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`, ""}, +func TestTemplateCreate(t *testing.T) { + for idx, test := range []struct { + in *sp.Template + err error + status int + json string + id string + }{ + {nil, errors.New("Create called with nil Template"), 0, "", ""}, + {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), 0, "", ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Unexpected response to Template creation (results)"), + 200, `{"foo":{"id":"new-template"}}`, ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Unexpected response to Template creation"), + 200, `{"results":{"ID":"new-template"}}`, ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("parsing api response: unexpected end of JSON input"), + 200, `{"results":{"ID":"new-template"}`, ""}, - {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, - 200, `{"results":{"id":"new-template"}}`, "new-template"}, -} + {&sp.Template{Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, + sp.SPErrors([]sp.SPError{{ + Message: "substitution language syntax error in template content", + Description: "Error while compiling header Subject: substitution statement missing ending '}}'", + Code: "3000", + Part: "Header:Subject", + }}), + 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`, ""}, -func TestTemplateCreate(t *testing.T) { - for idx, test := range templateCreateTests { + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, + 200, `{"results":{"id":"new-template"}}`, "new-template"}, + } { testSetup(t) defer testTeardown() @@ -195,31 +189,29 @@ func TestTemplateCreate(t *testing.T) { } } -var templateUpdateTests = []struct { - in *sp.Template - err error - status int - json string -}{ - {nil, errors.New("Update called with nil Template"), 0, ""}, - {&sp.Template{ID: ""}, errors.New("Update called with blank id"), 0, ""}, - {&sp.Template{ID: "id", Content: sp.Content{}}, errors.New("Template requires a non-empty Content.Subject"), 0, ""}, - {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, - - {&sp.Template{ID: "id", Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, - sp.SPErrors([]sp.SPError{{ - Message: "substitution language syntax error in template content", - Description: "Error while compiling header Subject: substitution statement missing ending '}}'", - Code: "3000", - Part: "Header:Subject", - }}), - 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`}, +func TestTemplateUpdate(t *testing.T) { + for idx, test := range []struct { + in *sp.Template + err error + status int + json string + }{ + {nil, errors.New("Update called with nil Template"), 0, ""}, + {&sp.Template{ID: ""}, errors.New("Update called with blank id"), 0, ""}, + {&sp.Template{ID: "id", Content: sp.Content{}}, errors.New("Template requires a non-empty Content.Subject"), 0, ""}, + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, - {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, 200, ""}, -} + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, + sp.SPErrors([]sp.SPError{{ + Message: "substitution language syntax error in template content", + Description: "Error while compiling header Subject: substitution statement missing ending '}}'", + Code: "3000", + Part: "Header:Subject", + }}), + 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`}, -func TestTemplateUpdate(t *testing.T) { - for idx, test := range templateUpdateTests { + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, 200, ""}, + } { testSetup(t) defer testTeardown() @@ -245,16 +237,30 @@ func TestTemplateUpdate(t *testing.T) { } } -var templatesTests = []struct { - err error - status int - json string -}{} - func TestTemplates(t *testing.T) { - for idx, test := range templatesTests { + for idx, test := range []struct { + err error + status int + json string + }{ + {errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, + } { testSetup(t) defer testTeardown() + path := fmt.Sprintf(sp.TemplatesPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(test.status) + w.Write([]byte(test.json)) + }) + + _, _, err := testClient.Templates() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Templates[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Templates[%d] => err %q want %q", idx, err, test.err) + } } } From 8ac2db9c92a13a33798baca5db938927cd69c5a7 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 6 Apr 2017 14:22:42 -0600 Subject: [PATCH 114/152] simplify some test setup; allow empty response bodies past `AssertJson` --- common.go | 9 +++++++++ templates_test.go | 29 +++-------------------------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/common.go b/common.go index 9e9615a..faa91c8 100644 --- a/common.go +++ b/common.go @@ -261,6 +261,15 @@ func (r *Response) AssertJson() error { if r.HTTP == nil { return errors.New("AssertJson got nil http.Response") } + body, err := r.ReadBody() + if err != nil { + return err + } + // Don't fail on an empty response + if bytes.Compare(body, []byte("")) == 0 { + return nil + } + ctype := r.HTTP.Header.Get("Content-Type") mediaType, _, err := mime.ParseMediaType(ctype) if err != nil { diff --git a/templates_test.go b/templates_test.go index 8a2eb63..17bb05e 100644 --- a/templates_test.go +++ b/templates_test.go @@ -3,8 +3,6 @@ package gosparkpost_test import ( "bytes" "encoding/json" - "fmt" - "net/http" "strings" "testing" @@ -169,14 +167,7 @@ func TestTemplateCreate(t *testing.T) { } { testSetup(t) defer testTeardown() - - path := fmt.Sprintf(sp.TemplatesPathFormat, testClient.Config.ApiVersion) - testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "POST") - w.Header().Set("Content-Type", "application/json; charset=utf8") - w.WriteHeader(test.status) - w.Write([]byte(test.json)) - }) + mockRestResponseBuilderFormat(t, "POST", test.status, sp.TemplatesPathFormat, test.json) id, _, err := testClient.TemplateCreate(test.in) if err == nil && test.err != nil || err != nil && test.err == nil { @@ -219,19 +210,12 @@ func TestTemplateUpdate(t *testing.T) { if test.in != nil { id = test.in.ID } - path := fmt.Sprintf(sp.TemplatesPathFormat+"/"+id, testClient.Config.ApiVersion) - testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "PUT") - w.Header().Set("Content-Type", "application/json; charset=utf8") - w.WriteHeader(test.status) - w.Write([]byte(test.json)) - }) + mockRestResponseBuilderFormat(t, "PUT", test.status, sp.TemplatesPathFormat+"/"+id, test.json) _, err := testClient.TemplateUpdate(test.in) if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) } else if err != nil && err.Error() != test.err.Error() { - t.Logf("%+v") t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) } } @@ -247,14 +231,7 @@ func TestTemplates(t *testing.T) { } { testSetup(t) defer testTeardown() - - path := fmt.Sprintf(sp.TemplatesPathFormat, testClient.Config.ApiVersion) - testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { - testMethod(t, r, "GET") - w.Header().Set("Content-Type", "application/json; charset=utf8") - w.WriteHeader(test.status) - w.Write([]byte(test.json)) - }) + mockRestResponseBuilderFormat(t, "GET", test.status, sp.TemplatesPathFormat, test.json) _, _, err := testClient.Templates() if err == nil && test.err != nil || err != nil && test.err == nil { From 4cd02d3abbf90402cc3c4f92cdf4bd7e7d82a78e Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 6 Apr 2017 15:09:24 -0600 Subject: [PATCH 115/152] better coverage on `*Client.Templates` --- templates_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/templates_test.go b/templates_test.go index 17bb05e..7a9b7f7 100644 --- a/templates_test.go +++ b/templates_test.go @@ -228,6 +228,9 @@ func TestTemplates(t *testing.T) { json string }{ {errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, + {errors.New("[{\"message\":\"truncated json\",\"code\":\"\",\"description\":\"\"}]"), 0, `{ "errors": [ { "message": "truncated json" } ] }`}, + {nil, 200, `{ "results": [ { "description": "A test message from SparkPost.com", "id": "my-first-email", "last_update_time": "2006-01-02T15:04:05+00:00", "name": "My First Email", "published": false } ] }`}, + {errors.New("Unexpected response to Template list"), 200, `{ "foo": [ { "description": "A malformed message from SparkPost.com" } ] }`}, } { testSetup(t) defer testTeardown() From 49262edb3cf4577cd1874d385b13a6758839e355 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 6 Apr 2017 15:21:58 -0600 Subject: [PATCH 116/152] add tests and simplify errors for `TemplateDelete` --- templates.go | 18 ++---------------- templates_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/templates.go b/templates.go index b5ff196..ee821d0 100644 --- a/templates.go +++ b/templates.go @@ -352,22 +352,8 @@ func (c *Client) TemplateDeleteContext(ctx context.Context, id string) (res *Res return } - if res.HTTP.StatusCode == 200 { - return - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Template", "delete") - if err != nil { - return - } - - // handle template-specific ones - if res.HTTP.StatusCode == 409 { - err = fmt.Errorf("Template with id [%s] is in use by msg generation", id) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + if res.HTTP.StatusCode != 200 { + err = res.Errors } return diff --git a/templates_test.go b/templates_test.go index 7a9b7f7..1bef8f6 100644 --- a/templates_test.go +++ b/templates_test.go @@ -244,3 +244,27 @@ func TestTemplates(t *testing.T) { } } } + +func TestTemplateDelete(t *testing.T) { + for idx, test := range []struct { + id string + err error + status int + json string + }{ + {"", errors.New("Delete called with blank id"), 0, ""}, + {"nope", errors.New("[{\"message\":\"Resource could not be found\",\"code\":\"\",\"description\":\"\"}]"), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`}, + {"id", nil, 200, "{}"}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "DELETE", test.status, sp.TemplatesPathFormat+"/"+test.id, test.json) + + _, err := testClient.TemplateDelete(test.id) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplateDelete[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplateDelete[%d] => err %q want %q", idx, err, test.err) + } + } +} From 4711d74cdfb6d904d8e7c2196121b7c76b0753a4 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 6 Apr 2017 15:41:24 -0600 Subject: [PATCH 117/152] add tests and simplify errors for `TemplatePreview` --- templates.go | 15 ++------------- templates_test.go | 32 +++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/templates.go b/templates.go index ee821d0..cfbaa42 100644 --- a/templates.go +++ b/templates.go @@ -396,19 +396,8 @@ func (c *Client) TemplatePreviewContext(ctx context.Context, id string, payload return } - if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Template", "preview") - if err != nil { - return - } - - if res.HTTP.StatusCode == 422 { // preview payload error - eobj := res.Errors[0] - err = fmt.Errorf("%s: %s\n%s", eobj.Code, eobj.Message, eobj.Description) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + if res.HTTP.StatusCode != 200 { + err = res.Errors } return diff --git a/templates_test.go b/templates_test.go index 1bef8f6..6233e80 100644 --- a/templates_test.go +++ b/templates_test.go @@ -253,7 +253,7 @@ func TestTemplateDelete(t *testing.T) { json string }{ {"", errors.New("Delete called with blank id"), 0, ""}, - {"nope", errors.New("[{\"message\":\"Resource could not be found\",\"code\":\"\",\"description\":\"\"}]"), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`}, + {"nope", errors.New(`[{"message":"Resource could not be found","code":"","description":""}]`), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`}, {"id", nil, 200, "{}"}, } { testSetup(t) @@ -268,3 +268,33 @@ func TestTemplateDelete(t *testing.T) { } } } + +func TestTemplatePreview(t *testing.T) { + for idx, test := range []struct { + id string + opts *sp.PreviewOptions + err error + status int + json string + }{ + {"", nil, errors.New("Preview called with blank id"), 200, ""}, + {"id", &sp.PreviewOptions{map[string]interface{}{ + "func": func() { return }}, + }, errors.New("json: unsupported type: func()"), 200, ""}, + {"id", nil, errors.New("parsing api response: unexpected end of JSON input"), 200, "{"}, + {"nope", nil, errors.New(`[{"message":"Resource could not be found","code":"","description":""}]`), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`}, + + {"id", nil, nil, 200, ""}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "POST", test.status, sp.TemplatesPathFormat+"/"+test.id+"/preview", test.json) + + _, err := testClient.TemplatePreview(test.id, test.opts) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplatePreview[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplatePreview[%d] => err %q want %q", idx, err, test.err) + } + } +} From e262766ec11dfb03690f1ee8c1223f2fb8417a01 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 6 Apr 2017 16:03:29 -0600 Subject: [PATCH 118/152] missed the GET for templates somehow --- templates.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/templates.go b/templates.go index cfbaa42..ef90e97 100644 --- a/templates.go +++ b/templates.go @@ -7,6 +7,8 @@ import ( "reflect" "strings" "time" + + "github.com/pkg/errors" ) // https://www.sparkpost.com/api#/reference/templates @@ -233,6 +235,55 @@ func (c *Client) TemplateCreateContext(ctx context.Context, t *Template) (id str return } +// TemplateGet fills out the provided template, using the specified id. +func (c *Client) TemplateGet(t *Template, draft bool) (*Response, error) { + return c.TemplateGetContext(context.Background(), t, draft) +} + +// TemplateGetContext is the same as TemplateGet, and it allows the caller to provide a context +func (c *Client) TemplateGetContext(ctx context.Context, t *Template, draft bool) (*Response, error) { + if t == nil { + return nil, errors.New("TemplateGet called with nil Template") + } + + if t.ID == "" { + return nil, errors.New("TemplateGet called with blank id") + } + + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) + url := fmt.Sprintf("%s%s/%s?draft=%t", c.Config.BaseUrl, path, t.ID, draft) + + res, err := c.HttpGet(ctx, url) + if err != nil { + return nil, err + } + + if err = res.AssertJson(); err != nil { + return res, err + } + + if res.HTTP.StatusCode == 200 { + var body []byte + body, err = res.ReadBody() + if err != nil { + return res, err + } + + // Unwrap the returned Template + if err = json.Unmarshal(body, &map[string]*Template{"results": t}); err != nil { + return res, err + } + } else { + err = res.ParseResponse() + if err != nil { + return res, err + } + return res, res.Errors + } + + return res, nil +} + // TemplateUpdate updates a draft/published template with the specified id func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { return c.TemplateUpdateContext(context.Background(), t) From 2b8f6097d9f14e0f52d5a9380398f86eca005b96 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 6 Apr 2017 16:59:52 -0600 Subject: [PATCH 119/152] pull out the value the right way --- templates.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/templates.go b/templates.go index ef90e97..ed42ac4 100644 --- a/templates.go +++ b/templates.go @@ -270,8 +270,12 @@ func (c *Client) TemplateGetContext(ctx context.Context, t *Template, draft bool } // Unwrap the returned Template - if err = json.Unmarshal(body, &map[string]*Template{"results": t}); err != nil { - return res, err + tmp := map[string]*json.RawMessage{} + if err = json.Unmarshal(body, &tmp); err != nil { + } else if results, ok := tmp["results"]; ok { + err = json.Unmarshal(*results, t) + } else { + err = errors.New("Unexpected response to TemplateGet") } } else { err = res.ParseResponse() From f95cb145803d1e50394208df41e020e1fcf93c28 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 6 Apr 2017 17:00:04 -0600 Subject: [PATCH 120/152] tests for TemplateGet --- templates_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/templates_test.go b/templates_test.go index 6233e80..e3bcd91 100644 --- a/templates_test.go +++ b/templates_test.go @@ -3,6 +3,7 @@ package gosparkpost_test import ( "bytes" "encoding/json" + "reflect" "strings" "testing" @@ -180,6 +181,45 @@ func TestTemplateCreate(t *testing.T) { } } +func TestTemplateGet(t *testing.T) { + for idx, test := range []struct { + in *sp.Template + draft bool + err error + status int + json string + out *sp.Template + }{ + {nil, false, errors.New("TemplateGet called with nil Template"), 200, "", nil}, + {&sp.Template{ID: ""}, false, errors.New("TemplateGet called with blank id"), 200, "", nil}, + {&sp.Template{ID: "nope"}, false, errors.New(`[{"message":"Resource could not be found","code":"","description":""}]`), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`, nil}, + + {&sp.Template{ID: "id"}, false, nil, 200, `{"results":{"content":{"from":{"email":"a@b.com","name": "a b"},"html":"hi!","subject":"blink","text":"no blink ;_;"},"id":"id"}}`, &sp.Template{ID: "id", Content: sp.Content{From: map[string]string{"email": "a@b.com", "name": "a b"}, HTML: "hi!", Text: "no blink ;_;", Subject: "blink"}}}, + } { + testSetup(t) + defer testTeardown() + + id := "" + if test.in != nil { + id = test.in.ID + } + mockRestResponseBuilderFormat(t, "GET", test.status, sp.TemplatesPathFormat+"/"+id, test.json) + + _, err := testClient.TemplateGet(test.in, test.draft) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil { + var t_in = *test.in + var t_out = *test.out + if !reflect.DeepEqual(t_in, t_out) { + t.Errorf("TemplateUpdate[%d] => template got/want:\n%q\n%q", idx, test.in, test.out) + } + } + } +} + func TestTemplateUpdate(t *testing.T) { for idx, test := range []struct { in *sp.Template From c16beb3dedcc738cfd1c70144af3959c857c0aea Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 7 Apr 2017 09:20:49 -0600 Subject: [PATCH 121/152] test coverage for TemplateGet --- templates.go | 3 +-- templates_test.go | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/templates.go b/templates.go index ed42ac4..9656fde 100644 --- a/templates.go +++ b/templates.go @@ -277,6 +277,7 @@ func (c *Client) TemplateGetContext(ctx context.Context, t *Template, draft bool } else { err = errors.New("Unexpected response to TemplateGet") } + return res, err } else { err = res.ParseResponse() if err != nil { @@ -284,8 +285,6 @@ func (c *Client) TemplateGetContext(ctx context.Context, t *Template, draft bool } return res, res.Errors } - - return res, nil } // TemplateUpdate updates a draft/published template with the specified id diff --git a/templates_test.go b/templates_test.go index e3bcd91..6f031e0 100644 --- a/templates_test.go +++ b/templates_test.go @@ -193,8 +193,11 @@ func TestTemplateGet(t *testing.T) { {nil, false, errors.New("TemplateGet called with nil Template"), 200, "", nil}, {&sp.Template{ID: ""}, false, errors.New("TemplateGet called with blank id"), 200, "", nil}, {&sp.Template{ID: "nope"}, false, errors.New(`[{"message":"Resource could not be found","code":"","description":""}]`), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`, nil}, + {&sp.Template{ID: "nope"}, false, errors.New("parsing api response: unexpected end of JSON input"), 404, `{ "errors": [ { "message": "Resource could not be found" } ]`, nil}, + {&sp.Template{ID: "id"}, false, errors.New("Unexpected response to TemplateGet"), 200, `{"foo":{}}`, nil}, + {&sp.Template{ID: "id"}, false, errors.New("unexpected end of JSON input"), 200, `{"foo":{}`, nil}, - {&sp.Template{ID: "id"}, false, nil, 200, `{"results":{"content":{"from":{"email":"a@b.com","name": "a b"},"html":"hi!","subject":"blink","text":"no blink ;_;"},"id":"id"}}`, &sp.Template{ID: "id", Content: sp.Content{From: map[string]string{"email": "a@b.com", "name": "a b"}, HTML: "hi!", Text: "no blink ;_;", Subject: "blink"}}}, + {&sp.Template{ID: "id"}, false, nil, 200, `{"results":{"content":{"from":{"email":"a@b.com","name": "a b"},"html":"hi!","subject":"blink","text":"no blink ;_;"},"id":"id"}}`, &sp.Template{ID: "id", Content: sp.Content{From: map[string]interface{}{"email": "a@b.com", "name": "a b"}, HTML: "hi!", Text: "no blink ;_;", Subject: "blink"}}}, } { testSetup(t) defer testTeardown() @@ -211,9 +214,14 @@ func TestTemplateGet(t *testing.T) { } else if err != nil && err.Error() != test.err.Error() { t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) } else if test.out != nil { - var t_in = *test.in - var t_out = *test.out - if !reflect.DeepEqual(t_in, t_out) { + var b bool + if test.in.Options == nil { + test.in.Options = &sp.TmplOptions{&b, &b, &b} + } + if test.out.Options == nil { + test.out.Options = &sp.TmplOptions{&b, &b, &b} + } + if !reflect.DeepEqual(test.out, test.in) { t.Errorf("TemplateUpdate[%d] => template got/want:\n%q\n%q", idx, test.in, test.out) } } From 7084aee592e6d3abdb8c7af7c8b4a45debe76757 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 7 Apr 2017 09:52:54 -0600 Subject: [PATCH 122/152] don't need to set options --- templates_test.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/templates_test.go b/templates_test.go index 6f031e0..52f9f7b 100644 --- a/templates_test.go +++ b/templates_test.go @@ -214,13 +214,6 @@ func TestTemplateGet(t *testing.T) { } else if err != nil && err.Error() != test.err.Error() { t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) } else if test.out != nil { - var b bool - if test.in.Options == nil { - test.in.Options = &sp.TmplOptions{&b, &b, &b} - } - if test.out.Options == nil { - test.out.Options = &sp.TmplOptions{&b, &b, &b} - } if !reflect.DeepEqual(test.out, test.in) { t.Errorf("TemplateUpdate[%d] => template got/want:\n%q\n%q", idx, test.in, test.out) } From 03acb793a7683a6c9c330c1c3fd470e3162287fb Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 7 Apr 2017 16:16:08 -0600 Subject: [PATCH 123/152] move table test structs inside functions --- common_test.go | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/common_test.go b/common_test.go index 1039472..dd73184 100644 --- a/common_test.go +++ b/common_test.go @@ -59,18 +59,16 @@ func testFailVerbose(t *testing.T, res *sp.Response, fmt string, args ...interfa t.Fatalf(fmt, args...) } -var newConfigTests = []struct { - in map[string]string - cfg *sp.Config - err error -}{ - {map[string]string{}, nil, errors.New("BaseUrl is required for api config")}, - {map[string]string{"baseurl": "http://example.com"}, nil, errors.New("ApiKey is required for api config")}, - {map[string]string{"baseurl": "http://example.com", "apikey": "foo"}, &sp.Config{BaseUrl: "http://example.com", ApiKey: "foo"}, nil}, -} - func TestNewConfig(t *testing.T) { - for idx, test := range newConfigTests { + for idx, test := range []struct { + in map[string]string + cfg *sp.Config + err error + }{ + {map[string]string{}, nil, errors.New("BaseUrl is required for api config")}, + {map[string]string{"baseurl": "http://example.com"}, nil, errors.New("ApiKey is required for api config")}, + {map[string]string{"baseurl": "http://example.com", "apikey": "foo"}, &sp.Config{BaseUrl: "http://example.com", ApiKey: "foo"}, nil}, + } { cfg, err := sp.NewConfig(test.in) if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("NewConfig[%d] => err %q, want %q", idx, err, test.err) @@ -101,18 +99,16 @@ func TestDoRequest_BadMethod(t *testing.T) { } } -var initTests = []struct { - api *sp.Client - cfg *sp.Config - out *sp.Config - err error -}{ - {&sp.Client{}, &sp.Config{BaseUrl: ""}, &sp.Config{BaseUrl: "https://api.sparkpost.com"}, nil}, - {&sp.Client{}, &sp.Config{BaseUrl: "http://api.sparkpost.com"}, nil, errors.New("API base url must be https!")}, -} - func TestInit(t *testing.T) { - for idx, test := range initTests { + for idx, test := range []struct { + api *sp.Client + cfg *sp.Config + out *sp.Config + err error + }{ + {&sp.Client{}, &sp.Config{BaseUrl: ""}, &sp.Config{BaseUrl: "https://api.sparkpost.com"}, nil}, + {&sp.Client{}, &sp.Config{BaseUrl: "http://api.sparkpost.com"}, nil, errors.New("API base url must be https!")}, + } { err := test.api.Init(test.cfg) if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("Init[%d] => err %q, want %q", idx, err, test.err) From 1e667d71130a965e02c4954ca1081384baba4f53 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 7 Apr 2017 16:32:35 -0600 Subject: [PATCH 124/152] export recipient list path; tests for `Address` validation --- recipient_lists.go | 6 ++--- recipient_lists_test.go | 60 +++++++++++++++++------------------------ 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/recipient_lists.go b/recipient_lists.go index b65ee67..b550f80 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -9,7 +9,7 @@ import ( ) // https://www.sparkpost.com/api#/reference/recipient-lists -var recipListsPathFormat = "/api/v%d/recipient-lists" +var RecipListsPathFormat = "/api/v%d/recipient-lists" // RecipientList is the JSON structure accepted by and returned from the SparkPost Recipient Lists API. // It's mostly metadata at this level - see Recipients for more detail. @@ -163,7 +163,7 @@ func (c *Client) RecipientListCreateContext(ctx context.Context, rl *RecipientLi return } - path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(RecipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { @@ -216,7 +216,7 @@ func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { // RecipientListsContext is the same as RecipientLists, and it accepts a context.Context func (c *Client) RecipientListsContext(ctx context.Context) (*[]RecipientList, *Response, error) { - path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(RecipListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err := c.HttpGet(ctx, url) if err != nil { diff --git a/recipient_lists_test.go b/recipient_lists_test.go index c2606d0..3e59a39 100644 --- a/recipient_lists_test.go +++ b/recipient_lists_test.go @@ -1,46 +1,34 @@ package gosparkpost_test import ( - "strings" + "reflect" "testing" sp "github.com/SparkPost/gosparkpost" - "github.com/SparkPost/gosparkpost/test" + "github.com/pkg/errors" ) -func TestRecipients(t *testing.T) { - if true { - // Temporarily disable test so TravisCI reports build success instead of test failure. - return +func TestAddressValidation(t *testing.T) { + for idx, test := range []struct { + in interface{} + err error + out sp.Address + }{ + {nil, errors.New("unsupported Recipient.Address value type [%!s()]"), sp.Address{}}, + {"", errors.New("Recipient.Address may not be empty"), sp.Address{}}, + {"a@b.com", nil, sp.Address{Email: "a@b.com"}}, + {sp.Address{"a@b.com", "A B", "c@d.com"}, nil, sp.Address{"a@b.com", "A B", "c@d.com"}}, + {map[string]interface{}{"foo": 42}, errors.New("strings are required for all Recipient.Address values"), sp.Address{}}, + {map[string]interface{}{"Name": "A B", "email": "a@b.com", "header_To": "c@d.com"}, nil, sp.Address{"a@b.com", "A B", "c@d.com"}}, + {map[string]string{"Name": "A B", "email": "a@b.com", "header_To": "c@d.com"}, nil, sp.Address{"a@b.com", "A B", "c@d.com"}}, + } { + a, err := sp.ParseAddress(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("ParseAddress[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("ParseAddress[%d] => err %q, want %q", idx, err, test.err) + } else if !reflect.DeepEqual(a, test.out) { + t.Errorf("ParseAddress[%d] => got/want:\n%q\n%q", idx, a, test.out) + } } - - cfgMap, err := test.LoadConfig() - if err != nil { - t.Error(err) - return - } - cfg, err := sp.NewConfig(cfgMap) - if err != nil { - t.Error(err) - return - } - - var client sp.Client - err = client.Init(cfg) - if err != nil { - t.Error(err) - return - } - - list, _, err := client.RecipientLists() - if err != nil { - t.Error(err) - return - } - - strs := make([]string, len(*list)) - for idx, rl := range *list { - strs[idx] = rl.String() - } - t.Errorf("%s\n", strings.Join(strs, "\n")) } From f7e274613c51e4e81a5ed9c148d69a3dc0155e9c Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 7 Apr 2017 16:57:08 -0600 Subject: [PATCH 125/152] use `reflect.DeepEqual` instead of a per-test comparator --- templates_test.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/templates_test.go b/templates_test.go index 52f9f7b..0c2896a 100644 --- a/templates_test.go +++ b/templates_test.go @@ -74,9 +74,9 @@ func TestTemplateOptions(t *testing.T) { func TestTemplateValidation(t *testing.T) { for idx, test := range []struct { - te *sp.Template + in *sp.Template err error - cmp func(te *sp.Template) bool + out *sp.Template }{ {nil, errors.New("Can't Validate a nil Template"), nil}, {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), nil}, @@ -117,19 +117,16 @@ func TestTemplateValidation(t *testing.T) { }}, errors.New("InlineImage data may not contain line breaks [\\r\\n]"), nil}, - { - &sp.Template{Content: sp.Content{EmailRFC822: "From:foo@example.com\r\n", Subject: "removeme"}}, - nil, - func(te *sp.Template) bool { return te.Content.Subject == "" }, - }, + {&sp.Template{Content: sp.Content{EmailRFC822: "From:foo@example.com\r\n", Subject: "removeme"}}, + nil, &sp.Template{Content: sp.Content{EmailRFC822: "From:foo@example.com\r\n"}}}, } { - err := test.te.Validate() + err := test.in.Validate() if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("Template.Validate[%d] => err %q, want %q", idx, err, test.err) } else if err != nil && err.Error() != test.err.Error() { t.Errorf("Template.Validate[%d] => err %q, want %q", idx, err, test.err) - } else if test.cmp != nil && test.cmp(test.te) == false { - t.Errorf("Template.Validate[%d] => failed post-condition check for %q", test.te) + } else if test.out != nil && !reflect.DeepEqual(test.in, test.out) { + t.Errorf("Template.Validate[%d] => failed post-condition check for %q", test.in) } } } From bcb7a5d191346f1d19226e19f00330811353699f Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 7 Apr 2017 16:58:09 -0600 Subject: [PATCH 126/152] tests for `Recipient` and `RecipientList` validation; guard against nil `RecipientList`s; remove unused stringifier for `RecipientList` --- recipient_lists.go | 25 +++++++++------------- recipient_lists_test.go | 47 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/recipient_lists.go b/recipient_lists.go index b550f80..a48b17f 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -6,6 +6,8 @@ import ( "fmt" "reflect" "strings" + + "github.com/pkg/errors" ) // https://www.sparkpost.com/api#/reference/recipient-lists @@ -23,17 +25,6 @@ type RecipientList struct { Accepted *int `json:"total_accepted_recipients,omitempty"` } -func (rl *RecipientList) String() string { - n := 0 - if rl.Recipients != nil { - n = len(*rl.Recipients) - } else if rl.Accepted != nil { - n = *rl.Accepted - } - return fmt.Sprintf("ID:\t%s\nName:\t%s\nDesc:\t%s\nCount:\t%d\n", - rl.ID, rl.Name, rl.Description, n) -} - // Recipient represents one email (you guessed it) recipient. type Recipient struct { Address interface{} `json:"address"` @@ -105,18 +96,22 @@ func ParseAddress(addr interface{}) (a Address, err error) { // Validate runs sanity checks on a RecipientList struct. This should // catch most errors before attempting a doomed API call. func (rl *RecipientList) Validate() error { + if rl == nil { + return errors.New("Can't validate a nil RecipientList") + } + // enforce required parameters if rl.Recipients == nil || len(*rl.Recipients) <= 0 { - return fmt.Errorf("RecipientList requires at least one Recipient") + return errors.New("RecipientList requires at least one Recipient") } // enforce max lengths if len(rl.ID) > 64 { - return fmt.Errorf("RecipientList id may not be longer than 64 bytes") + return errors.New("RecipientList id may not be longer than 64 bytes") } else if len(rl.Name) > 64 { - return fmt.Errorf("RecipientList name may not be longer than 64 bytes") + return errors.New("RecipientList name may not be longer than 64 bytes") } else if len(rl.Description) > 1024 { - return fmt.Errorf("RecipientList description may not be longer than 1024 bytes") + return errors.New("RecipientList description may not be longer than 1024 bytes") } var err error diff --git a/recipient_lists_test.go b/recipient_lists_test.go index 3e59a39..ce9e963 100644 --- a/recipient_lists_test.go +++ b/recipient_lists_test.go @@ -2,6 +2,7 @@ package gosparkpost_test import ( "reflect" + "strings" "testing" sp "github.com/SparkPost/gosparkpost" @@ -32,3 +33,49 @@ func TestAddressValidation(t *testing.T) { } } } + +func TestRecipientValidation(t *testing.T) { + for idx, test := range []struct { + in sp.Recipient + err error + }{ + {sp.Recipient{}, errors.New("unsupported Recipient.Address value type [%!s()]")}, + {sp.Recipient{Address: "a@b.com"}, nil}, + } { + err := test.in.Validate() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Recipient.Validate[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Recipient.Validate[%d] => err %q, want %q", idx, err, test.err) + } + } +} + +func TestRecipientListValidation(t *testing.T) { + for idx, test := range []struct { + in *sp.RecipientList + err error + }{ + {nil, errors.New("Can't validate a nil RecipientList")}, + {&sp.RecipientList{}, errors.New("RecipientList requires at least one Recipient")}, + + {&sp.RecipientList{ID: strings.Repeat("id", 33), + Recipients: &[]sp.Recipient{{}}}, errors.New("RecipientList id may not be longer than 64 bytes")}, + {&sp.RecipientList{ID: "id", Name: strings.Repeat("name", 17), + Recipients: &[]sp.Recipient{{}}}, errors.New("RecipientList name may not be longer than 64 bytes")}, + {&sp.RecipientList{ID: "id", Name: "name", Description: strings.Repeat("desc", 257), + Recipients: &[]sp.Recipient{{}}}, errors.New("RecipientList description may not be longer than 1024 bytes")}, + + {&sp.RecipientList{ID: "id", Name: "name", Description: "desc", + Recipients: &[]sp.Recipient{{}}}, errors.New("unsupported Recipient.Address value type [%!s()]")}, + {&sp.RecipientList{ID: "id", Name: "name", Description: "desc", + Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, nil}, + } { + err := test.in.Validate() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("RecipientList.Validate[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("RecipientList.Validate[%d] => err %q, want %q", idx, err, test.err) + } + } +} From 2fee7dcc46d14f4faf607360c346a0f4f1a3f2b0 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 7 Apr 2017 17:19:29 -0600 Subject: [PATCH 127/152] add tests and simplify errors for `RecipientListCreate` --- recipient_lists.go | 20 ++++---------------- recipient_lists_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/recipient_lists.go b/recipient_lists.go index a48b17f..46851b0 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -11,7 +11,7 @@ import ( ) // https://www.sparkpost.com/api#/reference/recipient-lists -var RecipListsPathFormat = "/api/v%d/recipient-lists" +var RecipientListsPathFormat = "/api/v%d/recipient-lists" // RecipientList is the JSON structure accepted by and returned from the SparkPost Recipient Lists API. // It's mostly metadata at this level - see Recipients for more detail. @@ -158,7 +158,7 @@ func (c *Client) RecipientListCreateContext(ctx context.Context, rl *RecipientLi return } - path := fmt.Sprintf(RecipListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(RecipientListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { @@ -186,19 +186,7 @@ func (c *Client) RecipientListCreateContext(ctx context.Context, rl *RecipientLi } } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("RecipientList", "create") - if err != nil { - return - } - - code := res.HTTP.StatusCode - if code == 400 || code == 422 { - eobj := res.Errors[0] - err = fmt.Errorf("%s: %s\n%s", eobj.Code, eobj.Message, eobj.Description) - } else { // everything else - err = fmt.Errorf("%d: %s", code, string(res.Body)) - } + err = res.Errors } return @@ -211,7 +199,7 @@ func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { // RecipientListsContext is the same as RecipientLists, and it accepts a context.Context func (c *Client) RecipientListsContext(ctx context.Context) (*[]RecipientList, *Response, error) { - path := fmt.Sprintf(RecipListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(RecipientListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err := c.HttpGet(ctx, url) if err != nil { diff --git a/recipient_lists_test.go b/recipient_lists_test.go index ce9e963..3fe6738 100644 --- a/recipient_lists_test.go +++ b/recipient_lists_test.go @@ -79,3 +79,44 @@ func TestRecipientListValidation(t *testing.T) { } } } + +func TestRecipientListCreate(t *testing.T) { + for idx, test := range []struct { + in *sp.RecipientList + err error + status int + json string + id string + }{ + {nil, errors.New("Create called with nil RecipientList"), 0, "", ""}, + {&sp.RecipientList{}, errors.New("RecipientList requires at least one Recipient"), 0, "", ""}, + {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + errors.New("Unexpected response to Recipient List creation (results)"), 200, `{"foo":{"id":"id"}}`, ""}, + {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + errors.New("Unexpected response to Recipient List creation (id)"), 200, `{"results":{"ID":"id"}}`, ""}, + {&sp.RecipientList{ID: "id", Attributes: func() { return }, Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + errors.New("json: unsupported type: func()"), 200, `{"results":{"ID":"id"}}`, ""}, + {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + errors.New("parsing api response: unexpected end of JSON input"), 200, `{"results":{"ID":"id"}`, ""}, + + {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + errors.New(`[{"message":"List already exists","code":"5001","description":"List 'id' already exists"}]`), 400, + `{"errors":[{"message":"List already exists","code":"5001","description":"List 'id' already exists"}]}`, ""}, + + {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, nil, 200, + `{"results":{"total_rejected_recipients": 0,"total_accepted_recipients":1,"id":"id"}}`, "id"}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "POST", test.status, sp.RecipientListsPathFormat, test.json) + + id, _, err := testClient.RecipientListCreate(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("RecipientListCreate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("RecipientListCreate[%d] => err %q want %q", idx, err, test.err) + } else if id != test.id { + t.Errorf("RecipientListCreate[%d] => id %q want %q", idx, id, test.id) + } + } +} From c47dfbd555c1271fd424e5e73e4447f9ba4e665f Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 10 Apr 2017 16:30:28 -0600 Subject: [PATCH 128/152] set `http.DefaultClient` in `Client.Init()` if one isn't specified; better `nil` checking in `Client.DoRequest(...)` (fixes panic in readme example) --- common.go | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/common.go b/common.go index faa91c8..62b450c 100644 --- a/common.go +++ b/common.go @@ -89,7 +89,7 @@ func (e SPErrors) Error() string { // Init pulls together everything necessary to make an API request. // Caller may provide their own http.Client by setting it in the provided API object. -func (api *Client) Init(cfg *Config) error { +func (c *Client) Init(cfg *Config) error { // Set default values if cfg.BaseUrl == "" { cfg.BaseUrl = "https://api.sparkpost.com" @@ -99,8 +99,11 @@ func (api *Client) Init(cfg *Config) error { if cfg.ApiVersion == 0 { cfg.ApiVersion = 1 } - api.Config = cfg - api.Headers = &http.Header{} + c.Config = cfg + c.Headers = &http.Header{} + if c.Client == nil { + c.Client = http.DefaultClient + } return nil } @@ -134,6 +137,12 @@ func (c *Client) HttpDelete(ctx context.Context, url string) (*Response, error) } func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []byte) (*Response, error) { + if c.Client == nil { + return nil, errors.New("Client.Client (http.Client) must be non-nil!") + } else if c.Config == nil { + return nil, errors.New("Client.Config must be non-nil!") + } + req, err := http.NewRequest(method, urlStr, bytes.NewBuffer(data)) if err != nil { return nil, errors.Wrap(err, "building request") @@ -165,9 +174,11 @@ func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []by } // Forward additional headers set in client to request - for header, values := range map[string][]string(*c.Headers) { - for _, value := range values { - req.Header.Add(header, value) + if c.Headers != nil { + for header, values := range map[string][]string(*c.Headers) { + for _, value := range values { + req.Header.Add(header, value) + } } } From 9bfc0cdbbd9056ccdc18304310b19795d9979c11 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 10 Apr 2017 17:02:24 -0600 Subject: [PATCH 129/152] no need to use pointer to array here, simplify --- recipient_lists.go | 20 ++++++++++---------- recipient_lists_test.go | 22 +++++++++++----------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/recipient_lists.go b/recipient_lists.go index 46851b0..c135529 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -16,11 +16,11 @@ var RecipientListsPathFormat = "/api/v%d/recipient-lists" // RecipientList is the JSON structure accepted by and returned from the SparkPost Recipient Lists API. // It's mostly metadata at this level - see Recipients for more detail. type RecipientList struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Attributes interface{} `json:"attributes,omitempty"` - Recipients *[]Recipient `json:"recipients"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Attributes interface{} `json:"attributes,omitempty"` + Recipients []Recipient `json:"recipients"` Accepted *int `json:"total_accepted_recipients,omitempty"` } @@ -101,7 +101,7 @@ func (rl *RecipientList) Validate() error { } // enforce required parameters - if rl.Recipients == nil || len(*rl.Recipients) <= 0 { + if rl.Recipients == nil || len(rl.Recipients) <= 0 { return errors.New("RecipientList requires at least one Recipient") } @@ -115,7 +115,7 @@ func (rl *RecipientList) Validate() error { } var err error - for _, r := range *rl.Recipients { + for _, r := range rl.Recipients { err = r.Validate() if err != nil { return err @@ -193,12 +193,12 @@ func (c *Client) RecipientListCreateContext(ctx context.Context, rl *RecipientLi } // RecipientLists returns all recipient lists -func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { +func (c *Client) RecipientLists() ([]RecipientList, *Response, error) { return c.RecipientListsContext(context.Background()) } // RecipientListsContext is the same as RecipientLists, and it accepts a context.Context -func (c *Client) RecipientListsContext(ctx context.Context) (*[]RecipientList, *Response, error) { +func (c *Client) RecipientListsContext(ctx context.Context) ([]RecipientList, *Response, error) { path := fmt.Sprintf(RecipientListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) res, err := c.HttpGet(ctx, url) @@ -220,7 +220,7 @@ func (c *Client) RecipientListsContext(ctx context.Context) (*[]RecipientList, * if err = json.Unmarshal(body, &rllist); err != nil { return nil, res, err } else if list, ok := rllist["results"]; ok { - return &list, res, nil + return list, res, nil } return nil, res, fmt.Errorf("Unexpected response to RecipientList list") diff --git a/recipient_lists_test.go b/recipient_lists_test.go index 3fe6738..ccbdfae 100644 --- a/recipient_lists_test.go +++ b/recipient_lists_test.go @@ -60,16 +60,16 @@ func TestRecipientListValidation(t *testing.T) { {&sp.RecipientList{}, errors.New("RecipientList requires at least one Recipient")}, {&sp.RecipientList{ID: strings.Repeat("id", 33), - Recipients: &[]sp.Recipient{{}}}, errors.New("RecipientList id may not be longer than 64 bytes")}, + Recipients: []sp.Recipient{{}}}, errors.New("RecipientList id may not be longer than 64 bytes")}, {&sp.RecipientList{ID: "id", Name: strings.Repeat("name", 17), - Recipients: &[]sp.Recipient{{}}}, errors.New("RecipientList name may not be longer than 64 bytes")}, + Recipients: []sp.Recipient{{}}}, errors.New("RecipientList name may not be longer than 64 bytes")}, {&sp.RecipientList{ID: "id", Name: "name", Description: strings.Repeat("desc", 257), - Recipients: &[]sp.Recipient{{}}}, errors.New("RecipientList description may not be longer than 1024 bytes")}, + Recipients: []sp.Recipient{{}}}, errors.New("RecipientList description may not be longer than 1024 bytes")}, {&sp.RecipientList{ID: "id", Name: "name", Description: "desc", - Recipients: &[]sp.Recipient{{}}}, errors.New("unsupported Recipient.Address value type [%!s()]")}, + Recipients: []sp.Recipient{{}}}, errors.New("unsupported Recipient.Address value type [%!s()]")}, {&sp.RecipientList{ID: "id", Name: "name", Description: "desc", - Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, nil}, + Recipients: []sp.Recipient{{Address: "a@b.com"}}}, nil}, } { err := test.in.Validate() if err == nil && test.err != nil || err != nil && test.err == nil { @@ -90,20 +90,20 @@ func TestRecipientListCreate(t *testing.T) { }{ {nil, errors.New("Create called with nil RecipientList"), 0, "", ""}, {&sp.RecipientList{}, errors.New("RecipientList requires at least one Recipient"), 0, "", ""}, - {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, errors.New("Unexpected response to Recipient List creation (results)"), 200, `{"foo":{"id":"id"}}`, ""}, - {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, errors.New("Unexpected response to Recipient List creation (id)"), 200, `{"results":{"ID":"id"}}`, ""}, - {&sp.RecipientList{ID: "id", Attributes: func() { return }, Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + {&sp.RecipientList{ID: "id", Attributes: func() { return }, Recipients: []sp.Recipient{{Address: "a@b.com"}}}, errors.New("json: unsupported type: func()"), 200, `{"results":{"ID":"id"}}`, ""}, - {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, errors.New("parsing api response: unexpected end of JSON input"), 200, `{"results":{"ID":"id"}`, ""}, - {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, errors.New(`[{"message":"List already exists","code":"5001","description":"List 'id' already exists"}]`), 400, `{"errors":[{"message":"List already exists","code":"5001","description":"List 'id' already exists"}]}`, ""}, - {&sp.RecipientList{ID: "id", Recipients: &[]sp.Recipient{{Address: "a@b.com"}}}, nil, 200, + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, nil, 200, `{"results":{"total_rejected_recipients": 0,"total_accepted_recipients":1,"id":"id"}}`, "id"}, } { testSetup(t) From 201464cb247a47da67d575b08f965364e6bef2b7 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 10 Apr 2017 17:02:43 -0600 Subject: [PATCH 130/152] first `RecipientLists` test --- recipient_lists_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/recipient_lists_test.go b/recipient_lists_test.go index ccbdfae..30ac74f 100644 --- a/recipient_lists_test.go +++ b/recipient_lists_test.go @@ -120,3 +120,25 @@ func TestRecipientListCreate(t *testing.T) { } } } + +func TestRecipientLists(t *testing.T) { + for idx, test := range []struct { + err error + status int + json string + //out []sp.RecipientList + }{ + {nil, 200, `{"results":[{}]}`}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "GET", test.status, sp.RecipientListsPathFormat, test.json) + + _, _, err := testClient.RecipientLists() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("RecipientLists[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("RecipientLists[%d] => err %q want %q", idx, err, test.err) + } + } +} From ff1941e08f3c91e84c88fb7ffab04bb66b2a5fbc Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 11 Apr 2017 10:18:24 -0600 Subject: [PATCH 131/152] finish up RecipientLists tests; save stack traces with errors; avoid using `reflect`, use `%T` format string instead --- recipient_lists.go | 23 ++++++++--------------- recipient_lists_test.go | 10 +++++++--- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/recipient_lists.go b/recipient_lists.go index c135529..e99c567 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "reflect" "strings" "github.com/pkg/errors" @@ -48,7 +47,7 @@ func ParseAddress(addr interface{}) (a Address, err error) { switch addrVal := addr.(type) { case string: // simple string value if addrVal == "" { - err = fmt.Errorf("Recipient.Address may not be empty") + err = errors.New("Recipient.Address may not be empty") } else { a.Email = addrVal } @@ -69,7 +68,7 @@ func ParseAddress(addr interface{}) (a Address, err error) { a.HeaderTo = vVal } default: - err = fmt.Errorf("strings are required for all Recipient.Address values") + err = errors.New("strings are required for all Recipient.Address values") break } } @@ -87,7 +86,7 @@ func ParseAddress(addr interface{}) (a Address, err error) { } default: - err = fmt.Errorf("unsupported Recipient.Address value type [%s]", reflect.TypeOf(addrVal)) + err = errors.Errorf("unsupported Recipient.Address value type [%T]", addrVal) } return @@ -144,7 +143,7 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons // RecipientListCreateContext is the same as RecipientListCreate, and it accepts a context.Context func (c *Client) RecipientListCreateContext(ctx context.Context, rl *RecipientList) (id string, res *Response, err error) { if rl == nil { - err = fmt.Errorf("Create called with nil RecipientList") + err = errors.New("Create called with nil RecipientList") return } @@ -178,11 +177,11 @@ func (c *Client) RecipientListCreateContext(ctx context.Context, rl *RecipientLi var ok bool var results map[string]interface{} if results, ok = res.Results.(map[string]interface{}); !ok { - return id, res, fmt.Errorf("Unexpected response to Recipient List creation (results)") + return id, res, errors.New("Unexpected response to Recipient List creation (results)") } id, ok = results["id"].(string) if !ok { - return id, res, fmt.Errorf("Unexpected response to Recipient List creation (id)") + return id, res, errors.New("Unexpected response to Recipient List creation (id)") } } else if len(res.Errors) > 0 { @@ -222,20 +221,14 @@ func (c *Client) RecipientListsContext(ctx context.Context) ([]RecipientList, *R } else if list, ok := rllist["results"]; ok { return list, res, nil } - return nil, res, fmt.Errorf("Unexpected response to RecipientList list") + err = errors.New("Unexpected response to RecipientList list") } else { err = res.ParseResponse() if err != nil { return nil, res, err } - if len(res.Errors) > 0 { - err = res.PrettyError("RecipientList", "list") - if err != nil { - return nil, res, err - } - } - return nil, res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = res.Errors } return nil, res, err diff --git a/recipient_lists_test.go b/recipient_lists_test.go index 30ac74f..2bd777f 100644 --- a/recipient_lists_test.go +++ b/recipient_lists_test.go @@ -15,7 +15,7 @@ func TestAddressValidation(t *testing.T) { err error out sp.Address }{ - {nil, errors.New("unsupported Recipient.Address value type [%!s()]"), sp.Address{}}, + {nil, errors.New("unsupported Recipient.Address value type []"), sp.Address{}}, {"", errors.New("Recipient.Address may not be empty"), sp.Address{}}, {"a@b.com", nil, sp.Address{Email: "a@b.com"}}, {sp.Address{"a@b.com", "A B", "c@d.com"}, nil, sp.Address{"a@b.com", "A B", "c@d.com"}}, @@ -39,7 +39,7 @@ func TestRecipientValidation(t *testing.T) { in sp.Recipient err error }{ - {sp.Recipient{}, errors.New("unsupported Recipient.Address value type [%!s()]")}, + {sp.Recipient{}, errors.New("unsupported Recipient.Address value type []")}, {sp.Recipient{Address: "a@b.com"}, nil}, } { err := test.in.Validate() @@ -67,7 +67,7 @@ func TestRecipientListValidation(t *testing.T) { Recipients: []sp.Recipient{{}}}, errors.New("RecipientList description may not be longer than 1024 bytes")}, {&sp.RecipientList{ID: "id", Name: "name", Description: "desc", - Recipients: []sp.Recipient{{}}}, errors.New("unsupported Recipient.Address value type [%!s()]")}, + Recipients: []sp.Recipient{{}}}, errors.New("unsupported Recipient.Address value type []")}, {&sp.RecipientList{ID: "id", Name: "name", Description: "desc", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, nil}, } { @@ -129,6 +129,10 @@ func TestRecipientLists(t *testing.T) { //out []sp.RecipientList }{ {nil, 200, `{"results":[{}]}`}, + {errors.New("Unexpected response to RecipientList list"), 200, `{"foo":[{}]}`}, + {errors.New("unexpected end of JSON input"), 200, `{"results":[{}]`}, + {errors.New(`[{"message":"No RecipientList for you!","code":"","description":""}]`), 401, `{"errors":[{"message":"No RecipientList for you!"}]}`}, + {errors.New("parsing api response: unexpected end of JSON input"), 401, `{"errors":[]`}, } { testSetup(t) defer testTeardown() From f40483c8fcce32f6dfe58c69afe894aa11cd0546 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Tue, 11 Apr 2017 10:48:24 -0600 Subject: [PATCH 132/152] move json files into a subdirectory under `test` --- suppression_list_test.go | 36 +++++++++---------- .../json}/suppression_combined.json | 0 .../json}/suppression_cursor.json | 0 .../json}/suppression_delete_no_email.json | 0 .../json}/suppression_entry_simple.json | 0 .../suppression_entry_simple_request.json | 0 .../json}/suppression_not_found_error.json | 0 .../json}/suppression_page1.json | 8 ++--- .../json}/suppression_page2.json | 10 +++--- .../json}/suppression_pageLast.json | 8 ++--- .../json}/suppression_retrieve.json | 0 .../json}/suppression_seperate_lists.json | 0 .../json}/suppression_single_page.json | 0 13 files changed, 31 insertions(+), 31 deletions(-) rename {test_data => test/json}/suppression_combined.json (100%) rename {test_data => test/json}/suppression_cursor.json (100%) rename {test_data => test/json}/suppression_delete_no_email.json (100%) rename {test_data => test/json}/suppression_entry_simple.json (100%) rename {test_data => test/json}/suppression_entry_simple_request.json (100%) rename {test_data => test/json}/suppression_not_found_error.json (100%) rename {test_data => test/json}/suppression_page1.json (76%) rename {test_data => test/json}/suppression_page2.json (72%) rename {test_data => test/json}/suppression_pageLast.json (76%) rename {test_data => test/json}/suppression_retrieve.json (100%) rename {test_data => test/json}/suppression_seperate_lists.json (100%) rename {test_data => test/json}/suppression_single_page.json (100%) diff --git a/suppression_list_test.go b/suppression_list_test.go index 82aadad..4f64297 100644 --- a/suppression_list_test.go +++ b/suppression_list_test.go @@ -15,7 +15,7 @@ func TestUnmarshal_SupressionEvent(t *testing.T) { testSetup(t) defer testTeardown() - var suppressionEventString = loadTestFile(t, "test_data/suppression_entry_simple.json") + var suppressionEventString = loadTestFile(t, "test/json/suppression_entry_simple.json") suppressionEntry := &sp.SuppressionEntry{} err := json.Unmarshal([]byte(suppressionEventString), suppressionEntry) @@ -32,7 +32,7 @@ func TestSuppression_Get_notFound(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_not_found_error.json") + var mockResponse = loadTestFile(t, "test/json/suppression_not_found_error.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -60,7 +60,7 @@ func TestSuppression_Retrieve(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_retrieve.json") + var mockResponse = loadTestFile(t, "test/json/suppression_retrieve.json") status := http.StatusOK email := "john.doe@domain.com" mockRestResponseBuilderFormat(t, "GET", status, sp.SuppressionListsPathFormat+"/"+email, mockResponse) @@ -89,7 +89,7 @@ func TestSuppression_Error_Bad_Path(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_not_found_error.json") + var mockResponse = loadTestFile(t, "test/json/suppression_not_found_error.json") mockRestBuilderFormat(t, "GET", "/bad/path", mockResponse) // hit our local handler @@ -145,7 +145,7 @@ func TestSuppression_Get_combinedList(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_combined.json") + var mockResponse = loadTestFile(t, "test/json/suppression_combined.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -175,7 +175,7 @@ func TestSuppression_Get_separateList(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_seperate_lists.json") + var mockResponse = loadTestFile(t, "test/json/suppression_seperate_lists.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -205,7 +205,7 @@ func TestSuppression_links(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_cursor.json") + var mockResponse = loadTestFile(t, "test/json/suppression_cursor.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -254,7 +254,7 @@ func TestSuppression_Empty_NextPage(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_single_page.json") + var mockResponse = loadTestFile(t, "test/json/suppression_single_page.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -285,11 +285,11 @@ func TestSuppression_NextPage(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_page1.json") + var mockResponse = loadTestFile(t, "test/json/suppression_page1.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) - mockResponse = loadTestFile(t, "test_data/suppression_page2.json") - mockRestBuilder(t, "GET", "/test_data/suppression_page2.json", mockResponse) + mockResponse = loadTestFile(t, "test/json/suppression_page2.json") + mockRestBuilder(t, "GET", "/test/json/suppression_page2.json", mockResponse) // hit our local handler suppressionPage := &sp.SuppressionPage{} @@ -302,13 +302,13 @@ func TestSuppression_NextPage(t *testing.T) { return } - if suppressionPage.NextPage != "/test_data/suppression_page2.json" { + if suppressionPage.NextPage != "/test/json/suppression_page2.json" { t.Errorf("Unexpected NextPage value: %s", suppressionPage.NextPage) } nextResponse, res, err := suppressionPage.Next() - if nextResponse.NextPage != "/test_data/suppression_pageLast.json" { + if nextResponse.NextPage != "/test/json/suppression_pageLast.json" { t.Errorf("Unexpected NextPage value: %s", nextResponse.NextPage) } } @@ -319,7 +319,7 @@ func TestSuppression_Search_combinedList(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_combined.json") + var mockResponse = loadTestFile(t, "test/json/suppression_combined.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -349,7 +349,7 @@ func TestSuppression_Search_params(t *testing.T) { defer testTeardown() // set up the response handler - var mockResponse = loadTestFile(t, "test_data/suppression_combined.json") + var mockResponse = loadTestFile(t, "test/json/suppression_combined.json") mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) // hit our local handler @@ -419,7 +419,7 @@ func TestClient_SuppressionUpsert_1_entry(t *testing.T) { testSetup(t) defer testTeardown() - var expectedRequest = loadTestFile(t, "test_data/suppression_entry_simple_request.json") + var expectedRequest = loadTestFile(t, "test/json/suppression_entry_simple_request.json") var mockResponse = "{}" mockRestRequestResponseBuilderFormat(t, "PUT", http.StatusOK, sp.SuppressionListsPathFormat, expectedRequest, mockResponse) @@ -445,7 +445,7 @@ func TestClient_SuppressionUpsert_error_response(t *testing.T) { testSetup(t) defer testTeardown() - var mockResponse = loadTestFile(t, "test_data/suppression_not_found_error.json") + var mockResponse = loadTestFile(t, "test/json/suppression_not_found_error.json") status := http.StatusBadRequest mockRestResponseBuilderFormat(t, "PUT", status, sp.SuppressionListsPathFormat, mockResponse) @@ -513,7 +513,7 @@ func TestClient_Suppression_Delete_Errors(t *testing.T) { email := "test@test.com" status := http.StatusBadRequest - var mockResponse = loadTestFile(t, "test_data/suppression_not_found_error.json") + var mockResponse = loadTestFile(t, "test/json/suppression_not_found_error.json") mockRestResponseBuilderFormat(t, "DELETE", status, sp.SuppressionListsPathFormat+"/"+email, mockResponse) response, err := testClient.SuppressionDelete(email) diff --git a/test_data/suppression_combined.json b/test/json/suppression_combined.json similarity index 100% rename from test_data/suppression_combined.json rename to test/json/suppression_combined.json diff --git a/test_data/suppression_cursor.json b/test/json/suppression_cursor.json similarity index 100% rename from test_data/suppression_cursor.json rename to test/json/suppression_cursor.json diff --git a/test_data/suppression_delete_no_email.json b/test/json/suppression_delete_no_email.json similarity index 100% rename from test_data/suppression_delete_no_email.json rename to test/json/suppression_delete_no_email.json diff --git a/test_data/suppression_entry_simple.json b/test/json/suppression_entry_simple.json similarity index 100% rename from test_data/suppression_entry_simple.json rename to test/json/suppression_entry_simple.json diff --git a/test_data/suppression_entry_simple_request.json b/test/json/suppression_entry_simple_request.json similarity index 100% rename from test_data/suppression_entry_simple_request.json rename to test/json/suppression_entry_simple_request.json diff --git a/test_data/suppression_not_found_error.json b/test/json/suppression_not_found_error.json similarity index 100% rename from test_data/suppression_not_found_error.json rename to test/json/suppression_not_found_error.json diff --git a/test_data/suppression_page1.json b/test/json/suppression_page1.json similarity index 76% rename from test_data/suppression_page1.json rename to test/json/suppression_page1.json index 716cd9a..b87c9b5 100644 --- a/test_data/suppression_page1.json +++ b/test/json/suppression_page1.json @@ -12,17 +12,17 @@ ], "links": [ { - "href": "/test_data/suppression_page1.json", + "href": "/test/json/suppression_page1.json", "rel": "first" }, { - "href": "/test_data/suppression_page2.json", + "href": "/test/json/suppression_page2.json", "rel": "next" }, { - "href": "/test_data/suppression_pageLast.json", + "href": "/test/json/suppression_pageLast.json", "rel": "last" } ], "total_count": 3 -} \ No newline at end of file +} diff --git a/test_data/suppression_page2.json b/test/json/suppression_page2.json similarity index 72% rename from test_data/suppression_page2.json rename to test/json/suppression_page2.json index 94bd59d..753aac7 100644 --- a/test_data/suppression_page2.json +++ b/test/json/suppression_page2.json @@ -12,21 +12,21 @@ ], "links": [ { - "href": "/test_data/suppression_page1.json", + "href": "/test/json/suppression_page1.json", "rel": "first" }, { - "href": "/test_data/suppression_pageLast.json", + "href": "/test/json/suppression_pageLast.json", "rel": "next" }, { - "href": "/test_data/suppression_page1.json", + "href": "/test/json/suppression_page1.json", "rel": "previous" }, { - "href": "/test_data/suppression_pageLast.json", + "href": "/test/json/suppression_pageLast.json", "rel": "last" } ], "total_count": 3 -} \ No newline at end of file +} diff --git a/test_data/suppression_pageLast.json b/test/json/suppression_pageLast.json similarity index 76% rename from test_data/suppression_pageLast.json rename to test/json/suppression_pageLast.json index 64680c1..591df6e 100644 --- a/test_data/suppression_pageLast.json +++ b/test/json/suppression_pageLast.json @@ -12,17 +12,17 @@ ], "links": [ { - "href": "/test_data/suppression_page1.json", + "href": "/test/json/suppression_page1.json", "rel": "first" }, { - "href": "/test_data/suppression_page2.json", + "href": "/test/json/suppression_page2.json", "rel": "previous" }, { - "href": "/test_data/suppression_pageLast.json", + "href": "/test/json/suppression_pageLast.json", "rel": "last" } ], "total_count": 3 -} \ No newline at end of file +} diff --git a/test_data/suppression_retrieve.json b/test/json/suppression_retrieve.json similarity index 100% rename from test_data/suppression_retrieve.json rename to test/json/suppression_retrieve.json diff --git a/test_data/suppression_seperate_lists.json b/test/json/suppression_seperate_lists.json similarity index 100% rename from test_data/suppression_seperate_lists.json rename to test/json/suppression_seperate_lists.json diff --git a/test_data/suppression_single_page.json b/test/json/suppression_single_page.json similarity index 100% rename from test_data/suppression_single_page.json rename to test/json/suppression_single_page.json From ebbca315121ea7a4bf61e83cb214647365c0aad2 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Thu, 13 Apr 2017 11:21:00 -0700 Subject: [PATCH 133/152] test that `[]RecipientList` is parsed as expected --- recipient_lists_test.go | 39 ++++++++++++++++++++++++------ test/json/recipient_lists_200.json | 24 ++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 test/json/recipient_lists_200.json diff --git a/recipient_lists_test.go b/recipient_lists_test.go index 2bd777f..f16df19 100644 --- a/recipient_lists_test.go +++ b/recipient_lists_test.go @@ -122,27 +122,52 @@ func TestRecipientListCreate(t *testing.T) { } func TestRecipientLists(t *testing.T) { + var res200 = loadTestFile(t, "test/json/recipient_lists_200.json") + var acc200 = []int{3, 8} + var rl200 = []sp.RecipientList{{ + ID: "unique_id_4_graduate_students_list", + Name: "graduate_students", + Description: "An email list of graduate students at UMBC", + Attributes: map[string]interface{}{ + "internal_id": float64(112), + "list_group_id": float64(12321), + }, + Accepted: &acc200[0], + }, { + ID: "unique_id_4_undergraduates", + Name: "undergraduate_students", + Description: "An email list of undergraduate students at UMBC", + Attributes: map[string]interface{}{ + "internal_id": float64(111), + "list_group_id": float64(11321), + }, + Accepted: &acc200[1], + }} + for idx, test := range []struct { err error status int json string - //out []sp.RecipientList + out []sp.RecipientList }{ - {nil, 200, `{"results":[{}]}`}, - {errors.New("Unexpected response to RecipientList list"), 200, `{"foo":[{}]}`}, - {errors.New("unexpected end of JSON input"), 200, `{"results":[{}]`}, - {errors.New(`[{"message":"No RecipientList for you!","code":"","description":""}]`), 401, `{"errors":[{"message":"No RecipientList for you!"}]}`}, - {errors.New("parsing api response: unexpected end of JSON input"), 401, `{"errors":[]`}, + {nil, 200, `{"results":[{}]}`, []sp.RecipientList{{}}}, + {errors.New("Unexpected response to RecipientList list"), 200, `{"foo":[{}]}`, nil}, + {errors.New("unexpected end of JSON input"), 200, `{"results":[{}]`, nil}, + {errors.New(`[{"message":"No RecipientList for you!","code":"","description":""}]`), 401, `{"errors":[{"message":"No RecipientList for you!"}]}`, nil}, + {errors.New("parsing api response: unexpected end of JSON input"), 401, `{"errors":[]`, nil}, + {nil, 200, res200, rl200}, } { testSetup(t) defer testTeardown() mockRestResponseBuilderFormat(t, "GET", test.status, sp.RecipientListsPathFormat, test.json) - _, _, err := testClient.RecipientLists() + lists, _, err := testClient.RecipientLists() if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("RecipientLists[%d] => err %q want %q", idx, err, test.err) } else if err != nil && err.Error() != test.err.Error() { t.Errorf("RecipientLists[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil && !reflect.DeepEqual(lists, test.out) { + t.Errorf("RecipientLists[%d] => got/want:\n%q\n%q", idx, lists, test.out) } } } diff --git a/test/json/recipient_lists_200.json b/test/json/recipient_lists_200.json new file mode 100644 index 0000000..d90559b --- /dev/null +++ b/test/json/recipient_lists_200.json @@ -0,0 +1,24 @@ +{ + "results": [ + { + "id": "unique_id_4_graduate_students_list", + "name": "graduate_students", + "description": "An email list of graduate students at UMBC", + "attributes": { + "internal_id": 112, + "list_group_id": 12321 + }, + "total_accepted_recipients": 3 + }, + { + "id": "unique_id_4_undergraduates", + "name": "undergraduate_students", + "description": "An email list of undergraduate students at UMBC", + "attributes": { + "internal_id": 111, + "list_group_id": 11321 + }, + "total_accepted_recipients": 8 + } + ] +} \ No newline at end of file From 086dd0b54efe2f7d1c44c62f879c33b81008386f Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 14 Apr 2017 15:27:48 -0700 Subject: [PATCH 134/152] SubaccountCreate tests --- subaccounts.go | 99 +++++++++------------------- subaccounts_test.go | 71 ++++++++++++++++++++ test/json/subaccount_create_200.json | 8 +++ 3 files changed, 109 insertions(+), 69 deletions(-) create mode 100644 subaccounts_test.go create mode 100644 test/json/subaccount_create_200.json diff --git a/subaccounts.go b/subaccounts.go index 40e6aa7..af1870a 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -4,19 +4,23 @@ import ( "context" "encoding/json" "fmt" + + "github.com/pkg/errors" ) // https://www.sparkpost.com/api#/reference/subaccounts var SubaccountsPathFormat = "/api/v%d/subaccounts" -var availableGrants = []string{ +var SubaccountGrants = []string{ "smtp/inject", "sending_domains/manage", "message_events/view", "suppression_lists/manage", + "tracking_domains/view", + "tracking_domains/manage", "transmissions/view", "transmissions/modify", } -var validStatuses = []string{ +var SubaccountStatuses = []string{ "active", "suspended", "terminated", @@ -32,6 +36,7 @@ type Subaccount struct { ShortKey string `json:"short_key,omitempty"` Status string `json:"status,omitempty"` ComplianceStatus string `json:"compliance_status,omitempty"` + IPPool string `json:"ip_pool,omitempty"` } // SubaccountCreate accepts a populated Subaccount object, validates it, @@ -44,30 +49,30 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (res *Response, err error) { // enforce required parameters if s == nil { - err = fmt.Errorf("Create called with nil Subaccount") + err = errors.New("Create called with nil Subaccount") } else if s.Name == "" { - err = fmt.Errorf("Subaccount requires a non-empty Name") + err = errors.New("Subaccount requires a non-empty Name") } else if s.KeyLabel == "" { - err = fmt.Errorf("Subaccount requires a non-empty Key Label") + err = errors.New("Subaccount requires a non-empty Key Label") } else // enforce max lengths if len(s.Name) > 1024 { - err = fmt.Errorf("Subaccount name may not be longer than 1024 bytes") + err = errors.New("Subaccount name may not be longer than 1024 bytes") } else if len(s.KeyLabel) > 1024 { - err = fmt.Errorf("Subaccount key label may not be longer than 1024 bytes") + err = errors.New("Subaccount key label may not be longer than 1024 bytes") + } else if s.IPPool != "" && len(s.IPPool) > 20 { + err = errors.New("Subaccount ip pool may not be longer than 20 bytes") } if err != nil { return } if len(s.Grants) == 0 { - s.Grants = availableGrants + s.Grants = SubaccountGrants } - jsonBytes, err := json.Marshal(s) - if err != nil { - return - } + // Marshaling a static type won't fail + jsonBytes, _ := json.Marshal(s) path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) @@ -89,31 +94,20 @@ func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (re var ok bool var results map[string]interface{} if results, ok = res.Results.(map[string]interface{}); !ok { - return res, fmt.Errorf("Unexpected response to Subaccount creation (results)") + return res, errors.New("Unexpected response to Subaccount creation (results)") } f, ok := results["subaccount_id"].(float64) if !ok { - err = fmt.Errorf("Unexpected response to Subaccount creation") + err = errors.New("Unexpected response to Subaccount creation (subaccount_id)") } s.ID = int(f) s.ShortKey, ok = results["short_key"].(string) if !ok { - err = fmt.Errorf("Unexpected response to Subaccount creation") + err = errors.New("Unexpected response to Subaccount creation (short_key)") } } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Subaccount", "create") - if err != nil { - return - } - - if res.HTTP.StatusCode == 422 { // subaccount syntax error - eobj := res.Errors[0] - err = fmt.Errorf("%s: %s\n%s", eobj.Code, eobj.Message, eobj.Description) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + err = res.Errors } return @@ -129,18 +123,18 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { // SubaccountUpdateContext is the same as SubaccountUpdate, and it allows the caller to provide a context func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (res *Response, err error) { if s.ID == 0 { - err = fmt.Errorf("Subaccount Update called with zero id") + err = errors.New("Subaccount Update called with zero id") } else if len(s.Name) > 1024 { - err = fmt.Errorf("Subaccount name may not be longer than 1024 bytes") + err = errors.New("Subaccount name may not be longer than 1024 bytes") } else if s.Status != "" { found := false - for _, v := range validStatuses { + for _, v := range SubaccountStatuses { if s.Status == v { found = true } } if !found { - err = fmt.Errorf("Not a valid subaccount status") + err = errors.New("Not a valid subaccount status") } } @@ -174,18 +168,7 @@ func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (re return } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Subaccount", "update") - if err != nil { - return - } - - // handle template-specific ones - if res.HTTP.StatusCode == 409 { - err = fmt.Errorf("Subaccount with id [%s] is in use by msg generation", s.ID) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + err = res.Errors } return @@ -224,22 +207,11 @@ func (c *Client) SubaccountsContext(ctx context.Context) (subaccounts []Subaccou subaccounts = list return } - err = fmt.Errorf("Unexpected response to Subaccount list") + err = errors.New("Unexpected response to Subaccount list") return } else { - err = res.ParseResponse() - if err != nil { - return - } - if len(res.Errors) > 0 { - err = res.PrettyError("Subaccount", "list") - if err != nil { - return - } - } - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - return + err = res.Errors } return @@ -279,22 +251,11 @@ func (c *Client) SubaccountContext(ctx context.Context, id int) (subaccount *Sub subaccount = &s return } - err = fmt.Errorf("Unexpected response to Subaccount") + err = errors.New("Unexpected response to Subaccount") return } } else { - err = res.ParseResponse() - if err != nil { - return - } - if len(res.Errors) > 0 { - err = res.PrettyError("Subaccount", "retrieve") - if err != nil { - return - } - } - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - return + err = res.Errors } return diff --git a/subaccounts_test.go b/subaccounts_test.go new file mode 100644 index 0000000..11d8a15 --- /dev/null +++ b/subaccounts_test.go @@ -0,0 +1,71 @@ +package gosparkpost_test + +import ( + "reflect" + "strings" + "testing" + + sp "github.com/SparkPost/gosparkpost" + "github.com/pkg/errors" +) + +func TestSubaccountCreate(t *testing.T) { + var res200 = loadTestFile(t, "test/json/subaccount_create_200.json") + + for idx, test := range []struct { + in *sp.Subaccount + err error + status int + json string + out *sp.Subaccount + }{ + {nil, errors.New("Create called with nil Subaccount"), 0, "", nil}, + {&sp.Subaccount{}, errors.New("Subaccount requires a non-empty Name"), 0, "", nil}, + {&sp.Subaccount{Name: "n"}, errors.New("Subaccount requires a non-empty Key Label"), 0, "", nil}, + {&sp.Subaccount{Name: strings.Repeat("name", 257), KeyLabel: "kl"}, + errors.New("Subaccount name may not be longer than 1024 bytes"), 0, "", nil}, + {&sp.Subaccount{Name: "n", KeyLabel: strings.Repeat("klkl", 257)}, + errors.New("Subaccount key label may not be longer than 1024 bytes"), 0, "", nil}, + {&sp.Subaccount{Name: "n", KeyLabel: "kl", IPPool: strings.Repeat("ip", 11)}, + errors.New("Subaccount ip pool may not be longer than 20 bytes"), 0, "", nil}, + + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New("Unexpected response to Subaccount creation (results)"), 200, + strings.Replace(res200, `"results"`, `"foo"`, 1), nil}, + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New("parsing api response: unexpected end of JSON input"), 200, + res200[:len(res200)/2], nil}, + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New("Unexpected response to Subaccount creation (subaccount_id)"), 200, + strings.Replace(res200, `"subaccount_id"`, `"foo"`, 1), nil}, + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New("Unexpected response to Subaccount creation (short_key)"), 200, + strings.Replace(res200, `"short_key"`, `"foo"`, 1), nil}, + + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New(`[{"message":"error","code":"","description":""}]`), 400, + `{"errors":[{"message":"error"}]}`, nil}, + + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, nil, 200, res200, + &sp.Subaccount{ + ID: 888, + Name: "n", + KeyLabel: "kl", + ShortKey: "cf80", + Grants: sp.SubaccountGrants, + }}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "POST", test.status, sp.SubaccountsPathFormat, test.json) + + _, err := testClient.SubaccountCreate(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("SubaccountCreate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("SubaccountCreate[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil && !reflect.DeepEqual(test.in, test.out) { + t.Errorf("SubaccountCreate[%d] => got/want:\n%q\n%q", idx, test.in, test.out) + } + } +} diff --git a/test/json/subaccount_create_200.json b/test/json/subaccount_create_200.json new file mode 100644 index 0000000..76cfac0 --- /dev/null +++ b/test/json/subaccount_create_200.json @@ -0,0 +1,8 @@ +{ + "results": { + "subaccount_id": 888, + "key": "cf806c8c472562ab98ad5acac1d1b06cbd1fb438", + "label": "API Key for Sparkle Ponies Subaccount", + "short_key": "cf80" + } +} From fa89d3a73822429cef75134638e2931c086f686e Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 14 Apr 2017 15:49:29 -0700 Subject: [PATCH 135/152] SubaccountUpdate tests --- subaccounts.go | 14 ++++++++------ subaccounts_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/subaccounts.go b/subaccounts.go index af1870a..a3e2a2f 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -122,10 +122,14 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { // SubaccountUpdateContext is the same as SubaccountUpdate, and it allows the caller to provide a context func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (res *Response, err error) { - if s.ID == 0 { + if s == nil { + err = errors.New("Subaccount Update called with nil Subaccount") + } else if s.ID == 0 { err = errors.New("Subaccount Update called with zero id") } else if len(s.Name) > 1024 { err = errors.New("Subaccount name may not be longer than 1024 bytes") + } else if len(s.IPPool) > 20 { + err = errors.New("Subaccount ip pool may not be longer than 20 bytes") } else if s.Status != "" { found := false for _, v := range SubaccountStatuses { @@ -142,13 +146,11 @@ func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (re return } - jsonBytes, err := json.Marshal(s) - if err != nil { - return - } + // Marshaling a static type won't fail + jsonBytes, _ := json.Marshal(s) path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, s.ID) + url := fmt.Sprintf("%s%s/%d", c.Config.BaseUrl, path, s.ID) res, err = c.HttpPut(ctx, url, jsonBytes) if err != nil { diff --git a/subaccounts_test.go b/subaccounts_test.go index 11d8a15..969e7d7 100644 --- a/subaccounts_test.go +++ b/subaccounts_test.go @@ -2,6 +2,7 @@ package gosparkpost_test import ( "reflect" + "strconv" "strings" "testing" @@ -69,3 +70,47 @@ func TestSubaccountCreate(t *testing.T) { } } } + +func TestSubaccountUpdate(t *testing.T) { + for idx, test := range []struct { + in *sp.Subaccount + err error + status int + json string + }{ + {nil, errors.New("Subaccount Update called with nil Subaccount"), 0, ""}, + {&sp.Subaccount{}, errors.New("Subaccount Update called with zero id"), 0, ""}, + {&sp.Subaccount{ID: 42, Name: strings.Repeat("name", 257)}, + errors.New("Subaccount name may not be longer than 1024 bytes"), 0, ""}, + {&sp.Subaccount{ID: 42, Name: "n", IPPool: strings.Repeat("ip", 11)}, + errors.New("Subaccount ip pool may not be longer than 20 bytes"), 0, ""}, + {&sp.Subaccount{ID: 42, Name: "n", Status: "super"}, + errors.New("Not a valid subaccount status"), 0, ""}, + + {&sp.Subaccount{ID: 42, Name: "n"}, + errors.New("parsing api response: unexpected end of JSON input"), 200, + `{"foo":{"message":"syntax error"}`}, + {&sp.Subaccount{ID: 42, Name: "n"}, + errors.New(`[{"message":"error","code":"","description":""}]`), 400, + `{"errors":[{"message":"error"}]}`}, + + {&sp.Subaccount{ID: 42, Name: "n", Status: "active"}, nil, 200, + `{"results":{"message":"Successfully updated subaccount information"}}`}, + } { + testSetup(t) + defer testTeardown() + + id := "0" + if test.in != nil { + id = strconv.Itoa(test.in.ID) + } + mockRestResponseBuilderFormat(t, "PUT", test.status, sp.SubaccountsPathFormat+"/"+id, test.json) + + _, err := testClient.SubaccountUpdate(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("SubaccountUpdate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("SubaccountUpdate[%d] => err %q want %q", idx, err, test.err) + } + } +} From 2741d19f93380e8c5d119d1fad51106b2f52369c Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 14 Apr 2017 16:11:16 -0700 Subject: [PATCH 136/152] Subaccounts tests --- subaccounts.go | 13 ++++++------ subaccounts_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/subaccounts.go b/subaccounts.go index a3e2a2f..20d541c 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -28,7 +28,7 @@ var SubaccountStatuses = []string{ // Subaccount is the JSON structure accepted by and returned from the SparkPost Subaccounts API. type Subaccount struct { - ID int `json:"subaccount_id,omitempty"` + ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` Key string `json:"key,omitempty"` KeyLabel string `json:"key_label,omitempty"` @@ -204,16 +204,17 @@ func (c *Client) SubaccountsContext(ctx context.Context) (subaccounts []Subaccou slist := map[string][]Subaccount{} err = json.Unmarshal(body, &slist) if err != nil { - return } else if list, ok := slist["results"]; ok { subaccounts = list - return + } else { + err = errors.New("Unexpected response to Subaccount list") } - err = errors.New("Unexpected response to Subaccount list") - return } else { - err = res.Errors + err = res.ParseResponse() + if err == nil { + err = res.Errors + } } return diff --git a/subaccounts_test.go b/subaccounts_test.go index 969e7d7..24b2e92 100644 --- a/subaccounts_test.go +++ b/subaccounts_test.go @@ -114,3 +114,52 @@ func TestSubaccountUpdate(t *testing.T) { } } } + +func TestSubaccounts(t *testing.T) { + var res200 = loadTestFile(t, "test/json/subaccounts_200.json") + var sa200 = []sp.Subaccount{{ + ID: 123, + Name: "Joe's Garage", + Status: "active", + IPPool: "my_ip_pool", + ComplianceStatus: "active", + }, { + ID: 456, + Name: "SharkPost", + Status: "active", + ComplianceStatus: "active", + }, { + ID: 789, + Name: "Dev Avocado", + Status: "suspended", + ComplianceStatus: "active", + }} + + for idx, test := range []struct { + err error + status int + json string + out []sp.Subaccount + }{ + {errors.New("unexpected end of JSON input"), 200, `{"foo":[]`, nil}, + {errors.New("Unexpected response to Subaccount list"), 200, `{"foo":[]}`, nil}, + + {errors.New(`[{"message":"error","code":"","description":""}]`), 400, + `{"errors":[{"message":"error"}]}`, nil}, + + {nil, 200, res200, sa200}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "GET", test.status, sp.SubaccountsPathFormat, test.json) + + subs, _, err := testClient.Subaccounts() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Subaccounts[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Subaccounts[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil && !reflect.DeepEqual(subs, test.out) { + t.Errorf("Subaccounts[%d] => got/want:\n%q\n%q", idx, subs, test.out) + } + } +} From 6dedbfcb6523e8b9a3a4bd119e448c96eded4d89 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 14 Apr 2017 16:27:40 -0700 Subject: [PATCH 137/152] Subaccount tests --- subaccounts.go | 11 ++++++----- subaccounts_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/subaccounts.go b/subaccounts.go index 20d541c..08eb243 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -249,16 +249,17 @@ func (c *Client) SubaccountContext(ctx context.Context, id int) (subaccount *Sub slist := map[string]Subaccount{} err = json.Unmarshal(body, &slist) if err != nil { - return } else if s, ok := slist["results"]; ok { subaccount = &s - return + } else { + err = errors.New("Unexpected response to Subaccount") } - err = errors.New("Unexpected response to Subaccount") - return } } else { - err = res.Errors + err = res.ParseResponse() + if err == nil { + err = res.Errors + } } return diff --git a/subaccounts_test.go b/subaccounts_test.go index 24b2e92..4406cb1 100644 --- a/subaccounts_test.go +++ b/subaccounts_test.go @@ -163,3 +163,44 @@ func TestSubaccounts(t *testing.T) { } } } + +func TestSubaccount(t *testing.T) { + var res200 = loadTestFile(t, "test/json/subaccount_200.json") + var sub200 = &sp.Subaccount{ + ID: 123, + Name: "Joe's Garage", + Status: "active", + IPPool: "assigned_ip_pool", + ComplianceStatus: "active", + } + + for idx, test := range []struct { + in int + err error + status int + json string + out *sp.Subaccount + }{ + {42, errors.New("unexpected end of JSON input"), 200, "{", nil}, + {42, errors.New("Unexpected response to Subaccount"), 200, `{"foo":{}}`, nil}, + {42, errors.New(`[{"message":"error","code":"","description":""}]`), 400, + `{"errors":[{"message":"error"}]}`, nil}, + + {123, nil, 200, res200, sub200}, + } { + testSetup(t) + defer testTeardown() + + id := strconv.Itoa(test.in) + mockRestResponseBuilderFormat(t, "GET", test.status, sp.SubaccountsPathFormat+"/"+id, test.json) + + sub, _, err := testClient.Subaccount(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("SubaccountCreate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("SubaccountCreate[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil && !reflect.DeepEqual(sub, test.out) { + t.Errorf("SubaccountCreate[%d] => got/want:\n%q\n%q", idx, sub, test.out) + } + } +} From 306bb509e357a056a6522c83faff165cfa054fd1 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Fri, 14 Apr 2017 16:31:26 -0700 Subject: [PATCH 138/152] might help to add the test files --- test/json/subaccount_200.json | 9 +++++++++ test/json/subaccounts_200.json | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 test/json/subaccount_200.json create mode 100644 test/json/subaccounts_200.json diff --git a/test/json/subaccount_200.json b/test/json/subaccount_200.json new file mode 100644 index 0000000..342c8e5 --- /dev/null +++ b/test/json/subaccount_200.json @@ -0,0 +1,9 @@ +{ + "results": { + "id": 123, + "name": "Joe's Garage", + "status": "active", + "compliance_status": "active", + "ip_pool": "assigned_ip_pool" + } +} diff --git a/test/json/subaccounts_200.json b/test/json/subaccounts_200.json new file mode 100644 index 0000000..6381720 --- /dev/null +++ b/test/json/subaccounts_200.json @@ -0,0 +1,23 @@ +{ + "results": [ + { + "id": 123, + "name": "Joe's Garage", + "status": "active", + "ip_pool": "my_ip_pool", + "compliance_status": "active" + }, + { + "id": 456, + "name": "SharkPost", + "status": "active", + "compliance_status": "active" + }, + { + "id": 789, + "name": "Dev Avocado", + "status": "suspended", + "compliance_status": "active" + } + ] +} From 1e74fc9ac067c52cae9be21c1b69c25937c08409 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 17 Apr 2017 11:13:36 -0700 Subject: [PATCH 139/152] make `update_published` a separate parameter to `TemplateUpdate` --- templates.go | 9 +++++---- templates_test.go | 15 ++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/templates.go b/templates.go index 9656fde..cad600b 100644 --- a/templates.go +++ b/templates.go @@ -288,12 +288,13 @@ func (c *Client) TemplateGetContext(ctx context.Context, t *Template, draft bool } // TemplateUpdate updates a draft/published template with the specified id -func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { - return c.TemplateUpdateContext(context.Background(), t) +// The `updatePublished` parameter controls which version (draft/false vs published/true) of the template will be updated. +func (c *Client) TemplateUpdate(t *Template, updatePublished bool) (res *Response, err error) { + return c.TemplateUpdateContext(context.Background(), t, updatePublished) } // TemplateUpdateContext is the same as TemplateUpdate, and it allows the caller to provide a context -func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *Response, err error) { +func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template, updatePublished bool) (res *Response, err error) { if t == nil { err = fmt.Errorf("Update called with nil Template") return @@ -313,7 +314,7 @@ func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template) (res *R jsonBytes, _ := json.Marshal(t) path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, t.Published) + url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, updatePublished) res, err = c.HttpPut(ctx, url, jsonBytes) if err != nil { diff --git a/templates_test.go b/templates_test.go index 0c2896a..9c40383 100644 --- a/templates_test.go +++ b/templates_test.go @@ -221,16 +221,17 @@ func TestTemplateGet(t *testing.T) { func TestTemplateUpdate(t *testing.T) { for idx, test := range []struct { in *sp.Template + pub bool err error status int json string }{ - {nil, errors.New("Update called with nil Template"), 0, ""}, - {&sp.Template{ID: ""}, errors.New("Update called with blank id"), 0, ""}, - {&sp.Template{ID: "id", Content: sp.Content{}}, errors.New("Template requires a non-empty Content.Subject"), 0, ""}, - {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, + {nil, false, errors.New("Update called with nil Template"), 0, ""}, + {&sp.Template{ID: ""}, false, errors.New("Update called with blank id"), 0, ""}, + {&sp.Template{ID: "id", Content: sp.Content{}}, false, errors.New("Template requires a non-empty Content.Subject"), 0, ""}, + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, false, errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, - {&sp.Template{ID: "id", Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, false, sp.SPErrors([]sp.SPError{{ Message: "substitution language syntax error in template content", Description: "Error while compiling header Subject: substitution statement missing ending '}}'", @@ -239,7 +240,7 @@ func TestTemplateUpdate(t *testing.T) { }}), 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`}, - {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, 200, ""}, + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, false, nil, 200, ""}, } { testSetup(t) defer testTeardown() @@ -250,7 +251,7 @@ func TestTemplateUpdate(t *testing.T) { } mockRestResponseBuilderFormat(t, "PUT", test.status, sp.TemplatesPathFormat+"/"+id, test.json) - _, err := testClient.TemplateUpdate(test.in) + _, err := testClient.TemplateUpdate(test.in, test.pub) if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) } else if err != nil && err.Error() != test.err.Error() { From 8208a7d3de879b1a68d13e82dab0723b503c245c Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 17 Apr 2017 14:19:20 -0700 Subject: [PATCH 140/152] remove client-side field length checks, and associated tests --- subaccounts.go | 8 -------- subaccounts_test.go | 10 ---------- 2 files changed, 18 deletions(-) diff --git a/subaccounts.go b/subaccounts.go index 08eb243..bec9194 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -54,14 +54,6 @@ func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (re err = errors.New("Subaccount requires a non-empty Name") } else if s.KeyLabel == "" { err = errors.New("Subaccount requires a non-empty Key Label") - } else - // enforce max lengths - if len(s.Name) > 1024 { - err = errors.New("Subaccount name may not be longer than 1024 bytes") - } else if len(s.KeyLabel) > 1024 { - err = errors.New("Subaccount key label may not be longer than 1024 bytes") - } else if s.IPPool != "" && len(s.IPPool) > 20 { - err = errors.New("Subaccount ip pool may not be longer than 20 bytes") } if err != nil { return diff --git a/subaccounts_test.go b/subaccounts_test.go index 4406cb1..9d1dc8f 100644 --- a/subaccounts_test.go +++ b/subaccounts_test.go @@ -23,12 +23,6 @@ func TestSubaccountCreate(t *testing.T) { {nil, errors.New("Create called with nil Subaccount"), 0, "", nil}, {&sp.Subaccount{}, errors.New("Subaccount requires a non-empty Name"), 0, "", nil}, {&sp.Subaccount{Name: "n"}, errors.New("Subaccount requires a non-empty Key Label"), 0, "", nil}, - {&sp.Subaccount{Name: strings.Repeat("name", 257), KeyLabel: "kl"}, - errors.New("Subaccount name may not be longer than 1024 bytes"), 0, "", nil}, - {&sp.Subaccount{Name: "n", KeyLabel: strings.Repeat("klkl", 257)}, - errors.New("Subaccount key label may not be longer than 1024 bytes"), 0, "", nil}, - {&sp.Subaccount{Name: "n", KeyLabel: "kl", IPPool: strings.Repeat("ip", 11)}, - errors.New("Subaccount ip pool may not be longer than 20 bytes"), 0, "", nil}, {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, errors.New("Unexpected response to Subaccount creation (results)"), 200, @@ -80,10 +74,6 @@ func TestSubaccountUpdate(t *testing.T) { }{ {nil, errors.New("Subaccount Update called with nil Subaccount"), 0, ""}, {&sp.Subaccount{}, errors.New("Subaccount Update called with zero id"), 0, ""}, - {&sp.Subaccount{ID: 42, Name: strings.Repeat("name", 257)}, - errors.New("Subaccount name may not be longer than 1024 bytes"), 0, ""}, - {&sp.Subaccount{ID: 42, Name: "n", IPPool: strings.Repeat("ip", 11)}, - errors.New("Subaccount ip pool may not be longer than 20 bytes"), 0, ""}, {&sp.Subaccount{ID: 42, Name: "n", Status: "super"}, errors.New("Not a valid subaccount status"), 0, ""}, From 8a68806dfa52be485dda21cfa254b81463f6cc06 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 17 Apr 2017 14:23:05 -0700 Subject: [PATCH 141/152] add some missing and update some existing comments --- subaccounts.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/subaccounts.go b/subaccounts.go index bec9194..aa1dcae 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -8,8 +8,10 @@ import ( "github.com/pkg/errors" ) -// https://www.sparkpost.com/api#/reference/subaccounts +// SubaccountsPathFormat provides an easy way to fill out the path including the version. var SubaccountsPathFormat = "/api/v%d/subaccounts" + +// SubaccountGrants contains the grants that will be given to new subaccounts by default. var SubaccountGrants = []string{ "smtp/inject", "sending_domains/manage", @@ -20,6 +22,8 @@ var SubaccountGrants = []string{ "transmissions/view", "transmissions/modify", } + +// SubaccountStatuses contains valid subaccount statuses. var SubaccountStatuses = []string{ "active", "suspended", @@ -39,8 +43,7 @@ type Subaccount struct { IPPool string `json:"ip_pool,omitempty"` } -// SubaccountCreate accepts a populated Subaccount object, validates it, -// and performs an API call against the configured endpoint. +// SubaccountCreate attempts to create a subaccount using the provided object func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { return c.SubaccountCreateContext(context.Background(), s) } @@ -106,8 +109,7 @@ func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (re } // SubaccountUpdate updates a subaccount with the specified id. -// Actually it will marshal and send all the subaccount fields, but that must not be a problem, -// as fields not supposed for update will be omitted +// It marshals and sends all the subaccount fields, ignoring the read-only ones. func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { return c.SubaccountUpdateContext(context.Background(), s) } @@ -168,7 +170,7 @@ func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (re return } -// Subaccounts returns metadata for all Templates in the system. +// Subaccounts returns metadata for all Subaccounts in the system. func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err error) { return c.SubaccountsContext(context.Background()) } @@ -212,7 +214,7 @@ func (c *Client) SubaccountsContext(ctx context.Context) (subaccounts []Subaccou return } -// Subaccount looks up a subaccount by its id +// Subaccount looks up a subaccount using the provided id func (c *Client) Subaccount(id int) (subaccount *Subaccount, res *Response, err error) { return c.SubaccountContext(context.Background(), id) } From 88c2286f1d7a7ffc3b54bcccf26ca437a07c1ed8 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 17 Apr 2017 14:33:21 -0700 Subject: [PATCH 142/152] trade native string concat for `fmt.Sprintf` in a couple places --- subaccounts.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/subaccounts.go b/subaccounts.go index aa1dcae..15ddca0 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -70,8 +70,7 @@ func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (re jsonBytes, _ := json.Marshal(s) path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(ctx, url, jsonBytes) + res, err = c.HttpPost(ctx, c.Config.BaseUrl+path, jsonBytes) if err != nil { return } @@ -178,8 +177,7 @@ func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err err // SubaccountsContext is the same as Subaccounts, and it allows the caller to provide a context func (c *Client) SubaccountsContext(ctx context.Context) (subaccounts []Subaccount, res *Response, err error) { path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpGet(ctx, url) + res, err = c.HttpGet(ctx, c.Config.BaseUrl+path) if err != nil { return } From 5356e3f8b7250e41f373adbddd5a24457aa60437 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 17 Apr 2017 14:34:18 -0700 Subject: [PATCH 143/152] remove some more client-side checks, in favor of what the server returns; leave another note about the default grants for new subaccounts, when none are specified --- subaccounts.go | 13 +------------ subaccounts_test.go | 3 --- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/subaccounts.go b/subaccounts.go index 15ddca0..ec7cb24 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -49,16 +49,11 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { } // SubaccountCreateContext is the same as SubaccountCreate, and it allows the caller to pass in a context +// New subaccounts will have all grants in SubaccountGrants, unless s.Grants is non-nil. func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (res *Response, err error) { // enforce required parameters if s == nil { err = errors.New("Create called with nil Subaccount") - } else if s.Name == "" { - err = errors.New("Subaccount requires a non-empty Name") - } else if s.KeyLabel == "" { - err = errors.New("Subaccount requires a non-empty Key Label") - } - if err != nil { return } @@ -117,12 +112,6 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (res *Response, err error) { if s == nil { err = errors.New("Subaccount Update called with nil Subaccount") - } else if s.ID == 0 { - err = errors.New("Subaccount Update called with zero id") - } else if len(s.Name) > 1024 { - err = errors.New("Subaccount name may not be longer than 1024 bytes") - } else if len(s.IPPool) > 20 { - err = errors.New("Subaccount ip pool may not be longer than 20 bytes") } else if s.Status != "" { found := false for _, v := range SubaccountStatuses { diff --git a/subaccounts_test.go b/subaccounts_test.go index 9d1dc8f..35d5ffb 100644 --- a/subaccounts_test.go +++ b/subaccounts_test.go @@ -21,8 +21,6 @@ func TestSubaccountCreate(t *testing.T) { out *sp.Subaccount }{ {nil, errors.New("Create called with nil Subaccount"), 0, "", nil}, - {&sp.Subaccount{}, errors.New("Subaccount requires a non-empty Name"), 0, "", nil}, - {&sp.Subaccount{Name: "n"}, errors.New("Subaccount requires a non-empty Key Label"), 0, "", nil}, {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, errors.New("Unexpected response to Subaccount creation (results)"), 200, @@ -73,7 +71,6 @@ func TestSubaccountUpdate(t *testing.T) { json string }{ {nil, errors.New("Subaccount Update called with nil Subaccount"), 0, ""}, - {&sp.Subaccount{}, errors.New("Subaccount Update called with zero id"), 0, ""}, {&sp.Subaccount{ID: 42, Name: "n", Status: "super"}, errors.New("Not a valid subaccount status"), 0, ""}, From f29e132603d48fe46474a634a88df5f17fec3c75 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 17 Apr 2017 15:15:02 -0700 Subject: [PATCH 144/152] remove duplicated if block; treat all 2XX codes as success --- common.go | 7 +++++++ subaccounts.go | 34 ++++++++++++++++------------------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/common.go b/common.go index 62b450c..96e42c8 100644 --- a/common.go +++ b/common.go @@ -223,6 +223,13 @@ func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []by return ares, nil } +func Is2XX(code int) bool { + if code < 300 && code >= 200 { + return true + } + return false +} + func basicAuth(username, password string) string { auth := username + ":" + password return base64.StdEncoding.EncodeToString([]byte(auth)) diff --git a/subaccounts.go b/subaccounts.go index ec7cb24..b6c15e6 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -79,7 +79,7 @@ func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (re return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var ok bool var results map[string]interface{} if results, ok = res.Results.(map[string]interface{}); !ok { @@ -148,7 +148,7 @@ func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (re return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { return } else if len(res.Errors) > 0 { @@ -176,7 +176,7 @@ func (c *Client) SubaccountsContext(ctx context.Context) (subaccounts []Subaccou return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { @@ -220,21 +220,19 @@ func (c *Client) SubaccountContext(ctx context.Context, id int) (subaccount *Sub return } - if res.HTTP.StatusCode == 200 { - if res.HTTP.StatusCode == 200 { - var body []byte - body, err = res.ReadBody() - if err != nil { - return - } - slist := map[string]Subaccount{} - err = json.Unmarshal(body, &slist) - if err != nil { - } else if s, ok := slist["results"]; ok { - subaccount = &s - } else { - err = errors.New("Unexpected response to Subaccount") - } + if Is2XX(res.HTTP.StatusCode) { + var body []byte + body, err = res.ReadBody() + if err != nil { + return + } + slist := map[string]Subaccount{} + err = json.Unmarshal(body, &slist) + if err != nil { + } else if s, ok := slist["results"]; ok { + subaccount = &s + } else { + err = errors.New("Unexpected response to Subaccount") } } else { err = res.ParseResponse() From 08a09c4bc01aff16981c7f3634ba5f455c5fb7f0 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 17 Apr 2017 15:15:34 -0700 Subject: [PATCH 145/152] more nil checks in `Client.DoRequest` --- common.go | 4 +++- common_test.go | 22 ++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/common.go b/common.go index 96e42c8..5c77629 100644 --- a/common.go +++ b/common.go @@ -137,7 +137,9 @@ func (c *Client) HttpDelete(ctx context.Context, url string) (*Response, error) } func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []byte) (*Response, error) { - if c.Client == nil { + if c == nil { + return nil, errors.New("Client must be non-nil!") + } else if c.Client == nil { return nil, errors.New("Client.Client (http.Client) must be non-nil!") } else if c.Config == nil { return nil, errors.New("Client.Config must be non-nil!") diff --git a/common_test.go b/common_test.go index dd73184..9b00596 100644 --- a/common_test.go +++ b/common_test.go @@ -89,13 +89,31 @@ func TestJson(t *testing.T) { } } -func TestDoRequest_BadMethod(t *testing.T) { +func TestDoRequest(t *testing.T) { testSetup(t) defer testTeardown() _, err := testClient.DoRequest(nil, "💩", "", nil) if err == nil { - t.Fatalf("bogus request method should fail") + t.Fatal("bogus request method should fail") + } + + var nullClient *sp.Client + _, err = nullClient.DoRequest(nil, "", "", nil) + if err == nil { + t.Fatal("null client should fail") + } + + var blankClient = &sp.Client{} + _, err = blankClient.DoRequest(nil, "", "", nil) + if err == nil { + t.Fatal("null http client should fail") + } + + blankClient.Client = http.DefaultClient + _, err = blankClient.DoRequest(nil, "", "", nil) + if err == nil { + t.Fatal("null client config should fail") } } From 1db1913e7f9dfbc850e168f3c9ecf9f1d779b85a Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 17 Apr 2017 15:30:56 -0700 Subject: [PATCH 146/152] remove calls to `Response.PrettyError`, return errors directly instead --- common.go | 19 ------------------- event_docs.go | 9 ++------- suppression_list.go | 16 ++-------------- transmissions.go | 30 +++++------------------------- 4 files changed, 9 insertions(+), 65 deletions(-) diff --git a/common.go b/common.go index 5c77629..43f26d0 100644 --- a/common.go +++ b/common.go @@ -301,22 +301,3 @@ func (r *Response) AssertJson() error { } return nil } - -// PrettyError returns a human-readable error message for common http errors returned by the API. -// The string parameters are used to customize the generated error message -// (example: noun=template, verb=create). -func (r *Response) PrettyError(noun, verb string) error { - if r.HTTP == nil { - return nil - } - code := r.HTTP.StatusCode - if code == 404 { - return errors.Errorf("%s does not exist, %s failed.", noun, verb) - } else if code == 401 { - return errors.Errorf("%s %s failed, permission denied. Check your API key.", noun, verb) - } else if code == 403 { - // This is what happens if an endpoint URL gets typo'd. - return errors.Errorf("%s %s failed. Are you using the right API path?", noun, verb) - } - return nil -} diff --git a/event_docs.go b/event_docs.go index b9efdda..0a360ca 100644 --- a/event_docs.go +++ b/event_docs.go @@ -60,18 +60,13 @@ func (c *Client) EventDocumentationContext(ctx context.Context) (g map[string]*E return groups, res, err } return nil, res, errors.New("Unexpected response format") + } else { err = res.ParseResponse() if err != nil { return nil, res, err } - if len(res.Errors) > 0 { - err = res.PrettyError("EventDocumentation", "retrieve") - if err != nil { - return nil, res, err - } - } - return nil, res, errors.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = res.Errors } return nil, res, err diff --git a/suppression_list.go b/suppression_list.go index 439aa2a..3ecde3d 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -164,13 +164,7 @@ func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (re return res, err } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("SuppressionEntry", "delete") - if err != nil { - return res, err - } - - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = res.Errors } return res, err @@ -218,13 +212,7 @@ func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []Writabl if res.HTTP.StatusCode == 200 { } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Transmission", "create") - if err != nil { - return res, err - } - - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = res.Errors } return res, err diff --git a/transmissions.go b/transmissions.go index 8fdabc5..ce0cd31 100644 --- a/transmissions.go +++ b/transmissions.go @@ -250,13 +250,7 @@ func (c *Client) SendContext(ctx context.Context, t *Transmission) (id string, r } } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Transmission", "create") - if err != nil { - return - } - - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = res.Errors } return @@ -311,12 +305,8 @@ func (c *Client) TransmissionContext(ctx context.Context, t *Transmission) (*Res return res, err } if len(res.Errors) > 0 { - err = res.PrettyError("Transmission", "retrieve") - if err != nil { - return res, err - } + err = res.Errors } - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } return res, err @@ -359,16 +349,10 @@ func (c *Client) TransmissionDeleteContext(ctx context.Context, t *Transmission) return res, nil } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Transmission", "delete") - if err != nil { - return res, err - } - - return res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + err = res.Errors } - return res, nil + return res, err } // Transmissions returns Transmission summary information for matching Transmissions. @@ -425,12 +409,8 @@ func (c *Client) TransmissionsContext(ctx context.Context, t *Transmission) ([]T return nil, res, err } if len(res.Errors) > 0 { - err = res.PrettyError("Transmission", "list") - if err != nil { - return nil, res, err - } + err = res.Errors } - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } return nil, res, err From f63ad65a7feb022c99edf52363bbff2d9d3cf80f Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Mon, 24 Apr 2017 22:51:24 -0600 Subject: [PATCH 147/152] factor out repeated error handling code; convert TestDoRequest to a table test; refactor a couple functions to reduce the number of returns --- common.go | 24 ++++++++++ common_test.go | 41 +++++++++------- event_docs.go | 27 +++++------ recipient_lists.go | 30 +++++------- subaccounts.go | 33 +++++-------- suppression_list.go | 30 ++++-------- template_from_json_test.go | 29 ------------ templates.go | 96 ++++++++++++++------------------------ templates_test.go | 42 +++++++++++++---- transmissions.go | 59 +++++++++-------------- 10 files changed, 183 insertions(+), 228 deletions(-) delete mode 100644 template_from_json_test.go diff --git a/common.go b/common.go index 43f26d0..e3f4128 100644 --- a/common.go +++ b/common.go @@ -68,6 +68,29 @@ type Response struct { Errors SPErrors `json:"errors,omitempty"` } +// HTTPError returns nil when the HTTP response code is in the range 200-299. +// If the API has returned a JSON error in the expected format, return that. +// Otherwise, return an error containing the HTTP code and response body. +func (res *Response) HTTPError() error { + if res == nil { + return errors.New("Internal error: Response may not be nil") + } else if res.HTTP == nil { + return errors.New("Internal error: Response.HTTP may not be nil") + } + + if Is2XX(res.HTTP.StatusCode) { + return nil + } else if len(res.Errors) > 0 { + return res.Errors + } + + return SPErrors{{ + Code: res.HTTP.Status, + Message: string(res.Body), + Description: "HTTP/JSON Error", + }} +} + // SPErrors is the plural of SPError type SPErrors []SPError @@ -225,6 +248,7 @@ func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []by return ares, nil } +// Is2XX returns true if the provided HTTP response code is in the range 200-299. func Is2XX(code int) bool { if code < 300 && code >= 200 { return true diff --git a/common_test.go b/common_test.go index 9b00596..2464edb 100644 --- a/common_test.go +++ b/common_test.go @@ -93,27 +93,36 @@ func TestDoRequest(t *testing.T) { testSetup(t) defer testTeardown() - _, err := testClient.DoRequest(nil, "💩", "", nil) - if err == nil { - t.Fatal("bogus request method should fail") - } - - var nullClient *sp.Client - _, err = nullClient.DoRequest(nil, "", "", nil) - if err == nil { - t.Fatal("null client should fail") + for idx, test := range []struct { + client *sp.Client + method string + err error + }{ + {nil, "", errors.New("Client must be non-nil!")}, + {&sp.Client{}, "", errors.New("Client.Client (http.Client) must be non-nil!")}, + {&sp.Client{Client: http.DefaultClient}, "", errors.New("Client.Config must be non-nil!")}, + {testClient, "💩", errors.New(`building request: net/http: invalid method "💩"`)}, + } { + _, err := test.client.DoRequest(nil, test.method, "", nil) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("DoRequest[%d] => err %v, want %v", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("DoRequest[%d] => err %v, want %v", idx, err, test.err) + } } +} - var blankClient = &sp.Client{} - _, err = blankClient.DoRequest(nil, "", "", nil) +func TestHTTPError(t *testing.T) { + var res *sp.Response + err := res.HTTPError() if err == nil { - t.Fatal("null http client should fail") + t.Error("nil response should fail") } - blankClient.Client = http.DefaultClient - _, err = blankClient.DoRequest(nil, "", "", nil) + res = &sp.Response{} + err = res.HTTPError() if err == nil { - t.Fatal("null client config should fail") + t.Error("nil http should fail") } } @@ -131,7 +140,7 @@ func TestInit(t *testing.T) { if err == nil && test.err != nil || err != nil && test.err == nil { t.Errorf("Init[%d] => err %q, want %q", idx, err, test.err) } else if err != nil && err.Error() != test.err.Error() { - t.Errorf("NewConfig[%d] => err %q, want %q", idx, err, test.err) + t.Errorf("Init[%d] => err %q, want %q", idx, err, test.err) } else if test.out != nil && test.api.Config.BaseUrl != test.out.BaseUrl { t.Errorf("Init[%d] => BaseUrl %q, want %q", idx, test.api.Config.BaseUrl, test.out.BaseUrl) } diff --git a/event_docs.go b/event_docs.go index 0a360ca..1090bc0 100644 --- a/event_docs.go +++ b/event_docs.go @@ -33,41 +33,36 @@ func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, return c.EventDocumentationContext(context.Background()) } -func (c *Client) EventDocumentationContext(ctx context.Context) (g map[string]*EventGroup, res *Response, err error) { +func (c *Client) EventDocumentationContext(ctx context.Context) (groups map[string]*EventGroup, res *Response, err error) { path := fmt.Sprintf(EventDocumentationFormat, c.Config.ApiVersion) res, err = c.HttpGet(ctx, c.Config.BaseUrl+path) if err != nil { - return nil, nil, err + return } if err = res.AssertJson(); err != nil { - return nil, res, err + return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte var ok bool body, err = res.ReadBody() if err != nil { - return nil, res, err + return } var results map[string]map[string]*EventGroup - var groups map[string]*EventGroup if err = json.Unmarshal(body, &results); err != nil { - return nil, res, err } else if groups, ok = results["results"]; ok { - return groups, res, err + // Success! + } else { + err = errors.New("Unexpected response format (results)") } - return nil, res, errors.New("Unexpected response format") - } else { - err = res.ParseResponse() - if err != nil { - return nil, res, err + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } - err = res.Errors } - - return nil, res, err + return } diff --git a/recipient_lists.go b/recipient_lists.go index e99c567..1ce780f 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -168,24 +168,20 @@ func (c *Client) RecipientListCreateContext(ctx context.Context, rl *RecipientLi return } - err = res.ParseResponse() - if err != nil { + if err = res.ParseResponse(); err != nil { return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var ok bool var results map[string]interface{} if results, ok = res.Results.(map[string]interface{}); !ok { - return id, res, errors.New("Unexpected response to Recipient List creation (results)") - } - id, ok = results["id"].(string) - if !ok { - return id, res, errors.New("Unexpected response to Recipient List creation (id)") + err = errors.New("Unexpected response to Recipient List creation (results)") + } else if id, ok = results["id"].(string); !ok { + err = errors.New("Unexpected response to Recipient List creation (id)") } - - } else if len(res.Errors) > 0 { - err = res.Errors + } else { + err = res.HTTPError() } return @@ -209,7 +205,7 @@ func (c *Client) RecipientListsContext(ctx context.Context) ([]RecipientList, *R return nil, res, err } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { @@ -217,18 +213,16 @@ func (c *Client) RecipientListsContext(ctx context.Context) ([]RecipientList, *R } rllist := map[string][]RecipientList{} if err = json.Unmarshal(body, &rllist); err != nil { - return nil, res, err } else if list, ok := rllist["results"]; ok { return list, res, nil + } else { + err = errors.New("Unexpected response to RecipientList list") } - err = errors.New("Unexpected response to RecipientList list") } else { - err = res.ParseResponse() - if err != nil { - return nil, res, err + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } - err = res.Errors } return nil, res, err diff --git a/subaccounts.go b/subaccounts.go index b6c15e6..4e498de 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -83,20 +83,17 @@ func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (re var ok bool var results map[string]interface{} if results, ok = res.Results.(map[string]interface{}); !ok { - return res, errors.New("Unexpected response to Subaccount creation (results)") - } - f, ok := results["subaccount_id"].(float64) - if !ok { + err = errors.New("Unexpected response to Subaccount creation (results)") + } else if f, ok := results["subaccount_id"].(float64); !ok { err = errors.New("Unexpected response to Subaccount creation (subaccount_id)") + } else { + s.ID = int(f) + if s.ShortKey, ok = results["short_key"].(string); !ok { + err = errors.New("Unexpected response to Subaccount creation (short_key)") + } } - s.ID = int(f) - s.ShortKey, ok = results["short_key"].(string) - if !ok { - err = errors.New("Unexpected response to Subaccount creation (short_key)") - } - - } else if len(res.Errors) > 0 { - err = res.Errors + } else { + err = res.HTTPError() } return @@ -148,13 +145,7 @@ func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (re return } - if Is2XX(res.HTTP.StatusCode) { - return - - } else if len(res.Errors) > 0 { - err = res.Errors - } - + err = res.HTTPError() return } @@ -194,7 +185,7 @@ func (c *Client) SubaccountsContext(ctx context.Context) (subaccounts []Subaccou } else { err = res.ParseResponse() if err == nil { - err = res.Errors + err = res.HTTPError() } } @@ -237,7 +228,7 @@ func (c *Client) SubaccountContext(ctx context.Context, id int) (subaccount *Sub } else { err = res.ParseResponse() if err == nil { - err = res.Errors + err = res.HTTPError() } } diff --git a/suppression_list.go b/suppression_list.go index 3ecde3d..b4e6d84 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -155,19 +155,15 @@ func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (re return res, err } - // If there are errors the response has JSON otherwise it is empty + // We get an empty response on success. If there are errors we get JSON. if res.AssertJson() == nil { - res.ParseResponse() - } - - if res.HTTP.StatusCode >= 200 && res.HTTP.StatusCode <= 299 { - return res, err - - } else if len(res.Errors) > 0 { - err = res.Errors + err = res.ParseResponse() + if err != nil { + return res, err + } } - return res, err + return res, res.HTTPError() } // SuppressionUpsert adds an entry to the suppression, or updates the existing entry @@ -189,10 +185,8 @@ func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []Writabl entriesWrapper := EntriesWrapper{entries} - jsonBytes, err := json.Marshal(entriesWrapper) - if err != nil { - return nil, err - } + // Marshaling a static type won't fail + jsonBytes, _ := json.Marshal(entriesWrapper) finalURL := c.Config.BaseUrl + path res, err := c.HttpPut(ctx, finalURL, jsonBytes) @@ -209,13 +203,7 @@ func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []Writabl return res, err } - if res.HTTP.StatusCode == 200 { - - } else if len(res.Errors) > 0 { - err = res.Errors - } - - return res, err + return res, res.HTTPError() } // Wraps call to server and unmarshals response diff --git a/template_from_json_test.go b/template_from_json_test.go deleted file mode 100644 index dd2f86e..0000000 --- a/template_from_json_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package gosparkpost_test - -import ( - "encoding/json" - "log" - - sp "github.com/SparkPost/gosparkpost" -) - -// Build a native Go Template structure from a JSON string -func ExampleTemplate() { - template := &sp.Template{} - jsonStr := `{ - "name": "testy template", - "content": { - "html": "this is a test email!", - "subject": "test email", - "from": { - "name": "tester", - "email": "tester@example.com" - }, - "reply_to": "tester@example.com" - } - }` - err := json.Unmarshal([]byte(jsonStr), template) - if err != nil { - log.Fatal(err) - } -} diff --git a/templates.go b/templates.go index cad600b..015c2b1 100644 --- a/templates.go +++ b/templates.go @@ -209,29 +209,21 @@ func (c *Client) TemplateCreateContext(ctx context.Context, t *Template) (id str return } - if err = res.AssertJson(); err != nil { - return - } - - err = res.ParseResponse() - if err != nil { + if err = res.ParseResponse(); err != nil { return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var ok bool var results map[string]interface{} if results, ok = res.Results.(map[string]interface{}); !ok { - return id, res, fmt.Errorf("Unexpected response to Template creation (results)") - } - id, ok = results["id"].(string) - if !ok { - err = fmt.Errorf("Unexpected response to Template creation") + err = fmt.Errorf("Unexpected response to Template creation (results)") + } else if id, ok = results["id"].(string); !ok { + err = fmt.Errorf("Unexpected response to Template creation (id)") } } else { - err = res.Errors + err = res.HTTPError() } - return } @@ -258,17 +250,17 @@ func (c *Client) TemplateGetContext(ctx context.Context, t *Template, draft bool return nil, err } - if err = res.AssertJson(); err != nil { + var body []byte + body, err = res.ReadBody() + if err != nil { return res, err } - if res.HTTP.StatusCode == 200 { - var body []byte - body, err = res.ReadBody() - if err != nil { - return res, err - } + if err = res.ParseResponse(); err != nil { + return res, err + } + if res.HTTP.StatusCode == 200 { // Unwrap the returned Template tmp := map[string]*json.RawMessage{} if err = json.Unmarshal(body, &tmp); err != nil { @@ -277,14 +269,11 @@ func (c *Client) TemplateGetContext(ctx context.Context, t *Template, draft bool } else { err = errors.New("Unexpected response to TemplateGet") } - return res, err } else { - err = res.ParseResponse() - if err != nil { - return res, err - } - return res, res.Errors + err = res.HTTPError() } + + return res, err } // TemplateUpdate updates a draft/published template with the specified id @@ -327,10 +316,10 @@ func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template, updateP if res.HTTP.StatusCode == 200 { return - } else { - if err = res.ParseResponse(); err == nil { - err = res.Errors - } + } + + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } return @@ -342,41 +331,36 @@ func (c *Client) Templates() ([]Template, *Response, error) { } // TemplatesContext is the same as Templates, and it allows the caller to provide a context -func (c *Client) TemplatesContext(ctx context.Context) ([]Template, *Response, error) { +func (c *Client) TemplatesContext(ctx context.Context) (tl []Template, res *Response, err error) { path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := c.Config.BaseUrl + path - res, err := c.HttpGet(ctx, url) + res, err = c.HttpGet(ctx, url) if err != nil { - return nil, nil, err + return } if err = res.AssertJson(); err != nil { - return nil, res, err + return } if res.HTTP.StatusCode == 200 { var body []byte body, err = res.ReadBody() if err != nil { - return nil, res, err + return } tlist := map[string][]Template{} if err = json.Unmarshal(body, &tlist); err != nil { - return nil, res, err - } else if list, ok := tlist["results"]; ok { - return list, res, nil + return } - err = fmt.Errorf("Unexpected response to Template list") + return tlist["results"], res, nil + } - } else { - err = res.ParseResponse() - if err != nil { - return nil, res, err - } - err = res.Errors + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } - return nil, res, err + return } // TemplateDelete removes the Template with the specified id. @@ -402,13 +386,8 @@ func (c *Client) TemplateDeleteContext(ctx context.Context, id string) (res *Res return } - err = res.ParseResponse() - if err != nil { - return - } - - if res.HTTP.StatusCode != 200 { - err = res.Errors + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } return @@ -446,13 +425,8 @@ func (c *Client) TemplatePreviewContext(ctx context.Context, id string, payload return } - err = res.ParseResponse() - if err != nil { - return - } - - if res.HTTP.StatusCode != 200 { - err = res.Errors + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } return diff --git a/templates_test.go b/templates_test.go index 9c40383..5131371 100644 --- a/templates_test.go +++ b/templates_test.go @@ -11,6 +11,27 @@ import ( "github.com/pkg/errors" ) +// ExampleTemplate builds a native Go Template structure from a JSON string +func ExampleTemplate() { + template := &sp.Template{} + jsonStr := `{ + "name": "testy template", + "content": { + "html": "this is a test email!", + "subject": "test email", + "from": { + "name": "tester", + "email": "tester@example.com" + }, + "reply_to": "tester@example.com" + } + }` + err := json.Unmarshal([]byte(jsonStr), template) + if err != nil { + panic(err) + } +} + func TestTemplateFromValidation(t *testing.T) { for idx, test := range []struct { in interface{} @@ -145,11 +166,11 @@ func TestTemplateCreate(t *testing.T) { errors.New("Unexpected response to Template creation (results)"), 200, `{"foo":{"id":"new-template"}}`, ""}, {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, - errors.New("Unexpected response to Template creation"), + errors.New("Unexpected response to Template creation (id)"), 200, `{"results":{"ID":"new-template"}}`, ""}, {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, errors.New("parsing api response: unexpected end of JSON input"), - 200, `{"results":{"ID":"new-template"}`, ""}, + 200, `{"truncated":{}`, ""}, {&sp.Template{Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, sp.SPErrors([]sp.SPError{{ @@ -160,6 +181,10 @@ func TestTemplateCreate(t *testing.T) { }}), 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`, ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New(`parsing api response: invalid character 'B' looking for beginning of value`), + 503, `Bad Gateway`, ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, 200, `{"results":{"id":"new-template"}}`, "new-template"}, } { @@ -190,9 +215,11 @@ func TestTemplateGet(t *testing.T) { {nil, false, errors.New("TemplateGet called with nil Template"), 200, "", nil}, {&sp.Template{ID: ""}, false, errors.New("TemplateGet called with blank id"), 200, "", nil}, {&sp.Template{ID: "nope"}, false, errors.New(`[{"message":"Resource could not be found","code":"","description":""}]`), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`, nil}, - {&sp.Template{ID: "nope"}, false, errors.New("parsing api response: unexpected end of JSON input"), 404, `{ "errors": [ { "message": "Resource could not be found" } ]`, nil}, + {&sp.Template{ID: "nope"}, false, errors.New(`parsing api response: unexpected end of JSON input`), 400, `{`, nil}, {&sp.Template{ID: "id"}, false, errors.New("Unexpected response to TemplateGet"), 200, `{"foo":{}}`, nil}, - {&sp.Template{ID: "id"}, false, errors.New("unexpected end of JSON input"), 200, `{"foo":{}`, nil}, + {&sp.Template{ID: "id"}, false, errors.New("parsing api response: unexpected end of JSON input"), 200, `{"foo":{}`, nil}, + + {&sp.Template{ID: "id"}, false, errors.New(`parsing api response: invalid character 'B' looking for beginning of value`), 503, `Bad Gateway`, nil}, {&sp.Template{ID: "id"}, false, nil, 200, `{"results":{"content":{"from":{"email":"a@b.com","name": "a b"},"html":"hi!","subject":"blink","text":"no blink ;_;"},"id":"id"}}`, &sp.Template{ID: "id", Content: sp.Content{From: map[string]interface{}{"email": "a@b.com", "name": "a b"}, HTML: "hi!", Text: "no blink ;_;", Subject: "blink"}}}, } { @@ -207,12 +234,12 @@ func TestTemplateGet(t *testing.T) { _, err := testClient.TemplateGet(test.in, test.draft) if err == nil && test.err != nil || err != nil && test.err == nil { - t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) + t.Errorf("TemplateGet[%d] => err %v want %v", idx, err, test.err) } else if err != nil && err.Error() != test.err.Error() { - t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) + t.Errorf("TemplateGet[%d] => err %v want %v", idx, err, test.err) } else if test.out != nil { if !reflect.DeepEqual(test.out, test.in) { - t.Errorf("TemplateUpdate[%d] => template got/want:\n%q\n%q", idx, test.in, test.out) + t.Errorf("TemplateGet[%d] => template got/want:\n%q\n%q", idx, test.in, test.out) } } } @@ -269,7 +296,6 @@ func TestTemplates(t *testing.T) { {errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, {errors.New("[{\"message\":\"truncated json\",\"code\":\"\",\"description\":\"\"}]"), 0, `{ "errors": [ { "message": "truncated json" } ] }`}, {nil, 200, `{ "results": [ { "description": "A test message from SparkPost.com", "id": "my-first-email", "last_update_time": "2006-01-02T15:04:05+00:00", "name": "My First Email", "published": false } ] }`}, - {errors.New("Unexpected response to Template list"), 200, `{ "foo": [ { "description": "A malformed message from SparkPost.com" } ] }`}, } { testSetup(t) defer testTeardown() diff --git a/transmissions.go b/transmissions.go index ce0cd31..fead186 100644 --- a/transmissions.go +++ b/transmissions.go @@ -238,19 +238,16 @@ func (c *Client) SendContext(ctx context.Context, t *Transmission) (id string, r return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var ok bool var results map[string]interface{} if results, ok = res.Results.(map[string]interface{}); !ok { - return id, res, fmt.Errorf("Unexpected response to Transmission creation (results)") + err = fmt.Errorf("Unexpected response to Transmission creation (results)") + } else if id, ok = results["id"].(string); !ok { + err = fmt.Errorf("Unexpected response to Transmission creation (id)") } - id, ok = results["id"].(string) - if !ok { - err = fmt.Errorf("Unexpected response to Transmission creation") - } - - } else if len(res.Errors) > 0 { - err = res.Errors + } else { + err = res.HTTPError() } return @@ -277,7 +274,7 @@ func (c *Client) TransmissionContext(ctx context.Context, t *Transmission) (*Res return res, err } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { @@ -287,25 +284,20 @@ func (c *Client) TransmissionContext(ctx context.Context, t *Transmission) (*Res // Unwrap the returned Transmission tmp := map[string]map[string]json.RawMessage{} if err = json.Unmarshal(body, &tmp); err != nil { - return res, err } else if results, ok := tmp["results"]; ok { if raw, ok := results["transmission"]; ok { - if err = json.Unmarshal(raw, t); err != nil { - return res, err - } - return res, nil + err = json.Unmarshal(raw, t) + } else { + err = fmt.Errorf("Unexpected response to Transmission (transmission)") } - return res, fmt.Errorf("Unexpected results structure in response") + } else { + err = fmt.Errorf("Unexpected response to Transmission (results)") } - err = fmt.Errorf("Unexpected response to Transmission.Retrieve") } else { err = res.ParseResponse() - if err != nil { - return res, err - } - if len(res.Errors) > 0 { - err = res.Errors + if err == nil { + err = res.HTTPError() } } @@ -345,14 +337,7 @@ func (c *Client) TransmissionDeleteContext(ctx context.Context, t *Transmission) return res, err } - if res.HTTP.StatusCode == 200 { - return res, nil - - } else if len(res.Errors) > 0 { - err = res.Errors - } - - return res, err + return res, res.HTTPError() } // Transmissions returns Transmission summary information for matching Transmissions. @@ -378,6 +363,7 @@ func (c *Client) TransmissionsContext(ctx context.Context, t *Transmission) ([]T qstr = strings.Join(qp, "&") } path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) + // FIXME: redo this using net/url u := fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, qstr) res, err := c.HttpGet(ctx, u) @@ -389,7 +375,7 @@ func (c *Client) TransmissionsContext(ctx context.Context, t *Transmission) ([]T return nil, res, err } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { @@ -397,19 +383,16 @@ func (c *Client) TransmissionsContext(ctx context.Context, t *Transmission) ([]T } tlist := map[string][]Transmission{} if err = json.Unmarshal(body, &tlist); err != nil { - return nil, res, err } else if list, ok := tlist["results"]; ok { return list, res, nil + } else { + err = fmt.Errorf("Unexpected response to Transmission list (results)") } - err = fmt.Errorf("Unexpected response to Transmission list") } else { err = res.ParseResponse() - if err != nil { - return nil, res, err - } - if len(res.Errors) > 0 { - err = res.Errors + if err == nil { + err = res.HTTPError() } } From dcf22569a0b9341c5c120032d6ac344d118ae4af Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 26 Apr 2017 10:53:59 -0600 Subject: [PATCH 148/152] last few `== 200` to `Is2XX` conversions --- templates.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates.go b/templates.go index 015c2b1..696055c 100644 --- a/templates.go +++ b/templates.go @@ -260,7 +260,7 @@ func (c *Client) TemplateGetContext(ctx context.Context, t *Template, draft bool return res, err } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { // Unwrap the returned Template tmp := map[string]*json.RawMessage{} if err = json.Unmarshal(body, &tmp); err != nil { @@ -314,7 +314,7 @@ func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template, updateP return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { return } @@ -343,7 +343,7 @@ func (c *Client) TemplatesContext(ctx context.Context) (tl []Template, res *Resp return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { From 17fb1dc4bfae889d4f30f57207828114f0bf919b Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 26 Apr 2017 16:06:02 -0600 Subject: [PATCH 149/152] more comments in `webhooks.go`; tests for WebhookStatus --- test/json/webhook_status_200.json | 17 ++++++++ webhooks.go | 50 +++++++++++++++-------- webhooks_test.go | 67 +++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 test/json/webhook_status_200.json create mode 100644 webhooks_test.go diff --git a/test/json/webhook_status_200.json b/test/json/webhook_status_200.json new file mode 100644 index 0000000..528ed1a --- /dev/null +++ b/test/json/webhook_status_200.json @@ -0,0 +1,17 @@ +{ + "results": [ + { + "batch_id": "032d330540298f54f0e8bcc1373f3cfd", + "ts": "2014-07-30T21:38:08.000Z", + "attempts": 7, + "response_code": "200" + }, + { + "batch_id": "13c6764994a8f6b4e29906d5712ca7d", + "ts": "2014-07-30T20:38:08.000Z", + "attempts": 2, + "failure_code": "400", + "response_code": "400" + } + ] +} diff --git a/webhooks.go b/webhooks.go index 5be984f..598d055 100644 --- a/webhooks.go +++ b/webhooks.go @@ -4,15 +4,15 @@ import ( "context" "encoding/json" "fmt" - "net/url" + + "github.com/pkg/errors" ) -// https://www.sparkpost.com/api#/reference/message-events -var WebhookListPathFormat = "/api/v%d/webhooks" -var WebhookQueryPathFormat = "/api/v%d/webhooks/%s" -var WebhookStatusPathFormat = "/api/v%d/webhooks/%s/batch-status" +// WebhooksPathFormat is the path prefix used for webhook-related requests, with a format string for the API version. +var WebhooksPathFormat = "/api/v%d/webhooks" +// WebhookItem defines how webhook objects will be returned, as well as how they must be sent. type WebhookItem struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -46,32 +46,38 @@ type WebhookItem struct { } `json:"links,omitempty"` } +// WebhookStatus defines how the status of a webhook will be returned. type WebhookStatus struct { BatchID string `json:"batch_id,omitempty"` Timestamp string `json:"ts,omitempty"` Attempts int `json:"attempts,omitempty"` ResponseCode string `json:"response_code,omitempty"` + FailureCode string `json:"failure_code,omitempty"` } +// WebhookCommon contains fields common to all response types. type WebhookCommon struct { Errors []interface{} `json:"errors,omitempty"` Params map[string]string `json:"-"` } +// WebhookListWrapper is returned by the Webhooks method. type WebhookListWrapper struct { Results []*WebhookItem `json:"results,omitempty"` WebhookCommon } -type WebhookQueryWrapper struct { +// WebhookDetailWrapper is returned by the WebhookDetail method. +type WebhookDetailWrapper struct { ID string `json:"-"` Results *WebhookItem `json:"results,omitempty"` WebhookCommon } +// WebhookStatusWrapper is updated by the WebhookStatus method, using results returned from the API. type WebhookStatusWrapper struct { - ID string `json:"-"` - Results []*WebhookStatus `json:"results,omitempty"` + ID string `json:"-"` + Results []WebhookStatus `json:"results,omitempty"` WebhookCommon } @@ -90,14 +96,20 @@ func buildUrl(c *Client, path string, parameters map[string]string) string { return path } +// WebhookStatus returns details of batch delivery to the specified webhook. // https://developers.sparkpost.com/api/#/reference/webhooks/batch-status/retrieve-status-information func (c *Client) WebhookStatus(s *WebhookStatusWrapper) (*Response, error) { return c.WebhookStatusContext(context.Background(), s) } +// WebhookStatusContext is the same as WebhookStatus, and allows the caller to specify their own context. func (c *Client) WebhookStatusContext(ctx context.Context, s *WebhookStatusWrapper) (*Response, error) { - path := fmt.Sprintf(WebhookStatusPathFormat, c.Config.ApiVersion, s.ID) - finalUrl := buildUrl(c, path, s.Params) + if s == nil { + return nil, errors.New("WebhookStatus called with nil WebhookStatusWrapper") + } + + path := fmt.Sprintf(WebhooksPathFormat, c.Config.ApiVersion) + finalUrl := buildUrl(c, path+"/"+s.ID+"/batch-status", s.Params) bodyBytes, res, err := doRequest(c, finalUrl, ctx) if err != nil { @@ -106,20 +118,22 @@ func (c *Client) WebhookStatusContext(ctx context.Context, s *WebhookStatusWrapp err = json.Unmarshal(bodyBytes, s) if err != nil { - return res, err + return res, errors.Wrap(err, "parsing api response") } return res, err } +// WebhookDetail returns details for the specified webhook. // https://developers.sparkpost.com/api/#/reference/webhooks/retrieve/retrieve-webhook-details -func (c *Client) QueryWebhook(q *WebhookQueryWrapper) (*Response, error) { - return c.QueryWebhookContext(context.Background(), q) +func (c *Client) WebhookDetail(q *WebhookDetailWrapper) (*Response, error) { + return c.WebhookDetailContext(context.Background(), q) } -func (c *Client) QueryWebhookContext(ctx context.Context, q *WebhookQueryWrapper) (*Response, error) { - path := fmt.Sprintf(WebhookQueryPathFormat, c.Config.ApiVersion, q.ID) - finalUrl := buildUrl(c, path, q.Params) +// WebhookDetailContext is the same as WebhookDetail, and allows the caller to specify their own context. +func (c *Client) WebhookDetailContext(ctx context.Context, q *WebhookDetailWrapper) (*Response, error) { + path := fmt.Sprintf(WebhooksPathFormat, c.Config.ApiVersion) + finalUrl := buildUrl(c, path+"/"+q.ID, q.Params) bodyBytes, res, err := doRequest(c, finalUrl, ctx) if err != nil { @@ -134,13 +148,15 @@ func (c *Client) QueryWebhookContext(ctx context.Context, q *WebhookQueryWrapper return res, err } +// Webhooks returns a list of all configured webhooks. // https://developers.sparkpost.com/api/#/reference/webhooks/list/list-all-webhooks func (c *Client) Webhooks(l *WebhookListWrapper) (*Response, error) { return c.WebhooksContext(context.Background(), l) } +// WebhooksContext is the same as Webhooks, and allows the caller to specify their own context. func (c *Client) WebhooksContext(ctx context.Context, l *WebhookListWrapper) (*Response, error) { - path := fmt.Sprintf(WebhookListPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(WebhooksPathFormat, c.Config.ApiVersion) finalUrl := buildUrl(c, path, l.Params) bodyBytes, res, err := doRequest(c, finalUrl, ctx) diff --git a/webhooks_test.go b/webhooks_test.go new file mode 100644 index 0000000..61b4f7b --- /dev/null +++ b/webhooks_test.go @@ -0,0 +1,67 @@ +package gosparkpost_test + +import ( + "reflect" + "testing" + + sp "github.com/SparkPost/gosparkpost" + "github.com/pkg/errors" +) + +func TestWebhookStatus(t *testing.T) { + var res200 = loadTestFile(t, "test/json/webhook_status_200.json") + var params = map[string]string{"timezone": "UTC"} + + for idx, test := range []struct { + in *sp.WebhookStatusWrapper + err error + status int + json string + out *sp.WebhookStatusWrapper + }{ + {nil, errors.New("WebhookStatus called with nil WebhookStatusWrapper"), 400, `{}`, nil}, + {&sp.WebhookStatusWrapper{ID: "id"}, errors.New("parsing api response: unexpected end of JSON input"), 200, res200[:len(res200)-2], nil}, + + {&sp.WebhookStatusWrapper{ID: "id", WebhookCommon: sp.WebhookCommon{Params: params}}, + nil, 200, res200, &sp.WebhookStatusWrapper{ + ID: "id", + Results: []sp.WebhookStatus{ + sp.WebhookStatus{ + BatchID: "032d330540298f54f0e8bcc1373f3cfd", + Timestamp: "2014-07-30T21:38:08.000Z", + Attempts: 7, + ResponseCode: "200", + FailureCode: "", + }, + sp.WebhookStatus{ + BatchID: "13c6764994a8f6b4e29906d5712ca7d", + Timestamp: "2014-07-30T20:38:08.000Z", + Attempts: 2, + ResponseCode: "400", + FailureCode: "400"}, + }, + + WebhookCommon: sp.WebhookCommon{Params: params}}, + }, + } { + testSetup(t) + defer testTeardown() + + id := "" + if test.in != nil { + id = test.in.ID + } + mockRestResponseBuilderFormat(t, "GET", test.status, sp.WebhooksPathFormat+"/"+id+"/batch-status", test.json) + + _, err := testClient.WebhookStatus(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("WebhookStatus[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("WebhookStatus[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil { + if !reflect.DeepEqual(test.out, test.in) { + t.Errorf("WebhookStatus[%d] => webhook got/want:\n%#v\n%#v", idx, test.in, test.out) + } + } + } +} From c3c60c1e5a939dbf0daf439abdc25332d58f80ef Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 26 Apr 2017 17:44:27 -0600 Subject: [PATCH 150/152] tests for WebhookDetail, Webhooks; add some sleight of hand to make http requests "fail"; switch arrays of pointers to arrays of structs (zero value of an array is nil); rename a function parameter; wrap json parsing errors --- webhooks.go | 20 +++-- webhooks_test.go | 198 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 207 insertions(+), 11 deletions(-) diff --git a/webhooks.go b/webhooks.go index 598d055..f8f7250 100644 --- a/webhooks.go +++ b/webhooks.go @@ -63,7 +63,7 @@ type WebhookCommon struct { // WebhookListWrapper is returned by the Webhooks method. type WebhookListWrapper struct { - Results []*WebhookItem `json:"results,omitempty"` + Results []WebhookItem `json:"results,omitempty"` WebhookCommon } @@ -131,18 +131,22 @@ func (c *Client) WebhookDetail(q *WebhookDetailWrapper) (*Response, error) { } // WebhookDetailContext is the same as WebhookDetail, and allows the caller to specify their own context. -func (c *Client) WebhookDetailContext(ctx context.Context, q *WebhookDetailWrapper) (*Response, error) { +func (c *Client) WebhookDetailContext(ctx context.Context, d *WebhookDetailWrapper) (*Response, error) { + if d == nil { + return nil, errors.New("WebhookDetail called with nil WebhookDetailWrapper") + } + path := fmt.Sprintf(WebhooksPathFormat, c.Config.ApiVersion) - finalUrl := buildUrl(c, path+"/"+q.ID, q.Params) + finalUrl := buildUrl(c, path+"/"+d.ID, d.Params) bodyBytes, res, err := doRequest(c, finalUrl, ctx) if err != nil { return res, err } - err = json.Unmarshal(bodyBytes, q) + err = json.Unmarshal(bodyBytes, d) if err != nil { - return res, err + return res, errors.Wrap(err, "parsing api response") } return res, err @@ -156,6 +160,10 @@ func (c *Client) Webhooks(l *WebhookListWrapper) (*Response, error) { // WebhooksContext is the same as Webhooks, and allows the caller to specify their own context. func (c *Client) WebhooksContext(ctx context.Context, l *WebhookListWrapper) (*Response, error) { + if l == nil { + return nil, errors.New("Webhooks called with nil WebhookListWrapper") + } + path := fmt.Sprintf(WebhooksPathFormat, c.Config.ApiVersion) finalUrl := buildUrl(c, path, l.Params) @@ -166,7 +174,7 @@ func (c *Client) WebhooksContext(ctx context.Context, l *WebhookListWrapper) (*R err = json.Unmarshal(bodyBytes, l) if err != nil { - return res, err + return res, errors.Wrap(err, "parsing api response") } return res, err diff --git a/webhooks_test.go b/webhooks_test.go index 61b4f7b..389b415 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -8,6 +8,29 @@ import ( "github.com/pkg/errors" ) +func TestWebhookBadHost(t *testing.T) { + testSetup(t) + defer testTeardown() + // Get the request to fail by mangling the HTTP host + testClient.Config.BaseUrl = "%zz" + + _, err := testClient.Webhooks(&sp.WebhookListWrapper{}) + if err == nil || err.Error() != `building request: parse %zz/api/v1/webhooks: invalid URL escape "%zz"` { + t.Errorf("error: %#v", err) + } + + _, err = testClient.WebhookStatus(&sp.WebhookStatusWrapper{ID: "id"}) + if err == nil || err.Error() != `building request: parse %zz/api/v1/webhooks/id/batch-status: invalid URL escape "%zz"` { + t.Errorf("error: %#v", err) + } + + _, err = testClient.WebhookDetail(&sp.WebhookDetailWrapper{ID: "id"}) + if err == nil || err.Error() != `building request: parse %zz/api/v1/webhooks/id: invalid URL escape "%zz"` { + t.Errorf("error: %#v", err) + } + +} + func TestWebhookStatus(t *testing.T) { var res200 = loadTestFile(t, "test/json/webhook_status_200.json") var params = map[string]string{"timezone": "UTC"} @@ -40,9 +63,7 @@ func TestWebhookStatus(t *testing.T) { ResponseCode: "400", FailureCode: "400"}, }, - - WebhookCommon: sp.WebhookCommon{Params: params}}, - }, + }}, } { testSetup(t) defer testTeardown() @@ -59,8 +80,175 @@ func TestWebhookStatus(t *testing.T) { } else if err != nil && err.Error() != test.err.Error() { t.Errorf("WebhookStatus[%d] => err %q want %q", idx, err, test.err) } else if test.out != nil { - if !reflect.DeepEqual(test.out, test.in) { - t.Errorf("WebhookStatus[%d] => webhook got/want:\n%#v\n%#v", idx, test.in, test.out) + if !reflect.DeepEqual(test.out.Results, test.in.Results) { + t.Errorf("WebhookStatus[%d] => webhook got/want:\n%#v\n%#v", idx, test.in.Results, test.out.Results) + } + } + } +} + +func TestWebhookDetail(t *testing.T) { + var res200 = loadTestFile(t, "test/json/webhook_detail_200.json") + + for idx, test := range []struct { + in *sp.WebhookDetailWrapper + err error + status int + json string + out *sp.WebhookDetailWrapper + }{ + {nil, errors.New("WebhookDetail called with nil WebhookDetailWrapper"), 400, `{}`, nil}, + {&sp.WebhookDetailWrapper{ID: "id"}, errors.New("parsing api response: unexpected end of JSON input"), + 200, res200[:len(res200)-2], nil}, + + {&sp.WebhookDetailWrapper{ID: "id"}, nil, 200, res200, &sp.WebhookDetailWrapper{ + Results: &sp.WebhookItem{ + ID: "", Name: "Example webhook", Target: "http://client.example.com/example-webhook", Events: []string{"delivery", "injection", "open", "click"}, AuthType: "oauth2", AuthRequestDetails: struct { + URL string "json:\"url,omitempty\"" + Body struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + } "json:\"body,omitempty\"" + }{URL: "https://oauth.myurl.com/tokens", Body: struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + }{ClientID: "", ClientSecret: ""}}, AuthCredentials: struct { + Username string "json:\"username,omitempty\"" + Password string "json:\"password,omitempty\"" + AccessToken string "json:\"access_token,omitempty\"" + ExpiresIn int "json:\"expires_in,omitempty\"" + }{Username: "", Password: "", AccessToken: "", ExpiresIn: 3600}, AuthToken: "", LastSuccessful: "", LastFailure: "", Links: []struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2/validate", Rel: "urn.msys.webhooks.validate", Method: []string{"POST"}}, struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2/batch-status", Rel: "urn.msys.webhooks.batches", Method: []string{"GET"}}}}}}, + } { + testSetup(t) + defer testTeardown() + + id := "foo" + if test.in != nil { + id = test.in.ID + } + mockRestResponseBuilderFormat(t, "GET", test.status, sp.WebhooksPathFormat+"/"+id, test.json) + + _, err := testClient.WebhookDetail(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("WebhookDetail[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("WebhookDetail[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil { + if !reflect.DeepEqual(test.out.Results, test.in.Results) { + t.Errorf("WebhookDetail[%d] => webhook got/want:\n%#v\n%#v", idx, test.in.Results, test.out.Results) + } + } + } +} + +func TestWebhooks(t *testing.T) { + var res200 = loadTestFile(t, "test/json/webhooks_200.json") + + for idx, test := range []struct { + in *sp.WebhookListWrapper + err error + status int + json string + out *sp.WebhookListWrapper + }{ + {nil, errors.New("Webhooks called with nil WebhookListWrapper"), 400, `{}`, nil}, + {&sp.WebhookListWrapper{}, errors.New("parsing api response: unexpected end of JSON input"), 200, res200[:len(res200)-2], nil}, + + {&sp.WebhookListWrapper{}, nil, 200, res200, &sp.WebhookListWrapper{Results: []sp.WebhookItem{ + sp.WebhookItem{ID: "a2b83490-10df-11e4-b670-c1ffa86371ff", Name: "Some webhook", Target: "http://client.example.com/some-webhook", Events: []string{"delivery", "injection", "open", "click"}, AuthType: "basic", AuthRequestDetails: struct { + URL string "json:\"url,omitempty\"" + Body struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + } "json:\"body,omitempty\"" + }{URL: "", Body: struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + }{ClientID: "", ClientSecret: ""}}, AuthCredentials: struct { + Username string "json:\"username,omitempty\"" + Password string "json:\"password,omitempty\"" + AccessToken string "json:\"access_token,omitempty\"" + ExpiresIn int "json:\"expires_in,omitempty\"" + }{Username: "basicuser", Password: "somepass", AccessToken: "", ExpiresIn: 0}, AuthToken: "", LastSuccessful: "2014-08-01 16:09:15", LastFailure: "2014-06-01 15:15:45", Links: []struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/a2b83490-10df-11e4-b670-c1ffa86371ff", Rel: "urn.msys.webhooks.webhook", Method: []string{"GET", "PUT"}}}}, + sp.WebhookItem{ID: "12affc24-f183-11e3-9234-3c15c2c818c2", Name: "Example webhook", Target: "http://client.example.com/example-webhook", Events: []string{"delivery", "injection", "open", "click"}, AuthType: "oauth2", AuthRequestDetails: struct { + URL string "json:\"url,omitempty\"" + Body struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + } "json:\"body,omitempty\"" + }{URL: "https://oauth.myurl.com/tokens", Body: struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + }{ClientID: "", ClientSecret: ""}}, AuthCredentials: struct { + Username string "json:\"username,omitempty\"" + Password string "json:\"password,omitempty\"" + AccessToken string "json:\"access_token,omitempty\"" + ExpiresIn int "json:\"expires_in,omitempty\"" + }{Username: "", Password: "", AccessToken: "", ExpiresIn: 3600}, AuthToken: "", LastSuccessful: "2014-07-01 16:09:15", LastFailure: "2014-08-01 15:15:45", Links: []struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2", Rel: "urn.msys.webhooks.webhook", Method: []string{"GET", "PUT"}}}}, + sp.WebhookItem{ID: "123456-abcd-efgh-7890-123445566778", Name: "Another webhook", Target: "http://client.example.com/another-example", Events: []string{"generation_rejection", "generation_failure"}, AuthType: "none", AuthRequestDetails: struct { + URL string "json:\"url,omitempty\"" + Body struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + } "json:\"body,omitempty\"" + }{URL: "", Body: struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + }{ClientID: "", ClientSecret: ""}}, AuthCredentials: struct { + Username string "json:\"username,omitempty\"" + Password string "json:\"password,omitempty\"" + AccessToken string "json:\"access_token,omitempty\"" + ExpiresIn int "json:\"expires_in,omitempty\"" + }{Username: "", Password: "", AccessToken: "", ExpiresIn: 0}, AuthToken: "5ebe2294ecd0e0f08eab7690d2a6ee69", LastSuccessful: "", LastFailure: "", Links: []struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/123456-abcd-efgh-7890-123445566778", Rel: "urn.msys.webhooks.webhook", Method: []string{"GET", "PUT"}}}}}}}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "GET", test.status, sp.WebhooksPathFormat, test.json) + + _, err := testClient.Webhooks(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Webhooks[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Webhooks[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil { + if !reflect.DeepEqual(test.out.Results, test.in.Results) { + t.Errorf("Webhooks[%d] => webhook got/want:\n%#v\n%#v", idx, test.in.Results, test.out.Results) } } } From 6fdaabf9c848742f73f7a1e0961ad8a8a7b7da98 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 26 Apr 2017 17:50:21 -0600 Subject: [PATCH 151/152] missing test files --- test/json/webhook_detail_200.json | 42 ++++++++++++++ test/json/webhooks_200.json | 96 +++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 test/json/webhook_detail_200.json create mode 100644 test/json/webhooks_200.json diff --git a/test/json/webhook_detail_200.json b/test/json/webhook_detail_200.json new file mode 100644 index 0000000..49faedd --- /dev/null +++ b/test/json/webhook_detail_200.json @@ -0,0 +1,42 @@ +{ + "results": { + "name": "Example webhook", + "target": "http://client.example.com/example-webhook", + "events": [ + "delivery", + "injection", + "open", + "click" + ], + "auth_type": "oauth2", + "auth_request_details": { + "url": "https://oauth.myurl.com/tokens", + "body": { + "client_id": "", + "client_secret": "" + } + }, + "auth_credentials": { + "access_token": "", + "expires_in": 3600 + }, + "auth_token": "", + "active": true, + "links": [ + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2/validate", + "rel": "urn.msys.webhooks.validate", + "method": [ + "POST" + ] + }, + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2/batch-status", + "rel": "urn.msys.webhooks.batches", + "method": [ + "GET" + ] + } + ] + } +} diff --git a/test/json/webhooks_200.json b/test/json/webhooks_200.json new file mode 100644 index 0000000..e1354f6 --- /dev/null +++ b/test/json/webhooks_200.json @@ -0,0 +1,96 @@ +{ + "results": [ + { + "id": "a2b83490-10df-11e4-b670-c1ffa86371ff", + "name": "Some webhook", + "target": "http://client.example.com/some-webhook", + "events": [ + "delivery", + "injection", + "open", + "click" + ], + "auth_type": "basic", + "auth_request_details": {}, + "auth_credentials": { + "username": "basicuser", + "password": "somepass" + }, + "auth_token": "", + "last_successful": "2014-08-01 16:09:15", + "last_failure": "2014-06-01 15:15:45", + "active": true, + "links": [ + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/a2b83490-10df-11e4-b670-c1ffa86371ff", + "rel": "urn.msys.webhooks.webhook", + "method": [ + "GET", + "PUT" + ] + } + ] + }, + { + "id": "12affc24-f183-11e3-9234-3c15c2c818c2", + "name": "Example webhook", + "target": "http://client.example.com/example-webhook", + "events": [ + "delivery", + "injection", + "open", + "click" + ], + "auth_type": "oauth2", + "auth_request_details": { + "url": "https://oauth.myurl.com/tokens", + "body": { + "client_id": "", + "client_secret": "" + } + }, + "auth_credentials": { + "access_token": "", + "expires_in": 3600 + }, + "auth_token": "", + "last_successful": "2014-07-01 16:09:15", + "last_failure": "2014-08-01 15:15:45", + "active": true, + "links": [ + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2", + "rel": "urn.msys.webhooks.webhook", + "method": [ + "GET", + "PUT" + ] + } + ] + }, + { + "id": "123456-abcd-efgh-7890-123445566778", + "name": "Another webhook", + "target": "http://client.example.com/another-example", + "events": [ + "generation_rejection", + "generation_failure" + ], + "auth_type": "none", + "auth_request_details": {}, + "auth_credentials": {}, + "auth_token": "5ebe2294ecd0e0f08eab7690d2a6ee69", + "active": true, + "links": [ + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/123456-abcd-efgh-7890-123445566778", + "rel": "urn.msys.webhooks.webhook", + "method": [ + "GET", + "PUT" + ] + } + ] + } + ] +} From afc3b1c3ff22d817eb76b351a355b5487ca4cc04 Mon Sep 17 00:00:00 2001 From: Dave Gray Date: Wed, 26 Apr 2017 18:08:00 -0600 Subject: [PATCH 152/152] update comments to clarify that the function argument is updated, instead of parsed values being returned --- webhooks.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/webhooks.go b/webhooks.go index f8f7250..3f98ec3 100644 --- a/webhooks.go +++ b/webhooks.go @@ -61,20 +61,20 @@ type WebhookCommon struct { Params map[string]string `json:"-"` } -// WebhookListWrapper is returned by the Webhooks method. +// WebhookListWrapper is passed into and updated by the Webhooks method, using results returned from the API. type WebhookListWrapper struct { Results []WebhookItem `json:"results,omitempty"` WebhookCommon } -// WebhookDetailWrapper is returned by the WebhookDetail method. +// WebhookDetailWrapper is passed into and updated by the WebhookDetail method, using results returned from the API. type WebhookDetailWrapper struct { ID string `json:"-"` Results *WebhookItem `json:"results,omitempty"` WebhookCommon } -// WebhookStatusWrapper is updated by the WebhookStatus method, using results returned from the API. +// WebhookStatusWrapper is passed into and updated by the WebhookStatus method, using results returned from the API. type WebhookStatusWrapper struct { ID string `json:"-"` Results []WebhookStatus `json:"results,omitempty"` @@ -96,7 +96,7 @@ func buildUrl(c *Client, path string, parameters map[string]string) string { return path } -// WebhookStatus returns details of batch delivery to the specified webhook. +// WebhookStatus updates its argument with details of batch delivery for the specified webhook. // https://developers.sparkpost.com/api/#/reference/webhooks/batch-status/retrieve-status-information func (c *Client) WebhookStatus(s *WebhookStatusWrapper) (*Response, error) { return c.WebhookStatusContext(context.Background(), s) @@ -124,7 +124,7 @@ func (c *Client) WebhookStatusContext(ctx context.Context, s *WebhookStatusWrapp return res, err } -// WebhookDetail returns details for the specified webhook. +// WebhookDetail updates its argument with details for the specified webhook. // https://developers.sparkpost.com/api/#/reference/webhooks/retrieve/retrieve-webhook-details func (c *Client) WebhookDetail(q *WebhookDetailWrapper) (*Response, error) { return c.WebhookDetailContext(context.Background(), q) @@ -152,7 +152,7 @@ func (c *Client) WebhookDetailContext(ctx context.Context, d *WebhookDetailWrapp return res, err } -// Webhooks returns a list of all configured webhooks. +// Webhooks updates its argument with a list of all configured webhooks. // https://developers.sparkpost.com/api/#/reference/webhooks/list/list-all-webhooks func (c *Client) Webhooks(l *WebhookListWrapper) (*Response, error) { return c.WebhooksContext(context.Background(), l)