feat(api): add sunset deprecation middleware
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
b64c8d3271
commit
29324b0a0b
6 changed files with 198 additions and 14 deletions
5
group.go
5
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
|
||||
|
|
|
|||
68
openapi.go
68
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{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
58
sunset.go
Normal 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
53
sunset_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue