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 }