Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace StellaOps.TaskRunner.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for API deprecation and sunset headers.
|
||||
/// </summary>
|
||||
public sealed class ApiDeprecationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "TaskRunner:ApiDeprecation";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to emit deprecation headers for deprecated endpoints.
|
||||
/// </summary>
|
||||
public bool EmitDeprecationHeaders { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to emit sunset headers per RFC 8594.
|
||||
/// </summary>
|
||||
public bool EmitSunsetHeaders { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// URL to deprecation policy documentation.
|
||||
/// </summary>
|
||||
public string? DeprecationPolicyUrl { get; set; } = "https://docs.stellaops.io/api/deprecation-policy";
|
||||
|
||||
/// <summary>
|
||||
/// List of deprecated endpoints with their sunset dates.
|
||||
/// </summary>
|
||||
public List<DeprecatedEndpoint> DeprecatedEndpoints { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a deprecated endpoint.
|
||||
/// </summary>
|
||||
public sealed class DeprecatedEndpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Path pattern to match (supports wildcards like /v1/packs/*).
|
||||
/// </summary>
|
||||
public string PathPattern { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Date when the endpoint was deprecated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DeprecatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Date when the endpoint will be removed (sunset date per RFC 8594).
|
||||
/// </summary>
|
||||
public DateTimeOffset? SunsetAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to documentation about the deprecation and migration path.
|
||||
/// </summary>
|
||||
public string? DeprecationLink { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested replacement endpoint path.
|
||||
/// </summary>
|
||||
public string? ReplacementPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable deprecation message.
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.TaskRunner.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for sending deprecation notifications to API consumers.
|
||||
/// </summary>
|
||||
public interface IDeprecationNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a notification about an upcoming deprecation.
|
||||
/// </summary>
|
||||
/// <param name="notification">Deprecation notification details.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets upcoming deprecations within a specified number of days.
|
||||
/// </summary>
|
||||
/// <param name="withinDays">Number of days to look ahead.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of upcoming deprecations.</returns>
|
||||
Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
|
||||
int withinDays = 90,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation notification details.
|
||||
/// </summary>
|
||||
public sealed record DeprecationNotification(
|
||||
string EndpointPath,
|
||||
string? ReplacementPath,
|
||||
DateTimeOffset? SunsetDate,
|
||||
string? Message,
|
||||
string? DocumentationUrl,
|
||||
IReadOnlyList<string>? AffectedConsumerIds);
|
||||
|
||||
/// <summary>
|
||||
/// Information about a deprecation.
|
||||
/// </summary>
|
||||
public sealed record DeprecationInfo(
|
||||
string EndpointPath,
|
||||
DateTimeOffset? DeprecatedAt,
|
||||
DateTimeOffset? SunsetAt,
|
||||
string? ReplacementPath,
|
||||
string? DocumentationUrl,
|
||||
int DaysUntilSunset);
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation that logs deprecation notifications.
|
||||
/// </summary>
|
||||
public sealed class LoggingDeprecationNotificationService : IDeprecationNotificationService
|
||||
{
|
||||
private readonly ILogger<LoggingDeprecationNotificationService> _logger;
|
||||
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
|
||||
|
||||
public LoggingDeprecationNotificationService(
|
||||
ILogger<LoggingDeprecationNotificationService> logger,
|
||||
IOptionsMonitor<ApiDeprecationOptions> options)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Deprecation notification: Endpoint {Endpoint} will be sunset on {SunsetDate}. " +
|
||||
"Replacement: {Replacement}. Message: {Message}",
|
||||
notification.EndpointPath,
|
||||
notification.SunsetDate?.ToString("o"),
|
||||
notification.ReplacementPath ?? "(none)",
|
||||
notification.Message ?? "(none)");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
|
||||
int withinDays = 90,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var cutoff = now.AddDays(withinDays);
|
||||
|
||||
var upcoming = options.DeprecatedEndpoints
|
||||
.Where(e => e.SunsetAt.HasValue && e.SunsetAt.Value <= cutoff && e.SunsetAt.Value > now)
|
||||
.OrderBy(e => e.SunsetAt)
|
||||
.Select(e => new DeprecationInfo(
|
||||
e.PathPattern,
|
||||
e.DeprecatedAt,
|
||||
e.SunsetAt,
|
||||
e.ReplacementPath,
|
||||
e.DeprecationLink,
|
||||
e.SunsetAt.HasValue ? (int)(e.SunsetAt.Value - now).TotalDays : int.MaxValue))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DeprecationInfo>>(upcoming);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace StellaOps.TaskRunner.WebService;
|
||||
/// <summary>
|
||||
/// Factory for creating OpenAPI metadata including version, build info, and spec signature.
|
||||
/// </summary>
|
||||
internal static class OpenApiMetadataFactory
|
||||
public static class OpenApiMetadataFactory
|
||||
{
|
||||
/// <summary>API version from the OpenAPI spec (docs/api/taskrunner-openapi.yaml).</summary>
|
||||
public const string ApiVersion = "0.1.0-draft";
|
||||
@@ -73,7 +73,7 @@ internal static class OpenApiMetadataFactory
|
||||
/// <param name="BuildVersion">Build/assembly version with optional git info.</param>
|
||||
/// <param name="ETag">ETag for HTTP caching.</param>
|
||||
/// <param name="Signature">SHA-256 signature for verification.</param>
|
||||
internal sealed record OpenApiMetadata(
|
||||
public sealed record OpenApiMetadata(
|
||||
string SpecUrl,
|
||||
string Version,
|
||||
string BuildVersion,
|
||||
|
||||
@@ -5,7 +5,10 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using MongoDB.Driver;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -17,6 +20,7 @@ using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
using StellaOps.TaskRunner.WebService;
|
||||
using StellaOps.TaskRunner.WebService.Deprecation;
|
||||
using StellaOps.Telemetry.Core;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -95,12 +99,42 @@ builder.Services.AddSingleton(sp =>
|
||||
});
|
||||
builder.Services.AddSingleton<IPackRunJobScheduler>(sp => sp.GetRequiredService<FilesystemPackRunDispatcher>());
|
||||
builder.Services.AddSingleton<PackRunApprovalDecisionService>();
|
||||
builder.Services.AddApiDeprecation(builder.Configuration);
|
||||
builder.Services.AddSingleton<IDeprecationNotificationService, LoggingDeprecationNotificationService>();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Add deprecation middleware for sunset headers (RFC 8594)
|
||||
app.UseApiDeprecation();
|
||||
|
||||
app.MapOpenApi("/openapi");
|
||||
|
||||
// Deprecation status endpoint
|
||||
app.MapGet("/v1/task-runner/deprecations", async (
|
||||
IDeprecationNotificationService deprecationService,
|
||||
[FromQuery] int? withinDays,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var days = withinDays ?? 90;
|
||||
var deprecations = await deprecationService.GetUpcomingDeprecationsAsync(days, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
withinDays = days,
|
||||
deprecations = deprecations.Select(d => new
|
||||
{
|
||||
endpoint = d.EndpointPath,
|
||||
deprecatedAt = d.DeprecatedAt?.ToString("o"),
|
||||
sunsetAt = d.SunsetAt?.ToString("o"),
|
||||
daysUntilSunset = d.DaysUntilSunset,
|
||||
replacement = d.ReplacementPath,
|
||||
documentation = d.DocumentationUrl
|
||||
})
|
||||
});
|
||||
}).WithName("GetDeprecations").WithTags("API Governance");
|
||||
|
||||
app.MapPost("/v1/task-runner/simulations", async (
|
||||
[FromBody] SimulationRequest request,
|
||||
TaskPackManifestLoader loader,
|
||||
@@ -290,11 +324,11 @@ async Task<IResult> HandleStreamRunLogs(
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Stream(async (stream, ct) =>
|
||||
return Results.Stream(async stream =>
|
||||
{
|
||||
await foreach (var entry in logStore.ReadAsync(runId, ct).ConfigureAwait(false))
|
||||
await foreach (var entry in logStore.ReadAsync(runId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
await RunLogMapper.WriteAsync(stream, entry, ct).ConfigureAwait(false);
|
||||
await RunLogMapper.WriteAsync(stream, entry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}, "application/x-ndjson");
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
|
||||
|
||||
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user