Skip to content
This repository has been archived by the owner on Jun 12, 2024. It is now read-only.

Commit

Permalink
Add BaseAPIClient (#113)
Browse files Browse the repository at this point in the history
Can be used to easily perform HTTP requests to various JSON
APIs. Also, it's going to be used in the code generator.
  • Loading branch information
rdner authored Apr 26, 2021
1 parent 4dff64d commit f2054d0
Show file tree
Hide file tree
Showing 2 changed files with 532 additions and 0 deletions.
220 changes: 220 additions & 0 deletions pkg/http/clients/base_api_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package clients

import (
"context"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"

cerrors "github.com/contiamo/go-base/v3/pkg/errors"
"github.com/contiamo/go-base/v3/pkg/tokens"
"github.com/contiamo/go-base/v3/pkg/tracing"
"github.com/opentracing/opentracing-go"
otext "github.com/opentracing/opentracing-go/ext"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)

// APIError describes an error during unsuccessful HTTP request to an API
type APIError struct {
// Status is the HTTP status of the API response
Status int
// Header is the set of response headers
Header http.Header
// Response is the response body
Response []byte
}

// Error implements the error interface
func (e APIError) Error() string {
return http.StatusText(e.Status)
}

// TokenProvider is a function that gets the token string for each request
type TokenProvider func() (token string, err error)

// TokenProviderFromCreator creates a token provider out of token creator.
func TokenProviderFromCreator(tc tokens.Creator, reference string, opts tokens.Options) TokenProvider {
return func() (token string, err error) {
return tc.Create(reference, opts)
}
}

var (
// NoopTokenProvider is a token provider that returns an empty string which is ignored by `DoRequest`.
NoopTokenProvider = TokenProvider(func() (token string, err error) { return "", nil })
)

// BaseAPIClient describes all basic HTTP client operations required to work with a JSON API
type BaseAPIClient interface {
// GetBaseURL returns the base URL of the service which can be used in HTTP request tasks.
GetBaseURL() string
// DoRequest performs the HTTP request with the given parameters, marshals the payload and
// unmarshals the response into the given output if the status code is successful
DoRequest(ctx context.Context, method, path string, query url.Values, payload, out interface{}) error
}

// NewBaseAPIClient creates a new instance of the base API client implementation.
// Never use `debug=true` in production environments, it will leak sensitive data
func NewBaseAPIClient(basePath, tokenHeaderName string, tokenProvider TokenProvider, client *http.Client, debug bool) BaseAPIClient {
return &baseAPIClient{
Tracer: tracing.NewTracer("clients", "BaseAPIClient"),
basePath: basePath,
tokenHeaderName: tokenHeaderName,
tokenProvider: tokenProvider,
client: client,
debug: debug,
}
}

type baseAPIClient struct {
tracing.Tracer

basePath string
tokenHeaderName string
tokenProvider TokenProvider
client *http.Client
debug bool
}

func (t baseAPIClient) GetBaseURL() string {
return t.basePath
}

func (t baseAPIClient) DoRequest(ctx context.Context, method, path string, query url.Values, payload, out interface{}) (err error) {
span, ctx := t.StartSpan(ctx, "DoRequest")
defer func() {
t.FinishSpan(span, err)
}()
span.SetTag("method", method)
span.SetTag("path", path)

queryString := query.Encode()
span.SetTag("query", queryString)

url := t.GetBaseURL() + path
if queryString != "" {
url += "?" + queryString
}

logrus := logrus.
WithField("method", method).
WithField("url", url)

logrus.Debug("creating the request token...")
token, err := t.tokenProvider()
if err != nil {
return errors.Wrap(err, "failed to create request token")
}
logrus.Debug("token created.")

var payloadReader io.Reader
if payload != nil {
// streaming the payload
r, w := io.Pipe()
payloadReader = r
encoder := json.NewEncoder(w)
go func() {
mErr := encoder.Encode(payload)
if mErr != nil {
_ = w.CloseWithError(mErr)
} else {
_ = w.Close()
}
}()
}

logrus.Debug("creating the HTTP request...")
req, err := http.NewRequest(method, url, payloadReader)
if err != nil {
return errors.Wrap(err, "failed to create a new request")
}

// so, the HTTP request can be cancelled
req = req.WithContext(ctx)

req.Header.Add("Content-Type", "application/json")
if token != "" {
req.Header.Add(t.tokenHeaderName, token)
} else {
span.LogKV("token", "token value is empty, header was not set")
}

// set tracing headers so we can connect spans in different services
err = opentracing.GlobalTracer().Inject(
span.Context(),
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header),
)
if err != nil {
// this error should not crash the request, we log it and skip it
otext.Error.Set(span, true)
span.SetTag("tracing.inject.err", err.Error())
logrus.Error(errors.Wrap(err, "cannot set tracing headers"))
err = nil
}
logrus.Debug("HTTP request created.")

logrus.Debug("doing request...")
resp, err := t.client.Do(req)
if err != nil {
return errors.Wrap(err, "failed to do request")
}
logrus.Debug("request is done.")

span.SetTag("response.status", resp.StatusCode)

logrus.Debug("reading the response...")
defer logrus.Debug("reading the response finished.")

if resp.StatusCode >= 200 && resp.StatusCode < 300 {
contentType := resp.Header.Get("content-type")
contentType = strings.ToLower(contentType)
span.SetTag("resp.contentType", contentType)

if strings.Contains(contentType, "json") {
decoder := json.NewDecoder(resp.Body)
return errors.Wrap(decoder.Decode(out), "failed to decode JSON response")
}

return nil
}

// these are the cases we can clearly map validation errors,
// should effectively be server errors because they indicate some kind of bug in our implementation,
// the Hub http layer validation should be strong enough to capture user fixable errors
switch resp.StatusCode {
case http.StatusUnauthorized:
return cerrors.ErrAuthorization
case http.StatusForbidden:
return cerrors.ErrPermission
case http.StatusNotFound:
return cerrors.ErrNotFound
case http.StatusNotImplemented:
return cerrors.ErrNotImplemented
default:
if t.debug {
// ignore the error on purpose here
requestBody, _ := json.Marshal(payload)
span.LogKV("request.body", string(requestBody))
}

// general error processing
response, err := ioutil.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "failed to read response body")
}
span.LogKV("response.body", string(response))
err = APIError{
Status: resp.StatusCode,
Header: resp.Header.Clone(),
Response: response,
}
logrus.Error(errors.Wrap(err, "request failed"))
logrus.Error(string(response))
return err
}
}
Loading

0 comments on commit f2054d0

Please sign in to comment.