├── .editorconfig
├── .github
├── ISSUE_TEMPLATE.md
└── PULL_REQUEST_TEMPLATE.md
├── .gitignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Directory.Build.props
├── GitVersion.yml
├── GlobalSuppressions.cs
├── HttpRecorder.Tests
├── Anonymizers
│ └── RulesInteractionAnonymizerTests.cs
├── ContextTests.cs
├── HttpClientFactoryTests.cs
├── HttpRecorder.Tests.csproj
├── HttpRecorderIntegrationTests.cs
├── Matchers
│ └── RulesMatcherUnitTests.cs
├── Server
│ ├── ApiController.cs
│ ├── SampleModel.cs
│ └── Startup.cs
├── ServerCollection.cs
└── ServerFixture.cs
├── HttpRecorder.sln
├── HttpRecorder
├── Anonymizers
│ ├── IInteractionAnonymizer.cs
│ └── RulesInteractionAnonymizer.cs
├── Context
│ ├── HttpRecorderConfiguration.cs
│ ├── HttpRecorderContext.cs
│ ├── HttpRecorderServiceCollectionExtensions.cs
│ └── RecorderHttpMessageHandlerBuilderFilter.cs
├── HttpClientBuilderExtensions.cs
├── HttpContentExtensions.cs
├── HttpRecorder.csproj
├── HttpRecorderDelegatingHandler.cs
├── HttpRecorderException.cs
├── HttpRecorderMode.cs
├── Interaction.cs
├── InteractionMessage.cs
├── InteractionMessageTimings.cs
├── Matchers
│ ├── IRequestMatcher.cs
│ └── RulesMatcher.cs
└── Repositories
│ ├── HAR
│ ├── Content.cs
│ ├── Creator.cs
│ ├── Entry.cs
│ ├── Header.cs
│ ├── HttpArchive.cs
│ ├── HttpArchiveInteractionRepository.cs
│ ├── Log.cs
│ ├── Message.cs
│ ├── Parameter.cs
│ ├── PostData.cs
│ ├── PostedParam.cs
│ ├── QueryParameter.cs
│ ├── Request.cs
│ ├── Response.cs
│ └── Timings.cs
│ └── IInteractionRepository.cs
├── LICENSE
├── README.md
├── TestsSuppressions.cs
├── azure-pipelines.yml
├── icon.png
└── stylecop.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | # Don't use tabs for indentation.
7 | [*]
8 | indent_style = space
9 | # (Please don't specify an indent_size here; that has too many unintended consequences.)
10 |
11 | # Code files
12 | [*.{cs,csx,vb,vbx}]
13 | indent_size = 4
14 | insert_final_newline = true
15 | charset = utf-8-bom
16 |
17 | # XML project files
18 | [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
19 | indent_size = 2
20 |
21 | # XML config files
22 | [*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
23 | indent_size = 2
24 |
25 | # JSON files
26 | [*.json]
27 | indent_size = 2
28 |
29 | # Shell script files
30 | [*.sh]
31 | end_of_line = lf
32 | indent_size = 2
33 |
34 | # Dotnet code style settings:
35 | [*.{cs,vb}]
36 | # Sort using and Import directives with System.* appearing first
37 | dotnet_sort_system_directives_first = true
38 | # Avoid "this." and "Me." if not necessary
39 | dotnet_style_qualification_for_field = false:suggestion
40 | dotnet_style_qualification_for_property = false:suggestion
41 | dotnet_style_qualification_for_method = false:suggestion
42 | dotnet_style_qualification_for_event = false:suggestion
43 |
44 | # Use language keywords instead of framework type names for type references
45 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
46 | dotnet_style_predefined_type_for_member_access = true:suggestion
47 |
48 | # Suggest more modern language features when available
49 | dotnet_style_object_initializer = true:suggestion
50 | dotnet_style_collection_initializer = true:suggestion
51 | dotnet_style_coalesce_expression = true:suggestion
52 | dotnet_style_null_propagation = true:suggestion
53 | dotnet_style_explicit_tuple_names = true:suggestion
54 |
55 | # Non-private static fields are PascalCase
56 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion
57 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields
58 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style
59 |
60 | dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
61 | dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected internal, private protected
62 | dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
63 |
64 | dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case
65 |
66 | # Non-private readonly fields are PascalCase
67 | dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion
68 | dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields
69 | dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style
70 |
71 | dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field
72 | dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected internal, private protected
73 | dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly
74 |
75 | dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case
76 |
77 | # Constants are PascalCase
78 | dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
79 | dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
80 | dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style
81 |
82 | dotnet_naming_symbols.constants.applicable_kinds = field, local
83 | dotnet_naming_symbols.constants.required_modifiers = const
84 |
85 | dotnet_naming_style.constant_style.capitalization = pascal_case
86 |
87 | # Static fields are camelCase
88 | dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion
89 | dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields
90 | dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style
91 |
92 | dotnet_naming_symbols.static_fields.applicable_kinds = field
93 | dotnet_naming_symbols.static_fields.required_modifiers = static
94 |
95 | dotnet_naming_style.static_field_style.capitalization = camel_case
96 |
97 | # Instance fields are camelCase and start with _
98 | dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion
99 | dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields
100 | dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style
101 |
102 | dotnet_naming_symbols.instance_fields.applicable_kinds = field
103 |
104 | dotnet_naming_style.instance_field_style.capitalization = camel_case
105 | dotnet_naming_style.instance_field_style.required_prefix = _
106 |
107 | # Locals and parameters are camelCase
108 | dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion
109 | dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
110 | dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style
111 |
112 | dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
113 |
114 | dotnet_naming_style.camel_case_style.capitalization = camel_case
115 |
116 | # Local functions are PascalCase
117 | dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion
118 | dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions
119 | dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style
120 |
121 | dotnet_naming_symbols.local_functions.applicable_kinds = local_function
122 |
123 | dotnet_naming_style.local_function_style.capitalization = pascal_case
124 |
125 | # By default, name items with PascalCase
126 | dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion
127 | dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members
128 | dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style
129 |
130 | dotnet_naming_symbols.all_members.applicable_kinds = *
131 |
132 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case
133 |
134 | # CSharp code style settings:
135 | [*.cs]
136 | # Indentation preferences
137 | csharp_indent_block_contents = true
138 | csharp_indent_braces = false
139 | csharp_indent_case_contents = true
140 | csharp_indent_case_contents_when_block = true
141 | csharp_indent_switch_labels = true
142 | csharp_indent_labels = flush_left
143 |
144 | # Prefer "var" everywhere
145 | csharp_style_var_for_built_in_types = true:suggestion
146 | csharp_style_var_when_type_is_apparent = true:suggestion
147 | csharp_style_var_elsewhere = true:suggestion
148 |
149 | # Prefer method-like constructs to have a block body
150 | csharp_style_expression_bodied_methods = false:none
151 | csharp_style_expression_bodied_constructors = false:none
152 | csharp_style_expression_bodied_operators = false:none
153 |
154 | # Prefer property-like constructs to have an expression-body
155 | csharp_style_expression_bodied_properties = true:none
156 | csharp_style_expression_bodied_indexers = true:none
157 | csharp_style_expression_bodied_accessors = true:none
158 |
159 | # Suggest more modern language features when available
160 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
161 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
162 | csharp_style_inlined_variable_declaration = true:suggestion
163 | csharp_style_throw_expression = true:suggestion
164 | csharp_style_conditional_delegate_call = true:suggestion
165 |
166 | # Newline settings
167 | csharp_new_line_before_open_brace = all
168 | csharp_new_line_before_else = true
169 | csharp_new_line_before_catch = true
170 | csharp_new_line_before_finally = true
171 | csharp_new_line_before_members_in_object_initializers = true
172 | csharp_new_line_before_members_in_anonymous_types = true
173 | csharp_new_line_between_query_expression_clauses = true
174 |
175 | # Spacing
176 | csharp_space_after_cast = false
177 | csharp_space_after_colon_in_inheritance_clause = true
178 | csharp_space_after_keywords_in_control_flow_statements = true
179 | csharp_space_around_binary_operators = before_and_after
180 | csharp_space_before_colon_in_inheritance_clause = true
181 | csharp_space_between_method_call_empty_parameter_list_parentheses = false
182 | csharp_space_between_method_call_name_and_opening_parenthesis = false
183 | csharp_space_between_method_call_parameter_list_parentheses = false
184 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
185 | csharp_space_between_method_declaration_parameter_list_parentheses = false
186 | csharp_space_between_parentheses = false
187 |
188 | # Blocks are allowed
189 | csharp_prefer_braces = true:silent
190 | csharp_preserve_single_line_blocks = true
191 | csharp_preserve_single_line_statements = true
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Expected Behavior
2 |
3 |
4 | ## Actual Behavior
5 |
6 |
7 | ## Steps to Reproduce the Problem
8 |
9 | 1.
10 | 2.
11 | 3.
12 |
13 | ## Specifications
14 |
15 | - Version:
16 | - Platform:
17 | - Subsystem:
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | GitHub Issue: #
2 |
4 |
5 | ## Proposed Changes
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | ## What is the current behavior?
18 |
20 |
21 |
22 | ## What is the new behavior?
23 |
24 |
25 |
26 | ## Checklist
27 |
28 | Please check if your PR fulfills the following requirements:
29 |
30 | - [ ] Documentation has been added/updated
31 | - [ ] Automated Unit / Integration tests for the changes have been added/updated
32 | - [ ] Contains **NO** breaking changes
33 | - [ ] Updated the Changelog
34 | - [ ] Associated with an issue
35 |
36 |
38 |
39 |
40 | ## Other information
41 |
42 |
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.suo
8 | *.user
9 | *.userosscache
10 | *.sln.docstates
11 |
12 | # User-specific files (MonoDevelop/Xamarin Studio)
13 | *.userprefs
14 |
15 | # Build results
16 | [Dd]ebug/
17 | [Dd]ebugPublic/
18 | [Rr]elease/
19 | [Rr]eleases/
20 | x64/
21 | x86/
22 | bld/
23 | [Bb]in/
24 | [Oo]bj/
25 | [Ll]og/
26 |
27 | # Visual Studio 2015/2017 cache/options directory
28 | .vs/
29 | # Uncomment if you have tasks that create the project's static files in wwwroot
30 | #wwwroot/
31 |
32 | # Visual Studio 2017 auto generated files
33 | Generated\ Files/
34 |
35 | # MSTest test Results
36 | [Tt]est[Rr]esult*/
37 | [Bb]uild[Ll]og.*
38 |
39 | # NUNIT
40 | *.VisualState.xml
41 | TestResult.xml
42 |
43 | # Build Results of an ATL Project
44 | [Dd]ebugPS/
45 | [Rr]eleasePS/
46 | dlldata.c
47 |
48 | # Benchmark Results
49 | BenchmarkDotNet.Artifacts/
50 |
51 | # .NET Core
52 | project.lock.json
53 | project.fragment.lock.json
54 | artifacts/
55 | **/Properties/launchSettings.json
56 |
57 | # StyleCop
58 | StyleCopReport.xml
59 |
60 | # Files built by Visual Studio
61 | *_i.c
62 | *_p.c
63 | *_i.h
64 | *.ilk
65 | *.meta
66 | *.obj
67 | *.iobj
68 | *.pch
69 | *.pdb
70 | *.ipdb
71 | *.pgc
72 | *.pgd
73 | *.rsp
74 | *.sbr
75 | *.tlb
76 | *.tli
77 | *.tlh
78 | *.tmp
79 | *.tmp_proj
80 | *.log
81 | *.vspscc
82 | *.vssscc
83 | .builds
84 | *.pidb
85 | *.svclog
86 | *.scc
87 |
88 | # Chutzpah Test files
89 | _Chutzpah*
90 |
91 | # Visual C++ cache files
92 | ipch/
93 | *.aps
94 | *.ncb
95 | *.opendb
96 | *.opensdf
97 | *.sdf
98 | *.cachefile
99 | *.VC.db
100 | *.VC.VC.opendb
101 |
102 | # Visual Studio profiler
103 | *.psess
104 | *.vsp
105 | *.vspx
106 | *.sap
107 |
108 | # Visual Studio Trace Files
109 | *.e2e
110 |
111 | # TFS 2012 Local Workspace
112 | $tf/
113 |
114 | # Guidance Automation Toolkit
115 | *.gpState
116 |
117 | # ReSharper is a .NET coding add-in
118 | _ReSharper*/
119 | *.[Rr]e[Ss]harper
120 | *.DotSettings.user
121 |
122 | # JustCode is a .NET coding add-in
123 | .JustCode
124 |
125 | # TeamCity is a build add-in
126 | _TeamCity*
127 |
128 | # DotCover is a Code Coverage Tool
129 | *.dotCover
130 |
131 | # AxoCover is a Code Coverage Tool
132 | .axoCover/*
133 | !.axoCover/settings.json
134 |
135 | # Visual Studio code coverage results
136 | *.coverage
137 | *.coveragexml
138 |
139 | # NCrunch
140 | _NCrunch_*
141 | .*crunch*.local.xml
142 | nCrunchTemp_*
143 |
144 | # MightyMoose
145 | *.mm.*
146 | AutoTest.Net/
147 |
148 | # Web workbench (sass)
149 | .sass-cache/
150 |
151 | # Installshield output folder
152 | [Ee]xpress/
153 |
154 | # DocProject is a documentation generator add-in
155 | DocProject/buildhelp/
156 | DocProject/Help/*.HxT
157 | DocProject/Help/*.HxC
158 | DocProject/Help/*.hhc
159 | DocProject/Help/*.hhk
160 | DocProject/Help/*.hhp
161 | DocProject/Help/Html2
162 | DocProject/Help/html
163 |
164 | # Click-Once directory
165 | publish/
166 |
167 | # Publish Web Output
168 | *.[Pp]ublish.xml
169 | *.azurePubxml
170 | # Note: Comment the next line if you want to checkin your web deploy settings,
171 | # but database connection strings (with potential passwords) will be unencrypted
172 | *.pubxml
173 | *.publishproj
174 |
175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
176 | # checkin your Azure Web App publish settings, but sensitive information contained
177 | # in these scripts will be unencrypted
178 | PublishScripts/
179 |
180 | # NuGet Packages
181 | *.nupkg
182 | # The packages folder can be ignored because of Package Restore
183 | **/[Pp]ackages/*
184 | # except build/, which is used as an MSBuild target.
185 | !**/[Pp]ackages/build/
186 | # Uncomment if necessary however generally it will be regenerated when needed
187 | #!**/[Pp]ackages/repositories.config
188 | # NuGet v3's project.json files produces more ignorable files
189 | *.nuget.props
190 | *.nuget.targets
191 |
192 | # Microsoft Azure Build Output
193 | csx/
194 | *.build.csdef
195 |
196 | # Microsoft Azure Emulator
197 | ecf/
198 | rcf/
199 |
200 | # Windows Store app package directories and files
201 | AppPackages/
202 | BundleArtifacts/
203 | Package.StoreAssociation.xml
204 | _pkginfo.txt
205 | *.appx
206 |
207 | # Visual Studio cache files
208 | # files ending in .cache can be ignored
209 | *.[Cc]ache
210 | # but keep track of directories ending in .cache
211 | !*.[Cc]ache/
212 |
213 | # Others
214 | ClientBin/
215 | ~$*
216 | *~
217 | *.dbmdl
218 | *.dbproj.schemaview
219 | *.jfm
220 | *.pfx
221 | *.publishsettings
222 | orleans.codegen.cs
223 |
224 | # Including strong name files can present a security risk
225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
226 | #*.snk
227 |
228 | # Since there are multiple workflows, uncomment next line to ignore bower_components
229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
230 | #bower_components/
231 |
232 | # RIA/Silverlight projects
233 | Generated_Code/
234 |
235 | # Backup & report files from converting an old project file
236 | # to a newer Visual Studio version. Backup files are not needed,
237 | # because we have git ;-)
238 | _UpgradeReport_Files/
239 | Backup*/
240 | UpgradeLog*.XML
241 | UpgradeLog*.htm
242 | ServiceFabricBackup/
243 | *.rptproj.bak
244 |
245 | # SQL Server files
246 | *.mdf
247 | *.ldf
248 | *.ndf
249 |
250 | # Business Intelligence projects
251 | *.rdl.data
252 | *.bim.layout
253 | *.bim_*.settings
254 | *.rptproj.rsuser
255 |
256 | # Microsoft Fakes
257 | FakesAssemblies/
258 |
259 | # GhostDoc plugin setting file
260 | *.GhostDoc.xml
261 |
262 | # Node.js Tools for Visual Studio
263 | .ntvs_analysis.dat
264 | node_modules/
265 |
266 | # Visual Studio 6 build log
267 | *.plg
268 |
269 | # Visual Studio 6 workspace options file
270 | *.opt
271 |
272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
273 | *.vbw
274 |
275 | # Visual Studio LightSwitch build output
276 | **/*.HTMLClient/GeneratedArtifacts
277 | **/*.DesktopClient/GeneratedArtifacts
278 | **/*.DesktopClient/ModelManifest.xml
279 | **/*.Server/GeneratedArtifacts
280 | **/*.Server/ModelManifest.xml
281 | _Pvt_Extensions
282 |
283 | # Paket dependency manager
284 | .paket/paket.exe
285 | paket-files/
286 |
287 | # FAKE - F# Make
288 | .fake/
289 |
290 | # JetBrains Rider
291 | .idea/
292 | *.sln.iml
293 |
294 | # CodeRush
295 | .cr/
296 |
297 | # Python Tools for Visual Studio (PTVS)
298 | __pycache__/
299 | *.pyc
300 |
301 | # Cake - Uncomment if you are using it
302 | # tools/**
303 | # !tools/packages.config
304 |
305 | # Tabs Studio
306 | *.tss
307 |
308 | # Telerik's JustMock configuration file
309 | *.jmconfig
310 |
311 | # BizTalk build output
312 | *.btp.cs
313 | *.btm.cs
314 | *.odx.cs
315 | *.xsd.cs
316 |
317 | # OpenCover UI analysis results
318 | OpenCover/
319 |
320 | # Azure Stream Analytics local run output
321 | ASALocalRun/
322 |
323 | # MSBuild Binary and Structured Log
324 | *.binlog
325 |
326 | # NVidia Nsight GPU debugger configuration file
327 | *.nvuser
328 |
329 | # MFractors (Xamarin productivity tool) working folder
330 | .mfractor/
331 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6 |
7 | ## [Unreleased]
8 |
9 | ### Added
10 |
11 | ### Changed
12 |
13 | ### Deprecated
14 |
15 | ### Removed
16 |
17 | ### Fixed
18 |
19 | ### Security
20 |
21 | ## 2.0.0
22 |
23 | ### Added
24 |
25 | - Added support for the `IHttpClientBuilder` methods.
26 | - Added support for `HttpRecorderContext`.
27 |
28 | ### Changed
29 |
30 | - [BREAKING]: Removed the `innerHandler` parameter in the `RequestsSignatureDelegatingHandler` constructor; you must now use the `InnerHandler` property.
31 | - [BREAKING]: Moved from NewtonSoft to System.Text.Json serialization.
32 |
33 | ### Deprecated
34 |
35 | ### Removed
36 |
37 | ### Fixed
38 |
39 | - `ObjectDisposedException` that can happen on complex interactions involving multiple requests/responses that are being disposed.
40 | - `ArgumentException`: *Object is not a array with the same number of elements as the array to compare it* to when comparing requests by content with different body sizes.
41 |
42 | ### Security
43 |
44 | ## 1.1.0
45 |
46 | ### Added
47 |
48 | - `IInteractionAnonymizer` to anonymize interactions (with `RulesInteractionAnonymizer` implementation)
49 |
50 | ### Changed
51 |
52 | ### Deprecated
53 |
54 | ### Removed
55 |
56 | ### Fixed
57 |
58 | ### Security
59 |
60 | ## 1.0.0
61 |
62 | ### Added
63 |
64 | - `HttpRecorderDelegatingHandler` that drives the interaction recording with 4 record modes: Auto, Record, Replay, Passthrough
65 | - `HttpArchiveInteractionRepository` that store interactions using HAR format
66 | - `RulesMatcher` to allow the customization of interactions matching
67 |
68 | ### Changed
69 |
70 | ### Deprecated
71 |
72 | ### Removed
73 |
74 | ### Fixed
75 |
76 | ### Security
77 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and
9 | expression, level of experience, education, socio-economic status, nationality,
10 | personal appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or reject
41 | comments, commits, code, wiki edits, issues, and other contributions that are
42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any
43 | contributor for other behaviors that they deem inappropriate, threatening,
44 | offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies within all project spaces, and it also applies when
49 | an individual is representing the project or its community in public spaces.
50 | Examples of representing a project or community include using an official
51 | project e-mail address, posting via an official social media account, or acting
52 | as an appointed representative at an online or offline event. Representation of
53 | a project may be further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at info@nventive.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an
62 | incident. Further details of specific enforcement policies may be posted
63 | separately.
64 |
65 | Project maintainers who do not follow or enforce the Code of Conduct in good
66 | faith may face temporary or permanent repercussions as determined by other
67 | members of the project's leadership.
68 |
69 | ## Attribution
70 |
71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
72 | version 1.4, available at
73 | https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
74 |
75 | [homepage]: https://www.contributor-covenant.org
76 |
77 | For answers to common questions about this code of conduct, see
78 | https://www.contributor-covenant.org/faq
79 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to Contribute
2 |
3 | We'd love to accept your patches, contributions and suggestions to this project.
4 | Here are a few small guidelines you need to follow.
5 |
6 | ## Code of conduct
7 |
8 | To better foster an open, innovative and inclusive community please refer to our
9 | [Code of Conduct](CODE_OF_CONDUCT.md) when contributing.
10 |
11 | ### Report a bug
12 |
13 | If you think you've found a bug, please log a new issue in the [GitHub issue
14 | tracker. When filing issues, please use our [issue
15 | template](.github/ISSUE_TEMPLATE.md). The best way to get your bug fixed is to
16 | be as detailed as you can be about the problem. Providing a minimal project with
17 | steps to reproduce the problem is ideal. Here are questions you can answer
18 | before you file a bug to make sure you're not missing any important information.
19 |
20 | 1. Did you read the documentation?
21 | 2. Did you include the snippet of broken code in the issue?
22 | 3. What are the *EXACT* steps to reproduce this problem?
23 | 4. What specific version or build are you using?
24 | 5. What operating system are you using?
25 |
26 | GitHub supports
27 | [markdown](https://help.github.com/articles/github-flavored-markdown/), so when
28 | filing bugs make sure you check the formatting before clicking submit.
29 |
30 | ### Make a suggestion
31 |
32 | If you have an idea for a new feature or enhancement let us know by filing an
33 | issue. To help us understand and prioritize your idea please provide as much
34 | detail about your scenario and why the feature or enhancement would be useful.
35 |
36 | ## Contributing code and content
37 |
38 | This is an open source project and we welcome code and content contributions
39 | from the community.
40 |
41 | **Identifying the scale**
42 |
43 | If you would like to contribute to this project, first identify the scale of
44 | what you would like to contribute. If it is small (grammar/spelling or a bug
45 | fix) feel free to start working on a fix.
46 |
47 | If you are submitting a feature or substantial code contribution, please discuss
48 | it with the team. You might also read these two blogs posts on contributing
49 | code: [Open Source Contribution
50 | Etiquette](http://tirania.org/blog/archive/2010/Dec-31.html) by Miguel de Icaza
51 | and [Don't "Push" Your Pull
52 | Requests](https://www.igvita.com/2011/12/19/dont-push-your-pull-requests/) by
53 | Ilya Grigorik. Note that all code submissions will be rigorously reviewed and
54 | tested by the project team, and only those that meet an extremely high bar for
55 | both quality and design/roadmap appropriateness will be merged into the source.
56 |
57 | **Obtaining the source code**
58 |
59 | If you are an outside contributor, please fork the repository to your account.
60 | See the GitHub documentation for [forking a
61 | repo](https://help.github.com/articles/fork-a-repo/) if you have any questions
62 | about this.
63 |
64 | **Submitting a pull request**
65 |
66 | If you don't know what a pull request is read this article:
67 | https://help.github.com/articles/using-pull-requests. Make sure the repository
68 | can build and all tests pass, as well as follow the current coding guidelines.
69 | When submitting a pull request, please use our [pull request
70 | template](.github/PULL_REQUEST_TEMPLATE.md).
71 |
72 | Pull requests should all be done to the **master** branch.
73 |
74 | ---
75 |
76 | ## Code reviews
77 |
78 | All submissions, including submissions by project members, require review. We
79 | use GitHub pull requests for this purpose. Consult [GitHub
80 | Help](https://help.github.com/articles/about-pull-requests/) for more
81 | information on using pull requests.
82 |
83 | ## Community Guidelines
84 |
85 | This project follows [Google's Open Source Community
86 | Guidelines](https://opensource.google.com/conduct/).
--------------------------------------------------------------------------------
/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | nventive
5 | .NET HttpClient integration tests made easy.
6 | Copyright (c) 2020, nventive
7 | Apache-2.0
8 | HttpRecorder
9 | 8.0
10 | true
11 |
12 | 1701;1702;1998
13 | https://nventive-email-assets.s3.amazonaws.com/packages/nv-package-logo%400%2C25x.png
14 | icon.png
15 | https://github.com/nventive/HttpRecorder
16 | true
17 |
18 |
19 |
20 |
21 | all
22 | runtime; build; native; contentfiles; analyzers
23 |
24 |
25 | all
26 | runtime; build; native; contentfiles; analyzers
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/GitVersion.yml:
--------------------------------------------------------------------------------
1 | mode: ContinuousDeployment
2 | branches:
3 | master:
4 | tag: beta
5 | ignore:
6 | sha: []
7 |
--------------------------------------------------------------------------------
/GlobalSuppressions.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 |
3 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1633:The file header is missing or not located at the top of the file", Justification = "Not needed in this app")]
4 | [assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names must not begin with an underscore", Justification = "Stylistic choice")]
5 | [assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "Stylistic choice")]
6 | [assembly: SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1005:Single line comments must begin with single space", Justification = "Prevents quick edits during development")]
7 | [assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1515:Single-line comment must be preceded by blank line", Justification = "Prevents quick edits during development")]
8 |
9 | [assembly: SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Localization is not in scope.")]
10 | [assembly: SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "HttpClient has some questionable Dispose patterns.")]
11 | [assembly: SuppressMessage("Reliability", "CA2007:Do not directly await a Task", Justification = "Not needed systematically.")]
12 | [assembly: SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Conflicts with Serialization.")]
13 | [assembly: SuppressMessage("Usage", "CA2234:Pass system uri objects instead of strings", Justification = "String is fine - Uri class is sometime cumbersome.")]
14 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/Anonymizers/RulesInteractionAnonymizerTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Net.Http;
5 | using System.Threading.Tasks;
6 | using FluentAssertions;
7 | using HttpRecorder.Anonymizers;
8 | using Xunit;
9 |
10 | namespace HttpRecorder.Tests.Anonymizers
11 | {
12 | public class RulesInteractionAnonymizerTests
13 | {
14 | [Fact]
15 | public async Task ItShouldDoNothingByDefault()
16 | {
17 | var interaction = BuildInteraction(
18 | new HttpRequestMessage { RequestUri = new Uri("http://first") });
19 |
20 | IInteractionAnonymizer anonymizer = RulesInteractionAnonymizer.Default;
21 |
22 | var result = await anonymizer.Anonymize(interaction);
23 | result.Should().BeEquivalentTo(interaction);
24 | }
25 |
26 | [Fact]
27 | public async Task ItShouldAnonymizeRequestQueryStringParameter()
28 | {
29 | var interaction = BuildInteraction(
30 | new HttpRequestMessage { RequestUri = new Uri("http://first/") },
31 | new HttpRequestMessage { RequestUri = new Uri("https://second/?key=foo&value=bar") });
32 |
33 | IInteractionAnonymizer anonymizer = RulesInteractionAnonymizer.Default
34 | .AnonymizeRequestQueryStringParameter("key");
35 |
36 | var result = await anonymizer.Anonymize(interaction);
37 | result.Messages[0].Response.RequestMessage.RequestUri.ToString().Should().Be("http://first/");
38 | result.Messages[1].Response.RequestMessage.RequestUri.ToString().Should().Be($"https://second/?key={RulesInteractionAnonymizer.DefaultAnonymizerReplaceValue}&value=bar");
39 | }
40 |
41 | [Fact]
42 | public async Task ItShouldAnonymizeRequestHeader()
43 | {
44 | var request = new HttpRequestMessage();
45 | request.Headers.TryAddWithoutValidation("X-RequestHeader", "Value");
46 | request.Content = new ByteArrayContent(Array.Empty());
47 | request.Content.Headers.TryAddWithoutValidation("X-RequestHeader", "Value2");
48 | var interaction = BuildInteraction(request);
49 |
50 | IInteractionAnonymizer anonymizer = RulesInteractionAnonymizer.Default
51 | .AnonymizeRequestHeader("X-RequestHeader");
52 |
53 | var result = await anonymizer.Anonymize(interaction);
54 | result.Messages[0].Response.RequestMessage.Headers.GetValues("X-RequestHeader").First().Should().Be(RulesInteractionAnonymizer.DefaultAnonymizerReplaceValue);
55 | }
56 |
57 | private Interaction BuildInteraction(params HttpRequestMessage[] requests)
58 | {
59 | return new Interaction(
60 | "test",
61 | requests.Select(x => new InteractionMessage(
62 | new HttpResponseMessage { RequestMessage = x },
63 | new InteractionMessageTimings(DateTimeOffset.UtcNow, TimeSpan.MinValue))));
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/ContextTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Net.Http;
3 | using System.Threading.Tasks;
4 | using FluentAssertions;
5 | using HttpRecorder.Context;
6 | using HttpRecorder.Tests.Server;
7 | using Microsoft.Extensions.DependencyInjection;
8 | using Xunit;
9 |
10 | namespace HttpRecorder.Tests
11 | {
12 | [Collection(ServerCollection.Name)]
13 | public class ContextTests
14 | {
15 | private readonly ServerFixture _fixture;
16 |
17 | public ContextTests(ServerFixture fixture)
18 | {
19 | _fixture = fixture;
20 | }
21 |
22 | [Fact]
23 | public async Task ItShouldWorkWithHttpRecorderContext()
24 | {
25 | var services = new ServiceCollection();
26 | services
27 | .AddHttpRecorderContextSupport()
28 | .AddHttpClient(
29 | "TheClient",
30 | options =>
31 | {
32 | options.BaseAddress = _fixture.ServerUri;
33 | });
34 |
35 | HttpResponseMessage passthroughResponse = null;
36 | using (var context = new HttpRecorderContext((sp, builder) => new HttpRecorderConfiguration
37 | {
38 | Mode = HttpRecorderMode.Record,
39 | InteractionName = nameof(ItShouldWorkWithHttpRecorderContext),
40 | }))
41 | {
42 | var client = services.BuildServiceProvider().GetRequiredService().CreateClient("TheClient");
43 | passthroughResponse = await client.GetAsync(ApiController.JsonUri);
44 | passthroughResponse.EnsureSuccessStatusCode();
45 | }
46 |
47 | using (var context = new HttpRecorderContext((sp, builder) => new HttpRecorderConfiguration
48 | {
49 | Mode = HttpRecorderMode.Replay,
50 | InteractionName = nameof(ItShouldWorkWithHttpRecorderContext),
51 | }))
52 | {
53 | var client = services.BuildServiceProvider().GetRequiredService().CreateClient("TheClient");
54 | var response = await client.GetAsync(ApiController.JsonUri);
55 | response.EnsureSuccessStatusCode();
56 | response.Should().BeEquivalentTo(passthroughResponse);
57 | }
58 | }
59 |
60 | [Fact]
61 | public async Task ItShouldWorkWithHttpRecorderContextWhenNotRecording()
62 | {
63 | var services = new ServiceCollection();
64 | services
65 | .AddHttpRecorderContextSupport()
66 | .AddHttpClient(
67 | "TheClient",
68 | options =>
69 | {
70 | options.BaseAddress = _fixture.ServerUri;
71 | });
72 |
73 | HttpResponseMessage passthroughResponse = null;
74 | using (var context = new HttpRecorderContext((sp, builder) => new HttpRecorderConfiguration
75 | {
76 | Enabled = false,
77 | Mode = HttpRecorderMode.Record,
78 | InteractionName = nameof(ItShouldWorkWithHttpRecorderContextWhenNotRecording),
79 | }))
80 | {
81 | var client = services.BuildServiceProvider().GetRequiredService().CreateClient("TheClient");
82 | passthroughResponse = await client.GetAsync(ApiController.JsonUri);
83 | passthroughResponse.EnsureSuccessStatusCode();
84 | }
85 |
86 | using (var context = new HttpRecorderContext((sp, builder) => new HttpRecorderConfiguration
87 | {
88 | Mode = HttpRecorderMode.Replay,
89 | InteractionName = nameof(ItShouldWorkWithHttpRecorderContextWhenNotRecording),
90 | }))
91 | {
92 | var client = services.BuildServiceProvider().GetRequiredService().CreateClient("TheClient");
93 | Func act = async () => await client.GetAsync(ApiController.JsonUri);
94 | act.Should().Throw();
95 | }
96 | }
97 |
98 | [Fact]
99 | public void ItShouldNotAllowMultipleContexts()
100 | {
101 | using var context = new HttpRecorderContext();
102 | Action act = () => { var ctx2 = new HttpRecorderContext(); };
103 | act.Should().Throw().WithMessage("*multiple*");
104 | }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/HttpClientFactoryTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Net.Http;
5 | using System.Threading.Tasks;
6 | using HttpRecorder.Tests.Server;
7 | using Microsoft.Extensions.DependencyInjection;
8 | using Xunit;
9 |
10 | namespace HttpRecorder.Tests
11 | {
12 | [Collection(ServerCollection.Name)]
13 | public class HttpClientFactoryTests
14 | {
15 | private readonly ServerFixture _fixture;
16 |
17 | public HttpClientFactoryTests(ServerFixture fixture)
18 | {
19 | _fixture = fixture;
20 | }
21 |
22 | [Fact]
23 | public async Task ItShouldWorkWithHttpClientFactory()
24 | {
25 | var services = new ServiceCollection();
26 | services
27 | .AddHttpClient(
28 | "TheClient",
29 | options =>
30 | {
31 | options.BaseAddress = _fixture.ServerUri;
32 | })
33 | .AddHttpRecorder(nameof(ItShouldWorkWithHttpClientFactory), HttpRecorderMode.Record);
34 |
35 | var client = services.BuildServiceProvider().GetRequiredService().CreateClient("TheClient");
36 | var response = await client.GetAsync(ApiController.JsonUri);
37 | response.EnsureSuccessStatusCode();
38 | }
39 |
40 | [Fact]
41 | public async Task ItShouldWorkWithComplexInteractionsInvolvingDisposedContent()
42 | {
43 | var services = new ServiceCollection();
44 | services
45 | .AddHttpClient(
46 | "TheClient",
47 | options =>
48 | {
49 | options.BaseAddress = _fixture.ServerUri;
50 | })
51 | .AddHttpRecorder(nameof(ItShouldWorkWithHttpClientFactory), HttpRecorderMode.Record);
52 |
53 | var client = services.BuildServiceProvider().GetRequiredService().CreateClient("TheClient");
54 |
55 | var formContent = new FormUrlEncodedContent(new[]
56 | {
57 | new KeyValuePair("name", "TheName"),
58 | });
59 |
60 | var response = await client.PostAsync(ApiController.FormDataUri, formContent);
61 | response.EnsureSuccessStatusCode();
62 | response.Dispose();
63 |
64 | response = await client.PostAsync(ApiController.FormDataUri, formContent);
65 | response.EnsureSuccessStatusCode();
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/HttpRecorder.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | netcoreapp2.2
5 | false
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/HttpRecorderIntegrationTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Net.Http;
5 | using System.Runtime.CompilerServices;
6 | using System.Threading;
7 | using System.Threading.Tasks;
8 | using FluentAssertions;
9 | using HttpRecorder.Anonymizers;
10 | using HttpRecorder.Repositories;
11 | using HttpRecorder.Tests.Server;
12 | using Moq;
13 | using Xunit;
14 |
15 | namespace HttpRecorder.Tests
16 | {
17 | ///
18 | /// integration tests.
19 | /// We do exclude the response Date headers from comparison as not to get skewed
20 | /// by timing issues.
21 | ///
22 | [Collection(ServerCollection.Name)]
23 | public class HttpRecorderIntegrationTests
24 | {
25 | private readonly ServerFixture _fixture;
26 |
27 | public HttpRecorderIntegrationTests(ServerFixture fixture)
28 | {
29 | _fixture = fixture;
30 | }
31 |
32 | [Fact]
33 | public async Task ItShouldGetJson()
34 | {
35 | HttpResponseMessage passthroughResponse = null;
36 |
37 | await ExecuteModeIterations(async (client, mode) =>
38 | {
39 | var response = await client.GetAsync(ApiController.JsonUri);
40 |
41 | response.EnsureSuccessStatusCode();
42 | response.Headers.Remove("Date");
43 | if (mode == HttpRecorderMode.Passthrough)
44 | {
45 | passthroughResponse = response;
46 | var result = await response.Content.ReadAsAsync();
47 | result.Name.Should().Be(SampleModel.DefaultName);
48 | }
49 | else
50 | {
51 | response.Should().BeEquivalentTo(passthroughResponse);
52 | }
53 | });
54 | }
55 |
56 | [Fact]
57 | public async Task ItShouldGetJsonWithQueryString()
58 | {
59 | HttpResponseMessage passthroughResponse = null;
60 | var name = "Bar";
61 |
62 | await ExecuteModeIterations(async (client, mode) =>
63 | {
64 | var response = await client.GetAsync($"{ApiController.JsonUri}?name={name}");
65 |
66 | response.EnsureSuccessStatusCode();
67 | response.Headers.Remove("Date");
68 | if (mode == HttpRecorderMode.Passthrough)
69 | {
70 | passthroughResponse = response;
71 | var result = await response.Content.ReadAsAsync();
72 | result.Name.Should().Be(name);
73 | }
74 | else
75 | {
76 | response.Should().BeEquivalentTo(passthroughResponse);
77 | }
78 | });
79 | }
80 |
81 | [Fact]
82 | public async Task ItShouldPostJson()
83 | {
84 | var sampleModel = new SampleModel();
85 | HttpResponseMessage passthroughResponse = null;
86 |
87 | await ExecuteModeIterations(async (client, mode) =>
88 | {
89 | var response = await client.PostAsJsonAsync(ApiController.JsonUri, sampleModel);
90 |
91 | response.EnsureSuccessStatusCode();
92 | response.Headers.Remove("Date");
93 |
94 | if (mode == HttpRecorderMode.Passthrough)
95 | {
96 | passthroughResponse = response;
97 | var result = await response.Content.ReadAsAsync();
98 | result.Name.Should().Be(sampleModel.Name);
99 | }
100 | else
101 | {
102 | response.Should().BeEquivalentTo(passthroughResponse);
103 | }
104 | });
105 | }
106 |
107 | [Fact]
108 | public async Task ItShouldPostFormData()
109 | {
110 | var sampleModel = new SampleModel();
111 | HttpResponseMessage passthroughResponse = null;
112 |
113 | await ExecuteModeIterations(async (client, mode) =>
114 | {
115 | var formContent = new FormUrlEncodedContent(new[]
116 | {
117 | new KeyValuePair("name", sampleModel.Name),
118 | });
119 |
120 | var response = await client.PostAsync(ApiController.FormDataUri, formContent);
121 |
122 | response.EnsureSuccessStatusCode();
123 | response.Headers.Remove("Date");
124 | if (mode == HttpRecorderMode.Passthrough)
125 | {
126 | passthroughResponse = response;
127 | var result = await response.Content.ReadAsAsync();
128 | result.Name.Should().Be(sampleModel.Name);
129 | }
130 | else
131 | {
132 | response.Should().BeEquivalentTo(passthroughResponse);
133 | }
134 | });
135 | }
136 |
137 | [Fact]
138 | public async Task ItShouldExecuteMultipleRequestsInParallel()
139 | {
140 | const int Concurrency = 10;
141 | IList passthroughResponses = null;
142 |
143 | await ExecuteModeIterations(async (client, mode) =>
144 | {
145 | var tasks = new List>();
146 |
147 | for (var i = 0; i < Concurrency; i++)
148 | {
149 | tasks.Add(client.GetAsync($"{ApiController.JsonUri}?name={i}"));
150 | }
151 |
152 | var responses = await Task.WhenAll(tasks);
153 | foreach (var response in responses)
154 | {
155 | response.Headers.Remove("Date");
156 | }
157 |
158 | if (mode == HttpRecorderMode.Passthrough)
159 | {
160 | passthroughResponses = responses;
161 | for (var i = 0; i < Concurrency; i++)
162 | {
163 | var response = responses[i];
164 | response.EnsureSuccessStatusCode();
165 | var result = await response.Content.ReadAsAsync();
166 | result.Name.Should().Be($"{i}");
167 | }
168 | }
169 | else
170 | {
171 | responses.Should().BeEquivalentTo(passthroughResponses);
172 | }
173 | });
174 | }
175 |
176 | [Fact]
177 | public async Task ItShouldExecuteMultipleRequestsInSequenceWithRecorderModeAuto()
178 | {
179 | // Let's clean the record first if any.
180 | var recordedFileName = $"{nameof(ItShouldExecuteMultipleRequestsInSequenceWithRecorderModeAuto)}.har";
181 | if (File.Exists(recordedFileName))
182 | {
183 | File.Delete(recordedFileName);
184 | }
185 |
186 | var client = CreateHttpClient(
187 | HttpRecorderMode.Auto,
188 | nameof(ItShouldExecuteMultipleRequestsInSequenceWithRecorderModeAuto));
189 | var response1 = await client.GetAsync($"{ApiController.JsonUri}?name=1");
190 | var response2 = await client.GetAsync($"{ApiController.JsonUri}?name=2");
191 | var result1 = await response1.Content.ReadAsAsync();
192 | result1.Name.Should().Be("1");
193 |
194 | var result2 = await response2.Content.ReadAsAsync();
195 | result2.Name.Should().Be("2");
196 |
197 | // We resolve to replay at this point.
198 | client = CreateHttpClient(
199 | HttpRecorderMode.Auto,
200 | nameof(ItShouldExecuteMultipleRequestsInSequenceWithRecorderModeAuto));
201 | var response2_1 = await client.GetAsync($"{ApiController.JsonUri}?name=1");
202 | var response2_2 = await client.GetAsync($"{ApiController.JsonUri}?name=2");
203 |
204 | response2_1.Should().BeEquivalentTo(response1);
205 | response2_2.Should().BeEquivalentTo(response2);
206 | }
207 |
208 | [Fact]
209 | public async Task ItShouldGetBinary()
210 | {
211 | HttpResponseMessage passthroughResponse = null;
212 | var expectedBinaryContent = await File.ReadAllBytesAsync(typeof(ApiController).Assembly.Location);
213 |
214 | await ExecuteModeIterations(async (client, mode) =>
215 | {
216 | var response = await client.GetAsync(ApiController.BinaryUri);
217 |
218 | response.EnsureSuccessStatusCode();
219 | response.Headers.Remove("Date");
220 |
221 | if (mode == HttpRecorderMode.Passthrough)
222 | {
223 | passthroughResponse = response;
224 | var result = await response.Content.ReadAsByteArrayAsync();
225 | result.Should().BeEquivalentTo(expectedBinaryContent);
226 | }
227 | else
228 | {
229 | response.Should().BeEquivalentTo(passthroughResponse);
230 | }
231 | });
232 | }
233 |
234 | [Fact]
235 | public async Task ItShouldThrowIfDoesNotFindFile()
236 | {
237 | const string TestFile = "unknown.file";
238 | var client = CreateHttpClient(HttpRecorderMode.Replay, TestFile);
239 |
240 | Func act = async () => await client.GetAsync(ApiController.JsonUri);
241 |
242 | act.Should().Throw()
243 | .WithMessage($"*{TestFile}*");
244 | }
245 |
246 | [Fact]
247 | public async Task ItShouldThrowIfFileIsCorrupted()
248 | {
249 | var file = typeof(HttpRecorderIntegrationTests).Assembly.Location;
250 | var client = CreateHttpClient(HttpRecorderMode.Replay, file);
251 |
252 | Func act = async () => await client.GetAsync(ApiController.JsonUri);
253 |
254 | act.Should().Throw()
255 | .WithMessage($"*{file}*");
256 | }
257 |
258 | [Fact]
259 | public async Task ItShouldThrowIfNoRequestCanBeMatched()
260 | {
261 | var repositoryMock = new Mock();
262 | repositoryMock.Setup(x => x.ExistsAsync(It.IsAny(), It.IsAny()))
263 | .ReturnsAsync(true);
264 | repositoryMock.Setup(x => x.LoadAsync(It.IsAny(), It.IsAny()))
265 | .Returns((interactionName, _) => Task.FromResult(new Interaction(interactionName)));
266 |
267 | var client = CreateHttpClient(HttpRecorderMode.Replay, repository: repositoryMock.Object);
268 |
269 | Func act = async () => await client.GetAsync(ApiController.JsonUri);
270 |
271 | act.Should().Throw()
272 | .WithMessage($"*{ApiController.JsonUri}*");
273 | }
274 |
275 | [Theory]
276 | [InlineData(202)]
277 | [InlineData(301)]
278 | [InlineData(303)]
279 | [InlineData(404)]
280 | [InlineData(500)]
281 | [InlineData(502)]
282 | public async Task ItShouldGetStatus(int statusCode)
283 | {
284 | HttpResponseMessage passthroughResponse = null;
285 |
286 | await ExecuteModeIterations(async (client, mode) =>
287 | {
288 | var response = await client.GetAsync($"{ApiController.StatusCodeUri}?statusCode={statusCode}");
289 | response.StatusCode.Should().Be(statusCode);
290 | response.Headers.Remove("Date");
291 |
292 | if (mode == HttpRecorderMode.Passthrough)
293 | {
294 | passthroughResponse = response;
295 | }
296 | else
297 | {
298 | response.Should().BeEquivalentTo(passthroughResponse);
299 | }
300 | });
301 | }
302 |
303 | [Fact]
304 | public async Task ItShouldOverrideModeWithEnvironmentVariable()
305 | {
306 | Environment.SetEnvironmentVariable(HttpRecorderDelegatingHandler.OverridingEnvironmentVariableName, HttpRecorderMode.Replay.ToString());
307 | try
308 | {
309 | var client = CreateHttpClient(HttpRecorderMode.Record);
310 |
311 | Func act = () => client.GetAsync(ApiController.JsonUri);
312 |
313 | act.Should().Throw();
314 | }
315 | finally
316 | {
317 | Environment.SetEnvironmentVariable(HttpRecorderDelegatingHandler.OverridingEnvironmentVariableName, string.Empty);
318 | }
319 | }
320 |
321 | [Fact]
322 | public async Task ItShouldAnonymize()
323 | {
324 | var repositoryMock = new Mock();
325 | var client = CreateHttpClient(
326 | HttpRecorderMode.Record,
327 | repository: repositoryMock.Object,
328 | anonymizer: RulesInteractionAnonymizer.Default.AnonymizeRequestQueryStringParameter("key"));
329 | Func act = async () => await client.GetAsync($"{ApiController.JsonUri}?key=foo");
330 | act.Should().Throw(); // Because we don't act on the stream in the repository. That's fine.
331 |
332 | repositoryMock.Verify(
333 | x => x.StoreAsync(
334 | It.Is(i => i.Messages[0].Response.RequestMessage.RequestUri.ToString().EndsWith($"{ApiController.JsonUri}?key={RulesInteractionAnonymizer.DefaultAnonymizerReplaceValue}", StringComparison.Ordinal)),
335 | It.IsAny()));
336 | }
337 |
338 | private async Task ExecuteModeIterations(Func test, [CallerMemberName] string testName = "")
339 | {
340 | var iterations = new[]
341 | {
342 | HttpRecorderMode.Passthrough,
343 | HttpRecorderMode.Record,
344 | HttpRecorderMode.Replay,
345 | HttpRecorderMode.Auto,
346 | };
347 | foreach (var mode in iterations)
348 | {
349 | var client = CreateHttpClient(mode, testName);
350 | await test(client, mode);
351 | }
352 | }
353 |
354 | private HttpClient CreateHttpClient(
355 | HttpRecorderMode mode,
356 | [CallerMemberName] string testName = "",
357 | IInteractionRepository repository = null,
358 | IInteractionAnonymizer anonymizer = null)
359 | => new HttpClient(
360 | new HttpRecorderDelegatingHandler(testName, mode: mode, repository: repository, anonymizer: anonymizer)
361 | {
362 | InnerHandler = new HttpClientHandler(),
363 | })
364 | {
365 | BaseAddress = _fixture.ServerUri,
366 | };
367 | }
368 | }
369 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/Matchers/RulesMatcherUnitTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 | using System.Net.Http;
4 | using FluentAssertions;
5 | using HttpRecorder.Matchers;
6 | using Newtonsoft.Json;
7 | using Xunit;
8 |
9 | namespace HttpRecorder.Tests.Matchers
10 | {
11 | public class RulesMatcherUnitTests
12 | {
13 | [Fact]
14 | public void ItShouldMatchOnce()
15 | {
16 | var interaction = BuildInteraction(
17 | new HttpRequestMessage { RequestUri = new Uri("http://first") },
18 | new HttpRequestMessage { RequestUri = new Uri("http://second") });
19 | var request = new HttpRequestMessage();
20 |
21 | var matcher = RulesMatcher.MatchOnce;
22 |
23 | var result = matcher.Match(request, interaction);
24 |
25 | result.Response.RequestMessage.RequestUri.Should().BeEquivalentTo(new Uri("http://first"));
26 |
27 | result = matcher.Match(request, interaction);
28 |
29 | result.Should().NotBeNull();
30 | result.Response.RequestMessage.RequestUri.Should().BeEquivalentTo(new Uri("http://second"));
31 | }
32 |
33 | [Fact]
34 | public void ItShouldMatchOnceByHttpMethod()
35 | {
36 | var interaction = BuildInteraction(
37 | new HttpRequestMessage(),
38 | new HttpRequestMessage { RequestUri = new Uri("http://first"), Method = HttpMethod.Get },
39 | new HttpRequestMessage { RequestUri = new Uri("http://second"), Method = HttpMethod.Head });
40 | var request = new HttpRequestMessage { Method = HttpMethod.Head };
41 |
42 | var matcher = RulesMatcher.MatchOnce
43 | .ByHttpMethod();
44 |
45 | var result = matcher.Match(request, interaction);
46 |
47 | result.Should().NotBeNull();
48 | result.Response.RequestMessage.Method.Should().BeEquivalentTo(HttpMethod.Head);
49 | }
50 |
51 | [Fact]
52 | public void ItShouldMatchOnceByCompleteRequestUri()
53 | {
54 | var interaction = BuildInteraction(
55 | new HttpRequestMessage(),
56 | new HttpRequestMessage { RequestUri = new Uri("http://first?name=foo") },
57 | new HttpRequestMessage { RequestUri = new Uri("http://first?name=bar") });
58 | var request = new HttpRequestMessage { RequestUri = new Uri("http://first?name=bar") };
59 |
60 | var matcher = RulesMatcher.MatchOnce
61 | .ByRequestUri();
62 |
63 | var result = matcher.Match(request, interaction);
64 |
65 | result.Should().NotBeNull();
66 | result.Response.RequestMessage.RequestUri.Should().BeEquivalentTo(new Uri("http://first?name=bar"));
67 | }
68 |
69 | [Fact]
70 | public void ItShouldMatchOnceByPartialRequestUri()
71 | {
72 | var interaction = BuildInteraction(
73 | new HttpRequestMessage(),
74 | new HttpRequestMessage { RequestUri = new Uri("http://first?name=foo") },
75 | new HttpRequestMessage { RequestUri = new Uri("http://first?name=bar") });
76 | var request = new HttpRequestMessage { RequestUri = new Uri("http://first?name=bar") };
77 |
78 | var matcher = RulesMatcher.MatchOnce
79 | .ByRequestUri(UriPartial.Path);
80 |
81 | var result = matcher.Match(request, interaction);
82 |
83 | result.Should().NotBeNull();
84 | result.Response.RequestMessage.RequestUri.Should().BeEquivalentTo(new Uri("http://first?name=foo"));
85 | }
86 |
87 | [Fact]
88 | public void ItShouldMatchOnceByHeader()
89 | {
90 | var headerName = "If-None-Match";
91 | var firstRequest = new HttpRequestMessage();
92 | firstRequest.Headers.TryAddWithoutValidation(headerName, "first");
93 | var secondRequest = new HttpRequestMessage();
94 | secondRequest.Headers.TryAddWithoutValidation(headerName, "second");
95 | var interaction = BuildInteraction(new HttpRequestMessage(), firstRequest, secondRequest);
96 | var request = new HttpRequestMessage();
97 | request.Headers.TryAddWithoutValidation(headerName, "second");
98 |
99 | var matcher = RulesMatcher.MatchOnce
100 | .ByHeader(headerName);
101 |
102 | var result = matcher.Match(request, interaction);
103 |
104 | result.Should().NotBeNull();
105 | result.Response.RequestMessage.Headers.IfNoneMatch.ToString().Should().Be("second");
106 | }
107 |
108 | [Fact]
109 | public void ItShouldMatchOnceByContentWithSameSize()
110 | {
111 | var firstContent = new ByteArrayContent(new byte[] { 0, 1, 2, 3 });
112 | var secondContent = new ByteArrayContent(new byte[] { 3, 2, 1, 0 });
113 | var interaction = BuildInteraction(
114 | new HttpRequestMessage(),
115 | new HttpRequestMessage { Content = firstContent },
116 | new HttpRequestMessage { Content = secondContent });
117 | var request = new HttpRequestMessage { Content = secondContent };
118 |
119 | var matcher = RulesMatcher.MatchOnce
120 | .ByContent();
121 |
122 | var result = matcher.Match(request, interaction);
123 |
124 | result.Should().NotBeNull();
125 | result.Response.RequestMessage.Content.Should().BeEquivalentTo(secondContent);
126 | }
127 |
128 | [Fact]
129 | public void ItShouldMatchOnceByContentWithDifferentSizes()
130 | {
131 | var firstContent = new ByteArrayContent(new byte[] { 0, 1 });
132 | var secondContent = new ByteArrayContent(new byte[] { 3, 2, 1, 0 });
133 | var interaction = BuildInteraction(
134 | new HttpRequestMessage(),
135 | new HttpRequestMessage { Content = firstContent },
136 | new HttpRequestMessage { Content = secondContent });
137 | var request = new HttpRequestMessage { Content = secondContent };
138 |
139 | var matcher = RulesMatcher.MatchOnce
140 | .ByContent();
141 |
142 | var result = matcher.Match(request, interaction);
143 |
144 | result.Should().NotBeNull();
145 | result.Response.RequestMessage.Content.Should().BeEquivalentTo(secondContent);
146 | }
147 |
148 | [Fact]
149 | public void ItShouldMatchOnceByJsonContent()
150 | {
151 | var firstModel = new Model { Name = "first" };
152 | var secondModel = new Model { Name = "second" };
153 | var firstContent = new StringContent(JsonConvert.SerializeObject(firstModel));
154 | var secondContent = new StringContent(JsonConvert.SerializeObject(secondModel));
155 |
156 | var interaction = BuildInteraction(
157 | new HttpRequestMessage(),
158 | new HttpRequestMessage { Content = firstContent },
159 | new HttpRequestMessage { Content = secondContent });
160 | var request = new HttpRequestMessage { Content = secondContent };
161 |
162 | var matcher = RulesMatcher.MatchOnce
163 | .ByJsonContent();
164 |
165 | var result = matcher.Match(request, interaction);
166 |
167 | result.Should().NotBeNull();
168 | result.Response.RequestMessage.Content.Should().BeEquivalentTo(secondContent);
169 | }
170 |
171 | [Fact]
172 | public void ItShouldWorkWithNoMatch()
173 | {
174 | var interaction = BuildInteraction();
175 | var request = new HttpRequestMessage();
176 |
177 | var matcher = RulesMatcher.MatchOnce
178 | .ByHttpMethod();
179 |
180 | var result = matcher.Match(request, interaction);
181 | result.Should().BeNull();
182 | }
183 |
184 | [Fact]
185 | public void ItShouldMatchMultiple()
186 | {
187 | var interaction = BuildInteraction(
188 | new HttpRequestMessage());
189 | var request = new HttpRequestMessage();
190 |
191 | var matcher = RulesMatcher.MatchMultiple;
192 |
193 | var result = matcher.Match(request, interaction);
194 | result.Should().NotBeNull();
195 |
196 | result = matcher.Match(request, interaction);
197 | result.Should().NotBeNull();
198 | }
199 |
200 | [Fact]
201 | public void ItShouldMatchWithCombination()
202 | {
203 | var interaction = BuildInteraction(
204 | new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = new Uri("http://first") },
205 | new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = new Uri("http://second") });
206 | var request = new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = new Uri("http://second") };
207 |
208 | var matcher = RulesMatcher.MatchOnce
209 | .ByHttpMethod()
210 | .ByRequestUri();
211 |
212 | var result = matcher.Match(request, interaction);
213 | result.Response.RequestMessage.RequestUri.Should().BeEquivalentTo(new Uri("http://second"));
214 | }
215 |
216 | private Interaction BuildInteraction(params HttpRequestMessage[] requests)
217 | {
218 | return new Interaction(
219 | "test",
220 | requests.Select(x => new InteractionMessage(
221 | new HttpResponseMessage { RequestMessage = x },
222 | new InteractionMessageTimings(DateTimeOffset.UtcNow, TimeSpan.MinValue))));
223 | }
224 |
225 | private class Model
226 | {
227 | public string Name { get; set; }
228 |
229 | public override bool Equals(object obj)
230 | {
231 | return Equals(obj as Model);
232 | }
233 |
234 | public bool Equals(Model other)
235 | {
236 | if (other == null)
237 | {
238 | return false;
239 | }
240 |
241 | return string.Equals(Name, other.Name, StringComparison.InvariantCulture);
242 | }
243 |
244 | public override int GetHashCode() => Name == null ? 0 : Name.GetHashCode(StringComparison.InvariantCulture);
245 | }
246 | }
247 | }
248 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/Server/ApiController.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Mvc;
2 |
3 | namespace HttpRecorder.Tests.Server
4 | {
5 | [ApiController]
6 | public class ApiController : ControllerBase
7 | {
8 | public const string JsonUri = "json";
9 | public const string FormDataUri = "formdata";
10 | public const string BinaryUri = "binary";
11 | public const string StatusCodeUri = "status";
12 |
13 | [HttpGet(JsonUri)]
14 | public IActionResult GetJson([FromQuery] string name = null)
15 | => Ok(new SampleModel { Name = name ?? SampleModel.DefaultName });
16 |
17 | [HttpPost(JsonUri)]
18 | public IActionResult PostJson(SampleModel model)
19 | => Ok(model);
20 |
21 | [HttpPost(FormDataUri)]
22 | public IActionResult PostFormData([FromForm] SampleModel model)
23 | => Ok(model);
24 |
25 | [HttpGet(BinaryUri)]
26 | public IActionResult GetBinary()
27 | => PhysicalFile(typeof(ApiController).Assembly.Location, "application/octet-stream");
28 |
29 | [HttpGet(StatusCodeUri)]
30 | public IActionResult GetStatus([FromQuery] int? statusCode = 200)
31 | => StatusCode(statusCode.Value);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/Server/SampleModel.cs:
--------------------------------------------------------------------------------
1 | namespace HttpRecorder.Tests.Server
2 | {
3 | public class SampleModel
4 | {
5 | public const string DefaultName = "Foo";
6 |
7 | public string Name { get; set; } = DefaultName;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/Server/Startup.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using Microsoft.AspNetCore.Builder;
3 | using Microsoft.Extensions.DependencyInjection;
4 |
5 | namespace HttpRecorder.Tests.Server
6 | {
7 | [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Default ASP.Net Core startup class.")]
8 | public class Startup
9 | {
10 | public void ConfigureServices(IServiceCollection services)
11 | {
12 | services.AddMvc();
13 | }
14 |
15 | public void Configure(IApplicationBuilder app)
16 | {
17 | app.UseMvc();
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/ServerCollection.cs:
--------------------------------------------------------------------------------
1 | using Xunit;
2 |
3 | namespace HttpRecorder.Tests
4 | {
5 | [CollectionDefinition(ServerCollection.Name)]
6 | public class ServerCollection : ICollectionFixture
7 | {
8 | public const string Name = "Server";
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/HttpRecorder.Tests/ServerFixture.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 | using HttpRecorder.Tests.Server;
4 | using Microsoft.AspNetCore;
5 | using Microsoft.AspNetCore.Hosting;
6 | using Microsoft.AspNetCore.Hosting.Server.Features;
7 |
8 | namespace HttpRecorder.Tests
9 | {
10 | ///
11 | /// xUnit collection fixture that starts an ASP.NET Core server listening to a random port.
12 | /// .
13 | ///
14 | public class ServerFixture : IDisposable
15 | {
16 | public ServerFixture()
17 | {
18 | ServerWebHost = WebHost
19 | .CreateDefaultBuilder()
20 | .UseStartup()
21 | .UseUrls("http://127.0.0.1:0")
22 | .Build();
23 | ServerWebHost.Start();
24 | }
25 |
26 | public IWebHost ServerWebHost { get; }
27 |
28 | public Uri ServerUri
29 | {
30 | get
31 | {
32 | var serverAddressesFeature = ServerWebHost.ServerFeatures.Get();
33 | return new Uri(serverAddressesFeature.Addresses.First());
34 | }
35 | }
36 |
37 | public void Dispose()
38 | {
39 | if (ServerWebHost != null)
40 | {
41 | ServerWebHost.Dispose();
42 | }
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/HttpRecorder.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 16
4 | VisualStudioVersion = 16.0.28803.452
5 | MinimumVisualStudioVersion = 15.0.26124.0
6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpRecorder", "HttpRecorder\HttpRecorder.csproj", "{ABB84D2D-341F-437B-AD54-DB88A4C5F998}"
7 | EndProject
8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpRecorder.Tests", "HttpRecorder.Tests\HttpRecorder.Tests.csproj", "{5CEB6088-4ACA-467D-8B76-B79F134D2B5C}"
9 | EndProject
10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B37AA433-2E55-4916-971F-04A80C987AE6}"
11 | ProjectSection(SolutionItems) = preProject
12 | .editorconfig = .editorconfig
13 | .gitignore = .gitignore
14 | azure-pipelines.yml = azure-pipelines.yml
15 | CHANGELOG.md = CHANGELOG.md
16 | Directory.Build.props = Directory.Build.props
17 | GitVersion.yml = GitVersion.yml
18 | GlobalSuppressions.cs = GlobalSuppressions.cs
19 | README.md = README.md
20 | stylecop.json = stylecop.json
21 | TestsSuppressions.cs = TestsSuppressions.cs
22 | EndProjectSection
23 | EndProject
24 | Global
25 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
26 | Debug|Any CPU = Debug|Any CPU
27 | Debug|x64 = Debug|x64
28 | Debug|x86 = Debug|x86
29 | Release|Any CPU = Release|Any CPU
30 | Release|x64 = Release|x64
31 | Release|x86 = Release|x86
32 | EndGlobalSection
33 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
34 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
35 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Debug|Any CPU.Build.0 = Debug|Any CPU
36 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Debug|x64.ActiveCfg = Debug|Any CPU
37 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Debug|x64.Build.0 = Debug|Any CPU
38 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Debug|x86.ActiveCfg = Debug|Any CPU
39 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Debug|x86.Build.0 = Debug|Any CPU
40 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Release|Any CPU.ActiveCfg = Release|Any CPU
41 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Release|Any CPU.Build.0 = Release|Any CPU
42 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Release|x64.ActiveCfg = Release|Any CPU
43 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Release|x64.Build.0 = Release|Any CPU
44 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Release|x86.ActiveCfg = Release|Any CPU
45 | {ABB84D2D-341F-437B-AD54-DB88A4C5F998}.Release|x86.Build.0 = Release|Any CPU
46 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
47 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Debug|Any CPU.Build.0 = Debug|Any CPU
48 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Debug|x64.ActiveCfg = Debug|Any CPU
49 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Debug|x64.Build.0 = Debug|Any CPU
50 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Debug|x86.ActiveCfg = Debug|Any CPU
51 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Debug|x86.Build.0 = Debug|Any CPU
52 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Release|Any CPU.ActiveCfg = Release|Any CPU
53 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Release|Any CPU.Build.0 = Release|Any CPU
54 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Release|x64.ActiveCfg = Release|Any CPU
55 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Release|x64.Build.0 = Release|Any CPU
56 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Release|x86.ActiveCfg = Release|Any CPU
57 | {5CEB6088-4ACA-467D-8B76-B79F134D2B5C}.Release|x86.Build.0 = Release|Any CPU
58 | EndGlobalSection
59 | GlobalSection(SolutionProperties) = preSolution
60 | HideSolutionNode = FALSE
61 | EndGlobalSection
62 | GlobalSection(ExtensibilityGlobals) = postSolution
63 | SolutionGuid = {8F6A4695-053E-4745-B537-F254465027F1}
64 | EndGlobalSection
65 | EndGlobal
66 |
--------------------------------------------------------------------------------
/HttpRecorder/Anonymizers/IInteractionAnonymizer.cs:
--------------------------------------------------------------------------------
1 | using System.Threading;
2 | using System.Threading.Tasks;
3 |
4 | namespace HttpRecorder.Anonymizers
5 | {
6 | ///
7 | /// Allows the alteration of to remove confidential parameters before storage.
8 | ///
9 | public interface IInteractionAnonymizer
10 | {
11 | ///
12 | /// Returns a new anonimyzed from .
13 | ///
14 | /// The to anonymize.
15 | /// The .
16 | /// A new anonymized .
17 | Task Anonymize(Interaction interaction, CancellationToken cancellationToken = default);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/HttpRecorder/Anonymizers/RulesInteractionAnonymizer.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading;
5 | using System.Threading.Tasks;
6 | using System.Web;
7 |
8 | namespace HttpRecorder.Anonymizers
9 | {
10 | ///
11 | /// that uses a rule system to conceal sensitive information.
12 | ///
13 | public sealed class RulesInteractionAnonymizer : IInteractionAnonymizer
14 | {
15 | ///
16 | /// The default value to use when replacing values.
17 | ///
18 | public const string DefaultAnonymizerReplaceValue = "******";
19 |
20 | private readonly IEnumerable> _rules;
21 |
22 | private RulesInteractionAnonymizer(
23 | IEnumerable> rules = null)
24 | {
25 | _rules = rules ?? Enumerable.Empty>();
26 | }
27 |
28 | ///
29 | /// Gets the default .
30 | ///
31 | public static RulesInteractionAnonymizer Default { get; } = new RulesInteractionAnonymizer();
32 |
33 | ///
34 | async Task IInteractionAnonymizer.Anonymize(Interaction interaction, CancellationToken cancellationToken)
35 | {
36 | if (!_rules.Any())
37 | {
38 | return interaction;
39 | }
40 |
41 | if (interaction == null)
42 | {
43 | throw new ArgumentNullException(nameof(interaction));
44 | }
45 |
46 | return new Interaction(interaction.Name, interaction.Messages.Select(x =>
47 | {
48 | foreach (var rule in _rules)
49 | {
50 | rule(x);
51 | }
52 |
53 | return x;
54 | }));
55 | }
56 |
57 | ///
58 | /// Add an anomizing rule.
59 | ///
60 | /// The rule to add.
61 | /// A new instance of with the added rule.
62 | public RulesInteractionAnonymizer WithRule(Action rule)
63 | => new RulesInteractionAnonymizer(_rules.Concat(new[] { rule }));
64 |
65 | ///
66 | /// Adds a rule that anonymize a request query parameter.
67 | ///
68 | /// The query parameter name.
69 | /// The replacement pattern. Defaults to .
70 | /// A new instance of with the added rule.
71 | public RulesInteractionAnonymizer AnonymizeRequestQueryStringParameter(string parameterName, string pattern = DefaultAnonymizerReplaceValue)
72 | => WithRule((x) =>
73 | {
74 | if (!string.IsNullOrEmpty(x.Response?.RequestMessage?.RequestUri?.Query))
75 | {
76 | var queryString = HttpUtility.ParseQueryString(x.Response.RequestMessage.RequestUri.Query);
77 | if (!string.IsNullOrEmpty(queryString[parameterName]))
78 | {
79 | queryString[parameterName] = pattern;
80 | x.Response.RequestMessage.RequestUri = new Uri($"{x.Response.RequestMessage.RequestUri.GetLeftPart(UriPartial.Path)}?{queryString}");
81 | }
82 | }
83 | });
84 |
85 | ///
86 | /// Adds a rule that anonymize a query parameter.
87 | ///
88 | /// The header name.
89 | /// The replacement pattern. Defaults to .
90 | /// A new instance of with the added rule.
91 | public RulesInteractionAnonymizer AnonymizeRequestHeader(string headerName, string pattern = DefaultAnonymizerReplaceValue)
92 | => WithRule((x) =>
93 | {
94 | if (x.Response?.RequestMessage == null)
95 | {
96 | return;
97 | }
98 |
99 | if (x.Response.RequestMessage.Headers.Contains(headerName))
100 | {
101 | x.Response.RequestMessage.Headers.Remove(headerName);
102 | x.Response.RequestMessage.Headers.TryAddWithoutValidation(headerName, pattern);
103 | }
104 |
105 | if (x.Response.RequestMessage.Content != null && x.Response.RequestMessage.Content.Headers.Contains(headerName))
106 | {
107 | x.Response.RequestMessage.Content.Headers.Remove(headerName);
108 | x.Response.RequestMessage.Content.Headers.TryAddWithoutValidation(headerName, pattern);
109 | }
110 | });
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/HttpRecorder/Context/HttpRecorderConfiguration.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http;
2 | using HttpRecorder.Anonymizers;
3 | using HttpRecorder.Matchers;
4 | using HttpRecorder.Repositories;
5 | using HttpRecorder.Repositories.HAR;
6 |
7 | namespace HttpRecorder.Context
8 | {
9 | ///
10 | /// Specific configuration for a .
11 | ///
12 | public class HttpRecorderConfiguration
13 | {
14 | ///
15 | /// Gets or sets a value indicating whether recording is enabled.
16 | /// Defaults to true.
17 | ///
18 | public bool Enabled { get; set; } = true;
19 |
20 | ///
21 | /// Gets or sets the name of the interaction.
22 | /// If you use the default , this will be the path to the HAR file (relative or absolute) and
23 | /// if no file extension is provided, .har will be used.
24 | ///
25 | public string InteractionName { get; set; }
26 |
27 | ///
28 | /// Gets or sets the . Defaults to .
29 | ///
30 | public HttpRecorderMode Mode { get; set; } = HttpRecorderMode.Auto;
31 |
32 | ///
33 | /// Gets or sets the to use to match interactions with incoming .
34 | /// Defaults to matching Once by and .
35 | /// and .
36 | ///
37 | public IRequestMatcher Matcher { get; set; }
38 |
39 | ///
40 | /// Gets or sets the to use to read/write the interaction.
41 | /// Defaults to .
42 | ///
43 | public IInteractionRepository Repository { get; set; }
44 |
45 | ///
46 | /// Gets or sets the to use to anonymize the interaction.
47 | /// Defaults to .
48 | ///
49 | public IInteractionAnonymizer Anonymizer { get; set; }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/HttpRecorder/Context/HttpRecorderContext.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Diagnostics.CodeAnalysis;
3 | using System.Net.Http;
4 | using System.Runtime.CompilerServices;
5 | using System.Threading;
6 | using Microsoft.Extensions.Http;
7 |
8 | namespace HttpRecorder.Context
9 | {
10 | ///
11 | /// Sets a global context for the recording.
12 | ///
13 | public sealed class HttpRecorderContext : IDisposable
14 | {
15 | private static HttpRecorderContext _current;
16 |
17 | private static volatile ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
18 |
19 | ///
20 | /// Initializes a new instance of the class.
21 | ///
22 | /// Factory to allow customization per .
23 | /// The .
24 | /// The .
25 | ///
26 | ///
33 | ///
34 | public HttpRecorderContext(
35 | Func configurationFactory = null,
36 | [CallerMemberName] string testName = "",
37 | [CallerFilePath] string filePath = "")
38 | {
39 | ConfigurationFactory = configurationFactory;
40 | TestName = testName;
41 | FilePath = filePath;
42 | _lock.EnterWriteLock();
43 | try
44 | {
45 | if (_current != null)
46 | {
47 | throw new HttpRecorderException(
48 | $"Cannot use multiple {nameof(HttpRecorderContext)} at the same time. Previous usage: {_current.FilePath}, current usage: {filePath}.");
49 | }
50 |
51 | _current = this;
52 | }
53 | finally
54 | {
55 | _lock.ExitWriteLock();
56 | }
57 | }
58 |
59 | ///
60 | /// Gets the current .
61 | ///
62 | public static HttpRecorderContext Current
63 | {
64 | get
65 | {
66 | _lock.EnterReadLock();
67 | try
68 | {
69 | return _current;
70 | }
71 | finally
72 | {
73 | _lock.ExitReadLock();
74 | }
75 | }
76 | }
77 |
78 | ///
79 | /// Gets the configuration factory.
80 | ///
81 | public Func ConfigurationFactory { get; }
82 |
83 | ///
84 | /// Gets the TestName, which should be the .
85 | ///
86 | public string TestName { get; }
87 |
88 | ///
89 | /// Gets the Test file path, which should be the .
90 | ///
91 | public string FilePath { get; }
92 |
93 | ///
94 | [SuppressMessage("Design", "CA1063:Implement IDisposable Correctly", Justification = "Dispose pattern used for context here, not resource diposal.")]
95 | [SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize", Justification = "Dispose pattern used for context here, not resource diposal.")]
96 | public void Dispose()
97 | {
98 | _lock.EnterWriteLock();
99 | try
100 | {
101 | _current = null;
102 | }
103 | finally
104 | {
105 | _lock.ExitWriteLock();
106 | }
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/HttpRecorder/Context/HttpRecorderServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using HttpRecorder.Context;
2 | using Microsoft.Extensions.DependencyInjection.Extensions;
3 | using Microsoft.Extensions.Http;
4 |
5 | namespace Microsoft.Extensions.DependencyInjection
6 | {
7 | ///
8 | /// extension methods.
9 | ///
10 | public static class HttpRecorderServiceCollectionExtensions
11 | {
12 | ///
13 | /// Enables support for the .
14 | ///
15 | /// The .
16 | /// The updated .
17 | public static IServiceCollection AddHttpRecorderContextSupport(this IServiceCollection services)
18 | {
19 | services.TryAddEnumerable(ServiceDescriptor.Singleton());
20 |
21 | return services;
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/HttpRecorder/Context/RecorderHttpMessageHandlerBuilderFilter.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using Microsoft.Extensions.Http;
4 |
5 | namespace HttpRecorder.Context
6 | {
7 | ///
8 | /// that adds
9 | /// based on the value of .
10 | ///
11 | public class RecorderHttpMessageHandlerBuilderFilter : IHttpMessageHandlerBuilderFilter
12 | {
13 | private readonly IServiceProvider _serviceProvider;
14 |
15 | ///
16 | /// Initializes a new instance of the class.
17 | ///
18 | /// The .
19 | public RecorderHttpMessageHandlerBuilderFilter(IServiceProvider serviceProvider)
20 | {
21 | _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
22 | }
23 |
24 | ///
25 | public Action Configure(Action next)
26 | {
27 | return (builder) =>
28 | {
29 | // Run other configuration first, we want to decorate.
30 | next(builder);
31 |
32 | var context = HttpRecorderContext.Current;
33 | if (context is null)
34 | {
35 | return;
36 | }
37 |
38 | var config = context.ConfigurationFactory?.Invoke(_serviceProvider, builder) ?? new HttpRecorderConfiguration();
39 |
40 | if (config.Enabled)
41 | {
42 | var interactionName = config.InteractionName;
43 | if (string.IsNullOrEmpty(interactionName))
44 | {
45 | interactionName = Path.Combine(
46 | Path.GetDirectoryName(context.FilePath),
47 | $"{Path.GetFileNameWithoutExtension(context.FilePath)}Fixtures",
48 | context.TestName,
49 | builder.Name);
50 | }
51 |
52 | builder.AdditionalHandlers.Add(new HttpRecorderDelegatingHandler(
53 | interactionName,
54 | mode: config.Mode,
55 | matcher: config.Matcher,
56 | repository: config.Repository,
57 | anonymizer: config.Anonymizer));
58 | }
59 | };
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/HttpRecorder/HttpClientBuilderExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http;
2 | using HttpRecorder;
3 | using HttpRecorder.Anonymizers;
4 | using HttpRecorder.Matchers;
5 | using HttpRecorder.Repositories;
6 | using HttpRecorder.Repositories.HAR;
7 |
8 | namespace Microsoft.Extensions.DependencyInjection
9 | {
10 | ///
11 | /// extension methods.
12 | ///
13 | public static class HttpClientBuilderExtensions
14 | {
15 | ///
16 | /// Adds as a HttpMessageHandler in the client pipeline.
17 | ///
18 | /// The .
19 | ///
20 | /// The name of the interaction.
21 | /// If you use the default , this will be the path to the HAR file (relative or absolute) and
22 | /// if no file extension is provided, .har will be used.
23 | ///
24 | /// The . Defaults to .
25 | ///
26 | /// The to use to match interactions with incoming .
27 | /// Defaults to matching Once by and .
28 | /// and .
29 | ///
30 | ///
31 | /// The to use to read/write the interaction.
32 | /// Defaults to .
33 | ///
34 | ///
35 | /// The to use to anonymize the interaction.
36 | /// Defaults to .
37 | ///
38 | /// The updated .
39 | public static IHttpClientBuilder AddHttpRecorder(
40 | this IHttpClientBuilder httpClientBuilder,
41 | string interactionName,
42 | HttpRecorderMode mode = HttpRecorderMode.Auto,
43 | IRequestMatcher matcher = null,
44 | IInteractionRepository repository = null,
45 | IInteractionAnonymizer anonymizer = null)
46 | {
47 | var recorder = new HttpRecorderDelegatingHandler(
48 | interactionName,
49 | mode: mode,
50 | matcher: matcher,
51 | repository: repository,
52 | anonymizer: anonymizer);
53 |
54 | return httpClientBuilder.AddHttpMessageHandler((sp) => recorder);
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/HttpRecorder/HttpContentExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http;
2 | using System.Text.RegularExpressions;
3 |
4 | namespace HttpRecorder
5 | {
6 | ///
7 | /// extension methods.
8 | ///
9 | public static class HttpContentExtensions
10 | {
11 | private static readonly Regex BinaryMimeRegex = new Regex("(image/*|audio/*|video/*|application/octet-stream|multipart/form-data)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
12 | private static readonly Regex FormDataMimeRegex = new Regex("application/x-www-form-urlencoded", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
13 |
14 | ///
15 | /// Indicates whether represents binary content.
16 | ///
17 | /// The .
18 | /// true if it is binary, false otherwise.
19 | public static bool IsBinary(this HttpContent content)
20 | {
21 | var contentType = content?.Headers?.ContentType?.MediaType;
22 | if (string.IsNullOrWhiteSpace(contentType))
23 | {
24 | return false;
25 | }
26 |
27 | return BinaryMimeRegex.IsMatch(contentType);
28 | }
29 |
30 | ///
31 | /// Indicates whether represents form data (URL-encoded) content.
32 | ///
33 | /// The .
34 | /// true if it is form data, false otherwise.
35 | public static bool IsFormData(this HttpContent content)
36 | {
37 | var contentType = content?.Headers?.ContentType?.MediaType;
38 | if (string.IsNullOrWhiteSpace(contentType))
39 | {
40 | return false;
41 | }
42 |
43 | return FormDataMimeRegex.IsMatch(contentType);
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/HttpRecorder/HttpRecorder.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | netstandard2.0
5 | true
6 | true
7 | snupkg
8 | true
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/HttpRecorder/HttpRecorderDelegatingHandler.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Diagnostics;
3 | using System.IO;
4 | using System.Linq;
5 | using System.Net.Http;
6 | using System.Threading;
7 | using System.Threading.Tasks;
8 | using HttpRecorder.Anonymizers;
9 | using HttpRecorder.Matchers;
10 | using HttpRecorder.Repositories;
11 | using HttpRecorder.Repositories.HAR;
12 |
13 | namespace HttpRecorder
14 | {
15 | ///
16 | /// that records HTTP interactions for integration tests.
17 | ///
18 | public class HttpRecorderDelegatingHandler : DelegatingHandler
19 | {
20 | ///
21 | /// Gets the name of the environment variable that allows overriding of the .
22 | ///
23 | public const string OverridingEnvironmentVariableName = "HTTP_RECORDER_MODE";
24 |
25 | private readonly IRequestMatcher _matcher;
26 | private readonly IInteractionRepository _repository;
27 | private readonly IInteractionAnonymizer _anonymizer;
28 | private readonly SemaphoreSlim _interactionLock = new SemaphoreSlim(1, 1);
29 | private bool _disposed = false;
30 | private HttpRecorderMode? _executionMode;
31 | private Interaction _interaction;
32 |
33 | ///
34 | /// Initializes a new instance of the class.
35 | ///
36 | ///
37 | /// The name of the interaction.
38 | /// If you use the default , this will be the path to the HAR file (relative or absolute) and
39 | /// if no file extension is provided, .har will be used.
40 | ///
41 | /// The . Defaults to .
42 | ///
43 | /// The to use to match interactions with incoming .
44 | /// Defaults to matching Once by and .
45 | /// and .
46 | ///
47 | ///
48 | /// The to use to read/write the interaction.
49 | /// Defaults to .
50 | ///
51 | ///
52 | /// The to use to anonymize the interaction.
53 | /// Defaults to .
54 | ///
55 | public HttpRecorderDelegatingHandler(
56 | string interactionName,
57 | HttpRecorderMode mode = HttpRecorderMode.Auto,
58 | IRequestMatcher matcher = null,
59 | IInteractionRepository repository = null,
60 | IInteractionAnonymizer anonymizer = null)
61 | {
62 | InteractionName = interactionName;
63 | Mode = mode;
64 | _matcher = matcher ?? RulesMatcher.MatchOnce.ByHttpMethod().ByRequestUri();
65 | _repository = repository ?? new HttpArchiveInteractionRepository();
66 | _anonymizer = anonymizer ?? RulesInteractionAnonymizer.Default;
67 | }
68 |
69 | ///
70 | /// Gets the name of the interaction.
71 | ///
72 | public string InteractionName { get; }
73 |
74 | ///
75 | /// Gets the .
76 | ///
77 | public HttpRecorderMode Mode { get; }
78 |
79 | ///
80 | protected override void Dispose(bool disposing)
81 | {
82 | if (_disposed)
83 | {
84 | return;
85 | }
86 |
87 | if (disposing)
88 | {
89 | _interactionLock.Dispose();
90 | }
91 |
92 | base.Dispose(disposing);
93 | }
94 |
95 | ///
96 | protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
97 | {
98 | if (request == null)
99 | {
100 | throw new ArgumentNullException(nameof(request));
101 | }
102 |
103 | if (Mode == HttpRecorderMode.Passthrough)
104 | {
105 | var response = await base.SendAsync(request, cancellationToken);
106 | return response;
107 | }
108 |
109 | await _interactionLock.WaitAsync();
110 | try
111 | {
112 | await ResolveExecutionMode(cancellationToken);
113 |
114 | if (_executionMode == HttpRecorderMode.Replay)
115 | {
116 | if (_interaction == null)
117 | {
118 | _interaction = await _repository.LoadAsync(InteractionName, cancellationToken);
119 | }
120 |
121 | var interactionMessage = _matcher.Match(request, _interaction);
122 | if (interactionMessage == null)
123 | {
124 | throw new HttpRecorderException($"Unable to find a matching interaction for request {request.Method} {request.RequestUri}.");
125 | }
126 |
127 | return await PostProcessResponse(interactionMessage.Response);
128 | }
129 |
130 | var start = DateTimeOffset.Now;
131 | var sw = Stopwatch.StartNew();
132 | var innerResponse = await base.SendAsync(request, cancellationToken);
133 | sw.Stop();
134 |
135 | var newInteractionMessage = new InteractionMessage(
136 | innerResponse,
137 | new InteractionMessageTimings(start, sw.Elapsed));
138 |
139 | _interaction = new Interaction(
140 | InteractionName,
141 | _interaction == null ? new[] { newInteractionMessage } : _interaction.Messages.Append(newInteractionMessage));
142 |
143 | _interaction = await _anonymizer.Anonymize(_interaction, cancellationToken);
144 | _interaction = await _repository.StoreAsync(_interaction, cancellationToken);
145 |
146 | return await PostProcessResponse(newInteractionMessage.Response);
147 | }
148 | finally
149 | {
150 | _interactionLock.Release();
151 | }
152 | }
153 |
154 | ///
155 | /// Resolves the current .
156 | /// Handles and , if they are set (in that priority order),
157 | /// otherwise uses the current .
158 | ///
159 | /// A cancellation token to cancel operation.
160 | /// A representing the asynchronous operation.
161 | private async Task ResolveExecutionMode(CancellationToken cancellationToken)
162 | {
163 | if (!_executionMode.HasValue)
164 | {
165 | var overridingEnvVarValue = Environment.GetEnvironmentVariable(OverridingEnvironmentVariableName);
166 | if (!string.IsNullOrWhiteSpace(overridingEnvVarValue) && Enum.TryParse(overridingEnvVarValue, out var parsedOverridingEnvVarValue))
167 | {
168 | _executionMode = parsedOverridingEnvVarValue;
169 | return;
170 | }
171 |
172 | if (Mode == HttpRecorderMode.Auto)
173 | {
174 | _executionMode = (await _repository.ExistsAsync(InteractionName, cancellationToken))
175 | ? HttpRecorderMode.Replay
176 | : HttpRecorderMode.Record;
177 |
178 | return;
179 | }
180 |
181 | _executionMode = Mode;
182 | }
183 | }
184 |
185 | ///
186 | /// Custom processing on to better simulate a real response from the network
187 | /// and allow replayability.
188 | ///
189 | /// The .
190 | /// The returned as convenience.
191 | private async Task PostProcessResponse(HttpResponseMessage response)
192 | {
193 | if (response.Content != null)
194 | {
195 | var stream = await response.Content.ReadAsStreamAsync();
196 | if (stream.CanSeek)
197 | {
198 | // The HTTP Client is adding the content length header on HttpConnectionResponseContent even when the server does not have a header.
199 | response.Content.Headers.ContentLength = stream.Length;
200 |
201 | // We do reset the stream in case it needs to be re-read.
202 | stream.Seek(0, SeekOrigin.Begin);
203 | }
204 | }
205 |
206 | return response;
207 | }
208 | }
209 | }
210 |
--------------------------------------------------------------------------------
/HttpRecorder/HttpRecorderException.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Runtime.Serialization;
3 |
4 | namespace HttpRecorder
5 | {
6 | ///
7 | /// Represents errors that occurs related to the execution,
8 | /// or any of its sub-components.
9 | ///
10 | [Serializable]
11 | public class HttpRecorderException : Exception
12 | {
13 | ///
14 | /// Initializes a new instance of the class.
15 | ///
16 | public HttpRecorderException()
17 | {
18 | }
19 |
20 | ///
21 | /// Initializes a new instance of the class with a specified error message.
22 | ///
23 | /// The message that describes the error.
24 | public HttpRecorderException(string message)
25 | : base(message)
26 | {
27 | }
28 |
29 | ///
30 | /// Initializes a new instance of the class with a specified error message
31 | /// and a reference to the inner exception that is the cause of this exception.
32 | ///
33 | /// The message that describes the error.
34 | /// The that is the cause of the current exception, or a null reference if no inner exception is specified.
35 | public HttpRecorderException(string message, Exception inner)
36 | : base(message, inner)
37 | {
38 | }
39 |
40 | ///
41 | /// Initializes a new instance of the class with serialized data.
42 | ///
43 | /// The that holds the serialized object data about the exception being thrown.
44 | /// The that contains contextual information about the source or destination.
45 | protected HttpRecorderException(SerializationInfo info, StreamingContext context)
46 | : base(info, context)
47 | {
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/HttpRecorder/HttpRecorderMode.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http;
2 |
3 | namespace HttpRecorder
4 | {
5 | ///
6 | /// The execution mode for .
7 | ///
8 | public enum HttpRecorderMode
9 | {
10 | ///
11 | /// Default mode.
12 | /// Uses if a record is present, or if not.
13 | ///
14 | Auto,
15 |
16 | ///
17 | /// Always record the interaction, even if a record is present.
18 | /// Overrides previous record.
19 | ///
20 | Record,
21 |
22 | ///
23 | /// Always replay the interaction.
24 | /// Throws during execution if there is no record available.
25 | ///
26 | Replay,
27 |
28 | ///
29 | /// Always invoke the underlying , and do not record the interaction.
30 | /// Does not try to deserialize the message as well.
31 | ///
32 | Passthrough,
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/HttpRecorder/Interaction.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 |
4 | namespace HttpRecorder
5 | {
6 | ///
7 | /// An interaction is a complete recording of a set of .
8 | ///
9 | public class Interaction
10 | {
11 | ///
12 | /// Initializes a new instance of the class.
13 | ///
14 | /// The interaction name.
15 | /// The set of .
16 | public Interaction(
17 | string name,
18 | IEnumerable messages = null)
19 | {
20 | Name = name;
21 | Messages = (messages ?? Enumerable.Empty()).ToList().AsReadOnly();
22 | }
23 |
24 | ///
25 | /// Gets the interaction name.
26 | ///
27 | public string Name { get; }
28 |
29 | ///
30 | /// Gets the list.
31 | ///
32 | public IReadOnlyList Messages { get; }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/HttpRecorder/InteractionMessage.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http;
2 |
3 | namespace HttpRecorder
4 | {
5 | ///
6 | /// Represents a single HTTP Interaction (Request/Response).
7 | /// is in the property.
8 | ///
9 | public class InteractionMessage
10 | {
11 | ///
12 | /// Initializes a new instance of the class.
13 | ///
14 | /// The .
15 | /// The .
16 | public InteractionMessage(
17 | HttpResponseMessage response,
18 | InteractionMessageTimings timings)
19 | {
20 | Response = response;
21 | Timings = timings;
22 | }
23 |
24 | ///
25 | /// Gets the .
26 | ///
27 | public HttpResponseMessage Response { get; }
28 |
29 | ///
30 | /// Gets the .
31 | ///
32 | public InteractionMessageTimings Timings { get; }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/HttpRecorder/InteractionMessageTimings.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace HttpRecorder
4 | {
5 | ///
6 | /// Information about timings.
7 | ///
8 | public class InteractionMessageTimings
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | /// The date and time stamp of the request start.
14 | /// Total elapsed time of the request.
15 | public InteractionMessageTimings(DateTimeOffset startedDateTime, TimeSpan time)
16 | {
17 | StartedDateTime = startedDateTime;
18 | Time = time;
19 | }
20 |
21 | ///
22 | /// Gets the date and time stamp of the request start.
23 | ///
24 | public DateTimeOffset StartedDateTime { get; }
25 |
26 | ///
27 | /// Gets the total elapsed time of the request.
28 | ///
29 | public TimeSpan Time { get; }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/HttpRecorder/Matchers/IRequestMatcher.cs:
--------------------------------------------------------------------------------
1 | using System.Net.Http;
2 |
3 | namespace HttpRecorder.Matchers
4 | {
5 | ///
6 | /// The is responsible from matching incoming
7 | /// from existing .
8 | ///
9 | public interface IRequestMatcher
10 | {
11 | ///
12 | /// Matches in the .
13 | ///
14 | /// The incoming to match.
15 | /// The .
16 | /// The matched , or null if not found.
17 | InteractionMessage Match(HttpRequestMessage request, Interaction interaction);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/HttpRecorder/Matchers/RulesMatcher.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections;
3 | using System.Collections.Generic;
4 | using System.Linq;
5 | using System.Net.Http;
6 | using System.Text.Json;
7 |
8 | namespace HttpRecorder.Matchers
9 | {
10 | ///
11 | /// implementation that matches
12 | /// Additional rules can be specified.
13 | ///
14 | public class RulesMatcher : IRequestMatcher
15 | {
16 | private readonly IEnumerable> _rules;
17 | private readonly bool _matchOnce;
18 | private readonly IList _matchedInteractionMessages = new List();
19 |
20 | private RulesMatcher(IEnumerable> rules = null, bool matchOnce = true)
21 | {
22 | _rules = rules ?? Enumerable.Empty>();
23 | _matchOnce = matchOnce;
24 | }
25 |
26 | ///
27 | /// Gets a new that matches request in sequence and only once.
28 | ///
29 | public static RulesMatcher MatchOnce { get => new RulesMatcher(Enumerable.Empty>(), true); }
30 |
31 | ///
32 | /// Gets a new that matches request in sequence and multiple times.
33 | ///
34 | public static RulesMatcher MatchMultiple { get => new RulesMatcher(Enumerable.Empty>(), false); }
35 |
36 | ///
37 | public InteractionMessage Match(HttpRequestMessage request, Interaction interaction)
38 | {
39 | if (interaction == null)
40 | {
41 | throw new ArgumentNullException(nameof(interaction));
42 | }
43 |
44 | IEnumerable query = interaction.Messages;
45 |
46 | if (_matchOnce)
47 | {
48 | query = query.Where(x => !_matchedInteractionMessages.Contains(x));
49 | }
50 |
51 | foreach (var rule in _rules)
52 | {
53 | query = query.Where(x => rule(request, x));
54 | }
55 |
56 | var matchedInteraction = query.FirstOrDefault();
57 |
58 | if (matchedInteraction != null && _matchOnce)
59 | {
60 | _matchedInteractionMessages.Add(matchedInteraction);
61 | }
62 |
63 | return matchedInteraction;
64 | }
65 |
66 | ///
67 | /// Returns a new with the added .
68 | ///
69 | /// The rule to add.
70 | /// A new .
71 | public RulesMatcher By(Func rule)
72 | => new RulesMatcher(_rules.Concat(new[] { rule }), _matchOnce);
73 |
74 | ///
75 | /// Adds a rule that matches by .
76 | ///
77 | /// A new .
78 | public RulesMatcher ByHttpMethod()
79 | => By((request, message) => request.Method == message.Response.RequestMessage.Method);
80 |
81 | ///
82 | /// Adds a rule that matches by .
83 | ///
84 | /// Specify a to restrict the matching to a subset of the request .
85 | /// A new .
86 | public RulesMatcher ByRequestUri(UriPartial part = UriPartial.Query)
87 | => By((request, message) => string.Equals(request.RequestUri?.GetLeftPart(part), message.Response.RequestMessage.RequestUri?.GetLeftPart(part), StringComparison.InvariantCulture));
88 |
89 | ///
90 | /// Adds a rule that matches by comparing request header values.
91 | ///
92 | /// The name of the header to compare values from.
93 | /// Allows customization of the string comparison.
94 | /// A new .
95 | public RulesMatcher ByHeader(string headerName, StringComparison stringComparison = StringComparison.InvariantCultureIgnoreCase)
96 | => By((request, message) =>
97 | {
98 | string requestHeader = null;
99 | string interactionHeader = null;
100 |
101 | if (request.Headers.TryGetValues(headerName, out var requestValues))
102 | {
103 | requestHeader = string.Join(",", requestValues);
104 | }
105 |
106 | if (request.Content != null && request.Content.Headers.TryGetValues(headerName, out var requestContentValues))
107 | {
108 | requestHeader = string.Join(",", requestContentValues);
109 | }
110 |
111 | if (message.Response.RequestMessage.Headers.TryGetValues(headerName, out var interactionValues))
112 | {
113 | interactionHeader = string.Join(",", interactionValues);
114 | }
115 |
116 | if (message.Response.RequestMessage.Content != null && message.Response.RequestMessage.Content.Headers.TryGetValues(headerName, out var interactionContentValues))
117 | {
118 | interactionHeader = string.Join(",", interactionContentValues);
119 | }
120 |
121 | return string.Equals(requestHeader, interactionHeader, stringComparison);
122 | });
123 |
124 | ///
125 | /// Adds a rule that matches by binary comparing the .
126 | ///
127 | /// A new .
128 | public RulesMatcher ByContent()
129 | => By((request, message) =>
130 | {
131 | var requestContent = request.Content?.ReadAsByteArrayAsync()?.ConfigureAwait(false).GetAwaiter().GetResult();
132 | var messageContent = message.Response.RequestMessage.Content?.ReadAsByteArrayAsync()?.ConfigureAwait(false).GetAwaiter().GetResult();
133 |
134 | if (requestContent is null)
135 | {
136 | return messageContent is null;
137 | }
138 | else
139 | {
140 | if (messageContent is null)
141 | {
142 | return false;
143 | }
144 | }
145 |
146 | if (requestContent.Length != messageContent.Length)
147 | {
148 | return false;
149 | }
150 |
151 | return StructuralComparisons.StructuralComparer.Compare(
152 | request.Content?.ReadAsByteArrayAsync()?.Result,
153 | message.Response.RequestMessage.Content?.ReadAsByteArrayAsync()?.Result) == 0;
154 | });
155 |
156 | ///
157 | /// Adds a rule that matches by comparing the JSON content of the requests.
158 | ///
159 | /// The json object type.
160 | /// to use. Defaults to .
161 | /// The to use.
162 | /// A new .
163 | public RulesMatcher ByJsonContent(
164 | IEqualityComparer equalityComparer = null,
165 | JsonSerializerOptions jsonSerializerOptions = null)
166 | => By((request, message) =>
167 | {
168 | var requestContent = request.Content?.ReadAsStringAsync()?.Result;
169 | var requestJson = !string.IsNullOrEmpty(requestContent) ? JsonSerializer.Deserialize(requestContent, jsonSerializerOptions) : default(T);
170 |
171 | var interactionContent = message.Response.RequestMessage.Content?.ReadAsStringAsync()?.Result;
172 | var interactionJson = !string.IsNullOrEmpty(interactionContent) ? JsonSerializer.Deserialize(interactionContent, jsonSerializerOptions) : default(T);
173 |
174 | if (equalityComparer == null)
175 | {
176 | equalityComparer = EqualityComparer.Default;
177 | }
178 |
179 | return equalityComparer.Equals(requestJson, interactionJson);
180 | });
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/HttpRecorder/Repositories/HAR/Content.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Net.Http;
3 |
4 | namespace HttpRecorder.Repositories.HAR
5 | {
6 | ///
7 | /// Describes details about response content
8 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#content.
9 | ///
10 | public class Content
11 | {
12 | private const string EncodingBase64 = "base64";
13 |
14 | ///
15 | /// Initializes a new instance of the class.
16 | ///
17 | public Content()
18 | {
19 | }
20 |
21 | ///
22 | /// Initializes a new instance of the class from .
23 | ///
24 | /// The to initialize from.
25 | public Content(HttpContent content)
26 | {
27 | if (content != null)
28 | {
29 | var bodyBytes = content.ReadAsByteArrayAsync().Result;
30 | Size = bodyBytes.Length;
31 | MimeType = content.Headers?.ContentType?.ToString();
32 | if (content.IsBinary())
33 | {
34 | Text = Convert.ToBase64String(bodyBytes);
35 | Encoding = EncodingBase64;
36 | }
37 | else
38 | {
39 | Text = System.Text.Encoding.UTF8.GetString(bodyBytes);
40 | }
41 | }
42 | }
43 |
44 | ///
45 | /// Gets or sets the length of the returned content in bytes.
46 | ///
47 | public long Size { get; set; } = -1;
48 |
49 | ///
50 | /// Gets or sets the MIME type of the response text (value of the Content-Type response header).
51 | /// The charset attribute of the MIME type is included (if available).
52 | ///
53 | public string MimeType { get; set; }
54 |
55 | ///
56 | /// Gets or sets the response body sent from the server or loaded from the browser cache.
57 | /// This field is populated with textual content only. The text field is either HTTP decoded text
58 | /// or a encoded (e.g. "base64") representation of the response body. Leave out this field if the information is not available.
59 | ///
60 | public string Text { get; set; }
61 |
62 | ///
63 | /// Gets or sets the encoding used for response text field e.g "base64".
64 | /// Leave out this field if the text field is HTTP decoded (decompressed and unchunked), than trans-coded from its original character set into UTF-8.
65 | ///
66 | public string Encoding { get; set; }
67 |
68 | ///
69 | /// Returns a .
70 | ///
71 | /// Either , or null if no content.
72 | public ByteArrayContent ToHttpContent()
73 | {
74 | ByteArrayContent result = null;
75 | if (!string.IsNullOrEmpty(Text))
76 | {
77 | if (string.Equals(Encoding, EncodingBase64, StringComparison.InvariantCultureIgnoreCase))
78 | {
79 | result = new ByteArrayContent(Convert.FromBase64String(Text));
80 | }
81 | else
82 | {
83 | result = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(Text));
84 | }
85 | }
86 |
87 | return result;
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/HttpRecorder/Repositories/HAR/Creator.cs:
--------------------------------------------------------------------------------
1 | namespace HttpRecorder.Repositories.HAR
2 | {
3 | ///
4 | /// This object contains information about the log creator application
5 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#sec-har-object-types-creator.
6 | ///
7 | public class Creator
8 | {
9 | ///
10 | /// Gets or sets the name of the application that created the log.
11 | ///
12 | public string Name { get; set; } = "HttpRecorder";
13 |
14 | ///
15 | /// Gets or sets the version number of the application that created the log.
16 | ///
17 | public string Version { get; set; } = typeof(Creator).Assembly.GetName().Version.ToString();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/HttpRecorder/Repositories/HAR/Entry.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace HttpRecorder.Repositories.HAR
4 | {
5 | ///
6 | /// Represents an exported HTTP requests.
7 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#entries.
8 | ///
9 | public class Entry
10 | {
11 | ///
12 | /// Initializes a new instance of the class.
13 | ///
14 | public Entry()
15 | {
16 | }
17 |
18 | ///
19 | /// Initializes a new instance of the class from .
20 | ///
21 | /// The to initialize from.
22 | public Entry(InteractionMessage message)
23 | {
24 | if (message == null)
25 | {
26 | throw new ArgumentNullException(nameof(message));
27 | }
28 |
29 | StartedDateTime = message.Timings.StartedDateTime;
30 | Time = Convert.ToInt64(Math.Round(message.Timings.Time.TotalMilliseconds, 0));
31 | Request = new Request(message.Response.RequestMessage);
32 | Response = new Response(message.Response);
33 | }
34 |
35 | ///
36 | /// Gets or sets the date and time stamp of the request start.
37 | ///
38 | public DateTimeOffset StartedDateTime { get; set; }
39 |
40 | ///
41 | /// Gets or sets the total elapsed time of the request in milliseconds.
42 | ///
43 | public long Time { get; set; }
44 |
45 | ///
46 | /// Gets or sets the .
47 | ///
48 | public Request Request { get; set; }
49 |
50 | ///
51 | /// Gets or sets the .
52 | ///
53 | public Response Response { get; set; }
54 |
55 | ///
56 | /// Gets or sets info about cache usage. NOT SUPPORTED.
57 | ///
58 | public object Cache { get; set; } = new object();
59 |
60 | ///
61 | /// Gets or sets the .
62 | ///
63 | public Timings Timings { get; set; } = new Timings();
64 |
65 | ///
66 | /// Returns a .
67 | ///
68 | /// The created from this.
69 | public InteractionMessage ToInteractionMessage()
70 | {
71 | var request = Request.ToHttpRequestMessage();
72 | var response = Response.ToHttpResponseMessage();
73 | response.RequestMessage = request;
74 |
75 | return new InteractionMessage(
76 | response,
77 | new InteractionMessageTimings(StartedDateTime, TimeSpan.FromMilliseconds(Time)));
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/HttpRecorder/Repositories/HAR/Header.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Net.Http.Headers;
3 |
4 | namespace HttpRecorder.Repositories.HAR
5 | {
6 | ///
7 | /// HTTP Header definition.
8 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#headers.
9 | ///
10 | public class Header : Parameter
11 | {
12 | ///
13 | /// Initializes a new instance of the class.
14 | ///
15 | public Header()
16 | {
17 | }
18 |
19 | ///
20 | /// Initializes a new instance of the class from .
21 | ///
22 | /// The to initialize from.
23 | public Header(KeyValuePair> keyValuePair)
24 | : base(keyValuePair)
25 | {
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/HttpRecorder/Repositories/HAR/HttpArchive.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 |
4 | namespace HttpRecorder.Repositories.HAR
5 | {
6 | ///
7 | /// Represents an HTTP Archive file content (https://w3c.github.io/web-performance/specs/HAR/Overview.html).
8 | ///
9 | public class HttpArchive
10 | {
11 | ///
12 | /// Initializes a new instance of the class.
13 | ///
14 | public HttpArchive()
15 | {
16 | }
17 |
18 | ///
19 | /// Initializes a new instance of the class from .
20 | ///
21 | /// The to use to initialize.
22 | public HttpArchive(Interaction interaction)
23 | {
24 | if (interaction == null)
25 | {
26 | throw new ArgumentNullException(nameof(interaction));
27 | }
28 |
29 | foreach (var message in interaction.Messages)
30 | {
31 | Log.Entries.Add(new Entry(message));
32 | }
33 | }
34 |
35 | ///
36 | /// Gets or sets the .
37 | ///
38 | public Log Log { get; set; } = new Log();
39 |
40 | ///
41 | /// Returns an .
42 | ///
43 | /// The .
44 | /// The interaction created from this.
45 | public Interaction ToInteraction(string interactionName)
46 | {
47 | return new Interaction(interactionName, Log.Entries.Select(x => x.ToInteractionMessage()));
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/HttpRecorder/Repositories/HAR/HttpArchiveInteractionRepository.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Text;
4 | using System.Text.Json;
5 | using System.Threading;
6 | using System.Threading.Tasks;
7 |
8 | namespace HttpRecorder.Repositories.HAR
9 | {
10 | ///
11 | /// implementation that stores
12 | /// in files in the HTTP Archive format (https://en.wikipedia.org/wiki/.har / https://w3c.github.io/web-performance/specs/HAR/Overview.html).
13 | ///
14 | ///
15 | /// The interactionName parameter is used as the file path.
16 | /// The .har extension will be added if no file extension is provided.
17 | ///
18 | public class HttpArchiveInteractionRepository : IInteractionRepository
19 | {
20 | private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
21 | {
22 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
23 | PropertyNameCaseInsensitive = true,
24 | IgnoreNullValues = true,
25 | WriteIndented = true,
26 | };
27 |
28 | ///
29 | public Task ExistsAsync(string interactionName, CancellationToken cancellationToken = default)
30 | {
31 | return Task.FromResult(File.Exists(GetFilePath(interactionName)));
32 | }
33 |
34 | ///
35 | public Task LoadAsync(string interactionName, CancellationToken cancellationToken = default)
36 | {
37 | try
38 | {
39 | var archive = JsonSerializer.Deserialize(
40 | File.ReadAllText(GetFilePath(interactionName), Encoding.UTF8),
41 | _jsonOptions);
42 |
43 | return Task.FromResult(archive.ToInteraction(interactionName));
44 | }
45 | catch (Exception ex) when ((ex is IOException) || (ex is JsonException))
46 | {
47 | throw new HttpRecorderException($"Error while loading file {GetFilePath(interactionName)}: {ex.Message}", ex);
48 | }
49 | }
50 |
51 | ///
52 | public async Task StoreAsync(Interaction interaction, CancellationToken cancellationToken = default)
53 | {
54 | if (interaction == null)
55 | {
56 | throw new ArgumentNullException(nameof(interaction));
57 | }
58 |
59 | var filePath = GetFilePath(interaction.Name);
60 | try
61 | {
62 | var archive = new HttpArchive(interaction);
63 | var archiveDirectory = Path.GetDirectoryName(filePath);
64 | if (!string.IsNullOrWhiteSpace(archiveDirectory) && !Directory.Exists(archiveDirectory))
65 | {
66 | Directory.CreateDirectory(archiveDirectory);
67 | }
68 |
69 | using (var stream = new FileStream(filePath, FileMode.Create))
70 | {
71 | await JsonSerializer.SerializeAsync(stream, archive, _jsonOptions, cancellationToken);
72 | }
73 |
74 | return archive.ToInteraction(interaction.Name);
75 | }
76 | catch (Exception ex) when ((ex is IOException) || (ex is JsonException))
77 | {
78 | throw new HttpRecorderException($"Error while writing file {filePath}: {ex.Message}", ex);
79 | }
80 | }
81 |
82 | private string GetFilePath(string interactionName)
83 | => Path.HasExtension(interactionName)
84 | ? Path.GetFullPath(interactionName)
85 | : Path.GetFullPath($"{interactionName}.har");
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/HttpRecorder/Repositories/HAR/Log.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 |
3 | namespace HttpRecorder.Repositories.HAR
4 | {
5 | ///
6 | /// This object represents the root of the exported data.
7 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#sec-har-object-types-log.
8 | ///
9 | public class Log
10 | {
11 | ///
12 | /// Gets or sets the Version number of the format.
13 | ///
14 | public string Version { get; set; } = "1.2";
15 |
16 | ///
17 | /// Gets or sets the .
18 | ///
19 | public Creator Creator { get; set; } = new Creator();
20 |
21 | ///
22 | /// Gets or sets the list of .
23 | ///
24 | public List Entries { get; set; } = new List();
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/HttpRecorder/Repositories/HAR/Message.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Net.Http.Headers;
4 |
5 | namespace HttpRecorder.Repositories.HAR
6 | {
7 | ///
8 | /// Base class for HAR messages.
9 | ///
10 | public abstract class Message
11 | {
12 | ///
13 | /// Prefix to use for .
14 | ///
15 | protected const string HTTPVERSIONPREFIX = "HTTP/";
16 |
17 | ///
18 | /// Gets or sets the HTTP version.
19 | ///
20 | public string HttpVersion { get; set; }
21 |
22 | ///
23 | /// Gets or sets the list of cookie objects. NOT SUPPORTED.
24 | ///
25 | public List