Files
ersatztv/ErsatzTV/Middleware/ApiVersionRewriteMiddleware.cs
T
timothyandClaude Opus 4.8 682dceec8f
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
fix(api): #286 review — allowlist non-/api routes in the versioning test; base-url-aware deprecation Link
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>
2026-07-13 00:40:58 +02:00

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&#160;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&lt;digits&gt;</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;
}
}