namespace ErsatzTV.Middleware; /// /// Rewrites an unversioned legacy /api/* request to the current default version /// (/api/v1/*) in-pipeline. This is a rewrite, not a redirect: the method, body, /// query string and auth headers all survive, so a legacy client (curl, the MCP server, a /// bookmarked URL) keeps working with no extra round-trip. The deprecation is advertised via the /// RFC 8594 Deprecation and (optionally) Sunset response headers. /// /// /// Sequenced before UseRouting so the rewritten path matches the versioned controller /// routes. Introduced by ersatztv#286 when the whole /api surface was versioned to /// /api/v1; the compat shim is scheduled for removal roughly two releases out (see /// docs/decisions.md). Once /api/v2 exists this middleware deliberately does not /// force an unversioned call onto v2 — an already-versioned path is passed through untouched. /// public sealed class ApiVersionRewriteMiddleware { /// The default API version an unversioned legacy path is rewritten onto. public const string DefaultVersionSegment = "v1"; private readonly RequestDelegate _next; private readonly string _sunset; public ApiVersionRewriteMiddleware(RequestDelegate next, IConfiguration configuration) { _next = next; // Optional operator-set removal date advertised in the Sunset header (RFC 8594). Advisory only — // the actual removal of the compat shim is a future release (ersatztv#286 Phase-3 follow-up). _sunset = configuration["Api:LegacyRoutesSunset"]; } public Task InvokeAsync(HttpContext context) { if (TryRewriteLegacyApiPath(context.Request.Path, out PathString rewritten)) { context.Request.Path = rewritten; context.Response.Headers["Deprecation"] = "true"; // Root the docs link at the request PathBase so it stays correct under a reverse-proxy // base URL (ETV_BASE_URL): empty PathBase → , PathBase "/etv" → . context.Response.Headers["Link"] = $"<{context.Request.PathBase}/docs>; rel=\"deprecation\""; if (!string.IsNullOrWhiteSpace(_sunset)) { context.Response.Headers["Sunset"] = _sunset; } } return _next(context); } /// /// Pure decision: an /api/* path whose first segment after /api is not already /// a version token (v<digits>) is a legacy unversioned call and is rewritten under the /// default version. Returns false (no rewrite) for an already-versioned path or any non-/api /// path. The /api and version segments are matched case-insensitively. /// public static bool TryRewriteLegacyApiPath(PathString path, out PathString rewritten) { rewritten = path; if (!path.HasValue) { return false; } string value = path.Value; if (!value.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) { return false; } const int firstSegmentStart = 5; // "/api/".Length int firstSegmentEnd = value.IndexOf('/', firstSegmentStart); string firstSegment = firstSegmentEnd < 0 ? value[firstSegmentStart..] : value[firstSegmentStart..firstSegmentEnd]; if (IsVersionSegment(firstSegment)) { return false; } // Keep everything from "/api" onward, splice the version segment in after it. rewritten = new PathString("/api/" + DefaultVersionSegment + value[4..]); return true; } private static bool IsVersionSegment(string segment) { if (segment.Length < 2 || (segment[0] != 'v' && segment[0] != 'V')) { return false; } for (var i = 1; i < segment.Length; i++) { if (!char.IsDigit(segment[i])) { return false; } } return true; } }