diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2208ae5..aadb897 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: timeout-minutes: 20 strategy: matrix: - go: [ '1.14', '1.15', '1.16', '1.17' ] + go: [ '1.14', '1.15', '1.16', '1.17', '1.18', '1.19' ] steps: - name: Checkout rest uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 60ef913..28aec86 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ _testmain.go .settings.json temp.go +.vscode diff --git a/README.md b/README.md index 8cfbb01..32b70e1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ This library supports the following Go implementations: * Go 1.15 * Go 1.16 * Go 1.17 +* Go 1.18 +* Go 1.19 ## Install Package diff --git a/docker/example.go b/docker/example.go index 8f4df54..4470bf3 100644 --- a/docker/example.go +++ b/docker/example.go @@ -1,24 +1,27 @@ package main -import "github.com/sendgrid/rest" -import "fmt" +import ( + "fmt" + + "github.com/sendgrid/rest" +) func main() { - const host = "https://httpbin.org" - param := "get" - endpoint := "/" + param - baseURL := host + endpoint - method := rest.Get - request := rest.Request{ - Method: method, - BaseURL: baseURL, - } - response, err := rest.Send(request) - if err != nil { - fmt.Println(err) - } else { - fmt.Println(response.StatusCode) - fmt.Println(response.Body) - fmt.Println(response.Headers) - } + const host = "https://httpbin.org" + param := "get" + endpoint := "/" + param + baseURL := host + endpoint + method := rest.Get + request := rest.Request{ + Method: method, + BaseURL: baseURL, + } + response, err := rest.Send(request) + if err != nil { + fmt.Println(err) + } else { + fmt.Println(response.StatusCode) + fmt.Println(response.Body) + fmt.Println(response.Headers) + } } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..845acb7 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/sendgrid/rest + +go 1.14 + +require golang.org/x/net v0.0.0-20220708220712-1185a9018129 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..64cf5c2 --- /dev/null +++ b/go.sum @@ -0,0 +1,7 @@ +golang.org/x/net v0.0.0-20220708220712-1185a9018129 h1:vucSRfWwTsoXro7P+3Cjlr6flUMtzCwzlvkxEQtHHB0= +golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/rest.go b/rest.go index b3e578c..7aab0ff 100644 --- a/rest.go +++ b/rest.go @@ -4,7 +4,7 @@ package rest import ( "bytes" "context" - "io/ioutil" + "io" "net/http" "net/url" ) @@ -97,7 +97,7 @@ func MakeRequest(req *http.Request) (*http.Response, error) { // BuildResponse builds the response struct. func BuildResponse(res *http.Response) (*Response, error) { - body, err := ioutil.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) response := Response{ StatusCode: res.StatusCode, Body: string(body), diff --git a/rest_test.go b/rest_test.go index 7ca4369..2a5aa80 100644 --- a/rest_test.go +++ b/rest_test.go @@ -3,7 +3,6 @@ package rest import ( "errors" "fmt" - "io/ioutil" "net/http" "net/http/httptest" "net/http/httputil" @@ -324,7 +323,7 @@ func TestRepoFiles(t *testing.T) { func TestLicenseYear(t *testing.T) { t.Parallel() - dat, err := ioutil.ReadFile("LICENSE") + dat, err := os.ReadFile("LICENSE") currentYear := time.Now().Year() r := fmt.Sprintf("%d", currentYear) diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 0000000..6c00642 --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,5 @@ +module github.com/sendgrid/rest/v2 + +go 1.14 + +require golang.org/x/net v0.0.0-20220708220712-1185a9018129 diff --git a/v2/go.sum b/v2/go.sum new file mode 100644 index 0000000..64cf5c2 --- /dev/null +++ b/v2/go.sum @@ -0,0 +1,7 @@ +golang.org/x/net v0.0.0-20220708220712-1185a9018129 h1:vucSRfWwTsoXro7P+3Cjlr6flUMtzCwzlvkxEQtHHB0= +golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/v2/rest.go b/v2/rest.go new file mode 100644 index 0000000..7aab0ff --- /dev/null +++ b/v2/rest.go @@ -0,0 +1,161 @@ +// Package rest allows for quick and easy access any REST or REST-like API. +package rest + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" +) + +// Version represents the current version of the rest library +const Version = "2.6.9" + +// Method contains the supported HTTP verbs. +type Method string + +// Supported HTTP verbs. +const ( + Get Method = "GET" + Post Method = "POST" + Put Method = "PUT" + Patch Method = "PATCH" + Delete Method = "DELETE" +) + +// Request holds the request to an API Call. +type Request struct { + Method Method + BaseURL string // e.g. https://api.sendgrid.com + Headers map[string]string + QueryParams map[string]string + Body []byte +} + +// RestError is a struct for an error handling. +type RestError struct { + Response *Response +} + +// Error is the implementation of the error interface. +func (e *RestError) Error() string { + return e.Response.Body +} + +// DefaultClient is used if no custom HTTP client is defined +var DefaultClient = &Client{HTTPClient: &http.Client{}} + +// Client allows modification of client headers, redirect policy +// and other settings +// See https://golang.org/pkg/net/http +type Client struct { + HTTPClient *http.Client +} + +// Response holds the response from an API call. +type Response struct { + StatusCode int // e.g. 200 + Body string // e.g. {"result: success"} + Headers map[string][]string // e.g. map[X-Ratelimit-Limit:[600]] +} + +// AddQueryParameters adds query parameters to the URL. +func AddQueryParameters(baseURL string, queryParams map[string]string) string { + baseURL += "?" + params := url.Values{} + for key, value := range queryParams { + params.Add(key, value) + } + return baseURL + params.Encode() +} + +// BuildRequestObject creates the HTTP request object. +func BuildRequestObject(request Request) (*http.Request, error) { + // Add any query parameters to the URL. + if len(request.QueryParams) != 0 { + request.BaseURL = AddQueryParameters(request.BaseURL, request.QueryParams) + } + req, err := http.NewRequest(string(request.Method), request.BaseURL, bytes.NewBuffer(request.Body)) + if err != nil { + return req, err + } + for key, value := range request.Headers { + req.Header.Set(key, value) + } + _, exists := req.Header["Content-Type"] + if len(request.Body) > 0 && !exists { + req.Header.Set("Content-Type", "application/json") + } + return req, err +} + +// MakeRequest makes the API call. +func MakeRequest(req *http.Request) (*http.Response, error) { + return DefaultClient.HTTPClient.Do(req) +} + +// BuildResponse builds the response struct. +func BuildResponse(res *http.Response) (*Response, error) { + body, err := io.ReadAll(res.Body) + response := Response{ + StatusCode: res.StatusCode, + Body: string(body), + Headers: res.Header, + } + res.Body.Close() // nolint + return &response, err +} + +// Deprecated: API supports old implementation +func API(request Request) (*Response, error) { + return Send(request) +} + +// Send uses the DefaultClient to send your request +func Send(request Request) (*Response, error) { + return SendWithContext(context.Background(), request) +} + +// SendWithContext uses the DefaultClient to send your request with the provided context. +func SendWithContext(ctx context.Context, request Request) (*Response, error) { + return DefaultClient.SendWithContext(ctx, request) +} + +// The following functions enable the ability to define a +// custom HTTP Client + +// MakeRequest makes the API call. +func (c *Client) MakeRequest(req *http.Request) (*http.Response, error) { + return c.HTTPClient.Do(req) +} + +// Deprecated: API supports old implementation +func (c *Client) API(request Request) (*Response, error) { + return c.Send(request) +} + +// Send will build your request, make the request, and build your response. +func (c *Client) Send(request Request) (*Response, error) { + return c.SendWithContext(context.Background(), request) +} + +// SendWithContext will build your request passing in the provided context, make the request, and build your response. +func (c *Client) SendWithContext(ctx context.Context, request Request) (*Response, error) { + // Build the HTTP request object. + req, err := BuildRequestObject(request) + if err != nil { + return nil, err + } + // Pass in the user provided context + req = req.WithContext(ctx) + + // Build the HTTP client and make the request. + res, err := c.MakeRequest(req) + if err != nil { + return nil, err + } + + // Build Response object. + return BuildResponse(res) +} diff --git a/v2/rest_test.go b/v2/rest_test.go new file mode 100644 index 0000000..535e098 --- /dev/null +++ b/v2/rest_test.go @@ -0,0 +1,364 @@ +package rest + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "os" + "regexp" + "strings" + "testing" + "time" + + "golang.org/x/net/context" +) + +func TestBuildURL(t *testing.T) { + t.Parallel() + host := "http://api.test.com" + queryParams := make(map[string]string) + queryParams["test"] = "1" + queryParams["test2"] = "2" + testURL := AddQueryParameters(host, queryParams) + if testURL != "http://api.test.com?test=1&test2=2" { + t.Error("Bad BuildURL result") + } +} + +func TestBuildRequest(t *testing.T) { + t.Parallel() + method := Get + baseURL := "http://api.test.com" + key := "API_KEY" + Headers := make(map[string]string) + Headers["Content-Type"] = "application/json" + Headers["Authorization"] = "Bearer " + key + queryParams := make(map[string]string) + queryParams["test"] = "1" + queryParams["test2"] = "2" + request := Request{ + Method: method, + BaseURL: baseURL, + Headers: Headers, + QueryParams: queryParams, + } + req, e := BuildRequestObject(request) + if e != nil { + t.Errorf("Rest failed to BuildRequest. Returned error: %v", e) + } + if req == nil { + t.Errorf("Failed to BuildRequest.") + } + + //Start PrintRequest + requestDump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Errorf("Error : %v", err) + } + fmt.Println("Request : ", string(requestDump)) + //End Print Request +} + +func TestBuildBadRequest(t *testing.T) { + t.Parallel() + request := Request{ + Method: Method("@"), + } + req, e := BuildRequestObject(request) + if e == nil { + t.Errorf("Expected an error for a bad HTTP Method") + } + if req != nil { + t.Errorf("If there's an error there shouldn't be a Request.") + } +} + +func TestBuildBadAPI(t *testing.T) { + t.Parallel() + request := Request{ + Method: Method("@"), + } + res, e := API(request) + if e == nil { + t.Errorf("Expected an error for a bad HTTP Method") + } + if res != nil { + t.Errorf("If there's an error there shouldn't be a Response.") + } +} + +func TestBuildResponse(t *testing.T) { + t.Parallel() + fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "{\"message\": \"success\"}") + })) + defer fakeServer.Close() + baseURL := fakeServer.URL + method := Get + request := Request{ + Method: method, + BaseURL: baseURL, + } + req, e := BuildRequestObject(request) + if e != nil { + t.Error("Failed to BuildRequestObject", e) + } + res, e := MakeRequest(req) + if e != nil { + t.Error("Failed to MakeRequest", e) + } + response, e := BuildResponse(res) + if response.StatusCode != 200 { + t.Error("Invalid status code in BuildResponse") + } + if len(response.Body) == 0 { + t.Error("Invalid response body in BuildResponse") + } + if len(response.Headers) == 0 { + t.Error("Invalid response headers in BuildResponse") + } + if e != nil { + t.Errorf("Rest failed to make a valid API request. Returned error: %v", e) + } + + //Start Print Request + requestDump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Errorf("Error : %v", err) + } + fmt.Println("Request :", string(requestDump)) + //End Print Request + +} + +type panicResponse struct{} + +func (*panicResponse) Read([]byte) (n int, err error) { + return 0, errors.New("test error") +} + +func (*panicResponse) Close() error { + return nil +} + +func TestBuildBadResponse(t *testing.T) { + t.Parallel() + res := &http.Response{ + Body: new(panicResponse), + } + _, e := BuildResponse(res) + if e == nil { + t.Errorf("This was a bad response and error should be returned") + } +} + +func TestRest(t *testing.T) { + t.Parallel() + testingAPI(t, Send) + testingAPI(t, API) +} + +func testingAPI(t *testing.T, fn func(request Request) (*Response, error)) { + fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "{\"message\": \"success\"}") + })) + defer fakeServer.Close() + + host := fakeServer.URL + endpoint := "/test_endpoint" + baseURL := host + endpoint + key := "API_KEY" + Headers := make(map[string]string) + Headers["Content-Type"] = "application/json" + Headers["Authorization"] = "Bearer " + key + method := Get + queryParams := make(map[string]string) + queryParams["test"] = "1" + queryParams["test2"] = "2" + request := Request{ + Method: method, + BaseURL: baseURL, + Headers: Headers, + QueryParams: queryParams, + } + + //Start Print Request + req, e := BuildRequestObject(request) + if e != nil { + t.Errorf("Error during BuildRequestObject: %v", e) + } + requestDump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Errorf("Error : %v", err) + } + fmt.Println("Request :", string(requestDump)) + //End Print Request + + response, e := fn(request) + + if response.StatusCode != 200 { + t.Error("Invalid status code") + } + if len(response.Body) == 0 { + t.Error("Invalid response body") + } + if len(response.Headers) == 0 { + t.Error("Invalid response headers") + } + if e != nil { + t.Errorf("Rest failed to make a valid API request. Returned error: %v", e) + } +} + +func TestDefaultContentTypeWithBody(t *testing.T) { + t.Parallel() + host := "http://localhost" + method := Get + request := Request{ + Method: method, + BaseURL: host, + Body: []byte("Hello World"), + } + + response, _ := BuildRequestObject(request) + if response.Header.Get("Content-Type") != "application/json" { + t.Error("Content-Type not set to the correct default value when a body is set.") + } + + //Start Print Request + fmt.Println("Request Body: ", string(request.Body)) + + requestDump, err := httputil.DumpRequest(response, true) + if err != nil { + t.Errorf("Error : %v", err) + } + fmt.Println("Request :", string(requestDump)) + //End Print Request +} + +func TestCustomContentType(t *testing.T) { + t.Parallel() + host := "http://localhost" + Headers := make(map[string]string) + Headers["Content-Type"] = "custom" + method := Get + request := Request{ + Method: method, + BaseURL: host, + Headers: Headers, + Body: []byte("Hello World"), + } + response, _ := BuildRequestObject(request) + if response.Header.Get("Content-Type") != "custom" { + t.Error("Content-Type not modified correctly") + } + + //Start Print Request + requestDump, err := httputil.DumpRequest(response, true) + if err != nil { + t.Errorf("Error : %v", err) + } + fmt.Println("Request :", string(requestDump)) + //End Print Request +} + +func TestCustomHTTPClient(t *testing.T) { + t.Parallel() + fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(time.Millisecond * 20) + fmt.Fprintln(w, "{\"message\": \"success\"}") + })) + defer fakeServer.Close() + host := fakeServer.URL + endpoint := "/test_endpoint" + baseURL := host + endpoint + method := Get + request := Request{ + Method: method, + BaseURL: baseURL, + } + + customClient := &Client{&http.Client{Timeout: time.Millisecond * 10}} + _, err := customClient.Send(request) + if err == nil { + t.Error("A timeout did not trigger as expected") + } + if !strings.Contains(err.Error(), "Client.Timeout exceeded while awaiting headers") { + t.Error("We did not receive the Timeout error") + } +} + +func TestRestError(t *testing.T) { + t.Parallel() + headers := make(map[string][]string) + headers["Content-Type"] = []string{"application/json"} + + response := &Response{ + StatusCode: 400, + Body: `{"result": "failure"}`, + Headers: headers, + } + + var err error = &RestError{Response: response} + + if err.Error() != `{"result": "failure"}` { + t.Error("Invalid error message.") + } +} + +func TestRepoFiles(t *testing.T) { + files := []string{"../.env_sample", "../.gitignore", "../.github/workflows/test.yml", "../CHANGELOG.md", + "../CODE_OF_CONDUCT.md", "../CONTRIBUTING.md", + "../LICENSE", "../PULL_REQUEST_TEMPLATE.md", "../README.md", + "../TROUBLESHOOTING.md", "../USAGE.md"} + + for _, file := range files { + if _, err := os.Stat(file); os.IsNotExist(err) { + t.Errorf("Repo file does not exist: %v", file) + } + } +} + +func TestLicenseYear(t *testing.T) { + t.Parallel() + dat, err := os.ReadFile("../LICENSE") + + currentYear := time.Now().Year() + r := fmt.Sprintf("%d", currentYear) + match, _ := regexp.MatchString(r, string(dat)) + + if err != nil { + t.Error("License File Not Found") + } + if !match { + t.Error("Incorrect Year in License Copyright") + } +} + +func TestSendWithContext(t *testing.T) { + t.Parallel() + fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(time.Millisecond * 20) + fmt.Fprintln(w, "{\"message\": \"success\"}") + })) + defer fakeServer.Close() + host := fakeServer.URL + endpoint := "/test_endpoint" + baseURL := host + endpoint + method := Get + request := Request{ + Method: method, + BaseURL: baseURL, + } + + ctx, _ := context.WithTimeout(context.Background(), time.Millisecond*10) + _, err := SendWithContext(ctx, request) + if err == nil { + t.Error("A timeout did not trigger as expected") + } + if !strings.Contains(err.Error(), "context deadline exceeded") { + t.Error("We did not receive the Timeout error") + } +}