diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39d0023 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build-test: + runs-on: ubuntu-latest + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL || 'https://sonarcloud.io' }} + SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY }} + SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Setup Java 17 (SonarScanner requirement) + if: env.SONAR_TOKEN != '' + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: '17' + + - name: Install SonarScanner for .NET + if: env.SONAR_TOKEN != '' + run: dotnet tool install --global dotnet-sonarscanner + + # Self-hosted SonarQube: set the SONAR_HOST_URL repo variable and REMOVE the /o: line below + # (organization is a SonarCloud-only concept). + - name: Begin SonarQube analysis + if: env.SONAR_TOKEN != '' + run: > + dotnet sonarscanner begin + /k:"$SONAR_PROJECT_KEY" + /o:"$SONAR_ORGANIZATION" + /d:sonar.token="$SONAR_TOKEN" + /d:sonar.host.url="$SONAR_HOST_URL" + /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" + /d:sonar.exclusions="benchmarks/**,samples/**" + + - name: Restore + run: dotnet restore AspNet.Cache.sln + + # The library enforces TreatWarningsAsErrors via its own csproj; not forcing it solution-wide + # here keeps SonarAnalyzer smells in the excluded sample/benchmark projects from breaking the + # analysis build. The strict -warnaserror gate runs in the allocation-gate job and locally. + - name: Build + run: dotnet build AspNet.Cache.sln -c Release --no-restore /p:TreatWarningsAsErrors=false + + - name: Test with coverage + run: > + dotnet test test/AspNet.Cache.Test/AspNet.Cache.Test.csproj -c Release --no-build + /p:CollectCoverage=true + /p:CoverletOutputFormat=opencover + /p:Threshold=80 + /p:ThresholdType=line + /p:Include="[AspNet.Cache]*" + + - name: End SonarQube analysis + if: env.SONAR_TOKEN != '' + run: dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN" + + allocation-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Run benchmarks (short job) + run: dotnet run -c Release --project benchmarks/AspNet.Cache.Benchmarks -- --filter "*CacheBenchmarks*" --job short --exporters json + + - name: Enforce allocation budgets + shell: pwsh + run: ./build/check-allocation-budgets.ps1 diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml deleted file mode 100644 index bdc2cfd..0000000 --- a/.github/workflows/dotnetcore.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: .NET Core - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v1 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 2.2.103 - - - name: Build solution - run: | - git submodule update --init --recursive - dotnet restore AspNet.Cache.sln - dotnet build -c Release AspNet.Cache/AspNet.Cache.csproj - - - name: Run unit tests - run: | - dotnet test test/AspNet.Cache.Test/AspNet.Cache.Test.csproj - - - name: Run unit tests coverage - run: | - dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:ThresholdType=line /p:CoverletOutput=./lcov test/AspNet.Cache.Test/AspNet.Cache.Test.csproj - - - name: Codecov - uses: codecov/codecov-action@v1.0.6 - with: - # User defined upload name. Visible in Codecov UI - #name: # optional - # Repository upload token - get it from codecov.io. Required only for private repositories - token: ${{ secrets.CODECOV_TOKEN }} - # Path to coverage file to upload - file: test/AspNet.Cache.Test/lcov.info - # Flag upload to group coverage metrics (e.g. unittests | integration | ui,chrome) - #flags: # optional - # Specify whether or not CI build should fail if Codecov runs into an error during upload - #fail_ci_if_error: # optional - - - name: Generate NuGet package - run: | - dotnet pack -c Release - - - name: publish on version change - uses: rohith/publish-nuget@v2 - with: - PROJECT_FILE_PATH: AspNet.Cache/AspNet.Cache.csproj # Relative to repository root - # VERSION_FILE_PATH: Directory.Build.props # Filepath with version info, relative to repository root. Defaults to project file - VERSION_REGEX: (.*)<\/Version> # Regex pattern to extract version info in a capturing group - # VERSION_STATIC: Bypasses version resolution; useful for external providers like Nerdbank.GitVersioning - # TAG_COMMIT: true # Flag to enable / disalge git tagging - # TAG_FORMAT: v* # Format of the git tag, [*] gets replaced with version - NUGET_KEY: ${{ secrets.nuget_token }} # nuget.org API key - # PACKAGE_NAME: NuGet package name, required when it's different from project name. Defaults to project name - #Get-ChildItem -Path E:\projetos\GitHub\Responses\Responses\bin\Release\* -Include *.nupkg | Select-Object -ExpandProperty FullName | %{dotnet nuget push $_ ${{ secrets.nuget_token }} -s https://api.nuget.org/v3/index.json} - - - name: Install NuGet client - uses: warrenbuckley/Setup-Nuget@v1 - - - name: Add private GitHub registry to NuGet - run: | - nuget sources add -name "GPR" -Source https://nuget.pkg.github.com/slipalison/index.json -Username slipalison -Password ${{ secrets.GITHUB_TOKEN }} - - - name: Push generated package to GitHub registry - run: | - dir AspNet.Cache\bin\Release\*.nupkg - nuget push AspNet.Cache\bin\Release\*.nupkg -Source "GPR" -SkipDuplicate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..aec382c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + id-token: write # NuGet Trusted Publishing (OIDC) — no API key secret needed + contents: write + +jobs: + release: + runs-on: ubuntu-latest + environment: production # matches the NuGet Trusted Publishing policy's Environment claim + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + + - name: Restore + run: dotnet restore AspNet.Cache.sln + + - name: Build + run: dotnet build AspNet.Cache.sln -c Release --no-restore -warnaserror /p:Version=${{ env.VERSION }} + + - name: Test + run: dotnet test test/AspNet.Cache.Test/AspNet.Cache.Test.csproj -c Release --no-build + + - name: Pack + run: > + dotnet pack AspNet.Cache/AspNet.Cache.csproj -c Release --no-build -o out + /p:Version=${{ env.VERSION }} + /p:ContinuousIntegrationBuild=true + + - name: NuGet login (Trusted Publishing) + id: nuget-login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 + with: + user: slipalison + + - name: Push to NuGet.org + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: > + dotnet nuget push "out/*.nupkg" + --api-key "$NUGET_API_KEY" + --source https://api.nuget.org/v3/index.json + --skip-duplicate + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" out/*.nupkg out/*.snupkg --generate-notes diff --git a/.gitignore b/.gitignore index c3940fe..4fd0f78 100644 --- a/.gitignore +++ b/.gitignore @@ -263,3 +263,11 @@ __pycache__/ /test/Core.Winchester.Tests/coverage.json /test/Core.Winchester.Tests/coverage.cobertura.xml *.info + +# BenchmarkDotNet +BenchmarkDotNet.Artifacts/ +.claude +PLANO-MODERNIZACAO.md +# Coverage reports +coverage*.xml +*.opencover.xml diff --git a/AspNet.Cache.sln b/AspNet.Cache.sln index 2ea44fa..79ba8da 100644 --- a/AspNet.Cache.sln +++ b/AspNet.Cache.sln @@ -17,26 +17,80 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{66320409-64EC-F7C5-3DEF-65E7510DAAD1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Cache.Benchmarks", "benchmarks\AspNet.Cache.Benchmarks\AspNet.Cache.Benchmarks.csproj", "{9D6D47E3-980D-4BF9-A3FB-B589211B043B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Cache.Sample", "samples\AspNet.Cache.Sample\AspNet.Cache.Sample.csproj", "{7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Debug|x64.ActiveCfg = Debug|Any CPU + {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Debug|x64.Build.0 = Debug|Any CPU + {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Debug|x86.ActiveCfg = Debug|Any CPU + {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Debug|x86.Build.0 = Debug|Any CPU {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Release|Any CPU.ActiveCfg = Release|Any CPU {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Release|Any CPU.Build.0 = Release|Any CPU + {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Release|x64.ActiveCfg = Release|Any CPU + {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Release|x64.Build.0 = Release|Any CPU + {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Release|x86.ActiveCfg = Release|Any CPU + {3390E21C-20D0-45F8-9AD1-4286FF67C598}.Release|x86.Build.0 = Release|Any CPU {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Debug|x64.Build.0 = Debug|Any CPU + {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Debug|x86.Build.0 = Debug|Any CPU {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Release|Any CPU.ActiveCfg = Release|Any CPU {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Release|Any CPU.Build.0 = Release|Any CPU + {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Release|x64.ActiveCfg = Release|Any CPU + {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Release|x64.Build.0 = Release|Any CPU + {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Release|x86.ActiveCfg = Release|Any CPU + {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C}.Release|x86.Build.0 = Release|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Debug|x64.Build.0 = Debug|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Debug|x86.ActiveCfg = Debug|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Debug|x86.Build.0 = Debug|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Release|Any CPU.Build.0 = Release|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Release|x64.ActiveCfg = Release|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Release|x64.Build.0 = Release|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Release|x86.ActiveCfg = Release|Any CPU + {9D6D47E3-980D-4BF9-A3FB-B589211B043B}.Release|x86.Build.0 = Release|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Debug|x64.ActiveCfg = Debug|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Debug|x64.Build.0 = Debug|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Debug|x86.ActiveCfg = Debug|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Debug|x86.Build.0 = Debug|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Release|Any CPU.Build.0 = Release|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Release|x64.ActiveCfg = Release|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Release|x64.Build.0 = Release|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Release|x86.ActiveCfg = Release|Any CPU + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {8FEFACF5-56FA-47C7-973A-4C10EE0B1A1C} = {2ACD33BC-B50B-4296-879C-0660E72B18CF} + {9D6D47E3-980D-4BF9-A3FB-B589211B043B} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1} + {7CC1F4FC-EF38-4F8C-BD75-360F62B9DEC7} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E47ABF9E-13CB-4CD1-B06B-3C7E9A86CD60} diff --git a/AspNet.Cache/AspNet.Cache.csproj b/AspNet.Cache/AspNet.Cache.csproj index 0aa8008..563c0ec 100644 --- a/AspNet.Cache/AspNet.Cache.csproj +++ b/AspNet.Cache/AspNet.Cache.csproj @@ -1,17 +1,47 @@ - + - netstandard2.1 + net8.0;net9.0;net10.0 + latest + enable + enable + true + latest-recommended + true + true + + + + AspNet.Cache + 2.0.0 + slipalison + Declarative response caching for ASP.NET Core MVC actions on top of any IDistributedCache provider (Redis, SQL Server, in-memory, MongoDB). Allocation-conscious: span-based key generation, pooled buffers, IBufferDistributedCache fast path, LOH-free by default. + aspnetcore;cache;caching;idistributedcache;redis;actionfilter;performance;response-cache https://github.com/slipalison/AspNet.Cache https://github.com/slipalison/AspNet.Cache - 1.0.0 - slipalison + git + MIT + README.md + true + true + true + snupkg - - - - + - \ No newline at end of file + + + + + + + + + + + + + diff --git a/AspNet.Cache/AspNetCacheOptions.cs b/AspNet.Cache/AspNetCacheOptions.cs new file mode 100644 index 0000000..cbddc3a --- /dev/null +++ b/AspNet.Cache/AspNetCacheOptions.cs @@ -0,0 +1,23 @@ +using System; +using System.Text.Json; + +namespace AspNet.Cache; + +/// Global options for . Configure via services.Configure<AspNetCacheOptions>(...). +public sealed class AspNetCacheOptions +{ + internal static readonly AspNetCacheOptions FallbackDefaults = new(); + + /// Serializer options used when hashing action arguments into the cache key. Defaults to web defaults. + public JsonSerializerOptions? KeySerializerOptions { get; set; } + + /// Serializer options for the cached payload. Defaults to the app's MVC JSON options, then web defaults. + public JsonSerializerOptions? PayloadSerializerOptions { get; set; } + + /// Responses whose serialized size exceeds this many bytes are not cached. Default keeps entries off the Large Object Heap. + public int MaxPayloadBytes { get; set; } = 84_000; + + /// Predicate deciding which action arguments are excluded from the cache key. + public Func IgnoreArgument { get; set; } = + static name => name.Contains("correlationid", StringComparison.OrdinalIgnoreCase); +} diff --git a/AspNet.Cache/CacheAttribute.cs b/AspNet.Cache/CacheAttribute.cs index acd95b9..01505d1 100644 --- a/AspNet.Cache/CacheAttribute.cs +++ b/AspNet.Cache/CacheAttribute.cs @@ -1,105 +1,215 @@ using System; -using System.Collections.Generic; -using System.Linq; +using System.Buffers; +using System.Globalization; using System.Net; -using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AspNet.Cache.Internal; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Caching.Distributed; -using Newtonsoft.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; -namespace AspNet.Cache +namespace AspNet.Cache; + +/// +/// Caches the JSON payload of a successful action result in the registered . +/// When the provider implements , reads and writes flow through pooled +/// buffers and never materialize a per-request byte array. +/// The attribute instance is shared across concurrent requests and therefore holds no per-request state. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class CacheAttribute : ActionFilterAttribute { - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public sealed class CacheAttribute : ActionFilterAttribute + private string? _cacheControlHeaderValue; + private DistributedCacheEntryOptions? _entryOptions; + + /// Optional key prefix ("folder") prepended to every cache key. + public string Folder { get; set; } = string.Empty; + + /// Expiration amount, interpreted according to . Default: 10. + public long ExpireAt { get; set; } = 10; + + /// Unit for . Default: minutes. + public TimeSpanType TimeSpanType { get; set; } = TimeSpanType.FromMinutes; + + /// Status code that marks a response as cacheable and is replayed on cache hits. Default: 200 OK. + public HttpStatusCode SuccessStatus { get; set; } = HttpStatusCode.OK; + + /// + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - private static readonly IList<(TimeSpanType TimeSpanType, Func TimeSpanFrom)> - _timeSpanTypeList = - new List<(TimeSpanType TimeSpanType, Func TimeSpanFrom)> - { - (TimeSpanType.FromMinutes, TimeSpan.FromMinutes), - (TimeSpanType.FromMilliseconds, TimeSpan.FromMilliseconds), - (TimeSpanType.FromDays, TimeSpan.FromDays), - (TimeSpanType.FromHours, TimeSpan.FromHours), - (TimeSpanType.FromSeconds, TimeSpan.FromSeconds) - }; - - private IDistributedCache _cache; - private string _hashKey = string.Empty; - private bool _isCach; - private string _path = string.Empty; - - public string Folder { get; set; } = string.Empty; - public long ExpireAt { get; set; } = 10; - public TimeSpanType TimeSpanType { get; set; } = TimeSpanType.FromMinutes; - public HttpStatusCode SuccessStatus { get; set; } = HttpStatusCode.OK; - - public override void OnActionExecuting(ActionExecutingContext context) + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); + + var services = context.HttpContext.RequestServices; + var cache = services.GetService(); + if (cache is null) { - _cache = (IDistributedCache) context.HttpContext.RequestServices.GetService(typeof(IDistributedCache)); - if (context.HttpContext.Request.Path.HasValue) - _path = context.HttpContext.Request.Path.Value; - - var body = JsonConvert.SerializeObject(context.ActionArguments.Where(x => - !x.Key.Contains("correlationid", StringComparison.InvariantCultureIgnoreCase))); - var separotor = string.IsNullOrWhiteSpace(Folder) ? string.Empty : ":"; - _hashKey = - $"{Folder}{separotor}{string.Join("-", _path.Replace("/", "-"), GetStringSha256Hash(body))}"; - var existingData = _cache.GetString(_hashKey); - if (string.IsNullOrEmpty(existingData)) - { - _isCach = false; - base.OnActionExecuting(context); - } - else - { - _isCach = true; - context.HttpContext.Response.StatusCode = (int) SuccessStatus; - context.HttpContext.Response.Headers.Clear(); - context.HttpContext.Response.Headers.Add("Cache-Control", - new StringValues($"max-age={GetTimeSpan().Seconds}")); - if (string.IsNullOrEmpty(existingData)) return; - if ("Empty" == existingData) - context.Result = new StatusCodeResult((int) SuccessStatus); - else - context.Result = new JsonResult(JsonConvert.DeserializeObject(existingData)); - } + await next().ConfigureAwait(false); + return; + } + + var options = services.GetService>()?.Value ?? AspNetCacheOptions.FallbackDefaults; + var key = CacheKeyBuilder.Build(Folder, context.HttpContext.Request.Path.Value, context.ActionArguments, + options.IgnoreArgument, options.KeySerializerOptions ?? SerializerDefaults.Web); + + var cancellationToken = context.HttpContext.RequestAborted; + var hitResult = cache is IBufferDistributedCache bufferedCache + ? await TryServeBufferedAsync(bufferedCache, key, services, cancellationToken).ConfigureAwait(false) + : await TryServeLegacyAsync(cache, key, services, cancellationToken).ConfigureAwait(false); + + if (hitResult is not null) + { + context.HttpContext.Response.Headers.CacheControl = GetCacheControlHeaderValue(); + context.Result = hitResult; + return; + } + + var executed = await next().ConfigureAwait(false); + if (executed.Exception is not null || executed.Canceled) + return; + + await StoreAsync(cache, key, executed.Result, services, options).ConfigureAwait(false); + } + + private async Task TryServeBufferedAsync(IBufferDistributedCache cache, string key, + IServiceProvider services, CancellationToken cancellationToken) + { + var buffer = new PooledArrayBufferWriter(); + var handedOff = false; + try + { + var found = await cache.TryGetAsync(key, buffer, cancellationToken).ConfigureAwait(false); + if (!found || buffer.WrittenCount == 0) + return null; + + if (CachedJsonResult.IsEmptyMarker(buffer.WrittenSpan)) + return new StatusCodeResult((int)SuccessStatus); + + handedOff = true; + return new PooledCachedJsonResult(buffer, (int)SuccessStatus); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + CacheLog.GetFailed(ResolveLogger(services), exception); + return null; + } + finally + { + if (!handedOff) + buffer.Dispose(); + } + } + + private async Task TryServeLegacyAsync(IDistributedCache cache, string key, + IServiceProvider services, CancellationToken cancellationToken) + { + byte[]? cached = null; + try + { + cached = await cache.GetAsync(key, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + CacheLog.GetFailed(ResolveLogger(services), exception); + } + + if (cached is not { Length: > 0 }) + return null; - public override void OnResultExecuted(ResultExecutedContext context) + return CachedJsonResult.IsEmptyMarker(cached) + ? new StatusCodeResult((int)SuccessStatus) + : new CachedJsonResult(cached, (int)SuccessStatus); + } + + private async Task StoreAsync(IDistributedCache cache, string key, IActionResult? result, + IServiceProvider services, AspNetCacheOptions options) + { + if (result is ObjectResult { Value: not null } objectResult && MatchesSuccessStatus(objectResult.StatusCode)) { - if (!_isCach && context.HttpContext.Response.StatusCode == (int) SuccessStatus) + var payloadOptions = options.PayloadSerializerOptions + ?? services.GetService>()?.Value.JsonSerializerOptions + ?? SerializerDefaults.Web; + + using var buffer = new PooledArrayBufferWriter(options.MaxPayloadBytes); + using (var writer = new Utf8JsonWriter(buffer)) + { + JsonSerializer.Serialize(writer, objectResult.Value, objectResult.Value.GetType(), payloadOptions); + } + + if (buffer.Overflowed) + return; + + try { - switch (context.Result) + // CancellationToken.None: a client disconnect after the action ran must not discard the produced entry. + if (cache is IBufferDistributedCache bufferedCache) { - case ObjectResult {Value: { }} result: - _cache.SetString(_hashKey, JsonConvert.SerializeObject(result.Value), - new DistributedCacheEntryOptions {AbsoluteExpirationRelativeToNow = GetTimeSpan()}); - break; - case StatusCodeResult _: - _cache.SetString(_hashKey, "Empty", - new DistributedCacheEntryOptions {AbsoluteExpirationRelativeToNow = GetTimeSpan()}); - break; + await bufferedCache.SetAsync(key, new ReadOnlySequence(buffer.WrittenMemory), + GetEntryOptions(), CancellationToken.None).ConfigureAwait(false); } + else + { + var payload = buffer.WrittenSpan.ToArray(); + await cache.SetAsync(key, payload, GetEntryOptions(), CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception exception) + { + CacheLog.SetFailed(ResolveLogger(services), exception); } - base.OnResultExecuted(context); + return; } - private TimeSpan GetTimeSpan() + if (result is StatusCodeResult statusCodeResult && statusCodeResult.StatusCode == (int)SuccessStatus) { - return _timeSpanTypeList.FirstOrDefault(x => x.TimeSpanType == TimeSpanType).TimeSpanFrom.Invoke(ExpireAt); + try + { + await cache.SetAsync(key, CachedJsonResult.EmptyMarker, GetEntryOptions(), CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception exception) + { + CacheLog.SetFailed(ResolveLogger(services), exception); + } } + } - private static string GetStringSha256Hash(string text) - { - if (string.IsNullOrWhiteSpace(text)) - return string.Empty; + private bool MatchesSuccessStatus(int? statusCode) => + (statusCode ?? (int)HttpStatusCode.OK) == (int)SuccessStatus; - using var sha = new SHA256Managed(); - var textData = Encoding.UTF8.GetBytes(text); - var hash = sha.ComputeHash(textData); - return BitConverter.ToString(hash).Replace("-", string.Empty); - } - } + private TimeSpan GetExpiration() => TimeSpanType switch + { + TimeSpanType.FromMilliseconds => TimeSpan.FromMilliseconds(ExpireAt), + TimeSpanType.FromSeconds => TimeSpan.FromSeconds(ExpireAt), + TimeSpanType.FromHours => TimeSpan.FromHours(ExpireAt), + TimeSpanType.FromDays => TimeSpan.FromDays(ExpireAt), + _ => TimeSpan.FromMinutes(ExpireAt) + }; + + private string GetCacheControlHeaderValue() => + // Benign race: concurrent first requests compute the same value; last write wins. + _cacheControlHeaderValue ??= string.Create(CultureInfo.InvariantCulture, + $"max-age={(long)GetExpiration().TotalSeconds}"); + + private DistributedCacheEntryOptions GetEntryOptions() => + // Benign race: same value on every compute; providers treat the instance as read-only. + _entryOptions ??= new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = GetExpiration() }; + + private static ILogger ResolveLogger(IServiceProvider services) => + services.GetService>() ?? NullLogger.Instance; } diff --git a/AspNet.Cache/Internal/CacheKeyBuilder.cs b/AspNet.Cache/Internal/CacheKeyBuilder.cs new file mode 100644 index 0000000..71210aa --- /dev/null +++ b/AspNet.Cache/Internal/CacheKeyBuilder.cs @@ -0,0 +1,94 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text.Json; + +namespace AspNet.Cache.Internal; + +/// Builds the cache key: {Folder}:{path with '/' replaced by '-'}-{SHA256 hex of the JSON of the action arguments}. +internal static class CacheKeyBuilder +{ + private const int HashSize = 32; + private const int HexLength = HashSize * 2; + private const int StackallocCharThreshold = 256; + private const string HexDigits = "0123456789ABCDEF"; + + public static string Build(string folder, string? path, IDictionary arguments, + Func ignoreArgument, JsonSerializerOptions keySerializerOptions) + { + Span hash = stackalloc byte[HashSize]; + HashArguments(arguments, ignoreArgument, keySerializerOptions, hash); + + var pathSpan = path.AsSpan(); + var prefixLength = folder.Length == 0 ? 0 : folder.Length + 1; + var totalLength = prefixLength + pathSpan.Length + 1 + HexLength; + + var rented = totalLength <= StackallocCharThreshold + ? null + : ArrayPool.Shared.Rent(totalLength); + var destination = rented ?? stackalloc char[StackallocCharThreshold]; + + var position = 0; + if (prefixLength != 0) + { + folder.CopyTo(destination); + position = folder.Length; + destination[position++] = ':'; + } + + foreach (var character in pathSpan) + destination[position++] = character == '/' ? '-' : character; + + destination[position++] = '-'; + WriteUpperHex(hash, destination.Slice(position, HexLength)); + position += HexLength; + + var key = new string(destination[..position]); + if (rented is not null) + ArrayPool.Shared.Return(rented); + return key; + } + + private static void HashArguments(IDictionary arguments, Func ignoreArgument, + JsonSerializerOptions serializerOptions, Span destination) + { + using var buffer = new PooledArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + if (arguments is Dictionary concrete) + { + foreach (var pair in concrete) + WriteArgument(writer, pair.Key, pair.Value, ignoreArgument, serializerOptions); + } + else + { + foreach (var pair in arguments) + WriteArgument(writer, pair.Key, pair.Value, ignoreArgument, serializerOptions); + } + + writer.WriteEndObject(); + } + + SHA256.HashData(buffer.WrittenSpan, destination); + } + + private static void WriteArgument(Utf8JsonWriter writer, string name, object? value, + Func ignoreArgument, JsonSerializerOptions serializerOptions) + { + if (ignoreArgument(name)) + return; + writer.WritePropertyName(name); + JsonSerializer.Serialize(writer, value, value?.GetType() ?? typeof(object), serializerOptions); + } + + private static void WriteUpperHex(ReadOnlySpan source, Span destination) + { + for (var i = 0; i < source.Length; i++) + { + destination[i * 2] = HexDigits[source[i] >> 4]; + destination[i * 2 + 1] = HexDigits[source[i] & 0xF]; + } + } +} diff --git a/AspNet.Cache/Internal/CacheLog.cs b/AspNet.Cache/Internal/CacheLog.cs new file mode 100644 index 0000000..5ac2665 --- /dev/null +++ b/AspNet.Cache/Internal/CacheLog.cs @@ -0,0 +1,15 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace AspNet.Cache.Internal; + +internal static partial class CacheLog +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, + Message = "AspNet.Cache: distributed cache read failed; the request continues without cache.")] + public static partial void GetFailed(ILogger logger, Exception exception); + + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, + Message = "AspNet.Cache: distributed cache write failed; the response was served but not cached.")] + public static partial void SetFailed(ILogger logger, Exception exception); +} diff --git a/AspNet.Cache/Internal/CachedJsonResult.cs b/AspNet.Cache/Internal/CachedJsonResult.cs new file mode 100644 index 0000000..b965faa --- /dev/null +++ b/AspNet.Cache/Internal/CachedJsonResult.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace AspNet.Cache.Internal; + +/// Writes a cached UTF-8 JSON payload straight to the response body, without re-deserializing it. +internal sealed class CachedJsonResult : IActionResult +{ + internal static readonly byte[] EmptyMarker = "Empty"u8.ToArray(); + + private const string JsonContentType = "application/json; charset=utf-8"; + + private readonly byte[] _payload; + private readonly int _statusCode; + + public CachedJsonResult(byte[] payload, int statusCode) + { + _payload = payload; + _statusCode = statusCode; + } + + public async Task ExecuteResultAsync(ActionContext context) + { + ArgumentNullException.ThrowIfNull(context); + var response = context.HttpContext.Response; + response.StatusCode = _statusCode; + response.ContentType = JsonContentType; + response.ContentLength = _payload.Length; + await response.BodyWriter.WriteAsync(_payload, context.HttpContext.RequestAborted).ConfigureAwait(false); + } + + internal static bool IsEmptyMarker(byte[] payload) => IsEmptyMarker(payload.AsSpan()); + + internal static bool IsEmptyMarker(ReadOnlySpan payload) => payload.SequenceEqual(EmptyMarker); +} diff --git a/AspNet.Cache/Internal/PooledArrayBufferWriter.cs b/AspNet.Cache/Internal/PooledArrayBufferWriter.cs new file mode 100644 index 0000000..3fb3693 --- /dev/null +++ b/AspNet.Cache/Internal/PooledArrayBufferWriter.cs @@ -0,0 +1,73 @@ +using System; +using System.Buffers; + +namespace AspNet.Cache.Internal; + +/// +/// backed by . Single-threaded, per-request use only. +/// +internal sealed class PooledArrayBufferWriter : IBufferWriter, IDisposable +{ + private const int DefaultInitialCapacity = 4096; + + private byte[] _buffer; + private readonly int _softLimit; + + public PooledArrayBufferWriter(int softLimit = int.MaxValue, int initialCapacity = DefaultInitialCapacity) + { + _buffer = ArrayPool.Shared.Rent(initialCapacity); + _softLimit = softLimit; + } + + /// True when more than the configured soft limit has been written; the caller must discard the content. + public bool Overflowed => WrittenCount > _softLimit; + + public ReadOnlySpan WrittenSpan => _buffer.AsSpan(0, WrittenCount); + + public ReadOnlyMemory WrittenMemory => _buffer.AsMemory(0, WrittenCount); + + public int WrittenCount { get; private set; } + + public void Advance(int count) + { + if (count < 0 || WrittenCount + count > _buffer.Length) + throw new ArgumentOutOfRangeException(nameof(count)); + WrittenCount += count; + } + + public Memory GetMemory(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsMemory(WrittenCount); + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsSpan(WrittenCount); + } + + public void Dispose() + { + var buffer = _buffer; + _buffer = Array.Empty(); + WrittenCount = 0; + if (buffer.Length > 0) + ArrayPool.Shared.Return(buffer); + } + + private void EnsureCapacity(int sizeHint) + { + if (sizeHint < 1) + sizeHint = 1; + if (_buffer.Length - WrittenCount >= sizeHint) + return; + + var newSize = Math.Max(_buffer.Length * 2, WrittenCount + sizeHint); + var next = ArrayPool.Shared.Rent(newSize); + _buffer.AsSpan(0, WrittenCount).CopyTo(next); + var previous = _buffer; + _buffer = next; + ArrayPool.Shared.Return(previous); + } +} diff --git a/AspNet.Cache/Internal/PooledCachedJsonResult.cs b/AspNet.Cache/Internal/PooledCachedJsonResult.cs new file mode 100644 index 0000000..74cfbc4 --- /dev/null +++ b/AspNet.Cache/Internal/PooledCachedJsonResult.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace AspNet.Cache.Internal; + +/// +/// Writes a cached UTF-8 JSON payload held in a pooled buffer straight to the response body, +/// returning the buffer to the pool after the write completes. +/// +internal sealed class PooledCachedJsonResult : IActionResult +{ + private const string JsonContentType = "application/json; charset=utf-8"; + + private readonly PooledArrayBufferWriter _buffer; + private readonly int _statusCode; + + public PooledCachedJsonResult(PooledArrayBufferWriter buffer, int statusCode) + { + _buffer = buffer; + _statusCode = statusCode; + } + + public async Task ExecuteResultAsync(ActionContext context) + { + ArgumentNullException.ThrowIfNull(context); + try + { + var response = context.HttpContext.Response; + response.StatusCode = _statusCode; + response.ContentType = JsonContentType; + response.ContentLength = _buffer.WrittenCount; + await response.BodyWriter.WriteAsync(_buffer.WrittenMemory, context.HttpContext.RequestAborted) + .ConfigureAwait(false); + } + finally + { + _buffer.Dispose(); + } + } +} diff --git a/AspNet.Cache/Internal/SerializerDefaults.cs b/AspNet.Cache/Internal/SerializerDefaults.cs new file mode 100644 index 0000000..d2bd68b --- /dev/null +++ b/AspNet.Cache/Internal/SerializerDefaults.cs @@ -0,0 +1,8 @@ +using System.Text.Json; + +namespace AspNet.Cache.Internal; + +internal static class SerializerDefaults +{ + internal static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web); +} diff --git a/AspNet.Cache/TimeSpanType.cs b/AspNet.Cache/TimeSpanType.cs index b3a257e..636d1b5 100644 --- a/AspNet.Cache/TimeSpanType.cs +++ b/AspNet.Cache/TimeSpanType.cs @@ -1,11 +1,11 @@ -namespace AspNet.Cache +namespace AspNet.Cache; + +/// Unit used to interpret . +public enum TimeSpanType { - public enum TimeSpanType - { - FromMinutes, - FromMilliseconds, - FromDays, - FromHours, - FromSeconds - } + FromMinutes, + FromMilliseconds, + FromDays, + FromHours, + FromSeconds } diff --git a/README.md b/README.md index c0d475f..62b8532 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,275 @@ -# AspNet.Cache +# AspNet.Cache -![.NET Core](https://github.com/slipalison/AspNet.Cache/workflows/.NET%20Core/badge.svg?event=push) -![.NET Core](https://github.com/slipalison/AspNet.Cache/workflows/.NET%20Core/badge.svg) -[![codecov](https://codecov.io/gh/slipalison/AspNet.Cache/branch/master/graph/badge.svg)](https://codecov.io/gh/slipalison/AspNet.Cache) +[![CI](https://github.com/slipalison/AspNet.Cache/actions/workflows/ci.yml/badge.svg)](https://github.com/slipalison/AspNet.Cache/actions/workflows/ci.yml) +[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=slipalison_AspNet.Cache&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=slipalison_AspNet.Cache) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=slipalison_AspNet.Cache&metric=coverage)](https://sonarcloud.io/summary/new_code?id=slipalison_AspNet.Cache) +[![NuGet](https://img.shields.io/nuget/v/AspNet.Cache.svg)](https://www.nuget.org/packages/AspNet.Cache/) +[![Downloads](https://img.shields.io/nuget/dt/AspNet.Cache.svg)](https://www.nuget.org/packages/AspNet.Cache/) -### Features -> ... +**English** | [Português](#português-br) ----- +Declarative response caching for ASP.NET Core MVC actions — one attribute, any +[`IDistributedCache`](https://learn.microsoft.com/aspnet/core/performance/caching/distributed) provider. -## C#  +```csharp +[HttpGet] +[Cache(Folder = "Weather", ExpireAt = 5, TimeSpanType = TimeSpanType.FromMinutes)] +public IReadOnlyList Get(int id) => _service.Load(id); +``` + +First call runs the action and stores the serialized JSON response. Subsequent calls with the same +route + arguments are served straight from the cache — the action never executes. + +## Why AspNet.Cache + +- **Provider-agnostic by design.** Anything that registers `IDistributedCache` works: + Redis (`AddStackExchangeRedisCache`), SQL Server (`AddDistributedSqlServerCache`), + in-memory (`AddDistributedMemoryCache`), MongoDB (community providers), NCache, and more. + You are never locked in. +- **Allocation-conscious hot path.** Cache keys are built with `stackalloc`/`ArrayPool` spans and a + static `SHA256.HashData` (~5× fewer bytes per operation than v1, zero Gen2 impact). Cache hits write + the stored UTF-8 bytes directly to the response body — no deserialize/re-serialize round-trip. +- **`IBufferDistributedCache` fast path.** On .NET 9+ providers that implement it (e.g. + StackExchange.Redis provider 9.x), reads and writes flow through pooled buffers — no per-request + payload `byte[]` at all. Older providers fall back to the classic path automatically. +- **LOH-safe by default.** Responses larger than `MaxPayloadBytes` (default 84,000 bytes — right under + the 85 KB Large Object Heap threshold) are simply not cached, so the cache layer never fragments + the LOH of a long-running service. +- **Fail-open.** If the cache provider is down, requests are served without cache and a single + `Warning` is logged. A cache outage never becomes an API outage. +- **Thread-safe.** The filter holds no per-request state (MVC shares attribute instances across + concurrent requests — v1 had a race here; v2 is stateless by construction and regression-tested + with 500 concurrent requests). + +## Install + +```bash +dotnet add package AspNet.Cache +``` + +Multi-targets **.NET 8.0, 9.0 and 10.0**. On net9.0+ the `IBufferDistributedCache` fast path is used +automatically when the provider supports it. + +## Quick start + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +builder.Services.AddDistributedMemoryCache(); // or Redis, SQL Server, Mongo... + +var app = builder.Build(); +app.MapControllers(); +app.Run(); +``` + +```csharp +[ApiController] +[Route("api/[controller]")] +public sealed class WeatherController : ControllerBase +{ + // Cache for 12 hours, replay hits with 201 Created + [HttpGet] + [Cache(Folder = "Weather", ExpireAt = 12, TimeSpanType = TimeSpanType.FromHours, + SuccessStatus = HttpStatusCode.Created)] + public IReadOnlyList Get(int id) => Load(id); +} +``` + +### Attribute settings + +| Property | Default | Meaning | +|---|---|---| +| `Folder` | `""` | Key prefix (`Folder:` is prepended to every key) | +| `ExpireAt` | `10` | Expiration amount, in `TimeSpanType` units | +| `TimeSpanType` | `FromMinutes` | Unit: milliseconds, seconds, minutes, hours or days | +| `SuccessStatus` | `200 OK` | Status that marks a response cacheable, and the status replayed on hits | + +### Global options -```C# - [ApiController, Route("api/[controller]")] - public class DefaultController : ControllerBase - { - private readonly ILogger _logger; - public DefaultController(ILogger logger) - { - _logger = logger; - } +```csharp +builder.Services.Configure(options => +{ + options.MaxPayloadBytes = 32_000; // skip caching anything bigger + options.IgnoreArgument = name => // args excluded from the cache key + name.Contains("correlationid", StringComparison.OrdinalIgnoreCase); + options.PayloadSerializerOptions = mySerializerOptions; // defaults to your MVC JSON options +}); +``` - [HttpGet, Cache(Folder = "Test", SuccessStatus = System.Net.HttpStatusCode.Created, TimeSpanType = TimeSpanType.FromDays, ExpireAt = 12)] - public IEnumerable Get([Required, FromHeader(Name = "X-Correlation-Id")] string correlationId) - { - ... - } +### How the key is built - [HttpGet("get1"), Cache] - public IEnumerable Get1([Required, FromHeader(Name = "X-Correlation-Id")] string correlationId) - { - ... - } - } ``` +{Folder}:{request path with "/" → "-"}-{SHA-256 hex of the JSON of the action arguments} +Weather:-api-weather-E39743B91B98367F... +``` + +Arguments matching `IgnoreArgument` (correlation ids by default) don't affect the key. +SHA-256 (not a fast non-crypto hash) is deliberate: keys derive from untrusted input, and a forgeable +hash would allow cross-user cache collisions. + +### Notes & trade-offs + +- Cached responses are always served as `application/json` (content negotiation does not apply to hits). +- The response is cached only when its status matches `SuccessStatus` exactly. +- Bodyless responses (`StatusCodeResult`) are cached with a tiny sentinel and replayed as status-only. +- On a cache hit the action does not run — side effects inside the action won't happen. + +## Performance + +Measured on the v2 implementation (net8.0, x64, Release; BenchmarkDotNet, short job): + +| Path | v1 | v2 | +|---|---|---| +| Key generation | 1,784 B/op | **344 B/op** | +| Hit read via `IBufferDistributedCache` | n/a | **104 B/op** (pooled) | +| Hit read via legacy `IDistributedCache` | n/a | **144 B/op** | +| Buffered store (serialize + set) | n/a | **864 B/op** | +| Gen1 / Gen2 collections | — | **0** (all paths Gen0-only) | + +CI enforces allocation budgets on every PR (`build/check-allocation-budgets.ps1`) — allocated bytes +per operation are deterministic, so regressions fail the build. + +> **On "1 million requests/second".** At that scale the bottleneck is the cache provider round-trip +> and the network, and the target is reached by horizontal scale-out (N instances). This library's job +> is to add no GC pressure and no payload copy on the request path — which the budgets above guarantee. + +## Migrating from v1 + +| Change | Impact | +|---|---| +| Target framework `netstandard2.1` → `net8.0`/`net9.0`/`net10.0` | Apps must run .NET 8+ | +| Newtonsoft.Json → System.Text.Json | Serialization follows your MVC JSON options (camelCase by default) | +| Key format internals changed | Existing cache entries miss once after upgrade (cache warms itself) | +| `Cache-Control: max-age` | Now correct (`TotalSeconds`; v1 emitted the 0–59 component) | +| Response headers on hit | No longer cleared — CORS/security headers survive | +| Concurrency | Race condition fixed; safe under parallel load | + +## License + +[MIT](LICENSE) --- + +## Português (BR) + +[English](#aspnetcache) | **Português** + +Cache de resposta declarativo para actions ASP.NET Core MVC — um attribute, qualquer provider de +[`IDistributedCache`](https://learn.microsoft.com/aspnet/core/performance/caching/distributed). + +```csharp +[HttpGet] +[Cache(Folder = "Weather", ExpireAt = 5, TimeSpanType = TimeSpanType.FromMinutes)] +public IReadOnlyList Get(int id) => _service.Load(id); +``` + +A primeira chamada executa a action e grava a resposta JSON serializada. Chamadas seguintes com o +mesmo path + argumentos são servidas direto do cache — a action nem executa. + +## Por quê + +- **Agnóstico de provider.** Qualquer `IDistributedCache` funciona: Redis, SQL Server, memória, + MongoDB (providers da comunidade), NCache. Sem lock-in. +- **Hot path consciente de alocação.** Chaves geradas com spans (`stackalloc`/`ArrayPool`) e + `SHA256.HashData` estático (~5× menos bytes por operação que a v1, zero impacto em Gen2). Hits + escrevem os bytes UTF-8 armazenados direto no body — sem deserializar/re-serializar. +- **Fast path `IBufferDistributedCache`.** Em providers .NET 9+ que o implementam (ex.: Redis 9.x), + leituras e escritas usam buffers pooled — nenhum `byte[]` de payload por request. Providers antigos + caem automaticamente no caminho clássico. +- **Seguro para a LOH por default.** Respostas maiores que `MaxPayloadBytes` (default 84.000 bytes — + logo abaixo do limiar de 85 KB da Large Object Heap) não são cacheadas: a camada de cache nunca + fragmenta a LOH de um serviço de longa vida. +- **Fail-open.** Provider fora do ar → request segue sem cache com um único log `Warning`. Queda do + cache nunca vira queda da API. +- **Thread-safe.** O filtro não guarda estado por-request (o MVC compartilha a instância do attribute + entre requests concorrentes — a v1 tinha race condition aqui; a v2 é stateless por construção, com + teste de regressão de 500 requests paralelos). + +## Instalação + +```bash +dotnet add package AspNet.Cache +``` + +Multi-target **.NET 8.0, 9.0 e 10.0**. No net9.0+ o fast path `IBufferDistributedCache` é usado +automaticamente quando o provider suporta. + +## Início rápido + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +builder.Services.AddDistributedMemoryCache(); // ou Redis, SQL Server, Mongo... + +var app = builder.Build(); +app.MapControllers(); +app.Run(); +``` + +### Configurações do attribute + +| Propriedade | Default | Significado | +|---|---|---| +| `Folder` | `""` | Prefixo da chave | +| `ExpireAt` | `10` | Quantidade de expiração, na unidade de `TimeSpanType` | +| `TimeSpanType` | `FromMinutes` | Milissegundos, segundos, minutos, horas ou dias | +| `SuccessStatus` | `200 OK` | Status que torna a resposta cacheável, e que é devolvido nos hits | + +## Opções globais + +```csharp +builder.Services.Configure(options => +{ + options.MaxPayloadBytes = 32_000; + options.IgnoreArgument = name => + name.Contains("correlationid", StringComparison.OrdinalIgnoreCase); +}); +``` + +## Como a chave é montada + +``` +{Folder}:{path da request com "/" → "-"}-{hex SHA-256 do JSON dos argumentos da action} +Weather:-api-weather-E39743B91B98367F... +``` + +Argumentos que casam com `IgnoreArgument` (correlation ids por default) não afetam a chave. +SHA-256 (não um hash rápido não-criptográfico) é proposital: a chave deriva de input não confiável, e +um hash forjável permitiria colisão de cache entre usuários. + +## Observações + +- Hits são sempre `application/json` (content negotiation não se aplica). +- Só cacheia quando o status da resposta é exatamente `SuccessStatus`. +- Respostas sem corpo (`StatusCodeResult`) são cacheadas com um sentinela pequeno e devolvidas como + status puro. +- Em um hit a action não executa — efeitos colaterais dentro dela não acontecem. + +## Desempenho + +Medido na v2 (net8.0, x64, Release; BenchmarkDotNet, job short): + +| Caminho | v1 | v2 | +|---|---|---| +| Geração de chave | 1.784 B/op | **344 B/op** | +| Leitura no hit via `IBufferDistributedCache` | n/a | **104 B/op** (pooled) | +| Leitura no hit via `IDistributedCache` legado | n/a | **144 B/op** | +| Gravação buffered (serialize + set) | n/a | **864 B/op** | +| Coletas Gen1 / Gen2 | — | **0** (todos os caminhos Gen0-only) | + +O CI aplica os budgets de alocação em todo PR (`build/check-allocation-budgets.ps1`) — bytes por +operação são determinísticos, então regressão quebra o build. + +> **Sobre "1 milhão de requests/segundo".** Nessa escala o gargalo é o round-trip do provider de cache +> e a rede, e o alvo se atinge com scale-out horizontal (N instâncias). O papel desta biblioteca é não +> adicionar pressão de GC nem cópia de payload no caminho da request — o que os budgets acima garantem. + +## Migração v1 → v2 + +Requer .NET 8+; serialização passa a System.Text.Json (segue as opções JSON do seu MVC); o cache +esquenta de novo no upgrade (mudança interna do formato de chave); `max-age` corrigido +(`TotalSeconds`); headers preservados no hit; race condition corrigida. + +## Licença + +[MIT](LICENSE) diff --git a/benchmarks/AspNet.Cache.Benchmarks/AspNet.Cache.Benchmarks.csproj b/benchmarks/AspNet.Cache.Benchmarks/AspNet.Cache.Benchmarks.csproj new file mode 100644 index 0000000..80266a8 --- /dev/null +++ b/benchmarks/AspNet.Cache.Benchmarks/AspNet.Cache.Benchmarks.csproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + Major + enable + enable + latest + false + + + + + + + + + + + + + + diff --git a/benchmarks/AspNet.Cache.Benchmarks/CacheBenchmarks.cs b/benchmarks/AspNet.Cache.Benchmarks/CacheBenchmarks.cs new file mode 100644 index 0000000..5dd046c --- /dev/null +++ b/benchmarks/AspNet.Cache.Benchmarks/CacheBenchmarks.cs @@ -0,0 +1,74 @@ +using System.Buffers; +using System.Text.Json; +using AspNet.Cache.Internal; +using AspNet.Cache.Test; +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace AspNet.Cache.Benchmarks; + +[MemoryDiagnoser] +public class CacheBenchmarks +{ + private static readonly JsonSerializerOptions Serializer = new(JsonSerializerDefaults.Web); + private static readonly Func Ignore = + static name => name.Contains("correlationid", StringComparison.OrdinalIgnoreCase); + + private Dictionary _arguments = null!; + private InMemoryBufferCache _buffered = null!; + private MemoryDistributedCache _legacy = null!; + private string _key = null!; + private Item[] _items = null!; + private DistributedCacheEntryOptions _entryOptions = null!; + + [GlobalSetup] + public void Setup() + { + _arguments = new Dictionary { ["id"] = 42, ["name"] = "abc" }; + _key = CacheKeyBuilder.Build("Bench", "/api/bench", _arguments, Ignore, Serializer); + _items = new Item[10]; + for (var i = 0; i < _items.Length; i++) + _items[i] = new Item(i, $"item-{i}", i * 1.5); + _entryOptions = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }; + + var payload = JsonSerializer.SerializeToUtf8Bytes(_items, Serializer); + _buffered = new InMemoryBufferCache(); + _buffered.Set(_key, payload, _entryOptions); + _legacy = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + _legacy.Set(_key, payload, _entryOptions); + } + + [Benchmark] + public string KeyGen() => CacheKeyBuilder.Build("Bench", "/api/bench", _arguments, Ignore, Serializer); + + [Benchmark] + public async Task BufferedHit() + { + using var buffer = new PooledArrayBufferWriter(); + await _buffered.TryGetAsync(_key, buffer, default); + return buffer.WrittenCount; + } + + [Benchmark] + public async Task LegacyHit() + { + var payload = await _legacy.GetAsync(_key); + return payload!.Length; + } + + [Benchmark] + public async Task StoreSerializeBuffered() + { + using var buffer = new PooledArrayBufferWriter(84_000); + using (var writer = new Utf8JsonWriter(buffer)) + { + JsonSerializer.Serialize(writer, _items, Serializer); + } + + await _buffered.SetAsync("store", new ReadOnlySequence(buffer.WrittenMemory), _entryOptions, default); + } + + public sealed record Item(int Id, string Name, double Value); +} diff --git a/benchmarks/AspNet.Cache.Benchmarks/Program.cs b/benchmarks/AspNet.Cache.Benchmarks/Program.cs new file mode 100644 index 0000000..b49023d --- /dev/null +++ b/benchmarks/AspNet.Cache.Benchmarks/Program.cs @@ -0,0 +1,3 @@ +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(AspNet.Cache.Benchmarks.CacheBenchmarks).Assembly).Run(args); diff --git a/benchmarks/budgets.json b/benchmarks/budgets.json new file mode 100644 index 0000000..378b07b --- /dev/null +++ b/benchmarks/budgets.json @@ -0,0 +1,6 @@ +{ + "KeyGen": 430, + "BufferedHit": 130, + "LegacyHit": 180, + "StoreSerializeBuffered": 1080 +} diff --git a/build/check-allocation-budgets.ps1 b/build/check-allocation-budgets.ps1 new file mode 100644 index 0000000..7a72090 --- /dev/null +++ b/build/check-allocation-budgets.ps1 @@ -0,0 +1,30 @@ +#Requires -Version 7 +$ErrorActionPreference = 'Stop' + +$budgets = Get-Content (Join-Path $PSScriptRoot '..' 'benchmarks' 'budgets.json') | ConvertFrom-Json +$reportFile = Get-ChildItem -Recurse -Path 'BenchmarkDotNet.Artifacts/results' -Filter '*-report-full*.json' | + Select-Object -First 1 +if ($null -eq $reportFile) { throw 'Benchmark report not found. Run the benchmarks with --exporters json first.' } + +$report = Get-Content $reportFile.FullName -Raw | ConvertFrom-Json +$failed = $false + +foreach ($benchmark in $report.Benchmarks) { + $name = $benchmark.Method + $allocated = $benchmark.Memory.BytesAllocatedPerOperation + $gen1 = $benchmark.Memory.Gen1Collections + $gen2 = $benchmark.Memory.Gen2Collections + $limit = $budgets.$name + + Write-Host ("{0}: {1} B/op (limit {2}), Gen1/1k={3}, Gen2/1k={4}" -f $name, $allocated, $limit, $gen1, $gen2) + + if ($gen2 -gt 0) { Write-Host "FAIL: $name triggered Gen2 collections"; $failed = $true } + if ($gen1 -gt 0) { Write-Host "FAIL: $name triggered Gen1 collections"; $failed = $true } + if ($null -ne $limit -and $allocated -gt $limit) { + Write-Host "FAIL: $name allocated $allocated B/op, budget is $limit" + $failed = $true + } +} + +if ($failed) { exit 1 } +Write-Host 'All allocation budgets respected.' diff --git a/samples/AspNet.Cache.Sample/AspNet.Cache.Sample.csproj b/samples/AspNet.Cache.Sample/AspNet.Cache.Sample.csproj new file mode 100644 index 0000000..44e28b5 --- /dev/null +++ b/samples/AspNet.Cache.Sample/AspNet.Cache.Sample.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + Major + enable + enable + false + + + + + + + diff --git a/samples/AspNet.Cache.Sample/Program.cs b/samples/AspNet.Cache.Sample/Program.cs new file mode 100644 index 0000000..7503d90 --- /dev/null +++ b/samples/AspNet.Cache.Sample/Program.cs @@ -0,0 +1,8 @@ +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); +builder.Services.AddDistributedMemoryCache(); +builder.Logging.SetMinimumLevel(LogLevel.Warning); + +var app = builder.Build(); +app.MapControllers(); +app.Run(); diff --git a/samples/AspNet.Cache.Sample/SampleController.cs b/samples/AspNet.Cache.Sample/SampleController.cs new file mode 100644 index 0000000..cbb2fd3 --- /dev/null +++ b/samples/AspNet.Cache.Sample/SampleController.cs @@ -0,0 +1,24 @@ +using AspNet.Cache; +using Microsoft.AspNetCore.Mvc; + +namespace AspNet.Cache.Sample; + +[ApiController] +[Route("api/[controller]")] +public sealed class SampleController : ControllerBase +{ + private static readonly string[] Summaries = + ["Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"]; + + [HttpGet] + [Cache(ExpireAt = 5, TimeSpanType = TimeSpanType.FromMinutes)] + public IReadOnlyList Get(int id) + { + var items = new Forecast[5]; + for (var i = 0; i < items.Length; i++) + items[i] = new Forecast(id, DateTime.UtcNow.AddDays(i), Random.Shared.Next(-20, 55), Summaries[i % Summaries.Length]); + return items; + } +} + +public sealed record Forecast(int Id, DateTime Date, int TemperatureC, string Summary); diff --git a/test/AspNet.Cache.Test/AspNet.Cache.Test.csproj b/test/AspNet.Cache.Test/AspNet.Cache.Test.csproj index f5f47ff..365ef61 100644 --- a/test/AspNet.Cache.Test/AspNet.Cache.Test.csproj +++ b/test/AspNet.Cache.Test/AspNet.Cache.Test.csproj @@ -1,35 +1,30 @@ - + - netcoreapp3.1 - + + net9.0 false + enable + enable + latest - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + + + + + + - \ No newline at end of file + + diff --git a/test/AspNet.Cache.Test/CacheAttributeCoverageTests.cs b/test/AspNet.Cache.Test/CacheAttributeCoverageTests.cs new file mode 100644 index 0000000..2e4b252 --- /dev/null +++ b/test/AspNet.Cache.Test/CacheAttributeCoverageTests.cs @@ -0,0 +1,175 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Shouldly; +using Xunit; + +namespace AspNet.Cache.Test; + +public sealed class CacheAttributeCoverageTests +{ + private static ServiceProvider WithCache(IDistributedCache cache) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton(cache); + return services.BuildServiceProvider(); + } + + [Theory] + [InlineData(TimeSpanType.FromMilliseconds, 5000, "max-age=5")] + [InlineData(TimeSpanType.FromSeconds, 90, "max-age=90")] + [InlineData(TimeSpanType.FromMinutes, 2, "max-age=120")] + [InlineData(TimeSpanType.FromHours, 2, "max-age=7200")] + [InlineData(TimeSpanType.FromDays, 1, "max-age=86400")] + public async Task Expiration_Unit_ProducesCorrectMaxAge(TimeSpanType unit, long expireAt, string expected) + { + await using var services = WithCache(new InMemoryBufferCache()); + var attribute = new CacheAttribute { ExpireAt = expireAt, TimeSpanType = unit }; + + var (first, _) = FilterContextFactory.Create(services, "/api/exp", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(first, + FilterContextFactory.NextReturning(first, new ObjectResult(new Payload(1, "x")))); + + var (second, http) = FilterContextFactory.Create(services, "/api/exp", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(second, + FilterContextFactory.NextReturning(second, new ObjectResult(new Payload(1, "x")))); + + second.Result.ShouldNotBeNull(); + http.Response.Headers.CacheControl.ToString().ShouldBe(expected); + } + + [Fact] + public async Task BufferedProvider_StatusCodeResult_CachedAsEmptyMarker_AndReplayed() + { + await using var services = WithCache(new InMemoryBufferCache()); + var attribute = new CacheAttribute(); + var executions = 0; + + var (first, _) = FilterContextFactory.Create(services, "/api/bempty", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(first, + FilterContextFactory.NextReturning(first, new StatusCodeResult(200), () => executions++)); + + var (second, _) = FilterContextFactory.Create(services, "/api/bempty", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(second, + FilterContextFactory.NextReturning(second, new StatusCodeResult(200), () => executions++)); + + executions.ShouldBe(1); + second.Result.ShouldBeOfType().StatusCode.ShouldBe(200); + } + + [Fact] + public async Task BufferedProvider_GetFailure_FailsOpen() + { + await using var services = WithCache(new FaultyBufferCache { OnGet = new InvalidOperationException("down") }); + var attribute = new CacheAttribute(); + var executions = 0; + + var (context, _) = FilterContextFactory.Create(services, "/api/bfail", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new ObjectResult(new Payload(1, "x")), () => executions++)); + + executions.ShouldBe(1); + context.Result.ShouldBeNull(); + } + + [Fact] + public async Task BufferedProvider_CanceledRequest_PropagatesOperationCanceled() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await using var services = WithCache(new FaultyBufferCache { OnGet = new OperationCanceledException(cts.Token) }); + var attribute = new CacheAttribute(); + + var (context, http) = FilterContextFactory.Create(services, "/api/bcancel", new() { ["id"] = 1 }); + http.RequestAborted = cts.Token; + + await Should.ThrowAsync(() => attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new ObjectResult(new Payload(1, "x"))))); + } + + [Fact] + public async Task BufferedProvider_SetFailure_FailsOpen() + { + await using var services = WithCache(new FaultyBufferCache { OnSet = new InvalidOperationException("down") }); + var attribute = new CacheAttribute(); + var executions = 0; + + var (context, _) = FilterContextFactory.Create(services, "/api/bsetfail", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new ObjectResult(new Payload(1, "x")), () => executions++)); + + executions.ShouldBe(1); + context.Result.ShouldBeNull(); + } + + [Fact] + public async Task StatusCodeResult_SetFailure_FailsOpen() + { + var failing = Substitute.For(); + failing.GetAsync(Arg.Any(), Arg.Any()).Returns((byte[]?)null); + failing.SetAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()).ThrowsAsync(new InvalidOperationException("down")); + await using var services = WithCache(failing); + var attribute = new CacheAttribute(); + var executions = 0; + + var (context, _) = FilterContextFactory.Create(services, "/api/scodefail", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new StatusCodeResult(200), () => executions++)); + + executions.ShouldBe(1); + } + + [Fact] + public async Task ActionCanceled_IsNotCached() + { + await using var services = WithCache(new InMemoryBufferCache()); + var attribute = new CacheAttribute(); + var executions = 0; + + for (var i = 0; i < 2; i++) + { + var (context, _) = FilterContextFactory.Create(services, "/api/actcancel", new() { ["id"] = 1 }); + ActionExecutionDelegate next = () => + { + executions++; + return Task.FromResult(new ActionExecutedContext(context, new List(), new object()) + { + Canceled = true + }); + }; + await attribute.OnActionExecutionAsync(context, next); + } + + executions.ShouldBe(2); + } + + [Fact] + public async Task NoOptionsRegistered_UsesFallbackDefaults() + { + await using var services = new ServiceCollection() + .AddSingleton(new InMemoryBufferCache()) + .BuildServiceProvider(); + var attribute = new CacheAttribute(); + var executions = 0; + + var (first, _) = FilterContextFactory.Create(services, "/api/nodefaults", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(first, + FilterContextFactory.NextReturning(first, new ObjectResult(new Payload(1, "x")), () => executions++)); + + var (second, _) = FilterContextFactory.Create(services, "/api/nodefaults", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(second, + FilterContextFactory.NextReturning(second, new ObjectResult(new Payload(1, "x")), () => executions++)); + + executions.ShouldBe(1); + second.Result.ShouldNotBeNull(); + } + + private sealed record Payload(int Id, string Name); +} diff --git a/test/AspNet.Cache.Test/CacheAttributeTests.cs b/test/AspNet.Cache.Test/CacheAttributeTests.cs new file mode 100644 index 0000000..21ce72b --- /dev/null +++ b/test/AspNet.Cache.Test/CacheAttributeTests.cs @@ -0,0 +1,200 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Shouldly; +using Xunit; + +namespace AspNet.Cache.Test; + +public sealed class CacheAttributeTests +{ + private static ServiceProvider BuildServices(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddDistributedMemoryCache(); + configure?.Invoke(services); + return services.BuildServiceProvider(); + } + + [Fact] + public async Task MissThenHit_ServesCachedPayload_WithoutReexecutingAction() + { + await using var services = BuildServices(); + var attribute = new CacheAttribute(); + var executions = 0; + + var (first, _) = FilterContextFactory.Create(services, "/api/test", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(first, + FilterContextFactory.NextReturning(first, new ObjectResult(new Payload(1, "fresh")), () => executions++)); + + executions.ShouldBe(1); + first.Result.ShouldBeNull(); + + var (second, http) = FilterContextFactory.Create(services, "/api/test", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(second, + FilterContextFactory.NextReturning(second, new ObjectResult(new Payload(1, "fresh")), () => executions++)); + + executions.ShouldBe(1); + second.Result.ShouldNotBeNull(); + var body = await FilterContextFactory.ReadBodyAsync(http, second.Result!); + body.ShouldContain("fresh"); + http.Response.Headers.CacheControl.ToString().ShouldBe("max-age=600"); + } + + [Fact] + public async Task BufferedProvider_MissThenHit_Roundtrips() + { + await using var services = BuildServices(s => + s.AddSingleton(new InMemoryBufferCache())); + var attribute = new CacheAttribute(); + var executions = 0; + + var (first, _) = FilterContextFactory.Create(services, "/api/buffered", new() { ["id"] = 9 }); + await attribute.OnActionExecutionAsync(first, + FilterContextFactory.NextReturning(first, new ObjectResult(new Payload(9, "pooled")), () => executions++)); + + var (second, http) = FilterContextFactory.Create(services, "/api/buffered", new() { ["id"] = 9 }); + await attribute.OnActionExecutionAsync(second, + FilterContextFactory.NextReturning(second, new ObjectResult(new Payload(9, "pooled")), () => executions++)); + + executions.ShouldBe(1); + var body = await FilterContextFactory.ReadBodyAsync(http, second.Result!); + body.ShouldContain("pooled"); + } + + [Fact] + public async Task SharedAttributeInstance_ConcurrentRequests_NeverMixResponses() + { + await using var services = BuildServices(); + var attribute = new CacheAttribute(); + + await Parallel.ForAsync(0, 500, async (iteration, _) => + { + var expected = iteration % 20; + var (context, http) = FilterContextFactory.Create(services, "/api/race", new() { ["id"] = expected }); + await attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new ObjectResult(new Echo(expected)))); + + if (context.Result is not null) + { + var body = await FilterContextFactory.ReadBodyAsync(http, context.Result); + body.ShouldBe($"{{\"id\":{expected}}}"); + } + }); + } + + [Fact] + public async Task StatusMismatch_IsNotCached() + { + await using var services = BuildServices(); + var attribute = new CacheAttribute { SuccessStatus = HttpStatusCode.Created }; + var executions = 0; + + for (var i = 0; i < 2; i++) + { + var (context, _) = FilterContextFactory.Create(services, "/api/status", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new ObjectResult(new Payload(1, "x")), () => executions++)); + } + + executions.ShouldBe(2); + } + + [Fact] + public async Task OversizedPayload_IsNotCached() + { + await using var services = BuildServices(s => + s.Configure(o => o.MaxPayloadBytes = 8)); + var attribute = new CacheAttribute(); + var executions = 0; + + for (var i = 0; i < 2; i++) + { + var (context, _) = FilterContextFactory.Create(services, "/api/big", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new ObjectResult(new Payload(1, "0123456789")), () => executions++)); + } + + executions.ShouldBe(2); + } + + [Fact] + public async Task StatusCodeResult_IsCachedAsEmptyMarker_AndReplayed() + { + await using var services = BuildServices(); + var attribute = new CacheAttribute(); + var executions = 0; + + var (first, _) = FilterContextFactory.Create(services, "/api/empty", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(first, + FilterContextFactory.NextReturning(first, new StatusCodeResult(200), () => executions++)); + + var (second, _) = FilterContextFactory.Create(services, "/api/empty", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(second, + FilterContextFactory.NextReturning(second, new StatusCodeResult(200), () => executions++)); + + executions.ShouldBe(1); + second.Result.ShouldBeOfType().StatusCode.ShouldBe(200); + } + + [Fact] + public async Task CacheGetFailure_FailsOpen_ActionStillExecutes() + { + var failing = Substitute.For(); + failing.GetAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("redis down")); + failing.SetAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any()).Returns(Task.CompletedTask); + await using var services = BuildServices(s => s.AddSingleton(failing)); + var attribute = new CacheAttribute(); + var executions = 0; + + var (context, _) = FilterContextFactory.Create(services, "/api/failopen", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new ObjectResult(new Payload(1, "x")), () => executions++)); + + executions.ShouldBe(1); + context.Result.ShouldBeNull(); + } + + [Fact] + public async Task CanceledRequest_PropagatesOperationCanceled() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var failing = Substitute.For(); + failing.GetAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + await using var services = BuildServices(s => s.AddSingleton(failing)); + var attribute = new CacheAttribute(); + + var (context, http) = FilterContextFactory.Create(services, "/api/cancel", new() { ["id"] = 1 }); + http.RequestAborted = cts.Token; + + await Should.ThrowAsync(() => attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new ObjectResult(new Payload(1, "x"))))); + } + + [Fact] + public async Task NoDistributedCacheRegistered_JustRunsAction() + { + await using var services = new ServiceCollection().AddLogging().BuildServiceProvider(); + var attribute = new CacheAttribute(); + var executions = 0; + + var (context, _) = FilterContextFactory.Create(services, "/api/nocache", new() { ["id"] = 1 }); + await attribute.OnActionExecutionAsync(context, + FilterContextFactory.NextReturning(context, new ObjectResult(new Payload(1, "x")), () => executions++)); + + executions.ShouldBe(1); + } + + private sealed record Payload(int Id, string Name); + + private sealed record Echo(int Id); +} diff --git a/test/AspNet.Cache.Test/CacheIntegrationTests.cs b/test/AspNet.Cache.Test/CacheIntegrationTests.cs new file mode 100644 index 0000000..38e71c2 --- /dev/null +++ b/test/AspNet.Cache.Test/CacheIntegrationTests.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Xunit; + +namespace AspNet.Cache.Test; + +public sealed class CacheIntegrationTests : IDisposable +{ + private readonly IHost _host; + private readonly HttpClient _client; + + public CacheIntegrationTests() + { + _host = new HostBuilder() + .ConfigureWebHost(webHost => webHost + .UseTestServer() + .ConfigureServices(services => + { + services.AddControllers().AddApplicationPart(typeof(CounterController).Assembly); + services.AddDistributedMemoryCache(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + })) + .Start(); + _client = _host.GetTestClient(); + } + + [Fact] + public async Task SecondRequest_SameArguments_IsServedFromCache() + { + CounterController.Reset(); + + var first = await _client.GetStringAsync("/api/counter?id=1"); + var second = await _client.GetStringAsync("/api/counter?id=1"); + + second.ShouldBe(first); + CounterController.Executions.ShouldBe(1); + } + + [Fact] + public async Task DifferentArguments_ProduceDifferentEntries() + { + CounterController.Reset(); + + var one = await _client.GetStringAsync("/api/counter?id=1"); + var two = await _client.GetStringAsync("/api/counter?id=2"); + + one.ShouldNotBe(two); + CounterController.Executions.ShouldBe(2); + } + + public void Dispose() + { + _client.Dispose(); + _host.Dispose(); + } +} + +[ApiController] +[Route("api/[controller]")] +public sealed class CounterController : ControllerBase +{ + private static int _executions; + + internal static int Executions => Volatile.Read(ref _executions); + + internal static void Reset() => Interlocked.Exchange(ref _executions, 0); + + [HttpGet] + [Cache(ExpireAt = 5, TimeSpanType = TimeSpanType.FromMinutes)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", + Justification = "MVC action methods must be instance methods.")] + public CounterPayload Get(int id) + { + Interlocked.Increment(ref _executions); + return new CounterPayload(id, Guid.NewGuid().ToString("N")); + } +} + +public sealed record CounterPayload(int Id, string Nonce); diff --git a/test/AspNet.Cache.Test/CacheKeyBuilderTests.cs b/test/AspNet.Cache.Test/CacheKeyBuilderTests.cs new file mode 100644 index 0000000..aca666f --- /dev/null +++ b/test/AspNet.Cache.Test/CacheKeyBuilderTests.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using AspNet.Cache.Internal; +using Shouldly; +using Xunit; + +namespace AspNet.Cache.Test; + +public sealed class CacheKeyBuilderTests +{ + private static readonly JsonSerializerOptions Serializer = new(JsonSerializerDefaults.Web); + private static readonly Func Ignore = + static name => name.Contains("correlationid", StringComparison.OrdinalIgnoreCase); + + private static string Build(string folder, string path, Dictionary args) => + CacheKeyBuilder.Build(folder, path, args, Ignore, Serializer); + + [Fact] + public void SameArguments_ProduceSameKey_IgnoringCorrelationId() + { + var first = Build("Test", "/api/default", + new() { ["id"] = 42, ["xCorrelationId"] = Guid.NewGuid().ToString() }); + var second = Build("Test", "/api/default", + new() { ["id"] = 42, ["xCorrelationId"] = Guid.NewGuid().ToString() }); + + first.ShouldBe(second); + } + + [Fact] + public void DifferentArguments_ProduceDifferentKeys() + { + var first = Build("Test", "/api/default", new() { ["id"] = 42 }); + var second = Build("Test", "/api/default", new() { ["id"] = 43 }); + + first.ShouldNotBe(second); + } + + [Fact] + public void Key_HasFolderPrefix_SlashesReplaced_And64HexSuffix() + { + var key = Build("Test", "/api/default", new() { ["id"] = 1 }); + + key.ShouldStartWith("Test:-api-default-"); + key.Length.ShouldBe("Test:-api-default-".Length + 64); + key.ShouldNotContain("/"); + } + + [Fact] + public void EmptyFolder_OmitsPrefixSeparator() + { + var key = Build(string.Empty, "/api/default", new() { ["id"] = 1 }); + + key.ShouldStartWith("-api-default-"); + } + + [Fact] + public void LongPath_UsesPooledBuffer_AndKeepsCorrectness() + { + var longPath = "/api/" + new string('x', 400); + + var key = Build("Test", longPath, new() { ["id"] = 1 }); + + key.Length.ShouldBe("Test:".Length + longPath.Length + 1 + 64); + key.ShouldNotContain("/"); + } + + [Fact] + public void ComplexObjectArgument_InfluencesKey() + { + var withBody = Build("Test", "/api/default", new() { ["body"] = new Payload(7, "coffee") }); + var without = Build("Test", "/api/default", new()); + + withBody.ShouldNotBe(without); + } + + private sealed record Payload(int Id, string Name); +} diff --git a/test/AspNet.Cache.Test/FaultyBufferCache.cs b/test/AspNet.Cache.Test/FaultyBufferCache.cs new file mode 100644 index 0000000..60d3e90 --- /dev/null +++ b/test/AspNet.Cache.Test/FaultyBufferCache.cs @@ -0,0 +1,68 @@ +using System.Buffers; +using System.Collections.Concurrent; +using Microsoft.Extensions.Caching.Distributed; + +namespace AspNet.Cache.Test; + +/// Configurable that can fault reads or writes, for fail-open tests. +internal sealed class FaultyBufferCache : IBufferDistributedCache +{ + private readonly ConcurrentDictionary _entries = new(); + + public Exception? OnGet { get; init; } + + public Exception? OnSet { get; init; } + + public byte[]? Get(string key) => _entries.TryGetValue(key, out var value) ? value : null; + + public Task GetAsync(string key, CancellationToken token = default) => Task.FromResult(Get(key)); + + public void Refresh(string key) + { + } + + public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask; + + public void Remove(string key) => _entries.TryRemove(key, out _); + + public Task RemoveAsync(string key, CancellationToken token = default) + { + Remove(key); + return Task.CompletedTask; + } + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => _entries[key] = value; + + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + if (OnSet is not null) + throw OnSet; + Set(key, value, options); + return Task.CompletedTask; + } + + public bool TryGet(string key, IBufferWriter destination) + { + if (OnGet is not null) + throw OnGet; + if (!_entries.TryGetValue(key, out var value)) + return false; + destination.Write(value); + return true; + } + + public ValueTask TryGetAsync(string key, IBufferWriter destination, CancellationToken token = default) => + new(TryGet(key, destination)); + + public void Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) => + _entries[key] = value.ToArray(); + + public ValueTask SetAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, + CancellationToken token = default) + { + if (OnSet is not null) + throw OnSet; + Set(key, value, options); + return ValueTask.CompletedTask; + } +} diff --git a/test/AspNet.Cache.Test/FilterContextFactory.cs b/test/AspNet.Cache.Test/FilterContextFactory.cs new file mode 100644 index 0000000..b47a45f --- /dev/null +++ b/test/AspNet.Cache.Test/FilterContextFactory.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; + +namespace AspNet.Cache.Test; + +internal static class FilterContextFactory +{ + public static (ActionExecutingContext Context, DefaultHttpContext Http) Create(IServiceProvider services, + string path, Dictionary arguments) + { + var http = new DefaultHttpContext { RequestServices = services }; + http.Request.Path = path; + http.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(http, new RouteData(), new ActionDescriptor()); + var context = new ActionExecutingContext(actionContext, new List(), arguments, new object()); + return (context, http); + } + + public static ActionExecutionDelegate NextReturning(ActionExecutingContext context, IActionResult result, + Action? onExecuted = null) => () => + { + onExecuted?.Invoke(); + return Task.FromResult(new ActionExecutedContext(context, new List(), new object()) + { + Result = result + }); + }; + + public static async Task ReadBodyAsync(DefaultHttpContext http, IActionResult result) + { + await result.ExecuteResultAsync(new ActionContext(http, new RouteData(), new ActionDescriptor())); + http.Response.Body.Position = 0; + using var reader = new StreamReader(http.Response.Body); + return await reader.ReadToEndAsync(); + } +} diff --git a/test/AspNet.Cache.Test/InMemoryBufferCache.cs b/test/AspNet.Cache.Test/InMemoryBufferCache.cs new file mode 100644 index 0000000..d4f519b --- /dev/null +++ b/test/AspNet.Cache.Test/InMemoryBufferCache.cs @@ -0,0 +1,58 @@ +using System.Buffers; +using System.Collections.Concurrent; +using Microsoft.Extensions.Caching.Distributed; + +namespace AspNet.Cache.Test; + +/// In-process used by tests and benchmarks. Not for production. +internal sealed class InMemoryBufferCache : IBufferDistributedCache +{ + private readonly ConcurrentDictionary _entries = new(); + + public byte[]? Get(string key) => _entries.TryGetValue(key, out var value) ? value : null; + + public Task GetAsync(string key, CancellationToken token = default) => Task.FromResult(Get(key)); + + public void Refresh(string key) + { + } + + public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask; + + public void Remove(string key) => _entries.TryRemove(key, out _); + + public Task RemoveAsync(string key, CancellationToken token = default) + { + Remove(key); + return Task.CompletedTask; + } + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => _entries[key] = value; + + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + Set(key, value, options); + return Task.CompletedTask; + } + + public bool TryGet(string key, IBufferWriter destination) + { + if (!_entries.TryGetValue(key, out var value)) + return false; + destination.Write(value); + return true; + } + + public ValueTask TryGetAsync(string key, IBufferWriter destination, CancellationToken token = default) => + new(TryGet(key, destination)); + + public void Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) => + _entries[key] = value.ToArray(); + + public ValueTask SetAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, + CancellationToken token = default) + { + Set(key, value, options); + return ValueTask.CompletedTask; + } +} diff --git a/test/AspNet.Cache.Test/PooledArrayBufferWriterTests.cs b/test/AspNet.Cache.Test/PooledArrayBufferWriterTests.cs new file mode 100644 index 0000000..c71f905 --- /dev/null +++ b/test/AspNet.Cache.Test/PooledArrayBufferWriterTests.cs @@ -0,0 +1,63 @@ +using AspNet.Cache.Internal; +using Shouldly; +using Xunit; + +namespace AspNet.Cache.Test; + +public sealed class PooledArrayBufferWriterTests +{ + [Fact] + public void Growth_PreservesContentAcrossRents() + { + using var writer = new PooledArrayBufferWriter(initialCapacity: 16); + + for (var i = 0; i < 100; i++) + { + var span = writer.GetSpan(64); + span[..64].Fill((byte)i); + writer.Advance(64); + } + + writer.WrittenCount.ShouldBe(6400); + writer.WrittenSpan[0].ShouldBe((byte)0); + writer.WrittenSpan[^1].ShouldBe((byte)99); + } + + [Fact] + public void SoftLimitExceeded_SetsOverflowed() + { + using var writer = new PooledArrayBufferWriter(softLimit: 8); + + writer.GetSpan(32)[..32].Fill(0xAB); + writer.Advance(32); + + writer.Overflowed.ShouldBeTrue(); + writer.WrittenCount.ShouldBe(32); + } + + [Fact] + public void NegativeAdvance_Throws() + { + using var writer = new PooledArrayBufferWriter(); + + Should.Throw(() => writer.Advance(-1)); + } + + [Fact] + public void AdvanceBeyondCapacity_Throws() + { + using var writer = new PooledArrayBufferWriter(initialCapacity: 16); + + Should.Throw(() => writer.Advance(1_000_000)); + } + + [Fact] + public void Dispose_IsIdempotent() + { + var writer = new PooledArrayBufferWriter(); + writer.Dispose(); + writer.Dispose(); + + writer.WrittenCount.ShouldBe(0); + } +} diff --git a/test/AspNet.Cache.Test/Properties/launchSettings.json b/test/AspNet.Cache.Test/Properties/launchSettings.json deleted file mode 100644 index f9b141c..0000000 --- a/test/AspNet.Cache.Test/Properties/launchSettings.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:25607/", - "sslPort": 44347 - } - }, - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "AspNet.Cache.Test": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" - } - } -} \ No newline at end of file diff --git a/test/AspNet.Cache.Test/UnitTest1.cs b/test/AspNet.Cache.Test/UnitTest1.cs deleted file mode 100644 index afcf397..0000000 --- a/test/AspNet.Cache.Test/UnitTest1.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Threading.Tasks; -using AspNet.Cache.Test.WebServer; -using Xunit; -using Newtonsoft.Json; -using System.Collections.Generic; -using System.Diagnostics; - -namespace AspNet.Cache.Test -{ - public class UnitTest1 : StartTestServer - { - [Fact] - public async Task Test1() - { - var sw = new Stopwatch(); - sw.Start(); - var t = await client.GetAsync("/api/default"); - sw.Stop(); - - var tempo = sw.ElapsedMilliseconds; - - var sw2 = new Stopwatch(); - sw2.Start(); - var t2 = await client.GetAsync("/api/default"); - sw2.Stop(); - - var tempo2 = sw2.ElapsedMilliseconds; - - Assert.True(tempo > tempo2); - - var p = JsonConvert.DeserializeObject>(await t.Content.ReadAsStringAsync()); - } - - [Fact] - public async Task Get1() - { - var t = await client.GetAsync("/api/default/get1"); - var t2 = await client.GetAsync("/api/default/get1"); - - var p = JsonConvert.DeserializeObject>(await t.Content.ReadAsStringAsync()); - - Assert.True(t.IsSuccessStatusCode); - Assert.True(t2.IsSuccessStatusCode); - } - } -} diff --git a/test/AspNet.Cache.Test/WebServer/DefaultController.cs b/test/AspNet.Cache.Test/WebServer/DefaultController.cs deleted file mode 100644 index df01930..0000000 --- a/test/AspNet.Cache.Test/WebServer/DefaultController.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace AspNet.Cache.Test.WebServer -{ - [ApiController, Route("api/[controller]")] - public class DefaultController : ControllerBase - { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public DefaultController(ILogger logger) - { - _logger = logger; - } - - [HttpGet, Cache(Folder = "Test", SuccessStatus = System.Net.HttpStatusCode.Created, TimeSpanType = TimeSpanType.FromDays, ExpireAt = 12)] - public async Task>> Get([Required, FromHeader(Name = "X-Correlation-Id")] string correlationId) - { - await Task.Delay(1000); - var rng = new Random(); - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateTime.Now.AddDays(index), - TemperatureC = rng.Next(-20, 55), - Summary = Summaries[rng.Next(Summaries.Length)] - }) - .ToArray(); - } - - [HttpGet("get1"), Cache] - public IEnumerable Get1([Required, FromHeader(Name = "X-Correlation-Id")] string correlationId) - { - var rng = new Random(); - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateTime.Now.AddDays(index), - TemperatureC = rng.Next(-20, 55), - Summary = Summaries[rng.Next(Summaries.Length)] - }) - .ToArray(); - } - } - - public class WeatherForecast - { - public DateTime Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string Summary { get; set; } - } -} diff --git a/test/AspNet.Cache.Test/WebServer/StartTestServer.cs b/test/AspNet.Cache.Test/WebServer/StartTestServer.cs deleted file mode 100644 index baa0bd5..0000000 --- a/test/AspNet.Cache.Test/WebServer/StartTestServer.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Hosting; -using System; -using System.Net.Http; - -namespace AspNet.Cache.Test.WebServer -{ - public class StartTestServer : IDisposable - { - private readonly IHost _host; - - protected readonly HttpClient client; - - public StartTestServer() - { - var _hostBuilder = new HostBuilder() - .UseEnvironment("Test") - .ConfigureWebHost(webHost => - { - webHost.UseTestServer(); - - webHost.UseStartup(); - }); - - _host = _hostBuilder.Start(); - - client = _host.GetTestClient(); - client.DefaultRequestHeaders.Add("X-Correlation-Id", "Teste"); - } - - #region IDisposable Support - - private bool disposedValue = false; // To detect redundant calls - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - client.Dispose(); - _host.Dispose(); - } - disposedValue = true; - } - } - - ~StartTestServer() - { - Dispose(false); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - #endregion IDisposable Support - } -} diff --git a/test/AspNet.Cache.Test/WebServer/StartupTest.cs b/test/AspNet.Cache.Test/WebServer/StartupTest.cs deleted file mode 100644 index 15a70a1..0000000 --- a/test/AspNet.Cache.Test/WebServer/StartupTest.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace AspNet.Cache.Test.WebServer -{ - public class StartupTest - { - public StartupTest(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration; - - public void ConfigureServices(IServiceCollection services) - { - services.AddControllers(); - services.AddDistributedMemoryCache(); - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - app.UseRouting(); - - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } - } -}