Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ const config: UserConfig<DefaultTheme.Config> = {
{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'},
Expand Down
134 changes: 134 additions & 0 deletions docs/guide/http/output-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Output Caching <Badge type="tip" text="5.13" />

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:

<!-- snippet: sample_adding_output_cache_services -->
<a id='snippet-sample_adding_output_cache_services'></a>
```cs
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromSeconds(30)));
options.AddPolicy("short", builder => builder.Expire(TimeSpan.FromSeconds(5)));
});
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L72-L80' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_adding_output_cache_services' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Then add the output caching middleware to your pipeline. It should be placed **after** routing and authorization, but **before** `MapWolverineEndpoints()`:

<!-- snippet: sample_using_output_cache_middleware -->
<a id='snippet-sample_using_output_cache_middleware'></a>
```cs
app.UseOutputCache();
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Program.cs#L196-L200' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_using_output_cache_middleware' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## 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

<!-- snippet: sample_output_cache_endpoint -->
<a id='snippet-sample_output_cache_endpoint'></a>
```cs
[WolverineGet("/api/cached")]
[OutputCache(PolicyName = "short")]
public static string GetCached()
{
return $"Cached at {DateTime.UtcNow:O} - {Interlocked.Increment(ref _counter)}";
}
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Caching/OutputCacheEndpoints.cs#L12-L21' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_output_cache_endpoint' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

### Using the Default (Base) Policy

<!-- snippet: sample_output_cache_default_endpoint -->
<a id='snippet-sample_output_cache_default_endpoint'></a>
```cs
[WolverineGet("/api/cached-default")]
[OutputCache]
public static string GetCachedDefault()
{
return $"Default cached at {DateTime.UtcNow:O} - {Interlocked.Increment(ref _counter)}";
}
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Caching/OutputCacheEndpoints.cs#L23-L32' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_output_cache_default_endpoint' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## 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).
78 changes: 78 additions & 0 deletions src/Http/Wolverine.Http.Tests/Caching/OutputCacheTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
37 changes: 37 additions & 0 deletions src/Http/WolverineWebApi/Caching/OutputCacheEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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()}";
}
}
11 changes: 11 additions & 0 deletions src/Http/WolverineWebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ public static async Task<int> 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 =>
{
Expand Down Expand Up @@ -206,6 +213,10 @@ public static async Task<int> Main(string[] args)

app.UseAuthorization();

#region sample_using_output_cache_middleware

app.UseOutputCache();

#region sample_use_rate_limiter_middleware
app.UseRateLimiter();
#endregion
Expand Down
Loading