From 6752f862f3ea9a2a535a74773ffdd3ed8633c5fa Mon Sep 17 00:00:00 2001 From: Ewen Quimerc'h <46993939+EwenQuim@users.noreply.github.com> Date: Wed, 15 Jan 2025 10:17:30 +0100 Subject: [PATCH] Global middlewares (#345) * Replace WithCorsMiddleware by WithGlobalMiddleware (#325) * refactor: move accept header form mux to server options * replace withcoresmiddleware by globalmiddleware * remove cors middle ware form server structure * Update serve.go * Update server.go * Fixes tests and formatting * Add more tests and documentation for global middlewares * Update documentation/docs/guides/middlewares.md Co-authored-by: ccoVeille <3875889+ccoVeille@users.noreply.github.com> * Update documentation/docs/guides/middlewares.md Co-authored-by: ccoVeille <3875889+ccoVeille@users.noreply.github.com> * Update documentation/docs/guides/middlewares.md Co-authored-by: Dylan Hitt --------- Co-authored-by: Ekuma Matthew <79433184+ekumamatthew@users.noreply.github.com> Co-authored-by: ccoVeille <3875889+ccoVeille@users.noreply.github.com> Co-authored-by: Dylan Hitt --- documentation/docs/guides/middlewares.md | 81 +++++++++++++++++++----- serve.go | 5 +- server.go | 31 ++++++--- server_test.go | 79 +++++++++++++++++++++++ testing-from-outside/cors_test.go | 2 +- 5 files changed, 168 insertions(+), 30 deletions(-) diff --git a/documentation/docs/guides/middlewares.md b/documentation/docs/guides/middlewares.md index 0c200e69..8b3aca1f 100644 --- a/documentation/docs/guides/middlewares.md +++ b/documentation/docs/guides/middlewares.md @@ -8,9 +8,43 @@ including the ones from `chi` and `gorilla` can be used with Fuego! :fire: You can use them to add functionalities to your routes, such as logging, authentication, etc. -## App-level middlewares +Middlewares can be registered at 2 levels: -You can add middlewares to the whole server using the `Use` method: +- **Route middlewares**: applies only to registered routes, is scoped to a specific group, or several routes. +- **Global middlewares**: applied on every request, even non-matching routes (useful for CORS for example). + +## Route middlewares + +You can add middlewares to a single route. +They are treated as an option to the route handler. +They will be added in the [`http.ServeMux`](https://pkg.go.dev/net/http#ServeMux) in the order they are declared, when registering the route. + +```go title="main.go" showLineNumbers {13-14} +package main + +import ( + "github.com/go-fuego/fuego" +) + +func main() { + s := fuego.NewServer() + + // Declare the middlewares after the route handler + fuego.Get(s, "/", myController, + option.QueryInt("page", "The page number"), + option.Middleware(middleware1), + option.Middleware(middleware2, middleware3), + ) + + s.Run() +} +``` + +### Apply on group or server + +To mimic the well-known `Use` method from [`chi`](https://pkg.go.dev/github.com/go-chi/chi/v5#Mux.Use) and [`Gin`](https://pkg.go.dev/github.com/gin-gonic/gin#Engine.Use), Fuego provides a `Use` method to add middlewares to a Group or Server. They are treated as an option to the server or group handler and will be applied to all routes. + +But we recommend using the `option.Middleware` method for better readability. ```go title="main.go" showLineNumbers package main @@ -41,10 +75,6 @@ func main() { } ``` -## Group middlewares - -You can also add middlewares to a group of routes using the `Group` method: - ```go title="main.go" showLineNumbers package main @@ -79,29 +109,46 @@ func main() { } ``` -## Route middlewares +## Global Middlewares -You can also add middlewares to a single route. -They are treated as an option to the route handler. -Simply add the middlewares as the last arguments of the route handler: +Global middlewares are applied to every request, even if the route does not match. +They are useful for CORS, for example as CORS are using the OPTION method even if not registered. +They are registered in the Server `Handler`, not the `Mux`, and just before +`Run` is called (not at route registration). -```go title="main.go" showLineNumbers {13-14} +```go title="main.go" showLineNumbers package main import ( + "net/http" + "github.com/go-fuego/fuego" ) func main() { s := fuego.NewServer() - // Declare the middlewares after the route handler - fuego.Get(s, "/", myController, - option.QueryInt("page", "The page number"), - option.Middleware(middleware1), - option.Middleware(middleware2, middleware3), - ) + // Add a global middleware + fuego.WithGlobalMiddlewares(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Hello", "World") + // Do something before the request + next.ServeHTTP(w, r) + // Do something after the request + }) + }) + + fuego.Get(s, "/my-route", myController) + // Here, the global middleware is applied s.Run() } ``` + +This will work even if the user requests a route that does not exist: + +```bash +curl -v -X GET http://localhost:3000/unknown-route +``` + +We can see the `X-Hello: World` header in the response. diff --git a/serve.go b/serve.go index 446884dc..1b335319 100644 --- a/serve.go +++ b/serve.go @@ -40,8 +40,9 @@ func (s *Server) setup() error { s.printStartupMessage() s.Server.Handler = s.Mux - if s.corsMiddleware != nil { - s.Server.Handler = s.corsMiddleware(s.Server.Handler) + + for _, middleware := range s.globalMiddlewares { + s.Server.Handler = middleware(s.Server.Handler) } return nil diff --git a/server.go b/server.go index 937cdaf3..cc4162ea 100644 --- a/server.go +++ b/server.go @@ -41,10 +41,9 @@ type Server struct { // [http.ServeMux.Handle] can also be used to register routes. Mux *http.ServeMux - // Not stored with the other middlewares because it is a special case : - // it applies on routes that are not registered. - // For example, it allows OPTIONS /foo even if it is not declared (only GET /foo is declared). - corsMiddleware func(http.Handler) http.Handler + // globalMiddlewares is used to store the options + // that will be applied on ALL routes. + globalMiddlewares []func(http.Handler) http.Handler *Engine @@ -177,22 +176,34 @@ func WithTemplateFS(fs fs.FS) func(*Server) { return func(c *Server) { c.fs = fs } } -// WithCorsMiddleware registers a middleware to handle CORS. -// It is not handled like other middlewares with [Use] because it applies routes that are not registered. -// For example: +// WithGlobalMiddlewares adds middleware(s) that will be executed on ALL requests, +// even those that don't match any registered routes. +// Global Middlewares are mounted on the [http.Server] Handler, when executing [Server.Run]. +// Route Middlewares are mounted directly on [http.ServeMux] added at route registration. +// +// For example, to add CORS middleware: // // import "github.com/rs/cors" // // s := fuego.NewServer( -// WithCorsMiddleware(cors.New(cors.Options{ +// WithGlobalMiddlewares(cors.New(cors.Options{ // AllowedOrigins: []string{"*"}, // AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, // AllowedHeaders: []string{"*"}, // AllowCredentials: true, -// }).Handler) +// }).Handler), // ) +func WithGlobalMiddlewares(middlewares ...func(http.Handler) http.Handler) func(*Server) { + return func(c *Server) { + c.globalMiddlewares = append(c.globalMiddlewares, middlewares...) + } +} + +// WithCorsMiddleware adds CORS middleware to the server. +// +// Deprecated: Please use [WithGlobalMiddlewares] instead. func WithCorsMiddleware(corsMiddleware func(http.Handler) http.Handler) func(*Server) { - return func(c *Server) { c.corsMiddleware = corsMiddleware } + return WithGlobalMiddlewares(corsMiddleware) } // WithGlobalResponseTypes adds default response types to the server. diff --git a/server_test.go b/server_test.go index adf3931a..a0af3ccc 100644 --- a/server_test.go +++ b/server_test.go @@ -764,3 +764,82 @@ func TestDefaultLoggingMiddleware(t *testing.T) { }) } } + +func TestWithSeveralGlobalMiddelwares(t *testing.T) { + s := NewServer( + WithGlobalMiddlewares( + dummyMiddleware, + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Global-Middleware", "1") + if r.FormValue("one") == "true" { + w.Write([]byte("one")) + return + } + next.ServeHTTP(w, r) + }) + }, + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Global-Middleware", "2") + if r.FormValue("two") == "true" { + w.Write([]byte("two")) + return + } + next.ServeHTTP(w, r) + }) + }), + ) + + Get(s, "/my-route", dummyController) + + err := s.setup() + require.NoError(t, err) + + t.Run("global middlewares work even on non-matching routes (returns 404)", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/non-existing-route", nil) + res := httptest.NewRecorder() + + s.Handler.ServeHTTP(res, req) + + t.Log(res.Body.String()) + require.Equal(t, 404, res.Code) + require.Equal(t, "1", res.Header().Get("X-Global-Middleware")) + require.Equal(t, "response", res.Header().Get("X-Test-Response")) + }) + + t.Run("global middlewares work on matching routes", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/my-route", nil) + res := httptest.NewRecorder() + + s.Handler.ServeHTTP(res, req) + + t.Log(res.Body.String()) + require.Equal(t, 200, res.Code) + require.Equal(t, "1", res.Header().Get("X-Global-Middleware")) + }) + + t.Run("stop the chain of middlewares and return 200 instead of expected 404 (non-matching route)", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/non-existing-route?one=true", nil) + res := httptest.NewRecorder() + + s.Handler.ServeHTTP(res, req) + + t.Log(res.Body.String()) + require.Equal(t, 200, res.Code) + require.Equal(t, "1", res.Header().Get("X-Global-Middleware")) + require.Equal(t, "one", res.Body.String()) + }) + + t.Run("pass the first global middleware, then stop the chain of middlewares returning 200 instead of returning 404 (non-matching route)", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/non-existing-route?two=true", nil) + res := httptest.NewRecorder() + + s.Handler.ServeHTTP(res, req) + + t.Log(res.Body.String()) + require.Equal(t, 200, res.Code) + require.Equal(t, "2", res.Header().Get("X-Global-Middleware")) + require.Equal(t, "two", res.Body.String()) + }) +} diff --git a/testing-from-outside/cors_test.go b/testing-from-outside/cors_test.go index a1a0614d..4ecf98f6 100644 --- a/testing-from-outside/cors_test.go +++ b/testing-from-outside/cors_test.go @@ -13,7 +13,7 @@ import ( func TestCors(t *testing.T) { s := fuego.NewServer( fuego.WithoutLogger(), - fuego.WithCorsMiddleware(cors.New(cors.Options{ + fuego.WithGlobalMiddlewares(cors.New(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET"}, }).Handler),