github.com/jxskiss/gopkg/v2@v2.14.9-0.20240514120614-899f3e7952b4/easy/ezhttp/request.go (about) 1 package ezhttp 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/xml" 7 "fmt" 8 "io" 9 "log" 10 "net/http" 11 "net/http/httputil" 12 "net/url" 13 "strings" 14 "time" 15 16 "github.com/jxskiss/gopkg/v2/internal/unsafeheader" 17 "github.com/jxskiss/gopkg/v2/perf/json" 18 ) 19 20 const ( 21 hdrContentTypeKey = "Content-Type" 22 contentTypeJSON = "application/json" 23 contentTypeXML = "application/xml" 24 contentTypeForm = "application/x-www-form-urlencoded" 25 ) 26 27 // Request represents a request and options to send with the Do function. 28 type Request struct { 29 30 // Req should be a fully prepared http Request to sent, if not nil, 31 // the following `Method`, `URL`, `Params`, `JSON`, `XML`, `Form`, `Body` 32 // will be ignored. 33 // 34 // If Req is nil, it will be filled using the following data `Method`, 35 // `URL`, `Params`, `JSON`, `XML`, `Form`, `Body` to construct the `http.Request`. 36 // 37 // When building http body, the priority is JSON > XML > Form > Body. 38 Req *http.Request 39 40 // Method specifies the verb for the http request, it's optional, 41 // default is "GET". 42 Method string 43 44 // URL specifies the url to make the http request. 45 // It's required if Req is nil. 46 URL string 47 48 // Params specifies optional params to merge with URL, it must be one of 49 // the following types: 50 // - map[string]string 51 // - map[string][]string 52 // - map[string]any 53 // - url.Values 54 Params any 55 56 // JSON specifies optional body data for request which can take body, 57 // the content-type will be "application/json", it must be one of 58 // the following types: 59 // - io.Reader 60 // - []byte (will be wrapped with bytes.NewReader) 61 // - string (will be wrapped with strings.NewReader) 62 // - any (will be marshaled with json.Marshal) 63 JSON any 64 65 // XML specifies optional body data for request which can take body, 66 // the content-type will be "application/xml", it must be one of 67 // the following types: 68 // - io.Reader 69 // - []byte (will be wrapped with bytes.NewReader) 70 // - string (will be wrapped with strings.NewReader) 71 // - any (will be marshaled with xml.Marshal) 72 XML any 73 74 // Form specifies optional body data for request which can take body, 75 // the content-type will be "application/x-www-form-urlencoded", 76 // it must be one of the following types: 77 // - io.Reader 78 // - []byte (will be wrapped with bytes.NewReader) 79 // - string (will be wrapped with strings.NewReader) 80 // - url.Values (will be encoded and wrapped as io.Reader) 81 // - map[string]string (will be converted to url.Values) 82 // - map[string][]string (will be converted to url.Values) 83 // - map[string]any (will be converted to url.Values) 84 Form any 85 86 // Body specifies optional body data for request which can take body, 87 // the content-type will be detected from the content (may be incorrect), 88 // it should be one of the following types: 89 // - io.Reader 90 // - []byte (will be wrapped with bytes.NewReader) 91 // - string (will be wrapped with strings.NewReader) 92 Body any 93 94 // Headers will be copied to the request before sent. 95 // 96 // If "Content-Type" presents, it will replace the default value 97 // set by `JSON`, `XML`, `Form`, or `Body`. 98 Headers map[string]string 99 100 // Resp specifies an optional destination to unmarshal the response data. 101 // if `Unmarshal` is not provided, the header "Content-Type" will be used to 102 // detect XML content, else `json.Unmarshal` will be used. 103 Resp any 104 105 // Unmarshal specifies an optional function to unmarshal the response data. 106 Unmarshal func([]byte, any) error 107 108 // Context specifies an optional context.Context to use with http request. 109 Context context.Context 110 111 // Timeout specifies an optional timeout of the http request, if 112 // timeout > 0, the request will be attached an timeout context.Context. 113 // Timeout takes higher priority than Context, if both available, only 114 // `Timeout` takes effect. 115 Timeout time.Duration 116 117 // Client specifies an optional http.Client to do the request, instead of 118 // the default http client. 119 Client *http.Client 120 121 // DisableRedirect tells the http client don't follow response redirection. 122 DisableRedirect bool 123 124 // CheckRedirect specifies the policy for handling redirects. 125 // See http.Client.CheckRedirect for details. 126 // 127 // CheckRedirect takes higher priority than DisableRedirect, 128 // if both available, only `CheckRedirect` takes effect. 129 CheckRedirect func(req *http.Request, via []*http.Request) error 130 131 // DiscardResponseBody makes the http response body being discarded. 132 DiscardResponseBody bool 133 134 // DumpRequest makes the http request being logged before sent. 135 DumpRequest bool 136 137 // DumpResponse makes the http response being logged after received. 138 DumpResponse bool 139 140 // When DumpRequest or DumpResponse is true, or both are true, 141 // DumpFunc optionally specifies a function to dump the request and response, 142 // by default, [log.Printf] is used. 143 DumpFunc func(format string, args ...any) 144 145 // RaiseForStatus tells Do to report an error if the response 146 // status code >= 400. The error will be formatted as "unexpected status: <STATUS>". 147 RaiseForStatus bool 148 149 internalData struct { 150 BasicAuth struct { 151 Username, Password string 152 } 153 } 154 } 155 156 // SetBasicAuth sets the request's Authorization header to use HTTP 157 // Basic Authentication with the provided username and password. 158 // 159 // With HTTP Basic Authentication the provided username and password 160 // are not encrypted. It should generally only be used in an HTTPS 161 // request. 162 // 163 // See http.Request.SetBasicAuth for details. 164 func (p *Request) SetBasicAuth(username, password string) *Request { 165 p.internalData.BasicAuth.Username = username 166 p.internalData.BasicAuth.Password = password 167 return p 168 } 169 170 func (p *Request) buildClient() *http.Client { 171 checkRedirectFunc := p.CheckRedirect 172 if checkRedirectFunc == nil && p.DisableRedirect { 173 checkRedirectFunc = func(_ *http.Request, _ []*http.Request) error { 174 return http.ErrUseLastResponse 175 } 176 } 177 178 if checkRedirectFunc == nil { 179 return p.getDefaultClient() 180 } 181 182 client := *(p.getDefaultClient()) 183 client.CheckRedirect = checkRedirectFunc 184 return &client 185 } 186 187 func (p *Request) getDefaultClient() *http.Client { 188 if p.Client != nil { 189 return p.Client 190 } 191 return http.DefaultClient 192 } 193 194 func (p *Request) prepareRequest(method string) (err error) { 195 if p.Req != nil { 196 return p.mergeRequest() 197 } 198 199 reqURL := p.URL 200 if p.Params != nil { 201 reqURL, err = mergeQuery(reqURL, p.Params) 202 if err != nil { 203 return err 204 } 205 } 206 207 if method == "" { 208 method = p.Method 209 if method == "" { 210 method = http.MethodGet 211 } 212 } 213 214 if mayHaveBody(method) { 215 var body io.Reader 216 var contentType string 217 body, contentType, err = p.buildBody() 218 if err != nil { 219 return err 220 } 221 p.Req, err = http.NewRequest(method, reqURL, body) 222 if err != nil { 223 return err 224 } 225 if contentType != "" { 226 p.Req.Header.Set(hdrContentTypeKey, contentType) 227 } 228 } else { 229 p.Req, err = http.NewRequest(method, reqURL, nil) 230 if err != nil { 231 return err 232 } 233 } 234 235 p.setHeaders() 236 return nil 237 } 238 239 func (p *Request) mergeRequest() (err error) { 240 httpReq := p.Req 241 if p.Params != nil { 242 addQuery := castQueryParams(p.Params).Encode() 243 if addQuery != "" { 244 if httpReq.URL.RawQuery != "" && !strings.HasSuffix(httpReq.URL.RawQuery, "&") { 245 httpReq.URL.RawQuery += "&" + addQuery 246 } else { 247 httpReq.URL.RawQuery += addQuery 248 } 249 } 250 } 251 p.setHeaders() 252 return nil 253 } 254 255 func mergeQuery(reqURL string, params any) (string, error) { 256 parsed, err := url.Parse(reqURL) 257 if err != nil { 258 return "", err 259 } 260 query, err := url.ParseQuery(parsed.RawQuery) 261 if err != nil { 262 return "", err 263 } 264 addQuery := castQueryParams(params) 265 for k, values := range addQuery { 266 for _, v := range values { 267 query.Add(k, v) 268 } 269 } 270 parsed.RawQuery = query.Encode() 271 return parsed.String(), nil 272 } 273 274 func castQueryParams(params any) url.Values { 275 switch x := params.(type) { 276 case url.Values: 277 return x 278 case map[string][]string: 279 return x 280 case map[string]string: 281 var values = make(url.Values, len(x)) 282 for k, v := range x { 283 values.Set(k, v) 284 } 285 return values 286 case map[string]any: 287 var values = make(url.Values, len(x)) 288 for k, v := range x { 289 switch val := v.(type) { 290 case string: 291 values.Add(k, val) 292 case []string: 293 values[k] = val 294 default: 295 values.Add(k, fmt.Sprint(v)) 296 } 297 } 298 return values 299 } 300 return nil 301 } 302 303 func (p *Request) buildBody() (body io.Reader, contentType string, err error) { 304 if p.JSON != nil { // JSON 305 body, err = makeHTTPBody(p.JSON, json.Marshal) 306 contentType = contentTypeJSON 307 } else if p.XML != nil { // XML 308 body, err = makeHTTPBody(p.XML, xml.Marshal) 309 contentType = contentTypeXML 310 } else if p.Form != nil { // urlencoded form 311 body, err = makeHTTPBody(p.Form, marshalForm) 312 contentType = contentTypeForm 313 } else if p.Body != nil { // detect content-type from the body data 314 var bodyBuf []byte 315 switch data := p.Body.(type) { 316 case io.Reader: 317 bodyBuf, err = io.ReadAll(data) 318 if err != nil { 319 return nil, "", err 320 } 321 case []byte: 322 bodyBuf = data 323 case string: 324 bodyBuf = unsafeheader.StringToBytes(data) 325 default: 326 err = fmt.Errorf("unsupported body data type: %T", data) 327 return nil, "", err 328 } 329 body = bytes.NewReader(bodyBuf) 330 if p.Headers[hdrContentTypeKey] == "" { 331 contentType = http.DetectContentType(bodyBuf) 332 } 333 } // else no body data 334 if err != nil { 335 return nil, "", err 336 } 337 338 return body, contentType, nil 339 } 340 341 func marshalForm(v any) ([]byte, error) { 342 var form url.Values 343 switch data := v.(type) { 344 case url.Values: 345 form = data 346 case map[string][]string: 347 form = data 348 case map[string]string: 349 form = make(url.Values, len(data)) 350 for k, v := range data { 351 form[k] = []string{v} 352 } 353 case map[string]any: 354 form = make(url.Values, len(data)) 355 for k, v := range data { 356 switch value := v.(type) { 357 case string: 358 form[k] = []string{value} 359 case []string: 360 form[k] = value 361 default: 362 form[k] = []string{fmt.Sprint(v)} 363 } 364 } 365 } 366 if form == nil { 367 err := fmt.Errorf("unsupported form data type: %T", v) 368 return nil, err 369 } 370 encoded := form.Encode() 371 buf := unsafeheader.StringToBytes(encoded) 372 return buf, nil 373 } 374 375 type marshalFunc func(any) ([]byte, error) 376 377 func makeHTTPBody(data any, marshal marshalFunc) (io.Reader, error) { 378 var body io.Reader 379 switch x := data.(type) { 380 case io.Reader: 381 body = x 382 case []byte: 383 body = bytes.NewReader(x) 384 case string: 385 body = strings.NewReader(x) 386 default: 387 buf, err := marshal(data) 388 if err != nil { 389 return nil, err 390 } 391 body = bytes.NewReader(buf) 392 } 393 return body, nil 394 } 395 396 func (p *Request) setHeaders() { 397 for k, v := range p.Headers { 398 p.Req.Header.Set(k, v) 399 } 400 if p.internalData.BasicAuth.Username != "" || p.internalData.BasicAuth.Password != "" { 401 p.Req.SetBasicAuth(p.internalData.BasicAuth.Username, p.internalData.BasicAuth.Password) 402 } 403 } 404 405 // Do is a convenient function to send request and control redirect 406 // and debug options. If `Request.Resp` is provided, it will be used as 407 // destination to try to unmarshal the response body. 408 // 409 // Trade-off was taken to balance simplicity and convenience. 410 // 411 // For more powerful controls of a http request and handy utilities, 412 // you may take a look at the awesome library `https://github.com/go-resty/resty/`. 413 func Do(req *Request) (header http.Header, respContent []byte, status int, err error) { 414 err = req.prepareRequest("") 415 if err != nil { 416 return header, respContent, status, err 417 } 418 419 dumpFunc := req.DumpFunc 420 if dumpFunc == nil { 421 dumpFunc = log.Printf 422 } 423 424 httpReq := req.Req 425 if req.Context != nil { 426 httpReq = httpReq.WithContext(req.Context) 427 } 428 if req.Timeout > 0 { 429 timeoutCtx, cancel := context.WithTimeout(httpReq.Context(), req.Timeout) 430 defer cancel() 431 httpReq = httpReq.WithContext(timeoutCtx) 432 } 433 if req.DumpRequest { 434 var dump []byte 435 dump, err = httputil.DumpRequestOut(httpReq, true) 436 if err != nil { 437 return header, respContent, status, err 438 } 439 dumpFunc("[DEBUG] ezhttp: dump HTTP request:\n%s", dump) 440 } 441 442 httpClient := req.buildClient() 443 httpResp, err := httpClient.Do(httpReq) 444 if err != nil { 445 return header, respContent, status, err 446 } 447 defer httpResp.Body.Close() 448 449 header = httpResp.Header 450 status = httpResp.StatusCode 451 if req.DumpResponse { 452 var dump []byte 453 dump, err = httputil.DumpResponse(httpResp, true) 454 if err != nil { 455 return header, respContent, status, err 456 } 457 dumpFunc("[DEBUG] ezhttp: dump HTTP response:\n%s", dump) 458 } 459 460 if req.DiscardResponseBody { 461 _, err = io.Copy(io.Discard, httpResp.Body) 462 if err != nil { 463 return header, respContent, status, err 464 } 465 } else { 466 respContent, err = io.ReadAll(httpResp.Body) 467 if err != nil { 468 return header, respContent, status, err 469 } 470 } 471 if req.RaiseForStatus { 472 if httpResp.StatusCode >= 400 { 473 err = fmt.Errorf("unexpected status: %v", httpResp.Status) 474 return header, respContent, status, err 475 } 476 } 477 478 if req.Resp != nil && len(respContent) > 0 { 479 unmarshal := req.Unmarshal 480 if unmarshal == nil { 481 ct := httpResp.Header.Get(hdrContentTypeKey) 482 if IsXMLType(ct) { 483 unmarshal = xml.Unmarshal 484 } 485 // default: JSON 486 if unmarshal == nil { 487 unmarshal = json.Unmarshal 488 } 489 } 490 err = unmarshal(respContent, req.Resp) 491 if err != nil { 492 return header, respContent, status, err 493 } 494 } 495 return header, respContent, status, err 496 }