diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index df5669cfe..e72e55d67 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -224,6 +224,7 @@ const config: UserConfig = { {text: 'Integration with Marten', link: '/guide/http/marten'}, {text: 'Fluent Validation', link: '/guide/http/fluentvalidation'}, {text: 'Problem Details', link: '/guide/http/problemdetails'}, + {text: 'Caching', link: '/guide/http/caching'} ] }, { diff --git a/docs/guide/http/as-parameters.md b/docs/guide/http/as-parameters.md index 12dd5e408..910f61d4a 100644 --- a/docs/guide/http/as-parameters.md +++ b/docs/guide/http/as-parameters.md @@ -67,7 +67,7 @@ public class AsParametersQuery{ public int? NullableHeader { get; set; } } ``` -snippet source | anchor +snippet source | anchor And the corresponding test case for utilizing this: @@ -110,7 +110,7 @@ response.IntegerNotUsed.ShouldBe(default); response.FloatNotUsed.ShouldBe(default); response.BooleanNotUsed.ShouldBe(default); ``` -snippet source | anchor +snippet source | anchor Wolverine.HTTP is also able to support `[FromServices]`, `[FromBody]`, and `[FromRoute]` bindings as well @@ -152,7 +152,7 @@ public static class AsParametersEndpoints2{ } } ``` -snippet source | anchor +snippet source | anchor And lastly, you can use C# records or really just any constructor function as well @@ -173,7 +173,7 @@ public static class AsParameterRecordEndpoint public static AsParameterRecord Post([AsParameters] AsParameterRecord input) => input; } ``` -snippet source | anchor +snippet source | anchor @@ -208,5 +208,5 @@ public class ValidatedQuery } } ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/guide/http/caching.md b/docs/guide/http/caching.md new file mode 100644 index 000000000..f2bd1721f --- /dev/null +++ b/docs/guide/http/caching.md @@ -0,0 +1,30 @@ +# Caching + +For caching HTTP responses, Wolverine can simply work with the [Response Caching Middleware in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/middleware?view=aspnetcore-10.0). + +Wolverine.HTTP *will* respect your usage of the `[ResponseCache]` attribute on either the endpoint handler or method to write out both the `vary` and `cache-control` HTTP headers -- +with an attribute on the method taking precedence. + +Here's an example or two: + + + +```cs +// This is all it takes: +[WolverineGet("/cache/one"), ResponseCache(Duration = 3, VaryByHeader = "accept-encoding", NoStore = false)] +public static string GetOne() +{ + return "one"; +} + +[WolverineGet("/cache/two"), ResponseCache(Duration = 10, NoStore = true)] +public static string GetTwo() +{ + return "two"; +} +``` +snippet source | anchor + + +Wolverine.HTTP will also modify the OpenAPI metadata to reflect the caching as well as embed the metadata in the ASP.Net Core +`Endpoint` for utilization inside of the response caching middleware. diff --git a/docs/guide/http/fluentvalidation.md b/docs/guide/http/fluentvalidation.md index a6e779950..b44874e3f 100644 --- a/docs/guide/http/fluentvalidation.md +++ b/docs/guide/http/fluentvalidation.md @@ -86,7 +86,7 @@ public class ValidatedQuery } } ``` -snippet source | anchor +snippet source | anchor ## QueryString Binding diff --git a/docs/guide/http/forms.md b/docs/guide/http/forms.md index baa011409..de036e466 100644 --- a/docs/guide/http/forms.md +++ b/docs/guide/http/forms.md @@ -70,7 +70,7 @@ public async Task use_decimal_form_hit() body.ReadAsText().ShouldBe("Amount is 42.1"); } ``` -snippet source | anchor +snippet source | anchor @@ -82,7 +82,7 @@ You can also use the FromForm attribute on a complex type, Wolverine will then a [WolverinePost("/api/fromformbigquery")] public static BigQuery Post([FromForm] BigQuery query) => query; ``` -snippet source | anchor +snippet source | anchor Individual properties on the class can be aliased using ``[FromForm(Name = "aliased")]`` diff --git a/docs/guide/http/security.md b/docs/guide/http/security.md index d5979f5bf..a762e7aa5 100644 --- a/docs/guide/http/security.md +++ b/docs/guide/http/security.md @@ -26,5 +26,5 @@ public void RequireAuthorizeOnAll() ConfigureEndpoints(e => e.RequireAuthorization()); } ``` -snippet source | anchor +snippet source | anchor diff --git a/src/Http/Wolverine.Http.Tests/response_cache.cs b/src/Http/Wolverine.Http.Tests/response_cache.cs new file mode 100644 index 000000000..be5471dd0 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/response_cache.cs @@ -0,0 +1,37 @@ +using JasperFx.Core.Reflection; +using Shouldly; + +namespace Wolverine.Http.Tests; + +public class response_cache : IntegrationContext +{ + public response_cache(AppFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task write_no_header_with_no_attribute() + { + var result = await Scenario(x => x.Get.Url("/cache/none")); + result.Context.Response.Headers.ContainsKey("vary").ShouldBeFalse(); + result.Context.Response.Headers.ContainsKey("cache-control").ShouldBeFalse(); + } + + [Fact] + public async Task write_cache_control_and_vary_by() + { + var result = await Scenario(x => x.Get.Url("/cache/one")); + + result.Context.Response.Headers["vary"].Single().ShouldBe("accept-encoding"); + result.Context.Response.Headers["cache-control"].Single().ShouldBe("max-age=3"); + } + + [Fact] + public async Task write_cache_control_no_vary() + { + var result = await Scenario(x => x.Get.Url("/cache/two")); + + result.Context.Response.Headers["vary"].Any().ShouldBeFalse(); + result.Context.Response.Headers["cache-control"].Single().ShouldBe("no-store, max-age=10"); + } +} \ No newline at end of file diff --git a/src/Http/Wolverine.Http/CodeGen/ResponseCacheFrame.cs b/src/Http/Wolverine.Http/CodeGen/ResponseCacheFrame.cs new file mode 100644 index 000000000..f1ec9ef80 --- /dev/null +++ b/src/Http/Wolverine.Http/CodeGen/ResponseCacheFrame.cs @@ -0,0 +1,51 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Microsoft.AspNetCore.Mvc; + +namespace Wolverine.Http.CodeGen; + +internal class HttpChainResponseCacheHeaderPolicy : IHttpPolicy +{ + public void Apply(IReadOnlyList chains, GenerationRules rules, IServiceContainer container) + { + foreach (var chain in chains.Where(x => x.HasAttribute())) + { + Apply(chain, container); + } + } + + public void Apply(HttpChain chain, IServiceContainer container) + { + if (chain.Method.Method.TryGetAttribute(out var cache) || chain.Method.HandlerType.TryGetAttribute(out cache) ) + { + chain.Postprocessors.Add(new ResponseCacheFrame(cache)); + } + } +} + +internal class ResponseCacheFrame : SyncFrame +{ + private readonly ResponseCacheAttribute _cache; + + public ResponseCacheFrame(ResponseCacheAttribute cache) + { + _cache = cache; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Write caching headers from a [ResponseCache] usage"); + writer.Write($"{nameof(HttpHandler.WriteCacheControls)}(httpContext, {_cache.Duration}, {_cache.NoStore.ToString().ToLowerInvariant()});"); + + if (_cache.VaryByHeader.IsNotEmpty()) + { + writer.Write($"httpContext.Response.Headers[\"vary\"] = {Constant.ForString(_cache.VaryByHeader).Usage};"); + } + + Next?.GenerateCode(method, writer); + } +} \ No newline at end of file diff --git a/src/Http/Wolverine.Http/HttpHandler.cs b/src/Http/Wolverine.Http/HttpHandler.cs index 8a71bb180..6b06daf25 100644 --- a/src/Http/Wolverine.Http/HttpHandler.cs +++ b/src/Http/Wolverine.Http/HttpHandler.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; using Wolverine.Http.Runtime.MultiTenancy; using JsonSerializer = System.Text.Json.JsonSerializer; @@ -25,6 +26,16 @@ public HttpHandler(WolverineHttpOptions wolverineHttpOptions) _jsonOptions = wolverineHttpOptions.JsonSerializerOptions.Value; } + public ResponseCacheAttribute? Caching { get; set; } + + public void WriteCacheControls(HttpContext context, int maxAgeInSeconds, bool noStore) + { + context.Response.GetTypedHeaders().CacheControl = new() + { + MaxAge = maxAgeInSeconds.Seconds(), NoStore = noStore, + }; + } + public async ValueTask TryDetectTenantId(HttpContext httpContext) { var tenantId = await _options.TryDetectTenantId(httpContext); @@ -95,7 +106,7 @@ public void ApplyHttpAware(object target, HttpContext context) { if (target is IHttpAware a) a.Apply(context); } - + private static bool isRequestJson(HttpContext context) { var contentType = context.Request.ContentType; diff --git a/src/Http/Wolverine.Http/WolverineHttpOptions.cs b/src/Http/Wolverine.Http/WolverineHttpOptions.cs index c50c52c23..5fcbb6dad 100644 --- a/src/Http/Wolverine.Http/WolverineHttpOptions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpOptions.cs @@ -126,6 +126,7 @@ public WolverineHttpOptions() Policies.Add(new HttpAwarePolicy()); Policies.Add(new RequestIdPolicy()); Policies.Add(new RequiredEntityPolicy()); + Policies.Add(new HttpChainResponseCacheHeaderPolicy()); Policies.Add(TenantIdDetection); } diff --git a/src/Http/WolverineWebApi/CachedEndpoint.cs b/src/Http/WolverineWebApi/CachedEndpoint.cs new file mode 100644 index 000000000..07cf0dbe2 --- /dev/null +++ b/src/Http/WolverineWebApi/CachedEndpoint.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; + +namespace WolverineWebApi; + +public static class CachedEndpoint +{ + #region sample_using_response_cache_attribute + + // This is all it takes: + [WolverineGet("/cache/one"), ResponseCache(Duration = 3, VaryByHeader = "accept-encoding", NoStore = false)] + public static string GetOne() + { + return "one"; + } + + [WolverineGet("/cache/two"), ResponseCache(Duration = 10, NoStore = true)] + public static string GetTwo() + { + return "two"; + } + + #endregion + + [WolverineGet("/cache/none")] + public static string GetNone() + { + return "none"; + } +} \ No newline at end of file