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; /// /// Provides extension methods to add FastEndpoints configured for use with Elsa API endpoints. /// [PublicAPI] public static class WebApplicationExtensions { private static readonly RequestDelegate NotFoundRequestDelegate = context => { context.Response.StatusCode = StatusCodes.Status404NotFound; return Task.CompletedTask; }; /// /// Registers the FastEndpoints middleware configured for use with Elsa API endpoints. /// /// /// The route prefix to apply to Elsa API endpoints. /// E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions" public static IApplicationBuilder UseWorkflowsApi(this IApplicationBuilder app, string routePrefix = "elsa/api") { return app.UseFastEndpoints(config => ConfigureWorkflowsApi(config, routePrefix)); } /// /// Applies an ASP.NET Core rate limiting policy to requests targeting the Elsa API route prefix. /// /// The application builder. /// The route prefix used by Elsa API endpoints. /// The registered ASP.NET Core rate limiting policy name. Leave empty to skip rate limiting. 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); } /// /// Maps FastEndpoints endpoint routes configured for use with Elsa API endpoints. /// /// The to register the endpoints with. /// The route prefix to apply to Elsa API endpoints. /// E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions" public static IEndpointRouteBuilder MapWorkflowsApi(this IEndpointRouteBuilder routes, string routePrefix = "elsa/api") => routes.MapFastEndpoints(config => ConfigureWorkflowsApi(config, routePrefix)); /// /// Applies an ASP.NET Core rate limiting policy to requests targeting the specified path prefix. /// /// The application builder. /// The path prefix to protect. /// The registered ASP.NET Core rate limiting policy name. /// The endpoint display name used for rate limiting metadata. /// /// 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 app.UseRateLimiter() middleware. ASP.NET Core validates the configured policy when the /// rate limiter middleware handles matching requests. /// public static IApplicationBuilder UseRateLimitingPolicyForPath(this IApplicationBuilder app, PathString pathPrefix, string policyName, string displayName) => app.UseRateLimitingPolicyForPath(pathPrefix, policyName, displayName, true); /// /// Applies an ASP.NET Core rate limiting policy to requests targeting the specified path prefix. /// /// The application builder. /// The path prefix to protect. /// The registered ASP.NET Core rate limiting policy name. /// The endpoint display name used for rate limiting metadata. /// Whether to skip rate limiting when endpoint routing selected no endpoint. 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(); 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(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 DeserializeRequestAsync(HttpRequest httpRequest, Type modelType, JsonSerializerContext? serializerContext, CancellationToken cancellationToken) { var serializer = httpRequest.HttpContext.RequestServices.GetRequiredService(); 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(); 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); } }