diff --git a/group.go b/group.go index 9abae08..d44acdf 100644 --- a/group.go +++ b/group.go @@ -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 diff --git a/openapi.go b/openapi.go index aa74fc6..a3111cb 100644 --- a/openapi.go +++ b/openapi.go @@ -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{ diff --git a/openapi_test.go b/openapi_test.go index beca70b..1c941c9 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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) { diff --git a/options.go b/options.go index 739d1fe..1487282 100644 --- a/options.go +++ b/options.go @@ -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 { diff --git a/sunset.go b/sunset.go new file mode 100644 index 0000000..6349905 --- /dev/null +++ b/sunset.go @@ -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) +} diff --git a/sunset_test.go b/sunset_test.go new file mode 100644 index 0000000..b8b46fc --- /dev/null +++ b/sunset_test.go @@ -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 != "; 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) + } +}