├── .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 Cookies { get; set; } = new List(); 26 | 27 | /// 28 | /// Gets or sets the list of . 29 | /// 30 | public List
Headers { get; set; } = new List
(); 31 | 32 | /// 33 | /// Gets or sets the total number of bytes from the start of the HTTP request message until (and including) the double CRLF before the body. 34 | /// Set to -1 if the info is not available. 35 | /// 36 | public int HeadersSize { get; set; } = -1; 37 | 38 | /// 39 | /// Gets or sets the size of the request body (POST data payload) in bytes. 40 | /// Set to -1 if the info is not available. 41 | /// 42 | public int BodySize { get; set; } = -1; 43 | 44 | /// 45 | /// Returns a from ;. 46 | /// 47 | /// The . 48 | protected Version GetVersion() 49 | { 50 | if (string.IsNullOrEmpty(HttpVersion)) 51 | { 52 | return new Version(); 53 | } 54 | 55 | var version = HttpVersion; 56 | if (version.StartsWith(HTTPVERSIONPREFIX, StringComparison.InvariantCultureIgnoreCase)) 57 | { 58 | version = version.Substring(HTTPVERSIONPREFIX.Length); 59 | } 60 | 61 | return new Version(version); 62 | } 63 | 64 | /// 65 | /// Adds tp , without validation. 66 | /// can be null. 67 | /// 68 | /// The to add to. 69 | protected void AddHeadersWithoutValidation(HttpHeaders headers) 70 | { 71 | if (headers != null) 72 | { 73 | foreach (var header in Headers) 74 | { 75 | if (!headers.TryGetValues(header.Name, out var _)) 76 | { 77 | headers.TryAddWithoutValidation(header.Name, header.Value); 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /HttpRecorder/Repositories/HAR/Parameter.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace HttpRecorder.Repositories.HAR 4 | { 5 | /// 6 | /// Base class for HTTP Archive name/value parameters. 7 | /// 8 | public abstract class Parameter 9 | { 10 | /// 11 | /// Initializes a new instance of the class. 12 | /// 13 | public Parameter() 14 | { 15 | } 16 | 17 | /// 18 | /// Initializes a new instance of the class from . 19 | /// 20 | /// The to initialize from. 21 | protected Parameter(KeyValuePair> keyValuePair) 22 | { 23 | Name = keyValuePair.Key; 24 | Value = string.Join(",", keyValuePair.Value); 25 | } 26 | 27 | /// 28 | /// Gets or sets the name. 29 | /// 30 | public string Name { get; set; } 31 | 32 | /// 33 | /// Gets or sets the value. 34 | /// 35 | public string Value { get; set; } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /HttpRecorder/Repositories/HAR/PostData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Net.Http; 4 | using System.Text; 5 | using System.Web; 6 | 7 | namespace HttpRecorder.Repositories.HAR 8 | { 9 | /// 10 | /// Describes posted data. 11 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#postData. 12 | /// 13 | public class PostData 14 | { 15 | /// 16 | /// Initializes a new instance of the class. 17 | /// 18 | public PostData() 19 | { 20 | } 21 | 22 | /// 23 | /// Initializes a new instance of the class from . 24 | /// 25 | /// The to initialize from. 26 | public PostData(HttpContent content) 27 | { 28 | if (content != null) 29 | { 30 | MimeType = content.Headers?.ContentType?.ToString(); 31 | if (content.IsFormData()) 32 | { 33 | var bodyParams = HttpUtility.ParseQueryString(content.ReadAsStringAsync().Result); 34 | foreach (string key in bodyParams) 35 | { 36 | Params.Add(new PostedParam { Name = key, Value = bodyParams[key] }); 37 | } 38 | } 39 | else 40 | { 41 | Text = Encoding.UTF8.GetString(content.ReadAsByteArrayAsync().Result); 42 | } 43 | } 44 | } 45 | 46 | /// 47 | /// Gets or sets the mime type of posted data. 48 | /// 49 | public string MimeType { get; set; } 50 | 51 | /// 52 | /// Gets or sets the list of . 53 | /// 54 | public List Params { get; set; } = new List(); 55 | 56 | /// 57 | /// Gets or sets plain text posted data. 58 | /// 59 | public string Text { get; set; } 60 | 61 | /// 62 | /// Returns a . 63 | /// 64 | /// Either , , or null if no content. 65 | public HttpContent ToHttpContent() 66 | { 67 | HttpContent result = null; 68 | if (!string.IsNullOrEmpty(Text)) 69 | { 70 | result = new ByteArrayContent(Encoding.UTF8.GetBytes(Text)); 71 | } 72 | 73 | if (Params != null && Params.Count > 0) 74 | { 75 | result = new FormUrlEncodedContent(Params.Select(x => new KeyValuePair(x.Name, x.Value))); 76 | } 77 | 78 | return result; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /HttpRecorder/Repositories/HAR/PostedParam.cs: -------------------------------------------------------------------------------- 1 | namespace HttpRecorder.Repositories.HAR 2 | { 3 | /// 4 | /// Posted parameter. 5 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#params. 6 | /// 7 | public class PostedParam : Parameter 8 | { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /HttpRecorder/Repositories/HAR/QueryParameter.cs: -------------------------------------------------------------------------------- 1 | namespace HttpRecorder.Repositories.HAR 2 | { 3 | /// 4 | /// HTTP QueryParameter definition. 5 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#queryString. 6 | /// 7 | public class QueryParameter : Parameter 8 | { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /HttpRecorder/Repositories/HAR/Request.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Web; 5 | 6 | namespace HttpRecorder.Repositories.HAR 7 | { 8 | /// 9 | /// Contains detailed info about performed request. 10 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#request. 11 | /// 12 | public class Request : Message 13 | { 14 | /// 15 | /// Initializes a new instance of the class. 16 | /// 17 | public Request() 18 | { 19 | } 20 | 21 | /// 22 | /// Initializes a new instance of the class from . 23 | /// 24 | /// The to initialize from. 25 | public Request(HttpRequestMessage request) 26 | { 27 | if (request == null) 28 | { 29 | throw new ArgumentNullException(nameof(request)); 30 | } 31 | 32 | HttpVersion = $"{HTTPVERSIONPREFIX}{request.Version}"; 33 | Method = request.Method.ToString(); 34 | Url = request.RequestUri; 35 | if (!string.IsNullOrEmpty(request.RequestUri.Query)) 36 | { 37 | var parsedQueryString = HttpUtility.ParseQueryString(request.RequestUri.Query); 38 | foreach (string queryStringKey in parsedQueryString) 39 | { 40 | QueryString.Add(new QueryParameter { Name = queryStringKey, Value = parsedQueryString[queryStringKey] }); 41 | } 42 | } 43 | 44 | foreach (var header in request.Headers) 45 | { 46 | Headers.Add(new Header(header)); 47 | } 48 | 49 | if (request.Content != null) 50 | { 51 | foreach (var header in request.Content.Headers) 52 | { 53 | Headers.Add(new Header(header)); 54 | } 55 | 56 | BodySize = request.Content.ReadAsByteArrayAsync().Result.Length; 57 | PostData = new PostData(request.Content); 58 | } 59 | } 60 | 61 | /// 62 | /// Gets or sets the request method (GET, POST, ...). 63 | /// 64 | public string Method { get; set; } 65 | 66 | /// 67 | /// Gets or sets the absolute URL of the request (fragments are not included). 68 | /// 69 | public Uri Url { get; set; } 70 | 71 | /// 72 | /// Gets or sets the list of parameters. 73 | /// 74 | public List QueryString { get; set; } = new List(); 75 | 76 | /// 77 | /// Gets or sets the . 78 | /// 79 | public PostData PostData { get; set; } 80 | 81 | /// 82 | /// Returns a . 83 | /// 84 | /// The created from this. 85 | public HttpRequestMessage ToHttpRequestMessage() 86 | { 87 | var uriBuilder = new UriBuilder(Url); 88 | if (QueryString != null && QueryString.Count > 0) 89 | { 90 | var queryStringParameters = HttpUtility.ParseQueryString(string.Empty); 91 | foreach (var queryParameter in QueryString) 92 | { 93 | queryStringParameters.Add(queryParameter.Name, queryParameter.Value); 94 | } 95 | 96 | uriBuilder.Query = queryStringParameters.ToString(); 97 | } 98 | 99 | var request = new HttpRequestMessage 100 | { 101 | Content = PostData?.ToHttpContent(), 102 | Method = new HttpMethod(Method), 103 | RequestUri = uriBuilder.Uri, 104 | Version = GetVersion(), 105 | }; 106 | AddHeadersWithoutValidation(request.Headers); 107 | AddHeadersWithoutValidation(request.Content?.Headers); 108 | 109 | return request; 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /HttpRecorder/Repositories/HAR/Response.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Net; 4 | using System.Net.Http; 5 | 6 | namespace HttpRecorder.Repositories.HAR 7 | { 8 | /// 9 | /// Contains detailed info about the response. 10 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#response. 11 | /// 12 | public class Response : Message 13 | { 14 | /// 15 | /// Initializes a new instance of the class. 16 | /// 17 | public Response() 18 | { 19 | } 20 | 21 | /// 22 | /// Initializes a new instance of the class from . 23 | /// 24 | /// The to initialize from. 25 | public Response(HttpResponseMessage response) 26 | { 27 | if (response == null) 28 | { 29 | throw new ArgumentNullException(nameof(response)); 30 | } 31 | 32 | HttpVersion = $"{HTTPVERSIONPREFIX}{response.Version}"; 33 | Status = (int)response.StatusCode; 34 | StatusText = response.ReasonPhrase; 35 | if (response.Headers.Location != null) 36 | { 37 | RedirectURL = response.Headers.Location.ToString(); 38 | } 39 | 40 | foreach (var header in response.Headers) 41 | { 42 | Headers.Add(new Header(header)); 43 | } 44 | 45 | if (response.Content != null) 46 | { 47 | foreach (var header in response.Content.Headers) 48 | { 49 | Headers.Add(new Header(header)); 50 | } 51 | 52 | BodySize = response.Content.ReadAsByteArrayAsync().Result.Length; 53 | Content = new Content(response.Content); 54 | } 55 | } 56 | 57 | /// 58 | /// Gets or sets the response status. 59 | /// 60 | public int Status { get; set; } 61 | 62 | /// 63 | /// Gets or sets the response status description. 64 | /// 65 | public string StatusText { get; set; } 66 | 67 | /// 68 | /// Gets or sets the details about the response body. 69 | /// 70 | public Content Content { get; set; } = new Content(); 71 | 72 | /// 73 | /// Gets or sets the redirection target URL from the Location response header. 74 | /// 75 | /// 76 | /// This property must have the URL part in uppercase to observe the HAR specification. 77 | /// Renaming this property to RedirectUrl could break tools implementing the HAR specification. 78 | /// 79 | [SuppressMessage("Design", "CA1056:Uri properties should not be strings", Justification = "Conform to specification that can include empty strings.")] 80 | [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Conform to specification requires URL to be uppercased.")] 81 | public string RedirectURL { get; set; } = string.Empty; 82 | 83 | /// 84 | /// Returns a . 85 | /// 86 | /// The created from this. 87 | public HttpResponseMessage ToHttpResponseMessage() 88 | { 89 | var response = new HttpResponseMessage 90 | { 91 | Content = Content?.ToHttpContent() ?? new ByteArrayContent(Array.Empty()), 92 | StatusCode = (HttpStatusCode)Status, 93 | ReasonPhrase = StatusText, 94 | Version = GetVersion(), 95 | }; 96 | AddHeadersWithoutValidation(response.Headers); 97 | AddHeadersWithoutValidation(response.Content?.Headers); 98 | 99 | return response; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /HttpRecorder/Repositories/HAR/Timings.cs: -------------------------------------------------------------------------------- 1 | namespace HttpRecorder.Repositories.HAR 2 | { 3 | /// 4 | /// Describes various phases within request-response round trip. All times are specified in milliseconds. 5 | /// https://w3c.github.io/web-performance/specs/HAR/Overview.html#timings. 6 | /// 7 | public class Timings 8 | { 9 | /// 10 | /// Gets or sets the time required to send HTTP request to the server. 11 | /// 12 | public double Send { get; set; } 13 | 14 | /// 15 | /// Gets or sets the waiting for a response from the server. 16 | /// 17 | public double Wait { get; set; } 18 | 19 | /// 20 | /// Gets or sets the time required to read entire response from the server (or cache). 21 | /// 22 | public double Receive { get; set; } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /HttpRecorder/Repositories/IInteractionRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace HttpRecorder.Repositories 5 | { 6 | /// 7 | /// Allow storage of . 8 | /// 9 | public interface IInteractionRepository 10 | { 11 | /// 12 | /// Determines whether the recorded interaction exists. 13 | /// 14 | /// The interaction name. 15 | /// A cancellation token to cancel operation. 16 | /// true if the interaction exists. 17 | Task ExistsAsync(string interactionName, CancellationToken cancellationToken = default); 18 | 19 | /// 20 | /// Loads the interaction. 21 | /// 22 | /// The interaction name. 23 | /// A cancellation token to cancel operation. 24 | /// The loaded interaction. 25 | /// If interactions cannot be loaded. 26 | Task LoadAsync(string interactionName, CancellationToken cancellationToken); 27 | 28 | /// 29 | /// Store the interaction. 30 | /// 31 | /// The to store. 32 | /// A cancellation token to cancel operation. 33 | /// The persisted . 34 | Task StoreAsync(Interaction interaction, CancellationToken cancellationToken); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2019 nventive. 191 | All rights reserved. 192 | 193 | Licensed under the Apache License, Version 2.0 (the "License"); 194 | you may not use this file except in compliance with the License. 195 | You may obtain a copy of the License at 196 | 197 | http://www.apache.org/licenses/LICENSE-2.0 198 | 199 | Unless required by applicable law or agreed to in writing, software 200 | distributed under the License is distributed on an "AS IS" BASIS, 201 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 202 | See the License for the specific language governing permissions and 203 | limitations under the License. 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HttpRecorder 2 | 3 | .NET HttpClient integration tests made easy. 4 | 5 | HttpRecorder is an `HttpMessageHandler` that can record and replay HTTP interactions through the standard `HttpClient` . This allows the creation of HTTP integration tests that are fast, repeatable and reliable. 6 | 7 | Interactions are recorded using the [HTTP Archive format standard](https://en.wikipedia.org/wiki/.har), so that they are easily manipulated by your favorite tool of choice. 8 | 9 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) 10 | [![Build Status](https://dev.azure.com/nventive-public/nventive/_apis/build/status/nventive.HttpRecorder?branchName=master)](https://dev.azure.com/nventive-public/nventive/_build/latest?definitionId=3&branchName=master) 11 | ![Nuget](https://img.shields.io/nuget/v/HttpRecorder.svg) 12 | 13 | ## Getting Started 14 | 15 | Install the package: 16 | 17 | ``` 18 | Install-Package HttpRecorder 19 | ``` 20 | 21 | Here is an example of an integration tests using **HttpRecorder** (the `HttpRecorderDelegatingHandler`): 22 | 23 | ```csharp 24 | using System; 25 | using System.IO; 26 | using System.Net; 27 | using System.Net.Http; 28 | using System.Runtime.CompilerServices; 29 | using System.Threading.Tasks; 30 | using HttpRecorder; 31 | using Xunit; 32 | 33 | namespace Sample 34 | { 35 | public class SampleIntegrationTests 36 | { 37 | [Fact] 38 | public async Task ItShould() 39 | { 40 | // Initialize the HttpClient with the recorded file 41 | // stored in a fixture repository. 42 | var client = CreateHttpClient(); 43 | 44 | // Performs HttpClient operations. 45 | // The interaction is recorded if there are no record, 46 | // or replayed if there are 47 | // (without actually hitting the target API). 48 | // Fixture is recorded in the SampleIntegrationTestsFixtures\ItShould.har file. 49 | var response = await client.GetAsync("api/user"); 50 | 51 | // Performs assertions. 52 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 53 | } 54 | 55 | private HttpClient CreateHttpClient( 56 | [CallerMemberName] string testName = "", 57 | [CallerFilePath] string filePath = "") 58 | { 59 | // The location of the file where the interaction is recorded. 60 | // We use the C# CallerMemberName/CallerFilePath attributes to 61 | // automatically set an appropriate path based on the test case. 62 | var interactionFilePath = Path.Join( 63 | Path.GetDirectoryName(filePath), 64 | $"{Path.GetFileNameWithoutExtension(filePath)}Fixtures", 65 | testName); 66 | 67 | // Initialize the HttpClient with HttpRecorderDelegatingHandler, which 68 | // records and replays the interactions. 69 | // Do not forget to set the InnerHandler property. 70 | return new HttpClient( 71 | new HttpRecorderDelegatingHandler(interactionFilePath) { InnerHandler = new HttpClientHandler() }) 72 | { 73 | BaseAddress = new Uri("https://reqres.in/"), 74 | }; 75 | } 76 | } 77 | } 78 | ``` 79 | 80 | ## Features 81 | 82 | ### Record mode 83 | 84 | The `HttpRecorderDelegatingHandler` can be run in different modes: 85 | 86 | - Auto: Default mode - replay the interactions if the recording exists, otherwise record it. 87 | - Record: Always record the interaction, even if a record is present. 88 | - Replay: Always replay the interaction, throw if there is no recording. 89 | - Passthrough: Always passes the request/response down the line, without any interaction 90 | 91 | Just use the appropriate mode in the `HttpRecorderDelegatingHandler` constructor. 92 | 93 | The mode can also be overridden using the environment variable `HTTP_RECORDER_MODE`. 94 | If this is set to any valid `HttpRecorderMode` value, it will override the mode set in the code, 95 | except if this mode is `HttpRecorderMode.Passthrough`. 96 | This is useful when running in a CI environment and you want to make sure that no 97 | request goes out and all interactions are properly committed to the codebase. 98 | 99 | ### Customize the matching behavior 100 | 101 | By default, matching of the recorded requests is done by comparing the HTTP Method and complete Request URI. The first request that match is used and will not be returned again in the current run. 102 | 103 | If needed, the matching behavior can be customized using the `RulesMatcher`: 104 | 105 | ```csharp 106 | using HttpRecorder.Matchers; 107 | 108 | // Will match requests once in order, without comparing requests. 109 | var matcher = RulesMatcher.MatchOnce; 110 | 111 | // Will match requests once only by comparing HTTP methods. 112 | matcher = RulesMatcher.MatchOnce.ByHttpMethod(); 113 | 114 | // Will match requests multiple times by comparing HTTP methods, 115 | // request uri (excluding the query string) and the X-API-Key header. 116 | matcher = RulesMatcher.MatchMultiple 117 | .ByHttpMethod() 118 | .ByRequestUri(UriPartial.Path) 119 | .ByHeader("X-API-Key"); 120 | 121 | // Custom matching rule using the provided incoming request 122 | // and a recorded interaction message. 123 | matcher = RulesMatcher.MatchOnce.By((request, message) => ...); 124 | 125 | var client = new HttpClient(new HttpRecorderDelegatingHandler("...", matcher: matcher)); 126 | ``` 127 | 128 | Additional customization can be done by providing a custom `IRequestMatcher` implementation. 129 | 130 | ### Anonymize the records 131 | 132 | Sometimes, there are portions of the requests / responses that you don't want recorded 133 | (e.g. because of API keys you do not want to commit to the source code repo...). 134 | 135 | In this case, you can use the `RulesInteractionAnonymizer` to perform the substitution. 136 | 137 | ```csharp 138 | using HttpRecorder.Anonymizers; 139 | 140 | var anonymizer = RulesInteractionAnonymizer.Default 141 | .AnonymizeRequestQueryStringParameter("queryStringParam") 142 | .AnonymizeRequestHeader("requestHeader"); 143 | 144 | var client = new HttpClient(new HttpRecorderDelegatingHandler("...", anonymizer: anonymizer)); 145 | ``` 146 | 147 | Additional customization can be done by providing a custom `IInteractionAnonymizer` 148 | implementation. 149 | 150 | ### HttpClientFactory 151 | 152 | The component comes with extension methods for the HttpClientFactory: 153 | 154 | ```csharp 155 | services 156 | .AddHttpClient("TheClient") 157 | .AddHttpRecorder(interactionName); 158 | ``` 159 | 160 | ### Recorder Context 161 | 162 | It is sometime helpful to be able to decoralate the injection of the `HttpRecorderDelegatingHandler` 163 | and the Test case setup. 164 | 165 | This is especially useful in the context of ASP.NET Core Integration tests. 166 | 167 | It is possible to add the `HttpRecorderDelegatingHandler` globally to all `HttpClient` managed by the `IHttpClientFactory`, 168 | and then to customize the recording in the test case by using the `HttpRecorderContext`. 169 | 170 | Here is how to do it: 171 | 172 | ```csharp 173 | // When registering the services, do the following: 174 | services.AddHttpRecorderContextSupport(); 175 | // This can be done in the ConfigureWebHost method of the WebApplicationFactory for example. 176 | // It will inject the HttpRecorderDelegatingHandler in all HttpClients. 177 | 178 | // Then, write your test cases using the following pattern: 179 | [Fact] 180 | public async Task ItShould() 181 | { 182 | using var context = new HttpRecorderContext(); // Notice the using pattern here. 183 | // .. Perform test case. Interactions are recorded and replay as expected :-) 184 | } 185 | 186 | // Additional configuration per HttpClient can be setup as well: 187 | [Fact] 188 | public async Task ItShould() 189 | { 190 | using var context = new HttpRecorderContext((sp, builder) => 191 | { 192 | return builder.Name switch // The builder name here is the name of the HttpClient. 193 | { 194 | nameof(TypedClient) => new HttpRecorderConfiguration 195 | { 196 | Matcher = RulesMatcher.MatchMultiple, 197 | }, 198 | nameof(DisabledClient) => new HttpRecorderConfiguration 199 | { 200 | Enabled = false, 201 | }, 202 | _ => null // Default configuration. 203 | }; 204 | }); 205 | // .. Perform test case. 206 | } 207 | ``` 208 | 209 | ### Record interaction in external tools 210 | 211 | Interaction files can be recorded using your favorite tool (e.g. [Fiddler](https://www.telerik.com/fiddler), Google Chrome Inspector, ...). 212 | 213 | You only have to export it using the HAR/HTTP Archive format. They can then be used as-is as a test fixture that will be loaded by the `HttpRecorderDelegatingHandler`. 214 | 215 | ### Customize the storage 216 | 217 | Reading/writing the interaction can be customized by providing a custom `IInteractionRepository` implementation. 218 | 219 | ## Changelog 220 | 221 | Please consult the [CHANGELOG](CHANGELOG.md) for more information about version 222 | history. 223 | 224 | ## License 225 | 226 | This project is licensed under the Apache 2.0 license - see the 227 | [LICENSE](LICENSE) file for details. 228 | 229 | ## Contributing 230 | 231 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on the process for 232 | contributing to this project. 233 | 234 | Be mindful of our [Code of Conduct](CODE_OF_CONDUCT.md). 235 | 236 | ## Acknowledgments 237 | 238 | - https://github.com/vcr/vcr 239 | - https://github.com/nock/nock 240 | - https://github.com/mleech/scotch 241 | -------------------------------------------------------------------------------- /TestsSuppressions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA0001:Xml comment analysis disabled", Justification = "Not needed for tests projects")] 4 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Not needed for tests projects")] 5 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1652:Enable XML documentation output", Justification = "Not needed for tests projects")] 6 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1615:Element return value must be documented", Justification = "Not needed for tests projects")] 7 | [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters must be documented", Justification = "Not needed for tests projects")] 8 | [assembly: SuppressMessage("Microsoft.Design", "CA1063:Implement IDisposable correctly", Justification = "Not needed for tests projects")] 9 | [assembly: SuppressMessage("Microsoft.Usage", "CA1816:Call GC.SuppressFinalize correctly", Justification = "Not needed for tests projects")] 10 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | 2 | pool: 3 | vmImage: 'windows-2019' 4 | 5 | variables: 6 | buildConfiguration: 'Release' 7 | 8 | steps: 9 | - task: GitVersion@4 10 | displayName: Git Version 11 | inputs: 12 | updateAssemblyInfo: false 13 | 14 | - script: dotnet build --configuration $(buildConfiguration) -p:Version=$(GitVersion.NuGetVersionV2) -p:FileVersion=$(GitVersion.AssemblySemVer) -p:InformationalVersion=$(GitVersion.InformationalVersion) 15 | displayName: Build 16 | 17 | - script: dotnet test --no-build --configuration $(buildConfiguration) --logger:trx 18 | displayName: Test 19 | 20 | - task: PublishTestResults@2 21 | displayName: Publish Tests Results 22 | inputs: 23 | testResultsFormat: 'VSTest' 24 | testResultsFiles: '**/*.trx' 25 | mergeTestResults: true 26 | testRunTitle: 'Unit tests' 27 | 28 | - script: dotnet pack --no-build --configuration $(buildConfiguration) -p:Version=$(GitVersion.NuGetVersionV2) --output $(build.artifactStagingDirectory) 29 | displayName: Pack 30 | 31 | - task: PublishBuildArtifacts@1 32 | displayName: 'Publish Build Artifacts' 33 | 34 | - task: NuGetToolInstaller@1 35 | condition: and(succeeded(), or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), eq('true', variables['forcePushNuget']))) 36 | inputs: 37 | versionSpec: '>= 4.9' 38 | checkLatest: true 39 | 40 | - task: NuGetCommand@2 41 | condition: and(succeeded(), or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), eq('true', variables['forcePushNuget']))) 42 | inputs: 43 | command: 'push' 44 | packagesToPush: '$(build.artifactStagingDirectory)/**/*.nupkg;!$(build.artifactStagingDirectory)/**/*.snupkg' 45 | nuGetFeedType: 'external' 46 | publishFeedCredentials: 'nventive' 47 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nventive/HttpRecorder/8b464c6dbc25956b3d30225c594249b6db365b3d/icon.png -------------------------------------------------------------------------------- /stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 3 | "settings": { 4 | "documentationRules": { 5 | "companyName": "nventive", 6 | "documentationCulture": "en-US", 7 | "documentPrivateElements": false, 8 | "documentPrivateFields": false 9 | }, 10 | "indentation": { 11 | "useTabs": false, 12 | "indentationSize": 4 13 | }, 14 | "orderingRules": { 15 | "usingDirectivesPlacement": "outsideNamespace" 16 | } 17 | } 18 | } --------------------------------------------------------------------------------