golift.io/starr@v1.0.0/http.go (about)

     1  package starr
     2  
     3  import (
     4  	"context"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/url"
    11  	"path"
    12  	"reflect"
    13  	"strings"
    14  )
    15  
    16  // API is the beginning of every API path.
    17  const API = "api"
    18  
    19  /* The methods in this file provide assumption-ridden HTTP calls for Starr apps. */
    20  
    21  // Request contains the GET and/or POST values for an HTTP request.
    22  type Request struct {
    23  	URI   string     // Required: path portion of the URL.
    24  	Query url.Values // GET parameters work for any request type.
    25  	Body  io.Reader  // Used in PUT, POST, DELETE. Not for GET.
    26  }
    27  
    28  // ReqError is returned when a Starr app returns an invalid status code.
    29  type ReqError struct {
    30  	Code int
    31  	Body []byte
    32  	Msg  string
    33  	Name string
    34  	Err  error // sub error, often nil, or not useful.
    35  	http.Header
    36  }
    37  
    38  // String turns a request into a string. Usually used in error messages.
    39  func (r *Request) String() string {
    40  	return r.URI
    41  }
    42  
    43  // Req makes an authenticated request to a starr application and returns the response.
    44  // Do not forget to read and close the response Body if there is no error.
    45  func (c *Config) Req(ctx context.Context, method string, req Request) (*http.Response, error) {
    46  	return c.req(ctx, method, req)
    47  }
    48  
    49  // api is an internal function to call an api path.
    50  func (c *Config) api(ctx context.Context, method string, req Request) (*http.Response, error) {
    51  	req.URI = SetAPIPath(req.URI)
    52  	return c.req(ctx, method, req)
    53  }
    54  
    55  // req is our abstraction method for calling a starr application.
    56  func (c *Config) req(ctx context.Context, method string, req Request) (*http.Response, error) {
    57  	if c.Client == nil { // we must have an http client.
    58  		return nil, ErrNilClient
    59  	}
    60  
    61  	httpReq, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(c.URL, "/")+req.URI, req.Body)
    62  	if err != nil {
    63  		return nil, fmt.Errorf("http.NewRequestWithContext(%s): %w", req.URI, err)
    64  	}
    65  
    66  	c.SetHeaders(httpReq)
    67  
    68  	if req.Query != nil {
    69  		httpReq.URL.RawQuery = req.Query.Encode()
    70  	}
    71  
    72  	resp, err := c.Client.Do(httpReq)
    73  	if err != nil {
    74  		return nil, fmt.Errorf("httpClient.Do(req): %w", err)
    75  	}
    76  
    77  	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
    78  		return nil, parseNon200(resp)
    79  	}
    80  
    81  	return resp, nil
    82  }
    83  
    84  // parseNon200 attempts to extract an error message from a non-200 response.
    85  func parseNon200(resp *http.Response) *ReqError {
    86  	defer resp.Body.Close()
    87  
    88  	response := &ReqError{Code: resp.StatusCode, Header: resp.Header}
    89  	if response.Body, response.Err = io.ReadAll(resp.Body); response.Err != nil {
    90  		return response
    91  	}
    92  
    93  	var msg struct {
    94  		Msg string `json:"message"`
    95  	}
    96  
    97  	if response.Err = json.Unmarshal(response.Body, &msg); response.Err == nil && msg.Msg != "" {
    98  		response.Msg = msg.Msg
    99  		return response
   100  	}
   101  
   102  	type propError struct {
   103  		Msg  string `json:"errorMessage"`
   104  		Name string `json:"propertyName"`
   105  	}
   106  
   107  	var errMsg propError
   108  
   109  	if response.Err = json.Unmarshal(response.Body, &errMsg); response.Err == nil && errMsg.Msg != "" {
   110  		response.Name, response.Msg = errMsg.Name, errMsg.Msg
   111  		return response
   112  	}
   113  
   114  	// Sometimes we get a list of errors. This grabs the first one.
   115  	var errMsg2 []propError
   116  
   117  	if response.Err = json.Unmarshal(response.Body, &errMsg2); response.Err == nil && len(errMsg2) > 0 {
   118  		response.Name, response.Msg = errMsg2[0].Name, errMsg2[0].Msg
   119  		return response
   120  	}
   121  
   122  	return response
   123  }
   124  
   125  // closeResp should be used to close requests that don't require a response body.
   126  func closeResp(resp *http.Response) {
   127  	if resp != nil && resp.Body != nil {
   128  		_, _ = io.ReadAll(resp.Body)
   129  		resp.Body.Close()
   130  	}
   131  }
   132  
   133  // SetHeaders sets all our request headers based on method and other data.
   134  func (c *Config) SetHeaders(req *http.Request) {
   135  	// This app allows http auth, in addition to api key (nginx proxy).
   136  	if auth := c.HTTPUser + ":" + c.HTTPPass; auth != ":" {
   137  		req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
   138  	}
   139  
   140  	if req.Body != nil {
   141  		req.Header.Set("Content-Type", "application/json")
   142  	}
   143  
   144  	if req.Method == http.MethodPost && strings.HasSuffix(req.URL.RequestURI(), "/login") {
   145  		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   146  	} else {
   147  		req.Header.Set("Accept", "application/json")
   148  	}
   149  
   150  	req.Header.Set("User-Agent", "go-starr: https://"+reflect.TypeOf(Config{}).PkgPath()) //nolint:exhaustivestruct
   151  	req.Header.Set("X-API-Key", c.APIKey)
   152  }
   153  
   154  // SetAPIPath makes sure the path starts with /api.
   155  func SetAPIPath(uriPath string) string {
   156  	if strings.HasPrefix(uriPath, API+"/") ||
   157  		strings.HasPrefix(uriPath, path.Join("/", API)+"/") {
   158  		return path.Join("/", uriPath)
   159  	}
   160  
   161  	return path.Join("/", API, uriPath)
   162  }
   163  
   164  // Error returns the formatted error message for an invalid status code.
   165  func (r *ReqError) Error() string {
   166  	const (
   167  		prefix  = "invalid status code"
   168  		maxBody = 400 // arbitrary.
   169  	)
   170  
   171  	msg := fmt.Sprintf("%s, %d < %d", prefix, r.Code, http.StatusOK)
   172  	if r.Code >= http.StatusMultipleChoices { // 300
   173  		msg = fmt.Sprintf("%s, %d >= %d", prefix, r.Code, http.StatusMultipleChoices)
   174  	}
   175  
   176  	switch body := string(r.Body); {
   177  	case r.Name != "":
   178  		return fmt.Sprintf("%s, %s: %s", msg, r.Name, r.Msg)
   179  	case r.Msg != "":
   180  		return fmt.Sprintf("%s, %s", msg, r.Msg)
   181  	case len(body) > maxBody:
   182  		return fmt.Sprintf("%s, %s", msg, body[:maxBody])
   183  	case len(body) != 0:
   184  		return fmt.Sprintf("%s, %s", msg, body)
   185  	default:
   186  		return msg
   187  	}
   188  }
   189  
   190  // Is provides a custom error match facility.
   191  func (r *ReqError) Is(tgt error) bool {
   192  	target, ok := tgt.(*ReqError) //nolint:errorlint
   193  	return ok && (r.Code == target.Code || target.Code == -1)
   194  }