diff --git a/builder/builder.go b/builder/builder.go index 9bdcc8f..4fdcf3e 100644 --- a/builder/builder.go +++ b/builder/builder.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/alexellis/hmac/v2" + "github.com/openfaas/go-sdk/internal/httpclient" ) const BuilderConfigFileName = "com.openfaas.docker.config" @@ -80,6 +81,8 @@ func WithHmacAuth(secret string) BuilderOption { // NewFunctionBuilder create a new builder for building OpenFaaS functions using the Function Builder API. func NewFunctionBuilder(url *url.URL, client *http.Client, options ...BuilderOption) *FunctionBuilder { + client = httpclient.WithFaasTransport(client) + b := &FunctionBuilder{ URL: url, diff --git a/client.go b/client.go index 47dc960..ca00a08 100644 --- a/client.go +++ b/client.go @@ -9,15 +9,13 @@ import ( "log" "net/http" "net/url" - "os" "path/filepath" - "sort" "strconv" - "strings" "time" "github.com/openfaas/faas-provider/logs" "github.com/openfaas/faas-provider/types" + "github.com/openfaas/go-sdk/internal/httpclient" ) // Client is used to manage OpenFaaS and invoke functions @@ -39,25 +37,6 @@ type Client struct { fnTokenCache TokenCache } -// Wrap http request Do function to add default headers and support debug capabilities -func (s *Client) do(req *http.Request) (*http.Response, error) { - // Add default user-agent header - if len(req.Header.Get("User-Agent")) == 0 { - req.Header.Set("User-Agent", "openfaas-go-sdk") - } - - if os.Getenv("FAAS_DEBUG") == "1" { - dump, err := dumpRequest(req) - if err != nil { - return nil, err - } - - fmt.Println(dump) - } - - return s.client.Do(req) -} - // ClientAuth an interface for client authentication. // to add authentication to the client implement this interface type ClientAuth interface { @@ -96,6 +75,9 @@ func NewClient(gatewayURL *url.URL, auth ClientAuth, client *http.Client) *Clien // NewClientWithOpts creates a Client for managing OpenFaaS and invoking functions // It takes a list of ClientOptions to configure the client. func NewClientWithOpts(gatewayURL *url.URL, client *http.Client, options ...ClientOption) *Client { + // Wrap http client to add default headers and support debug capabilities + client = httpclient.WithFaasTransport(client) + c := &Client{ GatewayURL: gatewayURL, @@ -136,7 +118,7 @@ func (s *Client) GetNamespaces(ctx context.Context) ([]string, error) { } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return namespaces, fmt.Errorf("unable to make request: %w", err) } @@ -181,7 +163,7 @@ func (s *Client) GetNamespace(ctx context.Context, namespace string) (types.Func } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return types.FunctionNamespace{}, fmt.Errorf("unable to make HTTP request: %w", err) } @@ -240,6 +222,7 @@ func (s *Client) CreateNamespace(ctx context.Context, spec types.FunctionNamespa if err != nil { return http.StatusBadGateway, err } + req.Header.Set("Content-Type", "application/json") if s.ClientAuth != nil { if err := s.ClientAuth.Set(req); err != nil { @@ -247,7 +230,7 @@ func (s *Client) CreateNamespace(ctx context.Context, spec types.FunctionNamespa } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return http.StatusBadGateway, err } @@ -299,6 +282,7 @@ func (s *Client) UpdateNamespace(ctx context.Context, spec types.FunctionNamespa if err != nil { return http.StatusBadGateway, err } + req.Header.Set("Content-Type", "application/json") if s.ClientAuth != nil { if err := s.ClientAuth.Set(req); err != nil { @@ -306,7 +290,7 @@ func (s *Client) UpdateNamespace(ctx context.Context, spec types.FunctionNamespa } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return http.StatusBadGateway, err } @@ -354,13 +338,14 @@ func (s *Client) DeleteNamespace(ctx context.Context, namespace string) error { if err != nil { return fmt.Errorf("cannot connect to OpenFaaS on URL: %s, error: %s", u.String(), err) } + req.Header.Set("Content-Type", "application/json") if s.ClientAuth != nil { if err := s.ClientAuth.Set(req); err != nil { return fmt.Errorf("unable to set Authorization header: %w", err) } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return fmt.Errorf("cannot connect to OpenFaaS on URL: %s, error: %s", s.GatewayURL, err) @@ -414,7 +399,7 @@ func (s *Client) GetFunctions(ctx context.Context, namespace string) ([]types.Fu } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return []types.FunctionStatus{}, fmt.Errorf("unable to make HTTP request: %w", err) } @@ -449,7 +434,7 @@ func (s *Client) GetInfo(ctx context.Context) (SystemInfo, error) { } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return SystemInfo{}, fmt.Errorf("unable to make HTTP request: %w", err) } @@ -491,7 +476,7 @@ func (s *Client) GetFunction(ctx context.Context, name, namespace string) (types } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return types.FunctionStatus{}, fmt.Errorf("unable to make HTTP request: %w", err) } @@ -536,6 +521,7 @@ func (s *Client) deploy(ctx context.Context, method string, spec types.FunctionD if err != nil { return http.StatusBadGateway, err } + req.Header.Set("Content-Type", "application/json") if s.ClientAuth != nil { if err := s.ClientAuth.Set(req); err != nil { @@ -543,7 +529,7 @@ func (s *Client) deploy(ctx context.Context, method string, spec types.FunctionD } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return http.StatusBadGateway, err } @@ -587,13 +573,14 @@ func (s *Client) ScaleFunction(ctx context.Context, functionName, namespace stri if err != nil { return fmt.Errorf("cannot connect to OpenFaaS on URL: %s, error: %s", u.String(), err) } + req.Header.Set("Content-Type", "application/json") if s.ClientAuth != nil { if err := s.ClientAuth.Set(req); err != nil { return fmt.Errorf("unable to set Authorization header: %w", err) } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return fmt.Errorf("cannot connect to OpenFaaS on URL: %s, error: %s", s.GatewayURL, err) @@ -645,13 +632,14 @@ func (s *Client) DeleteFunction(ctx context.Context, functionName, namespace str if err != nil { return fmt.Errorf("cannot connect to OpenFaaS on URL: %s, error: %s", u.String(), err) } + req.Header.Set("Content-Type", "application/json") if s.ClientAuth != nil { if err := s.ClientAuth.Set(req); err != nil { return fmt.Errorf("unable to set Authorization header: %w", err) } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return fmt.Errorf("cannot connect to OpenFaaS on URL: %s, error: %s", s.GatewayURL, err) @@ -705,7 +693,7 @@ func (s *Client) GetSecrets(ctx context.Context, namespace string) ([]types.Secr } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return []types.Secret{}, fmt.Errorf("unable to make HTTP request: %w", err) } @@ -742,6 +730,7 @@ func (s *Client) CreateSecret(ctx context.Context, spec types.Secret) (int, erro if err != nil { return http.StatusBadGateway, err } + req.Header.Set("Content-Type", "application/json") if s.ClientAuth != nil { if err := s.ClientAuth.Set(req); err != nil { @@ -749,7 +738,7 @@ func (s *Client) CreateSecret(ctx context.Context, spec types.Secret) (int, erro } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return http.StatusBadGateway, err } @@ -789,6 +778,7 @@ func (s *Client) UpdateSecret(ctx context.Context, spec types.Secret) (int, erro if err != nil { return http.StatusBadGateway, err } + req.Header.Set("Content-Type", "application/json") if s.ClientAuth != nil { if err := s.ClientAuth.Set(req); err != nil { @@ -796,7 +786,7 @@ func (s *Client) UpdateSecret(ctx context.Context, spec types.Secret) (int, erro } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return http.StatusBadGateway, err } @@ -842,13 +832,14 @@ func (s *Client) DeleteSecret(ctx context.Context, secretName, namespace string) if err != nil { return fmt.Errorf("cannot connect to OpenFaaS on URL: %s, error: %s", u.String(), err) } + req.Header.Set("Content-Type", "application/json") if s.ClientAuth != nil { if err := s.ClientAuth.Set(req); err != nil { return fmt.Errorf("unable to set Authorization header: %w", err) } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return fmt.Errorf("cannot connect to OpenFaaS on URL: %s, error: %s", s.GatewayURL, err) @@ -925,7 +916,7 @@ func (s *Client) GetLogs(ctx context.Context, functionName, namespace string, fo } } - res, err := s.do(req) + res, err := s.client.Do(req) if err != nil { return nil, fmt.Errorf("cannot connect to OpenFaaS on URL: %s, error: %s", s.GatewayURL, err) @@ -971,51 +962,3 @@ func (s *Client) GetLogs(ctx context.Context, functionName, namespace string, fo } return logStream, nil } - -func dumpRequest(req *http.Request) (string, error) { - var sb strings.Builder - - // Get all header keys and sort them - keys := make([]string, 0, len(req.Header)) - for k := range req.Header { - keys = append(keys, k) - } - sort.Strings(keys) - - sb.WriteString(fmt.Sprintf("%s %s\n", req.Method, req.URL.String())) - for _, k := range keys { - v := req.Header[k] - if k == "Authorization" { - auth := "[REDACTED]" - if len(v) == 0 { - auth = "[NOT_SET]" - } else { - l, _, ok := strings.Cut(v[0], " ") - if ok && (l == "Basic" || l == "Bearer") { - auth = l + " [REDACTED]" - } - } - sb.WriteString(fmt.Sprintf("%s: %s\n", k, auth)) - - } else { - sb.WriteString(fmt.Sprintf("%s: %s\n", k, v)) - } - } - - if req.Body != nil { - r := io.NopCloser(req.Body) - buf := new(strings.Builder) - _, err := io.Copy(buf, r) - if err != nil { - return "", err - } - bodyDebug := buf.String() - if len(bodyDebug) > 0 { - sb.WriteString(fmt.Sprintf("%s\n", bodyDebug)) - - } - req.Body = io.NopCloser(strings.NewReader(buf.String())) - } - - return sb.String(), nil -} diff --git a/client_test.go b/client_test.go index 9da0596..2c8db1e 100644 --- a/client_test.go +++ b/client_test.go @@ -4,12 +4,10 @@ import ( "context" "errors" "fmt" - "io" "log" "net/http" "net/http/httptest" "net/url" - "strings" "testing" "time" @@ -785,103 +783,3 @@ func TestSdk_DeleteSecret(t *testing.T) { }) } } - -func Test_dumpRequest(t *testing.T) { - tests := []struct { - name string - req *http.Request - want string - }{ - { - name: "request without body", - req: &http.Request{ - Method: http.MethodPost, - URL: &url.URL{ - Scheme: "https", - Host: "gw.example.com", - Path: "/function/env.openfaas-fn", - }, - }, - want: "POST https://gw.example.com/function/env.openfaas-fn\n", - }, - { - name: "request with headers", - req: &http.Request{ - Method: http.MethodPost, - URL: &url.URL{ - Scheme: "https", - Host: "gw.example.com", - Path: "/function/env.openfaas-fn", - }, - Header: http.Header{ - "Content-Type": []string{"text/plain"}, - "User-Agent": []string{"openfaas-go-sdk"}, - }, - }, - want: "POST https://gw.example.com/function/env.openfaas-fn\n" + - "Content-Type: [text/plain]\n" + - "User-Agent: [openfaas-go-sdk]\n", - }, - { - name: "request with body", - req: &http.Request{ - Method: http.MethodPost, - URL: &url.URL{ - Scheme: "https", - Host: "gw.example.com", - Path: "/function/env.openfaas-fn", - }, - Header: http.Header{}, - Body: io.NopCloser(strings.NewReader("Hello OpenFaaS!!")), - }, - want: "POST https://gw.example.com/function/env.openfaas-fn\n" + - "Hello OpenFaaS!!\n", - }, - { - name: "request with bearer auth", - req: &http.Request{ - Method: http.MethodPost, - URL: &url.URL{ - Scheme: "https", - Host: "gw.example.com", - Path: "/function/env.openfaas-fn", - }, - Header: http.Header{ - "Authorization": []string{"Bearer secret openfaas-token"}, - }, - }, - want: "POST https://gw.example.com/function/env.openfaas-fn\n" + - "Authorization: Bearer [REDACTED]\n", - }, - { - name: "request with basic auth", - req: &http.Request{ - Method: http.MethodPost, - URL: &url.URL{ - Scheme: "https", - Host: "gw.example.com", - Path: "/function/env.openfaas-fn", - }, - Header: http.Header{ - "Authorization": []string{"Basic username:password"}, - }, - }, - want: "POST https://gw.example.com/function/env.openfaas-fn\n" + - "Authorization: Basic [REDACTED]\n", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got, err := dumpRequest(test.req) - - if err != nil { - t.Errorf("want %s, but got error: %s", test.want, err) - } - - if test.want != got { - t.Errorf("want %s, but got: %s", test.want, got) - } - }) - } -} diff --git a/exchange.go b/exchange.go index b5ca5ab..d39cf95 100644 --- a/exchange.go +++ b/exchange.go @@ -8,6 +8,8 @@ import ( "net/url" "os" "strings" + + "github.com/openfaas/go-sdk/internal/httpclient" ) // Exchange an OIDC ID Token from an IdP for OpenFaaS token @@ -46,7 +48,7 @@ func ExchangeIDToken(tokenURL, rawIDToken string, options ...ExchangeOption) (*T req.Header.Set("User-Agent", "openfaas-go-sdk") if os.Getenv("FAAS_DEBUG") == "1" { - dump, err := dumpRequest(req) + dump, err := httpclient.DumpRequest(req) if err != nil { return nil, err } diff --git a/functions.go b/functions.go index ae14c9e..e3310bc 100644 --- a/functions.go +++ b/functions.go @@ -59,5 +59,5 @@ func (c *Client) InvokeFunction(name, namespace string, async bool, auth bool, r req.Header.Add("Authorization", "Bearer "+bearer) } - return c.do(req) + return c.client.Do(req) } diff --git a/internal/httpclient/transport.go b/internal/httpclient/transport.go new file mode 100644 index 0000000..278c34c --- /dev/null +++ b/internal/httpclient/transport.go @@ -0,0 +1,125 @@ +package httpclient + +import ( + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" +) + +// FaasTransport is an http.RoundTripper that adds default headers and request logging capabilities. +// Requests will be logged to the console if the FAAS_DEBUG environment variable is set to 1. +type FaasTransport struct { + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +func (t *FaasTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Add default user-agent header + if len(req.Header.Get("User-Agent")) == 0 { + req.Header.Set("User-Agent", "openfaas-go-sdk") + } + + // If the FAAS_DEBUG environment variable is set to 1, dump the request to the console + if os.Getenv("FAAS_DEBUG") == "1" { + dump, err := DumpRequest(req) + if err != nil { + return nil, err + } + + fmt.Println(dump) + } + + // Call the underlying transport + return t.transport().RoundTrip(req) +} + +func (t *FaasTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} + +// WithFaasTransport clones the http.Client and wraps the Transport with a FaasTransport. +// If the provided client is nil, the http.DefaultClient is used. +func WithFaasTransport(client *http.Client) *http.Client { + if client == nil { + return &http.Client{ + Transport: &FaasTransport{}, + } + } + + decoratedClient := &http.Client{} + decoratedClient.Transport = &FaasTransport{ + Transport: client.Transport, + } + decoratedClient.CheckRedirect = client.CheckRedirect + decoratedClient.Jar = client.Jar + decoratedClient.Timeout = client.Timeout + + return decoratedClient +} + +func DumpRequest(req *http.Request) (string, error) { + var sb strings.Builder + + // Get all header keys and sort them + keys := make([]string, 0, len(req.Header)) + for k := range req.Header { + keys = append(keys, k) + } + sort.Strings(keys) + + sb.WriteString(fmt.Sprintf("%s %s\n", req.Method, req.URL.String())) + for _, k := range keys { + v := req.Header[k] + if k == "Authorization" { + auth := "[REDACTED]" + if len(v) == 0 { + auth = "[NOT_SET]" + } else { + l, _, ok := strings.Cut(v[0], " ") + if ok && (l == "Basic" || l == "Bearer") { + auth = l + " [REDACTED]" + } + } + sb.WriteString(fmt.Sprintf("%s: %s\n", k, auth)) + + } else { + sb.WriteString(fmt.Sprintf("%s: %s\n", k, v)) + } + } + + contentType := req.Header.Get("Content-Type") + if req.Body != nil && isPrintableContentType(contentType) { + r := io.NopCloser(req.Body) + buf := new(strings.Builder) + _, err := io.Copy(buf, r) + if err != nil { + return "", err + } + bodyDebug := buf.String() + if len(bodyDebug) > 0 { + sb.WriteString(fmt.Sprintf("%s\n", bodyDebug)) + + } + } + + return sb.String(), nil +} + +func isPrintableContentType(contentType string) bool { + contentType = strings.ToLower(contentType) + + if strings.Contains(contentType, "application/json") || + strings.Contains(contentType, "text/plain") || + strings.Contains(contentType, "application/x-www-form-urlencoded") { + return true + } + + return false +} diff --git a/internal/httpclient/transport_test.go b/internal/httpclient/transport_test.go new file mode 100644 index 0000000..e2f5d53 --- /dev/null +++ b/internal/httpclient/transport_test.go @@ -0,0 +1,112 @@ +package httpclient + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" +) + +func Test_dumpRequest(t *testing.T) { + tests := []struct { + name string + req *http.Request + want string + }{ + { + name: "request without body", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n", + }, + { + name: "request with headers", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + Header: http.Header{ + "Content-Type": []string{"text/plain"}, + "User-Agent": []string{"openfaas-go-sdk"}, + }, + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n" + + "Content-Type: [text/plain]\n" + + "User-Agent: [openfaas-go-sdk]\n", + }, + { + name: "request with body", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + Header: http.Header{ + "Content-Type": []string{"text/plain"}, + }, + Body: io.NopCloser(strings.NewReader("Hello OpenFaaS!!")), + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n" + + "Content-Type: [text/plain]\n" + + "Hello OpenFaaS!!\n", + }, + { + name: "request with bearer auth", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + Header: http.Header{ + "Authorization": []string{"Bearer secret openfaas-token"}, + }, + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n" + + "Authorization: Bearer [REDACTED]\n", + }, + { + name: "request with basic auth", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + Header: http.Header{ + "Authorization": []string{"Basic username:password"}, + }, + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n" + + "Authorization: Basic [REDACTED]\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := DumpRequest(test.req) + + if err != nil { + t.Errorf("want %s, but got error: %s", test.want, err) + } + + if test.want != got { + t.Errorf("want %s, but got: %s", test.want, got) + } + }) + } +}