core/http_client.go
/*
* Nuts node
* Copyright (C) 2021 Nuts community
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
package core
import (
"context"
"fmt"
"github.com/sirupsen/logrus"
"io"
"net/http"
)
// HttpResponseBodyLogClipAt is the maximum length of a response body to log.
// If the response body is longer than this, it will be truncated.
const HttpResponseBodyLogClipAt = 200
// DefaultMaxHttpResponseSize is a default maximum size of an HTTP response body that will be read.
// Very large or unbounded HTTP responses can cause denial-of-service, so it's good to limit how much data is read.
// This of course heavily depends on the use case, but 1MB is a reasonable default.
const DefaultMaxHttpResponseSize = 1024 * 1024
// LimitedReadAll reads the given reader until the DefaultMaxHttpResponseSize is reached.
// It returns an error if more data is available than DefaultMaxHttpResponseSize.
func LimitedReadAll(reader io.Reader) ([]byte, error) {
result, err := io.ReadAll(io.LimitReader(reader, DefaultMaxHttpResponseSize+1))
if len(result) > DefaultMaxHttpResponseSize {
return nil, fmt.Errorf("data to read exceeds max. safety limit of %d bytes", DefaultMaxHttpResponseSize)
}
return result, err
}
// HttpError describes an error returned when invoking a remote server.
type HttpError struct {
error
StatusCode int
ResponseBody []byte
}
// TestResponseCode checks whether the returned HTTP status response code matches the expected code.
// If it doesn't match it returns an error, containing the received and expected status code, and the response body.
func TestResponseCode(expectedStatusCode int, response *http.Response) error {
return TestResponseCodeWithLog(expectedStatusCode, response, nil)
}
// TestResponseCodeWithLog acts like TestResponseCode, but logs the response body if the status code is not as expected.
// It logs using the given logger, unless nil is passed.
func TestResponseCodeWithLog(expectedStatusCode int, response *http.Response, log *logrus.Entry) error {
if response.StatusCode != expectedStatusCode {
responseData, _ := LimitedReadAll(response.Body)
if log != nil {
// Cut off the response body to 100 characters max to prevent logging of large responses
responseBodyString := string(responseData)
if len(responseBodyString) > HttpResponseBodyLogClipAt {
responseBodyString = responseBodyString[:HttpResponseBodyLogClipAt] + "...(clipped)"
}
log.WithField("http_request_path", response.Request.URL.Path).
Infof("Unexpected HTTP response (len=%d): %s", len(responseData), responseBodyString)
}
return HttpError{
error: fmt.Errorf("server returned HTTP %d (expected: %d)", response.StatusCode, expectedStatusCode),
StatusCode: response.StatusCode,
ResponseBody: responseData,
}
}
return nil
}
// UserAgentRequestEditor can be used as request editor function for generated OpenAPI clients,
// to set the HTTP User-Agent header to identify the Nuts node.
func UserAgentRequestEditor(_ context.Context, req *http.Request) error {
req.Header.Set("User-Agent", UserAgent())
return nil
}
// HTTPRequestDoer defines the Do method of the http.Client interface.
type HTTPRequestDoer interface {
Do(*http.Request) (*http.Response, error)
}
// httpRequestDoerAdapter wraps a HTTPRequestFn in a struct, so it can be used where HTTPRequestDoer is required.
type httpRequestDoerAdapter struct {
fn func(req *http.Request) (*http.Response, error)
}
// Do calls the wrapped HTTPRequestFn.
func (w httpRequestDoerAdapter) Do(req *http.Request) (*http.Response, error) {
return w.fn(req)
}
// CreateHTTPClient creates a new HTTP client with the given client configuration.
// The result HTTPRequestDoer can be supplied to OpenAPI generated clients for executing requests.
// This does not use the generated client options for e.g. authentication,
// because each generated OpenAPI client reimplements the client options using structs,
// which makes them incompatible with each other, making it impossible to use write generic client code for common traits like authorization.
// If the given authorization token builder is non-nil, it calls it and passes the resulting token as bearer token with requests.
func CreateHTTPClient(cfg ClientConfig, generator AuthorizationTokenGenerator) (HTTPRequestDoer, error) {
var result *httpRequestDoerAdapter
client := &http.Client{}
client.Timeout = cfg.Timeout
result = &httpRequestDoerAdapter{
fn: client.Do,
}
if generator == nil {
// Add auth interceptor if configured
authToken, err := cfg.GetAuthToken()
if err != nil {
return nil, err
}
if len(authToken) > 0 {
generator = newLegacyTokenGenerator(authToken)
}
}
if generator == nil {
generator = newEmptyTokenGenerator()
}
fn := result.fn
result = &httpRequestDoerAdapter{fn: func(req *http.Request) (*http.Response, error) {
token, err := generator()
if err != nil {
return nil, fmt.Errorf("failed to generate authorization token: %w", err)
}
if len(token) > 0 {
req.Header.Set("Authorization", "Bearer "+token)
}
return fn(req)
}}
return result, nil
}
// MustCreateHTTPClient is like CreateHTTPClient but panics if it returns an error.
func MustCreateHTTPClient(cfg ClientConfig, generator AuthorizationTokenGenerator) HTTPRequestDoer {
client, err := CreateHTTPClient(cfg, generator)
if err != nil {
panic(err)
}
return client
}
// AuthorizationTokenGenerator is a function type definition for creating authorization tokens
type AuthorizationTokenGenerator func() (string, error)
func newLegacyTokenGenerator(token string) AuthorizationTokenGenerator {
return func() (string, error) {
return token, nil
}
}
func newEmptyTokenGenerator() AuthorizationTokenGenerator {
return func() (string, error) {
return "", nil
}
}