Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m49s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m43s
Cold-fork + Codex review of PR #326: - ApiRouteVersioningTests: iterate IRouteTemplateProvider (covers a template-less [HttpGet] paired with an action-level [Route]) and assert any non-/api route against an explicit KnownNonApiRoutes allowlist instead of silently skipping — an accidental absolute non-/api route (which would also escape ApiAuthorizationFilter's /api-scoped gate) now fails the test. - ApiVersionRewriteMiddleware: root the deprecation Link at Request.PathBase so it stays correct under ETV_BASE_URL (</etv/docs>, not host-root </docs>). refs #286 #197 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
4.2 KiB
C#
106 lines
4.2 KiB
C#
namespace ErsatzTV.Middleware;
|
|
|
|
/// <summary>
|
|
/// Rewrites an unversioned legacy <c>/api/*</c> request to the current default version
|
|
/// (<c>/api/v1/*</c>) in-pipeline. This is a <b>rewrite, not a redirect</b>: 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 <c>Deprecation</c> and (optionally) <c>Sunset</c> response headers.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Sequenced <b>before</b> <c>UseRouting</c> so the rewritten path matches the versioned controller
|
|
/// routes. Introduced by ersatztv#286 when the whole <c>/api</c> surface was versioned to
|
|
/// <c>/api/v1</c>; the compat shim is scheduled for removal roughly two releases out (see
|
|
/// <c>docs/decisions.md</c>). Once <c>/api/v2</c> exists this middleware deliberately does <b>not</b>
|
|
/// force an unversioned call onto v2 — an already-versioned path is passed through untouched.
|
|
/// </remarks>
|
|
public sealed class ApiVersionRewriteMiddleware
|
|
{
|
|
/// <summary>The default API version an unversioned legacy path is rewritten onto.</summary>
|
|
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 → </docs>, PathBase "/etv" → </etv/docs>.
|
|
context.Response.Headers["Link"] = $"<{context.Request.PathBase}/docs>; rel=\"deprecation\"";
|
|
if (!string.IsNullOrWhiteSpace(_sunset))
|
|
{
|
|
context.Response.Headers["Sunset"] = _sunset;
|
|
}
|
|
}
|
|
|
|
return _next(context);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure decision: an <c>/api/*</c> path whose first segment after <c>/api</c> is <b>not</b> already
|
|
/// a version token (<c>v<digits></c>) is a legacy unversioned call and is rewritten under the
|
|
/// default version. Returns <c>false</c> (no rewrite) for an already-versioned path or any non-<c>/api</c>
|
|
/// path. The <c>/api</c> and version segments are matched case-insensitively.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|