├── .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 | [](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