Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add the ability to post a JSON payload to the Twilio API's #83

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -106,8 +114,17 @@ func (c *Client) SendRequest(method string, rawURL string, data url.Values,
}
}

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 @@ -121,17 +138,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
127 changes: 120 additions & 7 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

Expand Down Expand Up @@ -36,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 @@ -64,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 @@ -85,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 @@ -107,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 @@ -128,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 @@ -152,7 +153,7 @@ 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)
}
Expand All @@ -172,8 +173,120 @@ func TestClient_UnicodeResponse(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, 200, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, "{\"testing-unicode\":\"Ω≈ç√, 💩\"}\n", string(body))
}

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