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  }