* Add ingress rate limiting hooks * Fix ingress rate limiting middleware setup * Harden rate limiter policy validation * Preserve routed endpoints during rate limiting * Address rate limiting review feedback * Address rate limiting Copilot feedback * Register rate limiter services for external policies * Address rate limiting review comments * Keep rate limiter service detection best effort * Address rate limiting review comments * Remove brittle rate limiter validation * Address rate limiting review feedback * Address rate limiting nullable review * Address rate limiting review feedback * Assign ingress rate limit policies when enabled * Refine ingress rate limiting middleware cleanup * Address rate limiting review feedback * Align rate limiting review feedback * Clarify rate limiting policy semantics * Stabilize rate limiting exception tests * Fix rate limiting endpoint matching default
179 lines
9.1 KiB
C#
179 lines
9.1 KiB
C#
using System.Globalization;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Elsa.Workflows;
|
|
using FastEndpoints;
|
|
using JetBrains.Annotations;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
// ReSharper disable once CheckNamespace
|
|
namespace Elsa.Extensions;
|
|
|
|
/// <summary>
|
|
/// Provides extension methods to add FastEndpoints configured for use with Elsa API endpoints.
|
|
/// </summary>
|
|
[PublicAPI]
|
|
public static class WebApplicationExtensions
|
|
{
|
|
private static readonly RequestDelegate NotFoundRequestDelegate = context =>
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
return Task.CompletedTask;
|
|
};
|
|
|
|
/// <summary>
|
|
/// Registers the FastEndpoints middleware configured for use with Elsa API endpoints.
|
|
/// </summary>
|
|
/// <param name="app"></param>
|
|
/// <param name="routePrefix">The route prefix to apply to Elsa API endpoints.</param>
|
|
/// <example>E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions"</example>
|
|
public static IApplicationBuilder UseWorkflowsApi(this IApplicationBuilder app, string routePrefix = "elsa/api")
|
|
{
|
|
return app.UseFastEndpoints(config => ConfigureWorkflowsApi(config, routePrefix));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies an ASP.NET Core rate limiting policy to requests targeting the Elsa API route prefix.
|
|
/// </summary>
|
|
/// <param name="app">The application builder.</param>
|
|
/// <param name="routePrefix">The route prefix used by Elsa API endpoints.</param>
|
|
/// <param name="policyName">The registered ASP.NET Core rate limiting policy name. Leave empty to skip rate limiting.</param>
|
|
public static IApplicationBuilder UseWorkflowsApiRateLimiting(this IApplicationBuilder app, string routePrefix = "elsa/api", string? policyName = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(policyName))
|
|
return app;
|
|
|
|
var pathPrefix = NormalizeRoutePrefixPath(routePrefix);
|
|
return app.UseRateLimitingPolicyForPath(pathPrefix, policyName, "Elsa API rate limiting endpoint", requireMatchedEndpoint: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps FastEndpoints endpoint routes configured for use with Elsa API endpoints.
|
|
/// </summary>
|
|
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to register the endpoints with.</param>
|
|
/// <param name="routePrefix">The route prefix to apply to Elsa API endpoints.</param>
|
|
/// <example>E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions"</example>
|
|
public static IEndpointRouteBuilder MapWorkflowsApi(this IEndpointRouteBuilder routes, string routePrefix = "elsa/api") =>
|
|
routes.MapFastEndpoints(config => ConfigureWorkflowsApi(config, routePrefix));
|
|
|
|
/// <summary>
|
|
/// Applies an ASP.NET Core rate limiting policy to requests targeting the specified path prefix.
|
|
/// </summary>
|
|
/// <param name="app">The application builder.</param>
|
|
/// <param name="pathPrefix">The path prefix to protect.</param>
|
|
/// <param name="policyName">The registered ASP.NET Core rate limiting policy name.</param>
|
|
/// <param name="displayName">The endpoint display name used for rate limiting metadata.</param>
|
|
/// <remarks>
|
|
/// This method only attaches rate limiting metadata. In endpoint-routed pipelines, call this after routing has selected an endpoint
|
|
/// and before the host's single <c>app.UseRateLimiter()</c> middleware. ASP.NET Core validates the configured policy when the
|
|
/// rate limiter middleware handles matching requests.
|
|
/// </remarks>
|
|
public static IApplicationBuilder UseRateLimitingPolicyForPath(this IApplicationBuilder app, PathString pathPrefix, string policyName, string displayName) =>
|
|
app.UseRateLimitingPolicyForPath(pathPrefix, policyName, displayName, true);
|
|
|
|
/// <summary>
|
|
/// Applies an ASP.NET Core rate limiting policy to requests targeting the specified path prefix.
|
|
/// </summary>
|
|
/// <param name="app">The application builder.</param>
|
|
/// <param name="pathPrefix">The path prefix to protect.</param>
|
|
/// <param name="policyName">The registered ASP.NET Core rate limiting policy name.</param>
|
|
/// <param name="displayName">The endpoint display name used for rate limiting metadata.</param>
|
|
/// <param name="requireMatchedEndpoint">Whether to skip rate limiting when endpoint routing selected no endpoint.</param>
|
|
public static IApplicationBuilder UseRateLimitingPolicyForPath(this IApplicationBuilder app, PathString pathPrefix, string policyName, string displayName, bool requireMatchedEndpoint)
|
|
{
|
|
if (!pathPrefix.HasValue || string.IsNullOrWhiteSpace(policyName))
|
|
return app;
|
|
|
|
var rateLimitingMetadata = new EnableRateLimitingAttribute(policyName);
|
|
var fallbackEndpoint = CreateRateLimitingEndpoint(null, rateLimitingMetadata, displayName);
|
|
var endpointCache = new ConditionalWeakTable<Endpoint, Endpoint>();
|
|
|
|
return app.UseWhen(
|
|
context => context.Request.Path.StartsWithSegments(pathPrefix, StringComparison.OrdinalIgnoreCase),
|
|
branch =>
|
|
{
|
|
branch.Use(async (context, next) =>
|
|
{
|
|
var originalEndpoint = context.GetEndpoint();
|
|
if (requireMatchedEndpoint && originalEndpoint == null)
|
|
{
|
|
await next(context);
|
|
return;
|
|
}
|
|
|
|
var rateLimitingEndpoint = originalEndpoint == null
|
|
? fallbackEndpoint
|
|
: endpointCache.GetValue(originalEndpoint, endpoint => CreateRateLimitingEndpoint(endpoint, rateLimitingMetadata, displayName));
|
|
|
|
context.SetEndpoint(rateLimitingEndpoint);
|
|
|
|
try
|
|
{
|
|
await next(context);
|
|
}
|
|
finally
|
|
{
|
|
if (ReferenceEquals(context.GetEndpoint(), rateLimitingEndpoint))
|
|
context.SetEndpoint(originalEndpoint);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
private static PathString NormalizeRoutePrefixPath(string routePrefix)
|
|
{
|
|
var value = routePrefix.Trim().Trim('/');
|
|
|
|
return string.IsNullOrEmpty(value) ? PathString.Empty : new PathString("/" + value);
|
|
}
|
|
|
|
private static void ConfigureWorkflowsApi(Config config, string routePrefix)
|
|
{
|
|
config.Endpoints.RoutePrefix = routePrefix;
|
|
config.Serializer.RequestDeserializer = DeserializeRequestAsync;
|
|
config.Serializer.ResponseSerializer = SerializeRequestAsync;
|
|
|
|
config.Binding.ValueParserFor<DateTimeOffset>(s =>
|
|
new(DateTimeOffset.TryParse(s.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result), result));
|
|
}
|
|
|
|
private static Endpoint CreateRateLimitingEndpoint(Endpoint? originalEndpoint, EnableRateLimitingAttribute rateLimitingMetadata, string displayName)
|
|
{
|
|
var metadata = originalEndpoint == null
|
|
? new EndpointMetadataCollection(rateLimitingMetadata)
|
|
: new EndpointMetadataCollection(originalEndpoint.Metadata.Where(x => x is not EnableRateLimitingAttribute and not DisableRateLimitingAttribute).Concat([rateLimitingMetadata]));
|
|
|
|
if (originalEndpoint is RouteEndpoint routeEndpoint)
|
|
return new RouteEndpoint(routeEndpoint.RequestDelegate ?? NotFoundRequestDelegate, routeEndpoint.RoutePattern, routeEndpoint.Order, metadata, routeEndpoint.DisplayName ?? displayName);
|
|
|
|
return new Endpoint(originalEndpoint?.RequestDelegate ?? NotFoundRequestDelegate, metadata, originalEndpoint?.DisplayName ?? displayName);
|
|
}
|
|
|
|
private static ValueTask<object?> DeserializeRequestAsync(HttpRequest httpRequest, Type modelType, JsonSerializerContext? serializerContext, CancellationToken cancellationToken)
|
|
{
|
|
var serializer = httpRequest.HttpContext.RequestServices.GetRequiredService<IApiSerializer>();
|
|
var options = serializer.GetOptions();
|
|
|
|
return serializerContext == null
|
|
? JsonSerializer.DeserializeAsync(httpRequest.Body, modelType, options, cancellationToken)
|
|
: JsonSerializer.DeserializeAsync(httpRequest.Body, modelType, serializerContext, cancellationToken);
|
|
}
|
|
|
|
private static Task SerializeRequestAsync(HttpResponse httpResponse, object? dto, string contentType, JsonSerializerContext? serializerContext, CancellationToken cancellationToken)
|
|
{
|
|
var serializer = httpResponse.HttpContext.RequestServices.GetRequiredService<IApiSerializer>();
|
|
var options = serializer.GetOptions();
|
|
|
|
httpResponse.ContentType = contentType;
|
|
return serializerContext == null
|
|
? JsonSerializer.SerializeAsync(httpResponse.Body, dto, dto?.GetType() ?? typeof(object), options, cancellationToken)
|
|
: JsonSerializer.SerializeAsync(httpResponse.Body, dto, dto?.GetType() ?? typeof(object), serializerContext, cancellationToken);
|
|
}
|
|
|
|
}
|