Skip to content
Open
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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ with `net/http` can be used with chi's mux.
| [Compress] | Gzip compression for clients that accept compressed responses |
| [ContentCharset] | Ensure charset for Content-Type request headers |
| [CleanPath] | Clean double slashes from request path |
| [Deprecation] | Set the Deprecation response header (RFC 9745); see also [Sunset] |
| [GetHead] | Automatically route undefined HEAD requests to GET handlers |
| [Heartbeat] | Monitoring endpoint to check the servers pulse |
| [Logger] | Logs the start and end of each request with the elapsed processing time |
Expand All @@ -361,7 +362,7 @@ with `net/http` can be used with chi's mux.
| [RouteHeaders] | Route handling for request headers |
| [SetHeader] | Short-hand middleware to set a response header key/value |
| [StripSlashes] | Strip slashes on routing paths |
| [Sunset] | Sunset set Deprecation/Sunset header to response |
| [Sunset] | Set the Sunset response header (RFC 8594); see also [Deprecation] |
| [Throttle] | Puts a ceiling on the number of concurrent requests |
| [Timeout] | Signals to the request context when the timeout deadline is reached |
| [URLFormat] | Parse extension from url and put it on request context |
Expand All @@ -374,6 +375,7 @@ with `net/http` can be used with chi's mux.
[Compress]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Compress
[ContentCharset]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ContentCharset
[CleanPath]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#CleanPath
[Deprecation]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Deprecation
Comment thread
VojtechVitek marked this conversation as resolved.
[GetHead]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#GetHead
[GetReqID]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#GetReqID
[Heartbeat]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Heartbeat
Expand Down Expand Up @@ -474,6 +476,30 @@ See the per-function godoc for the full semantics of each middleware, and
[adam-p's "The perils of the 'real' client IP"](https://adam-p.ca/blog/2022/03/x-forwarded-for/)
for the underlying threat model.

### Deprecating and removing a route

Use `Deprecation` first. Add `Sunset` when you know the removal date.

| Situation | Use |
| :------------------------------------------------------- | :-------------------------- |
| Route works, clients should migrate, no removal date yet | `Deprecation` |
| You know the date the route will stop working | `Deprecation` + `Sunset` |
| Route is already removed | Neither. Return `410 Gone`. |

```go
// A bad date parses to the zero time, and the middleware panics at startup.
deprecatedAt, _ := time.Parse(time.DateOnly, "2026-01-15")
sunsetAt, _ := time.Parse(time.DateOnly, "2026-07-01")

// Step 1: the route is deprecated.
r.With(middleware.Deprecation(deprecatedAt)).Get("/v1/users", listUsers)

// Step 2: the removal date is known.
r.With(middleware.Deprecation(deprecatedAt), middleware.Sunset(sunsetAt)).Get("/v1/users", listUsers)

// After sunsetAt: remove the route, or return 410 Gone.
```

### Extra middlewares & packages

Please see https://github.com/go-chi for additional packages.
Expand Down
40 changes: 40 additions & 0 deletions middleware/deprecation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package middleware

import (
"net/http"
"strconv"
"time"
)

// Deprecation marks a route as deprecated, per RFC 9745.
// https://www.rfc-editor.org/rfc/rfc9745.html
//
// Use it from the day you decide to retire a route. The route keeps working,
// and clients are told to migrate. When you know the removal date, add [Sunset].
// Middleware order doesn't matter.
//
// deprecatedAt is when the route was, or will be, deprecated. A past date is fine.
// It panics if deprecatedAt is the zero time, which is usually an unset value.
// Pass a real date, or skip the middleware when no date is set.
//
// Each link is added as-is as a Link header, so it must be a full RFC 8288
// value, e.g. `<https://example.com/migrate>; rel="deprecation"`.
func Deprecation(deprecatedAt time.Time, links ...string) func(http.Handler) http.Handler {
if deprecatedAt.IsZero() {
panic("middleware.Deprecation: deprecatedAt must not be zero")
}

// RFC 9745 uses a Structured Field Date (RFC 9651), not an HTTP-date.
value := "@" + strconv.FormatInt(deprecatedAt.Unix(), 10)

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", value)

for _, link := range links {
w.Header().Add("Link", link)
}
next.ServeHTTP(w, r)
})
}
}
105 changes: 105 additions & 0 deletions middleware/deprecation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package middleware

import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/go-chi/chi/v5"
)

func TestDeprecation(t *testing.T) {
deprecatedAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC)

serve := func(mw func(http.Handler) http.Handler) http.Header {
r := chi.NewRouter()
r.Use(mw)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatal("Response Code should be 200")
}
return w.Header()
}

t.Run("Deprecation without link", func(t *testing.T) {
h := serve(Deprecation(deprecatedAt))

if got, want := h.Get("Deprecation"), "@1766571600"; got != want {
t.Fatalf("Deprecation = %q, want %q", got, want)
}
if h.Get("Sunset") != "" {
t.Fatal("Deprecation should not set Sunset.")
}
if h.Get("Link") != "" {
t.Fatal("Link should be empty.")
}
})

t.Run("Deprecation with link", func(t *testing.T) {
link := `<https://example.com/v1/deprecation-details>; rel="deprecation"`
h := serve(Deprecation(deprecatedAt, link))

if got, want := h.Get("Deprecation"), "@1766571600"; got != want {
t.Fatalf("Deprecation = %q, want %q", got, want)
}
if got := h.Get("Link"); got != link {
t.Fatalf("Link = %q, want %q", got, link)
}
})

t.Run("Deprecation with multiple links", func(t *testing.T) {
docs := `<https://example.com/v1/deprecation-details>; rel="deprecation"`
next := `<https://example.com/v2/users>; rel="successor-version"`
h := serve(Deprecation(deprecatedAt, docs, next))

got := h.Values("Link")
if len(got) != 2 || got[0] != docs || got[1] != next {
t.Fatalf("Link = %q, want [%q %q]", got, docs, next)
}
})

t.Run("Zero time panics", func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("Deprecation should panic for zero time.")
}
}()
Deprecation(time.Time{})
})

t.Run("Combined with Sunset", func(t *testing.T) {
sunsetAt := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
h := serve(func(next http.Handler) http.Handler {
return Deprecation(deprecatedAt)(Sunset(sunsetAt)(next))
})

if h.Get("Deprecation") != "@1766571600" {
t.Fatal("Deprecation header missing.")
}
if h.Get("Link") != "" {
t.Fatal("Link should be empty.")
}
if got, want := h.Get("Sunset"), "Mon, 01 Jun 2026 00:00:00 GMT"; got != want {
t.Fatalf("Sunset = %q, want %q", got, want)
}
})
t.Run("Combined with Sunset keeps links from both", func(t *testing.T) {
sunsetAt := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
dep := `<https://example.com/deprecation>; rel="deprecation"`
sun := `<https://example.com/sunset>; rel="sunset"`
h := serve(func(next http.Handler) http.Handler {
return Deprecation(deprecatedAt, dep)(Sunset(sunsetAt, sun)(next))
})

got := h.Values("Link")
if len(got) != 2 || got[0] != dep || got[1] != sun {
t.Fatalf("Link = %q, want [%q %q]", got, dep, sun)
}
})
}
30 changes: 21 additions & 9 deletions middleware/sunset.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,31 @@ import (
"time"
)

// Sunset set Deprecation/Sunset header to response
// This can be used to enable Sunset in a route or a route group
// For more: https://www.rfc-editor.org/rfc/rfc8594.html
// Sunset announces when a route will stop working, per RFC 8594.
// https://www.rfc-editor.org/rfc/rfc8594.html
//
// Use it once you know the removal date, and keep [Deprecation] on the same
// route. Sunset alone is valid, but clients get no "stop using this" signal.
// Middleware order doesn't matter.
//
// sunsetAt is usually a future date, and should not be before the deprecation date.
// After it, remove the route or return 410 Gone.
// It panics if sunsetAt is the zero time, which is usually an unset value.
// Pass a real date, or skip the middleware when no date is set.
//
// Each link is added as-is as a Link header, so it must be a full RFC 8288
// value, e.g. `<https://example.com/migrate>; rel="sunset"`.
func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler {
if sunsetAt.IsZero() {
panic("middleware.Sunset: sunsetAt must not be zero")
}

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !sunsetAt.IsZero() {
w.Header().Set("Sunset", sunsetAt.Format(http.TimeFormat))
w.Header().Set("Deprecation", sunsetAt.Format(http.TimeFormat))
w.Header().Set("Sunset", sunsetAt.UTC().Format(http.TimeFormat))

for _, link := range links {
w.Header().Add("Link", link)
}
for _, link := range links {
w.Header().Add("Link", link)
}
next.ServeHTTP(w, r)
})
Expand Down
46 changes: 36 additions & 10 deletions middleware/sunset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ func TestSunset(t *testing.T) {
t.Fatal("Test get sunset error.", sunset)
}

if deprecation != "Wed, 24 Dec 2025 10:20:00 GMT" {
t.Fatal("Test get deprecation error.")
if deprecation != "" {
t.Fatal("Sunset should not set Deprecation.", deprecation)
}
})

Expand All @@ -49,8 +49,8 @@ func TestSunset(t *testing.T) {
r := chi.NewRouter()

sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC)
deprecationLink := "https://example.com/v1/deprecation-details"
r.Use(Sunset(sunsetAt, deprecationLink))
sunsetLink := `<https://example.com/v1/sunset-details>; rel="sunset"`
r.Use(Sunset(sunsetAt, sunsetLink))

var sunset, deprecation, link string
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -72,15 +72,41 @@ func TestSunset(t *testing.T) {
t.Fatal("Test get sunset error.", sunset)
}

if deprecation != "Wed, 24 Dec 2025 10:20:00 GMT" {
t.Fatal("Test get deprecation error.")
if deprecation != "" {
t.Fatal("Sunset should not set Deprecation.", deprecation)
}

if link != deprecationLink {
t.Fatal("Test get deprecation link error.")
if link != sunsetLink {
t.Fatal("Test get sunset link error.")
}
})

t.Run("Sunset with multiple links", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()

r := chi.NewRouter()

docs := `<https://example.com/v1/sunset-details>; rel="sunset"`
next := `<https://example.com/v2/users>; rel="successor-version"`
r.Use(Sunset(time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC), docs, next))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {})
r.ServeHTTP(w, req)

got := w.Header().Values("Link")
if len(got) != 2 || got[0] != docs || got[1] != next {
t.Fatalf("Link = %q, want [%q %q]", got, docs, next)
}
})

t.Run("Zero time panics", func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("Sunset should panic for zero time.")
}
}()
Sunset(time.Time{})
})
}

/**
Expand All @@ -91,8 +117,8 @@ func main() {
sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC)
r.Use(middleware.Sunset(sunsetAt))

// can provide additional link for updated resource
// r.Use(middleware.Sunset(sunsetAt, "https://example.com/v1/deprecation-details"))
// can provide a Link header value (RFC 8288) pointing at more details
// r.Use(middleware.Sunset(sunsetAt, `<https://example.com/v1/sunset-details>; rel="sunset"`))

r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("This endpoint will be removed soon"))
Expand Down
Loading