diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 79049094f..4cfc7d4a1 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -243,6 +243,7 @@ const config: UserConfig = { {text: 'Fluent Validation', link: '/guide/http/fluentvalidation'}, {text: 'Problem Details', link: '/guide/http/problemdetails'}, {text: 'Caching', link: '/guide/http/caching'}, + {text: 'Output Caching', link: '/guide/http/output-caching'}, {text: 'Rate Limiting', link: '/guide/http/rate-limiting'}, {text: 'Streaming and SSE', link: '/guide/http/streaming'}, {text: 'HTTP Messaging Transport', link: '/guide/http/transport'}, diff --git a/docs/guide/http/output-caching.md b/docs/guide/http/output-caching.md new file mode 100644 index 000000000..271bb1884 --- /dev/null +++ b/docs/guide/http/output-caching.md @@ -0,0 +1,134 @@ +# Output Caching + +Wolverine.HTTP supports ASP.NET Core's [Output Caching](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/output?view=aspnetcore-10.0) middleware for server-side response caching. This is different from the client-side [Response Caching](/guide/http/caching) that uses `Cache-Control` headers. + +## Response Caching vs. Output Caching + +| Feature | Response Caching (`[ResponseCache]`) | Output Caching (`[OutputCache]`) | +|---------|--------------------------------------|----------------------------------| +| Where cached | Client-side (browser) | Server-side (in-memory) | +| Mechanism | Sets `Cache-Control` headers | Stores responses in server memory | +| Cache invalidation | Based on headers/expiration | Programmatic via tags or expiration | +| Use case | Reduce bandwidth, leverage browser cache | Reduce server processing, protect downstream services | + +For client-side response caching with `[ResponseCache]`, see [Caching](/guide/http/caching). + +## Setup + +First, register the output caching services and configure your caching policies: + + + +```cs +builder.Services.AddOutputCache(options => +{ + options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromSeconds(30))); + options.AddPolicy("short", builder => builder.Expire(TimeSpan.FromSeconds(5))); +}); +``` +snippet source | anchor + + +Then add the output caching middleware to your pipeline. It should be placed **after** routing and authorization, but **before** `MapWolverineEndpoints()`: + + + +```cs +app.UseOutputCache(); +``` +snippet source | anchor + + +## Per-Endpoint Usage + +Apply the `[OutputCache]` attribute to any Wolverine.HTTP endpoint to enable output caching. This works exactly as it does in standard ASP.NET Core -- no special Wolverine infrastructure is needed. + +### Using a Named Policy + + + +```cs +[WolverineGet("/api/cached")] +[OutputCache(PolicyName = "short")] +public static string GetCached() +{ + return $"Cached at {DateTime.UtcNow:O} - {Interlocked.Increment(ref _counter)}"; +} +``` +snippet source | anchor + + +### Using the Default (Base) Policy + + + +```cs +[WolverineGet("/api/cached-default")] +[OutputCache] +public static string GetCachedDefault() +{ + return $"Default cached at {DateTime.UtcNow:O} - {Interlocked.Increment(ref _counter)}"; +} +``` +snippet source | anchor + + +## Configuring Policies + +Output cache policies are configured through `AddOutputCache()` in your service registration. Common options include: + +### Expiration + +```cs +builder.Services.AddOutputCache(options => +{ + // Base policy applied to all [OutputCache] endpoints without a named policy + options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromSeconds(30))); + + // Named policy with a shorter expiration + options.AddPolicy("short", builder => builder.Expire(TimeSpan.FromSeconds(5))); +}); +``` + +### VaryByQuery and VaryByHeader + +```cs +builder.Services.AddOutputCache(options => +{ + options.AddPolicy("vary-by-query", builder => + builder.SetVaryByQuery("page", "pageSize")); + + options.AddPolicy("vary-by-header", builder => + builder.SetVaryByHeader("Accept-Language")); +}); +``` + +### Tag-Based Cache Invalidation + +You can tag cached responses and then evict them programmatically: + +```cs +builder.Services.AddOutputCache(options => +{ + options.AddPolicy("tagged", builder => + builder.Tag("products").Expire(TimeSpan.FromMinutes(10))); +}); +``` + +Then invalidate by tag when data changes: + +```cs +public static async Task Handle(ProductUpdated message, IOutputCacheStore cacheStore) +{ + await cacheStore.EvictByTagAsync("products", default); +} +``` + +## Important Notes + +- Output caching is a standard ASP.NET Core feature. Wolverine.HTTP endpoints are standard ASP.NET Core endpoints, so the `[OutputCache]` attribute works with no additional Wolverine-specific setup. +- The `UseOutputCache()` middleware must be added to the pipeline for the attribute to take effect. +- Output caching only applies to GET and HEAD requests by default. +- Authenticated requests (those with an `Authorization` header) are not cached by default. + +For the full set of configuration options, see [Microsoft's Output Caching documentation](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/output?view=aspnetcore-10.0). diff --git a/src/Http/Wolverine.Http.Tests/Caching/OutputCacheTests.cs b/src/Http/Wolverine.Http.Tests/Caching/OutputCacheTests.cs new file mode 100644 index 000000000..3adce6ecb --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/Caching/OutputCacheTests.cs @@ -0,0 +1,78 @@ +using Alba; +using Shouldly; + +namespace Wolverine.Http.Tests.Caching; + +public class output_cache : IntegrationContext +{ + public output_cache(AppFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task output_cached_endpoint_returns_same_response() + { + // First request + var first = await Scenario(s => + { + s.Get.Url("/api/cached"); + s.StatusCodeShouldBeOk(); + }); + var firstBody = first.ReadAsText(); + + // Second request should return cached response + var second = await Scenario(s => + { + s.Get.Url("/api/cached"); + s.StatusCodeShouldBeOk(); + }); + var secondBody = second.ReadAsText(); + + // Should be the same cached response + secondBody.ShouldBe(firstBody); + } + + [Fact] + public async Task non_cached_endpoint_returns_different_response() + { + var first = await Scenario(s => + { + s.Get.Url("/api/not-cached"); + s.StatusCodeShouldBeOk(); + }); + var firstBody = first.ReadAsText(); + + var second = await Scenario(s => + { + s.Get.Url("/api/not-cached"); + s.StatusCodeShouldBeOk(); + }); + var secondBody = second.ReadAsText(); + + // Should be different responses + secondBody.ShouldNotBe(firstBody); + } + + [Fact] + public async Task output_cached_default_endpoint_returns_same_response() + { + // First request + var first = await Scenario(s => + { + s.Get.Url("/api/cached-default"); + s.StatusCodeShouldBeOk(); + }); + var firstBody = first.ReadAsText(); + + // Second request should return cached response + var second = await Scenario(s => + { + s.Get.Url("/api/cached-default"); + s.StatusCodeShouldBeOk(); + }); + var secondBody = second.ReadAsText(); + + // Should be the same cached response + secondBody.ShouldBe(firstBody); + } +} diff --git a/src/Http/WolverineWebApi/Caching/OutputCacheEndpoints.cs b/src/Http/WolverineWebApi/Caching/OutputCacheEndpoints.cs new file mode 100644 index 000000000..5610c121e --- /dev/null +++ b/src/Http/WolverineWebApi/Caching/OutputCacheEndpoints.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.OutputCaching; +using Wolverine.Http; + +namespace WolverineWebApi.Caching; + +public static class OutputCacheEndpoints +{ + private static int _counter; + + #region sample_output_cache_endpoint + + [WolverineGet("/api/cached")] + [OutputCache(PolicyName = "short")] + public static string GetCached() + { + return $"Cached at {DateTime.UtcNow:O} - {Interlocked.Increment(ref _counter)}"; + } + + #endregion + + #region sample_output_cache_default_endpoint + + [WolverineGet("/api/cached-default")] + [OutputCache] + public static string GetCachedDefault() + { + return $"Default cached at {DateTime.UtcNow:O} - {Interlocked.Increment(ref _counter)}"; + } + + #endregion + + [WolverineGet("/api/not-cached")] + public static string GetNotCached() + { + return $"Not cached - {Guid.NewGuid()}"; + } +} diff --git a/src/Http/WolverineWebApi/Program.cs b/src/Http/WolverineWebApi/Program.cs index aca819f7b..99381c67a 100644 --- a/src/Http/WolverineWebApi/Program.cs +++ b/src/Http/WolverineWebApi/Program.cs @@ -69,6 +69,13 @@ public static async Task Main(string[] args) builder.Services.AddAuthorization(); + #region sample_adding_output_cache_services + + builder.Services.AddOutputCache(options => + { + options.AddPolicy("short", builder => builder.Expire(TimeSpan.FromSeconds(5))); + }); + #region sample_rate_limiting_configuration builder.Services.AddRateLimiter(options => { @@ -206,6 +213,10 @@ public static async Task Main(string[] args) app.UseAuthorization(); + #region sample_using_output_cache_middleware + + app.UseOutputCache(); + #region sample_use_rate_limiter_middleware app.UseRateLimiter(); #endregion