Skip to content

Commit

Permalink
feat: add the ability to post a JSON payload to the Twilio API's
Browse files Browse the repository at this point in the history
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
  • Loading branch information
RJPearson94 committed Jul 9, 2021
1 parent 5fd90d0 commit 3d97366
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 17 deletions.
3 changes: 1 addition & 2 deletions client/base_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ package client

import (
"net/http"
"net/url"
"time"
)

type BaseClient interface {
AccountSid() string
SetTimeout(timeout time.Duration)
SendRequest(method string, rawURL string, data url.Values,
SendRequest(method string, rawURL string, data interface{},
headers map[string]interface{}) (*http.Response, error)
}
51 changes: 44 additions & 7 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
package client

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"reflect"
"regexp"
"runtime"
"strconv"
Expand Down Expand Up @@ -85,15 +88,20 @@ func (c *Client) doWithErr(req *http.Request) (*http.Response, error) {
return res, nil
}

const (
contentTypeHeader = "Content-Type"
jsonContentType = "application/json"
formContentType = "application/x-www-form-urlencoded"
)

// SendRequest verifies, constructs, and authorizes an HTTP request.
func (c *Client) SendRequest(method string, rawURL string, data url.Values,
func (c *Client) SendRequest(method string, rawURL string, data interface{},
headers map[string]interface{}) (*http.Response, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}

valueReader := &strings.Reader{}
goVersion := runtime.Version()

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

var valueReader io.Reader
if method == http.MethodPost {
valueReader = strings.NewReader(data.Encode())
if headers == nil || headers[contentTypeHeader] == nil {
return nil, fmt.Errorf("the '%s' header must be set on a POST request", contentTypeHeader)
}

requestBody, err := requestBodyToReader(headers[contentTypeHeader].(string), data)
if err != nil {
return nil, err
}
valueReader = requestBody
}

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

if method == http.MethodPost {
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
}

for k, v := range headers {
req.Header.Add(k, fmt.Sprint(v))
}

return c.doWithErr(req)
}

func requestBodyToReader(contentTypeHeaderValue string, data interface{}) (io.Reader, error) {
kind := reflect.ValueOf(data).Kind()

if contentTypeHeaderValue == formContentType {
if v, ok := data.(url.Values); ok {
return strings.NewReader(v.Encode()), nil
}
return nil, fmt.Errorf("expected data to be of type url.Values for '%s' but got %s", formContentType, kind)
}

if contentTypeHeaderValue == jsonContentType {
if kind == reflect.Struct || kind == reflect.Map || kind == reflect.Slice {
body, err := json.Marshal(data)
if err != nil {
return nil, err
}
return bytes.NewBuffer(body), nil
}
return nil, fmt.Errorf("expected data to be either a struct, map or slice for '%s' but got %s", jsonContentType, kind)
}

return nil, fmt.Errorf("%s is not a supported media type", contentTypeHeaderValue)
}

// SetAccountSid sets the Client's accountSid field
func (c *Client) SetAccountSid(sid string) {
c.accountSid = sid
Expand Down
126 changes: 120 additions & 6 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package client_test

import (
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

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

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

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

client := NewClient("user", "pass")
resp, _ := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
resp, _ := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
assert.Equal(t, 307, resp.StatusCode)
}

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

client := NewClient("user", "pass")
client.SetTimeout(10 * time.Microsecond)
_, err := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
_, err := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
assert.Error(t, err)
}

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

client := NewClient("user", "pass")
client.SetTimeout(10 * time.Second)
resp, err := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
resp, err := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
Expand All @@ -151,7 +153,119 @@ func TestClient_SetTimeoutCreatesClient(t *testing.T) {
Credentials: twilio.NewCredentials("user", "pass"),
}
client.SetTimeout(20 * time.Second)
resp, err := client.SendRequest("get", mockServer.URL, nil, nil) //nolint:bodyclose
resp, err := client.SendRequest("GET", mockServer.URL, nil, nil) //nolint:bodyclose
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}

func TestClient_RequestBodyShouldContainJson(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, request *http.Request) {
bytes, err := ioutil.ReadAll(request.Body)
assert.NoError(t, err)
assert.Equal(t, `{"account_sid":"ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","chat_service_instance_sid":"ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}`, string(bytes))

writer.WriteHeader(200)
}),
)
defer mockServer.Close()

client := &twilio.Client{
Credentials: twilio.NewCredentials("user", "pass"),
}

headers := map[string]interface{}{
"Content-Type": "application/json",
}
body := map[string]interface{}{
"account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"chat_service_instance_sid": "ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
}

resp, err := client.SendRequest("POST", mockServer.URL, body, headers) //nolint:bodyclose
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
// Request body is asserted in the mock server. This can't be asserted here because the body has been consumed and will be nil
}

func TestClient_RequestBodyShouldContainFormData(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, request *http.Request) {
bytes, err := ioutil.ReadAll(request.Body)
assert.NoError(t, err)
assert.Equal(t, "AccountSid=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&ChatServiceInstanceSid=ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", string(bytes))

writer.WriteHeader(200)
}),
)
defer mockServer.Close()

client := &twilio.Client{
Credentials: twilio.NewCredentials("user", "pass"),
}

headers := map[string]interface{}{
"Content-Type": "application/x-www-form-urlencoded",
}
body := url.Values{
"AccountSid": []string{"ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
"ChatServiceInstanceSid": []string{"ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
}

resp, err := client.SendRequest("POST", mockServer.URL, body, headers) //nolint:bodyclose
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
// Request body is asserted in the mock server. This can't be asserted here because the body has been consumed and will be nil
}

func TestClient_ShouldThrowErrorWhenNoContentTypeHeaderIsPresentOnAPostRequest(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(400) // This shouldn't be hit as an error should have already been returned
}),
)
defer mockServer.Close()

client := &twilio.Client{
Credentials: twilio.NewCredentials("user", "pass"),
}

headers := map[string]interface{}{}
body := url.Values{
"account_sid": []string{"ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
"chat_service_instance_sid": []string{"ISxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
}

_, err := client.SendRequest("POST", mockServer.URL, body, headers) //nolint:bodyclose
assert.EqualError(t, err, "the 'Content-Type' header must be set on a POST request")
}

func TestClient_ShouldMakeGetRequestWithQueryStringParams(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, request *http.Request) {
d := map[string]interface{}{
"sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"status": "closed",
"date_updated": "2021-06-01T12:00:00Z",
}
encoder := json.NewEncoder(writer)
err := encoder.Encode(&d)
if err != nil {
t.Error(err)
}
}))
defer mockServer.Close()

client := &twilio.Client{
Credentials: twilio.NewCredentials("user", "pass"),
}

queryParams := url.Values{
"status": []string{"closed"},
}

resp, err := client.SendRequest("GET", mockServer.URL, queryParams, nil) //nolint:bodyclose
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, resp.Request.URL.RawQuery, "status=closed")
}
14 changes: 12 additions & 2 deletions client/request_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func NewRequestHandler(client BaseClient) *RequestHandler {
}
}

func (c *RequestHandler) sendRequest(method string, rawURL string, data url.Values,
func (c *RequestHandler) sendRequest(method string, rawURL string, data interface{},
headers map[string]interface{}) (*http.Response, error) {
return c.Client.SendRequest(method, c.BuildUrl(rawURL), data, headers)
}
Expand Down Expand Up @@ -76,7 +76,17 @@ func (c *RequestHandler) BuildUrl(rawURL string) string {
}

func (c *RequestHandler) Post(path string, bodyData url.Values, headers map[string]interface{}) (*http.Response, error) {
return c.sendRequest(http.MethodPost, path, bodyData, headers)
requestHeaders := headers
requestHeaders[contentTypeHeader] = formContentType

return c.sendRequest(http.MethodPost, path, bodyData, requestHeaders)
}

func (c *RequestHandler) PostJson(path string, bodyData interface{}, headers map[string]interface{}) (*http.Response, error) {
requestHeaders := headers
requestHeaders[contentTypeHeader] = jsonContentType

return c.sendRequest(http.MethodPost, path, bodyData, requestHeaders)
}

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

0 comments on commit 3d97366

Please sign in to comment.