diff --git a/docs/docfx/articles/output-caching.md b/docs/docfx/articles/output-caching.md new file mode 100644 index 000000000..36c460e8c --- /dev/null +++ b/docs/docfx/articles/output-caching.md @@ -0,0 +1,63 @@ +# Output Caching + +## Introduction +The reverse proxy can be used to cache proxied responses and serve requests before they are proxied to the destination servers. This can reduce load on the destination servers, add a layer of protection, and ensure consistent policies are implemented across your applications. + +> This feature is only available when using .NET 7.0 or later + +## Defaults + +No output caching is performed unless enabled in the route or application configuration. + +## Configuration +Output Cache policies can be specified per route via [RouteConfig.OutputCachePolicy](xref:Yarp.ReverseProxy.Configuration.RouteConfig) and can be bound from the `Routes` sections of the config file. As with other route properties, this can be modified and reloaded without restarting the proxy. Policy names are case insensitive. + +Example: +```JSON +{ + "ReverseProxy": { + "Routes": { + "route1" : { + "ClusterId": "cluster1", + "OutputCachePolicy": "customPolicy", + "Match": { + "Hosts": [ "localhost" ] + } + } + }, + "Clusters": { + "cluster1": { + "Destinations": { + "cluster1/destination1": { + "Address": "https://localhost:10001/" + } + } + } + } + } +} +``` + +[Output cache policies](https://learn.microsoft.com/aspnet/core/performance/caching/output) are an ASP.NET Core concept that the proxy utilizes. The proxy provides the above configuration to specify a policy per route and the rest is handled by existing ASP.NET Core output caching middleware. + +Output cache policies can be configured in Program.cs as follows: +```c# +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOutputCache(options => +{ + options.AddPolicy("customPolicy", builder => builder.Expire(TimeSpan.FromSeconds(20))); +}); +``` + +Then add the output caching middleware: + +```c# +var app = builder.Build(); + +app.UseOutputCache(); + +app.MapReverseProxy(); +``` + +See the [Output Caching](https://learn.microsoft.com/aspnet/core/performance/caching/output) docs for setting up your preferred kind of output caching. diff --git a/docs/docfx/articles/toc.yml b/docs/docfx/articles/toc.yml index b9d69d5c9..dda342244 100644 --- a/docs/docfx/articles/toc.yml +++ b/docs/docfx/articles/toc.yml @@ -24,6 +24,8 @@ href: authn-authz.md - name: Rate Limiting href: rate-limiting.md +- name: Output Caching + href: output-caching.md - name: Cross-Origin Requests (CORS) href: cors.md - name: Session Affinity diff --git a/samples/KubernetesIngress.Sample/README.md b/samples/KubernetesIngress.Sample/README.md index c9af40e02..c8f39e49c 100644 --- a/samples/KubernetesIngress.Sample/README.md +++ b/samples/KubernetesIngress.Sample/README.md @@ -43,16 +43,17 @@ metadata: annotations: yarp.ingress.kubernetes.io/authorization-policy: authzpolicy yarp.ingress.kubernetes.io/rate-limiter-policy: ratelimiterpolicy + yarp.ingress.kubernetes.io/output-cache-policy: outputcachepolicy yarp.ingress.kubernetes.io/transforms: | - PathRemovePrefix: "/apis" yarp.ingress.kubernetes.io/route-headers: | - Name: the-header-key - Values: + Values: - the-header-value Mode: Contains IsCaseSensitive: false - Name: another-header-key - Values: + Values: - another-header-value Mode: Contains IsCaseSensitive: false @@ -75,6 +76,7 @@ The table below lists the available annotations. |---|---| |yarp.ingress.kubernetes.io/authorization-policy|string| |yarp.ingress.kubernetes.io/rate-limiter-policy|string| +|yarp.ingress.kubernetes.io/output-cache-policy|string| |yarp.ingress.kubernetes.io/backend-protocol|string| |yarp.ingress.kubernetes.io/cors-policy|string| |yarp.ingress.kubernetes.io/health-check|[ActivateHealthCheckConfig](https://microsoft.github.io/reverse-proxy/api/Yarp.ReverseProxy.Configuration.ActiveHealthCheckConfig.html)| @@ -98,6 +100,10 @@ See https://microsoft.github.io/reverse-proxy/articles/rate-limiting.html for a `yarp.ingress.kubernetes.io/rate-limiter-policy: mypolicy` +#### Output Cache Policy + +`yarp.ingress.kubernetes.io/output-cache-policy: mycachepolicy` + #### Backend Protocol Specifies the protocol of the backend service. Defaults to http. @@ -196,12 +202,12 @@ See https://microsoft.github.io/reverse-proxy/api/Yarp.ReverseProxy.Configuratio ``` yarp.ingress.kubernetes.io/route-headers: | - Name: the-header-key - Values: + Values: - the-header-value Mode: Contains IsCaseSensitive: false - Name: another-header-key - Values: + Values: - another-header-value Mode: Contains IsCaseSensitive: false diff --git a/src/Kubernetes.Controller/Converters/YarpIngressOptions.cs b/src/Kubernetes.Controller/Converters/YarpIngressOptions.cs index 6f7069f71..c648405d5 100644 --- a/src/Kubernetes.Controller/Converters/YarpIngressOptions.cs +++ b/src/Kubernetes.Controller/Converters/YarpIngressOptions.cs @@ -14,6 +14,7 @@ internal sealed class YarpIngressOptions public string AuthorizationPolicy { get; set; } #if NET7_0_OR_GREATER public string RateLimiterPolicy { get; set; } + public string OutputCachePolicy { get; set; } #endif public SessionAffinityConfig SessionAffinity { get; set; } public HttpClientConfig HttpClientConfig { get; set; } diff --git a/src/Kubernetes.Controller/Converters/YarpParser.cs b/src/Kubernetes.Controller/Converters/YarpParser.cs index 3da4617ec..529cf7359 100644 --- a/src/Kubernetes.Controller/Converters/YarpParser.cs +++ b/src/Kubernetes.Controller/Converters/YarpParser.cs @@ -142,6 +142,7 @@ private static RouteConfig CreateRoute(YarpIngressContext ingressContext, V1HTTP AuthorizationPolicy = ingressContext.Options.AuthorizationPolicy, #if NET7_0_OR_GREATER RateLimiterPolicy = ingressContext.Options.RateLimiterPolicy, + OutputCachePolicy = ingressContext.Options.OutputCachePolicy, #endif #if NET8_0_OR_GREATER Timeout = ingressContext.Options.Timeout, @@ -234,6 +235,20 @@ private static YarpIngressOptions HandleAnnotations(YarpIngressContext context, { options.RateLimiterPolicy = rateLimiterPolicy; } + if (annotations.TryGetValue("yarp.ingress.kubernetes.io/output-cache-policy", out var outputCachePolicy)) + { + options.OutputCachePolicy = outputCachePolicy; + } +#endif +#if NET8_0_OR_GREATER + if (annotations.TryGetValue("yarp.ingress.kubernetes.io/timeout", out var timeout)) + { + options.Timeout = TimeSpan.Parse(timeout, CultureInfo.InvariantCulture); + } + if (annotations.TryGetValue("yarp.ingress.kubernetes.io/timeout-policy", out var timeoutPolicy)) + { + options.TimeoutPolicy = timeoutPolicy; + } #endif if (annotations.TryGetValue("yarp.ingress.kubernetes.io/cors-policy", out var corsPolicy)) { diff --git a/src/ReverseProxy/Configuration/ConfigProvider/ConfigurationConfigProvider.cs b/src/ReverseProxy/Configuration/ConfigProvider/ConfigurationConfigProvider.cs index 4e29effd5..bd5b66ffe 100644 --- a/src/ReverseProxy/Configuration/ConfigProvider/ConfigurationConfigProvider.cs +++ b/src/ReverseProxy/Configuration/ConfigProvider/ConfigurationConfigProvider.cs @@ -149,6 +149,7 @@ private static RouteConfig CreateRoute(IConfigurationSection section) AuthorizationPolicy = section[nameof(RouteConfig.AuthorizationPolicy)], #if NET7_0_OR_GREATER RateLimiterPolicy = section[nameof(RouteConfig.RateLimiterPolicy)], + OutputCachePolicy = section[nameof(RouteConfig.OutputCachePolicy)], #endif #if NET8_0_OR_GREATER TimeoutPolicy = section[nameof(RouteConfig.TimeoutPolicy)], diff --git a/src/ReverseProxy/Configuration/IYarpOutputCachePolicyProvider.cs b/src/ReverseProxy/Configuration/IYarpOutputCachePolicyProvider.cs new file mode 100644 index 000000000..c6fc031e5 --- /dev/null +++ b/src/ReverseProxy/Configuration/IYarpOutputCachePolicyProvider.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#if NET7_0_OR_GREATER +using System; +using System.Collections; +using System.Reflection; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.Extensions.Options; +#endif + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration; + +// TODO: update or remove this once AspNetCore provides a mechanism to validate the OutputCache policies https://github.com/dotnet/aspnetcore/issues/52419 + +internal interface IYarpOutputCachePolicyProvider +{ + ValueTask GetPolicyAsync(string policyName); +} + +internal class YarpOutputCachePolicyProvider : IYarpOutputCachePolicyProvider +{ +#if NET7_0_OR_GREATER + private readonly OutputCacheOptions _outputCacheOptions; + + private readonly IDictionary _policyMap; + + public YarpOutputCachePolicyProvider(IOptions outputCacheOptions) + { + _outputCacheOptions = outputCacheOptions?.Value ?? throw new ArgumentNullException(nameof(outputCacheOptions)); + + var type = typeof(OutputCacheOptions); + var flags = BindingFlags.Instance | BindingFlags.NonPublic; + var proprety = type.GetProperty("NamedPolicies", flags); + if (proprety == null || !typeof(IDictionary).IsAssignableFrom(proprety.PropertyType)) + { + throw new NotSupportedException("This version of YARP is incompatible with the current version of ASP.NET Core."); + } + _policyMap = (proprety.GetValue(_outputCacheOptions, null) as IDictionary) ?? new Dictionary(); + } + + public ValueTask GetPolicyAsync(string policyName) + { + return ValueTask.FromResult(_policyMap[policyName]); + } +#else + public ValueTask GetPolicyAsync(string policyName) + { + return default; + } +#endif +} diff --git a/src/ReverseProxy/Configuration/RouteConfig.cs b/src/ReverseProxy/Configuration/RouteConfig.cs index 817203b86..12975bfb6 100644 --- a/src/ReverseProxy/Configuration/RouteConfig.cs +++ b/src/ReverseProxy/Configuration/RouteConfig.cs @@ -51,6 +51,12 @@ public sealed record RouteConfig /// Set to "Default" or leave empty to use the global rate limits, if any. /// public string? RateLimiterPolicy { get; init; } + + /// + /// The name of the OutputCachePolicy to apply to this route. + /// If not set then only the BasePolicy will apply. + /// + public string? OutputCachePolicy { get; init; } #endif #if NET8_0_OR_GREATER /// @@ -106,6 +112,7 @@ public bool Equals(RouteConfig? other) && string.Equals(AuthorizationPolicy, other.AuthorizationPolicy, StringComparison.OrdinalIgnoreCase) #if NET7_0_OR_GREATER && string.Equals(RateLimiterPolicy, other.RateLimiterPolicy, StringComparison.OrdinalIgnoreCase) + && string.Equals(OutputCachePolicy, other.OutputCachePolicy, StringComparison.OrdinalIgnoreCase) #endif #if NET8_0_OR_GREATER && string.Equals(TimeoutPolicy, other.TimeoutPolicy, StringComparison.OrdinalIgnoreCase) @@ -127,6 +134,7 @@ public override int GetHashCode() hash.Add(AuthorizationPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase)); #if NET7_0_OR_GREATER hash.Add(RateLimiterPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase)); + hash.Add(OutputCachePolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase)); #endif #if NET8_0_OR_GREATER hash.Add(Timeout?.GetHashCode()); diff --git a/src/ReverseProxy/Configuration/RouteValidators/OutputCachePolicyValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/OutputCachePolicyValidator.cs new file mode 100644 index 000000000..253d2c8a6 --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/OutputCachePolicyValidator.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class OutputCachePolicyValidator : IRouteValidator +{ +#if NET7_0_OR_GREATER + private readonly IYarpOutputCachePolicyProvider _outputCachePolicyProvider; + public OutputCachePolicyValidator(IYarpOutputCachePolicyProvider outputCachePolicyProvider) + { + _outputCachePolicyProvider = outputCachePolicyProvider; + } + + public async ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var outputCachePolicyName = routeConfig.OutputCachePolicy; + + if (string.IsNullOrEmpty(outputCachePolicyName)) + { + return; + } + + try + { + var policy = await _outputCachePolicyProvider.GetPolicyAsync(outputCachePolicyName); + + if (policy is null) + { + errors.Add(new ArgumentException( + $"OutputCache policy '{outputCachePolicyName}' not found for route '{routeConfig.RouteId}'.")); + } + } + catch (Exception ex) + { + errors.Add(new ArgumentException( + $"Unable to retrieve the OutputCache policy '{outputCachePolicyName}' for route '{routeConfig.RouteId}'.", + ex)); + } + } +#else + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) => ValueTask.CompletedTask; +#endif +} diff --git a/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs b/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs index 22539fcd9..e2c0674de 100644 --- a/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs +++ b/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs @@ -26,9 +26,11 @@ internal static class IReverseProxyBuilderExtensions public static IReverseProxyBuilder AddConfigBuilder(this IReverseProxyBuilder builder) { builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); @@ -121,8 +123,10 @@ public static IReverseProxyBuilder AddActiveHealthChecks(this IReverseProxyBuild if (!builder.Services.Any(d => d.ServiceType == typeof(IActiveHealthCheckMonitor))) { builder.Services.AddSingleton(); - builder.Services.AddSingleton(p => p.GetRequiredService()); - builder.Services.AddSingleton(p => p.GetRequiredService()); + builder.Services.AddSingleton(p => + p.GetRequiredService()); + builder.Services.AddSingleton(p => + p.GetRequiredService()); } builder.Services.AddSingleton(); diff --git a/src/ReverseProxy/Routing/ProxyEndpointFactory.cs b/src/ReverseProxy/Routing/ProxyEndpointFactory.cs index 1be5aae6e..da1c82a0e 100644 --- a/src/ReverseProxy/Routing/ProxyEndpointFactory.cs +++ b/src/ReverseProxy/Routing/ProxyEndpointFactory.cs @@ -14,6 +14,7 @@ #endif #if NET7_0_OR_GREATER using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.OutputCaching; #endif using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; @@ -137,6 +138,11 @@ public Endpoint CreateEndpoint(RouteModel route, IReadOnlyList + { + options.AddPolicy("customPolicy", opt => + { + opt.Expire(TimeSpan.FromSeconds(12)); + opt.SetVaryByHost(true); + }); + }); + + services.AddReverseProxy(); + var provider = services.BuildServiceProvider(); + var outputCachePolicyProvider = provider.GetRequiredService(); + Assert.Null(await outputCachePolicyProvider.GetPolicyAsync("anotherPolicy")); + Assert.NotNull(await outputCachePolicyProvider.GetPolicyAsync("customPolicy")); + } +} +#endif