diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..ad07fc4 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Run tests + run: go test ./... \ No newline at end of file diff --git a/README.md b/README.md index 6b14009..1abd139 100644 --- a/README.md +++ b/README.md @@ -10,35 +10,47 @@ go get github.com/fervbmx/interceptor ## Usage +Import both packages: + +```go +import ( + "net/http" + + "github.com/fervbmx/interceptor" + "github.com/fervbmx/interceptor/interceptors" +) +``` + ```go -// Flow: HeaderInterceptor → BasicAuthInterceptor → http.DefaultTransport +// Flow: RequestLogging → Header → BasicAuth → http.DefaultTransport client := &http.Client{ - Transport: interceptor.NewTransportInterceptor( + Transport: interceptor.NewTransport( nil, - interceptors.HeaderInterceptor("X-API-KEY", "secret"), - interceptors.BasicAuthInterceptor("user", "pass"), + interceptors.Header("X-API-KEY", "secret"), + interceptors.BasicAuth("user", "pass"), + interceptors.RequestLogging(nil), ), } ``` -Pass `nil` as the first argument to use `http.DefaultTransport`, or provide your own `http.RoundTripper`. +Pass `nil` as the first argument to use `http.DefaultTransport`, or pass a custom `http.RoundTripper` as the base transport. ## Built-in interceptors | Interceptor | Description | |---|---| -| `HeaderInterceptor(key, value)` | Sets a header on every request | -| `BasicAuthInterceptor(user, password)` | Sets Basic authentication | +| `Header(key, value)` | Sets a header on every request | +| `BasicAuth(user, password)` | Sets Basic authentication | +| `RequestLogging(opts)` | Emits structured `slog` attributes | ## Custom interceptors -Write your own `InterceptorFunc` to hook into the request/response lifecycle. Call `next` to continue the chain, or return early to short-circuit it. +Write your own `interceptor.Middleware` to hook into the request/response lifecycle. Call `next` to continue the chain, or return early to short-circuit it. ```go // Log every request and its status code. -func LogginInterceptor(req *http.Request, next interceptor.HandlerFunc) (*http.Response, error) { +func Logging(req *http.Request, next interceptor.HandlerFunc) (*http.Response, error) { log.Printf("→ %s %s", req.Method, req.URL) - resp, err := next(req) if err != nil { return nil, err @@ -48,9 +60,9 @@ func LogginInterceptor(req *http.Request, next interceptor.HandlerFunc) (*http.R } client := &http.Client{ - Transport: interceptor.NewTransportInterceptor( + Transport: interceptor.NewTransport( http.DefaultTransport, - interceptor.LogginInterceptor + Logging, ), } ``` diff --git a/interceptors/auth.go b/interceptors/auth.go index ba13c16..96959d6 100644 --- a/interceptors/auth.go +++ b/interceptors/auth.go @@ -6,16 +6,16 @@ import ( "github.com/fervbmx/interceptor" ) -// BasicAuthInterceptor returns an interceptor that sets Basic authentication on every +// BasicAuth returns an interceptor that sets Basic authentication on every // outgoing request. // -// interceptor.NewTransportInterceptor(nil, -// interceptors.BasicAuthInterceptor("username", "password"), +// interceptor.NewTransport(nil, +// interceptors.BasicAuth("username", "password"), // ) -func BasicAuthInterceptor(username, password string) interceptor.InterceptorFunc { +func BasicAuth(username, password string) interceptor.Middleware { return func(req *http.Request, next interceptor.HandlerFunc) (*http.Response, error) { - req = req.Clone(req.Context()) - req.SetBasicAuth(username, password) - return next(req) + clonedReq := req.Clone(req.Context()) + clonedReq.SetBasicAuth(username, password) + return next(clonedReq) } } diff --git a/interceptors/auth_test.go b/interceptors/auth_test.go index 91cffa7..da62772 100644 --- a/interceptors/auth_test.go +++ b/interceptors/auth_test.go @@ -10,7 +10,7 @@ import ( "github.com/fervbmx/interceptor/interceptors" ) -func TestBasicAuthInterceptor(t *testing.T) { +func TestBasicAuth(t *testing.T) { cases := []struct { name string username string @@ -28,8 +28,8 @@ func TestBasicAuthInterceptor(t *testing.T) { }, } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { var username, password string var ok bool @@ -39,9 +39,9 @@ func TestBasicAuthInterceptor(t *testing.T) { t.Cleanup(server.Close) client := http.Client{ - Transport: interceptor.NewTransportInterceptor( + Transport: interceptor.NewTransport( http.DefaultTransport, - interceptors.BasicAuthInterceptor(c.username, c.password), + interceptors.BasicAuth(tc.username, tc.password), ), Timeout: 15 * time.Second, } @@ -50,8 +50,9 @@ func TestBasicAuthInterceptor(t *testing.T) { if err != nil { t.Fatalf("client.Get() returned error: %v", err) } + defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status code: %d", resp.StatusCode) } @@ -59,12 +60,12 @@ func TestBasicAuthInterceptor(t *testing.T) { t.Fatal("BasicAuth() returned ok=false, want true") } - if username != c.username { - t.Errorf("username = %q, want %q", username, c.username) + if username != tc.username { + t.Errorf("username = %q, want %q", username, tc.username) } - if password != c.password { - t.Errorf("password = %q, want %q", password, c.password) + if password != tc.password { + t.Errorf("password = %q, want %q", password, tc.password) } }) } diff --git a/interceptors/headers.go b/interceptors/headers.go index f4fa84a..66561df 100644 --- a/interceptors/headers.go +++ b/interceptors/headers.go @@ -6,16 +6,16 @@ import ( "github.com/fervbmx/interceptor" ) -// HeaderInterceptor returns an interceptor that sets a header on every outgoing +// Header returns an interceptor that sets a header on every outgoing // request. // -// interceptor.NewTransportInterceptor(nil, -// interceptors.HeaderInterceptor("User-Agent", "MyApp/1.0"), +// interceptor.NewTransport(nil, +// interceptors.Header("User-Agent", "MyApp/1.0"), // ) -func HeaderInterceptor(key, value string) interceptor.InterceptorFunc { +func Header(key, value string) interceptor.Middleware { return func(req *http.Request, next interceptor.HandlerFunc) (*http.Response, error) { - req = req.Clone(req.Context()) - req.Header.Set(key, value) - return next(req) + clonedReq := req.Clone(req.Context()) + clonedReq.Header.Set(key, value) + return next(clonedReq) } } diff --git a/interceptors/headers_test.go b/interceptors/headers_test.go index 93ac304..73f11c2 100644 --- a/interceptors/headers_test.go +++ b/interceptors/headers_test.go @@ -33,19 +33,19 @@ func TestHeaderInterceptor(t *testing.T) { }, } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { var header string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - header = r.Header.Get(c.key) + header = r.Header.Get(tc.key) })) t.Cleanup(server.Close) client := http.Client{ - Transport: interceptor.NewTransportInterceptor( + Transport: interceptor.NewTransport( http.DefaultTransport, - interceptors.HeaderInterceptor(c.key, c.value), + interceptors.Header(tc.key, tc.value), ), Timeout: 15 * time.Second, } @@ -54,13 +54,14 @@ func TestHeaderInterceptor(t *testing.T) { if err != nil { t.Fatalf("client.Get() returned error: %v", err) } + defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status code: %d", resp.StatusCode) } - if header != c.value { - t.Errorf("Header %q = %q, want %q", c.key, header, c.value) + if header != tc.value { + t.Errorf("Header %q = %q, want %q", tc.key, header, tc.value) } }) } diff --git a/interceptors/logging.go b/interceptors/logging.go new file mode 100644 index 0000000..a94ea7b --- /dev/null +++ b/interceptors/logging.go @@ -0,0 +1,384 @@ +package interceptors + +import ( + "log/slog" + "net/http" + "net/textproto" + "net/url" + "reflect" + "strconv" + "strings" + "time" + + "github.com/fervbmx/interceptor" +) + +const redacted = "REDACTED" + +var defaultSensitiveHeaders = newHeaderSet( + "Authorization", + "Cookie", + "Set-Cookie", +) + +type requestLoggingConfig struct { + logger *slog.Logger + headersToLog map[string]struct{} + sensitiveHeaders map[string]struct{} +} + +type eventData struct { + level slog.Level + message string + method string + urlFull string + urlScheme string + serverAddress string + serverPort int + statusCode *int + userAgent string + errorType string + requestBodySize *int64 + responseBodySize *int64 + requestHeaders map[string]string + duration *float64 +} + +// RequestLoggingOptions configures request logging behavior. +type RequestLoggingOptions struct { + Logger *slog.Logger + HeadersToLog []string + SensitiveHeaders []string +} + +// RequestLogging returns an interceptor that logs request lifecycle events +// before and after the next handler runs. It emits a start event, then either a +// completion event or a failure event. If opts is nil, default logging options +// are used. +// +// interceptor.NewTransport(nil, +// interceptors.RequestLogging(&interceptors.RequestLoggingOptions{ +// HeadersToLog: []string{"User-Agent"}, +// }), +// ) + +func RequestLogging(opts *RequestLoggingOptions) interceptor.Middleware { + cfg := buildLoggingConfig(opts) + + return func(req *http.Request, next interceptor.HandlerFunc) (*http.Response, error) { + clonedReq := req.Clone(req.Context()) + + startEvent := buildStartEvent(clonedReq, cfg) + emitLog(clonedReq, cfg, startEvent) + + start := time.Now() + resp, err := next(clonedReq) + duration := time.Since(start) + + endEvent := buildEndEvent(clonedReq, resp, err, duration) + emitLog(clonedReq, cfg, endEvent) + + return resp, err + } +} + +// buildLoggingConfig merges options with defaults and normalizes header names. +func buildLoggingConfig(opts *RequestLoggingOptions) requestLoggingConfig { + cfg := requestLoggingConfig{ + logger: slog.Default(), + headersToLog: make(map[string]struct{}), + sensitiveHeaders: defaultSensitiveHeaders, + } + + if opts == nil { + return cfg + } + + if opts.Logger != nil { + cfg.logger = opts.Logger + } + + if len(opts.HeadersToLog) > 0 { + cfg.headersToLog = newHeaderSet(opts.HeadersToLog...) + } + + if len(opts.SensitiveHeaders) > 0 { + cfg.sensitiveHeaders = newHeaderSet(opts.SensitiveHeaders...) + } + + return cfg +} + +func newHeaderSet(headers ...string) map[string]struct{} { + set := make(map[string]struct{}, len(headers)) + for _, h := range headers { + set[textproto.CanonicalMIMEHeaderKey(h)] = struct{}{} + } + return set +} + +// buildStartEvent assembles attributes for the request-start log entry. +func buildStartEvent(req *http.Request, cfg requestLoggingConfig) eventData { + e := eventData{ + level: slog.LevelInfo, + message: "http request started", + method: req.Method, + urlFull: req.URL.String(), + urlScheme: req.URL.Scheme, + serverAddress: req.URL.Hostname(), + serverPort: getServerPort(req.URL), + userAgent: req.Header.Get("User-Agent"), + requestHeaders: getAllowedHeaders(req.Header, cfg.headersToLog, cfg.sensitiveHeaders), + } + + if req.ContentLength >= 0 { + e.requestBodySize = &req.ContentLength + } + + return e +} + +// buildEndEvent assembles attributes for completion and failure log entries. +func buildEndEvent(req *http.Request, resp *http.Response, err error, duration time.Duration) eventData { + seconds := duration.Seconds() + e := eventData{ + level: getLogLevel(resp, err), + method: req.Method, + urlFull: req.URL.String(), + urlScheme: req.URL.Scheme, + serverAddress: req.URL.Hostname(), + serverPort: getServerPort(req.URL), + duration: &seconds, + } + + if req.ContentLength >= 0 { + e.requestBodySize = &req.ContentLength + } + + if err != nil { + e.message = "http request failed" + e.errorType = getErrorType(err) + return e + } + + e.message = "http request completed" + if resp != nil { + e.statusCode = &resp.StatusCode + if resp.ContentLength >= 0 { + e.responseBodySize = &resp.ContentLength + } + if resp.StatusCode >= http.StatusBadRequest { + e.errorType = strconv.Itoa(resp.StatusCode) + } + } + + return e +} + +func getLogLevel(resp *http.Response, err error) slog.Level { + if err != nil { + return slog.LevelError + } + if resp == nil { + return slog.LevelError + } + if resp.StatusCode >= http.StatusInternalServerError { + return slog.LevelError + } + if resp.StatusCode >= http.StatusBadRequest { + return slog.LevelWarn + } + return slog.LevelInfo +} + +func emitLog(req *http.Request, cfg requestLoggingConfig, event eventData) { + attrs := buildAttrs(event) + cfg.logger.LogAttrs(req.Context(), event.level, event.message, attrs...) +} + +func buildAttrs(event eventData) []slog.Attr { + attrs := make([]slog.Attr, 0, 6) + + attrs = append(attrs, buildURLAttrs(event)) + attrs = append(attrs, buildServerAttrs(event)) + attrs = append(attrs, buildHTTPAttrs(event)) + + if event.userAgent != "" { + attrs = append(attrs, + slog.Group("user_agent", + slog.String("original", event.userAgent), + ), + ) + } + + if event.errorType != "" { + attrs = append(attrs, + slog.Group("error", + slog.String("type", event.errorType), + ), + ) + } + + return attrs +} + +func buildURLAttrs(event eventData) slog.Attr { + attrs := make([]any, 0, 2) + attrs = append(attrs, slog.String("full", event.urlFull)) + + if event.urlScheme != "" { + attrs = append(attrs, slog.String("scheme", event.urlScheme)) + } + + return slog.Group("url", attrs...) +} + +func buildServerAttrs(event eventData) slog.Attr { + attrs := make([]any, 0, 2) + attrs = append(attrs, slog.String("address", event.serverAddress)) + + if event.serverPort > 0 { + attrs = append(attrs, slog.Int("port", event.serverPort)) + } + + return slog.Group("server", attrs...) +} + +func buildHTTPAttrs(event eventData) slog.Attr { + attrs := make([]any, 0, 3) + + attrs = append(attrs, buildHTTPRequestAttrs(event)) + attrs = append(attrs, buildHTTPResponseAttrs(event)) + attrs = append(attrs, buildHTTPClientAttrs(event)) + + return slog.Group("http", attrs...) +} + +func buildHTTPRequestAttrs(event eventData) slog.Attr { + attrs := make([]any, 0, 3) + attrs = append(attrs, slog.String("method", event.method)) + attrs = append(attrs, buildHTTPRequestHeaderAttrs(event)) + + if event.requestBodySize != nil { + attrs = append(attrs, + slog.Group("body", + slog.Int64("size", *event.requestBodySize), + ), + ) + } + + return slog.Group("request", attrs...) +} + +func buildHTTPRequestHeaderAttrs(event eventData) slog.Attr { + if len(event.requestHeaders) == 0 { + return slog.Attr{} + } + + attrs := make([]any, 0, len(event.requestHeaders)) + for k, v := range event.requestHeaders { + attrs = append(attrs, slog.String(strings.ToLower(k), v)) + } + + return slog.Group("header", attrs...) +} + +func buildHTTPResponseAttrs(event eventData) slog.Attr { + if event.statusCode == nil && event.responseBodySize == nil { + return slog.Attr{} + } + + attrs := make([]any, 0, 2) + if event.statusCode != nil { + attrs = append(attrs, slog.Int("status_code", *event.statusCode)) + } + + if event.responseBodySize != nil { + attrs = append(attrs, + slog.Group("body", + slog.Int64("size", *event.responseBodySize), + ), + ) + } + + return slog.Group("response", attrs...) +} + +func buildHTTPClientAttrs(event eventData) slog.Attr { + if event.duration == nil { + return slog.Attr{} + } + + return slog.Group("client", + slog.Group("request", slog.Float64("duration", *event.duration)), + ) +} + +// getErrorType returns port of the url. +func getServerPort(u *url.URL) int { + if u == nil { + return 0 + } + if port := u.Port(); port != "" { + value, err := strconv.Atoi(port) + if err == nil { + return value + } + } + switch strings.ToLower(u.Scheme) { + case "http": + return 80 + case "https": + return 443 + default: + return 0 + } +} + +// getErrorType returns the root error type name. +func getErrorType(err error) string { + if err == nil { + return "" + } + + t := reflect.TypeOf(err) + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + if name := t.Name(); name != "" { + return name + } + + return t.String() +} + +// getAllowedHeaders returns allowed headers with sensitive values redacted. +func getAllowedHeaders(headers http.Header, allowed, sensitive map[string]struct{}) map[string]string { + if len(headers) == 0 || len(allowed) == 0 { + return nil + } + + logged := make(map[string]string, len(headers)) + + for key, values := range headers { + if _, ok := allowed[key]; !ok { + continue + } + + if _, ok := sensitive[key]; ok { + logged[key] = redacted + continue + } + + if len(values) > 0 { + logged[key] = values[0] + } + } + + if len(logged) == 0 { + return nil + } + + return logged +} diff --git a/interceptors/logging_test.go b/interceptors/logging_test.go new file mode 100644 index 0000000..4820f5a --- /dev/null +++ b/interceptors/logging_test.go @@ -0,0 +1,321 @@ +package interceptors_test + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "testing" + + "github.com/fervbmx/interceptor" + "github.com/fervbmx/interceptor/interceptors" +) + +type capturedRecord struct { + level slog.Level + msg string + attrs map[string]any +} + +type captureHandler struct { + mu sync.Mutex + records []capturedRecord + level slog.Level +} + +func (h *captureHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + + attrs := make(map[string]any) + r.Attrs(func(a slog.Attr) bool { + resolveAttr(attrs, "", a) + return true + }) + + h.records = append(h.records, capturedRecord{level: r.Level, msg: r.Message, attrs: attrs}) + return nil +} + +func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } + +func (h *captureHandler) WithGroup(_ string) slog.Handler { return h } + +func (h *captureHandler) snapshot() []capturedRecord { + h.mu.Lock() + defer h.mu.Unlock() + + out := make([]capturedRecord, len(h.records)) + copy(out, h.records) + return out +} + +func resolveAttr(dest map[string]any, prefix string, a slog.Attr) { + key := a.Key + if prefix != "" { + key = prefix + "." + key + } + + if a.Value.Kind() == slog.KindGroup { + for _, ga := range a.Value.Group() { + resolveAttr(dest, key, ga) + } + return + } + + dest[key] = a.Value.Any() +} + +func newCaptureLogger() (*slog.Logger, *captureHandler) { + h := &captureHandler{level: slog.LevelDebug} + return slog.New(h), h +} + +type typedErr struct{} + +func (typedErr) Error() string { return "typed" } + +func TestRequestLogging_SuccessWithHeaders(t *testing.T) { + logger, sink := newCaptureLogger() + + transport := interceptor.NewTransport(nil, + interceptors.RequestLogging(&interceptors.RequestLoggingOptions{ + Logger: logger, + HeadersToLog: []string{"Authorization", "X-Correlation-ID"}, + }), + func(req *http.Request, _ interceptor.HandlerFunc) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + ContentLength: 2, + Body: io.NopCloser(strings.NewReader("ok")), + }, nil + }, + ) + + client := &http.Client{ + Transport: transport, + } + + req, err := http.NewRequest(http.MethodPost, "https://api.example.com/v1/items", strings.NewReader("abc")) + if err != nil { + t.Fatalf("http.NewRequest error: %v", err) + } + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("X-Correlation-ID", "corr-1") + req.Header.Set("User-Agent", "interceptor-tests/1.0") + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("client.Do error: %v", err) + } + _ = resp.Body.Close() + + records := sink.snapshot() + if len(records) != 2 { + t.Fatalf("len(records) = %d, want 2", len(records)) + } + + start := records[0] + if start.msg != "http request started" { + t.Fatalf("start message = %q, want %q", start.msg, "http request started") + } + if got := start.attrs["http.request.method"]; got != http.MethodPost { + t.Fatalf("http.request.method = %q, want %q", got, http.MethodPost) + } + if got := start.attrs["http.request.header.authorization"]; got != "REDACTED" { + t.Fatalf("http.request.header.authorization = %q, want %q", got, "REDACTED") + } + if got := start.attrs["http.request.header.x-correlation-id"]; got != "corr-1" { + t.Fatalf("http.request.header.x-correlation-id = %q, want %q", got, "corr-1") + } + if got := start.attrs["user_agent.original"]; got != "interceptor-tests/1.0" { + t.Fatalf("user_agent.original = %q, want %q", got, "interceptor-tests/1.0") + } + if got := fmt.Sprint(start.attrs["http.request.body.size"]); got != "3" { + t.Fatalf("http.request.body.size = %v, want %d", got, 3) + } + + end := records[1] + if end.level != slog.LevelInfo { + t.Fatalf("end level = %v, want INFO", end.level) + } + if got := fmt.Sprint(end.attrs["http.response.status_code"]); got != fmt.Sprint(http.StatusOK) { + t.Fatalf("http.response.status_code = %v, want %d", got, int64(http.StatusOK)) + } + if got := fmt.Sprint(end.attrs["http.response.body.size"]); got != "2" { + t.Fatalf("http.response.body.size = %v, want %d", got, 2) + } + if _, ok := end.attrs["http.client.request.duration"]; !ok { + t.Fatal("http.client.request.duration missing") + } + if _, ok := end.attrs["error.type"]; ok { + t.Fatalf("error.type should not exist on success: %+v", end.attrs) + } +} + +func TestRequestLogging_StatusLevels(t *testing.T) { + testCases := []struct { + name string + status int + wantLevel slog.Level + wantError string + }{ + {name: "2xx", status: http.StatusOK, wantLevel: slog.LevelInfo}, + {name: "4xx", status: http.StatusNotFound, wantLevel: slog.LevelWarn, wantError: "404"}, + {name: "5xx", status: http.StatusInternalServerError, wantLevel: slog.LevelError, wantError: "500"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + logger, sink := newCaptureLogger() + transport := interceptor.NewTransport(nil, + interceptors.RequestLogging(&interceptors.RequestLoggingOptions{Logger: logger}), + func(req *http.Request, _ interceptor.HandlerFunc) (*http.Response, error) { + return &http.Response{ + StatusCode: tc.status, + Body: io.NopCloser(strings.NewReader("")), + }, nil + }, + ) + + client := &http.Client{ + Transport: transport, + } + + resp, err := client.Get("http://example.com") + if err != nil { + t.Fatalf("client.Get error: %v", err) + } + _ = resp.Body.Close() + + end := sink.snapshot()[1] + if end.level != tc.wantLevel { + t.Fatalf("end level = %v, want %v", end.level, tc.wantLevel) + } + _, hasError := end.attrs["error.type"] + if tc.wantError == "" { + if hasError { + t.Fatalf("error.type should not exist: %+v", end.attrs) + } + } else { + if !hasError { + t.Fatal("error.type missing") + } + if got := end.attrs["error.type"]; got != tc.wantError { + t.Fatalf("error.type = %q, want %q", got, tc.wantError) + } + } + }) + } +} + +func TestRequestLogging_TransportErrors(t *testing.T) { + testCases := []struct { + name string + err error + wantType string + }{ + {name: "typed error", err: typedErr{}, wantType: "typedErr"}, + {name: "generic error", err: errors.New("dial failed"), wantType: "errorString"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + logger, sink := newCaptureLogger() + + transport := interceptor.NewTransport(nil, + interceptors.RequestLogging(&interceptors.RequestLoggingOptions{ + Logger: logger, + }), + func(req *http.Request, _ interceptor.HandlerFunc) (*http.Response, error) { + return nil, tc.err + }, + ) + + client := &http.Client{ + Transport: transport, + } + + _, err := client.Get("http://example.com") + if err == nil { + t.Fatal("expected error, got nil") + } + + records := sink.snapshot() + if len(records) != 2 { + t.Fatalf("len(records) = %d, want 2", len(records)) + } + if records[1].level != slog.LevelError { + t.Fatalf("level = %v, want ERROR", records[1].level) + } + if got := records[1].attrs["error.type"]; got != tc.wantType { + t.Fatalf("error.type = %q, want %q", got, tc.wantType) + } + }) + } +} + +func TestRequestLogging_HTTPSDefaultPort(t *testing.T) { + testCases := []struct { + name string + url string + wantHost string + wantPort int64 + portLogged bool + }{ + {name: "https default port", url: "https://example.com/resource", wantHost: "example.com", wantPort: 443, portLogged: true}, + {name: "http default port", url: "http://example.com/resource", wantHost: "example.com", wantPort: 80, portLogged: true}, + {name: "explicit port", url: "https://example.com:8443/resource", wantHost: "example.com", wantPort: 8443, portLogged: true}, + {name: "unknown scheme", url: "ftp://example.com/resource", wantHost: "example.com", portLogged: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + logger, sink := newCaptureLogger() + + transport := interceptor.NewTransport(nil, + interceptors.RequestLogging(&interceptors.RequestLoggingOptions{Logger: logger}), + func(req *http.Request, _ interceptor.HandlerFunc) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil + }, + ) + + client := &http.Client{Transport: transport} + req, err := http.NewRequest(http.MethodGet, tc.url, nil) + if err != nil { + t.Fatalf("http.NewRequest error: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("client.Do error: %v", err) + } + _ = resp.Body.Close() + + start := sink.snapshot()[0].attrs + if got := start["server.address"]; got != tc.wantHost { + t.Fatalf("server.address = %q, want %q", got, tc.wantHost) + } + + portValue, exists := start["server.port"] + if tc.portLogged { + if !exists { + t.Fatal("server.port missing") + } + if got := fmt.Sprint(portValue); got != fmt.Sprint(tc.wantPort) { + t.Fatalf("server.port = %v, want %d", got, tc.wantPort) + } + } else if exists { + t.Fatalf("server.port should not be logged for %q, got %v", tc.url, portValue) + } + }) + } +} diff --git a/transport.go b/transport.go index 54f9715..041152d 100644 --- a/transport.go +++ b/transport.go @@ -9,40 +9,38 @@ import "net/http" // It takes an HTTP request and returns a response or error. type HandlerFunc func(req *http.Request) (*http.Response, error) -// InterceptorFunc defines an interceptor. It receives the outgoing request and +// Middleware defines an interceptor. It receives the outgoing request and // a next function representing the next processing step in the interceptor // chain. -type InterceptorFunc func(req *http.Request, next HandlerFunc) (*http.Response, error) +type Middleware func(req *http.Request, next HandlerFunc) (*http.Response, error) -// TransportInterceptor implements http.RoundTripper by running a chain of interceptors +// Transport implements http.RoundTripper by running a chain of interceptors // in front of a default transport. -type TransportInterceptor struct { +type Transport struct { defaultTransport http.RoundTripper - interceptors []InterceptorFunc + interceptors []Middleware } -// NewTransportInterceptor creates a new TransportInterceptor with the given default RoundTripper +// NewTransport creates a new Transport with the given default RoundTripper // and interceptors. If defaultTransport is nil, http.DefaultTransport is used. // Interceptors are executed in the order provided. // -// // Using the default transport: -// client := &http.Client{ -// Transport: interceptor.NewTransportInterceptor(nil, AInterceptor, BInterceptor), -// } +// // Using the default transport: // -// // Using a custom default transport: // client := &http.Client{ -// Transport: interceptor.NewTransportInterceptor(customTransport, AInterceptor, BInterceptor), +// Transport: interceptor.NewTransport(nil, AInterceptor, BInterceptor), // } // -// With this configuration, a request flows as: +// // Using a custom default transport: // -// AInterceptor → BInterceptor → customTransport -func NewTransportInterceptor(defaultTransport http.RoundTripper, interceptors ...InterceptorFunc) *TransportInterceptor { +// client := &http.Client{ +// Transport: interceptor.NewTransport(customTransport, AInterceptor, BInterceptor), +// } +func NewTransport(defaultTransport http.RoundTripper, interceptors ...Middleware) *Transport { if defaultTransport == nil { defaultTransport = http.DefaultTransport } - return &TransportInterceptor{ + return &Transport{ defaultTransport: defaultTransport, interceptors: interceptors, } @@ -51,31 +49,29 @@ func NewTransportInterceptor(defaultTransport http.RoundTripper, interceptors .. // Use appends one or more interceptors to the chain. They are appended after // any interceptors already registered. // -// t := interceptor.NewTransportInterceptor(nil, AuthInterceptor).Use(MetricsInterceptor) -// // order: AuthInterceptor → MetricsInterceptor → default transport -func (t *TransportInterceptor) Use(interceptors ...InterceptorFunc) *TransportInterceptor { +// t := interceptor.NewTransport(nil, AuthInterceptor).Use(MetricsInterceptor) +func (t *Transport) Use(interceptors ...Middleware) *Transport { t.interceptors = append(t.interceptors, interceptors...) return t } -// RoundTrip executes the interceptor chain and then the underlying transport. -func (t *TransportInterceptor) RoundTrip(req *http.Request) (*http.Response, error) { - // Build the final handler that delegates to the default transport. +// RoundTrip executes the interceptor chain. The underlying transport is reached +// only if each interceptor calls next. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + final := HandlerFunc(func(r *http.Request) (*http.Response, error) { return t.defaultTransport.RoundTrip(r) }) - // Wrap interceptors in reverse order so that the first interceptor - // registered is the first to process the request (outermost). handler := final for i := len(t.interceptors) - 1; i >= 0; i-- { - interceptor := t.interceptors[i] + fn := t.interceptors[i] next := handler - handler = func(i InterceptorFunc, n HandlerFunc) HandlerFunc { + handler = func(fn Middleware, n HandlerFunc) HandlerFunc { return func(r *http.Request) (*http.Response, error) { - return i(r, n) + return fn(r, n) } - }(interceptor, next) + }(fn, next) } return handler(req) diff --git a/transport_test.go b/transport_test.go index 3f563e0..f797284 100644 --- a/transport_test.go +++ b/transport_test.go @@ -9,7 +9,7 @@ import ( "github.com/fervbmx/interceptor" ) -func TestTransportInterceptor(t *testing.T) { +func TestTransport(t *testing.T) { var order []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) @@ -24,7 +24,7 @@ func TestTransportInterceptor(t *testing.T) { return next(req) } - tp := interceptor.NewTransportInterceptor(nil, aInterceptor) + tp := interceptor.NewTransport(nil, aInterceptor) tp.Use(bInterceptor) client := &http.Client{ @@ -36,8 +36,9 @@ func TestTransportInterceptor(t *testing.T) { if err != nil { t.Fatalf("client.Get() returned error: %v", err) } + defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status code: %d", resp.StatusCode) }