├── .github ├── dependabot.yaml ├── release.yaml └── workflows │ └── test.yaml ├── CHANGES.md ├── LICENSE ├── Makefile ├── README.md ├── body.go ├── doc.go ├── examples ├── README.md ├── github.go ├── go.mod └── go.sum ├── go.mod ├── go.sum ├── response.go ├── sling.go └── sling_test.go /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: gomod 8 | directory: "/examples" 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /.github/release.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: Contributions 4 | labels: 5 | - '*' 6 | exclude: 7 | labels: 8 | - dependencies 9 | - no-release-note 10 | - title: Dependencies 11 | labels: 12 | - dependencies 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | go: 11 | uses: dghubble/.github/.github/workflows/golang-library.yaml@main 12 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Sling Changelog 2 | 3 | Notable changes between releases. 4 | 5 | ## Latest 6 | 7 | ## v1.4.2 8 | 9 | * Update Go module dependencies 10 | 11 | ## v1.4.1 12 | 13 | * Update minimum Go version to v1.18 ([#76](https://github.com/dghubble/sling/pull/76)) 14 | 15 | ## v1.4.0 16 | 17 | * `Do` reads Body to reuse HTTP/1.x "keep-alive" TCP connections ([#59](https://github.com/dghubble/sling/pull/59)) 18 | * `Receive` skips decoding if status is 204 (no content) ([#63](https://github.com/dghubble/sling/pull/63)) 19 | 20 | ## v1.3.0 21 | 22 | * Add Sling `ResponseDecoder` setter for receiving responses with a custom `ResponseDecoder` ([#49](https://github.com/dghubble/sling/pull/49)) 23 | * Add Go module support (i.e. `go.mod`). Exclude `examples` (multi-module). ([#52](https://github.com/dghubble/sling/pull/52)) 24 | 25 | ## v1.2.0 26 | 27 | * Add `Connect`, `Options`, and `Trace` HTTP methods ([c51967](https://github.com/dghubble/sling/commit/c519674860ff275e0ceb12caf5d87b31765c4e71)) 28 | * Skip receiving (i.e. decoding) `204 No Content` responses ([#31](https://github.com/dghubble/sling/pull/31)) 29 | 30 | ## v1.1.0 31 | 32 | * Allow JSON decoding, regardless of response Content-Type (#26) 33 | * Add `BodyProvider` interface and setter so request Body encoding can be customized (#23) 34 | * Add `Doer` interface and setter so request sending behavior can be customized (#21) 35 | * Add `SetBasicAuth` setter for Authorization headers (#16) 36 | * Add Sling `Body` setter to set an `io.Reader` on the Request (#9) 37 | 38 | ## v1.0.0 39 | 40 | * Added support for receiving and decoding error JSON structs 41 | * Renamed Sling `JsonBody` setter to `BodyJSON` (breaking) 42 | * Renamed Sling `BodyStruct` setter to `BodyForm` (breaking) 43 | * Renamed Sling fields `httpClient`, `method`, `rawURL`, and `header` to be internal (breaking) 44 | * Changed `Do` and `Receive` to skip response JSON decoding if "application/json" Content-Type is missing 45 | * Changed `Sling.Receive(v interface{})` to `Sling.Receive(successV, failureV interface{})` (breaking) 46 | * Previously `Receive` attempted to decode the response Body in all cases 47 | * Updated `Receive` will decode the response Body into successV for 2XX responses or decode the Body into failureV for other status codes. Pass a nil `successV` or `failureV` to skip JSON decoding into that value. 48 | * To upgrade, pass nil for the `failureV` argument or consider defining a JSON tagged struct appropriate for the API endpoint. (e.g. `s.Receive(&issue, nil)`, `s.Receive(&issue, &githubError)`) 49 | * To retain the old behavior, duplicate the first argument (e.g. s.Receive(&tweet, &tweet)) 50 | * Changed `Sling.Do(http.Request, v interface{})` to `Sling.Do(http.Request, successV, failureV interface{})` (breaking) 51 | * See the changelog entry about `Receive`, the upgrade path is the same. 52 | * Removed HEAD, GET, POST, PUT, PATCH, DELETE constants, no reason to export them (breaking) 53 | 54 | ## v0.4.0 55 | 56 | * Improved golint compliance 57 | * Fixed typos and test printouts 58 | 59 | ## v0.3.0 60 | 61 | * Added BodyStruct method for setting a url encoded form body on the Request 62 | * Added Add and Set methods for adding or setting Request Headers 63 | * Added JsonBody method for setting JSON Request Body 64 | * Improved examples and documentation 65 | 66 | ## v0.2.0 67 | 68 | * Added http.Client setter 69 | * Added Sling.New() method to return a copy of a Sling 70 | * Added Base setter and Path extension support 71 | * Added method setters (Get, Post, Put, Patch, Delete, Head) 72 | * Added support for encoding URL Query parameters 73 | * Added example tiny Github API 74 | * Changed v0.1.0 method signatures and names (breaking) 75 | * Removed Go 1.0 support 76 | 77 | ## v0.1.0 78 | 79 | * Support decoding JSON responses. 80 | 81 | 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dalton Hubble 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: test vet fmt 3 | 4 | .PHONY: test 5 | test: 6 | @go test . -cover 7 | 8 | .PHONY: vet 9 | vet: 10 | @go vet -all . 11 | 12 | .PHONY: fmt 13 | fmt: 14 | @test -z $$(go fmt ./...) 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sling 2 | [![GoDoc](https://pkg.go.dev/badge/github.com/dghubble/sling.svg)](https://pkg.go.dev/github.com/dghubble/sling) 3 | [![Workflow](https://github.com/dghubble/sling/actions/workflows/test.yaml/badge.svg)](https://github.com/dghubble/sling/actions/workflows/test.yaml?query=branch%3Amain) 4 | [![Sponsors](https://img.shields.io/github/sponsors/dghubble?logo=github)](https://github.com/sponsors/dghubble) 5 | [![Mastodon](https://img.shields.io/badge/follow-news-6364ff?logo=mastodon)](https://fosstodon.org/@typhoon) 6 | 7 | 8 | 9 | Sling is a Go HTTP client library for creating and sending API requests. 10 | 11 | Slings store HTTP Request properties to simplify sending requests and decoding responses. Check [usage](#usage) or the [examples](examples) to learn how to compose a Sling into your API client. 12 | 13 | ### Features 14 | 15 | * Method Setters: Get/Post/Put/Patch/Delete/Head 16 | * Add or Set Request Headers 17 | * Base/Path: Extend a Sling for different endpoints 18 | * Encode structs into URL query parameters 19 | * Encode a form or JSON into the Request Body 20 | * Receive JSON success or failure responses 21 | 22 | ## Install 23 | 24 | ``` 25 | go get github.com/dghubble/sling 26 | ``` 27 | 28 | ## Documentation 29 | 30 | Read [GoDoc](https://godoc.org/github.com/dghubble/sling) 31 | 32 | ## Usage 33 | 34 | Use a Sling to set path, method, header, query, or body properties and create an `http.Request`. 35 | 36 | ```go 37 | type Params struct { 38 | Count int `url:"count,omitempty"` 39 | } 40 | params := &Params{Count: 5} 41 | 42 | req, err := sling.New().Get("https://example.com").QueryStruct(params).Request() 43 | client.Do(req) 44 | ``` 45 | 46 | ### Path 47 | 48 | Use `Path` to set or extend the URL for created Requests. Extension means the path will be resolved relative to the existing URL. 49 | 50 | ```go 51 | // creates a GET request to https://example.com/foo/bar 52 | req, err := sling.New().Base("https://example.com/").Path("foo/").Path("bar").Request() 53 | ``` 54 | 55 | Use `Get`, `Post`, `Put`, `Patch`, `Delete`, `Head`, `Options`, `Trace`, or `Connect` which are exactly the same as `Path` except they set the HTTP method too. 56 | 57 | ```go 58 | req, err := sling.New().Post("http://upload.com/gophers") 59 | ``` 60 | 61 | ### Headers 62 | 63 | `Add` or `Set` headers for requests created by a Sling. 64 | 65 | ```go 66 | s := sling.New().Base(baseUrl).Set("User-Agent", "Gophergram API Client") 67 | req, err := s.New().Get("gophergram/list").Request() 68 | ``` 69 | 70 | ### Query 71 | 72 | #### QueryStruct 73 | 74 | Define [url tagged structs](https://godoc.org/github.com/google/go-querystring/query). Use `QueryStruct` to encode a struct as query parameters on requests. 75 | 76 | ```go 77 | // Github Issue Parameters 78 | type IssueParams struct { 79 | Filter string `url:"filter,omitempty"` 80 | State string `url:"state,omitempty"` 81 | Labels string `url:"labels,omitempty"` 82 | Sort string `url:"sort,omitempty"` 83 | Direction string `url:"direction,omitempty"` 84 | Since string `url:"since,omitempty"` 85 | } 86 | ``` 87 | 88 | ```go 89 | githubBase := sling.New().Base("https://api.github.com/").Client(httpClient) 90 | 91 | path := fmt.Sprintf("repos/%s/%s/issues", owner, repo) 92 | params := &IssueParams{Sort: "updated", State: "open"} 93 | req, err := githubBase.New().Get(path).QueryStruct(params).Request() 94 | ``` 95 | 96 | ### Body 97 | 98 | #### JSON Body 99 | 100 | Define [JSON tagged structs](https://golang.org/pkg/encoding/json/). Use `BodyJSON` to JSON encode a struct as the Body on requests. 101 | 102 | ```go 103 | type IssueRequest struct { 104 | Title string `json:"title,omitempty"` 105 | Body string `json:"body,omitempty"` 106 | Assignee string `json:"assignee,omitempty"` 107 | Milestone int `json:"milestone,omitempty"` 108 | Labels []string `json:"labels,omitempty"` 109 | } 110 | ``` 111 | 112 | ```go 113 | githubBase := sling.New().Base("https://api.github.com/").Client(httpClient) 114 | path := fmt.Sprintf("repos/%s/%s/issues", owner, repo) 115 | 116 | body := &IssueRequest{ 117 | Title: "Test title", 118 | Body: "Some issue", 119 | } 120 | req, err := githubBase.New().Post(path).BodyJSON(body).Request() 121 | ``` 122 | 123 | Requests will include an `application/json` Content-Type header. 124 | 125 | #### Form Body 126 | 127 | Define [url tagged structs](https://godoc.org/github.com/google/go-querystring/query). Use `BodyForm` to form url encode a struct as the Body on requests. 128 | 129 | ```go 130 | type StatusUpdateParams struct { 131 | Status string `url:"status,omitempty"` 132 | InReplyToStatusId int64 `url:"in_reply_to_status_id,omitempty"` 133 | MediaIds []int64 `url:"media_ids,omitempty,comma"` 134 | } 135 | ``` 136 | 137 | ```go 138 | tweetParams := &StatusUpdateParams{Status: "writing some Go"} 139 | req, err := twitterBase.New().Post(path).BodyForm(tweetParams).Request() 140 | ``` 141 | 142 | Requests will include an `application/x-www-form-urlencoded` Content-Type header. 143 | 144 | #### Plain Body 145 | 146 | Use `Body` to set a plain `io.Reader` on requests created by a Sling. 147 | 148 | ```go 149 | body := strings.NewReader("raw body") 150 | req, err := sling.New().Base("https://example.com").Body(body).Request() 151 | ``` 152 | 153 | Set a content type header, if desired (e.g. `Set("Content-Type", "text/plain")`). 154 | 155 | ### Extend a Sling 156 | 157 | Each Sling creates a standard `http.Request` (e.g. with some path and query 158 | params) each time `Request()` is called. You may wish to extend an existing Sling to minimize duplication (e.g. a common client or base url). 159 | 160 | Each Sling instance provides a `New()` method which creates an independent copy, so setting properties on the child won't mutate the parent Sling. 161 | 162 | ```go 163 | const twitterApi = "https://api.twitter.com/1.1/" 164 | base := sling.New().Base(twitterApi).Client(authClient) 165 | 166 | // statuses/show.json Sling 167 | tweetShowSling := base.New().Get("statuses/show.json").QueryStruct(params) 168 | req, err := tweetShowSling.Request() 169 | 170 | // statuses/update.json Sling 171 | tweetPostSling := base.New().Post("statuses/update.json").BodyForm(params) 172 | req, err := tweetPostSling.Request() 173 | ``` 174 | 175 | Without the calls to `base.New()`, `tweetShowSling` and `tweetPostSling` would reference the base Sling and POST to 176 | "https://api.twitter.com/1.1/statuses/show.json/statuses/update.json", which 177 | is undesired. 178 | 179 | Recap: If you wish to *extend* a Sling, create a new child copy with `New()`. 180 | 181 | ### Sending 182 | 183 | #### Receive 184 | 185 | Define a JSON struct to decode a type from 2XX success responses. Use `ReceiveSuccess(successV interface{})` to send a new Request and decode the response body into `successV` if it succeeds. 186 | 187 | ```go 188 | // Github Issue (abbreviated) 189 | type Issue struct { 190 | Title string `json:"title"` 191 | Body string `json:"body"` 192 | } 193 | ``` 194 | 195 | ```go 196 | issues := new([]Issue) 197 | resp, err := githubBase.New().Get(path).QueryStruct(params).ReceiveSuccess(issues) 198 | fmt.Println(issues, resp, err) 199 | ``` 200 | 201 | Most APIs return failure responses with JSON error details. To decode these, define success and failure JSON structs. Use `Receive(successV, failureV interface{})` to send a new Request that will automatically decode the response into the `successV` for 2XX responses or into `failureV` for non-2XX responses. 202 | 203 | ```go 204 | type GithubError struct { 205 | Message string `json:"message"` 206 | Errors []struct { 207 | Resource string `json:"resource"` 208 | Field string `json:"field"` 209 | Code string `json:"code"` 210 | } `json:"errors"` 211 | DocumentationURL string `json:"documentation_url"` 212 | } 213 | ``` 214 | 215 | ```go 216 | issues := new([]Issue) 217 | githubError := new(GithubError) 218 | resp, err := githubBase.New().Get(path).QueryStruct(params).Receive(issues, githubError) 219 | fmt.Println(issues, githubError, resp, err) 220 | ``` 221 | 222 | Pass a nil `successV` or `failureV` argument to skip JSON decoding into that value. 223 | 224 | ### Modify a Request 225 | 226 | Sling provides the raw http.Request so modifications can be made using standard net/http features. For example, in Go 1.7+ , add HTTP tracing to a request with a context: 227 | 228 | ```go 229 | req, err := sling.New().Get("https://example.com").QueryStruct(params).Request() 230 | // handle error 231 | 232 | trace := &httptrace.ClientTrace{ 233 | DNSDone: func(dnsInfo httptrace.DNSDoneInfo) { 234 | fmt.Printf("DNS Info: %+v\n", dnsInfo) 235 | }, 236 | GotConn: func(connInfo httptrace.GotConnInfo) { 237 | fmt.Printf("Got Conn: %+v\n", connInfo) 238 | }, 239 | } 240 | 241 | req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) 242 | client.Do(req) 243 | ``` 244 | 245 | ### Build an API 246 | 247 | APIs typically define an endpoint (also called a service) for each type of resource. For example, here is a tiny Github IssueService which [lists](https://developer.github.com/v3/issues/#list-issues-for-a-repository) repository issues. 248 | 249 | ```go 250 | const baseURL = "https://api.github.com/" 251 | 252 | type IssueService struct { 253 | sling *sling.Sling 254 | } 255 | 256 | func NewIssueService(httpClient *http.Client) *IssueService { 257 | return &IssueService{ 258 | sling: sling.New().Client(httpClient).Base(baseURL), 259 | } 260 | } 261 | 262 | func (s *IssueService) ListByRepo(owner, repo string, params *IssueListParams) ([]Issue, *http.Response, error) { 263 | issues := new([]Issue) 264 | githubError := new(GithubError) 265 | path := fmt.Sprintf("repos/%s/%s/issues", owner, repo) 266 | resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(issues, githubError) 267 | if err == nil { 268 | err = githubError 269 | } 270 | return *issues, resp, err 271 | } 272 | ``` 273 | 274 | ## Example APIs using Sling 275 | 276 | * Digits [dghubble/go-digits](https://github.com/dghubble/go-digits) 277 | * GoSquared [drinkin/go-gosquared](https://github.com/drinkin/go-gosquared) 278 | * Kala [ajvb/kala](https://github.com/ajvb/kala) 279 | * Parse [fergstar/go-parse](https://github.com/fergstar/go-parse) 280 | * Swagger Generator [swagger-api/swagger-codegen](https://github.com/swagger-api/swagger-codegen) 281 | * Twitter [dghubble/go-twitter](https://github.com/dghubble/go-twitter) 282 | * Stacksmith [jesustinoco/go-smith](https://github.com/jesustinoco/go-smith) 283 | * Spotify [omegastreamtv/Spotify](https://github.com/omegastreamtv/Spotify) 284 | 285 | Create a Pull Request to add a link to your own API. 286 | 287 | ## Motivation 288 | 289 | Many client libraries follow the lead of [google/go-github](https://github.com/google/go-github) (our inspiration!), but do so by reimplementing logic common to all clients. 290 | 291 | This project borrows and abstracts those ideas into a Sling, an agnostic component any API client can use for creating and sending requests. 292 | 293 | ## Contributing 294 | 295 | See the [Contributing Guide](https://gist.github.com/dghubble/be682c123727f70bcfe7). 296 | 297 | ## License 298 | 299 | [MIT License](LICENSE) 300 | -------------------------------------------------------------------------------- /body.go: -------------------------------------------------------------------------------- 1 | package sling 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "strings" 8 | 9 | goquery "github.com/google/go-querystring/query" 10 | ) 11 | 12 | // BodyProvider provides Body content for http.Request attachment. 13 | type BodyProvider interface { 14 | // ContentType returns the Content-Type of the body. 15 | ContentType() string 16 | // Body returns the io.Reader body. 17 | Body() (io.Reader, error) 18 | } 19 | 20 | // bodyProvider provides the wrapped body value as a Body for reqests. 21 | type bodyProvider struct { 22 | body io.Reader 23 | } 24 | 25 | func (p bodyProvider) ContentType() string { 26 | return "" 27 | } 28 | 29 | func (p bodyProvider) Body() (io.Reader, error) { 30 | return p.body, nil 31 | } 32 | 33 | // jsonBodyProvider encodes a JSON tagged struct value as a Body for requests. 34 | // See https://golang.org/pkg/encoding/json/#MarshalIndent for details. 35 | type jsonBodyProvider struct { 36 | payload interface{} 37 | } 38 | 39 | func (p jsonBodyProvider) ContentType() string { 40 | return jsonContentType 41 | } 42 | 43 | func (p jsonBodyProvider) Body() (io.Reader, error) { 44 | buf := &bytes.Buffer{} 45 | err := json.NewEncoder(buf).Encode(p.payload) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return buf, nil 50 | } 51 | 52 | // formBodyProvider encodes a url tagged struct value as Body for requests. 53 | // See https://godoc.org/github.com/google/go-querystring/query for details. 54 | type formBodyProvider struct { 55 | payload interface{} 56 | } 57 | 58 | func (p formBodyProvider) ContentType() string { 59 | return formContentType 60 | } 61 | 62 | func (p formBodyProvider) Body() (io.Reader, error) { 63 | values, err := goquery.Values(p.payload) 64 | if err != nil { 65 | return nil, err 66 | } 67 | return strings.NewReader(values.Encode()), nil 68 | } 69 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package sling is a Go HTTP client library for creating and sending API requests. 3 | 4 | Slings store HTTP Request properties to simplify sending requests and decoding 5 | responses. Check the examples to learn how to compose a Sling into your API 6 | client. 7 | 8 | # Usage 9 | 10 | Use a Sling to set path, method, header, query, or body properties and create an 11 | http.Request. 12 | 13 | type Params struct { 14 | Count int `url:"count,omitempty"` 15 | } 16 | params := &Params{Count: 5} 17 | 18 | req, err := sling.New().Get("https://example.com").QueryStruct(params).Request() 19 | client.Do(req) 20 | 21 | # Path 22 | 23 | Use Path to set or extend the URL for created Requests. Extension means the 24 | path will be resolved relative to the existing URL. 25 | 26 | // creates a GET request to https://example.com/foo/bar 27 | req, err := sling.New().Base("https://example.com/").Path("foo/").Path("bar").Request() 28 | 29 | Use Get, Post, Put, Patch, Delete, or Head which are exactly the same as Path 30 | except they set the HTTP method too. 31 | 32 | req, err := sling.New().Post("http://upload.com/gophers") 33 | 34 | # Headers 35 | 36 | Add or Set headers for requests created by a Sling. 37 | 38 | s := sling.New().Base(baseUrl).Set("User-Agent", "Gophergram API Client") 39 | req, err := s.New().Get("gophergram/list").Request() 40 | 41 | # QueryStruct 42 | 43 | Define url parameter structs (https://godoc.org/github.com/google/go-querystring/query). 44 | Use QueryStruct to encode a struct as query parameters on requests. 45 | 46 | // Github Issue Parameters 47 | type IssueParams struct { 48 | Filter string `url:"filter,omitempty"` 49 | State string `url:"state,omitempty"` 50 | Labels string `url:"labels,omitempty"` 51 | Sort string `url:"sort,omitempty"` 52 | Direction string `url:"direction,omitempty"` 53 | Since string `url:"since,omitempty"` 54 | } 55 | 56 | githubBase := sling.New().Base("https://api.github.com/").Client(httpClient) 57 | 58 | path := fmt.Sprintf("repos/%s/%s/issues", owner, repo) 59 | params := &IssueParams{Sort: "updated", State: "open"} 60 | req, err := githubBase.New().Get(path).QueryStruct(params).Request() 61 | 62 | # Json Body 63 | 64 | Define JSON tagged structs (https://golang.org/pkg/encoding/json/). 65 | Use BodyJSON to JSON encode a struct as the Body on requests. 66 | 67 | type IssueRequest struct { 68 | Title string `json:"title,omitempty"` 69 | Body string `json:"body,omitempty"` 70 | Assignee string `json:"assignee,omitempty"` 71 | Milestone int `json:"milestone,omitempty"` 72 | Labels []string `json:"labels,omitempty"` 73 | } 74 | 75 | githubBase := sling.New().Base("https://api.github.com/").Client(httpClient) 76 | path := fmt.Sprintf("repos/%s/%s/issues", owner, repo) 77 | 78 | body := &IssueRequest{ 79 | Title: "Test title", 80 | Body: "Some issue", 81 | } 82 | req, err := githubBase.New().Post(path).BodyJSON(body).Request() 83 | 84 | Requests will include an "application/json" Content-Type header. 85 | 86 | # Form Body 87 | 88 | Define url tagged structs (https://godoc.org/github.com/google/go-querystring/query). 89 | Use BodyForm to form url encode a struct as the Body on requests. 90 | 91 | type StatusUpdateParams struct { 92 | Status string `url:"status,omitempty"` 93 | InReplyToStatusId int64 `url:"in_reply_to_status_id,omitempty"` 94 | MediaIds []int64 `url:"media_ids,omitempty,comma"` 95 | } 96 | 97 | tweetParams := &StatusUpdateParams{Status: "writing some Go"} 98 | req, err := twitterBase.New().Post(path).BodyForm(tweetParams).Request() 99 | 100 | Requests will include an "application/x-www-form-urlencoded" Content-Type 101 | header. 102 | 103 | # Plain Body 104 | 105 | Use Body to set a plain io.Reader on requests created by a Sling. 106 | 107 | body := strings.NewReader("raw body") 108 | req, err := sling.New().Base("https://example.com").Body(body).Request() 109 | 110 | Set a content type header, if desired (e.g. Set("Content-Type", "text/plain")). 111 | 112 | # Extend a Sling 113 | 114 | Each Sling generates an http.Request (say with some path and query params) 115 | each time Request() is called, based on its state. When creating 116 | different slings, you may wish to extend an existing Sling to minimize 117 | duplication (e.g. a common client). 118 | 119 | Each Sling instance provides a New() method which creates an independent copy, 120 | so setting properties on the child won't mutate the parent Sling. 121 | 122 | const twitterApi = "https://api.twitter.com/1.1/" 123 | base := sling.New().Base(twitterApi).Client(authClient) 124 | 125 | // statuses/show.json Sling 126 | tweetShowSling := base.New().Get("statuses/show.json").QueryStruct(params) 127 | req, err := tweetShowSling.Request() 128 | 129 | // statuses/update.json Sling 130 | tweetPostSling := base.New().Post("statuses/update.json").BodyForm(params) 131 | req, err := tweetPostSling.Request() 132 | 133 | Without the calls to base.New(), tweetShowSling and tweetPostSling would 134 | reference the base Sling and POST to 135 | "https://api.twitter.com/1.1/statuses/show.json/statuses/update.json", which 136 | is undesired. 137 | 138 | Recap: If you wish to extend a Sling, create a new child copy with New(). 139 | 140 | # Receive 141 | 142 | Define a JSON struct to decode a type from 2XX success responses. Use 143 | ReceiveSuccess(successV interface{}) to send a new Request and decode the 144 | response body into successV if it succeeds. 145 | 146 | // Github Issue (abbreviated) 147 | type Issue struct { 148 | Title string `json:"title"` 149 | Body string `json:"body"` 150 | } 151 | 152 | issues := new([]Issue) 153 | resp, err := githubBase.New().Get(path).QueryStruct(params).ReceiveSuccess(issues) 154 | fmt.Println(issues, resp, err) 155 | 156 | Most APIs return failure responses with JSON error details. To decode these, 157 | define success and failure JSON structs. Use 158 | Receive(successV, failureV interface{}) to send a new Request that will 159 | automatically decode the response into the successV for 2XX responses or into 160 | failureV for non-2XX responses. 161 | 162 | type GithubError struct { 163 | Message string `json:"message"` 164 | Errors []struct { 165 | Resource string `json:"resource"` 166 | Field string `json:"field"` 167 | Code string `json:"code"` 168 | } `json:"errors"` 169 | DocumentationURL string `json:"documentation_url"` 170 | } 171 | 172 | issues := new([]Issue) 173 | githubError := new(GithubError) 174 | resp, err := githubBase.New().Get(path).QueryStruct(params).Receive(issues, githubError) 175 | fmt.Println(issues, githubError, resp, err) 176 | 177 | Pass a nil successV or failureV argument to skip JSON decoding into that value. 178 | */ 179 | package sling 180 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Example API Client with Sling 3 | 4 | Try the example Github API Client. 5 | 6 | cd examples 7 | go get . 8 | 9 | List the public issues on the [github.com/golang/go](https://github.com/golang/go) repository. 10 | 11 | go run github.go 12 | 13 | To list your public and private Github issues, pass your [Github Access Token](https://github.com/settings/tokens) 14 | 15 | go run github.go -access-token=xxx 16 | 17 | or set the `GITHUB_ACCESS_TOKEN` environment variable. 18 | 19 | For a complete Github API, see the excellent [google/go-github](https://github.com/google/go-github) package. -------------------------------------------------------------------------------- /examples/github.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/coreos/pkg/flagutil" 11 | "github.com/dghubble/sling" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | const baseURL = "https://api.github.com/" 16 | 17 | // Issue is a simplified Github issue 18 | // https://developer.github.com/v3/issues/#response 19 | type Issue struct { 20 | ID int `json:"id"` 21 | URL string `json:"url"` 22 | Number int `json:"number"` 23 | State string `json:"state"` 24 | Title string `json:"title"` 25 | Body string `json:"body"` 26 | } 27 | 28 | // GithubError represents a Github API error response 29 | // https://developer.github.com/v3/#client-errors 30 | type GithubError struct { 31 | Message string `json:"message"` 32 | Errors []struct { 33 | Resource string `json:"resource"` 34 | Field string `json:"field"` 35 | Code string `json:"code"` 36 | } `json:"errors"` 37 | DocumentationURL string `json:"documentation_url"` 38 | } 39 | 40 | func (e GithubError) Error() string { 41 | return fmt.Sprintf("github: %v %+v %v", e.Message, e.Errors, e.DocumentationURL) 42 | } 43 | 44 | // IssueRequest is a simplified issue request 45 | // https://developer.github.com/v3/issues/#create-an-issue 46 | type IssueRequest struct { 47 | Title string `json:"title,omitempty"` 48 | Body string `json:"body,omitempty"` 49 | Assignee string `json:"assignee,omitempty"` 50 | Milestone int `json:"milestone,omitempty"` 51 | Labels []string `json:"labels,omitempty"` 52 | } 53 | 54 | // IssueListParams are the params for IssueService.List 55 | // https://developer.github.com/v3/issues/#parameters 56 | type IssueListParams struct { 57 | Filter string `url:"filter,omitempty"` 58 | State string `url:"state,omitempty"` 59 | Labels string `url:"labels,omitempty"` 60 | Sort string `url:"sort,omitempty"` 61 | Direction string `url:"direction,omitempty"` 62 | Since string `url:"since,omitempty"` 63 | } 64 | 65 | // Services 66 | 67 | // IssueService provides methods for creating and reading issues. 68 | type IssueService struct { 69 | sling *sling.Sling 70 | } 71 | 72 | // NewIssueService returns a new IssueService. 73 | func NewIssueService(httpClient *http.Client) *IssueService { 74 | return &IssueService{ 75 | sling: sling.New().Client(httpClient).Base(baseURL), 76 | } 77 | } 78 | 79 | // List returns the authenticated user's issues across repos and orgs. 80 | func (s *IssueService) List(params *IssueListParams) ([]Issue, *http.Response, error) { 81 | issues := new([]Issue) 82 | githubError := new(GithubError) 83 | resp, err := s.sling.New().Path("issues").QueryStruct(params).Receive(issues, githubError) 84 | if err == nil { 85 | err = githubError 86 | } 87 | return *issues, resp, err 88 | } 89 | 90 | // ListByRepo returns a repository's issues. 91 | func (s *IssueService) ListByRepo(owner, repo string, params *IssueListParams) ([]Issue, *http.Response, error) { 92 | issues := new([]Issue) 93 | githubError := new(GithubError) 94 | path := fmt.Sprintf("repos/%s/%s/issues", owner, repo) 95 | resp, err := s.sling.New().Get(path).QueryStruct(params).Receive(issues, githubError) 96 | if err == nil { 97 | err = githubError 98 | } 99 | return *issues, resp, err 100 | } 101 | 102 | // Create creates a new issue on the specified repository. 103 | func (s *IssueService) Create(owner, repo string, issueBody *IssueRequest) (*Issue, *http.Response, error) { 104 | issue := new(Issue) 105 | githubError := new(GithubError) 106 | path := fmt.Sprintf("repos/%s/%s/issues", owner, repo) 107 | resp, err := s.sling.New().Post(path).BodyJSON(issueBody).Receive(issue, githubError) 108 | if err == nil { 109 | err = githubError 110 | } 111 | return issue, resp, err 112 | } 113 | 114 | // Client to wrap services 115 | 116 | // Client is a tiny Github client 117 | type Client struct { 118 | IssueService *IssueService 119 | // other service endpoints... 120 | } 121 | 122 | // NewClient returns a new Client 123 | func NewClient(httpClient *http.Client) *Client { 124 | return &Client{ 125 | IssueService: NewIssueService(httpClient), 126 | } 127 | } 128 | 129 | func main() { 130 | // Github Unauthenticated API 131 | client := NewClient(nil) 132 | params := &IssueListParams{Sort: "updated"} 133 | issues, _, _ := client.IssueService.ListByRepo("golang", "go", params) 134 | fmt.Printf("Public golang/go Issues:\n%v\n", issues) 135 | 136 | // Github OAuth2 API 137 | flags := flag.NewFlagSet("github-example", flag.ExitOnError) 138 | // -access-token=xxx or GITHUB_ACCESS_TOKEN env var 139 | accessToken := flags.String("access-token", "", "Github Access Token") 140 | flags.Parse(os.Args[1:]) 141 | flagutil.SetFlagsFromEnv(flags, "GITHUB") 142 | 143 | if *accessToken == "" { 144 | log.Fatal("Github Access Token required to list private issues") 145 | } 146 | 147 | config := &oauth2.Config{} 148 | token := &oauth2.Token{AccessToken: *accessToken} 149 | httpClient := config.Client(oauth2.NoContext, token) 150 | 151 | client = NewClient(httpClient) 152 | issues, _, _ = client.IssueService.List(params) 153 | fmt.Printf("Your Github Issues:\n%v\n", issues) 154 | 155 | // body := &IssueRequest{ 156 | // Title: "Test title", 157 | // Body: "Some test issue", 158 | // } 159 | // issue, _, _ := client.IssueService.Create("dghubble", "temp", body) 160 | // fmt.Println(issue) 161 | } 162 | -------------------------------------------------------------------------------- /examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dghubble/sling/examples 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/coreos/pkg v0.0.0-20230209195159-6f3db454fdf8 9 | github.com/dghubble/sling v1.4.2 10 | golang.org/x/oauth2 v0.30.0 11 | ) 12 | 13 | require github.com/google/go-querystring v1.1.0 // indirect 14 | -------------------------------------------------------------------------------- /examples/go.sum: -------------------------------------------------------------------------------- 1 | github.com/coreos/pkg v0.0.0-20230209195159-6f3db454fdf8 h1:bu+DLSrk0L1eW0/tNbOMJ9VcHO3jkvtxcncB9wcBa0A= 2 | github.com/coreos/pkg v0.0.0-20230209195159-6f3db454fdf8/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 3 | github.com/dghubble/sling v1.4.2 h1:vs1HIGBbSl2SEALyU+irpYFLZMfc49Fp+jYryFebQjM= 4 | github.com/dghubble/sling v1.4.2/go.mod h1:o0arCOz0HwfqYQJLrRtqunaWOn4X6jxE/6ORKRpVTD4= 5 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 6 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 7 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 8 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 9 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 10 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 11 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dghubble/sling 2 | 3 | go 1.19 4 | 5 | require github.com/google/go-querystring v1.1.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 2 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 3 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 4 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 5 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 6 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package sling 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | // ResponseDecoder decodes http responses into struct values. 9 | type ResponseDecoder interface { 10 | // Decode decodes the response into the value pointed to by v. 11 | Decode(resp *http.Response, v interface{}) error 12 | } 13 | 14 | // jsonDecoder decodes http response JSON into a JSON-tagged struct value. 15 | type jsonDecoder struct { 16 | } 17 | 18 | // Decode decodes the Response Body into the value pointed to by v. 19 | // Caller must provide a non-nil v and close the resp.Body. 20 | func (d jsonDecoder) Decode(resp *http.Response, v interface{}) error { 21 | return json.NewDecoder(resp.Body).Decode(v) 22 | } 23 | -------------------------------------------------------------------------------- /sling.go: -------------------------------------------------------------------------------- 1 | package sling 2 | 3 | import ( 4 | "encoding/base64" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | 9 | goquery "github.com/google/go-querystring/query" 10 | ) 11 | 12 | const ( 13 | contentType = "Content-Type" 14 | jsonContentType = "application/json" 15 | formContentType = "application/x-www-form-urlencoded" 16 | ) 17 | 18 | // Doer executes http requests. It is implemented by *http.Client. You can 19 | // wrap *http.Client with layers of Doers to form a stack of client-side 20 | // middleware. 21 | type Doer interface { 22 | Do(req *http.Request) (*http.Response, error) 23 | } 24 | 25 | // Sling is an HTTP Request builder and sender. 26 | type Sling struct { 27 | // http Client for doing requests 28 | httpClient Doer 29 | // HTTP method (GET, POST, etc.) 30 | method string 31 | // raw url string for requests 32 | rawURL string 33 | // stores key-values pairs to add to request's Headers 34 | header http.Header 35 | // url tagged query structs 36 | queryStructs []interface{} 37 | // body provider 38 | bodyProvider BodyProvider 39 | // response decoder 40 | responseDecoder ResponseDecoder 41 | } 42 | 43 | // New returns a new Sling with an http DefaultClient. 44 | func New() *Sling { 45 | return &Sling{ 46 | httpClient: http.DefaultClient, 47 | method: "GET", 48 | header: make(http.Header), 49 | queryStructs: make([]interface{}, 0), 50 | responseDecoder: jsonDecoder{}, 51 | } 52 | } 53 | 54 | // New returns a copy of a Sling for creating a new Sling with properties 55 | // from a parent Sling. For example, 56 | // 57 | // parentSling := sling.New().Client(client).Base("https://api.io/") 58 | // fooSling := parentSling.New().Get("foo/") 59 | // barSling := parentSling.New().Get("bar/") 60 | // 61 | // fooSling and barSling will both use the same client, but send requests to 62 | // https://api.io/foo/ and https://api.io/bar/ respectively. 63 | // 64 | // Note that query and body values are copied so if pointer values are used, 65 | // mutating the original value will mutate the value within the child Sling. 66 | func (s *Sling) New() *Sling { 67 | // copy Headers pairs into new Header map 68 | headerCopy := make(http.Header) 69 | for k, v := range s.header { 70 | headerCopy[k] = v 71 | } 72 | return &Sling{ 73 | httpClient: s.httpClient, 74 | method: s.method, 75 | rawURL: s.rawURL, 76 | header: headerCopy, 77 | queryStructs: append([]interface{}{}, s.queryStructs...), 78 | bodyProvider: s.bodyProvider, 79 | responseDecoder: s.responseDecoder, 80 | } 81 | } 82 | 83 | // Http Client 84 | 85 | // Client sets the http Client used to do requests. If a nil client is given, 86 | // the http.DefaultClient will be used. 87 | func (s *Sling) Client(httpClient *http.Client) *Sling { 88 | if httpClient == nil { 89 | return s.Doer(http.DefaultClient) 90 | } 91 | return s.Doer(httpClient) 92 | } 93 | 94 | // Doer sets the custom Doer implementation used to do requests. 95 | // If a nil client is given, the http.DefaultClient will be used. 96 | func (s *Sling) Doer(doer Doer) *Sling { 97 | if doer == nil { 98 | s.httpClient = http.DefaultClient 99 | } else { 100 | s.httpClient = doer 101 | } 102 | return s 103 | } 104 | 105 | // Method 106 | 107 | // Head sets the Sling method to HEAD and sets the given pathURL. 108 | func (s *Sling) Head(pathURL string) *Sling { 109 | s.method = "HEAD" 110 | return s.Path(pathURL) 111 | } 112 | 113 | // Get sets the Sling method to GET and sets the given pathURL. 114 | func (s *Sling) Get(pathURL string) *Sling { 115 | s.method = "GET" 116 | return s.Path(pathURL) 117 | } 118 | 119 | // Post sets the Sling method to POST and sets the given pathURL. 120 | func (s *Sling) Post(pathURL string) *Sling { 121 | s.method = "POST" 122 | return s.Path(pathURL) 123 | } 124 | 125 | // Put sets the Sling method to PUT and sets the given pathURL. 126 | func (s *Sling) Put(pathURL string) *Sling { 127 | s.method = "PUT" 128 | return s.Path(pathURL) 129 | } 130 | 131 | // Patch sets the Sling method to PATCH and sets the given pathURL. 132 | func (s *Sling) Patch(pathURL string) *Sling { 133 | s.method = "PATCH" 134 | return s.Path(pathURL) 135 | } 136 | 137 | // Delete sets the Sling method to DELETE and sets the given pathURL. 138 | func (s *Sling) Delete(pathURL string) *Sling { 139 | s.method = "DELETE" 140 | return s.Path(pathURL) 141 | } 142 | 143 | // Options sets the Sling method to OPTIONS and sets the given pathURL. 144 | func (s *Sling) Options(pathURL string) *Sling { 145 | s.method = "OPTIONS" 146 | return s.Path(pathURL) 147 | } 148 | 149 | // Trace sets the Sling method to TRACE and sets the given pathURL. 150 | func (s *Sling) Trace(pathURL string) *Sling { 151 | s.method = "TRACE" 152 | return s.Path(pathURL) 153 | } 154 | 155 | // Connect sets the Sling method to CONNECT and sets the given pathURL. 156 | func (s *Sling) Connect(pathURL string) *Sling { 157 | s.method = "CONNECT" 158 | return s.Path(pathURL) 159 | } 160 | 161 | // Header 162 | 163 | // Add adds the key, value pair in Headers, appending values for existing keys 164 | // to the key's values. Header keys are canonicalized. 165 | func (s *Sling) Add(key, value string) *Sling { 166 | s.header.Add(key, value) 167 | return s 168 | } 169 | 170 | // Set sets the key, value pair in Headers, replacing existing values 171 | // associated with key. Header keys are canonicalized. 172 | func (s *Sling) Set(key, value string) *Sling { 173 | s.header.Set(key, value) 174 | return s 175 | } 176 | 177 | // SetBasicAuth sets the Authorization header to use HTTP Basic Authentication 178 | // with the provided username and password. With HTTP Basic Authentication 179 | // the provided username and password are not encrypted. 180 | func (s *Sling) SetBasicAuth(username, password string) *Sling { 181 | return s.Set("Authorization", "Basic "+basicAuth(username, password)) 182 | } 183 | 184 | // basicAuth returns the base64 encoded username:password for basic auth copied 185 | // from net/http. 186 | func basicAuth(username, password string) string { 187 | auth := username + ":" + password 188 | return base64.StdEncoding.EncodeToString([]byte(auth)) 189 | } 190 | 191 | // Url 192 | 193 | // Base sets the rawURL. If you intend to extend the url with Path, 194 | // baseUrl should be specified with a trailing slash. 195 | func (s *Sling) Base(rawURL string) *Sling { 196 | s.rawURL = rawURL 197 | return s 198 | } 199 | 200 | // Path extends the rawURL with the given path by resolving the reference to 201 | // an absolute URL. If parsing errors occur, the rawURL is left unmodified. 202 | func (s *Sling) Path(path string) *Sling { 203 | baseURL, baseErr := url.Parse(s.rawURL) 204 | pathURL, pathErr := url.Parse(path) 205 | if baseErr == nil && pathErr == nil { 206 | s.rawURL = baseURL.ResolveReference(pathURL).String() 207 | return s 208 | } 209 | return s 210 | } 211 | 212 | // QueryStruct appends the queryStruct to the Sling's queryStructs. The value 213 | // pointed to by each queryStruct will be encoded as url query parameters on 214 | // new requests (see Request()). 215 | // The queryStruct argument should be a pointer to a url tagged struct. See 216 | // https://godoc.org/github.com/google/go-querystring/query for details. 217 | func (s *Sling) QueryStruct(queryStruct interface{}) *Sling { 218 | if queryStruct != nil { 219 | s.queryStructs = append(s.queryStructs, queryStruct) 220 | } 221 | return s 222 | } 223 | 224 | // Body 225 | 226 | // Body sets the Sling's body. The body value will be set as the Body on new 227 | // requests (see Request()). 228 | // If the provided body is also an io.Closer, the request Body will be closed 229 | // by http.Client methods. 230 | func (s *Sling) Body(body io.Reader) *Sling { 231 | if body == nil { 232 | return s 233 | } 234 | return s.BodyProvider(bodyProvider{body: body}) 235 | } 236 | 237 | // BodyProvider sets the Sling's body provider. 238 | func (s *Sling) BodyProvider(body BodyProvider) *Sling { 239 | if body == nil { 240 | return s 241 | } 242 | s.bodyProvider = body 243 | 244 | ct := body.ContentType() 245 | if ct != "" { 246 | s.Set(contentType, ct) 247 | } 248 | 249 | return s 250 | } 251 | 252 | // BodyJSON sets the Sling's bodyJSON. The value pointed to by the bodyJSON 253 | // will be JSON encoded as the Body on new requests (see Request()). 254 | // The bodyJSON argument should be a pointer to a JSON tagged struct. See 255 | // https://golang.org/pkg/encoding/json/#MarshalIndent for details. 256 | func (s *Sling) BodyJSON(bodyJSON interface{}) *Sling { 257 | if bodyJSON == nil { 258 | return s 259 | } 260 | return s.BodyProvider(jsonBodyProvider{payload: bodyJSON}) 261 | } 262 | 263 | // BodyForm sets the Sling's bodyForm. The value pointed to by the bodyForm 264 | // will be url encoded as the Body on new requests (see Request()). 265 | // The bodyForm argument should be a pointer to a url tagged struct. See 266 | // https://godoc.org/github.com/google/go-querystring/query for details. 267 | func (s *Sling) BodyForm(bodyForm interface{}) *Sling { 268 | if bodyForm == nil { 269 | return s 270 | } 271 | return s.BodyProvider(formBodyProvider{payload: bodyForm}) 272 | } 273 | 274 | // Requests 275 | 276 | // Request returns a new http.Request created with the Sling properties. 277 | // Returns any errors parsing the rawURL, encoding query structs, encoding 278 | // the body, or creating the http.Request. 279 | func (s *Sling) Request() (*http.Request, error) { 280 | reqURL, err := url.Parse(s.rawURL) 281 | if err != nil { 282 | return nil, err 283 | } 284 | 285 | err = addQueryStructs(reqURL, s.queryStructs) 286 | if err != nil { 287 | return nil, err 288 | } 289 | 290 | var body io.Reader 291 | if s.bodyProvider != nil { 292 | body, err = s.bodyProvider.Body() 293 | if err != nil { 294 | return nil, err 295 | } 296 | } 297 | req, err := http.NewRequest(s.method, reqURL.String(), body) 298 | if err != nil { 299 | return nil, err 300 | } 301 | addHeaders(req, s.header) 302 | return req, err 303 | } 304 | 305 | // addQueryStructs parses url tagged query structs using go-querystring to 306 | // encode them to url.Values and format them onto the url.RawQuery. Any 307 | // query parsing or encoding errors are returned. 308 | func addQueryStructs(reqURL *url.URL, queryStructs []interface{}) error { 309 | urlValues, err := url.ParseQuery(reqURL.RawQuery) 310 | if err != nil { 311 | return err 312 | } 313 | // encodes query structs into a url.Values map and merges maps 314 | for _, queryStruct := range queryStructs { 315 | queryValues, err := goquery.Values(queryStruct) 316 | if err != nil { 317 | return err 318 | } 319 | for key, values := range queryValues { 320 | for _, value := range values { 321 | urlValues.Add(key, value) 322 | } 323 | } 324 | } 325 | // url.Values format to a sorted "url encoded" string, e.g. "key=val&foo=bar" 326 | reqURL.RawQuery = urlValues.Encode() 327 | return nil 328 | } 329 | 330 | // addHeaders adds the key, value pairs from the given http.Header to the 331 | // request. Values for existing keys are appended to the keys values. 332 | func addHeaders(req *http.Request, header http.Header) { 333 | for key, values := range header { 334 | for _, value := range values { 335 | req.Header.Add(key, value) 336 | } 337 | } 338 | } 339 | 340 | // Sending 341 | 342 | // ResponseDecoder sets the Sling's response decoder. 343 | func (s *Sling) ResponseDecoder(decoder ResponseDecoder) *Sling { 344 | if decoder == nil { 345 | return s 346 | } 347 | s.responseDecoder = decoder 348 | return s 349 | } 350 | 351 | // ReceiveSuccess creates a new HTTP request and returns the response. Success 352 | // responses (2XX) are JSON decoded into the value pointed to by successV. 353 | // Any error creating the request, sending it, or decoding a 2XX response 354 | // is returned. 355 | func (s *Sling) ReceiveSuccess(successV interface{}) (*http.Response, error) { 356 | return s.Receive(successV, nil) 357 | } 358 | 359 | // Receive creates a new HTTP request and returns the response. Success 360 | // responses (2XX) are JSON decoded into the value pointed to by successV and 361 | // other responses are JSON decoded into the value pointed to by failureV. 362 | // If the status code of response is 204(no content) or the Content-Lenght is 0, 363 | // decoding is skipped. Any error creating the request, sending it, or decoding 364 | // the response is returned. 365 | // Receive is shorthand for calling Request and Do. 366 | func (s *Sling) Receive(successV, failureV interface{}) (*http.Response, error) { 367 | req, err := s.Request() 368 | if err != nil { 369 | return nil, err 370 | } 371 | return s.Do(req, successV, failureV) 372 | } 373 | 374 | // Do sends an HTTP request and returns the response. Success responses (2XX) 375 | // are JSON decoded into the value pointed to by successV and other responses 376 | // are JSON decoded into the value pointed to by failureV. 377 | // If the status code of response is 204(no content) or the Content-Length is 0, 378 | // decoding is skipped. Any error sending the request or decoding the response 379 | // is returned. 380 | func (s *Sling) Do(req *http.Request, successV, failureV interface{}) (*http.Response, error) { 381 | resp, err := s.httpClient.Do(req) 382 | if err != nil { 383 | return resp, err 384 | } 385 | // when err is nil, resp contains a non-nil resp.Body which must be closed 386 | defer resp.Body.Close() 387 | 388 | // The default HTTP client's Transport may not 389 | // reuse HTTP/1.x "keep-alive" TCP connections if the Body is 390 | // not read to completion and closed. 391 | // See: https://golang.org/pkg/net/http/#Response 392 | defer io.Copy(io.Discard, resp.Body) 393 | 394 | // Don't try to decode on 204s or Content-Length is 0 395 | if resp.StatusCode == http.StatusNoContent || resp.ContentLength == 0 { 396 | return resp, nil 397 | } 398 | 399 | // Decode from json 400 | if successV != nil || failureV != nil { 401 | err = decodeResponse(resp, s.responseDecoder, successV, failureV) 402 | } 403 | return resp, err 404 | } 405 | 406 | // decodeResponse decodes response Body into the value pointed to by successV 407 | // if the response is a success (2XX) or into the value pointed to by failureV 408 | // otherwise. If the successV or failureV argument to decode into is nil, 409 | // decoding is skipped. 410 | // Caller is responsible for closing the resp.Body. 411 | func decodeResponse(resp *http.Response, decoder ResponseDecoder, successV, failureV interface{}) error { 412 | if code := resp.StatusCode; 200 <= code && code <= 299 { 413 | if successV != nil { 414 | return decoder.Decode(resp, successV) 415 | } 416 | } else { 417 | if failureV != nil { 418 | return decoder.Decode(resp, failureV) 419 | } 420 | } 421 | return nil 422 | } 423 | -------------------------------------------------------------------------------- /sling_test.go: -------------------------------------------------------------------------------- 1 | package sling 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/xml" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "math" 11 | "net" 12 | "net/http" 13 | "net/http/httptest" 14 | "net/url" 15 | "reflect" 16 | "strings" 17 | "sync/atomic" 18 | "testing" 19 | ) 20 | 21 | type FakeParams struct { 22 | KindName string `url:"kind_name"` 23 | Count int `url:"count"` 24 | } 25 | 26 | // Url-tagged query struct 27 | var paramsA = struct { 28 | Limit int `url:"limit"` 29 | }{ 30 | 30, 31 | } 32 | var paramsB = FakeParams{KindName: "recent", Count: 25} 33 | 34 | // Json/XML-tagged model struct 35 | type FakeModel struct { 36 | Text string `json:"text,omitempty" xml:"text"` 37 | FavoriteCount int64 `json:"favorite_count,omitempty" xml:"favorite_count"` 38 | Temperature float64 `json:"temperature,omitempty" xml:"temperature"` 39 | } 40 | 41 | var modelA = FakeModel{Text: "note", FavoriteCount: 12} 42 | 43 | // Non-Json response decoder 44 | type xmlResponseDecoder struct{} 45 | 46 | func (d xmlResponseDecoder) Decode(resp *http.Response, v interface{}) error { 47 | return xml.NewDecoder(resp.Body).Decode(v) 48 | } 49 | 50 | func TestNew(t *testing.T) { 51 | sling := New() 52 | if sling.httpClient != http.DefaultClient { 53 | t.Errorf("expected %v, got %v", http.DefaultClient, sling.httpClient) 54 | } 55 | if sling.header == nil { 56 | t.Errorf("Header map not initialized with make") 57 | } 58 | if sling.queryStructs == nil { 59 | t.Errorf("queryStructs not initialized with make") 60 | } 61 | } 62 | 63 | func TestSlingNew(t *testing.T) { 64 | fakeBodyProvider := jsonBodyProvider{FakeModel{}} 65 | 66 | cases := []*Sling{ 67 | &Sling{httpClient: &http.Client{}, method: "GET", rawURL: "http://example.com"}, 68 | &Sling{httpClient: nil, method: "", rawURL: "http://example.com"}, 69 | &Sling{queryStructs: make([]interface{}, 0)}, 70 | &Sling{queryStructs: []interface{}{paramsA}}, 71 | &Sling{queryStructs: []interface{}{paramsA, paramsB}}, 72 | &Sling{bodyProvider: fakeBodyProvider}, 73 | &Sling{bodyProvider: fakeBodyProvider}, 74 | &Sling{bodyProvider: nil}, 75 | New().Add("Content-Type", "application/json"), 76 | New().Add("A", "B").Add("a", "c").New(), 77 | New().Add("A", "B").New().Add("a", "c"), 78 | New().BodyForm(paramsB), 79 | New().BodyForm(paramsB).New(), 80 | } 81 | for _, sling := range cases { 82 | child := sling.New() 83 | if child.httpClient != sling.httpClient { 84 | t.Errorf("expected %v, got %v", sling.httpClient, child.httpClient) 85 | } 86 | if child.method != sling.method { 87 | t.Errorf("expected %s, got %s", sling.method, child.method) 88 | } 89 | if child.rawURL != sling.rawURL { 90 | t.Errorf("expected %s, got %s", sling.rawURL, child.rawURL) 91 | } 92 | // Header should be a copy of parent Sling header. For example, calling 93 | // baseSling.Add("k","v") should not mutate previously created child Slings 94 | if sling.header != nil { 95 | // struct literal cases don't init Header in usual way, skip header check 96 | if !reflect.DeepEqual(sling.header, child.header) { 97 | t.Errorf("not DeepEqual: expected %v, got %v", sling.header, child.header) 98 | } 99 | sling.header.Add("K", "V") 100 | if child.header.Get("K") != "" { 101 | t.Errorf("child.header was a reference to original map, should be copy") 102 | } 103 | } 104 | // queryStruct slice should be a new slice with a copy of the contents 105 | if len(sling.queryStructs) > 0 { 106 | // mutating one slice should not mutate the other 107 | child.queryStructs[0] = nil 108 | if sling.queryStructs[0] == nil { 109 | t.Errorf("child.queryStructs was a re-slice, expected slice with copied contents") 110 | } 111 | } 112 | // body should be copied 113 | if child.bodyProvider != sling.bodyProvider { 114 | t.Errorf("expected %v, got %v", sling.bodyProvider, child.bodyProvider) 115 | } 116 | } 117 | } 118 | 119 | func TestClientSetter(t *testing.T) { 120 | developerClient := &http.Client{} 121 | cases := []struct { 122 | input *http.Client 123 | expected *http.Client 124 | }{ 125 | {nil, http.DefaultClient}, 126 | {developerClient, developerClient}, 127 | } 128 | for _, c := range cases { 129 | sling := New() 130 | sling.Client(c.input) 131 | if sling.httpClient != c.expected { 132 | t.Errorf("input %v, expected %v, got %v", c.input, c.expected, sling.httpClient) 133 | } 134 | } 135 | } 136 | 137 | func TestDoerSetter(t *testing.T) { 138 | developerClient := &http.Client{} 139 | cases := []struct { 140 | input Doer 141 | expected Doer 142 | }{ 143 | {nil, http.DefaultClient}, 144 | {developerClient, developerClient}, 145 | } 146 | for _, c := range cases { 147 | sling := New() 148 | sling.Doer(c.input) 149 | if sling.httpClient != c.expected { 150 | t.Errorf("input %v, expected %v, got %v", c.input, c.expected, sling.httpClient) 151 | } 152 | } 153 | } 154 | 155 | func TestBaseSetter(t *testing.T) { 156 | cases := []string{"http://a.io/", "http://b.io", "/path", "path", ""} 157 | for _, base := range cases { 158 | sling := New().Base(base) 159 | if sling.rawURL != base { 160 | t.Errorf("expected %s, got %s", base, sling.rawURL) 161 | } 162 | } 163 | } 164 | 165 | func TestPathSetter(t *testing.T) { 166 | cases := []struct { 167 | rawURL string 168 | path string 169 | expectedRawURL string 170 | }{ 171 | {"http://a.io/", "foo", "http://a.io/foo"}, 172 | {"http://a.io/", "/foo", "http://a.io/foo"}, 173 | {"http://a.io", "foo", "http://a.io/foo"}, 174 | {"http://a.io", "/foo", "http://a.io/foo"}, 175 | {"http://a.io/foo/", "bar", "http://a.io/foo/bar"}, 176 | // rawURL should end in trailing slash if it is to be Path extended 177 | {"http://a.io/foo", "bar", "http://a.io/bar"}, 178 | {"http://a.io/foo", "/bar", "http://a.io/bar"}, 179 | // path extension is absolute 180 | {"http://a.io", "http://b.io/", "http://b.io/"}, 181 | {"http://a.io/", "http://b.io/", "http://b.io/"}, 182 | {"http://a.io", "http://b.io", "http://b.io"}, 183 | {"http://a.io/", "http://b.io", "http://b.io"}, 184 | // empty base, empty path 185 | {"", "http://b.io", "http://b.io"}, 186 | {"http://a.io", "", "http://a.io"}, 187 | {"", "", ""}, 188 | } 189 | for _, c := range cases { 190 | sling := New().Base(c.rawURL).Path(c.path) 191 | if sling.rawURL != c.expectedRawURL { 192 | t.Errorf("expected %s, got %s", c.expectedRawURL, sling.rawURL) 193 | } 194 | } 195 | } 196 | 197 | func TestMethodSetters(t *testing.T) { 198 | cases := []struct { 199 | sling *Sling 200 | expectedMethod string 201 | }{ 202 | {New().Path("http://a.io"), "GET"}, 203 | {New().Head("http://a.io"), "HEAD"}, 204 | {New().Get("http://a.io"), "GET"}, 205 | {New().Post("http://a.io"), "POST"}, 206 | {New().Put("http://a.io"), "PUT"}, 207 | {New().Patch("http://a.io"), "PATCH"}, 208 | {New().Delete("http://a.io"), "DELETE"}, 209 | {New().Options("http://a.io"), "OPTIONS"}, 210 | {New().Trace("http://a.io"), "TRACE"}, 211 | {New().Connect("http://a.io"), "CONNECT"}, 212 | } 213 | for _, c := range cases { 214 | if c.sling.method != c.expectedMethod { 215 | t.Errorf("expected method %s, got %s", c.expectedMethod, c.sling.method) 216 | } 217 | } 218 | } 219 | 220 | func TestAddHeader(t *testing.T) { 221 | cases := []struct { 222 | sling *Sling 223 | expectedHeader map[string][]string 224 | }{ 225 | {New().Add("authorization", "OAuth key=\"value\""), map[string][]string{"Authorization": []string{"OAuth key=\"value\""}}}, 226 | // header keys should be canonicalized 227 | {New().Add("content-tYPE", "application/json").Add("User-AGENT", "sling"), map[string][]string{"Content-Type": []string{"application/json"}, "User-Agent": []string{"sling"}}}, 228 | // values for existing keys should be appended 229 | {New().Add("A", "B").Add("a", "c"), map[string][]string{"A": []string{"B", "c"}}}, 230 | // Add should add to values for keys added by parent Slings 231 | {New().Add("A", "B").Add("a", "c").New(), map[string][]string{"A": []string{"B", "c"}}}, 232 | {New().Add("A", "B").New().Add("a", "c"), map[string][]string{"A": []string{"B", "c"}}}, 233 | } 234 | for _, c := range cases { 235 | // type conversion from header to alias'd map for deep equality comparison 236 | headerMap := map[string][]string(c.sling.header) 237 | if !reflect.DeepEqual(c.expectedHeader, headerMap) { 238 | t.Errorf("not DeepEqual: expected %v, got %v", c.expectedHeader, headerMap) 239 | } 240 | } 241 | } 242 | 243 | func TestSetHeader(t *testing.T) { 244 | cases := []struct { 245 | sling *Sling 246 | expectedHeader map[string][]string 247 | }{ 248 | // should replace existing values associated with key 249 | {New().Add("A", "B").Set("a", "c"), map[string][]string{"A": []string{"c"}}}, 250 | {New().Set("content-type", "A").Set("Content-Type", "B"), map[string][]string{"Content-Type": []string{"B"}}}, 251 | // Set should replace values received by copying parent Slings 252 | {New().Set("A", "B").Add("a", "c").New(), map[string][]string{"A": []string{"B", "c"}}}, 253 | {New().Add("A", "B").New().Set("a", "c"), map[string][]string{"A": []string{"c"}}}, 254 | } 255 | for _, c := range cases { 256 | // type conversion from Header to alias'd map for deep equality comparison 257 | headerMap := map[string][]string(c.sling.header) 258 | if !reflect.DeepEqual(c.expectedHeader, headerMap) { 259 | t.Errorf("not DeepEqual: expected %v, got %v", c.expectedHeader, headerMap) 260 | } 261 | } 262 | } 263 | 264 | func TestBasicAuth(t *testing.T) { 265 | cases := []struct { 266 | sling *Sling 267 | expectedAuth []string 268 | }{ 269 | // basic auth: username & password 270 | {New().SetBasicAuth("Aladdin", "open sesame"), []string{"Aladdin", "open sesame"}}, 271 | // empty username 272 | {New().SetBasicAuth("", "secret"), []string{"", "secret"}}, 273 | // empty password 274 | {New().SetBasicAuth("admin", ""), []string{"admin", ""}}, 275 | } 276 | for _, c := range cases { 277 | req, err := c.sling.Request() 278 | if err != nil { 279 | t.Errorf("unexpected error when building Request with .SetBasicAuth()") 280 | } 281 | username, password, ok := req.BasicAuth() 282 | if !ok { 283 | t.Errorf("basic auth missing when expected") 284 | } 285 | auth := []string{username, password} 286 | if !reflect.DeepEqual(c.expectedAuth, auth) { 287 | t.Errorf("not DeepEqual: expected %v, got %v", c.expectedAuth, auth) 288 | } 289 | } 290 | } 291 | 292 | func TestQueryStructSetter(t *testing.T) { 293 | cases := []struct { 294 | sling *Sling 295 | expectedStructs []interface{} 296 | }{ 297 | {New(), []interface{}{}}, 298 | {New().QueryStruct(nil), []interface{}{}}, 299 | {New().QueryStruct(paramsA), []interface{}{paramsA}}, 300 | {New().QueryStruct(paramsA).QueryStruct(paramsA), []interface{}{paramsA, paramsA}}, 301 | {New().QueryStruct(paramsA).QueryStruct(paramsB), []interface{}{paramsA, paramsB}}, 302 | {New().QueryStruct(paramsA).New(), []interface{}{paramsA}}, 303 | {New().QueryStruct(paramsA).New().QueryStruct(paramsB), []interface{}{paramsA, paramsB}}, 304 | } 305 | 306 | for _, c := range cases { 307 | if count := len(c.sling.queryStructs); count != len(c.expectedStructs) { 308 | t.Errorf("expected length %d, got %d", len(c.expectedStructs), count) 309 | } 310 | check: 311 | for _, expected := range c.expectedStructs { 312 | for _, param := range c.sling.queryStructs { 313 | if param == expected { 314 | continue check 315 | } 316 | } 317 | t.Errorf("expected to find %v in %v", expected, c.sling.queryStructs) 318 | } 319 | } 320 | } 321 | 322 | func TestBodyJSONSetter(t *testing.T) { 323 | fakeModel := &FakeModel{} 324 | fakeBodyProvider := jsonBodyProvider{payload: fakeModel} 325 | 326 | cases := []struct { 327 | initial BodyProvider 328 | input interface{} 329 | expected BodyProvider 330 | }{ 331 | // json tagged struct is set as bodyJSON 332 | {nil, fakeModel, fakeBodyProvider}, 333 | // nil argument to bodyJSON does not replace existing bodyJSON 334 | {fakeBodyProvider, nil, fakeBodyProvider}, 335 | // nil bodyJSON remains nil 336 | {nil, nil, nil}, 337 | } 338 | for _, c := range cases { 339 | sling := New() 340 | sling.bodyProvider = c.initial 341 | sling.BodyJSON(c.input) 342 | if sling.bodyProvider != c.expected { 343 | t.Errorf("expected %v, got %v", c.expected, sling.bodyProvider) 344 | } 345 | // Header Content-Type should be application/json if bodyJSON arg was non-nil 346 | if c.input != nil && sling.header.Get(contentType) != jsonContentType { 347 | t.Errorf("Incorrect or missing header, expected %s, got %s", jsonContentType, sling.header.Get(contentType)) 348 | } else if c.input == nil && sling.header.Get(contentType) != "" { 349 | t.Errorf("did not expect a Content-Type header, got %s", sling.header.Get(contentType)) 350 | } 351 | } 352 | } 353 | 354 | func TestBodyFormSetter(t *testing.T) { 355 | fakeParams := FakeParams{KindName: "recent", Count: 25} 356 | fakeBodyProvider := formBodyProvider{payload: fakeParams} 357 | 358 | cases := []struct { 359 | initial BodyProvider 360 | input interface{} 361 | expected BodyProvider 362 | }{ 363 | // url tagged struct is set as bodyStruct 364 | {nil, paramsB, fakeBodyProvider}, 365 | // nil argument to bodyStruct does not replace existing bodyStruct 366 | {fakeBodyProvider, nil, fakeBodyProvider}, 367 | // nil bodyStruct remains nil 368 | {nil, nil, nil}, 369 | } 370 | for _, c := range cases { 371 | sling := New() 372 | sling.bodyProvider = c.initial 373 | sling.BodyForm(c.input) 374 | if sling.bodyProvider != c.expected { 375 | t.Errorf("expected %v, got %v", c.expected, sling.bodyProvider) 376 | } 377 | // Content-Type should be application/x-www-form-urlencoded if bodyStruct was non-nil 378 | if c.input != nil && sling.header.Get(contentType) != formContentType { 379 | t.Errorf("Incorrect or missing header, expected %s, got %s", formContentType, sling.header.Get(contentType)) 380 | } else if c.input == nil && sling.header.Get(contentType) != "" { 381 | t.Errorf("did not expect a Content-Type header, got %s", sling.header.Get(contentType)) 382 | } 383 | } 384 | } 385 | 386 | func TestBodySetter(t *testing.T) { 387 | fakeInput := io.NopCloser(strings.NewReader("test")) 388 | fakeBodyProvider := bodyProvider{body: fakeInput} 389 | 390 | cases := []struct { 391 | initial BodyProvider 392 | input io.Reader 393 | expected BodyProvider 394 | }{ 395 | // nil body is overriden by a set body 396 | {nil, fakeInput, fakeBodyProvider}, 397 | // initial body is not overriden by nil body 398 | {fakeBodyProvider, nil, fakeBodyProvider}, 399 | // nil body is returned unaltered 400 | {nil, nil, nil}, 401 | } 402 | for _, c := range cases { 403 | sling := New() 404 | sling.bodyProvider = c.initial 405 | sling.Body(c.input) 406 | if sling.bodyProvider != c.expected { 407 | t.Errorf("expected %v, got %v", c.expected, sling.bodyProvider) 408 | } 409 | } 410 | } 411 | 412 | func TestRequest_urlAndMethod(t *testing.T) { 413 | cases := []struct { 414 | sling *Sling 415 | expectedMethod string 416 | expectedURL string 417 | expectedErr error 418 | }{ 419 | {New().Base("http://a.io"), "GET", "http://a.io", nil}, 420 | {New().Path("http://a.io"), "GET", "http://a.io", nil}, 421 | {New().Get("http://a.io"), "GET", "http://a.io", nil}, 422 | {New().Put("http://a.io"), "PUT", "http://a.io", nil}, 423 | {New().Base("http://a.io/").Path("foo"), "GET", "http://a.io/foo", nil}, 424 | {New().Base("http://a.io/").Post("foo"), "POST", "http://a.io/foo", nil}, 425 | // if relative path is an absolute url, base is ignored 426 | {New().Base("http://a.io").Path("http://b.io"), "GET", "http://b.io", nil}, 427 | {New().Path("http://a.io").Path("http://b.io"), "GET", "http://b.io", nil}, 428 | // last method setter takes priority 429 | {New().Get("http://b.io").Post("http://a.io"), "POST", "http://a.io", nil}, 430 | {New().Post("http://a.io/").Put("foo/").Delete("bar"), "DELETE", "http://a.io/foo/bar", nil}, 431 | // last Base setter takes priority 432 | {New().Base("http://a.io").Base("http://b.io"), "GET", "http://b.io", nil}, 433 | // Path setters are additive 434 | {New().Base("http://a.io/").Path("foo/").Path("bar"), "GET", "http://a.io/foo/bar", nil}, 435 | {New().Path("http://a.io/").Path("foo/").Path("bar"), "GET", "http://a.io/foo/bar", nil}, 436 | // removes extra '/' between base and ref url 437 | {New().Base("http://a.io/").Get("/foo"), "GET", "http://a.io/foo", nil}, 438 | } 439 | for _, c := range cases { 440 | req, err := c.sling.Request() 441 | if err != c.expectedErr { 442 | t.Errorf("expected error %v, got %v for %+v", c.expectedErr, err, c.sling) 443 | } 444 | if req.URL.String() != c.expectedURL { 445 | t.Errorf("expected url %s, got %s for %+v", c.expectedURL, req.URL.String(), c.sling) 446 | } 447 | if req.Method != c.expectedMethod { 448 | t.Errorf("expected method %s, got %s for %+v", c.expectedMethod, req.Method, c.sling) 449 | } 450 | } 451 | } 452 | 453 | func TestRequest_queryStructs(t *testing.T) { 454 | cases := []struct { 455 | sling *Sling 456 | expectedURL string 457 | }{ 458 | {New().Base("http://a.io").QueryStruct(paramsA), "http://a.io?limit=30"}, 459 | {New().Base("http://a.io").QueryStruct(paramsA).QueryStruct(paramsB), "http://a.io?count=25&kind_name=recent&limit=30"}, 460 | {New().Base("http://a.io/").Path("foo?path=yes").QueryStruct(paramsA), "http://a.io/foo?limit=30&path=yes"}, 461 | {New().Base("http://a.io").QueryStruct(paramsA).New(), "http://a.io?limit=30"}, 462 | {New().Base("http://a.io").QueryStruct(paramsA).New().QueryStruct(paramsB), "http://a.io?count=25&kind_name=recent&limit=30"}, 463 | } 464 | for _, c := range cases { 465 | req, _ := c.sling.Request() 466 | if req.URL.String() != c.expectedURL { 467 | t.Errorf("expected url %s, got %s for %+v", c.expectedURL, req.URL.String(), c.sling) 468 | } 469 | } 470 | } 471 | 472 | func TestRequest_body(t *testing.T) { 473 | cases := []struct { 474 | sling *Sling 475 | expectedBody string // expected Body io.Reader as a string 476 | expectedContentType string 477 | }{ 478 | // BodyJSON 479 | {New().BodyJSON(modelA), "{\"text\":\"note\",\"favorite_count\":12}\n", jsonContentType}, 480 | {New().BodyJSON(&modelA), "{\"text\":\"note\",\"favorite_count\":12}\n", jsonContentType}, 481 | {New().BodyJSON(&FakeModel{}), "{}\n", jsonContentType}, 482 | {New().BodyJSON(FakeModel{}), "{}\n", jsonContentType}, 483 | // BodyJSON overrides existing values 484 | {New().BodyJSON(&FakeModel{}).BodyJSON(&FakeModel{Text: "msg"}), "{\"text\":\"msg\"}\n", jsonContentType}, 485 | // BodyForm 486 | {New().BodyForm(paramsA), "limit=30", formContentType}, 487 | {New().BodyForm(paramsB), "count=25&kind_name=recent", formContentType}, 488 | {New().BodyForm(¶msB), "count=25&kind_name=recent", formContentType}, 489 | // BodyForm overrides existing values 490 | {New().BodyForm(paramsA).New().BodyForm(paramsB), "count=25&kind_name=recent", formContentType}, 491 | // Mixture of BodyJSON and BodyForm prefers body setter called last with a non-nil argument 492 | {New().BodyForm(paramsB).New().BodyJSON(modelA), "{\"text\":\"note\",\"favorite_count\":12}\n", jsonContentType}, 493 | {New().BodyJSON(modelA).New().BodyForm(paramsB), "count=25&kind_name=recent", formContentType}, 494 | {New().BodyForm(paramsB).New().BodyJSON(nil), "count=25&kind_name=recent", formContentType}, 495 | {New().BodyJSON(modelA).New().BodyForm(nil), "{\"text\":\"note\",\"favorite_count\":12}\n", jsonContentType}, 496 | // Body 497 | {New().Body(strings.NewReader("this-is-a-test")), "this-is-a-test", ""}, 498 | {New().Body(strings.NewReader("a")).Body(strings.NewReader("b")), "b", ""}, 499 | } 500 | for _, c := range cases { 501 | req, _ := c.sling.Request() 502 | buf := new(bytes.Buffer) 503 | buf.ReadFrom(req.Body) 504 | // req.Body should have contained the expectedBody string 505 | if value := buf.String(); value != c.expectedBody { 506 | t.Errorf("expected Request.Body %s, got %s", c.expectedBody, value) 507 | } 508 | // Header Content-Type should be expectedContentType ("" means no contentType expected) 509 | if actualHeader := req.Header.Get(contentType); actualHeader != c.expectedContentType && c.expectedContentType != "" { 510 | t.Errorf("Incorrect or missing header, expected %s, got %s", c.expectedContentType, actualHeader) 511 | } 512 | } 513 | } 514 | 515 | func TestRequest_bodyNoData(t *testing.T) { 516 | // test that Body is left nil when no bodyJSON or bodyStruct set 517 | slings := []*Sling{ 518 | New(), 519 | New().BodyJSON(nil), 520 | New().BodyForm(nil), 521 | } 522 | for _, sling := range slings { 523 | req, _ := sling.Request() 524 | if req.Body != nil { 525 | t.Errorf("expected nil Request.Body, got %v", req.Body) 526 | } 527 | // Header Content-Type should not be set when bodyJSON argument was nil or never called 528 | if actualHeader := req.Header.Get(contentType); actualHeader != "" { 529 | t.Errorf("did not expect a Content-Type header, got %s", actualHeader) 530 | } 531 | } 532 | } 533 | 534 | func TestRequest_bodyEncodeErrors(t *testing.T) { 535 | cases := []struct { 536 | sling *Sling 537 | expectedErr error 538 | }{ 539 | // check that Encode errors are propagated, illegal JSON field 540 | {New().BodyJSON(FakeModel{Temperature: math.Inf(1)}), errors.New("json: unsupported value: +Inf")}, 541 | } 542 | for _, c := range cases { 543 | req, err := c.sling.Request() 544 | if err == nil || err.Error() != c.expectedErr.Error() { 545 | t.Errorf("expected error %v, got %v", c.expectedErr, err) 546 | } 547 | if req != nil { 548 | t.Errorf("expected nil Request, got %+v", req) 549 | } 550 | } 551 | } 552 | 553 | func TestRequest_headers(t *testing.T) { 554 | cases := []struct { 555 | sling *Sling 556 | expectedHeader map[string][]string 557 | }{ 558 | {New().Add("authorization", "OAuth key=\"value\""), map[string][]string{"Authorization": []string{"OAuth key=\"value\""}}}, 559 | // header keys should be canonicalized 560 | {New().Add("content-tYPE", "application/json").Add("User-AGENT", "sling"), map[string][]string{"Content-Type": []string{"application/json"}, "User-Agent": []string{"sling"}}}, 561 | // values for existing keys should be appended 562 | {New().Add("A", "B").Add("a", "c"), map[string][]string{"A": []string{"B", "c"}}}, 563 | // Add should add to values for keys added by parent Slings 564 | {New().Add("A", "B").Add("a", "c").New(), map[string][]string{"A": []string{"B", "c"}}}, 565 | {New().Add("A", "B").New().Add("a", "c"), map[string][]string{"A": []string{"B", "c"}}}, 566 | // Add and Set 567 | {New().Add("A", "B").Set("a", "c"), map[string][]string{"A": []string{"c"}}}, 568 | {New().Set("content-type", "A").Set("Content-Type", "B"), map[string][]string{"Content-Type": []string{"B"}}}, 569 | // Set should replace values received by copying parent Slings 570 | {New().Set("A", "B").Add("a", "c").New(), map[string][]string{"A": []string{"B", "c"}}}, 571 | {New().Add("A", "B").New().Set("a", "c"), map[string][]string{"A": []string{"c"}}}, 572 | } 573 | for _, c := range cases { 574 | req, _ := c.sling.Request() 575 | // type conversion from Header to alias'd map for deep equality comparison 576 | headerMap := map[string][]string(req.Header) 577 | if !reflect.DeepEqual(c.expectedHeader, headerMap) { 578 | t.Errorf("not DeepEqual: expected %v, got %v", c.expectedHeader, headerMap) 579 | } 580 | } 581 | } 582 | 583 | func TestAddQueryStructs(t *testing.T) { 584 | cases := []struct { 585 | rawurl string 586 | queryStructs []interface{} 587 | expected string 588 | }{ 589 | {"http://a.io", []interface{}{}, "http://a.io"}, 590 | {"http://a.io", []interface{}{paramsA}, "http://a.io?limit=30"}, 591 | {"http://a.io", []interface{}{paramsA, paramsA}, "http://a.io?limit=30&limit=30"}, 592 | {"http://a.io", []interface{}{paramsA, paramsB}, "http://a.io?count=25&kind_name=recent&limit=30"}, 593 | // don't blow away query values on the rawURL (parsed into RawQuery) 594 | {"http://a.io?initial=7", []interface{}{paramsA}, "http://a.io?initial=7&limit=30"}, 595 | } 596 | for _, c := range cases { 597 | reqURL, _ := url.Parse(c.rawurl) 598 | addQueryStructs(reqURL, c.queryStructs) 599 | if reqURL.String() != c.expected { 600 | t.Errorf("expected %s, got %s", c.expected, reqURL.String()) 601 | } 602 | } 603 | } 604 | 605 | // Sending 606 | 607 | type APIError struct { 608 | Message string `json:"message"` 609 | Code int `json:"code"` 610 | } 611 | 612 | func TestDo_onSuccess(t *testing.T) { 613 | const expectedText = "Some text" 614 | const expectedFavoriteCount int64 = 24 615 | 616 | client, mux, server := testServer() 617 | defer server.Close() 618 | mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) { 619 | w.Header().Set("Content-Type", "application/json") 620 | fmt.Fprintf(w, `{"text": "Some text", "favorite_count": 24}`) 621 | }) 622 | 623 | sling := New().Client(client) 624 | req, _ := http.NewRequest("GET", "http://example.com/success", nil) 625 | 626 | model := new(FakeModel) 627 | apiError := new(APIError) 628 | resp, err := sling.Do(req, model, apiError) 629 | 630 | if err != nil { 631 | t.Errorf("expected nil, got %v", err) 632 | } 633 | if resp.StatusCode != 200 { 634 | t.Errorf("expected %d, got %d", 200, resp.StatusCode) 635 | } 636 | if model.Text != expectedText { 637 | t.Errorf("expected %s, got %s", expectedText, model.Text) 638 | } 639 | if model.FavoriteCount != expectedFavoriteCount { 640 | t.Errorf("expected %d, got %d", expectedFavoriteCount, model.FavoriteCount) 641 | } 642 | } 643 | 644 | func TestDo_onSuccessWithNilValue(t *testing.T) { 645 | client, mux, server := testServer() 646 | defer server.Close() 647 | mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) { 648 | w.Header().Set("Content-Type", "application/json") 649 | fmt.Fprintf(w, `{"text": "Some text", "favorite_count": 24}`) 650 | }) 651 | 652 | sling := New().Client(client) 653 | req, _ := http.NewRequest("GET", "http://example.com/success", nil) 654 | 655 | apiError := new(APIError) 656 | resp, err := sling.Do(req, nil, apiError) 657 | 658 | if err != nil { 659 | t.Errorf("expected nil, got %v", err) 660 | } 661 | if resp.StatusCode != 200 { 662 | t.Errorf("expected %d, got %d", 200, resp.StatusCode) 663 | } 664 | expected := &APIError{} 665 | if !reflect.DeepEqual(expected, apiError) { 666 | t.Errorf("failureV should not be populated, exepcted %v, got %v", expected, apiError) 667 | } 668 | } 669 | 670 | func TestDo_noContent(t *testing.T) { 671 | client, mux, server := testServer() 672 | defer server.Close() 673 | mux.HandleFunc("/nocontent", func(w http.ResponseWriter, r *http.Request) { 674 | w.WriteHeader(204) 675 | }) 676 | 677 | sling := New().Client(client) 678 | req, _ := http.NewRequest("DELETE", "http://example.com/nocontent", nil) 679 | 680 | model := new(FakeModel) 681 | apiError := new(APIError) 682 | resp, err := sling.Do(req, model, apiError) 683 | 684 | if err != nil { 685 | t.Errorf("expected nil, got %v", err) 686 | } 687 | if resp.StatusCode != 204 { 688 | t.Errorf("expected %d, got %d", 204, resp.StatusCode) 689 | } 690 | expectedModel := &FakeModel{} 691 | if !reflect.DeepEqual(expectedModel, model) { 692 | t.Errorf("successV should not be populated, exepcted %v, got %v", expectedModel, model) 693 | } 694 | expectedAPIError := &APIError{} 695 | if !reflect.DeepEqual(expectedAPIError, apiError) { 696 | t.Errorf("failureV should not be populated, exepcted %v, got %v", expectedAPIError, apiError) 697 | } 698 | } 699 | 700 | func TestDo_onFailure(t *testing.T) { 701 | const expectedMessage = "Invalid argument" 702 | const expectedCode int = 215 703 | 704 | client, mux, server := testServer() 705 | defer server.Close() 706 | mux.HandleFunc("/failure", func(w http.ResponseWriter, r *http.Request) { 707 | w.Header().Set("Content-Type", "application/json") 708 | w.WriteHeader(400) 709 | fmt.Fprintf(w, `{"message": "Invalid argument", "code": 215}`) 710 | }) 711 | 712 | sling := New().Client(client) 713 | req, _ := http.NewRequest("GET", "http://example.com/failure", nil) 714 | 715 | model := new(FakeModel) 716 | apiError := new(APIError) 717 | resp, err := sling.Do(req, model, apiError) 718 | 719 | if err != nil { 720 | t.Errorf("expected nil, got %v", err) 721 | } 722 | if resp.StatusCode != 400 { 723 | t.Errorf("expected %d, got %d", 400, resp.StatusCode) 724 | } 725 | if apiError.Message != expectedMessage { 726 | t.Errorf("expected %s, got %s", expectedMessage, apiError.Message) 727 | } 728 | if apiError.Code != expectedCode { 729 | t.Errorf("expected %d, got %d", expectedCode, apiError.Code) 730 | } 731 | } 732 | 733 | func TestDo_onFailureWithNilValue(t *testing.T) { 734 | client, mux, server := testServer() 735 | defer server.Close() 736 | mux.HandleFunc("/failure", func(w http.ResponseWriter, r *http.Request) { 737 | w.Header().Set("Content-Type", "application/json") 738 | w.WriteHeader(420) 739 | fmt.Fprintf(w, `{"message": "Enhance your calm", "code": 88}`) 740 | }) 741 | 742 | sling := New().Client(client) 743 | req, _ := http.NewRequest("GET", "http://example.com/failure", nil) 744 | 745 | model := new(FakeModel) 746 | resp, err := sling.Do(req, model, nil) 747 | 748 | if err != nil { 749 | t.Errorf("expected nil, got %v", err) 750 | } 751 | if resp.StatusCode != 420 { 752 | t.Errorf("expected %d, got %d", 420, resp.StatusCode) 753 | } 754 | expected := &FakeModel{} 755 | if !reflect.DeepEqual(expected, model) { 756 | t.Errorf("successV should not be populated, exepcted %v, got %v", expected, model) 757 | } 758 | } 759 | 760 | func TestReceive_success_nonDefaultDecoder(t *testing.T) { 761 | client, mux, server := testServer() 762 | defer server.Close() 763 | mux.HandleFunc("/foo/submit", func(w http.ResponseWriter, r *http.Request) { 764 | w.Header().Set("Content-Type", "application/xml") 765 | data := ` 766 | Some text 767 | 24 768 | 10.5 769 | ` 770 | fmt.Fprintf(w, xml.Header) 771 | fmt.Fprint(w, data) 772 | }) 773 | 774 | endpoint := New().Client(client).Base("http://example.com/").Path("foo/").Post("submit") 775 | 776 | model := new(FakeModel) 777 | apiError := new(APIError) 778 | resp, err := endpoint.New().ResponseDecoder(xmlResponseDecoder{}).Receive(model, apiError) 779 | 780 | if err != nil { 781 | t.Errorf("expected nil, got %v", err) 782 | } 783 | if resp.StatusCode != 200 { 784 | t.Errorf("expected %d, got %d", 200, resp.StatusCode) 785 | } 786 | expectedModel := &FakeModel{Text: "Some text", FavoriteCount: 24, Temperature: 10.5} 787 | if !reflect.DeepEqual(expectedModel, model) { 788 | t.Errorf("expected %v, got %v", expectedModel, model) 789 | } 790 | expectedAPIError := &APIError{} 791 | if !reflect.DeepEqual(expectedAPIError, apiError) { 792 | t.Errorf("failureV should be zero valued, exepcted %v, got %v", expectedAPIError, apiError) 793 | } 794 | } 795 | 796 | func TestReceive_success(t *testing.T) { 797 | client, mux, server := testServer() 798 | defer server.Close() 799 | mux.HandleFunc("/foo/submit", func(w http.ResponseWriter, r *http.Request) { 800 | assertMethod(t, "POST", r) 801 | assertQuery(t, map[string]string{"kind_name": "vanilla", "count": "11"}, r) 802 | assertPostForm(t, map[string]string{"kind_name": "vanilla", "count": "11"}, r) 803 | w.Header().Set("Content-Type", "application/json") 804 | fmt.Fprintf(w, `{"text": "Some text", "favorite_count": 24}`) 805 | }) 806 | 807 | endpoint := New().Client(client).Base("http://example.com/").Path("foo/").Post("submit") 808 | // encode url-tagged struct in query params and as post body for testing purposes 809 | params := FakeParams{KindName: "vanilla", Count: 11} 810 | model := new(FakeModel) 811 | apiError := new(APIError) 812 | resp, err := endpoint.New().QueryStruct(params).BodyForm(params).Receive(model, apiError) 813 | 814 | if err != nil { 815 | t.Errorf("expected nil, got %v", err) 816 | } 817 | if resp.StatusCode != 200 { 818 | t.Errorf("expected %d, got %d", 200, resp.StatusCode) 819 | } 820 | expectedModel := &FakeModel{Text: "Some text", FavoriteCount: 24} 821 | if !reflect.DeepEqual(expectedModel, model) { 822 | t.Errorf("expected %v, got %v", expectedModel, model) 823 | } 824 | expectedAPIError := &APIError{} 825 | if !reflect.DeepEqual(expectedAPIError, apiError) { 826 | t.Errorf("failureV should be zero valued, exepcted %v, got %v", expectedAPIError, apiError) 827 | } 828 | } 829 | 830 | func TestReceive_StatusOKNoContent(t *testing.T) { 831 | client, mux, server := testServer() 832 | defer server.Close() 833 | mux.HandleFunc("/foo/submit", func(w http.ResponseWriter, r *http.Request) { 834 | assertMethod(t, "POST", r) 835 | w.WriteHeader(201) 836 | w.Header().Set("Location", "/foo/latest") 837 | }) 838 | 839 | endpoint := New().Client(client).Base("http://example.com/").Path("foo/").Post("submit") 840 | // fake a post response for testing purposes, checking that it's valid happens in other tests 841 | params := FakeParams{} 842 | model := new(FakeModel) 843 | apiError := new(APIError) 844 | resp, err := endpoint.New().BodyForm(params).Receive(model, apiError) 845 | 846 | if err != nil { 847 | t.Errorf("expected nil, got %v", err) 848 | } 849 | if resp.StatusCode != 201 { 850 | t.Errorf("expected %d, got %d", 201, resp.StatusCode) 851 | } 852 | expectedModel := &FakeModel{} 853 | if !reflect.DeepEqual(expectedModel, model) { 854 | t.Errorf("expected %v, got %v", expectedModel, model) 855 | } 856 | expectedAPIError := &APIError{} 857 | if !reflect.DeepEqual(expectedAPIError, apiError) { 858 | t.Errorf("failureV should be zero valued, exepcted %v, got %v", expectedAPIError, apiError) 859 | } 860 | } 861 | 862 | func TestReceive_failure(t *testing.T) { 863 | client, mux, server := testServer() 864 | defer server.Close() 865 | mux.HandleFunc("/foo/submit", func(w http.ResponseWriter, r *http.Request) { 866 | assertMethod(t, "POST", r) 867 | assertQuery(t, map[string]string{"kind_name": "vanilla", "count": "11"}, r) 868 | assertPostForm(t, map[string]string{"kind_name": "vanilla", "count": "11"}, r) 869 | w.Header().Set("Content-Type", "application/json") 870 | w.WriteHeader(429) 871 | fmt.Fprintf(w, `{"message": "Rate limit exceeded", "code": 88}`) 872 | }) 873 | 874 | endpoint := New().Client(client).Base("http://example.com/").Path("foo/").Post("submit") 875 | // encode url-tagged struct in query params and as post body for testing purposes 876 | params := FakeParams{KindName: "vanilla", Count: 11} 877 | model := new(FakeModel) 878 | apiError := new(APIError) 879 | resp, err := endpoint.New().QueryStruct(params).BodyForm(params).Receive(model, apiError) 880 | 881 | if err != nil { 882 | t.Errorf("expected nil, got %v", err) 883 | } 884 | if resp.StatusCode != 429 { 885 | t.Errorf("expected %d, got %d", 429, resp.StatusCode) 886 | } 887 | expectedAPIError := &APIError{Message: "Rate limit exceeded", Code: 88} 888 | if !reflect.DeepEqual(expectedAPIError, apiError) { 889 | t.Errorf("expected %v, got %v", expectedAPIError, apiError) 890 | } 891 | expectedModel := &FakeModel{} 892 | if !reflect.DeepEqual(expectedModel, model) { 893 | t.Errorf("successV should not be zero valued, expected %v, got %v", expectedModel, model) 894 | } 895 | } 896 | 897 | func TestReceive_noContent(t *testing.T) { 898 | client, mux, server := testServer() 899 | defer server.Close() 900 | mux.HandleFunc("/foo/submit", func(w http.ResponseWriter, r *http.Request) { 901 | assertMethod(t, "HEAD", r) 902 | w.WriteHeader(204) 903 | }) 904 | 905 | endpoint := New().Client(client).Base("http://example.com/").Path("foo/").Head("submit") 906 | resp, err := endpoint.New().Receive(nil, nil) 907 | 908 | if err != nil { 909 | t.Errorf("expected nil, got %v", err) 910 | } 911 | if resp.StatusCode != 204 { 912 | t.Errorf("expected %d, got %d", 204, resp.StatusCode) 913 | } 914 | } 915 | 916 | func TestReceive_errorCreatingRequest(t *testing.T) { 917 | expectedErr := errors.New("json: unsupported value: +Inf") 918 | resp, err := New().BodyJSON(FakeModel{Temperature: math.Inf(1)}).Receive(nil, nil) 919 | if err == nil || err.Error() != expectedErr.Error() { 920 | t.Errorf("expected %v, got %v", expectedErr, err) 921 | } 922 | if resp != nil { 923 | t.Errorf("expected nil resp, got %v", resp) 924 | } 925 | } 926 | 927 | func TestReuseTcpConnections(t *testing.T) { 928 | var connCount int32 929 | 930 | ln, _ := net.Listen("tcp", ":0") 931 | rawURL := fmt.Sprintf("http://%s/", ln.Addr()) 932 | 933 | server := http.Server{ 934 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 935 | assertMethod(t, "GET", r) 936 | fmt.Fprintf(w, `{"text": "Some text"}`) 937 | }), 938 | ConnState: func(conn net.Conn, state http.ConnState) { 939 | if state == http.StateNew { 940 | atomic.AddInt32(&connCount, 1) 941 | } 942 | }, 943 | } 944 | 945 | go server.Serve(ln) 946 | 947 | endpoint := New().Client(http.DefaultClient).Base(rawURL).Path("foo/").Get("get") 948 | 949 | for i := 0; i < 10; i++ { 950 | resp, err := endpoint.New().Receive(nil, nil) 951 | if err != nil { 952 | t.Errorf("expected nil, got %v", err) 953 | } 954 | if resp.StatusCode != 200 { 955 | t.Errorf("expected %d, got %d", 200, resp.StatusCode) 956 | } 957 | } 958 | 959 | server.Shutdown(context.Background()) 960 | 961 | if count := atomic.LoadInt32(&connCount); count != 1 { 962 | t.Errorf("expected 1, got %v", count) 963 | } 964 | } 965 | 966 | // Testing Utils 967 | 968 | // testServer returns an http Client, ServeMux, and Server. The client proxies 969 | // requests to the server and handlers can be registered on the mux to handle 970 | // requests. The caller must close the test server. 971 | func testServer() (*http.Client, *http.ServeMux, *httptest.Server) { 972 | mux := http.NewServeMux() 973 | server := httptest.NewServer(mux) 974 | transport := &http.Transport{ 975 | Proxy: func(req *http.Request) (*url.URL, error) { 976 | return url.Parse(server.URL) 977 | }, 978 | } 979 | client := &http.Client{Transport: transport} 980 | return client, mux, server 981 | } 982 | 983 | func assertMethod(t *testing.T, expectedMethod string, req *http.Request) { 984 | if actualMethod := req.Method; actualMethod != expectedMethod { 985 | t.Errorf("expected method %s, got %s", expectedMethod, actualMethod) 986 | } 987 | } 988 | 989 | // assertQuery tests that the Request has the expected url query key/val pairs 990 | func assertQuery(t *testing.T, expected map[string]string, req *http.Request) { 991 | queryValues := req.URL.Query() // net/url Values is a map[string][]string 992 | expectedValues := url.Values{} 993 | for key, value := range expected { 994 | expectedValues.Add(key, value) 995 | } 996 | if !reflect.DeepEqual(expectedValues, queryValues) { 997 | t.Errorf("expected parameters %v, got %v", expected, req.URL.RawQuery) 998 | } 999 | } 1000 | 1001 | // assertPostForm tests that the Request has the expected key values pairs url 1002 | // encoded in its Body 1003 | func assertPostForm(t *testing.T, expected map[string]string, req *http.Request) { 1004 | req.ParseForm() // parses request Body to put url.Values in r.Form/r.PostForm 1005 | expectedValues := url.Values{} 1006 | for key, value := range expected { 1007 | expectedValues.Add(key, value) 1008 | } 1009 | if !reflect.DeepEqual(expectedValues, req.PostForm) { 1010 | t.Errorf("expected parameters %v, got %v", expected, req.PostForm) 1011 | } 1012 | } 1013 | --------------------------------------------------------------------------------