diff --git a/context_test.go b/context_test.go index 8872ebb..a0e1aaa 100644 --- a/context_test.go +++ b/context_test.go @@ -202,13 +202,19 @@ func TestContext_Route(t *testing.T) { assert.Equal(t, "/foo", w.Body.String()) } -func TestContext_Tags(t *testing.T) { +func TestContext_Annotations(t *testing.T) { t.Parallel() f := New() - f.MustHandle(http.MethodGet, "/foo", emptyHandler, WithTags("foo", "bar", "baz")) + f.MustHandle( + http.MethodGet, + "/foo", + emptyHandler, + WithAnnotations(Annotation{Key: "foo", Value: "bar"}, Annotation{Key: "foo", Value: "baz"}), + WithAnnotation("john", 1), + ) rte := f.Tree().Route(http.MethodGet, "/foo") require.NotNil(t, rte) - assert.Equal(t, []string{"foo", "bar", "baz"}, slices.Collect(rte.Tags())) + assert.Equal(t, []Annotation{{"foo", "bar"}, {"foo", "baz"}, {"john", 1}}, slices.Collect(rte.Annotations())) } func TestContext_Clone(t *testing.T) { diff --git a/logger.go b/logger.go index cda4249..a4b7399 100644 --- a/logger.go +++ b/logger.go @@ -38,11 +38,6 @@ func LoggerWithHandler(handler slog.Handler) MiddlewareFunc { ipStr = "unknown" } - var tags []string - if route := c.Route(); route != nil { - tags = route.tags - } - if location == "" { log.LogAttrs( req.Context(), @@ -52,7 +47,6 @@ func LoggerWithHandler(handler slog.Handler) MiddlewareFunc { slog.String("method", req.Method), slog.String("path", c.Request().URL.String()), slog.Duration("latency", roundLatency(latency)), - slog.Any("tags", tags), ) } else { log.LogAttrs( @@ -63,7 +57,6 @@ func LoggerWithHandler(handler slog.Handler) MiddlewareFunc { slog.String("method", req.Method), slog.String("path", c.Request().URL.String()), slog.Duration("latency", roundLatency(latency)), - slog.Any("tags", tags), slog.String("location", location), ) } diff --git a/logger_test.go b/logger_test.go index 7fb4a89..3c26451 100644 --- a/logger_test.go +++ b/logger_test.go @@ -2,13 +2,11 @@ package fox import ( "bytes" - "fmt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "log/slog" "net/http" "net/http/httptest" - "slices" "testing" ) @@ -35,10 +33,6 @@ func TestLoggerWithHandler(t *testing.T) { require.NoError(t, f.Handle(http.MethodGet, "/failure", func(c Context) { c.Writer().WriteHeader(http.StatusInternalServerError) })) - require.NoError(t, f.Handle(http.MethodGet, "/tags", func(c Context) { - c.Writer().WriteHeader(http.StatusOK) - fmt.Println(slices.Collect(c.Route().Tags())) - }, WithTags("foo", "bar", "baz"))) cases := []struct { name string @@ -48,27 +42,22 @@ func TestLoggerWithHandler(t *testing.T) { { name: "should log info level", req: httptest.NewRequest(http.MethodGet, "/success", nil), - want: "time=time level=INFO msg=192.0.2.1 status=200 method=GET path=/success latency=latency tags=[]\n", + want: "time=time level=INFO msg=192.0.2.1 status=200 method=GET path=/success latency=latency\n", }, { name: "should log error level", req: httptest.NewRequest(http.MethodGet, "/failure", nil), - want: "time=time level=ERROR msg=192.0.2.1 status=500 method=GET path=/failure latency=latency tags=[]\n", + want: "time=time level=ERROR msg=192.0.2.1 status=500 method=GET path=/failure latency=latency\n", }, { name: "should log warn level", req: httptest.NewRequest(http.MethodGet, "/foobar", nil), - want: "time=time level=WARN msg=192.0.2.1 status=404 method=GET path=/foobar latency=latency tags=[]\n", + want: "time=time level=WARN msg=192.0.2.1 status=404 method=GET path=/foobar latency=latency\n", }, { name: "should log debug level", req: httptest.NewRequest(http.MethodGet, "/success/", nil), - want: "time=time level=DEBUG msg=192.0.2.1 status=301 method=GET path=/success/ latency=latency tags=[] location=../success\n", - }, - { - name: "should log info level tags", - req: httptest.NewRequest(http.MethodGet, "/tags", nil), - want: "time=time level=INFO msg=192.0.2.1 status=200 method=GET path=/tags latency=latency tags=\"[foo bar baz]\"\n", + want: "time=time level=DEBUG msg=192.0.2.1 status=301 method=GET path=/success/ latency=latency location=../success\n", }, } diff --git a/options.go b/options.go index 1bc7729..c56579a 100644 --- a/options.go +++ b/options.go @@ -223,9 +223,19 @@ func WithClientIPStrategy(strategy ClientIPStrategy) Option { }) } -func WithTags(tags ...string) PathOption { +// WithAnnotations attach arbitrary metadata to routes. Annotations are key-value pairs that allow middleware, handler or +// any other components to modify behavior based on the attached metadata. Annotations must be explicitly reapplied when +// updating a route. +func WithAnnotations(annotations ...Annotation) PathOption { return pathOptionFunc(func(route *Route) { - route.tags = tags + route.annots = append(route.annots, annotations...) + }) +} + +// WithAnnotation attaches a single key-value annotation to a route. See also [WithAnnotations] and [Annotation] for more details. +func WithAnnotation(key string, value any) PathOption { + return pathOptionFunc(func(route *Route) { + route.annots = append(route.annots, Annotation{key, value}) }) } diff --git a/recovery.go b/recovery.go index ce03d47..f84013d 100644 --- a/recovery.go +++ b/recovery.go @@ -80,12 +80,18 @@ func recovery(logger *slog.Logger, c Context, handle RecoveryFunc) { sb.WriteString("Stack:\n") sb.WriteString(stacktrace(3, 6)) + params := slices.Collect(mapParamsToAttr(c.Params())) + var annotations []any + if route := c.Route(); route != nil { + annotations = slices.Collect(mapAnnotationsToAttr(route.Annotations())) + } logger.Error( sb.String(), slog.String("path", c.Path()), - slog.Group("param", params...), + slog.Group("params", params...), + slog.Group("annotations", annotations...), slog.Any("error", err), ) @@ -143,3 +149,13 @@ func mapParamsToAttr(params iter.Seq[Param]) iter.Seq[any] { } } } + +func mapAnnotationsToAttr(annotations iter.Seq[Annotation]) iter.Seq[any] { + return func(yield func(any) bool) { + for a := range annotations { + if !yield(slog.Any(a.Key, a.Value)) { + break + } + } + } +} diff --git a/route.go b/route.go index 02b7cbe..2847340 100644 --- a/route.go +++ b/route.go @@ -5,6 +5,17 @@ import ( "strings" ) +// Annotations is a collection of Annotation key-value pairs that can be attached to routes. +type Annotations []Annotation + +// Annotation represents a single key-value pair that provides metadata for a route. +// Annotations are typically used to store information that can be leveraged by middleware, handlers, or external +// libraries to modify or customize route behavior. +type Annotation struct { + Key string + Value any +} + // Route represent a registered route in the route tree. // Most of the Route API is EXPERIMENTAL and is likely to change in future release. type Route struct { @@ -14,17 +25,17 @@ type Route struct { hall HandlerFunc path string mws []middleware - tags []string + annots Annotations redirectTrailingSlash bool ignoreTrailingSlash bool } -// Handle calls the handler with the provided Context. See also HandleMiddleware. +// Handle calls the handler with the provided [Context]. See also [HandleMiddleware]. func (r *Route) Handle(c Context) { r.hbase(c) } -// HandleMiddleware calls the handler with route-specific middleware applied, using the provided Context. +// HandleMiddleware calls the handler with route-specific middleware applied, using the provided [Context]. func (r *Route) HandleMiddleware(c Context, _ ...struct{}) { // The variadic parameter is intentionally added to prevent this method from having the same signature as HandlerFunc. // This avoids accidental use of HandleMiddleware where a HandlerFunc is required. @@ -36,11 +47,11 @@ func (r *Route) Path() string { return r.path } -// Tags returns a range iterator over the tags associated with the route. -func (r *Route) Tags() iter.Seq[string] { - return func(yield func(string) bool) { - for _, tag := range r.tags { - if !yield(tag) { +// Annotations returns a range iterator over annotations associated with the route. +func (r *Route) Annotations() iter.Seq[Annotation] { + return func(yield func(Annotation) bool) { + for _, a := range r.annots { + if !yield(a) { return } } @@ -61,7 +72,7 @@ func (r *Route) IgnoreTrailingSlashEnabled() bool { return r.ignoreTrailingSlash } -// ClientIPStrategyEnabled returns whether the route is configured with a ClientIPStrategy. +// ClientIPStrategyEnabled returns whether the route is configured with a [ClientIPStrategy]. // This api is EXPERIMENTAL and is likely to change in future release. func (r *Route) ClientIPStrategyEnabled() bool { _, ok := r.ipStrategy.(noClientIPStrategy)