github.com/alwitt/goutils@v0.6.4/rest.go (about)

     1  package goutils
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"net/http"
     8  	"time"
     9  
    10  	"github.com/apex/log"
    11  	"github.com/go-resty/resty/v2"
    12  	"github.com/google/uuid"
    13  	"github.com/urfave/negroni"
    14  )
    15  
    16  // ==============================================================================
    17  // Base HTTP Request Handling
    18  
    19  // HTTPRequestLogLevel HTTP request log level data type
    20  type HTTPRequestLogLevel string
    21  
    22  // HTTP request log levels
    23  const (
    24  	HTTPLogLevelWARN  HTTPRequestLogLevel = "warn"
    25  	HTTPLogLevelINFO  HTTPRequestLogLevel = "info"
    26  	HTTPLogLevelDEBUG HTTPRequestLogLevel = "debug"
    27  )
    28  
    29  // RestAPIHandler base REST API handler
    30  type RestAPIHandler struct {
    31  	Component
    32  	// CallRequestIDHeaderField the HTTP header containing the request ID provided by the caller
    33  	CallRequestIDHeaderField *string
    34  	// DoNotLogHeaders marks the set of HTTP headers to not log
    35  	DoNotLogHeaders map[string]bool
    36  	// LogLevel configure the request logging level
    37  	LogLevel HTTPRequestLogLevel
    38  	// MetricsHelper HTTP request metric collection agent
    39  	MetricsHelper HTTPRequestMetricHelper
    40  }
    41  
    42  // ErrorDetail is the response detail in case of error
    43  type ErrorDetail struct {
    44  	// Code is the response code
    45  	Code int `json:"code" validate:"required"`
    46  	// Msg is an optional descriptive message
    47  	Msg string `json:"message,omitempty"`
    48  	// Detail is an optional descriptive message providing additional details on the error
    49  	Detail string `json:"detail,omitempty"`
    50  }
    51  
    52  // RestAPIBaseResponse standard REST API response
    53  type RestAPIBaseResponse struct {
    54  	// Success indicates whether the request was successful
    55  	Success bool `json:"success" validate:"required"`
    56  	// RequestID gives the request ID to match against logs
    57  	RequestID string `json:"request_id" validate:"required"`
    58  	// Error are details in case of errors
    59  	Error *ErrorDetail `json:"error,omitempty"`
    60  }
    61  
    62  /*
    63  LoggingMiddleware is a support middleware to be used with Mux to perform request logging
    64  
    65  	@param next http.HandlerFunc - the core request handler function
    66  	@return middleware http.HandlerFunc
    67  */
    68  func (h RestAPIHandler) LoggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
    69  	return func(rw http.ResponseWriter, r *http.Request) {
    70  		// use provided request id from incoming request if any
    71  		reqID := uuid.New().String()
    72  		if h.CallRequestIDHeaderField != nil {
    73  			reqID = r.Header.Get(*h.CallRequestIDHeaderField)
    74  			if reqID == "" {
    75  				// or use some generated string
    76  				reqID = uuid.New().String()
    77  			}
    78  		}
    79  		// Construct the request param tracking structure
    80  		params := RestRequestParam{
    81  			ID:             reqID,
    82  			Host:           r.Host,
    83  			URI:            r.URL.String(),
    84  			Method:         r.Method,
    85  			Referer:        r.Referer(),
    86  			RemoteAddr:     r.RemoteAddr,
    87  			Proto:          r.Proto,
    88  			ProtoMajor:     r.ProtoMajor,
    89  			ProtoMinor:     r.ProtoMinor,
    90  			Timestamp:      time.Now(),
    91  			RequestHeaders: make(http.Header),
    92  		}
    93  		// Fill in the request headers
    94  		userAgentString := "-"
    95  		for headerField, headerValues := range r.Header {
    96  			if _, present := h.DoNotLogHeaders[headerField]; !present {
    97  				params.RequestHeaders[headerField] = headerValues
    98  				if headerField == "User-Agent" && len(headerValues) > 0 {
    99  					userAgentString = fmt.Sprintf("\"%s\"", headerValues[0])
   100  				}
   101  			}
   102  		}
   103  		requestReferer := "-"
   104  		if r.Referer() != "" {
   105  			requestReferer = fmt.Sprintf("\"%s\"", r.Referer())
   106  		}
   107  		// Construct new context
   108  		ctxt := context.WithValue(r.Context(), RestRequestParamKey{}, params)
   109  		// Make the request
   110  		newRespWriter := negroni.NewResponseWriter(rw)
   111  		if h.CallRequestIDHeaderField != nil {
   112  			newRespWriter.Header().Add(*h.CallRequestIDHeaderField, reqID)
   113  		}
   114  		next(newRespWriter, r.WithContext(ctxt))
   115  		newRespWriter.Flush()
   116  		respTimestamp := time.Now()
   117  		// Log result of request
   118  		logTags := h.GetLogTagsForContext(ctxt)
   119  		respLen := newRespWriter.Size()
   120  		respCode := newRespWriter.Status()
   121  		// Log level based on config
   122  		logHandle := log.WithFields(logTags).
   123  			WithField("response_code", respCode).
   124  			WithField("response_size", respLen).
   125  			WithField("response_timestamp", respTimestamp.UTC().Format(time.RFC3339Nano))
   126  		switch h.LogLevel {
   127  		case HTTPLogLevelDEBUG:
   128  			logHandle.Debugf(
   129  				"%s - - [%s] \"%s %s %s\" %d %d %s %s",
   130  				params.RemoteAddr,
   131  				params.Timestamp.UTC().Format(time.RFC3339Nano),
   132  				params.Method,
   133  				params.URI,
   134  				params.Proto,
   135  				respCode,
   136  				respLen,
   137  				requestReferer,
   138  				userAgentString,
   139  			)
   140  
   141  		case HTTPLogLevelINFO:
   142  			logHandle.Infof(
   143  				"%s - - [%s] \"%s %s %s\" %d %d %s %s",
   144  				params.RemoteAddr,
   145  				params.Timestamp.UTC().Format(time.RFC3339Nano),
   146  				params.Method,
   147  				params.URI,
   148  				params.Proto,
   149  				respCode,
   150  				respLen,
   151  				requestReferer,
   152  				userAgentString,
   153  			)
   154  
   155  		default:
   156  			logHandle.Warnf(
   157  				"%s - - [%s] \"%s %s %s\" %d %d %s %s",
   158  				params.RemoteAddr,
   159  				params.Timestamp.UTC().Format(time.RFC3339Nano),
   160  				params.Method,
   161  				params.URI,
   162  				params.Proto,
   163  				respCode,
   164  				respLen,
   165  				requestReferer,
   166  				userAgentString,
   167  			)
   168  		}
   169  
   170  		// Record metrics
   171  		if h.MetricsHelper != nil {
   172  			h.MetricsHelper.RecordRequest(
   173  				r.Method, respCode, respTimestamp.Sub(params.Timestamp), int64(respLen),
   174  			)
   175  		}
   176  	}
   177  }
   178  
   179  /*
   180  ReadRequestIDFromContext reads the request ID from the request context if available
   181  
   182  	@param ctxt context.Context - a request context
   183  	@return if available, the request ID
   184  */
   185  func (h RestAPIHandler) ReadRequestIDFromContext(ctxt context.Context) string {
   186  	if ctxt.Value(RestRequestParamKey{}) != nil {
   187  		v, ok := ctxt.Value(RestRequestParamKey{}).(RestRequestParam)
   188  		if ok {
   189  			return v.ID
   190  		}
   191  	}
   192  	return ""
   193  }
   194  
   195  /*
   196  GetStdRESTSuccessMsg defines a standard success message
   197  
   198  	@param ctxt context.Context - a request context
   199  	@return the standard REST response
   200  */
   201  func (h RestAPIHandler) GetStdRESTSuccessMsg(ctxt context.Context) RestAPIBaseResponse {
   202  	return RestAPIBaseResponse{Success: true, RequestID: h.ReadRequestIDFromContext(ctxt)}
   203  }
   204  
   205  /*
   206  GetStdRESTErrorMsg defines a standard error message
   207  
   208  	@param ctxt context.Context - a request context
   209  	@param respCode int - the request response code
   210  	@param errMsg string - the error message
   211  	@param errDetail string - the details on the error
   212  	@return the standard REST response
   213  */
   214  func (h RestAPIHandler) GetStdRESTErrorMsg(
   215  	ctxt context.Context, respCode int, errMsg string, errDetail string,
   216  ) RestAPIBaseResponse {
   217  	return RestAPIBaseResponse{
   218  		Success:   false,
   219  		RequestID: h.ReadRequestIDFromContext(ctxt),
   220  		Error:     &ErrorDetail{Code: respCode, Msg: errMsg, Detail: errDetail},
   221  	}
   222  }
   223  
   224  /*
   225  WriteRESTResponse helper function to write out the REST API response
   226  
   227  	@param w http.ResponseWriter - response writer
   228  	@param respCode int - the response code
   229  	@param resp interface{} - the response body
   230  	@param headers map[string]string - the response header
   231  	@return whether write succeeded
   232  */
   233  func (h RestAPIHandler) WriteRESTResponse(
   234  	w http.ResponseWriter, respCode int, resp interface{}, headers map[string]string,
   235  ) error {
   236  	w.Header().Set("content-type", "application/json")
   237  	for name, value := range headers {
   238  		w.Header().Set(name, value)
   239  	}
   240  	w.WriteHeader(respCode)
   241  	t, err := json.Marshal(resp)
   242  	if err != nil {
   243  		w.WriteHeader(http.StatusInternalServerError)
   244  		return err
   245  	}
   246  	if _, err = w.Write(t); err != nil {
   247  		w.WriteHeader(http.StatusInternalServerError)
   248  		return err
   249  	}
   250  	return nil
   251  }
   252  
   253  // ==============================================================================
   254  // HTTP Client Support
   255  
   256  // HTTPClientAuthConfig HTTP client OAuth middleware configuration
   257  //
   258  // Currently only support client-credential OAuth flow configuration
   259  type HTTPClientAuthConfig struct {
   260  	// IssuerURL OpenID provider issuer URL
   261  	IssuerURL string `json:"issuer"`
   262  	// ClientID OAuth client ID
   263  	ClientID string `json:"client_id"`
   264  	// ClientSecret OAuth client secret
   265  	ClientSecret string `json:"client_secret"`
   266  	// TargetAudience target audience `aud` to acquire a token for
   267  	TargetAudience string `json:"target_audience"`
   268  	// LogTags auth middleware log tags
   269  	LogTags log.Fields
   270  }
   271  
   272  // HTTPClientRetryConfig HTTP client config retry configuration
   273  type HTTPClientRetryConfig struct {
   274  	// MaxAttempts max number of retry attempts
   275  	MaxAttempts int `json:"max_attempts"`
   276  	// InitWaitTime wait time before the first wait retry
   277  	InitWaitTime time.Duration `json:"initialWaitTimeInSec"`
   278  	// MaxWaitTime max wait time
   279  	MaxWaitTime time.Duration `json:"maxWaitTimeInSec"`
   280  }
   281  
   282  // setHTTPClientRetryParam helper function to install retry on HTTP client
   283  func setHTTPClientRetryParam(
   284  	client *resty.Client, config HTTPClientRetryConfig,
   285  ) *resty.Client {
   286  	return client.
   287  		SetRetryCount(config.MaxAttempts).
   288  		SetRetryWaitTime(config.InitWaitTime).
   289  		SetRetryMaxWaitTime(config.MaxWaitTime)
   290  }
   291  
   292  // installHTTPClientAuthMiddleware install OAuth middleware on HTTP client
   293  func installHTTPClientAuthMiddleware(
   294  	parentCtxt context.Context,
   295  	parentClient *resty.Client,
   296  	config HTTPClientAuthConfig,
   297  	retryCfg HTTPClientRetryConfig,
   298  ) error {
   299  	// define client specifically to be used by
   300  	oauthHTTPClient := setHTTPClientRetryParam(resty.New(), retryCfg)
   301  
   302  	// define OAuth token manager
   303  	oauthMgmt, err := GetNewClientCredOAuthTokenManager(
   304  		parentCtxt, oauthHTTPClient, ClientCredOAuthTokenManagerParam{
   305  			IDPIssuerURL:   config.IssuerURL,
   306  			ClientID:       config.ClientID,
   307  			ClientSecret:   config.ClientSecret,
   308  			TargetAudience: config.TargetAudience,
   309  			LogTags:        config.LogTags,
   310  		},
   311  	)
   312  	if err != nil {
   313  		return err
   314  	}
   315  
   316  	// Build middleware for parent client
   317  	parentClient.OnBeforeRequest(func(c *resty.Client, r *resty.Request) error {
   318  		// Fetch OAuth token for request
   319  		token, err := oauthMgmt.GetToken(parentCtxt, time.Now().UTC())
   320  		if err != nil {
   321  			return err
   322  		}
   323  
   324  		// Apply the token
   325  		r.SetAuthToken(token)
   326  
   327  		return nil
   328  	})
   329  
   330  	return nil
   331  }
   332  
   333  /*
   334  DefineHTTPClient helper function to define a resty HTTP client
   335  
   336  	@param parentCtxt context.Context - caller context
   337  	@param retryConfig HTTPClientRetryConfig - HTTP client retry config
   338  	@param authConfig *HTTPClientAuthConfig - HTTP client auth config
   339  	@returns new resty client
   340  */
   341  func DefineHTTPClient(
   342  	parentCtxt context.Context,
   343  	retryConfig HTTPClientRetryConfig,
   344  	authConfig *HTTPClientAuthConfig,
   345  ) (*resty.Client, error) {
   346  	newClient := resty.New()
   347  
   348  	// Configure resty client retry setting
   349  	newClient = setHTTPClientRetryParam(newClient, retryConfig)
   350  
   351  	// Install OAuth middleware
   352  	if authConfig != nil {
   353  		err := installHTTPClientAuthMiddleware(parentCtxt, newClient, *authConfig, retryConfig)
   354  		if err != nil {
   355  			return nil, err
   356  		}
   357  	}
   358  
   359  	return newClient, nil
   360  }