Repository: go-fuego/fuego

Issue:
Path parsing issue
**To Reproduce**
Steps to reproduce the behavior:

when creating routes like this
```
        bookGroup := fuego.Group(s, "/book")
	fuego.Get(bookGroup, "/", rs.getAllBook)
	fuego.Post(bookGroup, "/", rs.postBook)

	fuego.Get(bookGroup, "/{id}", rs.getBook)
	fuego.Put(bookGroup, "/{id}", rs.putBook)
	fuego.Delete(bookGroup, "/{id}", rs.deleteBook)
```

requests like 

`GET http://localhost:9999/book/43/`

are routed to`rs.getAllBook` instead of `rs.getBook` while requests like 

`GET http://localhost:9999/book/43`

are correctly routed to  `rs.getBook`

**Versions**
Go v1.23.5
Fuego v0.17.0



Discussion:
@dylanhitt: hmmm. Maye you have a typo here?

I can't tell different between your to sample requests you provide.

`GET http://localhost:9999/book/43/`

and 

`GET http://localhost:9999/book/43/`

Appear to be the same.@Seemone: Apologies, typo indeed. Corrected@EwenQuim: Good issue for people not coming from a Go background, we should definitely tackle this.

That is [expected from the Go 1.22 default router](https://go.dev/blog/routing-enhancements). You can see by yourself [on this Go playground](https://go.dev/play/p/ZrUKrFZMFvg) I set up for you.

- `/book/43/` does NOT match `/book/{id}` (missing ending slash)
- `/book/{id}` does match `/book/` because of a special rule in Go : if it does not contains wildcard AND end with a trailing slash, it matches if the beginning matches

--- 

That being said, what can we do with Fuego?

- 1️⃣ Strip ending slashes from requests (optionally ofc, what should be the default?)
- 2️⃣ Strip ending slashes from route declarations (also optionally ofc, what should be the default?)
- 3️⃣ Nothing and let the user make the correct request (current solution)

My suggestion : 3️⃣ I think that current behaviour is expected for gophers and make sense. I don't want to dive into routing, I want to rely heavily on Go's defaults

We can add an option for 1️⃣ (that should be deactivated by default to keep Fuego's philosophy in mind), a bit like chi's middleware [RedirectSlashes](https://pkg.go.dev/github.com/Go-chi/chi/middleware#RedirectSlashes) or [StripSlashes](https://pkg.go.dev/github.com/Go-chi/chi/middleware#StripSlashes). But if we do 1️⃣, requests like `/books/` will be rewritten as `/books` and will not match `/books/` anymore, which implies to do 2️⃣ as well (or to let users declare routes like `fuego.Get(s, "", myController)` which looks weird)


@GideonBature: Could I take a shot at this?@danielrobotz: Could I try solving this?@EwenQuim: Well, as spec is not really well defined, please state what you'll be doing first.

If you're going for what I've suggested in my previous comment, just know that Fuego's team might make a lot of comments and it might not be merged at all, depending on if we're happy with what is proposed :) So no guarantee of it being merged in the end :)@sonkiee: Would love to tackle this! I’m an experienced full-stack developer with strong expertise in Golang and building robust APIs. I’d like to help resolve this routing issue by investigating how Fuego handles trailing slashes in route definitions. I will ensure that the behavior is consistent for routes like /book/{id} and /book/{id}/ to correctly map to the intended handlers. Looking forward to contributing and improving the framework!@NueloSE: am a developer specializing in Golang API development, I'm eager to address the trailing slash routing challenge. This is a common issue in Go web frameworks, and I'll focus on implementing a consistent solution for Fuego's route handling. By examining how routes like /book/{id} are processed, we can ensure proper handler mapping regardless of trailing slashes. I'm committed to enhancing Fuego's routing functionality while maintaining its idiomatic Go approach @fabrobles92: Hello I am Fab a full stack engineer that has contributed to Go web3 projects in the past like https://github.com/concrete-eth/concrete-geth. I would love to address this issue by fixing the path parsing issue@Seemone: to my knowledge a URL is equivalent to the same URL + "/" (this implies any number of trailing slashes).
The current implementation leads to ambiguous result and at least have an option to strip slashes would be very useful.
@EwenQuim: Is my solution with ideas 2️⃣ and 3️⃣ OK for you @Seemone ?@Seemone: you mean 1 and 2? in that case yes. An option to strip trailing slashes (default false) seems sensible to me. Personally I don't find "" routes weird@EwenQuim: Yes sorry, I meant 1 and 2.

I found that declaring `/books` handler does not match the requests ending in `/books/`, so we definitely need 1 and 2 simulteanously.

[Proof of concept](https://go.dev/play/p/Bd3cOpUWG9a) what do you think @dylanhitt?

@NueloSE you didn't reply and the issue is currently assigned to you, what is your personal opinion?@dylanhitt: Yes that is fine. I'm for it. 

Solution patch:
diff --git a/default_middlewares.go b/default_middlewares.go
index 05c3432..ff7a274 100644
--- a/default_middlewares.go
+++ b/default_middlewares.go
@@ -5,6 +5,7 @@ import (
 	"log/slog"
 	"net"
 	"net/http"
+	"strings"
 	"time"
 
 	"github.com/google/uuid"
@@ -158,3 +159,14 @@ func (l defaultLogger) middleware(next http.Handler) http.Handler {
 		}
 	})
 }
+
+// stripTrailingSlashMiddleware is a middleware that removes trailing slashes from the request path.
+// Not active by default but can be added to the server using [WithStripTrailingSlash]
+func stripTrailingSlashMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if len(r.URL.Path) > 1 {
+			r.URL.Path = strings.TrimRight(r.URL.Path, "/")
+		}
+		next.ServeHTTP(w, r)
+	})
+}
diff --git a/option.go b/option.go
index 9b0e666..0f448dc 100644
--- a/option.go
+++ b/option.go
@@ -5,6 +5,7 @@ import (
 	"net/http"
 	"slices"
 	"strconv"
+	"strings"
 
 	"github.com/getkin/kin-openapi/openapi3"
 )
@@ -460,3 +461,13 @@ func OptionSecurity(securityRequirements ...openapi3.SecurityRequirement) func(*
 		*r.Operation.Security = append(*r.Operation.Security, securityRequirements...)
 	}
 }
+
+// OptionStripTrailingSlash ensure that the route declaration
+// will have its ending trailing slash stripped.
+func OptionStripTrailingSlash() func(*BaseRoute) {
+	return func(r *BaseRoute) {
+		if len(r.Path) > 1 {
+			r.Path = strings.TrimRight(r.Path, "/")
+		}
+	}
+}
diff --git a/option/option.go b/option/option.go
index d22f30a..f1f163d 100644
--- a/option/option.go
+++ b/option/option.go
@@ -180,3 +180,7 @@ var Show = fuego.OptionShow
 
 // DefaultStatusCode sets the default status code for the route.
 var DefaultStatusCode = fuego.OptionDefaultStatusCode
+
+// StripTrailingSlash removes the trailing slash from the route.
+// By default, the trailing slash is kept, so becauseful when registering route like "/" within a group.
+var StripTrailingSlash = fuego.OptionStripTrailingSlash
diff --git a/server.go b/server.go
index b08d8b3..470738c 100644
--- a/server.go
+++ b/server.go
@@ -449,3 +449,13 @@ func WithLoggingMiddleware(loggingConfig LoggingConfig) func(*Server) {
 		}
 	}
 }
+
+// WithStripTrailingSlash ensure all declared routes trailing slash
+// is stripped. This option also applies a middleware
+// that strips the trailing slash from every incoming request.
+func WithStripTrailingSlash() func(*Server) {
+	return func(s *Server) {
+		s.routeOptions = append(s.routeOptions, OptionStripTrailingSlash())
+		s.globalMiddlewares = append(s.globalMiddlewares, stripTrailingSlashMiddleware)
+	}
+}



Test patch:
diff --git a/option_test.go b/option_test.go
index 5914776..dc0cdab 100644
--- a/option_test.go
+++ b/option_test.go
@@ -1016,3 +1016,11 @@ func TestDefaultStatusCode(t *testing.T) {
 		require.Equal(t, 500, w.Code)
 	})
 }
+
+func TestOptionStripTrailingSlash(t *testing.T) {
+	t.Run("Route trailing slash is stripped", func(t *testing.T) {
+		s := fuego.NewServer()
+		route := fuego.Get(s, "/test/", helloWorld, fuego.OptionStripTrailingSlash())
+		require.Equal(t, "/test", route.Path)
+	})
+}
diff --git a/server_test.go b/server_test.go
index 2e96655..1dcd39b 100644
--- a/server_test.go
+++ b/server_test.go
@@ -843,3 +843,57 @@ func TestWithSeveralGlobalMiddelwares(t *testing.T) {
 		require.Equal(t, "two", res.Body.String())
 	})
 }
+
+func TestWithStripTrailingSlash(t *testing.T) {
+	s := NewServer(
+		WithStripTrailingSlash(),
+		WithAddr(":9998"),
+	)
+	Get(s, "/withtrailingslash/", dummyController) // Will be registered as /withtrailingslash
+	Get(s, "/withouttrailingslash", dummyController)
+
+	err := s.setup()
+	require.NoError(t, err)
+
+	t.Run("requests with trailing slash", func(t *testing.T) {
+		t.Run("route with trailing slash", func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "/withtrailingslash/", nil)
+			res := httptest.NewRecorder()
+
+			s.Handler.ServeHTTP(res, req)
+
+			t.Log(res.Body.String())
+			require.Equal(t, 200, res.Code)
+		})
+		t.Run("route without trailing slash", func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "/withouttrailingslash/", nil)
+			res := httptest.NewRecorder()
+
+			s.Handler.ServeHTTP(res, req)
+
+			t.Log(res.Body.String())
+			require.Equal(t, 200, res.Code)
+		})
+	})
+
+	t.Run("requests without trailing slash", func(t *testing.T) {
+		t.Run("route with trailing slash", func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "/withtrailingslash", nil)
+			res := httptest.NewRecorder()
+
+			s.Handler.ServeHTTP(res, req)
+
+			t.Log(res.Body.String())
+			require.Equal(t, 200, res.Code)
+		})
+		t.Run("route without trailing slash", func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "/withouttrailingslash", nil)
+			res := httptest.NewRecorder()
+
+			s.Handler.ServeHTTP(res, req)
+
+			t.Log(res.Body.String())
+			require.Equal(t, 200, res.Code)
+		})
+	})
+}