Skip to content

Commit 3d97366

Browse files
committed
feat: add the ability to post a JSON payload to the Twilio API's
Updating the configuration of a Flex instance requires a POST request to be made with a JSON payload. This change lays the foundations to support this. The PostJson accepts an interface for the request body (data) instead of just a map as this prevents the computational overhead of having to marshal the struct to a slice of bytes then create a string from the bytes. The JSON string would then be unmarshalled to a map and then marshalled again to a slice of bytes
1 parent 5fd90d0 commit 3d97366

File tree

5 files changed

+274
-17
lines changed

5 files changed

+274
-17
lines changed

client/base_client.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ package client
22

33
import (
44
"net/http"
5-
"net/url"
65
"time"
76
)
87

98
type BaseClient interface {
109
AccountSid() string
1110
SetTimeout(timeout time.Duration)
12-
SendRequest(method string, rawURL string, data url.Values,
11+
SendRequest(method string, rawURL string, data interface{},
1312
headers map[string]interface{}) (*http.Response, error)
1413
}

client/client.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
package client
33

44
import (
5+
"bytes"
56
"encoding/json"
67
"fmt"
8+
"io"
79
"net/http"
810
"net/url"
11+
"reflect"
912
"regexp"
1013
"runtime"
1114
"strconv"
@@ -85,15 +88,20 @@ func (c *Client) doWithErr(req *http.Request) (*http.Response, error) {
8588
return res, nil
8689
}
8790

91+
const (
92+
contentTypeHeader = "Content-Type"
93+
jsonContentType = "application/json"
94+
formContentType = "application/x-www-form-urlencoded"
95+
)
96+
8897
// SendRequest verifies, constructs, and authorizes an HTTP request.
89-
func (c *Client) SendRequest(method string, rawURL string, data url.Values,
98+
func (c *Client) SendRequest(method string, rawURL string, data interface{},
9099
headers map[string]interface{}) (*http.Response, error) {
91100
u, err := url.Parse(rawURL)
92101
if err != nil {
93102
return nil, err
94103
}
95104

96-
valueReader := &strings.Reader{}
97105
goVersion := runtime.Version()
98106

99107
if method == http.MethodGet {
@@ -104,8 +112,17 @@ func (c *Client) SendRequest(method string, rawURL string, data url.Values,
104112
u.RawQuery = s
105113
}
106114

115+
var valueReader io.Reader
107116
if method == http.MethodPost {
108-
valueReader = strings.NewReader(data.Encode())
117+
if headers == nil || headers[contentTypeHeader] == nil {
118+
return nil, fmt.Errorf("the '%s' header must be set on a POST request", contentTypeHeader)
119+
}
120+
121+
requestBody, err := requestBodyToReader(headers[contentTypeHeader].(string), data)
122+
if err != nil {
123+
return nil, err
124+
}
125+
valueReader = requestBody
109126
}
110127

111128
req, err := http.NewRequest(method, u.String(), valueReader)
@@ -119,17 +136,37 @@ func (c *Client) SendRequest(method string, rawURL string, data url.Values,
119136
userAgent := fmt.Sprint("twilio-go/", LibraryVersion, " (", goVersion, ")")
120137
req.Header.Add("User-Agent", userAgent)
121138

122-
if method == http.MethodPost {
123-
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
124-
}
125-
126139
for k, v := range headers {
127140
req.Header.Add(k, fmt.Sprint(v))
128141
}
129142

130143
return c.doWithErr(req)
131144
}
132145

146+
func requestBodyToReader(contentTypeHeaderValue string, data interface{}) (io.Reader, error) {
147+
kind := reflect.ValueOf(data).Kind()
148+
149+
if contentTypeHeaderValue == formContentType {
150+
if v, ok := data.(url.Values); ok {
151+
return strings.NewReader(v.Encode()), nil
152+
}
153+
return nil, fmt.Errorf("expected data to be of type url.Values for '%s' but got %s", formContentType, kind)
154+
}
155+
156+
if contentTypeHeaderValue == jsonContentType {
157+
if kind == reflect.Struct || kind == reflect.Map || kind == reflect.Slice {
158+
body, err := json.Marshal(data)
159+
if err != nil {
160+
return nil, err
161+
}
162+
return bytes.NewBuffer(body), nil
163+
}
164+
return nil, fmt.Errorf("expected data to be either a struct, map or slice for '%s' but got %s", jsonContentType, kind)
165+
}
166+
167+
return nil, fmt.Errorf("%s is not a supported media type", contentTypeHeaderValue)
168+
}
169+
133170
// SetAccountSid sets the Client's accountSid field
134171
func (c *Client) SetAccountSid(sid string) {
135172
c.accountSid = sid

client/client_test.go

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package client_test
22

33
import (
44
"encoding/json"
5+
"io/ioutil"
56
"net/http"
67
"net/http/httptest"
8+
"net/url"
79
"testing"
810
"time"
911

@@ -35,7 +37,7 @@ func TestClient_SendRequestError(t *testing.T) {
3537
defer mockServer.Close()
3638

3739
client := NewClient("user", "pass")
38-
resp, err := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
40+
resp, err := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
3941
twilioError := err.(*twilio.TwilioRestError)
4042
assert.Nil(t, resp)
4143
assert.Equal(t, 400, twilioError.Status)
@@ -63,7 +65,7 @@ func TestClient_SendRequestErrorWithDetails(t *testing.T) {
6365
defer mockServer.Close()
6466

6567
client := NewClient("user", "pass")
66-
resp, err := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
68+
resp, err := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
6769
twilioError := err.(*twilio.TwilioRestError)
6870
details := make(map[string]interface{})
6971
details["foo"] = "bar"
@@ -84,7 +86,7 @@ func TestClient_SendRequestWithRedirect(t *testing.T) {
8486
defer mockServer.Close()
8587

8688
client := NewClient("user", "pass")
87-
resp, _ := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
89+
resp, _ := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
8890
assert.Equal(t, 307, resp.StatusCode)
8991
}
9092

@@ -106,7 +108,7 @@ func TestClient_SetTimeoutTimesOut(t *testing.T) {
106108

107109
client := NewClient("user", "pass")
108110
client.SetTimeout(10 * time.Microsecond)
109-
_, err := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
111+
_, err := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
110112
assert.Error(t, err)
111113
}
112114

@@ -127,7 +129,7 @@ func TestClient_SetTimeoutSucceeds(t *testing.T) {
127129

128130
client := NewClient("user", "pass")
129131
client.SetTimeout(10 * time.Second)
130-
resp, err := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
132+
resp, err := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
131133
assert.NoError(t, err)
132134
assert.Equal(t, 200, resp.StatusCode)
133135
}
@@ -151,7 +153,119 @@ func TestClient_SetTimeoutCreatesClient(t *testing.T) {
151153
Credentials: twilio.NewCredentials("user", "pass"),
152154
}
153155
client.SetTimeout(20 * time.Second)
154-
resp, err := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
156+
resp, err := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
155157
assert.NoError(t, err)
156158
assert.Equal(t, 200, resp.StatusCode)
157159
}
160+
161+
func TestClient_RequestBodyShouldContainJson(t *testing.T) {
162+
mockServer := httptest.NewServer(http.HandlerFunc(
163+
func(writer http.ResponseWriter, request *http.Request) {
164+
bytes, err := ioutil.ReadAll(request.Body)
165+
assert.NoError(t, err)
166+
assert.Equal(t, `{"account_sid":"ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","chat_service_instance_sid":"ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}`, string(bytes))
167+
168+
writer.WriteHeader(200)
169+
}),
170+
)
171+
defer mockServer.Close()
172+
173+
client := &twilio.Client{
174+
Credentials: twilio.NewCredentials("user", "pass"),
175+
}
176+
177+
headers := map[string]interface{}{
178+
"Content-Type": "application/json",
179+
}
180+
body := map[string]interface{}{
181+
"account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
182+
"chat_service_instance_sid": "ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
183+
}
184+
185+
resp, err := client.SendRequest("POST", mockServer.URL, body, headers) //nolint:bodyclose
186+
assert.NoError(t, err)
187+
assert.Equal(t, 200, resp.StatusCode)
188+
// Request body is asserted in the mock server. This can't be asserted here because the body has been consumed and will be nil
189+
}
190+
191+
func TestClient_RequestBodyShouldContainFormData(t *testing.T) {
192+
mockServer := httptest.NewServer(http.HandlerFunc(
193+
func(writer http.ResponseWriter, request *http.Request) {
194+
bytes, err := ioutil.ReadAll(request.Body)
195+
assert.NoError(t, err)
196+
assert.Equal(t, "AccountSid=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&ChatServiceInstanceSid=ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", string(bytes))
197+
198+
writer.WriteHeader(200)
199+
}),
200+
)
201+
defer mockServer.Close()
202+
203+
client := &twilio.Client{
204+
Credentials: twilio.NewCredentials("user", "pass"),
205+
}
206+
207+
headers := map[string]interface{}{
208+
"Content-Type": "application/x-www-form-urlencoded",
209+
}
210+
body := url.Values{
211+
"AccountSid": []string{"ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
212+
"ChatServiceInstanceSid": []string{"ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
213+
}
214+
215+
resp, err := client.SendRequest("POST", mockServer.URL, body, headers) //nolint:bodyclose
216+
assert.NoError(t, err)
217+
assert.Equal(t, 200, resp.StatusCode)
218+
// Request body is asserted in the mock server. This can't be asserted here because the body has been consumed and will be nil
219+
}
220+
221+
func TestClient_ShouldThrowErrorWhenNoContentTypeHeaderIsPresentOnAPostRequest(t *testing.T) {
222+
mockServer := httptest.NewServer(http.HandlerFunc(
223+
func(writer http.ResponseWriter, request *http.Request) {
224+
writer.WriteHeader(400) // This shouldn't be hit as an error should have already been returned
225+
}),
226+
)
227+
defer mockServer.Close()
228+
229+
client := &twilio.Client{
230+
Credentials: twilio.NewCredentials("user", "pass"),
231+
}
232+
233+
headers := map[string]interface{}{}
234+
body := url.Values{
235+
"account_sid": []string{"ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
236+
"chat_service_instance_sid": []string{"ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
237+
}
238+
239+
_, err := client.SendRequest("POST", mockServer.URL, body, headers) //nolint:bodyclose
240+
assert.EqualError(t, err, "the 'Content-Type' header must be set on a POST request")
241+
}
242+
243+
func TestClient_ShouldMakeGetRequestWithQueryStringParams(t *testing.T) {
244+
mockServer := httptest.NewServer(http.HandlerFunc(
245+
func(writer http.ResponseWriter, request *http.Request) {
246+
d := map[string]interface{}{
247+
"sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
248+
"status": "closed",
249+
"date_updated": "2021-06-01T12:00:00Z",
250+
}
251+
encoder := json.NewEncoder(writer)
252+
err := encoder.Encode(&d)
253+
if err != nil {
254+
t.Error(err)
255+
}
256+
}))
257+
defer mockServer.Close()
258+
259+
client := &twilio.Client{
260+
Credentials: twilio.NewCredentials("user", "pass"),
261+
}
262+
263+
queryParams := url.Values{
264+
"status": []string{"closed"},
265+
}
266+
267+
resp, err := client.SendRequest("GET", mockServer.URL, queryParams, nil) //nolint:bodyclose
268+
assert.NoError(t, err)
269+
assert.Equal(t, 200, resp.StatusCode)
270+
assert.Equal(t, resp.Request.URL.RawQuery, "status=closed")
271+
}

client/request_handler.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func NewRequestHandler(client BaseClient) *RequestHandler {
2222
}
2323
}
2424

25-
func (c *RequestHandler) sendRequest(method string, rawURL string, data url.Values,
25+
func (c *RequestHandler) sendRequest(method string, rawURL string, data interface{},
2626
headers map[string]interface{}) (*http.Response, error) {
2727
return c.Client.SendRequest(method, c.BuildUrl(rawURL), data, headers)
2828
}
@@ -76,7 +76,17 @@ func (c *RequestHandler) BuildUrl(rawURL string) string {
7676
}
7777

7878
func (c *RequestHandler) Post(path string, bodyData url.Values, headers map[string]interface{}) (*http.Response, error) {
79-
return c.sendRequest(http.MethodPost, path, bodyData, headers)
79+
requestHeaders := headers
80+
requestHeaders[contentTypeHeader] = formContentType
81+
82+
return c.sendRequest(http.MethodPost, path, bodyData, requestHeaders)
83+
}
84+
85+
func (c *RequestHandler) PostJson(path string, bodyData interface{}, headers map[string]interface{}) (*http.Response, error) {
86+
requestHeaders := headers
87+
requestHeaders[contentTypeHeader] = jsonContentType
88+
89+
return c.sendRequest(http.MethodPost, path, bodyData, requestHeaders)
8090
}
8191

8292
func (c *RequestHandler) Get(path string, queryData url.Values, headers map[string]interface{}) (*http.Response, error) {

0 commit comments

Comments
 (0)