diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..8a2cef9 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,46 @@ +name: Build + +'on': + push: + branches: + - main + +jobs: + Validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Go + uses: actions/setup-go@v2 + + - name: Setup Dependencies + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sudo sh -s -- -b $GOPATH/bin v1.24.0 + golangci-lint --version + go get golang.org/x/tools/cmd/cover + go get -t -v ./... + + - name: Golang CI Lint + run: golangci-lint run + + Test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Go + uses: actions/setup-go@v2 + + - name: Setup Dependencies + run: | + go get golang.org/x/tools/cmd/cover + go get -t -v ./... + + - name: Run Tests + run: go test -v -race -coverprofile=coverage.txt -covermode=atomic + + - name: Upload coverage to Codecov + run: bash <(curl -s https://codecov.io/bash) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6160f56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ +$path +.idea \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cdc540a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: +- repo: https://github.com/tekwizely/pre-commit-golang + rev: master + hooks: + - id: go-fumpt + - id: go-mod-tidy + - id: go-lint + - id: go-imports +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3d1c1bc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Ndole Studio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b1bbae --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# go-http-client + +[![Build](https://github.com/NdoleStudio/go-http-client/actions/workflows/main.yml/badge.svg)](https://github.com/NdoleStudio/go-http-client/actions/workflows/main.yml) +[![codecov](https://codecov.io/gh/NdoleStudio/go-http-client/branch/main/graph/badge.svg)](https://codecov.io/gh/NdoleStudio/go-http-client) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/NdoleStudio/go-http-client/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/NdoleStudio/go-http-client/?branch=main) +[![Go Report Card](https://goreportcard.com/badge/github.com/NdoleStudio/go-http-client)](https://goreportcard.com/report/github.com/NdoleStudio/go-http-client) +[![GitHub contributors](https://img.shields.io/github/contributors/NdoleStudio/go-http-client)](https://github.com/NdoleStudio/go-http-client/graphs/contributors) +[![GitHub license](https://img.shields.io/github/license/NdoleStudio/go-http-client?color=brightgreen)](https://github.com/NdoleStudio/go-http-client/blob/master/LICENSE) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/NdoleStudio/go-http-client)](https://pkg.go.dev/github.com/NdoleStudio/go-http-client) + + +This package provides a generic `go` client template for an HTTP API + +## Installation + +`go-http-client` is compatible with modern Go releases in module mode, with Go installed: + +```bash +go get github.com/NdoleStudio/go-http-client +``` + +Alternatively the same can be achieved if you use `import` in a package: + +```go +import "github.com/NdoleStudio/go-http-client" +``` + + +## Implemented + +- [Status Codes](#status-codes) + - `GET /200`: OK + +## Usage + +### Initializing the Client + +An instance of the client can be created using `New()`. + +```go +package main + +import ( + "github.com/NdoleStudio/go-http-client" +) + +func main() { + statusClient := client.New(client.WithDelay(200)) +} +``` + +### Error handling + +All API calls return an `error` as the last return object. All successful calls will return a `nil` error. + +```go +status, response, err := statusClient.Status.Ok(context.Background()) +if err != nil { + //handle error +} +``` + +### Status Codes + +#### `GET /200`: OK + +```go +status, response, err := statusClient.Status.Ok(context.Background()) + +if err != nil { + log.Fatal(err) +} + +log.Println(status.Description) // OK +``` + +## Testing + +You can run the unit tests for this client from the root directory using the command below: + +```bash +go test -v +``` + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details diff --git a/client.go b/client.go new file mode 100644 index 0000000..670a6d5 --- /dev/null +++ b/client.go @@ -0,0 +1,130 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "strconv" +) + +type service struct { + client *Client +} + +// Client is the campay API client. +// Do not instantiate this client with Client{}. Use the New method instead. +type Client struct { + httpClient *http.Client + common service + baseURL string + delay int + + Status *statusService +} + +// New creates and returns a new campay.Client from a slice of campay.ClientOption. +func New(options ...Option) *Client { + config := defaultClientConfig() + + for _, option := range options { + option.apply(config) + } + + client := &Client{ + httpClient: config.httpClient, + delay: config.delay, + baseURL: config.baseURL, + } + + client.common.client = client + client.Status = (*statusService)(&client.common) + return client +} + +// newRequest creates an API request. A relative URL can be provided in uri, +// in which case it is resolved relative to the BaseURL of the Client. +// URI's should always be specified without a preceding slash. +func (client *Client) newRequest(ctx context.Context, method, uri string, body interface{}) (*http.Request, error) { + var buf io.ReadWriter + if body != nil { + buf = &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + err := enc.Encode(body) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequestWithContext(ctx, method, client.baseURL+uri, buf) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + if client.delay > 0 { + client.addURLParams(req, map[string]string{"sleep": strconv.Itoa(client.delay)}) + } + + return req, nil +} + +// addURLParams adds urls parameters to an *http.Request +func (client *Client) addURLParams(request *http.Request, params map[string]string) *http.Request { + q := request.URL.Query() + for key, value := range params { + q.Add(key, value) + } + request.URL.RawQuery = q.Encode() + return request +} + +// do carries out an HTTP request and returns a Response +func (client *Client) do(req *http.Request) (*Response, error) { + if req == nil { + return nil, fmt.Errorf("%T cannot be nil", req) + } + + httpResponse, err := client.httpClient.Do(req) + if err != nil { + return nil, err + } + + defer func() { _ = httpResponse.Body.Close() }() + + resp, err := client.newResponse(httpResponse) + if err != nil { + return resp, err + } + + _, err = io.Copy(ioutil.Discard, httpResponse.Body) + if err != nil { + return resp, err + } + + return resp, nil +} + +// newResponse converts an *http.Response to *Response +func (client *Client) newResponse(httpResponse *http.Response) (*Response, error) { + if httpResponse == nil { + return nil, fmt.Errorf("%T cannot be nil", httpResponse) + } + + resp := new(Response) + resp.HTTPResponse = httpResponse + + buf, err := ioutil.ReadAll(resp.HTTPResponse.Body) + if err != nil { + return nil, err + } + resp.Body = &buf + + return resp, resp.Error() +} diff --git a/client_config.go b/client_config.go new file mode 100644 index 0000000..c9c68c8 --- /dev/null +++ b/client_config.go @@ -0,0 +1,17 @@ +package client + +import "net/http" + +type clientConfig struct { + httpClient *http.Client + delay int + baseURL string +} + +func defaultClientConfig() *clientConfig { + return &clientConfig{ + httpClient: http.DefaultClient, + delay: 0, + baseURL: "https://httpstat.us", + } +} diff --git a/client_option.go b/client_option.go new file mode 100644 index 0000000..3001106 --- /dev/null +++ b/client_option.go @@ -0,0 +1,46 @@ +package client + +import ( + "net/http" + "strings" +) + +// Option is options for constructing a client +type Option interface { + apply(config *clientConfig) +} + +type clientOptionFunc func(config *clientConfig) + +func (fn clientOptionFunc) apply(config *clientConfig) { + fn(config) +} + +// WithHTTPClient sets the underlying HTTP client used for API requests. +// By default, http.DefaultClient is used. +func WithHTTPClient(httpClient *http.Client) Option { + return clientOptionFunc(func(config *clientConfig) { + if httpClient != nil { + config.httpClient = httpClient + } + }) +} + +// WithBaseURL set's the base url for the flutterwave API +func WithBaseURL(baseURL string) Option { + return clientOptionFunc(func(config *clientConfig) { + if baseURL != "" { + config.baseURL = strings.TrimRight(baseURL, "/") + } + }) +} + +// WithDelay sets the delay in milliseconds before a response is gotten. +// The delay must be > 0 for it to be used. +func WithDelay(delay int) Option { + return clientOptionFunc(func(config *clientConfig) { + if delay > 0 { + config.delay = delay + } + }) +} diff --git a/client_option_test.go b/client_option_test.go new file mode 100644 index 0000000..7756017 --- /dev/null +++ b/client_option_test.go @@ -0,0 +1,104 @@ +package client + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWithHTTPClient(t *testing.T) { + t.Run("httpClient is not set when the httpClient is nil", func(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + config := defaultClientConfig() + + // Act + WithHTTPClient(nil).apply(config) + + // Assert + assert.NotNil(t, config.httpClient) + }) + + t.Run("httpClient is set when the httpClient is not nil", func(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + config := defaultClientConfig() + newClient := &http.Client{Timeout: 300} + + // Act + WithHTTPClient(newClient).apply(config) + + // Assert + assert.NotNil(t, config.httpClient) + assert.Equal(t, newClient.Timeout, config.httpClient.Timeout) + }) +} + +func TestWithBaseURL(t *testing.T) { + t.Run("baseURL is set successfully", func(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + baseURL := "https://example.com" + config := defaultClientConfig() + + // Act + WithBaseURL(baseURL).apply(config) + + // Assert + assert.Equal(t, config.baseURL, config.baseURL) + }) + + t.Run("tailing / is trimmed from baseURL", func(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + baseURL := "https://example.com/" + config := defaultClientConfig() + + // Act + WithBaseURL(baseURL).apply(config) + + // Assert + assert.Equal(t, "https://example.com", config.baseURL) + }) +} + +func TestWithDelay(t *testing.T) { + t.Run("delay is set successfully", func(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + config := defaultClientConfig() + delay := 1 + + // Act + WithDelay(delay).apply(config) + + // Assert + assert.Equal(t, delay, config.delay) + }) + + t.Run("delay is not set when value < 0", func(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + config := defaultClientConfig() + delay := -1 + + // Act + WithDelay(delay).apply(config) + + // Assert + assert.Equal(t, 0, config.delay) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5e43425 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/NdoleStudio/go-http-client + +go 1.17 + +require github.com/stretchr/testify v1.7.0 + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..acb88a4 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http_status.go b/http_status.go new file mode 100644 index 0000000..798523f --- /dev/null +++ b/http_status.go @@ -0,0 +1,6 @@ +package client + +type HTTPStatus struct { + Code int `json:"code"` + Description string `json:"description"` +} diff --git a/internal/helpers/test_helper.go b/internal/helpers/test_helper.go new file mode 100644 index 0000000..ffd5b2c --- /dev/null +++ b/internal/helpers/test_helper.go @@ -0,0 +1,43 @@ +package helpers + +import ( + "bytes" + "context" + "io/ioutil" + "net/http" + "net/http/httptest" +) + +// MakeTestServer creates an api server for testing +func MakeTestServer(responseCode int, body []byte) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(responseCode) + _, err := res.Write(body) + if err != nil { + panic(err) + } + })) +} + +// MakeRequestCapturingTestServer creates an api server that captures the request object +func MakeRequestCapturingTestServer(responseCode int, response []byte, request *http.Request) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, req *http.Request) { + clonedRequest := req.Clone(context.Background()) + + // clone body + body, err := ioutil.ReadAll(req.Body) + if err != nil { + panic(err) + } + req.Body = ioutil.NopCloser(bytes.NewReader(body)) + clonedRequest.Body = ioutil.NopCloser(bytes.NewReader(body)) + + *request = *clonedRequest + + responseWriter.WriteHeader(responseCode) + _, err = responseWriter.Write(response) + if err != nil { + panic(err) + } + })) +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..eb1adce --- /dev/null +++ b/response.go @@ -0,0 +1,35 @@ +package client + +import ( + "bytes" + "errors" + "net/http" + "strconv" +) + +// Response captures the http response +type Response struct { + HTTPResponse *http.Response + Body *[]byte +} + +// Error ensures that the response can be decoded into a string inc ase it's an error response +func (r *Response) Error() error { + switch r.HTTPResponse.StatusCode { + case 200, 201, 202, 204, 205: + return nil + default: + return errors.New(r.errorMessage()) + } +} + +func (r *Response) errorMessage() string { + var buf bytes.Buffer + buf.WriteString(strconv.Itoa(r.HTTPResponse.StatusCode)) + buf.WriteString(": ") + buf.WriteString(http.StatusText(r.HTTPResponse.StatusCode)) + buf.WriteString(", Body: ") + buf.Write(*r.Body) + + return buf.String() +} diff --git a/status_service.go b/status_service.go new file mode 100644 index 0000000..385cac4 --- /dev/null +++ b/status_service.go @@ -0,0 +1,32 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" +) + +// statusService is the API client for the `/` endpoint +type statusService service + +// Ok returns the 200 HTTP status Code. +// +// API Docs: https://httpstat.us +func (service *statusService) Ok(ctx context.Context) (*HTTPStatus, *Response, error) { + request, err := service.client.newRequest(ctx, http.MethodGet, "/200", nil) + if err != nil { + return nil, nil, err + } + + response, err := service.client.do(request) + if err != nil { + return nil, response, err + } + + status := new(HTTPStatus) + if err = json.Unmarshal(*response.Body, status); err != nil { + return nil, response, err + } + + return status, response, nil +} diff --git a/status_service_test.go b/status_service_test.go new file mode 100644 index 0000000..85e57fd --- /dev/null +++ b/status_service_test.go @@ -0,0 +1,70 @@ +package client + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/NdoleStudio/go-http-client/internal/helpers" + "github.com/stretchr/testify/assert" +) + +func TestStatusService_Ok(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + client := New() + + // Act + status, response, err := client.Status.Ok(context.Background()) + + // Assert + assert.Nil(t, err) + + assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + assert.Equal(t, &HTTPStatus{Code: 200, Description: "OK"}, status) +} + +func TestBillsService_OkWithDelay(t *testing.T) { + // Setup + t.Parallel() + start := time.Now() + + // Arrange + client := New(WithDelay(500)) + + // Act + status, response, err := client.Status.Ok(context.Background()) + + // Assert + assert.Nil(t, err) + assert.LessOrEqual(t, int64(100), time.Since(start).Milliseconds()) + assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + assert.Equal(t, &HTTPStatus{Code: 200, Description: "OK"}, status) +} + +func TestBillsService_OkWithError(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + server := helpers.MakeTestServer(http.StatusInternalServerError, []byte("Internal Server Error")) + client := New(WithBaseURL(server.URL)) + + // Act + status, response, err := client.Status.Ok(context.Background()) + + // Assert + assert.NotNil(t, err) + assert.Nil(t, status) + + assert.Equal(t, "500: Internal Server Error, Body: Internal Server Error", err.Error()) + + assert.Equal(t, http.StatusInternalServerError, response.HTTPResponse.StatusCode) + assert.Equal(t, "Internal Server Error", string(*response.Body)) + + // Teardown + server.Close() +}