feat(api): add sunset deprecation middleware

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 23:02:52 +00:00
parent b64c8d3271
commit 29324b0a0b
6 changed files with 198 additions and 14 deletions

View file

@ -53,6 +53,11 @@ type RouteDescription struct {
Tags []string // OpenAPI tags for grouping
// Deprecated marks the operation as deprecated in OpenAPI.
Deprecated bool
// SunsetDate marks when a deprecated operation will be removed.
// Use YYYY-MM-DD or an RFC 7231 HTTP date string.
SunsetDate string
// Replacement points to the successor endpoint URL, when known.
Replacement string
// StatusCode is the documented 2xx success status code.
// Zero defaults to 200.
StatusCode int

View file

@ -187,14 +187,16 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
for _, rd := range g.descs {
fullPath := joinOpenAPIPath(g.group.BasePath(), rd.Path)
method := strings.ToLower(rd.Method)
deprecated := rd.Deprecated || strings.TrimSpace(rd.SunsetDate) != "" || strings.TrimSpace(rd.Replacement) != ""
deprecationHeaders := deprecationResponseHeaders(deprecated, rd.SunsetDate, rd.Replacement)
operation := map[string]any{
"summary": rd.Summary,
"description": rd.Description,
"operationId": operationID(method, fullPath, operationIDs),
"responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample, rd.ResponseHeaders, rd.Security),
"responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample, rd.ResponseHeaders, rd.Security, deprecationHeaders),
}
if rd.Deprecated {
if deprecated {
operation["deprecated"] = true
}
if rd.Security != nil {
@ -321,8 +323,8 @@ func normaliseOpenAPIPath(path string) string {
// operationResponses builds the standard response set for a documented API
// operation. The framework always exposes the common envelope responses, plus
// middleware-driven 429 and 504 errors.
func operationResponses(method string, statusCode int, dataSchema map[string]any, example any, responseHeaders map[string]string, security []map[string][]string) map[string]any {
successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
func operationResponses(method string, statusCode int, dataSchema map[string]any, example any, responseHeaders map[string]string, security []map[string][]string, deprecationHeaders map[string]any) map[string]any {
successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders)
if method == "get" {
successHeaders = mergeHeaders(successHeaders, cacheSuccessHeaders())
}
@ -361,7 +363,7 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
"schema": envelopeSchema(nil),
},
},
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()),
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders),
},
"429": map[string]any{
"description": "Too many requests",
@ -370,7 +372,7 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
"schema": envelopeSchema(nil),
},
},
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()),
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders(), deprecationHeaders),
},
"504": map[string]any{
"description": "Gateway timeout",
@ -379,7 +381,7 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
"schema": envelopeSchema(nil),
},
},
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()),
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders),
},
"500": map[string]any{
"description": "Internal server error",
@ -388,7 +390,7 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
"schema": envelopeSchema(nil),
},
},
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()),
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders),
},
}
@ -400,7 +402,7 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
"schema": envelopeSchema(nil),
},
},
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()),
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders),
}
responses["403"] = map[string]any{
"description": "Forbidden",
@ -409,7 +411,7 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
"schema": envelopeSchema(nil),
},
},
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()),
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders),
}
}
@ -493,6 +495,52 @@ func healthResponses() map[string]any {
}
}
// deprecationResponseHeaders documents the standard deprecation headers for
// deprecated or sunsetted operations.
func deprecationResponseHeaders(deprecated bool, sunsetDate, replacement string) map[string]any {
sunsetDate = strings.TrimSpace(sunsetDate)
replacement = strings.TrimSpace(replacement)
if !deprecated && sunsetDate == "" && replacement == "" {
return nil
}
headers := map[string]any{
"Deprecation": map[string]any{
"description": "Indicates the endpoint is deprecated",
"schema": map[string]any{
"type": "string",
},
},
"X-API-Warn": map[string]any{
"description": "Human-readable deprecation warning",
"schema": map[string]any{
"type": "string",
},
},
}
if sunsetDate != "" {
headers["Sunset"] = map[string]any{
"description": "RFC 7231 date when the endpoint will be removed",
"schema": map[string]any{
"type": "string",
},
}
}
if replacement != "" {
headers["Link"] = map[string]any{
"description": "Successor endpoint advertised with rel=\"successor-version\"",
"schema": map[string]any{
"type": "string",
},
}
}
return headers
}
// buildTags generates the tags array from all RouteGroups.
func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any {
tags := []map[string]any{

View file

@ -1451,10 +1451,12 @@ func TestSpecBuilder_Good_DeprecatedOperation(t *testing.T) {
basePath: "/api/legacy",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Check legacy status",
Deprecated: true,
Method: "GET",
Path: "/status",
Summary: "Check legacy status",
Deprecated: true,
SunsetDate: "2025-06-01",
Replacement: "/api/v2/status",
Response: map[string]any{
"type": "object",
},
@ -1480,6 +1482,15 @@ func TestSpecBuilder_Good_DeprecatedOperation(t *testing.T) {
if !deprecated {
t.Fatal("expected deprecated operation to be marked true")
}
responses := op["responses"].(map[string]any)
success := responses["200"].(map[string]any)
headers := success["headers"].(map[string]any)
for _, name := range []string{"Deprecation", "Sunset", "Link", "X-API-Warn"} {
if _, ok := headers[name]; !ok {
t.Fatalf("expected deprecation header %q in operation response headers", name)
}
}
}
func TestSpecBuilder_Good_BlankTagsAreIgnored(t *testing.T) {

View file

@ -118,6 +118,15 @@ func WithAuthentik(cfg AuthentikConfig) Option {
}
}
// WithSunset adds deprecation headers to every response.
// The middleware emits Deprecation, optional Sunset, optional Link, and
// X-API-Warn headers. Use it to deprecate an entire route group or API version.
func WithSunset(sunsetDate, replacement string) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, ApiSunset(sunsetDate, replacement))
}
}
// WithSwagger enables the Swagger UI at /swagger/.
// The title, description, and version populate the OpenAPI info block.
func WithSwagger(title, description, version string) Option {

58
sunset.go Normal file
View file

@ -0,0 +1,58 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// ApiSunset returns middleware that marks a route or group as deprecated.
//
// The middleware adds standard deprecation headers to every response:
// Deprecation, optional Sunset, optional Link, and X-API-Warn.
//
// Example:
//
// rg.Use(api.ApiSunset("2025-06-01", "/api/v2/users"))
func ApiSunset(sunsetDate, replacement string) gin.HandlerFunc {
sunsetDate = strings.TrimSpace(sunsetDate)
replacement = strings.TrimSpace(replacement)
formatted := formatSunsetDate(sunsetDate)
warning := "This endpoint is deprecated."
if sunsetDate != "" {
warning = "This endpoint is deprecated and will be removed on " + sunsetDate + "."
}
return func(c *gin.Context) {
c.Header("Deprecation", "true")
if formatted != "" {
c.Header("Sunset", formatted)
}
if replacement != "" {
c.Header("Link", "<"+replacement+">; rel=\"successor-version\"")
}
c.Header("X-API-Warn", warning)
c.Next()
}
}
func formatSunsetDate(sunsetDate string) string {
sunsetDate = strings.TrimSpace(sunsetDate)
if sunsetDate == "" {
return ""
}
if strings.Contains(sunsetDate, ",") {
return sunsetDate
}
parsed, err := time.Parse("2006-01-02", sunsetDate)
if err != nil {
return sunsetDate
}
return parsed.UTC().Format(http.TimeFormat)
}

53
sunset_test.go Normal file
View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
api "dappco.re/go/core/api"
)
type sunsetStubGroup struct{}
func (sunsetStubGroup) Name() string { return "legacy" }
func (sunsetStubGroup) BasePath() string { return "/legacy" }
func (sunsetStubGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/status", func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
}
func TestWithSunset_Good_AddsDeprecationHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithSunset("2025-06-01", "/api/v2/status"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e.Register(sunsetStubGroup{})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/legacy/status", nil)
e.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if got := w.Header().Get("Deprecation"); got != "true" {
t.Fatalf("expected Deprecation=true, got %q", got)
}
if got := w.Header().Get("Sunset"); got != "Sun, 01 Jun 2025 00:00:00 GMT" {
t.Fatalf("expected formatted Sunset header, got %q", got)
}
if got := w.Header().Get("Link"); got != "</api/v2/status>; rel=\"successor-version\"" {
t.Fatalf("expected successor Link header, got %q", got)
}
if got := w.Header().Get("X-API-Warn"); got != "This endpoint is deprecated and will be removed on 2025-06-01." {
t.Fatalf("expected deprecation warning, got %q", got)
}
}