cuelang.org/go@v0.10.1/internal/httplog/client.go (about)

     1  package httplog
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"io"
     7  	"maps"
     8  	"net/http"
     9  	"net/url"
    10  	"slices"
    11  	"strings"
    12  	"sync/atomic"
    13  	"unicode/utf8"
    14  )
    15  
    16  // DefaultMaxBodySize holds the maximum body size to include in
    17  // logged requests when [TransportConfig.MaxBodySize] is <=0.
    18  const DefaultMaxBodySize = 1024
    19  
    20  // TransportConfig holds configuration for [Transport].
    21  type TransportConfig struct {
    22  	// Logger is used to log the requests. If it is nil,
    23  	// the zero [SlogLogger] will be used.
    24  	Logger Logger
    25  
    26  	// Transport is used as the underlying transport for
    27  	// making HTTP requests. If it is nil,
    28  	// [http.DefaultTransport] will be used.
    29  	Transport http.RoundTripper
    30  
    31  	// IncludeAllQueryParams causes all URL query parameters to be included
    32  	// rather than redacted using [RedactedURL].
    33  	IncludeAllQueryParams bool
    34  
    35  	// MaxBodySize holds the maximum size of body data to include
    36  	// in the logged data. When a body is larger than this, only this
    37  	// amount of body will be included, and the "BodyTruncated"
    38  	// field will be set to true to indicate that this happened.
    39  	//
    40  	// If this is <=0, DefaultMaxBodySize will be used.
    41  	// Use [RedactRequestBody] or [RedactResponseBody]
    42  	// to cause body data to be omitted entirely.
    43  	MaxBodySize int
    44  }
    45  
    46  // Transport returns an [http.RoundTripper] implementation that
    47  // logs HTTP requests. If cfg0 is nil, it's equivalent to a pointer
    48  // to a zero-valued [TransportConfig].
    49  func Transport(cfg0 *TransportConfig) http.RoundTripper {
    50  	var cfg TransportConfig
    51  	if cfg0 != nil {
    52  		cfg = *cfg0
    53  	}
    54  	if cfg.Logger == nil {
    55  		cfg.Logger = SlogLogger{}
    56  	}
    57  	if cfg.Transport == nil {
    58  		cfg.Transport = http.DefaultTransport
    59  	}
    60  	if cfg.MaxBodySize <= 0 {
    61  		cfg.MaxBodySize = DefaultMaxBodySize
    62  	}
    63  	return &loggingTransport{
    64  		cfg: cfg,
    65  	}
    66  }
    67  
    68  type loggingTransport struct {
    69  	cfg TransportConfig
    70  }
    71  
    72  var seq atomic.Int64
    73  
    74  func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    75  	ctx := req.Context()
    76  	id := seq.Add(1)
    77  	var reqURL string
    78  	if t.cfg.IncludeAllQueryParams {
    79  		reqURL = req.URL.String()
    80  	} else {
    81  		reqURL = RedactedURL(ctx, req.URL).String()
    82  	}
    83  	t.cfg.Logger.Log(ctx, KindClientSendRequest, fromHTTPRequest(ctx, id, reqURL, req, true, t.cfg.MaxBodySize))
    84  	resp, err := t.cfg.Transport.RoundTrip(req)
    85  	if err != nil {
    86  		t.cfg.Logger.Log(ctx, KindClientRecvResponse, &Response{
    87  			ID:     id,
    88  			Method: req.Method,
    89  			URL:    reqURL,
    90  			Error:  err.Error(),
    91  		})
    92  		return nil, err
    93  	}
    94  	logResp := &Response{
    95  		ID:         id,
    96  		Method:     req.Method,
    97  		URL:        reqURL,
    98  		Header:     resp.Header,
    99  		StatusCode: resp.StatusCode,
   100  	}
   101  	resp.Body = logResp.BodyData.init(ctx, resp.Body, true, false, t.cfg.MaxBodySize)
   102  	t.cfg.Logger.Log(ctx, KindClientRecvResponse, logResp)
   103  	return resp, nil
   104  }
   105  
   106  func fromHTTPRequest(ctx context.Context, id int64, reqURL string, req *http.Request, closeBody bool, maxBodySize int) *Request {
   107  	logReq := &Request{
   108  		ID:            id,
   109  		URL:           reqURL,
   110  		Method:        req.Method,
   111  		Header:        redactAuthorization(req.Header),
   112  		ContentLength: req.ContentLength,
   113  	}
   114  	req.Body = logReq.BodyData.init(ctx, req.Body, closeBody, true, maxBodySize)
   115  
   116  	return logReq
   117  }
   118  
   119  func redactAuthorization(h http.Header) http.Header {
   120  	auths, ok := h["Authorization"]
   121  	if !ok {
   122  		return h
   123  	}
   124  	h = maps.Clone(h) // shallow copy
   125  	auths = slices.Clone(auths)
   126  	for i, auth := range auths {
   127  		if kind, _, ok := strings.Cut(auth, " "); ok && (kind == "Basic" || kind == "Bearer") {
   128  			auths[i] = kind + " REDACTED"
   129  		} else {
   130  			auths[i] = "REDACTED"
   131  		}
   132  	}
   133  	h["Authorization"] = auths
   134  	return h
   135  }
   136  
   137  // init initializes body to contain information about the body data read from r.
   138  // It returns a replacement reader to use instead of r.
   139  func (body *BodyData) init(ctx context.Context, r io.ReadCloser, needClose, isRequest bool, maxBodySize int) io.ReadCloser {
   140  	if r == nil {
   141  		return nil
   142  	}
   143  	if reason := shouldRedactBody(ctx, isRequest); reason != "" {
   144  		body.BodyRedactedBecause = reason
   145  		return r
   146  	}
   147  	data, err := io.ReadAll(io.LimitReader(r, int64(maxBodySize+1)))
   148  	if len(data) > maxBodySize {
   149  		body.BodyTruncated = true
   150  		r = struct {
   151  			io.Reader
   152  			io.Closer
   153  		}{
   154  			Reader: io.MultiReader(
   155  				bytes.NewReader(data),
   156  				r,
   157  			),
   158  			Closer: r,
   159  		}
   160  		data = data[:maxBodySize]
   161  	} else {
   162  		if err != nil {
   163  			body.BodyTruncated = true
   164  		}
   165  		if needClose {
   166  			r.Close()
   167  		}
   168  		r = io.NopCloser(bytes.NewReader(data))
   169  	}
   170  	if utf8.Valid(data) {
   171  		body.Body = string(data)
   172  	} else {
   173  		body.Body64 = data
   174  	}
   175  	return r
   176  }
   177  
   178  // RedactedURL returns u with query parameters redacted according
   179  // to [ContextWithAllowedURLQueryParams].
   180  // If there is no allow function associated with the context,
   181  // all query parameters will be redacted.
   182  func RedactedURL(ctx context.Context, u *url.URL) *url.URL {
   183  	if u.RawQuery == "" {
   184  		return u
   185  	}
   186  	qs := u.Query()
   187  	allow := queryParamChecker(ctx)
   188  	changed := false
   189  	for k, v := range qs {
   190  		if allow(k) {
   191  			continue
   192  		}
   193  		changed = true
   194  		for i := range v {
   195  			v[i] = "REDACTED"
   196  		}
   197  	}
   198  	if !changed {
   199  		return u
   200  	}
   201  	r := *u
   202  	r.RawQuery = qs.Encode()
   203  	return &r
   204  }