├── .gitattributes ├── .gitignore ├── LICENSE ├── Marvin.Cache.Headers.All.sln ├── Marvin.Cache.Headers.sln ├── NuGet.config ├── README.md ├── build.bat ├── global.json ├── sample └── Marvin.Cache.Headers.Sample │ ├── Controllers │ ├── MoreValuesController.cs │ └── ValuesController.cs │ ├── Marvin.Cache.Headers.Sample.csproj │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── SampleRequests.http │ └── appsettings.json ├── src ├── Marvin.Cache.Headers.DistributedStore.Redis │ ├── Extensions │ │ └── ServicesExtensions.cs │ ├── Marvin.Cache.Headers.DistributedStore.Redis.csproj │ ├── Options │ │ └── RedisDistributedCacheKeyRetrieverOptions.cs │ └── Stores │ │ └── RedisDistributedCacheKeyRetriever.cs ├── Marvin.Cache.Headers.DistributedStore │ ├── Interfaces │ │ └── IRetrieveDistributedCacheKeys.cs │ ├── Marvin.Cache.Headers.DistributedStore.csproj │ └── Stores │ │ └── DistributedCacheValidatorValueStore.cs └── Marvin.Cache.Headers │ ├── Attributes │ ├── HttpCacheExpirationAttribute.cs │ ├── HttpCacheIgnoreAttribute.cs │ └── HttpCacheValidationAttribute.cs │ ├── DefaultDateParser.cs │ ├── DefaultETagInjector.cs │ ├── DefaultLastModifiedInjector.cs │ ├── DefaultStoreKeyGenerator.cs │ ├── DefaultStrongETagGenerator.cs │ ├── Domain │ ├── CacheLocation.cs │ ├── ETag.cs │ ├── ETagContext.cs │ ├── ETagType.cs │ ├── ExpirationModelOptions.cs │ ├── MiddlewareOptions.cs │ ├── ResourceContext.cs │ ├── StoreKey.cs │ ├── StoreKeyContext.cs │ ├── ValidationModelOptions.cs │ └── ValidatorValue.cs │ ├── Extensions │ ├── AppBuilderExtensions.cs │ ├── ByteExtensions.cs │ ├── HttpContextExtensions.cs │ ├── MinimalApiExtensions.cs │ └── ServicesExtensions.cs │ ├── HttpCacheHeadersMiddleware.cs │ ├── Interfaces │ ├── IDateParser.cs │ ├── IETagGenerator.cs │ ├── IETagInjector.cs │ ├── ILastModifiedInjector.cs │ ├── IModelOptions.cs │ ├── IModelOptionsProvider.cs │ ├── IStoreKeyAccessor.cs │ ├── IStoreKeyGenerator.cs │ ├── IStoreKeySerializer.cs │ ├── IValidatorValueInvalidator.cs │ └── IValidatorValueStore.cs │ ├── Marvin.Cache.Headers.csproj │ ├── Marvin.Cache.Headers.csproj.DotSettings │ ├── Properties │ └── AssemblyInfo.cs │ ├── Serialization │ └── DefaultStoreKeySerializer.cs │ ├── StoreKeyAccessor.cs │ ├── Stores │ └── InMemoryValidatorValueStore.cs │ ├── Utils │ └── HttpStatusCodes.cs │ └── ValidatorValueInvalidator.cs └── test ├── Marvin.Cache.Headers.DistributedStore.Redis.Test ├── Extensions │ └── ServicesExtensionsFacts.cs ├── Marvin.Cache.Headers.DistributedStore.Redis.Test.csproj ├── Stores │ └── RedisDistributedCacheKeyRetrieverFacts.cs └── TestStartups │ └── DefaultStartup.cs ├── Marvin.Cache.Headers.DistributedStore.Test ├── Marvin.Cache.Headers.DistributedStore.Test.csproj └── Stores │ └── DistributedCacheValidatorValueStoreFacts.cs └── Marvin.Cache.Headers.Test ├── DefaultConfigurationFacts.cs ├── Extensions ├── AppBuilderExtensionsFacts.cs └── ServiceExtensionFacts.cs ├── IgnoreCachingFacts.cs ├── Injectors └── ETagInjectorFacts.cs ├── Marvin.Cache.Headers.Test.csproj ├── MvcConfigurationFacts.cs ├── Serialization └── DefaultStoreKeySerializerFacts.cs ├── Stores └── InMemoryValidatorValueStoreFacts.cs └── TestStartups ├── ConfiguredStartup.cs └── DefaultStartup.cs /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | [Xx]64/ 19 | [Xx]86/ 20 | [Bb]uild/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | *.VC.db 84 | 85 | # Visual Studio profiler 86 | *.psess 87 | *.vsp 88 | *.vspx 89 | *.sap 90 | 91 | # TFS 2012 Local Workspace 92 | $tf/ 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | *.DotSettings.user 101 | 102 | # JustCode is a .NET coding add-in 103 | .JustCode 104 | 105 | # TeamCity is a build add-in 106 | _TeamCity* 107 | 108 | # DotCover is a Code Coverage Tool 109 | *.dotCover 110 | 111 | # NCrunch 112 | _NCrunch_* 113 | .*crunch*.local.xml 114 | nCrunchTemp_* 115 | 116 | # MightyMoose 117 | *.mm.* 118 | AutoTest.Net/ 119 | 120 | # Web workbench (sass) 121 | .sass-cache/ 122 | 123 | # Installshield output folder 124 | [Ee]xpress/ 125 | 126 | # DocProject is a documentation generator add-in 127 | DocProject/buildhelp/ 128 | DocProject/Help/*.HxT 129 | DocProject/Help/*.HxC 130 | DocProject/Help/*.hhc 131 | DocProject/Help/*.hhk 132 | DocProject/Help/*.hhp 133 | DocProject/Help/Html2 134 | DocProject/Help/html 135 | 136 | # Click-Once directory 137 | publish/ 138 | 139 | # Publish Web Output 140 | *.[Pp]ublish.xml 141 | *.azurePubxml 142 | 143 | # TODO: Un-comment the next line if you do not want to checkin 144 | # your web deploy settings because they may include unencrypted 145 | # passwords 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # NuGet Packages 150 | *.nupkg 151 | # The packages folder can be ignored because of Package Restore 152 | **/packages/* 153 | # except build/, which is used as an MSBuild target. 154 | !**/packages/build/ 155 | # Uncomment if necessary however generally it will be regenerated when needed 156 | #!**/packages/repositories.config 157 | # NuGet v3's project.json files produces more ignoreable files 158 | *.nuget.props 159 | *.nuget.targets 160 | 161 | # Microsoft Azure Build Output 162 | csx/ 163 | *.build.csdef 164 | 165 | # Microsoft Azure Emulator 166 | ecf/ 167 | rcf/ 168 | 169 | # Microsoft Azure ApplicationInsights config file 170 | ApplicationInsights.config 171 | 172 | # Windows Store app package directory 173 | AppPackages/ 174 | BundleArtifacts/ 175 | 176 | # Visual Studio cache files 177 | # files ending in .cache can be ignored 178 | *.[Cc]ache 179 | # but keep track of directories ending in .cache 180 | !*.[Cc]ache/ 181 | 182 | # Others 183 | ClientBin/ 184 | [Ss]tyle[Cc]op.* 185 | ~$* 186 | *~ 187 | *.dbmdl 188 | *.dbproj.schemaview 189 | *.pfx 190 | *.publishsettings 191 | node_modules/ 192 | orleans.codegen.cs 193 | 194 | # RIA/Silverlight projects 195 | Generated_Code/ 196 | 197 | # Backup & report files from converting an old project file 198 | # to a newer Visual Studio version. Backup files are not needed, 199 | # because we have git ;-) 200 | _UpgradeReport_Files/ 201 | Backup*/ 202 | UpgradeLog*.XML 203 | UpgradeLog*.htm 204 | 205 | # SQL Server files 206 | *.mdf 207 | *.ldf 208 | 209 | # Business Intelligence projects 210 | *.rdl.data 211 | *.bim.layout 212 | *.bim_*.settings 213 | 214 | # Microsoft Fakes 215 | FakesAssemblies/ 216 | 217 | # GhostDoc plugin setting file 218 | *.GhostDoc.xml 219 | 220 | # Node.js Tools for Visual Studio 221 | .ntvs_analysis.dat 222 | 223 | # Visual Studio 6 build log 224 | *.plg 225 | 226 | # Visual Studio 6 workspace options file 227 | *.opt 228 | 229 | # Visual Studio LightSwitch build output 230 | **/*.HTMLClient/GeneratedArtifacts 231 | **/*.DesktopClient/GeneratedArtifacts 232 | **/*.DesktopClient/ModelManifest.xml 233 | **/*.Server/GeneratedArtifacts 234 | **/*.Server/ModelManifest.xml 235 | _Pvt_Extensions 236 | 237 | # LightSwitch generated files 238 | GeneratedArtifacts/ 239 | ModelManifest.xml 240 | 241 | # Paket dependency manager 242 | .paket/paket.exe 243 | 244 | # FAKE - F# Make 245 | .fake/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kevin Dockx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Marvin.Cache.Headers.All.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31919.166 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{26BCE0B1-AEE3-4814-8A00-841DDD25F7F4}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers.Test", "test\Marvin.Cache.Headers.Test\Marvin.Cache.Headers.Test.csproj", "{714C7259-D574-490C-A04F-9F9A47295CE7}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers", "src\Marvin.Cache.Headers\Marvin.Cache.Headers.csproj", "{CDE1E3DF-1161-494E-B910-7A99A3E8B0C0}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers.DistributedStore", "src\Marvin.Cache.Headers.DistributedStore\Marvin.Cache.Headers.DistributedStore.csproj", "{179FDD1E-6510-4D34-AFCF-FD27F3DFA709}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers.DistributedStore.Redis", "src\Marvin.Cache.Headers.DistributedStore.Redis\Marvin.Cache.Headers.DistributedStore.Redis.csproj", "{B88F1620-CF2C-4F6C-817B-D3528971A38C}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers.DistributedStore.Test", "test\Marvin.Cache.Headers.DistributedStore.Test\Marvin.Cache.Headers.DistributedStore.Test.csproj", "{525ACFA2-4BDA-4BBF-A9E6-DC5B894CCDE6}" 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers.DistributedStore.Redis.Test", "test\Marvin.Cache.Headers.DistributedStore.Redis.Test\Marvin.Cache.Headers.DistributedStore.Redis.Test.csproj", "{44258DC7-0EDA-4FFE-B020-FC74EE58631E}" 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers.Sample", "sample\Marvin.Cache.Headers.Sample\Marvin.Cache.Headers.Sample.csproj", "{566D40A6-53E6-454B-969C-2C15A7012657}" 21 | EndProject 22 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "02. Tests", "02. Tests", "{17E46011-F326-462F-87D5-3BA9EFD16EA7}" 23 | EndProject 24 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "03. Samples", "03. Samples", "{D2F35E29-5546-4BC2-A2A9-57682005F42B}" 25 | EndProject 26 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01. Codebases", "01. Codebases", "{CB3AA1A2-5D30-4771-A1B6-215A38820A6C}" 27 | EndProject 28 | Global 29 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 30 | Debug|Any CPU = Debug|Any CPU 31 | Release|Any CPU = Release|Any CPU 32 | EndGlobalSection 33 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 34 | {714C7259-D574-490C-A04F-9F9A47295CE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {714C7259-D574-490C-A04F-9F9A47295CE7}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {714C7259-D574-490C-A04F-9F9A47295CE7}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {714C7259-D574-490C-A04F-9F9A47295CE7}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {CDE1E3DF-1161-494E-B910-7A99A3E8B0C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {CDE1E3DF-1161-494E-B910-7A99A3E8B0C0}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {CDE1E3DF-1161-494E-B910-7A99A3E8B0C0}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {CDE1E3DF-1161-494E-B910-7A99A3E8B0C0}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {179FDD1E-6510-4D34-AFCF-FD27F3DFA709}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {179FDD1E-6510-4D34-AFCF-FD27F3DFA709}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {179FDD1E-6510-4D34-AFCF-FD27F3DFA709}.Release|Any CPU.ActiveCfg = Release|Any CPU 45 | {179FDD1E-6510-4D34-AFCF-FD27F3DFA709}.Release|Any CPU.Build.0 = Release|Any CPU 46 | {B88F1620-CF2C-4F6C-817B-D3528971A38C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {B88F1620-CF2C-4F6C-817B-D3528971A38C}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {B88F1620-CF2C-4F6C-817B-D3528971A38C}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {B88F1620-CF2C-4F6C-817B-D3528971A38C}.Release|Any CPU.Build.0 = Release|Any CPU 50 | {525ACFA2-4BDA-4BBF-A9E6-DC5B894CCDE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 51 | {525ACFA2-4BDA-4BBF-A9E6-DC5B894CCDE6}.Debug|Any CPU.Build.0 = Debug|Any CPU 52 | {525ACFA2-4BDA-4BBF-A9E6-DC5B894CCDE6}.Release|Any CPU.ActiveCfg = Release|Any CPU 53 | {525ACFA2-4BDA-4BBF-A9E6-DC5B894CCDE6}.Release|Any CPU.Build.0 = Release|Any CPU 54 | {44258DC7-0EDA-4FFE-B020-FC74EE58631E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 55 | {44258DC7-0EDA-4FFE-B020-FC74EE58631E}.Debug|Any CPU.Build.0 = Debug|Any CPU 56 | {44258DC7-0EDA-4FFE-B020-FC74EE58631E}.Release|Any CPU.ActiveCfg = Release|Any CPU 57 | {44258DC7-0EDA-4FFE-B020-FC74EE58631E}.Release|Any CPU.Build.0 = Release|Any CPU 58 | {566D40A6-53E6-454B-969C-2C15A7012657}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 59 | {566D40A6-53E6-454B-969C-2C15A7012657}.Debug|Any CPU.Build.0 = Debug|Any CPU 60 | {566D40A6-53E6-454B-969C-2C15A7012657}.Release|Any CPU.ActiveCfg = Release|Any CPU 61 | {566D40A6-53E6-454B-969C-2C15A7012657}.Release|Any CPU.Build.0 = Release|Any CPU 62 | EndGlobalSection 63 | GlobalSection(SolutionProperties) = preSolution 64 | HideSolutionNode = FALSE 65 | EndGlobalSection 66 | GlobalSection(NestedProjects) = preSolution 67 | {714C7259-D574-490C-A04F-9F9A47295CE7} = {17E46011-F326-462F-87D5-3BA9EFD16EA7} 68 | {CDE1E3DF-1161-494E-B910-7A99A3E8B0C0} = {CB3AA1A2-5D30-4771-A1B6-215A38820A6C} 69 | {179FDD1E-6510-4D34-AFCF-FD27F3DFA709} = {CB3AA1A2-5D30-4771-A1B6-215A38820A6C} 70 | {B88F1620-CF2C-4F6C-817B-D3528971A38C} = {CB3AA1A2-5D30-4771-A1B6-215A38820A6C} 71 | {525ACFA2-4BDA-4BBF-A9E6-DC5B894CCDE6} = {17E46011-F326-462F-87D5-3BA9EFD16EA7} 72 | {44258DC7-0EDA-4FFE-B020-FC74EE58631E} = {17E46011-F326-462F-87D5-3BA9EFD16EA7} 73 | {566D40A6-53E6-454B-969C-2C15A7012657} = {D2F35E29-5546-4BC2-A2A9-57682005F42B} 74 | EndGlobalSection 75 | GlobalSection(ExtensibilityGlobals) = postSolution 76 | SolutionGuid = {1DB7EC6B-6966-4A99-9FCA-F93364450822} 77 | EndGlobalSection 78 | EndGlobal 79 | -------------------------------------------------------------------------------- /Marvin.Cache.Headers.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.4.33213.308 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{006274C4-3D3A-4206-BF23-78582B4DADAD}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{26BCE0B1-AEE3-4814-8A00-841DDD25F7F4}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers", "src\Marvin.Cache.Headers\Marvin.Cache.Headers.csproj", "{B06BDC10-2211-49C4-9D83-8C6B0C6BF70E}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers.DistributedStore", "src\Marvin.Cache.Headers.DistributedStore\Marvin.Cache.Headers.DistributedStore.csproj", "{7CA27500-9102-4ECC-92F6-715F54B653ED}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marvin.Cache.Headers.DistributedStore.Redis", "src\Marvin.Cache.Headers.DistributedStore.Redis\Marvin.Cache.Headers.DistributedStore.Redis.csproj", "{177C3CF0-BE0D-4162-957F-2143C0B71D7E}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {B06BDC10-2211-49C4-9D83-8C6B0C6BF70E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {B06BDC10-2211-49C4-9D83-8C6B0C6BF70E}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {B06BDC10-2211-49C4-9D83-8C6B0C6BF70E}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {B06BDC10-2211-49C4-9D83-8C6B0C6BF70E}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {7CA27500-9102-4ECC-92F6-715F54B653ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {7CA27500-9102-4ECC-92F6-715F54B653ED}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {7CA27500-9102-4ECC-92F6-715F54B653ED}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {7CA27500-9102-4ECC-92F6-715F54B653ED}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {177C3CF0-BE0D-4162-957F-2143C0B71D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {177C3CF0-BE0D-4162-957F-2143C0B71D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {177C3CF0-BE0D-4162-957F-2143C0B71D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {177C3CF0-BE0D-4162-957F-2143C0B71D7E}.Release|Any CPU.Build.0 = Release|Any CPU 34 | EndGlobalSection 35 | GlobalSection(SolutionProperties) = preSolution 36 | HideSolutionNode = FALSE 37 | EndGlobalSection 38 | GlobalSection(NestedProjects) = preSolution 39 | {B06BDC10-2211-49C4-9D83-8C6B0C6BF70E} = {006274C4-3D3A-4206-BF23-78582B4DADAD} 40 | {7CA27500-9102-4ECC-92F6-715F54B653ED} = {006274C4-3D3A-4206-BF23-78582B4DADAD} 41 | {177C3CF0-BE0D-4162-957F-2143C0B71D7E} = {006274C4-3D3A-4206-BF23-78582B4DADAD} 42 | EndGlobalSection 43 | GlobalSection(ExtensibilityGlobals) = postSolution 44 | SolutionGuid = {1DB7EC6B-6966-4A99-9FCA-F93364450822} 45 | EndGlobalSection 46 | EndGlobal 47 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Http Cache Headers Middleware for ASP.NET Core 2 | ASP.NET Core middleware that adds HttpCache headers to responses (Cache-Control, Expires, ETag, Last-Modified), and implements cache expiration & validation models. It can be used to ensure caches correctly cache responses and/or to implement concurrency for REST-based APIs using ETags. 3 | 4 | The middleware itself does not store responses. Looking at [this description]( http://2ndscale.com/rtomayko/2008/things-caches-do "Things Caches Do"), this middleware handles the "backend"-part: it generates the correct cache-related headers, and ensures a cache can check for expiration (304 Not Modified) & preconditions (412 Precondition Failed) (often used for concurrency checks). 5 | 6 | It can be used together with a shared cache, a private cache or both. For production scenarios the best approach is to use this middleware to generate the ETags, combined with a cache server or CDN to inspect those tags and effectively cache the responses. In the sample, the Microsoft.AspNetCore.ResponseCaching cache store is used to cache the responses. 7 | 8 | [![NuGet version](https://badge.fury.io/nu/marvin.cache.headers.svg)](https://badge.fury.io/nu/marvin.cache.headers) 9 | 10 | # Installation (NuGet) 11 | ````powershell 12 | Install-Package Marvin.Cache.Headers 13 | ```` 14 | 15 | # Usage 16 | 17 | First, register the services with ASP.NET Core's dependency injection container (in the ConfigureServices method on the Startup class) 18 | 19 | ````csharp 20 | services.AddHttpCacheHeaders(); 21 | ```` 22 | 23 | Then, add the middleware to the request pipeline. Starting with version 6.0, the middleware MUST be added between UseRouting() and UseEndpoints(). 24 | 25 | ````csharp 26 | app.UseRouting(); 27 | 28 | app.UseHttpCacheHeaders(); 29 | 30 | app.UseEndpoints(...); 31 | ```` 32 | 33 | # Configuring Options 34 | 35 | The middleware allows customization of how headers are generated. The AddHttpCacheHeaders() method has parameters for configuring options related to expiration, validation and middleware. 36 | 37 | For example, this code will set the max-age directive to 600 seconds, add the must-revalidate directive and ignore header generation for all responses with status code 500. 38 | 39 | ````csharp 40 | services.AddHttpCacheHeaders( 41 | expirationModelOptions => 42 | { 43 | expirationModelOptions.MaxAge = 600; 44 | }, 45 | validationModelOptions => 46 | { 47 | validationModelOptions.MustRevalidate = true; 48 | }, 49 | middlewareOptions => 50 | { 51 | middlewareOptions.IgnoreStatusCodes = new[] { 500 }; 52 | }); 53 | ```` 54 | 55 | There are some predefined collections with status codes you can use when you want to ignore: 56 | - all server errors `HttpStatusCodes.ServerErrors` 57 | - all client errors `HttpStatusCodes.ClientErrors` 58 | - all errors `HttpStatusCodes.AllErrors` 59 | 60 | # Action (Resource) and Controller-level Header Configuration 61 | 62 | For anything but the simplest of cases having one global cache policy isn't sufficient: configuration at level of each resource (action/controller) is required. For those cases, use the HttpCacheExpiration and/or HttpCacheValidation attributes at action or controller level. 63 | 64 | ````csharp 65 | [HttpGet] 66 | [HttpCacheExpiration(CacheLocation = CacheLocation.Public, MaxAge = 99999)] 67 | [HttpCacheValidation(MustRevalidate = true)] 68 | public IEnumerable Get() 69 | { 70 | return new[] { "value1", "value2" }; 71 | } 72 | ``` 73 | Both override the global options. Action-level configuration overrides controller-level configuration. 74 | 75 | # Ignoring Cache Headers / eTag Generation 76 | 77 | You don't always want tags / headers to be generated for all resources (e.g.: for a large file). You can ignore generation by applying the HttpCacheIgnore attribute at controller or action level. 78 | 79 | ````csharp 80 | [HttpGet] 81 | [HttpCacheIgnore] 82 | public IEnumerable Get() 83 | { 84 | return new[] { "value1", "value2" }; 85 | } 86 | ```` 87 | 88 | If you want to globally disable automatic header generation, you can do so by setting DisableGlobalHeaderGeneration on the middleware options to true. 89 | 90 | ````csharp 91 | services.AddHttpCacheHeaders( 92 | middlewareOptionsAction: middlewareOptions => 93 | { 94 | middlewareOptions.DisableGlobalHeaderGeneration = true; 95 | }); 96 | ```` 97 | 98 | # Marking for Invalidation 99 | Cache invalidation essentially means wiping a response from the cache because you know it isn't the correct version anymore. Caches often partially automate this (a response can be invalidated when it becomes stale, for example) and/or expose an API to manually invalidate items. 100 | 101 | The same goes for the cache headers middleware, which holds a store of records with previously generated cache headers & tags. Replacement of store key records (/invalidation) is mostly automatic. Say you're interacting with values/1. First time the backend is hit and you get back an eTag in the response headers. Next request you send is again a GET request with the "If-None-Match"-header set to the eTag: the backend won't be hit. Then, you send a PUT request to values/1, which potentially results in a change; if you send a GET request now, the backend will be hit again. 102 | 103 | However: if you're updating/changing resources by using an out of band mechanism (eg: a backend process that changes the data in your database, or a resource gets updated that has an update of related resources as a side effect), this process can't be automated. 104 | 105 | Take a list of employees as an example. If a PUT statement is sent to one "employees" resource, then that one "employees" resource will get a new Etag. Yet: if you're sending a PUT request to one specific employee ("employees/1", "employees/2", ...), this might have the effect that the "employees" resource has also changed: if the employee you just updated is one of the employees in the returned employees list when fetching the "employees" resource, the "employees" resource is out of date. Same goes for deleting or creating an employee: that, too, might have an effect on the "employees" resource. 106 | 107 | To support this scenario the cache headers middleware allows marking an item for invalidation. When doing that, the related item will be removed from the internal store, meaning that for subsequent requests a stored item will not be found. 108 | 109 | To use this, inject an IValidatorValueInvalidator and call MarkForInvalidation on it, passing through the key(s) of the item(s) you want to be removed. You can additionally inject an IStoreKeyAccessor, which contains methods that make it easy to find one or more keys from (part of) a URI. 110 | 111 | # Extensibility 112 | 113 | The middleware is very extensible. If you have a look at the AddHttpCacheHeaders method you'll notice it allows injecting custom implementations of 114 | IValidatorValueStore, IStoreKeyGenerator, IETagGenerator and/or IDateParser (via actions). 115 | 116 | ## IValidatorValueStore 117 | 118 | A validator value store stores validator values. A validator value is used by the cache validation model when checking if a cached item is still valid. It contains ETag and LastModified properties. The default IValidatorValueStore implementation (InMemoryValidatorValueStore) is an in-memory store that stores items in a ConcurrentDictionary. 119 | 120 | ````csharp 121 | /// 122 | /// Contract for a store for validator values. Each item is stored with a as key``` 123 | /// and a as value (consisting of an ETag and Last-Modified date). 124 | /// 125 | public interface IValidatorValueStore 126 | { 127 | /// 128 | /// Get a value from the store. 129 | /// 130 | /// The of the value to get. 131 | /// 132 | Task GetAsync(StoreKey key); 133 | /// 134 | /// Set a value in the store. 135 | /// 136 | /// The of the value to store. 137 | /// The to store. 138 | /// 139 | Task SetAsync(StoreKey key, ValidatorValue validatorValue); 140 | 141 | /// 142 | /// Find one or more keys that contain the inputted valueToMatch 143 | /// 144 | /// The value to match as part of the key 145 | /// Ignore case when matching 146 | /// 147 | Task> FindStoreKeysByKeyPartAsync(string valueToMatch, bool ignoreCase); 148 | } 149 | ```` 150 | 151 | BREAKING CHANGE from v7 onwards: the FindStoreKeysByKeyPartAsync methods return an IAsyncEnumerable to enable async streaming of results. 152 | 153 | ````csharp 154 | /// 155 | /// Contract for a store for validator values. Each item is stored with a as key``` 156 | /// and a as value (consisting of an ETag and Last-Modified date). 157 | /// 158 | public interface IValidatorValueStore 159 | { 160 | /// 161 | /// Get a value from the store. 162 | /// 163 | /// The of the value to get. 164 | /// 165 | Task GetAsync(StoreKey key); 166 | /// 167 | /// Set a value in the store. 168 | /// 169 | /// The of the value to store. 170 | /// The to store. 171 | /// 172 | Task SetAsync(StoreKey key, ValidatorValue validatorValue); 173 | 174 | /// 175 | /// Find one or more keys that contain the inputted valueToMatch 176 | /// 177 | /// The value to match as part of the key 178 | /// Ignore case when matching 179 | /// 180 | IAsyncEnumerable FindStoreKeysByKeyPartAsync(string valueToMatch, bool ignoreCase); 181 | } 182 | ```` 183 | 184 | ## IStoreKeyGenerator 185 | The StoreKey, as used by the IValidatorValueStore as key, can be customized as well. To do so, implement the IStoreKeyGenerator interface. The default implementation (DefaultStoreKeyGenerator) generates a key from the request path, request query string and request header values (taking VaryBy into account). Through StoreKeyContext you can access all applicable values that can be useful for generating such a key. 186 | 187 | ````csharp 188 | /// 189 | /// Contract for a key generator, used to generate a ``` 190 | /// 191 | public interface IStoreKeyGenerator 192 | { 193 | /// 194 | /// Generate a key for storing a in a . 195 | /// 196 | /// The . 197 | /// 198 | Task GenerateStoreKey( 199 | StoreKeyContext context); 200 | } 201 | ```` 202 | 203 | ## IETagGenerator 204 | 205 | You can inject an IETagGenerator-implementing class to modify how ETags are generated (ETags are part of a ValidatorValue). The default implementation (DefaultStrongETagGenerator) generates strong Etags from the request key + response body (MD5 hash from combined bytes). 206 | 207 | ````csharp 208 | /// 209 | /// Contract for an E-Tag Generator, used to generate the unique weak or strong E-Tags for cache items 210 | /// 211 | public interface IETagGenerator 212 | { 213 | Task GenerateETag( 214 | StoreKey storeKey, 215 | string responseBodyContent); 216 | } 217 | ```` 218 | ## IETagInjector 219 | 220 | You can inject an IETagInjector-implementing class to modify how, where and when ETags are provided. The default implementation (DefaultETagInjector) injects the DefaultETag generator using the response body on the http context as the string along with the provided request key. 221 | 222 | ````csharp 223 | /// 224 | /// Contract for a ETagInjector, which can be used to inject custom eTags for resources 225 | /// of which may be injected in the request pipeline (eg: based on existing calculated eTag on resource and store key) 226 | /// 227 | /// 228 | /// This injector will wrap the to allow for eTag source to be swapped out 229 | /// based on the (rather than extend the interface of to 230 | /// to extended including the 231 | /// 232 | public interface IETagInjector 233 | { 234 | Task RetrieveETag(ETagContext eTagContext); 235 | } 236 | ```` 237 | 238 | ## ILastModifiedInjector 239 | 240 | You can inject an ILastModifiedInjector-implementing class to modify how LastModified values are provided. The default implementation (DefaultLastModifiedInjector) injects the current UTC. 241 | 242 | ````csharp 243 | /// 244 | /// Contract for a LastModifiedInjector, which can be used to inject custom last modified dates for resources 245 | /// of which you know when they were last modified (eg: a DB timestamp, custom logic, ...) 246 | /// 247 | public interface ILastModifiedInjector 248 | { 249 | Task CalculateLastModified( 250 | ResourceContext context); 251 | } 252 | ```` 253 | 254 | ## IDateParser 255 | 256 | Through IDateParser you can inject a custom date parser in case you want to override the default way dates are stringified. The default implementation (DefaultDateParser) uses the RFC1123 pattern (https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx). 257 | 258 | ````csharp 259 | /// 260 | /// Contract for a date parser, used to parse Last-Modified, Expires, If-Modified-Since and If-Unmodified-Since headers. 261 | /// 262 | public interface IDateParser 263 | { 264 | Task LastModifiedToString(DateTimeOffset lastModified); 265 | 266 | Task ExpiresToString(DateTimeOffset lastModified); 267 | 268 | Task IfModifiedSinceToDateTimeOffset(string ifModifiedSince); 269 | 270 | Task IfUnmodifiedSinceToDateTimeOffset(string ifUnmodifiedSince); 271 | } 272 | ```` 273 | 274 | ## IValidatorValueInvalidator 275 | 276 | An IValidatorValueInvalidator-implenting class is responsible for marking items for invalidation. 277 | 278 | ````csharp 279 | /// 280 | /// Contract for the 281 | /// 282 | public interface IValidatorValueInvalidator 283 | { 284 | /// 285 | /// Get the list of of items marked for invalidation 286 | /// 287 | List KeysMarkedForInvalidation { get; } 288 | 289 | /// 290 | /// Mark an item stored with a for invalidation 291 | /// 292 | /// The 293 | /// 294 | Task MarkForInvalidation(StoreKey storeKey); 295 | 296 | /// 297 | /// Mark a set of items for invlidation by their collection of 298 | /// 299 | /// The collection of 300 | /// 301 | Task MarkForInvalidation(IEnumerable storeKeys); 302 | } 303 | ```` 304 | 305 | ## IStoreKeyAccessor 306 | 307 | The IStoreKeyAccessor contains helper methods for getting keys from parts of a URI. Override this if you're not storing items with their default keys. 308 | 309 | ````csharp 310 | /// 311 | /// Contract for finding (a) (s) 312 | /// 313 | public interface IStoreKeyAccessor 314 | { 315 | /// 316 | /// Find a by part of the key 317 | /// 318 | /// The value to match as part of the key 319 | /// 320 | Task> FindByKeyPart(string valueToMatch); 321 | 322 | /// 323 | /// Find a of which the current resource path is part of the key 324 | /// 325 | /// 326 | Task> FindByCurrentResourcePath(); 327 | } 328 | ```` 329 | 330 | BREAKING CHANGE from v7 onwards: the methods return an IAsyncEnumerable to enable async streaming of results. 331 | 332 | ````csharp 333 | /// 334 | /// Contract for finding (a) (s) 335 | /// 336 | public interface IStoreKeyAccessor 337 | { 338 | /// 339 | /// Find a by part of the key 340 | /// 341 | /// The value to match as part of the key 342 | /// 343 | IAsyncEnumerable FindByKeyPart(string valueToMatch); 344 | 345 | /// 346 | /// Find a of which the current resource path is part of the key 347 | /// 348 | /// 349 | IAsyncEnumerable FindByCurrentResourcePath(); 350 | } 351 | ```` 352 | 353 | -------------------------------------------------------------------------------- /build.bat: -------------------------------------------------------------------------------- 1 | @echo Off 2 | set config=%1 3 | if "%config%" == "" ( 4 | set config=Release 5 | ) 6 | 7 | set version= 8 | if not "%BuildCounter%" == "" ( 9 | set version=--version-suffix ci-%BuildCounter% 10 | ) 11 | 12 | REM Restore 13 | call dotnet restore 14 | if not "%errorlevel%"=="0" goto failure 15 | 16 | REM Build 17 | REM - Option 1: Run dotnet build for every source folder in the project 18 | REM e.g. call dotnet build --configuration %config% 19 | REM - Option 2: Let msbuild handle things and build the solution 20 | call "%msbuild%" Marvin.Cache.Headers.sln /p:Configuration="%config%" /m /v:M /fl /flp:LogFile=msbuild.log;Verbosity=Normal /nr:false 21 | REM call dotnet build --configuration %config% 22 | if not "%errorlevel%"=="0" goto failure 23 | 24 | REM Unit tests 25 | rem call dotnet test test\Marvin.Cache.Headers.Test --configuration %config% 26 | rem if not "%errorlevel%"=="0" goto failure 27 | 28 | REM Package 29 | mkdir %cd%\artifacts 30 | call dotnet pack src\Marvin.Cache.Headers --configuration %config% %version% --output artifacts 31 | if not "%errorlevel%"=="0" goto failure 32 | 33 | :success 34 | exit 0 35 | 36 | :failure 37 | exit -1 38 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "8.0.100-rc.1.23455.8", 4 | "rollForward": "latestMinor", 5 | "allowPrerelease": true 6 | } 7 | } -------------------------------------------------------------------------------- /sample/Marvin.Cache.Headers.Sample/Controllers/MoreValuesController.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace Marvin.Cache.Headers.Sample.Controllers; 7 | 8 | [Route("api/morevalues")] 9 | [HttpCacheExpiration(CacheLocation = CacheLocation.Public, MaxAge = 11111)] 10 | [HttpCacheValidation(MustRevalidate = false)] 11 | public class MoreValuesController : Controller 12 | { 13 | [HttpGet] 14 | [HttpCacheExpiration(CacheLocation = CacheLocation.Private, MaxAge = 99999)] 15 | [HttpCacheValidation(MustRevalidate = true, Vary = new[] { "Test" })] 16 | public IEnumerable Get() 17 | { 18 | return new[] { "anothervalue1", "anothervalue2" }; 19 | } 20 | 21 | // no expiration/validation attributes: must take controller level config 22 | [HttpGet("{id}")] 23 | public string GetOne(string id) 24 | { 25 | return "somevalue"; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /sample/Marvin.Cache.Headers.Sample/Controllers/ValuesController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Marvin.Cache.Headers.Sample.Controllers; 4 | 5 | 6 | [Route("api/values")] 7 | public class ValuesController : Controller 8 | { 9 | // GET api/values 10 | [HttpGet] 11 | [HttpCacheExpiration(CacheLocation = CacheLocation.Public, MaxAge = 99999)] 12 | [HttpCacheValidation(MustRevalidate = true)] 13 | public IEnumerable Get() 14 | { 15 | return new[] { "value1", "value2" }; 16 | } 17 | 18 | // GET api/values/5 19 | [HttpGet("{id}")] 20 | [HttpCacheExpiration(CacheLocation = CacheLocation.Private, MaxAge = 1337)] 21 | [HttpCacheValidation] 22 | public string Get(int id) 23 | { 24 | return "value"; 25 | } 26 | 27 | // POST api/values 28 | [HttpPost] 29 | public void Post([FromBody] string value) 30 | { 31 | } 32 | 33 | // PUT api/values/5 34 | [HttpPut("{id}")] 35 | public void Put(int id, [FromBody] string value) 36 | { 37 | } 38 | 39 | // DELETE api/values/5 40 | [HttpDelete("{id}")] 41 | public void Delete(int id) 42 | { 43 | } 44 | } -------------------------------------------------------------------------------- /sample/Marvin.Cache.Headers.Sample/Marvin.Cache.Headers.Sample.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0;net7.0;net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /sample/Marvin.Cache.Headers.Sample/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = WebApplication.CreateBuilder(args); 2 | 3 | // Add services to the container. 4 | 5 | builder.Services.AddControllers(); 6 | 7 | // Add HttpCacheHeaders services with custom options 8 | builder.Services.AddHttpCacheHeaders( 9 | expirationModelOptions => 10 | { 11 | expirationModelOptions.MaxAge = 600; 12 | expirationModelOptions.SharedMaxAge = 300; 13 | }, 14 | validationModelOptions => 15 | { 16 | validationModelOptions.MustRevalidate = true; 17 | validationModelOptions.ProxyRevalidate = true; 18 | }); 19 | 20 | var app = builder.Build(); 21 | 22 | // Configure the HTTP request pipeline. 23 | 24 | app.UseAuthorization(); 25 | 26 | app.UseHttpCacheHeaders(); 27 | 28 | app.MapControllers(); 29 | 30 | app.Run(); 31 | 32 | public partial class Program 33 | { 34 | } -------------------------------------------------------------------------------- /sample/Marvin.Cache.Headers.Sample/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:53998", 8 | "sslPort": 0 9 | } 10 | }, 11 | "profiles": { 12 | "Marvin.Cache.Headers.Sample.NET6": { 13 | "commandName": "Project", 14 | "launchBrowser": true, 15 | "launchUrl": "api/values", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | }, 19 | "applicationUrl": "http://localhost:5246", 20 | "dotnetRunMessages": true 21 | }, 22 | "IIS Express": { 23 | "commandName": "IISExpress", 24 | "launchBrowser": true, 25 | "launchUrl": "weatherforecast", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /sample/Marvin.Cache.Headers.Sample/SampleRequests.http: -------------------------------------------------------------------------------- 1 | @HostAddressRoot = http://localhost:5246 2 | 3 | GET {{HostAddressRoot}}/api/morevalues 4 | Accept: application/json 5 | 6 | ### 7 | 8 | GET {{HostAddressRoot}}/api/values 9 | Accept: application/json 10 | 11 | ### 12 | 13 | GET {{HostAddressRoot}}/api/values/1 14 | Accept: application/json -------------------------------------------------------------------------------- /sample/Marvin.Cache.Headers.Sample/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers.DistributedStore.Redis/Extensions/ServicesExtensions.cs: -------------------------------------------------------------------------------- 1 | using Marvin.Cache.Headers.DistributedStore.Interfaces; 2 | using Marvin.Cache.Headers.DistributedStore.Redis.Options; 3 | using Marvin.Cache.Headers.DistributedStore.Redis.Stores; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using System; 6 | 7 | namespace Marvin.Cache.Headers.DistributedStore.Redis.Extensions; 8 | 9 | /// 10 | /// Extension methods for the HttpCache middleware (on IServiceCollection) 11 | /// 12 | public static class ServicesExtensions 13 | { 14 | /// 15 | /// Add redis key retriever services to the service collection. 16 | /// 17 | /// The to add services to. 18 | /// Action to provide custom 19 | public static IServiceCollection AddRedisKeyRetriever(this IServiceCollection services, Action redisDistributedCacheKeyRetrieverOptionsAction) 20 | { 21 | if (services == null) 22 | { 23 | throw new ArgumentNullException(nameof(services)); 24 | } 25 | 26 | if (redisDistributedCacheKeyRetrieverOptionsAction == null) 27 | { 28 | throw new ArgumentNullException(nameof(redisDistributedCacheKeyRetrieverOptionsAction)); 29 | } 30 | 31 | services.Configure(redisDistributedCacheKeyRetrieverOptionsAction); 32 | services.Add(ServiceDescriptor.Singleton(typeof(IRetrieveDistributedCacheKeys), typeof(RedisDistributedCacheKeyRetriever))); 33 | return services; 34 | } 35 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers.DistributedStore.Redis/Marvin.Cache.Headers.DistributedStore.Redis.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0;net7.0;net8.0 5 | Library 6 | Marvin.Cache.Headers.DistributedStore.Redis 7 | Marvin.Cache.Headers.DistributedStore.Redis 8 | false 9 | false 10 | false 11 | 1.1.0 12 | Provides an implementation of the IValidatorValueStore for use with the Marvin.Cache.Headers NuGet package that supports Redis. 13 | True 14 | Sean Farrow, Kevin Dockx 15 | https://github.com/KevinDockx/HttpCacheHeaders 16 | Threading issue fix, see https://github.com/KevinDockx/HttpCacheHeaders/milestone/17 17 | 18 | MIT 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers.DistributedStore.Redis/Options/RedisDistributedCacheKeyRetrieverOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Marvin.Cache.Headers.DistributedStore.Redis.Options; 2 | 3 | public class RedisDistributedCacheKeyRetrieverOptions 4 | { 5 | /// 6 | /// The Redis database you wish keys to be retrieved from. 7 | /// 8 | public int Database { get; set; } 9 | 10 | /// 11 | /// Indicates whether only replicas should be used when choosing the servers to iterate looking for keys in the selected database. 12 | /// 13 | public bool OnlyUseReplicas { get; set; } 14 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers.DistributedStore.Redis/Stores/RedisDistributedCacheKeyRetriever.cs: -------------------------------------------------------------------------------- 1 | using Marvin.Cache.Headers.DistributedStore.Redis.Options; 2 | using Microsoft.Extensions.Options; 3 | using StackExchange.Redis; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using Marvin.Cache.Headers.DistributedStore.Interfaces; 8 | 9 | namespace Marvin.Cache.Headers.DistributedStore.Redis.Stores; 10 | 11 | public class RedisDistributedCacheKeyRetriever : IRetrieveDistributedCacheKeys 12 | { 13 | private readonly IConnectionMultiplexer _connectionMultiplexer; 14 | private readonly RedisDistributedCacheKeyRetrieverOptions _redisDistributedCacheKeyRetrieverOptions; 15 | 16 | public RedisDistributedCacheKeyRetriever(IConnectionMultiplexer connectionMultiplexer, IOptions redisDistributedCacheKeyRetrieverOptions) 17 | { 18 | _connectionMultiplexer = connectionMultiplexer ?? throw new ArgumentNullException(nameof(connectionMultiplexer)); 19 | if (redisDistributedCacheKeyRetrieverOptions == null) 20 | { 21 | throw new ArgumentNullException(nameof(redisDistributedCacheKeyRetrieverOptions)); 22 | } 23 | 24 | if (redisDistributedCacheKeyRetrieverOptions.Value == null) 25 | { 26 | throw new ArgumentNullException(nameof(redisDistributedCacheKeyRetrieverOptions.Value)); 27 | } 28 | 29 | _redisDistributedCacheKeyRetrieverOptions = redisDistributedCacheKeyRetrieverOptions.Value; 30 | } 31 | 32 | public async IAsyncEnumerable FindStoreKeysByKeyPartAsync(string valueToMatch, bool ignoreCase =true) 33 | { 34 | if (valueToMatch == null) 35 | { 36 | throw new ArgumentNullException(nameof(valueToMatch)); 37 | } 38 | else if (valueToMatch.Length == 0) 39 | { 40 | throw new ArgumentException(nameof(valueToMatch)); 41 | } 42 | 43 | var servers = _connectionMultiplexer.GetServers(); 44 | if (_redisDistributedCacheKeyRetrieverOptions.OnlyUseReplicas) 45 | { 46 | servers = servers.Where(x => x.IsReplica).ToArray(); 47 | } 48 | 49 | RedisValue valueToMatchWithRedisPattern = ignoreCase ? $"pattern: {valueToMatch.ToLower()}" : $"pattern: {valueToMatch}"; 50 | 51 | foreach (var server in servers) 52 | { 53 | var keys = server.KeysAsync(_redisDistributedCacheKeyRetrieverOptions.Database, valueToMatchWithRedisPattern); 54 | 55 | await foreach (var key in keys) 56 | { 57 | yield return key; 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers.DistributedStore/Interfaces/IRetrieveDistributedCacheKeys.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Marvin.Cache.Headers.DistributedStore.Interfaces; 4 | 5 | public interface IRetrieveDistributedCacheKeys 6 | { 7 | IAsyncEnumerable FindStoreKeysByKeyPartAsync(string valueToMatch, bool ignoreCase =true); 8 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers.DistributedStore/Marvin.Cache.Headers.DistributedStore.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0;net7.0;net8.0 5 | Library 6 | Marvin.Cache.Headers.DistributedStore 7 | Marvin.Cache.Headers.DistributedStore 8 | false 9 | false 10 | false 11 | 1.1.0 12 | Provides an implementation of the IValidatorValueStore for use with the Marvin.Cache.Headers NuGet package that allows ValidatorValue instances to be stored in a distributed cache. 13 | True 14 | Sean Farrow, Kevin Dockx 15 | https://github.com/KevinDockx/HttpCacheHeaders 16 | Threading issue fix, see https://github.com/KevinDockx/HttpCacheHeaders/milestone/17 17 | 18 | MIT 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers.DistributedStore/Stores/DistributedCacheValidatorValueStore.cs: -------------------------------------------------------------------------------- 1 | using Marvin.Cache.Headers.DistributedStore.Interfaces; 2 | using Marvin.Cache.Headers.Interfaces; 3 | using Microsoft.Extensions.Caching.Distributed; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Globalization; 7 | using System.Text; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | namespace Marvin.Cache.Headers.DistributedStore.Stores; 12 | 13 | public class DistributedCacheValidatorValueStore : IValidatorValueStore 14 | { 15 | private readonly IDistributedCache _distributedCache; 16 | private readonly IRetrieveDistributedCacheKeys _distributedCacheKeyRetriever; 17 | private readonly IStoreKeySerializer _storeKeySerializer; 18 | 19 | public DistributedCacheValidatorValueStore(IDistributedCache distributedCache, IRetrieveDistributedCacheKeys distributedCacheKeyRetriever, IStoreKeySerializer storeKeySerializer =null) 20 | { 21 | _distributedCache = distributedCache ?? throw new ArgumentNullException(nameof(distributedCache)); 22 | _distributedCacheKeyRetriever = distributedCacheKeyRetriever ?? throw new ArgumentNullException(nameof(distributedCacheKeyRetriever)); 23 | _storeKeySerializer = storeKeySerializer ?? throw new ArgumentNullException(nameof(storeKeySerializer)); 24 | } 25 | 26 | public async Task GetAsync(StoreKey key) 27 | { 28 | if (key == null) 29 | { 30 | throw new ArgumentNullException(nameof(key)); 31 | } 32 | 33 | var serializedKey = _storeKeySerializer.SerializeStoreKey(key); 34 | var result = await _distributedCache.GetAsync(serializedKey, CancellationToken.None); 35 | return result == null ? null : CreateValidatorValue(result); 36 | } 37 | 38 | private static ValidatorValue CreateValidatorValue(byte[] validatorValueBytes) 39 | { 40 | var validatorValueUtf8String = Encoding.UTF8.GetString(validatorValueBytes); 41 | var validatorValueETagTypeString = validatorValueUtf8String[..validatorValueUtf8String.IndexOf(" ", StringComparison.InvariantCulture)]; 42 | var validatorValueETagType = Enum.Parse(validatorValueETagTypeString); 43 | var validatorValueETagValueWithLastModifiedDate = validatorValueUtf8String[(validatorValueETagTypeString.Length+7)..]; 44 | var lastModifiedIndex = validatorValueETagValueWithLastModifiedDate.LastIndexOf("LastModified=", StringComparison.InvariantCulture); 45 | var validatorValueETagValueWithQuotes = validatorValueETagValueWithLastModifiedDate.Substring(0, lastModifiedIndex-1); 46 | var validatorValueETagValue = validatorValueETagValueWithQuotes.Substring(1, validatorValueETagValueWithQuotes.Length - 2); //We can't use String.Replace here as we may have embedded quotes. 47 | var lastModifiedDateString = validatorValueETagValueWithLastModifiedDate.Substring(validatorValueETagValueWithLastModifiedDate.LastIndexOf("=", StringComparison.InvariantCulture)+1); 48 | DateTimeOffset parsedDateTime =DateTimeOffset.Parse(lastModifiedDateString, CultureInfo.InvariantCulture); 49 | return new ValidatorValue(new ETag(validatorValueETagType, validatorValueETagValue), parsedDateTime); 50 | } 51 | 52 | public Task SetAsync(StoreKey key, ValidatorValue validatorValue) 53 | { 54 | if (key == null) 55 | { 56 | throw new ArgumentNullException(nameof(key)); 57 | } 58 | 59 | if (validatorValue == null) 60 | { 61 | throw new ArgumentNullException(nameof(validatorValue)); 62 | } 63 | 64 | var keyJson = _storeKeySerializer.SerializeStoreKey(key); 65 | var eTagString = $"{validatorValue.ETag.ETagType} Value=\"{validatorValue.ETag.Value}\" LastModified={validatorValue.LastModified.ToString(CultureInfo.InvariantCulture)}"; 66 | var eTagBytes = Encoding.UTF8.GetBytes(eTagString); 67 | return _distributedCache.SetAsync(keyJson, eTagBytes); 68 | } 69 | 70 | public async Task RemoveAsync(StoreKey key) 71 | { 72 | if (key == null) 73 | { 74 | throw new ArgumentNullException(nameof(key)); 75 | } 76 | 77 | var keyJson = _storeKeySerializer.SerializeStoreKey(key); 78 | var cacheEntry = await _distributedCache.GetAsync(keyJson, CancellationToken.None); 79 | if (cacheEntry is null) 80 | { 81 | return false; 82 | } 83 | 84 | await _distributedCache.RemoveAsync(keyJson); 85 | return true; 86 | } 87 | 88 | public async IAsyncEnumerable FindStoreKeysByKeyPartAsync(string valueToMatch, bool ignoreCase) 89 | { 90 | if (valueToMatch == null) 91 | { 92 | throw new ArgumentNullException(nameof(valueToMatch)); 93 | } 94 | else if (valueToMatch.Length is 0) 95 | { 96 | throw new ArgumentException(); 97 | } 98 | 99 | var foundKeys = _distributedCacheKeyRetriever.FindStoreKeysByKeyPartAsync(valueToMatch, ignoreCase); 100 | await foreach (var foundKey in foundKeys.ConfigureAwait(false)) 101 | { 102 | var k = _storeKeySerializer.DeserializeStoreKey(foundKey); 103 | yield return k; 104 | } 105 | } 106 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Attributes/HttpCacheExpirationAttribute.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using System.Threading.Tasks; 6 | using Marvin.Cache.Headers.Extensions; 7 | using Marvin.Cache.Headers.Interfaces; 8 | using Microsoft.AspNetCore.Mvc.Filters; 9 | 10 | 11 | namespace Marvin.Cache.Headers; 12 | 13 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] 14 | public class HttpCacheExpirationAttribute : Attribute, IAsyncResourceFilter, IModelOptionsProvider 15 | { 16 | readonly Lazy _expirationModelOptions; 17 | 18 | /// 19 | /// Maximum age, in seconds, after which a response expires. Has an effect on Expires & on the max-age directive 20 | /// of the Cache-Control header. 21 | /// 22 | /// Defaults to 60. 23 | /// 24 | public int MaxAge { get; set; } = 60; 25 | 26 | /// 27 | /// Maximum age, in seconds, after which a response expires for shared caches. If included, 28 | /// a shared cache should use this value rather than the max-age value. (s-maxage directive) 29 | /// 30 | /// Not set by default. 31 | /// 32 | public int? SharedMaxAge { get; set; } 33 | 34 | /// 35 | /// The location where a response can be cached. Public means it can be cached by both 36 | /// public (shared) and private (client) caches. Private means it can only be cached by 37 | /// private (client) caches. (public or private directive) 38 | /// 39 | /// Defaults to public. 40 | /// 41 | public CacheLocation CacheLocation { get; set; } = CacheLocation.Public; 42 | 43 | /// 44 | /// When true, the no-store directive is included in the Cache-Control header. 45 | /// When this directive is included, a cache must not store any part of the message, 46 | /// mostly for confidentiality reasons. 47 | /// 48 | /// Defaults to false. 49 | /// 50 | public bool NoStore { get; set; } = false; 51 | 52 | /// 53 | /// When true, the no-transform directive is included in the Cache-Control header. 54 | /// When this directive is included, a cache must not convert the media type of the 55 | /// response body. 56 | /// 57 | /// Defaults to false. 58 | /// 59 | public bool NoTransform { get; set; } = false; 60 | 61 | public HttpCacheExpirationAttribute() 62 | { 63 | _expirationModelOptions = new Lazy(() => new ExpirationModelOptions 64 | { 65 | MaxAge = MaxAge, 66 | SharedMaxAge = SharedMaxAge, 67 | CacheLocation = CacheLocation, 68 | NoStore = NoStore, 69 | NoTransform = NoTransform 70 | }); 71 | } 72 | 73 | public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next) 74 | { 75 | await next(); 76 | 77 | // add options to Items dictionary. If the dictionary already contains a value, don't overwrite it - this 78 | // means the value was already set at method level and the current class level attribute is trying 79 | // to overwrite it. Method (action) should win over class (controller). 80 | 81 | if (!context.HttpContext.Items.ContainsKey(HttpContextExtensions.ContextItemsExpirationModelOptions)) 82 | { 83 | context.HttpContext.Items[HttpContextExtensions.ContextItemsExpirationModelOptions] = GetModelOptions(); 84 | } 85 | } 86 | 87 | public IModelOptions GetModelOptions() 88 | { 89 | return _expirationModelOptions.Value; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Attributes/HttpCacheIgnoreAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Marvin.Cache.Headers; 4 | 5 | 6 | /// 7 | /// Mark your action with this attribute to ensure the HttpCache middleware fully ignores it, for example: to avoid 8 | /// ETags being generated for large file output. 9 | /// 10 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] 11 | public class HttpCacheIgnoreAttribute : Attribute 12 | { 13 | } 14 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Attributes/HttpCacheValidationAttribute.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using System.Threading.Tasks; 6 | using Marvin.Cache.Headers.Extensions; 7 | using Marvin.Cache.Headers.Interfaces; 8 | using Microsoft.AspNetCore.Mvc.Filters; 9 | 10 | namespace Marvin.Cache.Headers; 11 | 12 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] 13 | public class HttpCacheValidationAttribute : Attribute, IAsyncResourceFilter, IModelOptionsProvider 14 | { 15 | internal readonly Lazy _validationModelOptions; 16 | 17 | /// 18 | /// A case-insensitive list of headers from the request to take into account as differentiator 19 | /// between requests (eg: for generating ETags) 20 | /// 21 | /// Defaults to Accept, Accept-Language, Accept-Encoding. * indicates all request headers can be taken into account. 22 | /// 23 | public string[] Vary { get; set; } 24 | = new string[] { "Accept", "Accept-Language", "Accept-Encoding" }; 25 | 26 | /// 27 | /// Indicates that all request headers are taken into account as differentiator. 28 | /// When set to true, this is the same as Vary *. The Vary list will thus be ignored. 29 | /// 30 | /// Note that a Vary field value of "*" implies that a cache cannot determine 31 | /// from the request headers of a subsequent request whether this response is 32 | /// the appropriate representation. This should thus only be set to true for 33 | /// exceptional cases. 34 | /// 35 | /// Defaults to false. 36 | /// 37 | public bool VaryByAll { get; set; } = false; 38 | 39 | /// 40 | /// When true, the no-cache directive is added to the Cache-Control header. 41 | /// This indicates to a cache that the response should not be used for subsequent requests 42 | /// without successful revalidation with the origin server. 43 | /// 44 | /// Defaults to false. 45 | /// 46 | public bool NoCache { get; set; } = false; 47 | 48 | /// 49 | /// When true, the must-revalidate directive is added to the Cache-Control header. 50 | /// This tells a cache that if a response becomes stale, ie: it's expired, revalidation has to happen. 51 | /// By adding this directive, we can force revalidation by the cache even if the client 52 | /// has decided that stale responses are for a specified amount of time (which a client can 53 | /// do by setting the max-stale directive). 54 | /// 55 | /// Defaults to false. 56 | /// 57 | public bool MustRevalidate { get; set; } = false; 58 | 59 | /// 60 | /// When true, the proxy-revalidate directive is added to the Cache-Control header. 61 | /// Exactly the same as must-revalidate, but only for shared caches. 62 | /// So: this tells a shared cache that if a response becomes stale, ie: it's expired, revalidation has to happen. 63 | /// By adding this directive, we can force revalidation by the cache even if the client 64 | /// has decided that stale responses are for a specified amount of time (which a client can 65 | /// do by setting the max-stale directive). 66 | /// 67 | /// Defaults to false. 68 | /// 69 | public bool ProxyRevalidate { get; set; } = false; 70 | 71 | public HttpCacheValidationAttribute() 72 | { 73 | _validationModelOptions = new Lazy(() => new ValidationModelOptions 74 | { 75 | Vary = Vary, 76 | VaryByAll = VaryByAll, 77 | NoCache = NoCache, 78 | MustRevalidate = MustRevalidate, 79 | ProxyRevalidate = ProxyRevalidate 80 | }); 81 | } 82 | 83 | public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next) 84 | { 85 | await next(); 86 | 87 | // add options to Items dictionary. If the dictionary already contains a value, don't overwrite it - this 88 | // means the value was already set at method level and the current class level attribute is trying 89 | // to overwrite it. Method (action) should win over class (controller). 90 | 91 | if (!context.HttpContext.Items.ContainsKey(HttpContextExtensions.ContextItemsValidationModelOptions)) 92 | { 93 | context.HttpContext.Items[HttpContextExtensions.ContextItemsValidationModelOptions] = GetModelOptions(); 94 | } 95 | } 96 | 97 | public IModelOptions GetModelOptions() 98 | { 99 | return _validationModelOptions.Value; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/DefaultDateParser.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using System.Globalization; 6 | using System.Threading.Tasks; 7 | using Marvin.Cache.Headers.Interfaces; 8 | 9 | namespace Marvin.Cache.Headers; 10 | 11 | public class DefaultDateParser : IDateParser 12 | { 13 | // r = RFC1123 pattern (https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx) 14 | 15 | public Task LastModifiedToString(DateTimeOffset lastModified) => DateTimeOffsetToString(lastModified); 16 | 17 | public Task ExpiresToString(DateTimeOffset expires) => DateTimeOffsetToString(expires); 18 | 19 | public Task IfModifiedSinceToDateTimeOffset(string ifModifiedSince) => StringToDateTimeOffset(ifModifiedSince); 20 | 21 | public Task IfUnmodifiedSinceToDateTimeOffset(string ifUnmodifiedSince) => StringToDateTimeOffset(ifUnmodifiedSince); 22 | 23 | private static Task DateTimeOffsetToString(DateTimeOffset lastModified) => Task.FromResult(lastModified.ToString("r", CultureInfo.InvariantCulture)); 24 | 25 | private static Task StringToDateTimeOffset(string @string) 26 | { 27 | return Task.FromResult( 28 | DateTimeOffset.TryParseExact( 29 | @string, 30 | "r", 31 | CultureInfo.InvariantCulture.DateTimeFormat, 32 | DateTimeStyles.AdjustToUniversal, 33 | out var parsedIfModifiedSince) 34 | ? parsedIfModifiedSince 35 | : new DateTimeOffset?()); 36 | } 37 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/DefaultETagInjector.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Marvin.Cache.Headers.Interfaces; 5 | using Microsoft.AspNetCore.Http; 6 | 7 | namespace Marvin.Cache.Headers; 8 | 9 | /// 10 | /// Default E-Tag injector generates an eTag each-and-every time based on the resource payload (ie .Response.Body) 11 | /// 12 | public class DefaultETagInjector : IETagInjector 13 | { 14 | private readonly IETagGenerator _eTagGenerator; 15 | 16 | public DefaultETagInjector(IETagGenerator eTagGenerator) 17 | { 18 | _eTagGenerator = eTagGenerator ?? throw new ArgumentNullException(nameof(eTagGenerator)); 19 | } 20 | 21 | public async Task RetrieveETag(ETagContext eTagContext) 22 | { 23 | // get the response bytes 24 | if (eTagContext.HttpContext.Response.Body.CanSeek) 25 | { 26 | eTagContext.HttpContext.Response.Body.Position = 0; 27 | } 28 | 29 | var responseBodyContent = await new StreamReader(eTagContext.HttpContext.Response.Body).ReadToEndAsync(); 30 | 31 | // Calculate the ETag to store in the store. 32 | return await _eTagGenerator.GenerateETag(eTagContext.StoreKey, responseBodyContent); 33 | } 34 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/DefaultLastModifiedInjector.cs: -------------------------------------------------------------------------------- 1 | using Marvin.Cache.Headers.Domain; 2 | using Marvin.Cache.Headers.Interfaces; 3 | using System; 4 | using System.Threading.Tasks; 5 | 6 | namespace Marvin.Cache.Headers; 7 | 8 | public class DefaultLastModifiedInjector : ILastModifiedInjector 9 | { 10 | public Task CalculateLastModified(ResourceContext context) 11 | { 12 | // the default implementation returns the current date without 13 | // milliseconds 14 | var now = DateTimeOffset.UtcNow; 15 | 16 | return Task.FromResult(new DateTimeOffset( 17 | now.Year, 18 | now.Month, 19 | now.Day, 20 | now.Hour, 21 | now.Minute, 22 | now.Second, 23 | now.Offset)); 24 | } 25 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/DefaultStoreKeyGenerator.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Marvin.Cache.Headers.Domain; 5 | using Marvin.Cache.Headers.Interfaces; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Threading.Tasks; 10 | 11 | namespace Marvin.Cache.Headers; 12 | 13 | public class DefaultStoreKeyGenerator : IStoreKeyGenerator 14 | { 15 | public Task GenerateStoreKey(StoreKeyContext context) 16 | { 17 | // generate a key to store the entity tag with in the entity tag store 18 | List requestHeaderValues; 19 | 20 | // get the request headers to take into account (VaryBy) & take 21 | // their values 22 | if (context.VaryByAll) 23 | { 24 | requestHeaderValues = context.HttpRequest 25 | .Headers 26 | .SelectMany(h => h.Value) 27 | .ToList(); 28 | } 29 | else 30 | { 31 | requestHeaderValues = context.HttpRequest 32 | .Headers 33 | .Where(x => context.Vary.Any(h => 34 | h.Equals(x.Key, StringComparison.CurrentCultureIgnoreCase))) 35 | .SelectMany(h => h.Value) 36 | .ToList(); 37 | } 38 | 39 | // get the resource path 40 | var resourcePath = context.HttpRequest.Path.ToString(); 41 | 42 | // get the query string 43 | var queryString = context.HttpRequest.QueryString.ToString(); 44 | 45 | // combine these 46 | return Task.FromResult(new StoreKey 47 | { 48 | { nameof(resourcePath), resourcePath }, 49 | { nameof(queryString), queryString }, 50 | { nameof(requestHeaderValues), string.Join("-", requestHeaderValues)} 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/DefaultStrongETagGenerator.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | using Marvin.Cache.Headers.Extensions; 8 | using Marvin.Cache.Headers.Interfaces; 9 | 10 | namespace Marvin.Cache.Headers; 11 | 12 | public class DefaultStrongETagGenerator : IETagGenerator 13 | { 14 | private IStoreKeySerializer _storeKeySerializer; 15 | 16 | public DefaultStrongETagGenerator(IStoreKeySerializer storeKeySerializer) 17 | { 18 | _storeKeySerializer = storeKeySerializer; 19 | } 20 | 21 | // Key = generated from request URI & headers (if VaryBy is set, only use those headers) 22 | // ETag itself is generated from the key + response body (strong ETag) 23 | public Task GenerateETag( 24 | StoreKey storeKey, 25 | string responseBodyContent) 26 | { 27 | var requestKeyAsBytes = Encoding.UTF8.GetBytes(_storeKeySerializer.SerializeStoreKey(storeKey)); 28 | var responseBodyContentAsBytes = Encoding.UTF8.GetBytes(responseBodyContent); 29 | 30 | // combine both to generate an etag 31 | var combinedBytes = Combine(requestKeyAsBytes, responseBodyContentAsBytes); 32 | 33 | return Task.FromResult(new ETag(ETagType.Strong, combinedBytes.GenerateMD5Hash())); 34 | } 35 | 36 | private static byte[] Combine(byte[] a, byte[] b) 37 | { 38 | var c = new byte[a.Length + b.Length]; 39 | Buffer.BlockCopy(a, 0, c, 0, a.Length); 40 | Buffer.BlockCopy(b, 0, c, a.Length, b.Length); 41 | return c; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/CacheLocation.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | namespace Marvin.Cache.Headers; 5 | 6 | public enum CacheLocation 7 | { 8 | Public, 9 | Private 10 | } 11 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/ETag.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | namespace Marvin.Cache.Headers; 5 | 6 | public class ETag 7 | { 8 | public ETagType ETagType { get; } 9 | public string Value { get; } 10 | 11 | public ETag(ETagType eTagType, string value) 12 | { 13 | ETagType = eTagType; 14 | Value = value; 15 | } 16 | 17 | public override string ToString() 18 | { 19 | switch (ETagType) 20 | { 21 | case ETagType.Strong: 22 | return $"\"{Value}\""; 23 | 24 | case ETagType.Weak: 25 | return $"W\"{Value}\""; 26 | 27 | default: 28 | return $"\"{Value}\""; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/ETagContext.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Microsoft.AspNetCore.Http; 5 | 6 | namespace Marvin.Cache.Headers; 7 | 8 | /// 9 | /// Context containing information that might be useful when generating or retrieving an e-Tag 10 | /// 11 | public class ETagContext 12 | { 13 | public ETagContext(StoreKey storeKey, HttpContext httpContext) 14 | { 15 | StoreKey = storeKey; 16 | HttpContext = httpContext; 17 | } 18 | 19 | /// 20 | /// The current for the resource 21 | /// 22 | public StoreKey StoreKey { get; private set; } 23 | 24 | /// 25 | /// The current allowing access to the 26 | /// 27 | public HttpContext HttpContext { get; private set; } 28 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/ETagType.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | namespace Marvin.Cache.Headers; 5 | 6 | public enum ETagType 7 | { 8 | Strong, 9 | Weak 10 | } 11 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/ExpirationModelOptions.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Marvin.Cache.Headers.Interfaces; 5 | 6 | namespace Marvin.Cache.Headers; 7 | 8 | /// 9 | /// Options that have to do with the expiration model, mainly related to Cache-Control & Expires headers on the response. 10 | /// 11 | public class ExpirationModelOptions : IModelOptions 12 | { 13 | /// 14 | /// Maximum age, in seconds, after which a response expires. Has an effect on Expires & on the max-age directive 15 | /// of the Cache-Control header. 16 | /// 17 | /// Defaults to 60. 18 | /// 19 | public int MaxAge { get; set; } = 60; 20 | 21 | /// 22 | /// Maximum age, in seconds, after which a response expires for shared caches. If included, 23 | /// a shared cache should use this value rather than the max-age value. (s-maxage directive) 24 | /// 25 | /// Not set by default. 26 | /// 27 | public int? SharedMaxAge { get; set; } 28 | 29 | /// 30 | /// The location where a response can be cached. Public means it can be cached by both 31 | /// public (shared) and private (client) caches. Private means it can only be cached by 32 | /// private (client) caches. (public or private directive) 33 | /// 34 | /// Defaults to public. 35 | /// 36 | public CacheLocation CacheLocation { get; set; } = CacheLocation.Public; 37 | 38 | /// 39 | /// When true, the no-store directive is included in the Cache-Control header. 40 | /// When this directive is included, a cache must not store any part of the message, 41 | /// mostly for confidentiality reasons. 42 | /// 43 | /// Defaults to false. 44 | /// 45 | public bool NoStore { get; set; } = false; 46 | 47 | /// 48 | /// When true, the no-transform directive is included in the Cache-Control header. 49 | /// When this directive is included, a cache must not convert the media type of the 50 | /// response body. 51 | /// 52 | /// Defaults to false. 53 | /// 54 | public bool NoTransform { get; set; } = false; 55 | } 56 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/MiddlewareOptions.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace Marvin.Cache.Headers; 8 | 9 | /// 10 | /// Options that have to do with the HttpCacheHeadersMiddleware, mainly related to ignoring header generation in some cases. 11 | /// 12 | public class MiddlewareOptions 13 | { 14 | /// 15 | /// Set this to true if you don't want to automatically generate headers for all endpoints. 16 | /// The value "true" is typically used in accordance with [HttpCacheValidation] and [HttpCacheExpiration] attributes to only enable it for a few endpoints. 17 | /// 18 | /// Defaults to false. 19 | /// 20 | public bool DisableGlobalHeaderGeneration { get; set; } = false; 21 | 22 | /// 23 | /// Ignore header generation for responses with specific status codes. 24 | /// Often used when you don't want to generate headers for error responses. 25 | /// 26 | /// Defaults to none. 27 | /// 28 | public IEnumerable IgnoredStatusCodes { get; set; } = Enumerable.Empty(); 29 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/ResourceContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace Marvin.Cache.Headers.Domain; 4 | 5 | /// 6 | /// Context containing information on a specific resource 7 | /// 8 | public sealed class ResourceContext 9 | { 10 | /// 11 | /// The current 12 | /// 13 | public HttpRequest HttpRequest { get; } 14 | 15 | /// 16 | /// The current for the resource, if available 17 | /// 18 | public StoreKey StoreKey { get; } 19 | 20 | /// 21 | /// The current for the resource, if available 22 | /// 23 | public ValidatorValue ValidatorValue { get; } 24 | 25 | 26 | public ResourceContext( 27 | HttpRequest httpRequest, 28 | StoreKey storeKey) 29 | { 30 | HttpRequest = httpRequest; 31 | StoreKey = storeKey; 32 | } 33 | 34 | public ResourceContext( 35 | HttpRequest httpRequest, 36 | StoreKey storeKey, 37 | ValidatorValue validatorValue) : this(httpRequest, storeKey) 38 | { 39 | ValidatorValue = validatorValue; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/StoreKey.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | namespace Marvin.Cache.Headers; 5 | 6 | using System.Collections.Generic; 7 | 8 | public class StoreKey : Dictionary 9 | { 10 | public override string ToString() => string.Join("-", Values); 11 | } 12 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/StoreKeyContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using System.Collections.Generic; 3 | 4 | namespace Marvin.Cache.Headers.Domain; 5 | 6 | /// 7 | /// Context containing information that might be useful when generating a custom store key. 8 | /// 9 | public class StoreKeyContext 10 | { 11 | /// 12 | /// The current 13 | /// 14 | public HttpRequest HttpRequest { get; } 15 | 16 | /// 17 | /// The Vary header keys as set on or through 18 | /// 19 | public IEnumerable Vary { get; } 20 | 21 | /// 22 | /// The VaryByAll option as set on or through 23 | /// 24 | public bool VaryByAll { get; } 25 | 26 | public StoreKeyContext( 27 | HttpRequest httpRequest, 28 | IEnumerable vary, 29 | bool varyByAll) 30 | { 31 | HttpRequest = httpRequest; 32 | Vary = vary; 33 | VaryByAll = varyByAll; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/ValidationModelOptions.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System.Collections.Generic; 5 | using Marvin.Cache.Headers.Interfaces; 6 | 7 | namespace Marvin.Cache.Headers; 8 | 9 | /// 10 | /// Options that have to do with the validation model, mainly related to ETag generation, Last-Modified on the response, 11 | /// but also to the Cache-Control header (as that is used for both expiration & validation requirements) 12 | /// 13 | public class ValidationModelOptions : IModelOptions 14 | { 15 | /// 16 | /// A case-insensitive list of headers from the request to take into account as differentiator 17 | /// between requests (eg: for generating ETags) 18 | /// 19 | /// Defaults to Accept, Accept-Language, Accept-Encoding. * indicates all request headers can be taken into account. 20 | /// 21 | public IEnumerable Vary { get; set; } 22 | = new List { "Accept", "Accept-Language", "Accept-Encoding" }; 23 | 24 | /// 25 | /// Indicates that all request headers are taken into account as differentiator. 26 | /// When set to true, this is the same as Vary *. The Vary list will thus be ignored. 27 | /// 28 | /// Note that a Vary field value of "*" implies that a cache cannot determine 29 | /// from the request headers of a subsequent request whether this response is 30 | /// the appropriate representation. This should thus only be set to true for 31 | /// exceptional cases. 32 | /// 33 | /// Defaults to false. 34 | /// 35 | public bool VaryByAll { get; set; } = false; 36 | 37 | /// 38 | /// When true, the no-cache directive is added to the Cache-Control header. 39 | /// This indicates to a cache that the response should not be used for subsequent requests 40 | /// without successful revalidation with the origin server. 41 | /// 42 | /// Defaults to false. 43 | /// 44 | public bool NoCache { get; set; } = false; 45 | 46 | /// 47 | /// When true, the must-revalidate directive is added to the Cache-Control header. 48 | /// This tells a cache that if a response becomes stale, ie: it's expired, revalidation has to happen. 49 | /// By adding this directive, we can force revalidation by the cache even if the client 50 | /// has decided that stale responses are for a specified amount of time (which a client can 51 | /// do by setting the max-stale directive). 52 | /// 53 | /// Defaults to false. 54 | /// 55 | public bool MustRevalidate { get; set; } = false; 56 | 57 | /// 58 | /// When true, the proxy-revalidate directive is added to the Cache-Control header. 59 | /// Exactly the same as must-revalidate, but only for shared caches. 60 | /// So: this tells a shared cache that if a response becomes stale, ie: it's expired, revalidation has to happen. 61 | /// By adding this directive, we can force revalidation by the cache even if the client 62 | /// has decided that stale responses are for a specified amount of time (which a client can 63 | /// do by setting the max-stale directive). 64 | /// 65 | /// Defaults to false. 66 | /// 67 | public bool ProxyRevalidate { get; set; } = false; 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Domain/ValidatorValue.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | 6 | namespace Marvin.Cache.Headers; 7 | 8 | public class ValidatorValue 9 | { 10 | public ETag ETag { get; } 11 | public DateTimeOffset LastModified { get; } 12 | 13 | public ValidatorValue(ETag eTag, DateTimeOffset lastModified) 14 | { 15 | ETag = eTag; 16 | LastModified = lastModified; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Extensions/AppBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Marvin.Cache.Headers; 5 | using Microsoft.AspNetCore.Routing; 6 | using System; 7 | 8 | namespace Microsoft.AspNetCore.Builder; 9 | 10 | /// 11 | /// Extension methods for the HttpCache middleware (on IApplicationBuilder) 12 | /// 13 | public static class AppBuilderExtensions 14 | { 15 | /// 16 | /// Add HttpCacheHeaders to the request pipeline. 17 | /// 18 | /// The . 19 | /// 20 | public static IApplicationBuilder UseHttpCacheHeaders(this IApplicationBuilder builder) 21 | { 22 | if (builder == null) 23 | { 24 | throw new ArgumentNullException(nameof(builder)); 25 | } 26 | 27 | // Check whether the EndpointDataSource class has been registered on the container (they are 28 | // required for getting endpoint metadata) 29 | if (builder.ApplicationServices.GetService(typeof(EndpointDataSource)) == null) 30 | { 31 | throw new InvalidOperationException("Cannot resolve required routing services on the container. "); 32 | } 33 | 34 | return builder.UseMiddleware(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Extensions/ByteExtensions.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using System.Security.Cryptography; 6 | 7 | namespace Marvin.Cache.Headers.Extensions; 8 | 9 | public static class ByteExtensions 10 | { 11 | // from http://jakzaprogramowac.pl/pytanie/20645,implement-http-cache-etag-in-aspnet-core-web-api 12 | public static string GenerateMD5Hash(this byte[] data) 13 | { 14 | using (var md5 = MD5.Create()) 15 | { 16 | var hash = md5.ComputeHash(data); 17 | var hex = BitConverter.ToString(hash); 18 | return hex.Replace("-", ""); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Extensions/HttpContextExtensions.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Microsoft.AspNetCore.Http; 5 | 6 | namespace Marvin.Cache.Headers.Extensions; 7 | 8 | internal static class HttpContextExtensions 9 | { 10 | internal static readonly string ContextItemsExpirationModelOptions = "HttpCacheHeadersMiddleware-ExpirationModelOptions"; 11 | internal static readonly string ContextItemsValidationModelOptions = "HttpCacheHeadersMiddleware-ValidationModelOptions"; 12 | 13 | internal static ExpirationModelOptions ExpirationModelOptionsOrDefault(this HttpContext httpContext, ExpirationModelOptions @default) => 14 | httpContext.Items.ContainsKey(ContextItemsExpirationModelOptions) 15 | ? (ExpirationModelOptions)httpContext.Items[ContextItemsExpirationModelOptions] 16 | : @default; 17 | 18 | internal static ValidationModelOptions ValidationModelOptionsOrDefault(this HttpContext httpContext, ValidationModelOptions @default) => 19 | httpContext.Items.ContainsKey(ContextItemsValidationModelOptions) 20 | ? (ValidationModelOptions)httpContext.Items[ContextItemsValidationModelOptions] 21 | : @default; 22 | } 23 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Extensions/MinimalApiExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using System; 3 | 4 | namespace Marvin.Cache.Headers.Extensions; 5 | 6 | public static class MinimalApiExtensions 7 | { 8 | /// 9 | /// Adds the HttpCacheExpiration attribute to the minimal API endpoint. 10 | /// 11 | /// The type of builder (e.g. group or individual) 12 | /// The builder to use to add the attribute. 13 | /// The location where a response can be cached. 14 | /// Maximum age, in seconds, after which a response expires. 15 | /// When true, the no-store directive is included in the Cache-Control header. 16 | /// When true, the no-transform directive is included in the Cache-Control header. 17 | /// Maximum age, in seconds, after which a response expires for shared caches. 18 | /// 19 | /// The builder for chaining of commands. 20 | public static TBuilder AddHttpCacheExpiration(this TBuilder builder, 21 | CacheLocation? cacheLocation = null, 22 | int? maxAge = null, 23 | bool? noStore = null, 24 | bool? noTransform = null, 25 | int? sharedMaxAge = null) 26 | where TBuilder : IEndpointConventionBuilder 27 | { 28 | ArgumentNullException.ThrowIfNull(builder); 29 | 30 | var attribute = new HttpCacheExpirationAttribute(); 31 | 32 | attribute.CacheLocation = cacheLocation ?? attribute.CacheLocation; 33 | attribute.MaxAge = maxAge ?? attribute.MaxAge; 34 | attribute.NoStore = noStore ?? attribute.NoStore; 35 | attribute.NoTransform = noTransform ?? attribute.NoTransform; 36 | attribute.SharedMaxAge = sharedMaxAge ?? attribute.SharedMaxAge; 37 | 38 | WireMetadata(builder, attribute); 39 | 40 | return builder; 41 | } 42 | 43 | /// 44 | /// Adds the HttpCacheValidation attribute to the minimal API endpoint. 45 | /// 46 | /// The type of builder (e.g. group or individual) 47 | /// The builder to use to add the attribute. 48 | /// A case-insensitive list of headers from the request to take into account as differentiator 49 | /// between requests. 50 | /// Indicates that all request headers are taken into account as differentiator. 51 | /// When true, the no-cache directive is added to the Cache-Control header. 52 | /// When true, the must-revalidate directive is added to the Cache-Control header. 53 | /// When true, the proxy-revalidate directive is added to the Cache-Control header. 54 | /// 55 | /// The builder for chaining of commands. 56 | public static TBuilder AddHttpCacheValidation(this TBuilder builder, 57 | string[] vary = null, 58 | bool? varyByAll = null, 59 | bool? noCache = null, 60 | bool? mustRevalidate = null, 61 | bool? proxyRevalidate = null) 62 | where TBuilder : IEndpointConventionBuilder 63 | { 64 | ArgumentNullException.ThrowIfNull(builder); 65 | 66 | var attribute = new HttpCacheValidationAttribute(); 67 | 68 | attribute.Vary = vary ?? attribute.Vary; 69 | attribute.VaryByAll = varyByAll ?? attribute.VaryByAll; 70 | attribute.NoCache = noCache ?? attribute.NoCache; 71 | attribute.MustRevalidate = mustRevalidate ?? attribute.MustRevalidate; 72 | attribute.ProxyRevalidate = proxyRevalidate ?? attribute.ProxyRevalidate; 73 | 74 | WireMetadata(builder, attribute); 75 | 76 | return builder; 77 | } 78 | 79 | /// 80 | /// Adds the HttpCacheIgnore attribute to the endpoints 81 | /// 82 | /// The type of builder (e.g. group or individual) 83 | /// The builder to use to add the attribute. 84 | /// The builder for chaining of commands. 85 | public static TBuilder IgnoreHttpCache(this TBuilder builder) 86 | where TBuilder : IEndpointConventionBuilder 87 | { 88 | ArgumentNullException.ThrowIfNull(builder); 89 | 90 | WireMetadata(builder, new HttpCacheIgnoreAttribute()); 91 | 92 | return builder; 93 | } 94 | 95 | static private void WireMetadata(TBuilder builder, 96 | T attribute) where T : Attribute 97 | where TBuilder : IEndpointConventionBuilder 98 | { 99 | builder.Add(endpointBuilder => 100 | { 101 | endpointBuilder.Metadata.Add(attribute); 102 | }); 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Extensions/ServicesExtensions.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using Marvin.Cache.Headers; 6 | using Marvin.Cache.Headers.Interfaces; 7 | using Marvin.Cache.Headers.Serialization; 8 | using Marvin.Cache.Headers.Stores; 9 | using Microsoft.AspNetCore.Http; 10 | using Microsoft.Extensions.Caching.Memory; 11 | using Microsoft.Extensions.DependencyInjection.Extensions; 12 | 13 | namespace Microsoft.Extensions.DependencyInjection; 14 | 15 | /// 16 | /// Extension methods for the HttpCache middleware (on IServiceCollection) 17 | /// 18 | public static class ServicesExtensions 19 | { 20 | /// 21 | /// Add HttpCacheHeaders services to the specified . 22 | /// 23 | /// The to add services to. 24 | /// Action to provide custom 25 | /// Action to provide custom 26 | /// Action to provide custom 27 | /// Func to provide a custom 28 | /// Func to provide a custom 29 | /// Func to provide a custom 30 | /// Func to provide a custom 31 | /// Func to provide a custom 32 | /// Func to provide a custom 33 | /// Func to provide a custom 34 | public static IServiceCollection AddHttpCacheHeaders( 35 | this IServiceCollection services, 36 | Action expirationModelOptionsAction = null, 37 | Action validationModelOptionsAction = null, 38 | Action middlewareOptionsAction = null, 39 | Func dateParserFunc = null, 40 | Func validatorValueStoreFunc = null, 41 | Func storeKeyGeneratorFunc = null, 42 | Func storeKeySerializerFunc = null, 43 | Func eTagGeneratorFunc = null, 44 | Func lastModifiedInjectorFunc = null, 45 | Func eTagInjectorFunc = null) 46 | { 47 | services.AddMemoryCache(); 48 | if(expirationModelOptionsAction != null) 49 | AddConfigureExpirationModelOptions(services, expirationModelOptionsAction); 50 | if(validationModelOptionsAction != null) 51 | AddConfigureValidationModelOptions(services, validationModelOptionsAction); 52 | if(middlewareOptionsAction != null) 53 | AddConfigureMiddlewareOptions(services, middlewareOptionsAction); 54 | 55 | AddModularParts( 56 | services, 57 | dateParserFunc, 58 | validatorValueStoreFunc, 59 | storeKeyGeneratorFunc, 60 | storeKeySerializerFunc, 61 | eTagGeneratorFunc, 62 | lastModifiedInjectorFunc, 63 | eTagInjectorFunc); 64 | 65 | return services; 66 | } 67 | 68 | private static void AddModularParts(IServiceCollection services, 69 | Func dateParserFunc, 70 | Func validatorValueStoreFunc, 71 | Func storeKeyGeneratorFunc, 72 | Func storeKeySerializerFunc, 73 | Func eTagGeneratorFunc, 74 | Func lastModifiedInjectorFunc, 75 | Func eTagInjectorFunc) 76 | { 77 | AddDateParser(services, dateParserFunc); 78 | AddValidatorValueStore(services, validatorValueStoreFunc); 79 | AddStoreKeyGenerator(services, storeKeyGeneratorFunc); 80 | AddStoreKeySerializer(services, storeKeySerializerFunc); 81 | AddETagGenerator(services, eTagGeneratorFunc); 82 | AddLastModifiedInjector(services, lastModifiedInjectorFunc); 83 | AddETagInjector(services, eTagInjectorFunc); 84 | 85 | // register dependencies for required services 86 | services.TryAddSingleton(); 87 | 88 | // add required additional services 89 | services.AddScoped(); 90 | services.AddTransient(); 91 | } 92 | 93 | private static void AddLastModifiedInjector( 94 | IServiceCollection services, 95 | Func lastModifiedInjectorFunc) 97 | { 98 | if (services == null) 99 | { 100 | throw new ArgumentNullException(nameof(services)); 101 | } 102 | 103 | if (lastModifiedInjectorFunc == null) 104 | { 105 | lastModifiedInjectorFunc = _ => new DefaultLastModifiedInjector(); 106 | } 107 | 108 | services.Add(ServiceDescriptor.Singleton(typeof(ILastModifiedInjector), lastModifiedInjectorFunc)); 109 | } 110 | 111 | private static void AddETagInjector( 112 | IServiceCollection services, 113 | Func eTagInjectorFunc) 115 | { 116 | if (services == null) 117 | { 118 | throw new ArgumentNullException(nameof(services)); 119 | } 120 | 121 | if (eTagInjectorFunc == null) 122 | { 123 | eTagInjectorFunc = services => new DefaultETagInjector(services.GetRequiredService()); 124 | } 125 | 126 | services.Add(ServiceDescriptor.Singleton(typeof(IETagInjector), eTagInjectorFunc)); 127 | } 128 | 129 | private static void AddDateParser( 130 | IServiceCollection services, 131 | Func dateParserFunc) 132 | { 133 | if (services == null) 134 | { 135 | throw new ArgumentNullException(nameof(services)); 136 | } 137 | 138 | if (dateParserFunc == null) 139 | { 140 | dateParserFunc = _ => new DefaultDateParser(); 141 | } 142 | 143 | services.Add(ServiceDescriptor.Singleton(typeof(IDateParser), dateParserFunc)); 144 | } 145 | 146 | private static void AddValidatorValueStore( 147 | IServiceCollection services, 148 | Func validatorValueStoreFunc) 149 | { 150 | if (services == null) 151 | { 152 | throw new ArgumentNullException(nameof(services)); 153 | } 154 | 155 | if (validatorValueStoreFunc == null) 156 | { 157 | validatorValueStoreFunc = services => new InMemoryValidatorValueStore(services.GetRequiredService(), services.GetRequiredService()); 158 | } 159 | 160 | services.Add(ServiceDescriptor.Singleton(typeof(IValidatorValueStore), validatorValueStoreFunc)); 161 | } 162 | 163 | private static void AddStoreKeyGenerator( 164 | IServiceCollection services, 165 | Func storeKeyGeneratorFunc) 166 | { 167 | if (services == null) 168 | { 169 | throw new ArgumentNullException(nameof(services)); 170 | } 171 | 172 | if (storeKeyGeneratorFunc == null) 173 | { 174 | storeKeyGeneratorFunc = _ => new DefaultStoreKeyGenerator(); 175 | } 176 | 177 | services.Add(ServiceDescriptor.Singleton(typeof(IStoreKeyGenerator), storeKeyGeneratorFunc)); 178 | } 179 | 180 | private static void AddStoreKeySerializer( 181 | IServiceCollection services, 182 | Func storeKeySerializerFunc) 183 | { 184 | if (services == null) 185 | { 186 | throw new ArgumentNullException(nameof(services)); 187 | } 188 | 189 | if (storeKeySerializerFunc == null) 190 | { 191 | storeKeySerializerFunc = _ => new DefaultStoreKeySerializer(); 192 | } 193 | 194 | services.Add(ServiceDescriptor.Singleton(typeof(IStoreKeySerializer), storeKeySerializerFunc)); 195 | } 196 | 197 | private static void AddETagGenerator( 198 | IServiceCollection services, 199 | Func eTagGeneratorFunc) 200 | { 201 | if (services == null) 202 | { 203 | throw new ArgumentNullException(nameof(services)); 204 | } 205 | 206 | if (eTagGeneratorFunc == null) 207 | { 208 | eTagGeneratorFunc = services => new DefaultStrongETagGenerator(services.GetRequiredService()); 209 | } 210 | 211 | services.Add(ServiceDescriptor.Singleton(typeof(IETagGenerator), eTagGeneratorFunc)); 212 | } 213 | 214 | private static void AddConfigureMiddlewareOptions( 215 | IServiceCollection services, 216 | Action configureExpirationModelOptions) 217 | { 218 | if (services == null) 219 | { 220 | throw new ArgumentNullException(nameof(services)); 221 | } 222 | 223 | if (configureExpirationModelOptions == null) 224 | { 225 | throw new ArgumentNullException(nameof(configureExpirationModelOptions)); 226 | } 227 | 228 | services.Configure(configureExpirationModelOptions); 229 | } 230 | 231 | private static void AddConfigureExpirationModelOptions( 232 | IServiceCollection services, 233 | Action configureExpirationModelOptions) 234 | { 235 | if (services == null) 236 | { 237 | throw new ArgumentNullException(nameof(services)); 238 | } 239 | 240 | if (configureExpirationModelOptions == null) 241 | { 242 | throw new ArgumentNullException(nameof(configureExpirationModelOptions)); 243 | } 244 | 245 | services.Configure(configureExpirationModelOptions); 246 | } 247 | 248 | private static void AddConfigureValidationModelOptions( 249 | IServiceCollection services, 250 | Action configureValidationModelOptions) 251 | { 252 | if (services == null) 253 | { 254 | throw new ArgumentNullException(nameof(services)); 255 | } 256 | 257 | if (configureValidationModelOptions == null) 258 | { 259 | throw new ArgumentNullException(nameof(configureValidationModelOptions)); 260 | } 261 | 262 | services.Configure(configureValidationModelOptions); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IDateParser.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using System.Threading.Tasks; 6 | 7 | namespace Marvin.Cache.Headers.Interfaces; 8 | 9 | /// 10 | /// Contract for a date parser, used to parse Last-Modified, Expires, If-Modified-Since and If-Unmodified-Since headers. 11 | /// 12 | public interface IDateParser 13 | { 14 | Task LastModifiedToString(DateTimeOffset lastModified); 15 | 16 | Task ExpiresToString(DateTimeOffset lastModified); 17 | 18 | Task IfModifiedSinceToDateTimeOffset(string ifModifiedSince); 19 | 20 | Task IfUnmodifiedSinceToDateTimeOffset(string ifUnmodifiedSince); 21 | } 22 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IETagGenerator.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System.Threading.Tasks; 5 | 6 | namespace Marvin.Cache.Headers.Interfaces; 7 | 8 | /// 9 | /// Contract for an E-Tag Generator, used to generate the unique weak or strong E-Tags for cache items 10 | /// 11 | public interface IETagGenerator 12 | { 13 | Task GenerateETag( 14 | StoreKey storeKey, 15 | string responseBodyContent); 16 | } 17 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IETagInjector.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Http; 3 | 4 | namespace Marvin.Cache.Headers.Interfaces; 5 | 6 | /// 7 | /// Contract for a ETagInjector, which can be used to inject custom eTags for resources 8 | /// of which may be injected in the request pipeline (eg: based on existing calculated eTag on resource and store key) 9 | /// 10 | /// 11 | /// This injector will wrap the to allow for eTag source to be swapped out 12 | /// based on the (rather than extend the interface of to 13 | /// to extended including the 14 | /// 15 | public interface IETagInjector 16 | { 17 | Task RetrieveETag(ETagContext eTagContext); 18 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/ILastModifiedInjector.cs: -------------------------------------------------------------------------------- 1 | using Marvin.Cache.Headers.Domain; 2 | using System; 3 | using System.Threading.Tasks; 4 | 5 | namespace Marvin.Cache.Headers.Interfaces; 6 | 7 | /// 8 | /// Contract for a LastModifiedInjector, which can be used to inject custom last modified dates for resources 9 | /// of which you know when they were last modified (eg: a DB timestamp, custom logic, ...) 10 | /// 11 | public interface ILastModifiedInjector 12 | { 13 | Task CalculateLastModified( 14 | ResourceContext context); 15 | } 16 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IModelOptions.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | namespace Marvin.Cache.Headers.Interfaces; 5 | 6 | public interface IModelOptions 7 | { 8 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IModelOptionsProvider.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | namespace Marvin.Cache.Headers.Interfaces; 5 | 6 | internal interface IModelOptionsProvider 7 | { 8 | IModelOptions GetModelOptions(); 9 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IStoreKeyAccessor.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System.Collections.Generic; 5 | 6 | namespace Marvin.Cache.Headers; 7 | 8 | /// 9 | /// Contract for finding (a) (s) 10 | /// 11 | public interface IStoreKeyAccessor 12 | { 13 | /// 14 | /// Find a by part of the key 15 | /// 16 | /// The value to match as part of the key 17 | /// Ignore case when matching (default = true) 18 | /// 19 | IAsyncEnumerable FindByKeyPart(string valueToMatch, bool ignoreCase = true); 20 | 21 | /// 22 | /// Find a of which the current resource path is part of the key 23 | /// 24 | /// Ignore case when matching (default = true) 25 | /// 26 | IAsyncEnumerable FindByCurrentResourcePath(bool ignoreCase = true); 27 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IStoreKeyGenerator.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Marvin.Cache.Headers.Domain; 5 | using System.Threading.Tasks; 6 | 7 | namespace Marvin.Cache.Headers.Interfaces; 8 | 9 | /// 10 | /// Contract for a key generator, used to generate a 11 | /// 12 | public interface IStoreKeyGenerator 13 | { 14 | /// 15 | /// Generate a key for storing a in a . 16 | /// 17 | /// The . 18 | /// 19 | Task GenerateStoreKey( 20 | StoreKeyContext context); 21 | } 22 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IStoreKeySerializer.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | 5 | using System; 6 | using System.Text.Json; 7 | 8 | namespace Marvin.Cache.Headers.Interfaces; 9 | 10 | /// 11 | /// Contract for a key serializer, used to serialize a 12 | /// 13 | public interface IStoreKeySerializer 14 | { 15 | /// 16 | /// Serialize a . 17 | /// 18 | /// The to be serialized. 19 | /// The serialized to a . 20 | ///thrown when the passed in is null. 21 | string SerializeStoreKey(StoreKey keyToSerialize); 22 | 23 | /// 24 | /// Deserialize a from a . 25 | /// 26 | /// The Json representation of a to be deserialized. 27 | /// The deserialized to a . 28 | ///thrown when the passed in is null. 29 | ///thrown when the passed in is an empty string. 30 | ///thrown when the passed in cannot be deserialized to a . 31 | StoreKey DeserializeStoreKey(string storeKeyJson); 32 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IValidatorValueInvalidator.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System.Collections.Generic; 5 | using System.Threading.Tasks; 6 | 7 | namespace Marvin.Cache.Headers.Interfaces; 8 | 9 | /// 10 | /// Contract for the 11 | /// 12 | public interface IValidatorValueInvalidator 13 | { 14 | /// 15 | /// Get the list of of items marked for invalidation 16 | /// 17 | List KeysMarkedForInvalidation { get; } 18 | 19 | /// 20 | /// Mark an item stored with a for invalidation 21 | /// 22 | /// The 23 | /// 24 | Task MarkForInvalidation(StoreKey storeKey); 25 | 26 | /// 27 | /// Mark a set of items for invalidation by their collection of 28 | /// 29 | /// The collection of 30 | /// 31 | Task MarkForInvalidation(IEnumerable storeKeys); 32 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Interfaces/IValidatorValueStore.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System.Collections.Generic; 5 | using System.Threading.Tasks; 6 | 7 | namespace Marvin.Cache.Headers.Interfaces; 8 | 9 | /// 10 | /// Contract for a store for validator values. Each item is stored with a as key 11 | /// and a as value (consisting of an ETag and Last-Modified date). 12 | /// 13 | public interface IValidatorValueStore 14 | { 15 | /// 16 | /// Get a value from the store. 17 | /// 18 | /// The of the value to get. 19 | /// 20 | Task GetAsync(StoreKey key); 21 | 22 | /// 23 | /// Set a value in the store. 24 | /// 25 | /// The of the value to store. 26 | /// The to store. 27 | /// 28 | Task SetAsync(StoreKey key, ValidatorValue validatorValue); 29 | 30 | /// 31 | /// Remove a value from the store. 32 | /// 33 | /// The of the value to remove. 34 | /// 35 | Task RemoveAsync(StoreKey key); 36 | 37 | /// 38 | /// Find one or more keys that contain the inputted valueToMatch 39 | /// 40 | /// The value to match as part of the key 41 | /// Ignore case when matching 42 | /// 43 | IAsyncEnumerable FindStoreKeysByKeyPartAsync(string valueToMatch, bool ignoreCase); 44 | } 45 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Marvin.Cache.Headers.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0;net7.0;net8.0 5 | Library 6 | Marvin.Cache.Headers 7 | Marvin.Cache.Headers 8 | false 9 | false 10 | false 11 | 7.2.0 12 | ASP.NET Core middleware that adds HttpCache headers to responses (Cache-Control, Expires, ETag, Last-Modified), and implements cache expiration & validation models. 13 | True 14 | KevinDockx, Kevin Dockx 15 | https://github.com/KevinDockx/HttpCacheHeaders 16 | See milestone 7.2.0: https://github.com/KevinDockx/HttpCacheHeaders/milestone/17 17 | README.md 18 | MIT 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Marvin.Cache.Headers.csproj.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | True 3 | True -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.InteropServices; 3 | 4 | // General Information about an assembly is controlled through the following 5 | // set of attributes. Change these attribute values to modify the information 6 | // associated with an assembly. 7 | [assembly: AssemblyConfiguration("")] 8 | [assembly: AssemblyCompany("")] 9 | [assembly: AssemblyProduct("Marvin.Cache.Headers")] 10 | [assembly: AssemblyTrademark("")] 11 | 12 | // Setting ComVisible to false makes the types in this assembly not visible 13 | // to COM components. If you need to access a type in this assembly from 14 | // COM, set the ComVisible attribute to true on that type. 15 | [assembly: ComVisible(false)] 16 | 17 | // The following GUID is for the ID of the typelib if this project is exposed to COM 18 | [assembly: Guid("b06bdc10-2211-49c4-9d83-8c6b0c6bf70e")] 19 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Serialization/DefaultStoreKeySerializer.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using System.Text.Json; 6 | using Marvin.Cache.Headers.Interfaces; 7 | 8 | namespace Marvin.Cache.Headers.Serialization; 9 | 10 | /// 11 | /// Serializes a to JSON./// 12 | public class DefaultStoreKeySerializer : IStoreKeySerializer 13 | { 14 | /// 15 | public string SerializeStoreKey(StoreKey keyToSerialize) 16 | { 17 | ArgumentNullException.ThrowIfNull(keyToSerialize); 18 | return JsonSerializer.Serialize(keyToSerialize); 19 | } 20 | 21 | /// 22 | public StoreKey DeserializeStoreKey(string storeKeyJson) 23 | { 24 | if (storeKeyJson == null) 25 | { 26 | throw new ArgumentNullException(nameof(storeKeyJson)); 27 | } 28 | else if (storeKeyJson.Length == 0) 29 | { 30 | throw new ArgumentException("The storeKeyJson parameter cannot be an empty string.", nameof(storeKeyJson)); 31 | } 32 | 33 | return JsonSerializer.Deserialize(storeKeyJson); 34 | } 35 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/StoreKeyAccessor.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Marvin.Cache.Headers.Interfaces; 5 | using Microsoft.AspNetCore.Http; 6 | using System; 7 | using System.Collections.Generic; 8 | 9 | namespace Marvin.Cache.Headers; 10 | 11 | /// 12 | /// An accessor for finding (s) 13 | /// 14 | public class StoreKeyAccessor : IStoreKeyAccessor 15 | { 16 | private readonly IValidatorValueStore _validatorValueStore; 17 | private readonly IHttpContextAccessor _httpContextAccessor; 18 | 19 | public StoreKeyAccessor(IValidatorValueStore validatorValueStore, 20 | IStoreKeyGenerator storeKeyGenerator, 21 | IHttpContextAccessor httpContextAccessor) 22 | { 23 | _validatorValueStore = validatorValueStore 24 | ?? throw new ArgumentNullException(nameof(validatorValueStore)); 25 | _httpContextAccessor = httpContextAccessor 26 | ?? throw new ArgumentNullException(nameof(httpContextAccessor)); 27 | } 28 | 29 | /// 30 | /// Find a by part of the key 31 | /// 32 | /// The value to match as part of the key 33 | /// 34 | public async IAsyncEnumerable FindByKeyPart(string valueToMatch, bool ignoreCase = true) 35 | { 36 | await foreach (var value in _validatorValueStore.FindStoreKeysByKeyPartAsync(valueToMatch, ignoreCase)) 37 | { 38 | yield return value; 39 | } 40 | } 41 | 42 | /// 43 | /// Find a of which the current resource path is part of the key 44 | /// 45 | /// 46 | public async IAsyncEnumerable FindByCurrentResourcePath(bool ignoreCase = true) 47 | { 48 | string path = _httpContextAccessor.HttpContext.Request.Path.ToString(); 49 | await foreach (var value in _validatorValueStore.FindStoreKeysByKeyPartAsync(path, ignoreCase)) 50 | { 51 | yield return value; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Stores/InMemoryValidatorValueStore.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Marvin.Cache.Headers.Interfaces; 5 | using Microsoft.Extensions.Caching.Memory; 6 | using System; 7 | using System.Collections.Concurrent; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | using System.Threading.Tasks; 11 | 12 | namespace Marvin.Cache.Headers.Stores; 13 | 14 | /// 15 | /// In-memory implementation of . 16 | /// 17 | public class InMemoryValidatorValueStore : IValidatorValueStore 18 | { 19 | // store for validatorvalues 20 | private readonly IMemoryCache _store; 21 | 22 | // store for storekeys - different store to speed up search. 23 | // 24 | // A ConcurrentList or ConcurrentHashSet would be slightly better, but they don't 25 | // exist out of the box. ConcurrentBag doesn't safely allow removing a specific 26 | // item, so: ConcurrentDictionary it is. 27 | private readonly ConcurrentDictionary _storeKeyStore; 28 | 29 | //Serializer for StoreKeys. 30 | private readonly IStoreKeySerializer _storeKeySerializer; 31 | 32 | public InMemoryValidatorValueStore(IStoreKeySerializer storeKeySerializer, IMemoryCache store, ConcurrentDictionary storeKeyStore = null) 33 | { 34 | _storeKeySerializer = storeKeySerializer ?? throw new ArgumentNullException(nameof(storeKeySerializer)); 35 | _store = store ?? throw new ArgumentNullException(nameof(store)); 36 | _storeKeyStore = storeKeyStore ?? new ConcurrentDictionary(); 37 | } 38 | 39 | public Task GetAsync(StoreKey key) 40 | { 41 | var keyJson = _storeKeySerializer.SerializeStoreKey(key); 42 | return Task.FromResult(!_store.TryGetValue(keyJson, out ValidatorValue eTag) ? null : eTag); 43 | } 44 | 45 | /// 46 | /// Add an item to the store (or update it) 47 | /// 48 | /// The . 49 | /// The . 50 | /// 51 | public Task SetAsync(StoreKey key, ValidatorValue eTag) 52 | { 53 | // store the validator value 54 | var keyJson = _storeKeySerializer.SerializeStoreKey(key); 55 | _store.Set(keyJson, eTag); 56 | 57 | // save the key itself as well, with an easily searchable stringified key 58 | _storeKeyStore[keyJson] = keyJson; 59 | return Task.CompletedTask; 60 | } 61 | 62 | /// 63 | /// Remove an item from the store 64 | /// 65 | /// The . 66 | /// 67 | public Task RemoveAsync(StoreKey key) 68 | { 69 | var keyJson = _storeKeySerializer.SerializeStoreKey(key); 70 | 71 | if (!_storeKeyStore.ContainsKey(keyJson)) 72 | { 73 | return Task.FromResult(false); 74 | } 75 | 76 | _store.Remove(keyJson); 77 | _ = _storeKeyStore.TryRemove(keyJson, out string _); 78 | return Task.FromResult(true); 79 | } 80 | 81 | /// 82 | /// Find store keys 83 | /// 84 | /// The value to match as (part of) the key 85 | /// 86 | #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - disabled, in-memory implementation doesn't need await. 87 | public async IAsyncEnumerable FindStoreKeysByKeyPartAsync(string valueToMatch, 88 | #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously 89 | bool ignoreCase) 90 | { 91 | var lstStoreKeysToReturn = new List(); 92 | 93 | // search for keys that contain valueToMatch 94 | if (ignoreCase) 95 | { 96 | valueToMatch = valueToMatch.ToLowerInvariant(); 97 | } 98 | 99 | foreach (var keyValuePair in _storeKeyStore) 100 | { 101 | var deserializedKey = _storeKeySerializer.DeserializeStoreKey(keyValuePair.Key); 102 | var deserializedKeyValues = String.Join(',', ignoreCase ? deserializedKey.Values.Select(x => x.ToLower()) : deserializedKey.Values); 103 | if (deserializedKeyValues.Contains(valueToMatch)) 104 | { 105 | yield return deserializedKey; 106 | } 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/Utils/HttpStatusCodes.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | // Used list on https://en.wikipedia.org/wiki/List_of_HTTP_status_codes 5 | 6 | namespace Marvin.Cache.Headers.Utils 7 | { 8 | /// 9 | /// Contains predefined list of status codes. 10 | /// 11 | public static class HttpStatusCodes 12 | { 13 | /// 14 | /// Contains all status codes for client errors in the 4xx range. 15 | /// 16 | public static readonly IEnumerable ClientErrors = new[] { 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 428, 429, 430, 431, 440, 449, 450, 451, 498, 499 }; 17 | 18 | /// 19 | /// Contains all status codes for server errors in the 5xx range. 20 | /// 21 | public static readonly IEnumerable ServerErrors = new[] { 500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 529, 530, 598, 599 }; 22 | 23 | /// 24 | /// Contains all error status codes in the 4xx and 5xx range. 25 | /// 26 | public static readonly IEnumerable AllErrors = ClientErrors.Concat(ServerErrors); 27 | } 28 | } -------------------------------------------------------------------------------- /src/Marvin.Cache.Headers/ValidatorValueInvalidator.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Marvin.Cache.Headers.Interfaces; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Threading.Tasks; 8 | 9 | namespace Marvin.Cache.Headers; 10 | 11 | 12 | /// 13 | /// Invalidator for the values from the 14 | /// 15 | public sealed class ValidatorValueInvalidator : IValidatorValueInvalidator 16 | { 17 | // ValidatorValueInvalidator is registered with a scoped lifetime. It'll thus 18 | // only be accessed by one thread at a time - no need for concurrent collection implementations 19 | 20 | /// 21 | /// Get the list of of items marked for invalidation 22 | /// 23 | public List KeysMarkedForInvalidation { get; } = new List(); 24 | 25 | private readonly IValidatorValueStore _validatorValueStore; 26 | 27 | public ValidatorValueInvalidator(IValidatorValueStore validatorValueStore) 28 | { 29 | _validatorValueStore = validatorValueStore 30 | ?? throw new ArgumentNullException(nameof(validatorValueStore)); 31 | } 32 | 33 | /// 34 | /// Mark an item stored with a for invalidation 35 | /// 36 | /// The 37 | /// 38 | public Task MarkForInvalidation(StoreKey storeKey) 39 | { 40 | if (storeKey == null) 41 | { 42 | throw new ArgumentNullException(nameof(storeKey)); 43 | } 44 | 45 | KeysMarkedForInvalidation.Add(storeKey); 46 | return Task.CompletedTask; 47 | } 48 | 49 | /// 50 | /// Mark a set of items for invlidation by their collection of 51 | /// 52 | /// The collection of 53 | /// 54 | public Task MarkForInvalidation(IEnumerable storeKeys) 55 | { 56 | if (storeKeys == null) 57 | { 58 | throw new ArgumentNullException(nameof(storeKeys)); 59 | } 60 | 61 | KeysMarkedForInvalidation.AddRange(storeKeys); 62 | 63 | return Task.CompletedTask; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.DistributedStore.Redis.Test/Extensions/ServicesExtensionsFacts.cs: -------------------------------------------------------------------------------- 1 | using Marvin.Cache.Headers.DistributedStore.Interfaces; 2 | using Marvin.Cache.Headers.DistributedStore.Redis.Extensions; 3 | using Marvin.Cache.Headers.DistributedStore.Redis.Options; 4 | using Marvin.Cache.Headers.Test.TestStartups; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Moq; 8 | using StackExchange.Redis; 9 | using System; 10 | using Xunit; 11 | 12 | namespace Marvin.Cache.Headers.DistributedStore.Redis.Test.Extensions; 13 | 14 | public class ServicesExtensionsFacts 15 | { 16 | [Fact] 17 | public void AddRedisKeyRetriever_Throws_An_Argument_Null_Exception_When_The_Services_Parameter_Passed_In_Is_Null() 18 | { 19 | IServiceCollection? services = null; 20 | Action redisDistributedCacheKeyRetrieverOptionsAction =o =>{}; 21 | Assert.Throws(() => services.AddRedisKeyRetriever(redisDistributedCacheKeyRetrieverOptionsAction)); 22 | } 23 | 24 | [Fact] 25 | public void AddRedisKeyRetriever_Throws_An_Argument_Null_Exception_When_The_RedisDistributedCacheKeyRetrieverOptionsAction_Parameter_Passed_In_Is_Null() 26 | { 27 | var services = new Mock(); 28 | Action? redisDistributedCacheKeyRetrieverOptionsAction = null; 29 | Assert.Throws(() => services.Object.AddRedisKeyRetriever(redisDistributedCacheKeyRetrieverOptionsAction)); 30 | } 31 | 32 | [Fact] 33 | public void AddRedisKeyRetriever_Successfully_Registers_All_Required_Services() 34 | { 35 | var connectionMultiplexer = new Mock(); 36 | var host = new WebHostBuilder() 37 | .UseStartup() 38 | .ConfigureServices(services => 39 | { 40 | services.AddSingleton(connectionMultiplexer.Object); 41 | services.AddRedisKeyRetriever(x => { }); 42 | }) 43 | .Build(); 44 | Assert.NotNull(host.Services.GetService(typeof(IRetrieveDistributedCacheKeys))); 45 | } 46 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.DistributedStore.Redis.Test/Marvin.Cache.Headers.DistributedStore.Redis.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0;net7.0;net8.0 5 | Marvin.Cache.Headers.DistributedStore.Redis.Test 6 | Marvin.Cache.Headers.DistributedStore.Redis.Test 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | all 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.DistributedStore.Redis.Test/Stores/RedisDistributedCacheKeyRetrieverFacts.cs: -------------------------------------------------------------------------------- 1 | using Marvin.Cache.Headers.DistributedStore.Redis.Options; 2 | using Marvin.Cache.Headers.DistributedStore.Redis.Stores; 3 | using Microsoft.Extensions.Options; 4 | using Moq; 5 | using StackExchange.Redis; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Threading.Tasks; 10 | using Xunit; 11 | 12 | namespace Marvin.Cache.Headers.DistributedStore.Redis.Test.Stores; 13 | 14 | public class RedisDistributedCacheKeyRetrieverFacts 15 | { 16 | [Fact] 17 | public void Throws_ArgumentNullException_When_A_Null_Connection_Multiplexer_Is_Passed_in() 18 | { 19 | IConnectionMultiplexer connectionMultiplexer = null; 20 | var redisDistributedCacheKeyRetrieverOptions = new Mock>(); 21 | var exception = Record.Exception(() => new RedisDistributedCacheKeyRetriever(connectionMultiplexer, redisDistributedCacheKeyRetrieverOptions.Object)); 22 | Assert.IsType(exception); 23 | } 24 | 25 | [Fact] 26 | public void Throws_ArgumentNullException_When_A_NullRedis_Distributed_Cache_Key_Retriever_Options_Is_Passed_in() 27 | { 28 | var connectionMultiplexer = new Mock(); 29 | IOptions redisDistributedCacheKeyRetrieverOptions = null; 30 | var exception = Record.Exception(() => new RedisDistributedCacheKeyRetriever(connectionMultiplexer.Object, redisDistributedCacheKeyRetrieverOptions)); 31 | Assert.IsType(exception); 32 | } 33 | 34 | [Fact] 35 | public void Throws_ArgumentNullException_When_The_Value_Property_Of_The_Passed_In_RedisDistributedCacheKeyRetrieverOptions_Is_Null() 36 | { 37 | var connectionMultiplexer = new Mock(); 38 | var redisDistributedCacheKeyRetrieverOptions = new Mock>(); 39 | redisDistributedCacheKeyRetrieverOptions.SetupGet(x =>x.Value).Returns((RedisDistributedCacheKeyRetrieverOptions)null); 40 | var exception = Record.Exception(() => new RedisDistributedCacheKeyRetriever(connectionMultiplexer.Object, redisDistributedCacheKeyRetrieverOptions.Object)); 41 | Assert.IsType(exception); 42 | redisDistributedCacheKeyRetrieverOptions.VerifyGet(x =>x.Value, Times.Exactly(1)); 43 | } 44 | 45 | [Fact] 46 | public void Constructs_A_RedisDistributedCacheKeyRetriever_When_All_The_Passed_In_Parameters_Are_Valid() 47 | { 48 | var connectionMultiplexer = new Mock(); 49 | var redisDistributedCacheKeyRetrieverOptions = new Mock>(); 50 | redisDistributedCacheKeyRetrieverOptions.SetupGet(x => x.Value).Returns(new RedisDistributedCacheKeyRetrieverOptions()); 51 | var redisDistributedCacheKeyRetriever = new RedisDistributedCacheKeyRetriever(connectionMultiplexer.Object, redisDistributedCacheKeyRetrieverOptions.Object); 52 | Assert.NotNull(redisDistributedCacheKeyRetriever); 53 | redisDistributedCacheKeyRetrieverOptions.VerifyGet(x => x.Value, Times.Exactly(2)); 54 | } 55 | 56 | [Fact] 57 | public async Task FindStoreKeysByKeyPartAsync_Throws_An_Argument_Null_Exception_When_The_valueToMatch_Passed_in_Is_null() 58 | { 59 | var connectionMultiplexer = new Mock(); 60 | var redisDistributedCacheKeyRetrieverOptions = new Mock>(); 61 | redisDistributedCacheKeyRetrieverOptions.SetupGet(x => x.Value).Returns(new RedisDistributedCacheKeyRetrieverOptions()); 62 | var redisDistributedCacheKeyRetriever = new RedisDistributedCacheKeyRetriever(connectionMultiplexer.Object, redisDistributedCacheKeyRetrieverOptions.Object); 63 | string? valueToMatch = null; 64 | var exception = await CaptureTheExceptionIfOneIsThrownFromAnIAsyncEnumerable(() => redisDistributedCacheKeyRetriever.FindStoreKeysByKeyPartAsync(valueToMatch)); 65 | Assert.IsType(exception); 66 | } 67 | 68 | [Fact] 69 | public async Task FindStoreKeysByKeyPartAsync_Throws_An_Argument_Exception_When_The_valueToMatch_Passed_in_Is_an_empty_string() 70 | { 71 | var connectionMultiplexer = new Mock(); 72 | var redisDistributedCacheKeyRetrieverOptions = new Mock>(); 73 | redisDistributedCacheKeyRetrieverOptions.SetupGet(x => x.Value).Returns(new RedisDistributedCacheKeyRetrieverOptions()); 74 | var redisDistributedCacheKeyRetriever = new RedisDistributedCacheKeyRetriever(connectionMultiplexer.Object, redisDistributedCacheKeyRetrieverOptions.Object); 75 | var valueToMatch = String.Empty; 76 | var exception = await CaptureTheExceptionIfOneIsThrownFromAnIAsyncEnumerable(() => redisDistributedCacheKeyRetriever.FindStoreKeysByKeyPartAsync(valueToMatch)); 77 | Assert.IsType(exception); 78 | } 79 | 80 | [Theory, CombinatorialData] 81 | public async Task FindStoreKeysByKeyPartAsync_Returns_An_Empty_Collection_Of_Keys_When_No_Servers_Are_available(bool onlyUseReplicas) 82 | { 83 | var connectionMultiplexer = new Mock(); 84 | connectionMultiplexer.Setup(x => x.GetServers()).Returns(Array.Empty()); 85 | var redisDistributedCacheKeyRetrieverOptions = new Mock>(); 86 | var redisDistributedCacheKeyRetrieverOptionsValue = new RedisDistributedCacheKeyRetrieverOptions 87 | { 88 | OnlyUseReplicas = onlyUseReplicas 89 | }; 90 | redisDistributedCacheKeyRetrieverOptions.SetupGet(x => x.Value).Returns(redisDistributedCacheKeyRetrieverOptionsValue); 91 | var redisDistributedCacheKeyRetriever = new RedisDistributedCacheKeyRetriever(connectionMultiplexer.Object, redisDistributedCacheKeyRetrieverOptions.Object); 92 | var valueToMatch = "test"; 93 | var result = redisDistributedCacheKeyRetriever.FindStoreKeysByKeyPartAsync(valueToMatch); 94 | var hasKeys = await result.AnyAsync(); 95 | Assert.False(hasKeys); 96 | connectionMultiplexer.Verify(x => x.GetServers(), Times.Exactly(1)); 97 | } 98 | 99 | [Theory, CombinatorialData] 100 | public async Task FindStoreKeysByKeyPartAsync_Returns_An_Empty_Collection_Of_Keys_When_At_Least_One_Server_Is_Available_But_No_Keys_Exist_On_Any_Of_The_Available_Servers_That_Match_The_Past_in_Value_To_Match_In_The_Database_Specified_In_The_Options_Passed_to_The_Constructor(bool onlyUseReplicas, bool ignoreCase, [CombinatorialRange(1, 2)] int numberOfServers) 101 | { 102 | var valueToMatch = "TestKey"; 103 | var valueToMatchWithPattern = GetValueToMatchWithPattern(valueToMatch, ignoreCase); 104 | var redisDistributedCacheKeyRetrieverOptionsValue = new RedisDistributedCacheKeyRetrieverOptions 105 | { 106 | OnlyUseReplicas = onlyUseReplicas, 107 | Database = 0 108 | }; 109 | 110 | var connectionMultiplexer = new Mock(); 111 | var servers = Enumerable.Range(0, numberOfServers - 1) 112 | .Select(x =>SetupAServer(onlyUseReplicas, redisDistributedCacheKeyRetrieverOptionsValue.Database, valueToMatchWithPattern, Enumerable.Empty())).ToArray(); 113 | connectionMultiplexer.Setup(x => x.GetServers()).Returns(servers.Select(x =>x.Object).ToArray); 114 | var redisDistributedCacheKeyRetrieverOptions = new Mock>(); 115 | redisDistributedCacheKeyRetrieverOptions.SetupGet(x => x.Value).Returns(redisDistributedCacheKeyRetrieverOptionsValue); 116 | var redisDistributedCacheKeyRetriever = new RedisDistributedCacheKeyRetriever(connectionMultiplexer.Object, redisDistributedCacheKeyRetrieverOptions.Object); 117 | 118 | var result = redisDistributedCacheKeyRetriever.FindStoreKeysByKeyPartAsync(valueToMatch, ignoreCase); 119 | var hasKeys = await result.AnyAsync(); 120 | Assert.False(hasKeys); 121 | connectionMultiplexer.Verify(x => x.GetServers(), Times.Exactly(1)); 122 | 123 | foreach (var server in servers) 124 | { 125 | if (onlyUseReplicas) 126 | { 127 | server.VerifyGet(x => x.IsReplica, Times.Exactly(1)); 128 | } 129 | else 130 | { 131 | server.VerifyGet(x => x.IsReplica, Times.Never); 132 | } 133 | 134 | server.Verify(x => x.KeysAsync(It.Is(v => v == redisDistributedCacheKeyRetrieverOptionsValue.Database), It.Is(v => v == valueToMatchWithPattern), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); 135 | } 136 | } 137 | 138 | [Theory, CombinatorialData] 139 | public async Task FindStoreKeysByKeyPartAsync_Returns_A_Collection_Of_Keys_When_At_Least_One_Server_Is_Available_And_At_Least_One_Key_Exists_On_Any_Of_The_Available_Servers_That_Match_The_Past_in_Value_To_Match_In_The_Database_Specified_In_The_Options_Passed_to_The_Constructor(bool onlyUseReplicas, bool ignoreCase, [CombinatorialRange(1, 2)] int numberOfServers) 140 | { 141 | var rand = Random.Shared; 142 | var valueToMatch = "TestKey"; 143 | var valueToMatchWithPattern = GetValueToMatchWithPattern(valueToMatch, ignoreCase); 144 | var keyPostFixes = Enumerable.Range(0, numberOfServers * 2); 145 | var keysWithPostFixes = keyPostFixes.Select(x => $"{valueToMatch}{x}"); 146 | var groupedKeys = keysWithPostFixes 147 | .Select(x => new KeyValuePair(rand.Next(1, numberOfServers + 1), new RedisKey(x))) 148 | .GroupBy(x => x.Key, y => y.Value); 149 | 150 | var redisDistributedCacheKeyRetrieverOptionsValue = new RedisDistributedCacheKeyRetrieverOptions 151 | { 152 | OnlyUseReplicas = onlyUseReplicas, 153 | Database = 0 154 | }; 155 | 156 | var redisDistributedCacheKeyRetrieverOptions = new Mock>(); 157 | redisDistributedCacheKeyRetrieverOptions.SetupGet(x => x.Value).Returns(redisDistributedCacheKeyRetrieverOptionsValue); 158 | var connectionMultiplexer = new Mock(); 159 | var servers = groupedKeys.Select(groupKey => SetupAServer(redisDistributedCacheKeyRetrieverOptionsValue.OnlyUseReplicas, redisDistributedCacheKeyRetrieverOptionsValue.Database, valueToMatchWithPattern, groupKey)).ToArray(); 160 | connectionMultiplexer.Setup(x => x.GetServers()).Returns(servers.Select(x => x.Object).ToArray); 161 | var redisDistributedCacheKeyRetriever = new RedisDistributedCacheKeyRetriever(connectionMultiplexer.Object, redisDistributedCacheKeyRetrieverOptions.Object); 162 | var result = redisDistributedCacheKeyRetriever.FindStoreKeysByKeyPartAsync(valueToMatch, ignoreCase); 163 | var hasKeys = await result.CountAsync() >0; 164 | Assert.True(hasKeys); 165 | connectionMultiplexer.Verify(x => x.GetServers(), Times.Exactly(1)); 166 | 167 | foreach (var server in servers) 168 | { 169 | if (onlyUseReplicas) 170 | { 171 | server.VerifyGet(x => x.IsReplica, Times.Exactly(1)); 172 | } 173 | else 174 | { 175 | server.VerifyGet(x => x.IsReplica, Times.Never); 176 | } 177 | 178 | server.Verify(x => x.KeysAsync(It.Is(v => v == redisDistributedCacheKeyRetrieverOptionsValue.Database), It.Is(v => v == valueToMatchWithPattern), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); 179 | } 180 | } 181 | 182 | private static Mock SetupAServer(bool isReplica, int database, RedisValue valueToMatchWithPattern, IEnumerable keysToReturn) 183 | { 184 | var server = new Mock(); 185 | server.SetupGet(x => x.IsReplica).Returns(isReplica); 186 | var asyncKeys = keysToReturn.ToAsyncEnumerable(); 187 | server.Setup(x => x.KeysAsync(It.Is(v => v == database), It.Is(v => v == valueToMatchWithPattern), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(asyncKeys); 188 | return server; 189 | } 190 | 191 | private static RedisValue GetValueToMatchWithPattern(string valueToMatch, bool ignoreCase) => ignoreCase ? $"pattern: {valueToMatch.ToLower()}" : $"pattern: {valueToMatch}"; 192 | 193 | private static async Task CaptureTheExceptionIfOneIsThrownFromAnIAsyncEnumerable(Func> sequenceGenerator) 194 | { 195 | try 196 | { 197 | await foreach (var _ in sequenceGenerator()) 198 | { 199 | } 200 | } 201 | catch (Exception e) 202 | { 203 | return e; 204 | } 205 | 206 | return null; 207 | } 208 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.DistributedStore.Redis.Test/TestStartups/DefaultStartup.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace Marvin.Cache.Headers.Test.TestStartups; 10 | 11 | public class DefaultStartup 12 | { 13 | public DefaultStartup() 14 | { 15 | var builder = new ConfigurationBuilder() 16 | .AddEnvironmentVariables(); 17 | 18 | Configuration = builder.Build(); 19 | } 20 | 21 | public IConfigurationRoot Configuration { get; } 22 | 23 | public void ConfigureServices(IServiceCollection services) 24 | { 25 | services.AddControllers(); 26 | 27 | services.AddHttpCacheHeaders(); 28 | } 29 | 30 | public void Configure(IApplicationBuilder app) 31 | { 32 | app.UseRouting(); 33 | 34 | app.UseHttpCacheHeaders(); 35 | 36 | app.UseEndpoints(endpoints => 37 | { 38 | endpoints.MapControllerRoute( 39 | name: "default", 40 | pattern: "{controller=Home}/{action=Index}/{id?}"); 41 | }); 42 | 43 | app.Run(async context => 44 | { 45 | await context.Response.WriteAsync($"Hello from {nameof(DefaultStartup)}"); 46 | }); 47 | } 48 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.DistributedStore.Test/Marvin.Cache.Headers.DistributedStore.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0;net7.0;net8.0 5 | Marvin.Cache.Headers.DistributedStore.Test 6 | Marvin.Cache.Headers.DistributedStore.Test 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.DistributedStore.Test/Stores/DistributedCacheValidatorValueStoreFacts.cs: -------------------------------------------------------------------------------- 1 | using Marvin.Cache.Headers.DistributedStore.Interfaces; 2 | using Marvin.Cache.Headers.DistributedStore.Stores; 3 | using Marvin.Cache.Headers.Interfaces; 4 | using Microsoft.Extensions.Caching.Distributed; 5 | using Moq; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Globalization; 9 | using System.Linq; 10 | using System.Text; 11 | using System.Text.Json; 12 | using System.Threading; 13 | using System.Threading.Tasks; 14 | using Xunit; 15 | 16 | namespace Marvin.Cache.Headers.DistributedStore.Test.Stores; 17 | 18 | public class DistributedCacheValidatorValueStoreFacts 19 | { 20 | [Fact] 21 | public void Ctor_ExpectArgumentNullException_WhenDistributedCacheIsNull() 22 | { 23 | IDistributedCache distributedCache = null; 24 | var distributedCacheKeyRetriever = new Mock(); 25 | var storeKeySerializer = new Mock(); 26 | Assert.Throws(() => new DistributedCacheValidatorValueStore(distributedCache, distributedCacheKeyRetriever.Object,storeKeySerializer.Object)); 27 | } 28 | 29 | [Fact] 30 | public void Ctor_ExpectArgumentNullException_WhenDistributedCacheKeyRetrieverIsNull() 31 | { 32 | var distributedCache = new Mock(); 33 | IRetrieveDistributedCacheKeys distributedCacheKeyRetriever = null; 34 | var storeKeySerializer = new Mock(); 35 | Assert.Throws(() => new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever, storeKeySerializer.Object)); 36 | } 37 | 38 | [Fact] 39 | public void Ctor_ExpectArgumentNullException_WhenStoreKeySerializerIsNull() 40 | { 41 | var distributedCache = new Mock(); 42 | var distributedCacheKeyRetriever = new Mock(); 43 | IStoreKeySerializer storeKeySerializer = null; 44 | Assert.Throws(() => new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer)); 45 | } 46 | 47 | [Fact] 48 | public void Constructs_An_DistributedCacheValidatorValueStore_When_All_The_Parameters_Passed_In_Are_Not_Null() 49 | { 50 | var distributedCache = new Mock(); 51 | var distributedCacheKeyRetriever = new Mock(); 52 | var storeKeySerializer = new Mock(); 53 | DistributedCacheValidatorValueStore distributedCacheValidatorValueStore = null; 54 | var exception = Record.Exception(() => distributedCacheValidatorValueStore =new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object)); 55 | Assert.Null(exception); 56 | Assert.NotNull(distributedCacheValidatorValueStore); 57 | } 58 | 59 | [Fact] 60 | public async Task GetAsync_ExpectArgumentNullException_WhenSoteKeyIsNull() 61 | { 62 | var distributedCache = new Mock(); 63 | var distributedCacheKeyRetriever = new Mock(); 64 | var storeKeySerializer = new Mock(); 65 | StoreKey key = null; 66 | var distributedCacheValidatorValueStore =new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 67 | await Assert.ThrowsAsync(() => distributedCacheValidatorValueStore.GetAsync(key)); 68 | storeKeySerializer.Verify(x =>x.SerializeStoreKey(It.IsAny()), Times.Never); 69 | distributedCache.Verify(x =>x.GetAsync(It.IsAny(), It.IsAny()), Times.Never); 70 | } 71 | 72 | [Fact] 73 | public async Task GetAsync_ExpectNull_WhenTheKeyIsNotInTheCache() 74 | { 75 | var distributedCache = new Mock(); 76 | var distributedCacheKeyRetriever = new Mock(); 77 | var storeKeySerializer = new Mock(); 78 | var storeKey = new StoreKey 79 | { 80 | { "resourcePath", "/v1/gemeenten/11057" }, 81 | { "queryString", string.Empty }, 82 | { "requestHeaderValues", string.Join("-", new List {"text/plain", "gzip"})} 83 | }; 84 | var storeKeyJson = JsonSerializer.Serialize(storeKey); 85 | storeKeySerializer.Setup(x =>x.SerializeStoreKey(storeKey)).Returns(storeKeyJson); 86 | distributedCache.Setup(x => x.GetAsync(storeKeyJson, CancellationToken.None)).Returns(Task.FromResult(null)); 87 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 88 | var result = await distributedCacheValidatorValueStore.GetAsync(storeKey); 89 | Assert.Null(result); 90 | storeKeySerializer.Verify(x => x.SerializeStoreKey(storeKey), Times.Exactly(1)); 91 | distributedCache.Verify(x => x.GetAsync(It.Is(x =>x.Equals(storeKeyJson, StringComparison.InvariantCulture)), It.IsAny()), Times.Once); 92 | } 93 | 94 | [Fact] 95 | public async Task GetAsync_ExpectTheValueToBeReturned_WhenTheKeyIsInTheCache() 96 | { 97 | var distributedCache = new Mock(); 98 | var distributedCacheKeyRetriever = new Mock(); 99 | var storeKeySerializer = new Mock(); 100 | var storeKey = new StoreKey 101 | { 102 | { "resourcePath", "/v1/gemeenten/11057" }, 103 | { "queryString", string.Empty }, 104 | { "requestHeaderValues", string.Join("-", new List {"text/plain", "gzip"})} 105 | }; 106 | var storeKeyJson = JsonSerializer.Serialize(storeKey); 107 | storeKeySerializer.Setup(x => x.SerializeStoreKey(storeKey)).Returns(storeKeyJson); 108 | var referenceTime = new DateTimeOffset(2022, 1, 31, 0, 0, 0, TimeSpan.Zero); 109 | var eTag = new ValidatorValue(new ETag(ETagType.Strong, "Test"), referenceTime); 110 | var eTagString = $"{ETagType.Strong} Value=\"Test\" LastModified={referenceTime.ToString(CultureInfo.InvariantCulture)}"; 111 | var eTagBytes = Encoding.UTF8.GetBytes(eTagString); 112 | distributedCache.Setup(x => x.GetAsync(storeKeyJson, CancellationToken.None)).ReturnsAsync(eTagBytes); 113 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 114 | var result = await distributedCacheValidatorValueStore.GetAsync(storeKey); 115 | Assert.Equal(result.LastModified, eTag.LastModified); 116 | Assert.Equal(result.ETag.ETagType, eTag.ETag.ETagType); 117 | Assert.Equal(result.ETag.Value, eTag.ETag.Value); 118 | storeKeySerializer.Verify(x => x.SerializeStoreKey(storeKey), Times.Exactly(1)); 119 | distributedCache.Verify(x => x.GetAsync(It.Is(x => x.Equals(storeKeyJson, StringComparison.InvariantCulture)), It.IsAny()), Times.Once); 120 | } 121 | 122 | [Fact] 123 | public async Task SetAsync_ExpectArgumentNullException_WhenStoreKeyIsNull() 124 | { 125 | var distributedCache = new Mock(); 126 | var distributedCacheKeyRetriever = new Mock(); 127 | var storeKeySerializer = new Mock(); 128 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 129 | 130 | StoreKey storeKey = null; 131 | var referenceTime = new DateTimeOffset(2022, 1, 31, 0, 0, 0, TimeSpan.Zero); 132 | var eTag = new ValidatorValue(new ETag(ETagType.Strong, "Test"), referenceTime); 133 | await Assert.ThrowsAsync(() => distributedCacheValidatorValueStore.SetAsync(storeKey, eTag)); 134 | storeKeySerializer.Verify(x => x.SerializeStoreKey(storeKey), Times.Never); 135 | distributedCache.Verify(x => x.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); 136 | } 137 | 138 | [Fact] 139 | public async Task SetAsync_ExpectArgumentNullException_WhenValidatorValueIsNull() 140 | { 141 | var distributedCache = new Mock(); 142 | var distributedCacheKeyRetriever = new Mock(); 143 | var storeKeySerializer = new Mock(); 144 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 145 | var storeKey = new StoreKey 146 | { 147 | { "resourcePath", "/v1/gemeenten/11057" }, 148 | { "queryString", string.Empty }, 149 | { "requestHeaderValues", string.Join("-", new List {"text/plain", "gzip"})} 150 | }; 151 | ValidatorValue eTag = null; 152 | await Assert.ThrowsAsync(() =>distributedCacheValidatorValueStore.SetAsync(storeKey, eTag)); 153 | storeKeySerializer.Verify(x => x.SerializeStoreKey(storeKey), Times.Never); 154 | distributedCache.Verify(x => x.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); 155 | } 156 | 157 | [Fact] 158 | public async Task SetAsync_ExpectTheValueToBeAddedToTheCache_WhenTheStoreKeyAndValidatorValueAreBothNotNull() 159 | { 160 | var distributedCache = new Mock(); 161 | var distributedCacheKeyRetriever = new Mock(); 162 | var storeKeySerializer = new Mock(); 163 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 164 | var storeKey = new StoreKey 165 | { 166 | { "resourcePath", "/v1/gemeenten/11057" }, 167 | { "queryString", string.Empty }, 168 | { "requestHeaderValues", string.Join("-", new List {"text/plain", "gzip"})} 169 | }; 170 | var storeKeyJson = JsonSerializer.Serialize(storeKey); 171 | storeKeySerializer.Setup(x => x.SerializeStoreKey(storeKey)).Returns(storeKeyJson); 172 | var referenceTime = new DateTimeOffset(2022, 1, 31, 0, 0, 0, TimeSpan.Zero); 173 | var eTag = new ValidatorValue(new ETag(ETagType.Strong, "Test"), referenceTime); 174 | var eTagString =$"{eTag.ETag.ETagType} Value=\"{eTag.ETag.Value}\" LastModified={eTag.LastModified.ToString(CultureInfo.InvariantCulture)}"; 175 | var eTagBytes = Encoding.UTF8.GetBytes(eTagString); 176 | distributedCache.Setup(x =>x.SetAsync(storeKeyJson, eTagBytes, It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); 177 | await distributedCacheValidatorValueStore.SetAsync(storeKey, eTag); 178 | storeKeySerializer.Verify(x => x.SerializeStoreKey(storeKey), Times.Exactly(1)); 179 | distributedCache.Verify(x => x.SetAsync(It.Is(x =>x.Equals(storeKeyJson, StringComparison.InvariantCulture)), It.Is(x =>eTagBytes.SequenceEqual(x)), It.IsAny(), It.IsAny()), Times.Exactly(1)); 180 | } 181 | 182 | [Fact] 183 | public async Task RemoveAsync_ExpectArgumentNullException_WhenStoreKeyIsNull() 184 | { 185 | var distributedCache = new Mock(); 186 | var distributedCacheKeyRetriever = new Mock(); 187 | var storeKeySerializer = new Mock(); 188 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 189 | StoreKey storeKey =null; 190 | await Assert.ThrowsAsync(() => distributedCacheValidatorValueStore.RemoveAsync(storeKey)); 191 | storeKeySerializer.Verify(x => x.SerializeStoreKey(storeKey), Times.Never); 192 | distributedCache.Verify(x => x.RemoveAsync(It.IsAny(), It.IsAny()), Times.Never); 193 | } 194 | 195 | [Fact] 196 | public async Task RemoveAsync_ExpectFalseToBeReturned_WhenTheProvidedKeyIsNotInTheCache() 197 | { 198 | var distributedCache = new Mock(); 199 | var distributedCacheKeyRetriever = new Mock(); 200 | var storeKeySerializer = new Mock(); 201 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 202 | var storeKey = new StoreKey 203 | { 204 | { "resourcePath", "/v1/gemeenten/11057" }, 205 | { "queryString", string.Empty }, 206 | { "requestHeaderValues", string.Join("-", new List {"text/plain", "gzip"})} 207 | }; 208 | var storeKeyJson = JsonSerializer.Serialize(storeKey); 209 | storeKeySerializer.Setup(x => x.SerializeStoreKey(storeKey)).Returns(storeKeyJson); 210 | distributedCache.Setup(x => x.GetAsync(It.Is(x => x.Equals(storeKeyJson, StringComparison.InvariantCulture)), It.IsAny())).ReturnsAsync((byte[])null); 211 | 212 | var result = await distributedCacheValidatorValueStore.RemoveAsync(storeKey); 213 | Assert.False(result); 214 | storeKeySerializer.Verify(x => x.SerializeStoreKey(storeKey), Times.Exactly(1)); 215 | distributedCache.Verify(x => x.GetAsync(It.Is(x => x.Equals(storeKeyJson, StringComparison.InvariantCulture)), It.IsAny()), Times.Exactly(1)); 216 | distributedCache.Verify(x => x.RemoveAsync(It.Is(x => x.Equals(storeKeyJson, StringComparison.InvariantCulture)), It.IsAny()), Times.Never); 217 | } 218 | 219 | [Fact] 220 | public async Task RemoveAsync_ExpectTrueToBeReturned_WhenTheKeyIsInTheCacheAndHasBeenRemoved() 221 | { 222 | var distributedCache = new Mock(); 223 | var distributedCacheKeyRetriever = new Mock(); 224 | var storeKeySerializer = new Mock(); 225 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 226 | var storeKey = new StoreKey 227 | { 228 | { "resourcePath", "/v1/gemeenten/11057" }, 229 | { "queryString", string.Empty }, 230 | { "requestHeaderValues", string.Join("-", new List {"text/plain", "gzip"})} 231 | }; 232 | var storeKeyJson = JsonSerializer.Serialize(storeKey); 233 | var referenceTime = new DateTimeOffset(2022, 1, 31, 0, 0, 0, TimeSpan.Zero); 234 | var eTag = new ValidatorValue(new ETag(ETagType.Strong, "Test"), referenceTime); 235 | var eTagString = $"{ETagType.Strong} Value=\"Test\" LastModified={referenceTime.ToString(CultureInfo.InvariantCulture)}"; 236 | var eTagBytes = Encoding.UTF8.GetBytes(eTagString); 237 | storeKeySerializer.Setup(x => x.SerializeStoreKey(storeKey)).Returns(storeKeyJson); 238 | distributedCache.Setup(x => x.GetAsync(It.Is(x => x.Equals(storeKeyJson, StringComparison.InvariantCulture)), It.IsAny())).ReturnsAsync(eTagBytes); 239 | var result = await distributedCacheValidatorValueStore.RemoveAsync(storeKey); 240 | Assert.True(result); 241 | storeKeySerializer.Verify(x => x.SerializeStoreKey(storeKey), Times.Exactly(1)); 242 | distributedCache.Verify(x => x.GetAsync(It.Is(x => x.Equals(storeKeyJson, StringComparison.InvariantCulture)), It.IsAny()), Times.Exactly(1)); 243 | distributedCache.Verify(x => x.RemoveAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); 244 | } 245 | 246 | [Fact] 247 | public async Task FindStoreKeysByKeyPartAsync_ExpectAnArgumentNullException_WhenValueToMatchIsNull() 248 | { 249 | var distributedCache = new Mock(); 250 | var distributedCacheKeyRetriever = new Mock(); 251 | var storeKeySerializer = new Mock(); 252 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 253 | string valueToMatch = null; 254 | var ignoreCase = false; 255 | var exception = await CaptureTheExceptionIfOneIsThrownFromAnIAsyncEnumerable(() =>distributedCacheValidatorValueStore.FindStoreKeysByKeyPartAsync(valueToMatch, ignoreCase)); 256 | Assert.IsType(exception); 257 | distributedCacheKeyRetriever.Verify(x =>x.FindStoreKeysByKeyPartAsync(It.IsAny(), It.IsAny()), Times.Never); 258 | storeKeySerializer.Verify(x => x.DeserializeStoreKey(It.IsAny()), Times.Never); 259 | } 260 | 261 | [Fact] 262 | public async Task FindStoreKeysByKeyPartAsync_ExpectArgumentException_WhenTheValueToMatchIsAnEmptyString() 263 | { 264 | var distributedCache = new Mock(); 265 | var distributedCacheKeyRetriever = new Mock(); 266 | var storeKeySerializer = new Mock(); 267 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 268 | string valueToMatch = String.Empty; 269 | var ignoreCase = false; 270 | var exception = await CaptureTheExceptionIfOneIsThrownFromAnIAsyncEnumerable(() => distributedCacheValidatorValueStore.FindStoreKeysByKeyPartAsync(valueToMatch, ignoreCase)); 271 | Assert.IsType(exception); 272 | distributedCacheKeyRetriever.Verify(x => x.FindStoreKeysByKeyPartAsync(It.IsAny(), It.IsAny()), Times.Never); 273 | storeKeySerializer.Verify(x => x.DeserializeStoreKey(It.IsAny()), Times.Never); 274 | } 275 | 276 | [Theory] 277 | [InlineData("/V1", true)] 278 | [InlineData("/V1", false)] 279 | public async Task FindStoreKeysByKeyPartAsync_AttemptsToFindTheKeysThatStartWithThePassedInKeyPrefix(string keyPrefix, bool ignoreCase) 280 | { 281 | var distributedCache = new Mock(); 282 | var storeKeySerializer = new Mock(); 283 | var storeKey = GenerateStoreKey(keyPrefix); 284 | var serializedStoreKey=JsonSerializer.Serialize(storeKey); 285 | storeKeySerializer 286 | .Setup(x => x.DeserializeStoreKey(It.Is(y => y == serializedStoreKey))) 287 | .Returns(storeKey); 288 | 289 | var distributedCacheKeyRetriever = new Mock(); 290 | distributedCacheKeyRetriever.Setup(x => x.FindStoreKeysByKeyPartAsync(keyPrefix, ignoreCase)).Returns(new[] {serializedStoreKey}.ToAsyncEnumerable); 291 | var distributedCacheValidatorValueStore = new DistributedCacheValidatorValueStore(distributedCache.Object, distributedCacheKeyRetriever.Object, storeKeySerializer.Object); 292 | var isKeyPrefixPresent = await distributedCacheValidatorValueStore.FindStoreKeysByKeyPartAsync(keyPrefix, ignoreCase).AnyAsync(key => IsKeyPrefixPresent(key, keyPrefix, ignoreCase)); 293 | Assert.True(isKeyPrefixPresent); 294 | distributedCacheKeyRetriever.Verify(x => x.FindStoreKeysByKeyPartAsync(keyPrefix, ignoreCase), Times.Exactly(1)); 295 | storeKeySerializer.Verify(x => x.DeserializeStoreKey(It.Is(y => y == serializedStoreKey)), Times.Exactly(1)); 296 | } 297 | 298 | private bool IsKeyPrefixPresent(StoreKey storeKey, string keyPrefix, bool ignoreCase) 299 | { 300 | var result = false; 301 | foreach (var value in storeKey.Values) 302 | { 303 | result = value.StartsWith(keyPrefix, ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); 304 | 305 | if (result) 306 | { 307 | break; 308 | } 309 | } 310 | 311 | return result; 312 | } 313 | 314 | private StoreKey GenerateStoreKey(string prefix) => 315 | new() 316 | { 317 | { "resourcePath", $"{prefix}/gemeenten/11758" }, 318 | { "queryString", string.Empty }, 319 | { "requestHeaderValues", string.Join("-", new List {"text/plain", "gzip"})} 320 | }; 321 | 322 | private static async Task CaptureTheExceptionIfOneIsThrownFromAnIAsyncEnumerable(Func> sequenceGenerator) 323 | { 324 | try 325 | { 326 | await foreach (var _ in sequenceGenerator()) 327 | { 328 | } 329 | } 330 | catch (Exception e) 331 | { 332 | return e; 333 | } 334 | 335 | return null; 336 | } 337 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/DefaultConfigurationFacts.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Marvin.Cache.Headers.Test.TestStartups; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.TestHost; 7 | using Microsoft.Net.Http.Headers; 8 | using System.Linq; 9 | using System.Net; 10 | using System.Threading.Tasks; 11 | using Xunit; 12 | using EntityTagHeaderValue = System.Net.Http.Headers.EntityTagHeaderValue; 13 | 14 | namespace Marvin.Cache.Headers.Test; 15 | 16 | public class DefaultConfigurationFacts 17 | { 18 | private readonly IWebHostBuilder _hostBuilder = new WebHostBuilder() 19 | .UseStartup(); 20 | 21 | private readonly TestServer _server; 22 | 23 | public DefaultConfigurationFacts() 24 | { 25 | _server = new TestServer(_hostBuilder); 26 | } 27 | 28 | [Fact] 29 | public async Task Adds_Default_Validation_And_ExpirationHeaders() 30 | { 31 | using (var client = _server.CreateClient()) 32 | { 33 | var response = await client.GetAsync("/"); 34 | 35 | Assert.True(response.IsSuccessStatusCode); 36 | 37 | Assert.Collection(response.Headers, 38 | pair => Assert.True(pair.Key == HeaderNames.CacheControl && pair.Value.First() == "public, max-age=60"), 39 | pair => Assert.True(pair.Key == HeaderNames.ETag), 40 | pair => 41 | { 42 | Assert.True(pair.Key == HeaderNames.Vary); 43 | Assert.Collection(response.Headers.Vary, 44 | vary => Assert.Equal("Accept", vary), 45 | vary => Assert.Equal("Accept-Language", vary), 46 | vary => Assert.Equal("Accept-Encoding", vary)); 47 | }); 48 | } 49 | } 50 | 51 | [Fact] 52 | public async Task Returns_Same_Etag_For_Same_Request() 53 | { 54 | using (var client = _server.CreateClient()) 55 | { 56 | var response1 = await client.GetAsync("/"); 57 | var response2 = await client.GetAsync("/"); 58 | 59 | Assert.True(response1.IsSuccessStatusCode); 60 | Assert.True(response2.IsSuccessStatusCode); 61 | 62 | Assert.Equal( 63 | response1.Headers.GetValues(HeaderNames.ETag).First(), 64 | response2.Headers.GetValues(HeaderNames.ETag).First()); 65 | } 66 | } 67 | 68 | [Fact] 69 | public async Task Returns_Different_Etag_For_Different_Request() 70 | { 71 | using (var client = _server.CreateClient()) 72 | { 73 | var response1 = await client.GetAsync("/foo"); 74 | var response2 = await client.GetAsync("/bar"); 75 | 76 | Assert.True(response1.IsSuccessStatusCode); 77 | Assert.True(response2.IsSuccessStatusCode); 78 | 79 | Assert.NotEqual( 80 | response1.Headers.GetValues(HeaderNames.ETag).First(), 81 | response2.Headers.GetValues(HeaderNames.ETag).First()); 82 | } 83 | } 84 | 85 | [Theory] 86 | [InlineData(500)] 87 | [InlineData(1000)] 88 | [InlineData(1500)] 89 | public async Task Return_304_When_Request_Is_Cached(int delayInMs) 90 | { 91 | using (var client = _server.CreateClient()) 92 | { 93 | var response1 = await client.GetAsync("/"); 94 | var lastmodified = response1.Content.Headers.LastModified; 95 | var etag = response1.Headers.GetValues(HeaderNames.ETag).First(); 96 | 97 | await Task.Delay(delayInMs); 98 | client.DefaultRequestHeaders.IfNoneMatch.Add(new EntityTagHeaderValue(etag, false)); 99 | client.DefaultRequestHeaders.IfModifiedSince = lastmodified.Value.AddMilliseconds(delayInMs); 100 | var response2 = await client.GetAsync("/"); 101 | 102 | Assert.True(response1.IsSuccessStatusCode); 103 | Assert.True(response2.StatusCode == HttpStatusCode.NotModified); 104 | 105 | Assert.Equal( 106 | response1.Headers.GetValues(HeaderNames.ETag).First(), 107 | response2.Headers.GetValues(HeaderNames.ETag).First()); 108 | } 109 | } 110 | 111 | [Fact] 112 | public async Task Returns_304_NotModified_When_Request_Has_Matching_Etag_And_No_IfModifiedSince() 113 | { 114 | using (var client = _server.CreateClient()) 115 | { 116 | var response1 = await client.GetAsync("/"); 117 | var etag = response1.Headers.GetValues(HeaderNames.ETag).First(); 118 | 119 | client.DefaultRequestHeaders.IfNoneMatch.Add(new EntityTagHeaderValue(etag, false)); 120 | var response2 = await client.GetAsync("/"); 121 | 122 | Assert.True(response1.IsSuccessStatusCode); 123 | Assert.True(response2.StatusCode == HttpStatusCode.NotModified); 124 | } 125 | } 126 | 127 | [Fact] 128 | public async Task Returns_304_NotModified_When_Request_HasValid_IfModifiedSince_AndNo_Etag() 129 | { 130 | using (var client = _server.CreateClient()) 131 | { 132 | var response1 = await client.GetAsync("/"); 133 | var lastmodified = response1.Content.Headers.LastModified; 134 | 135 | client.DefaultRequestHeaders.IfModifiedSince = lastmodified; 136 | var response2 = await client.GetAsync("/"); 137 | 138 | Assert.True(response1.IsSuccessStatusCode); 139 | Assert.True(response2.StatusCode == HttpStatusCode.NotModified); 140 | } 141 | } 142 | 143 | [Fact] 144 | public async Task IfModifiedSince_OnRequest_IsIgnored_When_RequestContains_Valid_IfNoneMatch_Header() 145 | { 146 | using (var client = _server.CreateClient()) 147 | { 148 | var response1 = await client.GetAsync("/"); 149 | var lastmodified = response1.Content.Headers.LastModified; 150 | var etag = response1.Headers.GetValues(HeaderNames.ETag).First(); 151 | 152 | var expiredLastModified = lastmodified.Value.AddMilliseconds(-600000); 153 | client.DefaultRequestHeaders.IfNoneMatch.Add(new EntityTagHeaderValue(etag, false)); 154 | client.DefaultRequestHeaders.IfModifiedSince = expiredLastModified; 155 | var response2 = await client.GetAsync("/"); 156 | 157 | Assert.True(response1.IsSuccessStatusCode); 158 | Assert.True(response2.StatusCode == HttpStatusCode.NotModified); 159 | } 160 | } 161 | 162 | [Fact] 163 | public async Task Returns_200_Ok_When_Request_HasValid_IfModifiedSince_But_Invalid_Etag() 164 | { 165 | using (var client = _server.CreateClient()) 166 | { 167 | var response1 = await client.GetAsync("/"); 168 | var lastmodified = response1.Content.Headers.LastModified; 169 | 170 | client.DefaultRequestHeaders.IfNoneMatch.Add(new EntityTagHeaderValue("\"invalid-etag\"", false)); 171 | client.DefaultRequestHeaders.IfModifiedSince = lastmodified; 172 | var response2 = await client.GetAsync("/"); 173 | 174 | Assert.True(response1.IsSuccessStatusCode); 175 | Assert.True(response2.StatusCode == HttpStatusCode.OK); 176 | } 177 | } 178 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/Extensions/AppBuilderExtensionsFacts.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using Marvin.Cache.Headers.Interfaces; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.TestHost; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Xunit; 11 | 12 | namespace Marvin.Cache.Headers.Test.Extensions; 13 | 14 | public class AppBuilderExtensionsFacts 15 | { 16 | [Fact] 17 | public void Correctly_register_HttpCacheHeadersMiddleware() 18 | { 19 | var hostBuilder = new WebHostBuilder().Configure(app => app.UseHttpCacheHeaders()) 20 | .ConfigureServices(service => 21 | { 22 | service.AddControllers(); 23 | service.AddHttpCacheHeaders(); 24 | }); 25 | var testServer = new TestServer(hostBuilder); 26 | 27 | // not sure this is the correct way to test if the middleware is registered 28 | var middleware = testServer.Host.Services.GetService(typeof(IValidatorValueStore)); 29 | Assert.NotNull(middleware); 30 | } 31 | 32 | [Fact] 33 | public void When_no_ApplicationBuilder_expect_ArgumentNullException() 34 | { 35 | IApplicationBuilder appBuilder = null; 36 | 37 | Assert.Throws(() => appBuilder.UseHttpCacheHeaders()); 38 | } 39 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/Extensions/ServiceExtensionFacts.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using Marvin.Cache.Headers.Interfaces; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.TestHost; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.Options; 11 | using Xunit; 12 | 13 | namespace Marvin.Cache.Headers.Test.Extensions; 14 | 15 | public class ServiceExtensionFacts 16 | { 17 | [Fact] 18 | public void Correctly_register_HttpCacheHeadersMiddleware_as_service() 19 | { 20 | var hostBuilder = 21 | new WebHostBuilder() 22 | .Configure(app => app.UseHttpCacheHeaders()) 23 | .ConfigureServices(service => 24 | { 25 | service.AddControllers(); 26 | service.AddHttpCacheHeaders(); 27 | }); 28 | 29 | var testServer = new TestServer(hostBuilder); 30 | var middleware = testServer.Host.Services.GetService(typeof(IValidatorValueStore)); 31 | Assert.NotNull(middleware); 32 | } 33 | 34 | [Fact] 35 | public void Correctly_register_HttpCacheHeadersMiddleware_as_service_with_ExpirationModelOptions() 36 | { 37 | 38 | var hostBuilder = 39 | new WebHostBuilder() 40 | .Configure(app => app.UseHttpCacheHeaders()) 41 | .ConfigureServices(service => 42 | { 43 | service.AddControllers(); 44 | service.AddHttpCacheHeaders(options => options.MaxAge = 1); 45 | }); 46 | 47 | var testServer = new TestServer(hostBuilder); 48 | 49 | ValidateServiceOptions(testServer, options => options.Value.MaxAge == 1); 50 | } 51 | 52 | [Fact] 53 | public void Correctly_register_HttpCacheHeadersMiddleware_as_service_with_ValidationModelOptions() 54 | { 55 | var hostBuilder = 56 | new WebHostBuilder() 57 | .Configure(app => app.UseHttpCacheHeaders()) 58 | .ConfigureServices(service => 59 | { 60 | service.AddControllers(); 61 | service.AddHttpCacheHeaders(validationModelOptionsAction: options => options.NoCache = true); 62 | }); 63 | 64 | var testServer = new TestServer(hostBuilder); 65 | 66 | ValidateServiceOptions(testServer, options => options.Value.NoCache); 67 | } 68 | 69 | [Fact] 70 | public void Correctly_register_HttpCacheHeadersMiddleware_as_service_with_MiddlewareOptions() 71 | { 72 | var hostBuilder = 73 | new WebHostBuilder() 74 | .Configure(app => app.UseHttpCacheHeaders()) 75 | .ConfigureServices(service => 76 | { 77 | service.AddControllers(); 78 | service.AddHttpCacheHeaders(middlewareOptionsAction: options => options.DisableGlobalHeaderGeneration = true); 79 | }); 80 | 81 | var testServer = new TestServer(hostBuilder); 82 | 83 | ValidateServiceOptions(testServer, options => options.Value.DisableGlobalHeaderGeneration); 84 | } 85 | 86 | [Fact] 87 | public void Correctly_register_HttpCacheHeadersMiddleware_as_service_with_ExpirationModelOptions_and_ValidationModelOptions() 88 | { 89 | var hostBuilder = 90 | new WebHostBuilder() 91 | .Configure(app => app.UseHttpCacheHeaders()) 92 | .ConfigureServices(service => 93 | { 94 | service.AddControllers(); 95 | service.AddHttpCacheHeaders( 96 | options => options.MaxAge = 1, 97 | options => options.NoCache = true); 98 | }); 99 | 100 | var testServer = new TestServer(hostBuilder); 101 | 102 | ValidateServiceOptions(testServer, options => options.Value.MaxAge == 1); 103 | ValidateServiceOptions(testServer, options => options.Value.NoCache); 104 | } 105 | 106 | [Fact] 107 | public void Correctly_register_HttpCacheHeadersMiddleware_as_service_with_ExpirationModelOptions_and_ValidationModelOptions_and_MiddlewareOptions() 108 | { 109 | var hostBuilder = 110 | new WebHostBuilder() 111 | .Configure(app => app.UseHttpCacheHeaders()) 112 | .ConfigureServices(service => 113 | { 114 | service.AddControllers(); 115 | service.AddHttpCacheHeaders( 116 | options => options.MaxAge = 1, 117 | options => options.NoCache = true, 118 | options => options.DisableGlobalHeaderGeneration = true); 119 | }); 120 | 121 | var testServer = new TestServer(hostBuilder); 122 | 123 | ValidateServiceOptions(testServer, options => options.Value.MaxAge == 1); 124 | ValidateServiceOptions(testServer, options => options.Value.NoCache); 125 | ValidateServiceOptions(testServer, options => options.Value.DisableGlobalHeaderGeneration); 126 | } 127 | 128 | private static void ValidateServiceOptions(TestServer testServer, Func, bool> validOptions) where T : class, new() 129 | { 130 | var options = testServer.Host.Services.GetService>(); 131 | Assert.NotNull(options); 132 | Assert.True(validOptions(options)); 133 | } 134 | 135 | [Fact] 136 | public void When_no_ApplicationBuilder_expect_ArgumentNullException() 137 | { 138 | IServiceCollection serviceCollection = null; 139 | 140 | Assert.Throws( 141 | () => serviceCollection.AddHttpCacheHeaders()); 142 | } 143 | 144 | [Fact] 145 | public void When_no_ApplicationBuilder_while_using_ExpirationModelOptions_expect_ArgumentNullException() 146 | { 147 | IServiceCollection serviceCollection = null; 148 | 149 | Assert.Throws( 150 | () => serviceCollection.AddHttpCacheHeaders(options => options.MaxAge = 1)); 151 | } 152 | 153 | [Fact] 154 | public void When_no_ApplicationBuilder_while_using_ValidationModelOptions_expect_ArgumentNullException() 155 | { 156 | IServiceCollection serviceCollection = null; 157 | 158 | Assert.Throws( 159 | () => serviceCollection.AddHttpCacheHeaders(validationModelOptionsAction: options => options.NoCache = true)); 160 | } 161 | 162 | [Fact] 163 | public void When_no_ApplicationBuilder_when_setting_both_ValidationModelOption_and_ExpirationModelOptions_expect_ArgumentNullException() 164 | { 165 | IServiceCollection serviceCollection = null; 166 | 167 | Assert.Throws( 168 | () => serviceCollection.AddHttpCacheHeaders( 169 | options => options.MaxAge = 1, 170 | options => options.NoCache = true)); 171 | } 172 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/IgnoreCachingFacts.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using System.Threading.Tasks; 6 | using Marvin.Cache.Headers.Test.TestStartups; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.TestHost; 9 | using Microsoft.Net.Http.Headers; 10 | using Xunit; 11 | 12 | namespace Marvin.Cache.Headers.Test; 13 | 14 | public class IgnoreCachingFacts 15 | { 16 | [Fact] 17 | public async Task Can_Ignore_Header_Generation_Globally() 18 | { 19 | var server = GetTestServer(null, null, options => options.DisableGlobalHeaderGeneration = true); 20 | 21 | using var client = server.CreateClient(); 22 | 23 | var response = await client.GetAsync("/"); 24 | 25 | Assert.True(response.IsSuccessStatusCode); 26 | Assert.Empty(response.Headers); 27 | Assert.False(string.IsNullOrWhiteSpace(await response.Content.ReadAsStringAsync())); 28 | } 29 | 30 | [Fact] 31 | public async Task Dont_Ignore_Caching_Per_Default() 32 | { 33 | var server = GetTestServer(null, null, null); 34 | 35 | using var client = server.CreateClient(); 36 | 37 | var response = await client.GetAsync("/"); 38 | 39 | Assert.True(response.IsSuccessStatusCode); 40 | Assert.Collection(response.Headers, 41 | pair => Assert.True(pair.Key == HeaderNames.CacheControl), 42 | pair => Assert.True(pair.Key == HeaderNames.ETag), 43 | pair => Assert.True(pair.Key == HeaderNames.Vary)); 44 | Assert.False(string.IsNullOrWhiteSpace(await response.Content.ReadAsStringAsync())); 45 | } 46 | 47 | [Fact] 48 | public async Task Can_Ignore_Status_Codes() 49 | { 50 | var server = GetTestServer(null, null, options => options.IgnoredStatusCodes = new[] { 400, 500 }); 51 | 52 | using var client = server.CreateClient(); 53 | 54 | //Assert ignored status codes 55 | var badRequestResponse = await client.GetAsync("/bad-request"); 56 | 57 | Assert.Equal(400, (int)badRequestResponse.StatusCode); 58 | Assert.Empty(badRequestResponse.Headers); 59 | Assert.False(string.IsNullOrWhiteSpace(await badRequestResponse.Content.ReadAsStringAsync())); 60 | 61 | var serverErrorResponse = await client.GetAsync("/server-error"); 62 | 63 | Assert.Equal(500, (int)serverErrorResponse.StatusCode); 64 | Assert.Empty(serverErrorResponse.Headers); 65 | Assert.False(string.IsNullOrWhiteSpace(await serverErrorResponse.Content.ReadAsStringAsync())); 66 | 67 | //Assert other status codes 68 | var notfoundResult = await client.GetAsync("/not-found"); 69 | 70 | Assert.Equal(404, (int)notfoundResult.StatusCode); 71 | Assert.Collection(notfoundResult.Headers, 72 | pair => Assert.True(pair.Key == HeaderNames.CacheControl), 73 | pair => Assert.True(pair.Key == HeaderNames.Vary)); 74 | Assert.False(string.IsNullOrWhiteSpace(await notfoundResult.Content.ReadAsStringAsync())); 75 | 76 | var successResponse = await client.GetAsync("/"); 77 | 78 | Assert.Equal(200, (int)successResponse.StatusCode); 79 | Assert.Collection(successResponse.Headers, 80 | pair => Assert.True(pair.Key == HeaderNames.CacheControl), 81 | pair => Assert.True(pair.Key == HeaderNames.ETag), 82 | pair => Assert.True(pair.Key == HeaderNames.Vary)); 83 | Assert.False(string.IsNullOrWhiteSpace(await successResponse.Content.ReadAsStringAsync())); 84 | } 85 | 86 | private static TestServer GetTestServer(Action validationModelOptions, Action expirationModelOptions, Action middlewareOptions) 87 | { 88 | var hostBuilder = new WebHostBuilder() 89 | .UseStartup(_ => new ConfiguredStartup(validationModelOptions, expirationModelOptions, middlewareOptions)); 90 | 91 | return new TestServer(hostBuilder); 92 | } 93 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/Injectors/ETagInjectorFacts.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using System.IO; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using Marvin.Cache.Headers.Interfaces; 9 | using Microsoft.AspNetCore.Http; 10 | using Moq; 11 | using Xunit; 12 | 13 | namespace Marvin.Cache.Headers.Test.Stores; 14 | 15 | public class ETagInjectorFacts 16 | { 17 | [Fact] 18 | public void Ctor_ThrowsArgumentNullException_WhenETagGeneratorIsNull() 19 | { 20 | Assert.Throws(() => new DefaultETagInjector(null)); 21 | } 22 | 23 | [Theory] 24 | [InlineData("payload")] 25 | [InlineData("")] 26 | public async Task RetrieveETag_Returns_ETag_BasedOnResponseBody(string payload) 27 | { 28 | // arrange 29 | var eTagGenerator = new Mock(); 30 | 31 | eTagGenerator 32 | .Setup(x => x.GenerateETag(It.IsAny(), It.IsAny())) 33 | .ReturnsAsync(new ETag(ETagType.Strong, "B56")); 34 | 35 | var httpContext = new Mock(); 36 | httpContext.Setup(x => x.HttpContext.Response.Body) 37 | .Returns(new MemoryStream(Encoding.UTF8.GetBytes(payload))); 38 | 39 | var target = new DefaultETagInjector(eTagGenerator.Object); 40 | 41 | // act 42 | var result = await target.RetrieveETag(new ETagContext(new StoreKey(), httpContext.Object.HttpContext)); 43 | 44 | // assert 45 | Assert.NotNull(result); 46 | Assert.Equal(ETagType.Strong, result.ETagType); 47 | Assert.Equal("B56", result.Value); 48 | eTagGenerator.Verify(x => x.GenerateETag(It.IsAny(), payload), Times.Exactly(1)); 49 | httpContext.Verify(x => x.HttpContext.Response.Body, Times.AtLeastOnce()); 50 | } 51 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/Marvin.Cache.Headers.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0;net7.0;net8.0 5 | Marvin.Cache.Headers.Test 6 | Marvin.Cache.Headers.Test 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | all 27 | runtime; build; native; contentfiles; analyzers; buildtransitive 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/MvcConfigurationFacts.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Mvc.Testing; 7 | using Microsoft.Net.Http.Headers; 8 | using Xunit; 9 | 10 | namespace Marvin.Cache.Headers.Test; 11 | 12 | public class MvcConfigurationFacts 13 | { 14 | private readonly WebApplicationFactory _webApplicationFactory =new WebApplicationFactory(); 15 | 16 | [Fact] 17 | public async Task Adds_Default_Validation_And_ExpirationHeaders() 18 | { 19 | using (var client = _webApplicationFactory.CreateDefaultClient()) 20 | { 21 | var response = await client.GetAsync("/api/values"); 22 | 23 | Assert.True(response.IsSuccessStatusCode); 24 | 25 | Assert.Contains(response.Headers, pair => pair.Key == HeaderNames.CacheControl && pair.Value.First() == "public, must-revalidate, max-age=99999"); 26 | 27 | var response2 = await client.GetAsync("/api/values/1"); 28 | 29 | Assert.True(response2.IsSuccessStatusCode); 30 | 31 | Assert.Contains(response2.Headers, pair => pair.Key == HeaderNames.CacheControl && pair.Value.First() == "max-age=1337, private"); 32 | } 33 | } 34 | 35 | [Fact] 36 | public async Task Method_Level_Validation_And_ExpirationHeaders_Override_Class_Level() 37 | { 38 | using (var client = _webApplicationFactory.CreateDefaultClient()) 39 | { 40 | var response = await client.GetAsync("/api/morevalues"); 41 | 42 | Assert.True(response.IsSuccessStatusCode); 43 | 44 | Assert.Contains(response.Headers, pair => pair.Key == HeaderNames.CacheControl && pair.Value.First() == "must-revalidate, max-age=99999, private"); 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/Serialization/DefaultStoreKeySerializerFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using Marvin.Cache.Headers.Serialization; 4 | using Quibble.Xunit; 5 | using Xunit; 6 | 7 | namespace Marvin.Cache.Headers.Test.Serialization; 8 | 9 | public class DefaultStoreKeySerializerFacts 10 | { 11 | private readonly DefaultStoreKeySerializer _storeKeySerializer =new(); 12 | 13 | [Fact] 14 | public void SerializeStoreKey_ThrowsArgumentNullException_WhenKeyToSerializeIsNull() 15 | { 16 | StoreKey keyToSerialize = null; 17 | Assert.Throws(() =>_storeKeySerializer.SerializeStoreKey(keyToSerialize)); 18 | } 19 | 20 | [Fact] 21 | public void SerializeStoreKey_ReturnsTheKeyToSerializeAsJson_WhenStoreKeyIsNotNull() 22 | { 23 | var keyToSerialize = new StoreKey 24 | { 25 | { "testKey", "TestValue" } 26 | }; 27 | const string expectedStoreKeyJson = "{\"testKey\":\"TestValue\"}"; 28 | 29 | var serializedStoreKey = _storeKeySerializer. 30 | SerializeStoreKey(keyToSerialize); 31 | 32 | JsonAssert.Equal(expectedStoreKeyJson, serializedStoreKey); 33 | } 34 | 35 | [Fact] 36 | public void DeserializeStoreKey_ThrowsArgumentNullException_WhenStoreKeyJsonIsNull() 37 | { 38 | string storeKeyJson = null; 39 | Assert.Throws(() => _storeKeySerializer.DeserializeStoreKey(storeKeyJson)); 40 | } 41 | 42 | [Fact] 43 | public void DeserializeStoreKey_ThrowsArgumentException_WhenStoreKeyJsonIsAnEmptyString() 44 | { 45 | var storeKeyJson = String.Empty; 46 | Assert.Throws(() => _storeKeySerializer.DeserializeStoreKey(storeKeyJson)); 47 | } 48 | [Fact] 49 | public void DeserializeStoreKey_ThrowsJsonException_WhenStoreKeyJsonIsInvalid() 50 | { 51 | const string storeKeyJson = "{"; 52 | Assert.Throws(() => _storeKeySerializer.DeserializeStoreKey(storeKeyJson)); 53 | } 54 | 55 | [Fact] 56 | public void DeserializeStoreKey_ReturnsTheStoreKeyJsonAsAStoreKey_WhenTheStoreKeyJsonIsValidJson() 57 | { 58 | var expectedStoreKey = new StoreKey 59 | { 60 | { "testKey", "TestValue" } 61 | }; 62 | const string storeKeyJson = "{\"testKey\":\"TestValue\"}"; 63 | 64 | var deserializedStoreKey = _storeKeySerializer.DeserializeStoreKey(storeKeyJson); 65 | 66 | Assert.Equal(expectedStoreKey, deserializedStoreKey); 67 | } 68 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/Stores/InMemoryValidatorValueStoreFacts.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Marvin.Cache.Headers.Interfaces; 5 | using Marvin.Cache.Headers.Stores; 6 | using Microsoft.Extensions.Caching.Memory; 7 | using Moq; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Text.Json; 11 | using System.Threading.Tasks; 12 | using Xunit; 13 | 14 | namespace Marvin.Cache.Headers.Test.Stores; 15 | 16 | public class InMemoryValidatorValueStoreFacts 17 | { 18 | [Fact] 19 | public void Ctor_ThrowsArgumentNullException_WhenStoreKeySerializerIsNull() 20 | { 21 | IStoreKeySerializer storeKeySerializer = null; 22 | var cache = new Mock(); 23 | Assert.Throws(() =>new InMemoryValidatorValueStore(storeKeySerializer, cache.Object)); 24 | } 25 | 26 | [Fact] 27 | public void Ctor_ThrowsArgumentNullException_WhenCacheIsNull() 28 | { 29 | var storeKeySerializer = new Mock(); 30 | IMemoryCache cache = null; 31 | Assert.Throws(() => new InMemoryValidatorValueStore(storeKeySerializer.Object, cache)); 32 | } 33 | 34 | [Fact] 35 | public async Task GetAsync_Returns_Stored_ValidatorValue() 36 | { 37 | // arrange 38 | var referenceTime = DateTimeOffset.UtcNow; 39 | object validatorValue = new ValidatorValue(new ETag(ETagType.Strong, "test"), referenceTime); 40 | var requestKey = new StoreKey 41 | { 42 | { "resourcePath", "/v1/gemeenten/11057" }, 43 | { "queryString", string.Empty }, 44 | { "requestHeaderValues", string.Join("-", new List {"text/plain", "gzip"})} 45 | }; 46 | 47 | var requestKeyJson =JsonSerializer.Serialize(requestKey); 48 | var storeKeySerializer =new Mock(); 49 | storeKeySerializer.Setup(x =>x.SerializeStoreKey(requestKey)).Returns(requestKeyJson); 50 | var cache = new Mock(); 51 | cache.Setup(x => x.TryGetValue(requestKeyJson, out validatorValue)).Returns(true); 52 | var target = new InMemoryValidatorValueStore(storeKeySerializer.Object, cache.Object); 53 | 54 | // act 55 | var result = await target.GetAsync(requestKey); 56 | 57 | // assert 58 | Assert.NotNull(result); 59 | Assert.Equal(ETagType.Strong, result.ETag.ETagType); 60 | Assert.Equal("test", result.ETag.Value); 61 | Assert.Equal(result.LastModified, referenceTime); 62 | storeKeySerializer.Verify(x => x.SerializeStoreKey(requestKey), Times.Exactly(1)); 63 | cache.Verify(x =>x.TryGetValue(requestKeyJson, out validatorValue), Times.Exactly(1)); 64 | } 65 | 66 | [Fact] 67 | public async Task GetAsync_DoesNotReturn_Unknown_ValidatorValue() 68 | { 69 | // arrange 70 | var referenceTime = DateTimeOffset.UtcNow; 71 | object validatorValue = new ValidatorValue(new ETag(ETagType.Strong, "test"), referenceTime); 72 | var requestKey = new StoreKey 73 | { 74 | { "resourcePath", "/v1/gemeenten/11057" }, 75 | { "queryString", string.Empty }, 76 | { "requestHeaderValues", string.Join("-", new List {"text/plain", "gzip"})} 77 | }; 78 | 79 | var storeKeySerializer = new Mock(); 80 | var requestKeyJson =JsonSerializer.Serialize(requestKey); 81 | storeKeySerializer.Setup(x =>x.SerializeStoreKey(requestKey)).Returns(requestKeyJson); 82 | var cache = new Mock(); 83 | cache.Setup(x => x.TryGetValue(requestKeyJson, out validatorValue)).Returns(false); 84 | var target = new InMemoryValidatorValueStore(storeKeySerializer.Object, cache.Object); 85 | 86 | // act 87 | var result = await target.GetAsync(requestKey); 88 | 89 | // assert 90 | Assert.Null(result); 91 | storeKeySerializer.Verify(x => x.SerializeStoreKey(requestKey), Times.Exactly(1)); 92 | cache.Verify(x => x.TryGetValue(requestKeyJson, out validatorValue), Times.Exactly(1)); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/TestStartups/ConfiguredStartup.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using System; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.Extensions.Configuration; 8 | using Microsoft.Extensions.DependencyInjection; 9 | 10 | namespace Marvin.Cache.Headers.Test.TestStartups; 11 | 12 | public class ConfiguredStartup 13 | { 14 | private readonly Action _validationModelOptions; 15 | private readonly Action _expirationModelOptions; 16 | private readonly Action _middlewareOptions; 17 | 18 | public ConfiguredStartup(Action validationModelOptions, Action expirationModelOptions, Action middlewareOptions) 19 | { 20 | _validationModelOptions = validationModelOptions; 21 | _expirationModelOptions = expirationModelOptions; 22 | _middlewareOptions = middlewareOptions; 23 | 24 | var builder = new ConfigurationBuilder() 25 | .AddEnvironmentVariables(); 26 | 27 | Configuration = builder.Build(); 28 | } 29 | 30 | public IConfigurationRoot Configuration { get; } 31 | 32 | public void ConfigureServices(IServiceCollection services) 33 | { 34 | services.AddControllers(); 35 | 36 | services.AddHttpCacheHeaders(_expirationModelOptions, _validationModelOptions, _middlewareOptions); 37 | } 38 | 39 | public void Configure(IApplicationBuilder app) 40 | { 41 | app.UseRouting(); 42 | 43 | app.UseHttpCacheHeaders(); 44 | 45 | app.UseEndpoints(endpoints => 46 | { 47 | endpoints.MapControllerRoute( 48 | name: "default", 49 | pattern: "{controller=Home}/{action=Index}/{id?}"); 50 | }); 51 | 52 | app.Run(async context => 53 | { 54 | switch (context.Request.Path) 55 | { 56 | case "/bad-request": 57 | context.Response.StatusCode = 400; 58 | break; 59 | case "/server-error": 60 | context.Response.StatusCode = 500; 61 | break; 62 | case "/not-found": 63 | context.Response.StatusCode = 404; 64 | break; 65 | default: 66 | context.Response.StatusCode = 200; 67 | break; 68 | } 69 | 70 | await context.Response.WriteAsync($"Hello from {nameof(DefaultStartup)}"); 71 | }); 72 | } 73 | } -------------------------------------------------------------------------------- /test/Marvin.Cache.Headers.Test/TestStartups/DefaultStartup.cs: -------------------------------------------------------------------------------- 1 | // Any comments, input: @KevinDockx 2 | // Any issues, requests: https://github.com/KevinDockx/HttpCacheHeaders 3 | 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace Marvin.Cache.Headers.Test.TestStartups; 10 | 11 | public class DefaultStartup 12 | { 13 | public DefaultStartup() 14 | { 15 | var builder = new ConfigurationBuilder() 16 | .AddEnvironmentVariables(); 17 | 18 | Configuration = builder.Build(); 19 | } 20 | 21 | public IConfigurationRoot Configuration { get; } 22 | 23 | public void ConfigureServices(IServiceCollection services) 24 | { 25 | services.AddControllers(); 26 | 27 | services.AddHttpCacheHeaders(); 28 | } 29 | 30 | public void Configure(IApplicationBuilder app) 31 | { 32 | app.UseRouting(); 33 | 34 | app.UseHttpCacheHeaders(); 35 | 36 | app.UseEndpoints(endpoints => 37 | { 38 | endpoints.MapControllerRoute( 39 | name: "default", 40 | pattern: "{controller=Home}/{action=Index}/{id?}"); 41 | }); 42 | 43 | app.Run(async context => 44 | { 45 | await context.Response.WriteAsync($"Hello from {nameof(DefaultStartup)}"); 46 | }); 47 | } 48 | } --------------------------------------------------------------------------------