Files
git.stella-ops.org/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Deprecation/ApiDeprecationMiddleware.cs
StellaOps Bot dd0067ea0b
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Refactor code structure for improved readability and maintainability
2025-12-06 21:48:12 +02:00

197 lines
6.4 KiB
C#

using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace StellaOps.TaskRunner.WebService.Deprecation;
/// <summary>
/// Middleware that adds deprecation and sunset headers per RFC 8594.
/// </summary>
public sealed class ApiDeprecationMiddleware
{
private readonly RequestDelegate _next;
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
private readonly ILogger<ApiDeprecationMiddleware> _logger;
private readonly List<CompiledEndpointPattern> _patterns;
/// <summary>
/// HTTP header for deprecation status per draft-ietf-httpapi-deprecation-header.
/// </summary>
public const string DeprecationHeader = "Deprecation";
/// <summary>
/// HTTP header for sunset date per RFC 8594.
/// </summary>
public const string SunsetHeader = "Sunset";
/// <summary>
/// HTTP Link header for deprecation documentation.
/// </summary>
public const string LinkHeader = "Link";
public ApiDeprecationMiddleware(
RequestDelegate next,
IOptionsMonitor<ApiDeprecationOptions> options,
ILogger<ApiDeprecationMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_patterns = CompilePatterns(options.CurrentValue.DeprecatedEndpoints);
options.OnChange(newOptions =>
{
_patterns.Clear();
_patterns.AddRange(CompilePatterns(newOptions.DeprecatedEndpoints));
});
}
public async Task InvokeAsync(HttpContext context)
{
var options = _options.CurrentValue;
var path = context.Request.Path.Value ?? string.Empty;
var deprecatedEndpoint = FindMatchingEndpoint(path);
if (deprecatedEndpoint is not null)
{
AddDeprecationHeaders(context.Response, deprecatedEndpoint, options);
_logger.LogInformation(
"Deprecated endpoint accessed: {Path} (sunset: {Sunset})",
path,
deprecatedEndpoint.Config.SunsetAt?.ToString("o", CultureInfo.InvariantCulture) ?? "not set");
}
await _next(context).ConfigureAwait(false);
}
private CompiledEndpointPattern? FindMatchingEndpoint(string path)
{
foreach (var pattern in _patterns)
{
if (pattern.Regex.IsMatch(path))
{
return pattern;
}
}
return null;
}
private static void AddDeprecationHeaders(
HttpResponse response,
CompiledEndpointPattern endpoint,
ApiDeprecationOptions options)
{
var config = endpoint.Config;
// Add Deprecation header per draft-ietf-httpapi-deprecation-header
if (options.EmitDeprecationHeaders && config.DeprecatedAt.HasValue)
{
// RFC 7231 date format: Sun, 06 Nov 1994 08:49:37 GMT
var deprecationDate = config.DeprecatedAt.Value.ToString("R", CultureInfo.InvariantCulture);
response.Headers.Append(DeprecationHeader, deprecationDate);
}
else if (options.EmitDeprecationHeaders)
{
// If no specific date, use "true" to indicate deprecated
response.Headers.Append(DeprecationHeader, "true");
}
// Add Sunset header per RFC 8594
if (options.EmitSunsetHeaders && config.SunsetAt.HasValue)
{
var sunsetDate = config.SunsetAt.Value.ToString("R", CultureInfo.InvariantCulture);
response.Headers.Append(SunsetHeader, sunsetDate);
}
// Add Link headers for documentation
var links = new List<string>();
if (!string.IsNullOrWhiteSpace(config.DeprecationLink))
{
links.Add($"<{config.DeprecationLink}>; rel=\"deprecation\"; type=\"text/html\"");
}
if (!string.IsNullOrWhiteSpace(options.DeprecationPolicyUrl))
{
links.Add($"<{options.DeprecationPolicyUrl}>; rel=\"sunset\"; type=\"text/html\"");
}
if (!string.IsNullOrWhiteSpace(config.ReplacementPath))
{
links.Add($"<{config.ReplacementPath}>; rel=\"successor-version\"");
}
if (links.Count > 0)
{
response.Headers.Append(LinkHeader, string.Join(", ", links));
}
// Add custom deprecation message header
if (!string.IsNullOrWhiteSpace(config.Message))
{
response.Headers.Append("X-Deprecation-Notice", config.Message);
}
}
private static List<CompiledEndpointPattern> CompilePatterns(List<DeprecatedEndpoint> endpoints)
{
var patterns = new List<CompiledEndpointPattern>(endpoints.Count);
foreach (var endpoint in endpoints)
{
if (string.IsNullOrWhiteSpace(endpoint.PathPattern))
{
continue;
}
// Convert wildcard pattern to regex
var pattern = "^" + Regex.Escape(endpoint.PathPattern)
.Replace("\\*\\*", ".*")
.Replace("\\*", "[^/]*") + "$";
try
{
var regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
patterns.Add(new CompiledEndpointPattern(regex, endpoint));
}
catch (ArgumentException)
{
// Invalid regex pattern, skip
}
}
return patterns;
}
private sealed record CompiledEndpointPattern(Regex Regex, DeprecatedEndpoint Config);
}
/// <summary>
/// Extension methods for adding API deprecation middleware.
/// </summary>
public static class ApiDeprecationMiddlewareExtensions
{
/// <summary>
/// Adds the API deprecation middleware to the pipeline.
/// </summary>
public static IApplicationBuilder UseApiDeprecation(this IApplicationBuilder app)
{
return app.UseMiddleware<ApiDeprecationMiddleware>();
}
/// <summary>
/// Adds API deprecation services to the service collection.
/// </summary>
public static IServiceCollection AddApiDeprecation(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<ApiDeprecationOptions>(
configuration.GetSection(ApiDeprecationOptions.SectionName));
return services;
}
}