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;
}
}