197 lines
6.4 KiB
C#
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;
|
|
}
|
|
}
|