Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

re-implementing default logging middleware #313

Merged
merged 5 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions default_middlewares.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package fuego

import (
"log/slog"
"net/http"
"time"

"github.com/google/uuid"
)

// By default, all logging is enabled
var defaultLoggingConfig = LoggingConfig{
RequestIDFunc: defaultRequestIDFunc,
}

// LoggingConfig is the configuration for the default logging middleware
//
// It allows for request and response logging to be disabled independently,
// and for a custom request ID generator to be used
//
// For example:
//
// config := fuego.LoggingConfig{
// DisableRequest: true,
// RequestIDFunc: func() string {
// return fmt.Sprintf("custom-%d", time.Now().UnixNano())
// },
// }
//
// The above configuration will disable the debug request logging and
// override the default request ID generator (UUID) with a custom one that
// appends the current Unix time in nanoseconds for response logs
type LoggingConfig struct {
// If true, request logging is disabled
DisableRequest bool
// If true, response logging is disabled
DisableResponse bool
// Optional custom request ID generator
RequestIDFunc func() string
}

func (l *LoggingConfig) Disabled() bool {
return l.DisableRequest && l.DisableResponse
}

// defaultRequestIDFunc generates a UUID as the default request ID if none exist in X-Request-ID header
func defaultRequestIDFunc() string {
return uuid.New().String()
}

// responseWriter wraps [http.ResponseWriter] to capture response metadata.
// Implements [http.ResponseWriter.Write] to ensure proper status code capture for implicit 200 responses
type responseWriter struct {
http.ResponseWriter
status int
wroteHeader bool
}

func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{ResponseWriter: w}
}

func (rw *responseWriter) WriteHeader(code int) {
if rw.wroteHeader {
return
}

rw.status = code
rw.ResponseWriter.WriteHeader(code)
rw.wroteHeader = true
}

func (rw *responseWriter) Write(b []byte) (int, error) {
if !rw.wroteHeader {
rw.WriteHeader(http.StatusOK)
}
return rw.ResponseWriter.Write(b)
}

func logRequest(requestID string, r *http.Request) {
slog.Debug("incoming request",
"request_id", requestID,
"method", r.Method,
"path", r.URL.Path,
"timestamp", time.Now().Format(time.RFC3339),
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
}

func logResponse(r *http.Request, rw *responseWriter, requestID string, duration time.Duration) {
slog.Info("outgoing response",
jwtly10 marked this conversation as resolved.
Show resolved Hide resolved
"request_id", requestID,
"method", r.Method,
"path", r.URL.Path,
"timestamp", time.Now().Format(time.RFC3339),
"remote_addr", r.RemoteAddr,
"duration_ms", duration.Milliseconds(),
"status_code", rw.status,
)
}

type defaultLogger struct {
s *Server
}

func newDefaultLogger(s *Server) defaultLogger {
return defaultLogger{s: s}
}

// defaultLogger.middleware is the default middleware that logs incoming requests and outgoing responses.
//
// By default, request logging will be logged at the debug level, and response
// logging will be logged at the info level
//
// Log levels managed by [WithLogHandler]
func (l defaultLogger) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()

requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = l.s.loggingConfig.RequestIDFunc()
}
w.Header().Set("X-Request-ID", requestID)

wrapped := newResponseWriter(w)

if !l.s.loggingConfig.DisableRequest {
logRequest(requestID, r)
}

next.ServeHTTP(wrapped, r)

if !l.s.loggingConfig.DisableResponse {
duration := time.Since(start)
logResponse(r, wrapped, requestID, duration)
}
})
}
18 changes: 9 additions & 9 deletions examples/petstore/lib/testdata/doc/openapi.golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
"paths": {
"/pets/": {
"get": {
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.filterPets`\n\n---\n\nFilter pets",
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.filterPets`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\nFilter pets",
"operationId": "GET_/pets/",
"parameters": [
{
Expand Down Expand Up @@ -330,7 +330,7 @@
]
},
"post": {
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.postPets`\n\n---\n\n",
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.postPets`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n",
"operationId": "POST_/pets/",
"parameters": [
{
Expand Down Expand Up @@ -448,7 +448,7 @@
},
"/pets/all": {
"get": {
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.getAllPets`\n\n---\n\nGet all pets",
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.getAllPets`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\nGet all pets",
"operationId": "GET_/pets/all",
"parameters": [
{
Expand Down Expand Up @@ -603,7 +603,7 @@
},
"/pets/by-age": {
"get": {
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.getAllPetsByAge`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego/examples/petstore/controllers.dummyMiddleware`\n\n---\n\nReturns an array of pets grouped by age",
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.getAllPetsByAge`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/go-fuego/fuego/examples/petstore/controllers.dummyMiddleware`\n\n---\n\nReturns an array of pets grouped by age",
"operationId": "GET_/pets/by-age",
"parameters": [
{
Expand Down Expand Up @@ -707,7 +707,7 @@
},
"/pets/by-name/{name...}": {
"get": {
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.getPetByName`\n\n---\n\n",
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.getPetByName`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n",
"operationId": "GET_/pets/by-name/:name...",
"parameters": [
{
Expand Down Expand Up @@ -808,7 +808,7 @@
},
"/pets/std/all": {
"get": {
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.Routes.func1`\n\n---\n\n",
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.Routes.func1`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n",
"operationId": "GET_/pets/std/all",
"parameters": [
{
Expand Down Expand Up @@ -892,7 +892,7 @@
},
"/pets/{id}": {
"delete": {
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.deletePets`\n\n---\n\n",
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.deletePets`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n",
"operationId": "DELETE_/pets/:id",
"parameters": [
{
Expand Down Expand Up @@ -1094,7 +1094,7 @@
]
},
"put": {
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.putPets`\n\n---\n\n",
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.putPets`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n",
"operationId": "PUT_/pets/:id",
"parameters": [
{
Expand Down Expand Up @@ -1205,7 +1205,7 @@
},
"/pets/{id}/json": {
"put": {
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.putPets`\n\n---\n\n",
"description": "#### Controller: \n\n`github.com/go-fuego/fuego/examples/petstore/controllers.PetsResources.putPets`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\n",
"operationId": "PUT_/pets/:id/json",
"parameters": [
{
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/getkin/kin-openapi v0.128.0
github.com/go-playground/validator/v10 v10.23.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.4.1
github.com/stretchr/testify v1.10.0
github.com/thejerf/slogassert v0.3.4
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
Expand Down
2 changes: 1 addition & 1 deletion openapi_operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func TestTags(t *testing.T) {
)

require.Equal(t, []string{"my-tag"}, route.Operation.Tags)
require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego.testController`\n\n---\n\nmy description", route.Operation.Description)
require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego.testController`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\nmy description", route.Operation.Description)
require.Equal(t, "my summary", route.Operation.Summary)
require.Equal(t, true, route.Operation.Deprecated)
}
Expand Down
13 changes: 6 additions & 7 deletions option_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ func TestOpenAPI(t *testing.T) {
)

require.Equal(t, "test summary", route.Operation.Summary)
require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego_test.helloWorld`\n\n---\n\ntest description", route.Operation.Description)
require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego_test.helloWorld`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\ntest description", route.Operation.Description)
require.Equal(t, []string{"first-tag", "second-tag"}, route.Operation.Tags)
require.True(t, route.Operation.Deprecated)
})
Expand Down Expand Up @@ -788,7 +788,7 @@ func TestOptionDescription(t *testing.T) {
option.Description("test description"),
)

require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego_test.helloWorld`\n\n---\n\ntest description", route.Operation.Description)
require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego_test.helloWorld`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n\n---\n\ntest description", route.Operation.Description)
})

t.Run("Override Fuego's description for the route", func(t *testing.T) {
Expand All @@ -809,11 +809,11 @@ func TestOptionDescription(t *testing.T) {
option.Description("another description"),
)

require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego_test.helloWorld`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n\n---\n\nanother description", route.Operation.Description)
require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego_test.helloWorld`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n\n---\n\nanother description", route.Operation.Description)
})

t.Run("Add description to the route, route middleware is included", func(t *testing.T) {
s := fuego.NewServer()
s := fuego.NewServer() // Default logger middleware is added

fuego.Use(s, dummyMiddleware)

Expand All @@ -824,12 +824,11 @@ func TestOptionDescription(t *testing.T) {
route := fuego.Get(s, "/test", helloWorld,
option.Middleware(dummyMiddleware),
option.Description("another description"),
option.Middleware(dummyMiddleware), // After the description
option.Middleware(dummyMiddleware), // 6th middleware
option.Middleware(dummyMiddleware), // After the description (6th middleware)
option.Middleware(dummyMiddleware), // 7th middleware, should not be included
)

require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego_test.helloWorld`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n- more middleware…\n\n---\n\nanother description", route.Operation.Description)
require.Equal(t, "#### Controller: \n\n`github.com/go-fuego/fuego_test.helloWorld`\n\n#### Middlewares:\n\n- `github.com/go-fuego/fuego.defaultLogger.middleware`\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n- `github.com/go-fuego/fuego_test.dummyMiddleware`\n- more middleware…\n\n---\n\nanother description", route.Operation.Description)
})
}

Expand Down
19 changes: 19 additions & 0 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ type Server struct {

OpenAPIConfig OpenAPIConfig

loggingConfig LoggingConfig

isTLS bool
}

Expand Down Expand Up @@ -104,6 +106,8 @@ func NewServer(options ...func(*Server)) *Server {
OpenAPIConfig: defaultOpenAPIConfig,

Security: NewSecurity(),

loggingConfig: defaultLoggingConfig,
}

// Default options that can be overridden
Expand Down Expand Up @@ -144,6 +148,10 @@ func NewServer(options ...func(*Server)) *Server {
)
}

if !s.loggingConfig.Disabled() {
s.middlewares = append(s.middlewares, newDefaultLogger(s).middleware)
}

return s
}

Expand Down Expand Up @@ -423,3 +431,14 @@ func WithRouteOptions(options ...func(*BaseRoute)) func(*Server) {
s.routeOptions = append(s.routeOptions, options...)
}
}

// WithLoggingMiddleware configures the default logging middleware for the server.
func WithLoggingMiddleware(loggingConfig LoggingConfig) func(*Server) {
return func(s *Server) {
s.loggingConfig.DisableRequest = loggingConfig.DisableRequest
s.loggingConfig.DisableResponse = loggingConfig.DisableResponse
if loggingConfig.RequestIDFunc != nil {
s.loggingConfig.RequestIDFunc = loggingConfig.RequestIDFunc
}
}
}
Loading
Loading