├── .gitignore ├── HttpBatchHandler.sln ├── HttpBatchHandler.sln.DotSettings ├── LICENSE ├── NuGet.Config ├── README.md ├── azure-pipelines.yml ├── performance ├── batch.lua ├── payload.txt └── run.sh ├── src └── HttpBatchHandler │ ├── AssemblyInfo.cs │ ├── BatchMiddleware.cs │ ├── BatchMiddlewareExtensions.cs │ ├── Events │ ├── BatchEndContext.cs │ ├── BatchMiddlewareEvents.cs │ ├── BatchMiddlewareOptions.cs │ ├── BatchRequestExecutedContext.cs │ ├── BatchRequestExecutingContext.cs │ ├── BatchRequestPreparationContext.cs │ └── BatchStartContext.cs │ ├── HttpBatchHandler.csproj │ ├── HttpRequestExtensions.cs │ ├── HttpResponseExtensions.cs │ ├── Multipart │ ├── HttpApplicationContent.cs │ ├── HttpApplicationMultipart.cs │ ├── HttpApplicationRequestSection.cs │ ├── HttpApplicationRequestSectionExtensions.cs │ ├── HttpApplicationResponseSection.cs │ ├── HttpApplicationResponseSectionExtensions.cs │ ├── HttpContentExtensions.cs │ ├── IMultipart.cs │ ├── MultipartWriter.cs │ └── SectionHelper.cs │ ├── RequestState.cs │ ├── ResponseFeature.cs │ ├── StreamExtensions.cs │ └── WriteOnlyResponseStream.cs └── test ├── HttpBatchHandler.Benchmarks ├── HttpBatchHandler.Benchmarks.csproj ├── KestrelBenchmark.cs ├── MultipartReaderBenchmark.cs ├── MultipartWriterBenchmark.cs ├── Program.cs ├── Tools │ ├── RandomPortHelper.cs │ ├── Setup.cs │ └── ValuesController.cs └── payload.bin ├── HttpBatchHandler.Tests ├── BaseServerTests.cs ├── BaseWriterTests.cs ├── BatchMiddlewareTestFixture.cs ├── BatchMiddlewareTests.cs ├── HttpBatchHandler.Tests.csproj ├── MultipartContentTests.cs ├── MultipartParserTests.cs ├── MultipartRequest.txt ├── MultipartRequestPathBase.txt ├── MultipartResponse.txt ├── MultipartWriterTests.cs ├── RandomPortHelper.cs ├── ServerTestsWithPathBase.cs ├── ServerTestsWithoutPathBase.cs ├── TestFixture.cs ├── TestFixtureWithPathBase.cs ├── TestFixtureWithoutPathBase.cs ├── TestUtilities.cs └── WriteOnlyResponseStreamTests.cs └── HttpBatchHandler.Website ├── Controllers └── ValuesController.cs ├── HttpBatchHandler.Website.csproj ├── Program.cs ├── Startup.cs ├── appsettings.Development.json └── appsettings.json /.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 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # .NET Core 46 | project.lock.json 47 | project.fragment.lock.json 48 | artifacts/ 49 | **/Properties/launchSettings.json 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # Visual Studio code coverage results 117 | *.coverage 118 | *.coveragexml 119 | 120 | # NCrunch 121 | _NCrunch_* 122 | .*crunch*.local.xml 123 | nCrunchTemp_* 124 | 125 | # MightyMoose 126 | *.mm.* 127 | AutoTest.Net/ 128 | 129 | # Web workbench (sass) 130 | .sass-cache/ 131 | 132 | # Installshield output folder 133 | [Ee]xpress/ 134 | 135 | # DocProject is a documentation generator add-in 136 | DocProject/buildhelp/ 137 | DocProject/Help/*.HxT 138 | DocProject/Help/*.HxC 139 | DocProject/Help/*.hhc 140 | DocProject/Help/*.hhk 141 | DocProject/Help/*.hhp 142 | DocProject/Help/Html2 143 | DocProject/Help/html 144 | 145 | # Click-Once directory 146 | publish/ 147 | 148 | # Publish Web Output 149 | *.[Pp]ublish.xml 150 | *.azurePubxml 151 | # TODO: Comment the next line if you want to checkin your web deploy settings 152 | # but database connection strings (with potential passwords) will be unencrypted 153 | *.pubxml 154 | *.publishproj 155 | 156 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 157 | # checkin your Azure Web App publish settings, but sensitive information contained 158 | # in these scripts will be unencrypted 159 | PublishScripts/ 160 | 161 | # NuGet Packages 162 | *.nupkg 163 | # The packages folder can be ignored because of Package Restore 164 | **/packages/* 165 | # except build/, which is used as an MSBuild target. 166 | !**/packages/build/ 167 | # Uncomment if necessary however generally it will be regenerated when needed 168 | #!**/packages/repositories.config 169 | # NuGet v3's project.json files produces more ignorable files 170 | *.nuget.props 171 | *.nuget.targets 172 | 173 | # Microsoft Azure Build Output 174 | csx/ 175 | *.build.csdef 176 | 177 | # Microsoft Azure Emulator 178 | ecf/ 179 | rcf/ 180 | 181 | # Windows Store app package directories and files 182 | AppPackages/ 183 | BundleArtifacts/ 184 | Package.StoreAssociation.xml 185 | _pkginfo.txt 186 | 187 | # Visual Studio cache files 188 | # files ending in .cache can be ignored 189 | *.[Cc]ache 190 | # but keep track of directories ending in .cache 191 | !*.[Cc]ache/ 192 | 193 | # Others 194 | ClientBin/ 195 | ~$* 196 | *~ 197 | *.dbmdl 198 | *.dbproj.schemaview 199 | *.jfm 200 | *.pfx 201 | *.publishsettings 202 | orleans.codegen.cs 203 | 204 | # Since there are multiple workflows, uncomment next line to ignore bower_components 205 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 206 | #bower_components/ 207 | 208 | # RIA/Silverlight projects 209 | Generated_Code/ 210 | 211 | # Backup & report files from converting an old project file 212 | # to a newer Visual Studio version. Backup files are not needed, 213 | # because we have git ;-) 214 | _UpgradeReport_Files/ 215 | Backup*/ 216 | UpgradeLog*.XML 217 | UpgradeLog*.htm 218 | 219 | # SQL Server files 220 | *.mdf 221 | *.ldf 222 | *.ndf 223 | 224 | # Business Intelligence projects 225 | *.rdl.data 226 | *.bim.layout 227 | *.bim_*.settings 228 | 229 | # Microsoft Fakes 230 | FakesAssemblies/ 231 | 232 | # GhostDoc plugin setting file 233 | *.GhostDoc.xml 234 | 235 | # Node.js Tools for Visual Studio 236 | .ntvs_analysis.dat 237 | node_modules/ 238 | 239 | # Typescript v1 declaration files 240 | typings/ 241 | 242 | # Visual Studio 6 build log 243 | *.plg 244 | 245 | # Visual Studio 6 workspace options file 246 | *.opt 247 | 248 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 249 | *.vbw 250 | 251 | # Visual Studio LightSwitch build output 252 | **/*.HTMLClient/GeneratedArtifacts 253 | **/*.DesktopClient/GeneratedArtifacts 254 | **/*.DesktopClient/ModelManifest.xml 255 | **/*.Server/GeneratedArtifacts 256 | **/*.Server/ModelManifest.xml 257 | _Pvt_Extensions 258 | 259 | # Paket dependency manager 260 | .paket/paket.exe 261 | paket-files/ 262 | 263 | # FAKE - F# Make 264 | .fake/ 265 | 266 | # JetBrains Rider 267 | .idea/ 268 | *.sln.iml 269 | 270 | # CodeRush 271 | .cr/ 272 | 273 | # Python Tools for Visual Studio (PTVS) 274 | __pycache__/ 275 | *.pyc 276 | 277 | # Cake - Uncomment if you are using it 278 | # tools/** 279 | # !tools/packages.config 280 | 281 | # Telerik's JustMock configuration file 282 | *.jmconfig 283 | 284 | # BizTalk build output 285 | *.btp.cs 286 | *.btm.cs 287 | *.odx.cs 288 | *.xsd.cs 289 | /test/HttpBatchHandler.Website/Published 290 | /test/HttpBatchHandler.Benchmarks/BenchmarkDotNet.Artifacts/results 291 | -------------------------------------------------------------------------------- /HttpBatchHandler.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29001.49 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpBatchHandler", "src\HttpBatchHandler\HttpBatchHandler.csproj", "{4BB4B450-3407-4B95-A504-19C1A678B83C}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpBatchHandler.Tests", "test\HttpBatchHandler.Tests\HttpBatchHandler.Tests.csproj", "{9446A0ED-4D0C-463E-9395-0E22830E4DEA}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpBatchHandler.Website", "test\HttpBatchHandler.Website\HttpBatchHandler.Website.csproj", "{30ABB2A7-8028-4098-89AD-C83E08D3390D}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HttpBatchHandler.Benchmarks", "test\HttpBatchHandler.Benchmarks\HttpBatchHandler.Benchmarks.csproj", "{49F2296C-5C1F-4F7D-B52F-1530C52F004B}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Release|Any CPU = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {4BB4B450-3407-4B95-A504-19C1A678B83C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {4BB4B450-3407-4B95-A504-19C1A678B83C}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {4BB4B450-3407-4B95-A504-19C1A678B83C}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {4BB4B450-3407-4B95-A504-19C1A678B83C}.Release|Any CPU.Build.0 = Release|Any CPU 24 | {9446A0ED-4D0C-463E-9395-0E22830E4DEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {9446A0ED-4D0C-463E-9395-0E22830E4DEA}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {9446A0ED-4D0C-463E-9395-0E22830E4DEA}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {9446A0ED-4D0C-463E-9395-0E22830E4DEA}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {30ABB2A7-8028-4098-89AD-C83E08D3390D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {30ABB2A7-8028-4098-89AD-C83E08D3390D}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {30ABB2A7-8028-4098-89AD-C83E08D3390D}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {30ABB2A7-8028-4098-89AD-C83E08D3390D}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {49F2296C-5C1F-4F7D-B52F-1530C52F004B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {49F2296C-5C1F-4F7D-B52F-1530C52F004B}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {49F2296C-5C1F-4F7D-B52F-1530C52F004B}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {49F2296C-5C1F-4F7D-B52F-1530C52F004B}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {0661CE0D-AB2E-4736-A2D4-3A30E1F47295} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /HttpBatchHandler.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | SUGGESTION 3 | SUGGESTION 4 | Required 5 | ExpressionBody 6 | ExpressionBody -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NuGet.Config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HttpBatchHandler 2 | HttpBatchHandler for ASP .NET Core 3.0 3 | 4 | ## Currently working 5 | - Customization of batch handler by OnXXXEvents 6 | - HttpContent for batch requests (the old WebAPI 2.0 libraries should not be necessary anymore) 7 | 8 | ## TODO 9 | - Edge case error handling 10 | - The library should be probably split into two parts, one for the requests and one for the actual batchhandler 11 | 12 | ## Deprectaed 13 | See https://github.com/Tornhoof/HttpBatchHandler/issues/29 for thoughts about a better, less coupled approach. 14 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # ASP.NET Core 2 | # Build and test ASP.NET Core projects targeting .NET Core. 3 | # Add steps that run tests, create a NuGet package, deploy, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core 5 | 6 | trigger: 7 | - master 8 | 9 | pool: 10 | vmImage: 'ubuntu-latest' 11 | 12 | variables: 13 | buildConfiguration: 'Release' 14 | 15 | steps: 16 | - task: DotNetCoreInstaller@1 17 | inputs: 18 | version: '3.0.100' 19 | - script: dotnet restore 20 | displayName: 'dotnet restore' 21 | - script: dotnet build --configuration $(buildConfiguration) 22 | - script: dotnet test -c Release test/HttpBatchHandler.Tests/HttpBatchHandler.Tests.csproj --logger trx --collect "Code coverage" 23 | displayName: 'dotnet test' 24 | - task: PublishTestResults@2 25 | inputs: 26 | testRunner: VSTest 27 | testResultsFiles: '**/*.trx' 28 | - task: CopyFiles@2 29 | inputs: 30 | SourceFolder: '$(Build.SourcesDirectory)' 31 | Contents: '**/$(buildConfiguration)/**/*.nupkg' 32 | TargetFolder: '$(Build.ArtifactStagingDirectory)' 33 | CleanTargetFolder: true 34 | flattenFolders: true 35 | - task: PublishBuildArtifacts@1 36 | inputs: 37 | PathtoPublish: '$(Build.ArtifactStagingDirectory)' 38 | ArtifactName: 'nuget' 39 | publishLocation: 'Container' -------------------------------------------------------------------------------- /performance/batch.lua: -------------------------------------------------------------------------------- 1 | local open = io.open 2 | local function read_file(path) 3 | local file = open(path, "rb") -- r read mode and b binary mode 4 | if not file then return nil end 5 | local content = file:read "*a" -- *a or *all reads the whole file 6 | file:close() 7 | return content 8 | end 9 | 10 | wrk.method = "POST" 11 | wrk.body = read_file("payload.txt") 12 | wrk.headers["Content-Type"] = "multipart/mixed; boundary=\"batch_45cdcaaf-774f-40c6-8a12-dbb835d3132e\"" -------------------------------------------------------------------------------- /performance/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ./wrk -t10 -c100 -d20 -s batch.lua http://192.168.128.221:5123/api/batch 3 | -------------------------------------------------------------------------------- /src/HttpBatchHandler/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("HttpBatchHandler.Tests")] -------------------------------------------------------------------------------- /src/HttpBatchHandler/BatchMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using HttpBatchHandler.Events; 4 | using HttpBatchHandler.Multipart; 5 | using Microsoft.AspNetCore.Authentication; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.Http.Extensions; 8 | using Microsoft.AspNetCore.Http.Features; 9 | using Microsoft.AspNetCore.Http.Features.Authentication; 10 | using Microsoft.AspNetCore.WebUtilities; 11 | using Microsoft.Net.Http.Headers; 12 | 13 | namespace HttpBatchHandler 14 | { 15 | public class BatchMiddleware 16 | { 17 | private readonly IHttpContextFactory _factory; 18 | private readonly RequestDelegate _next; 19 | private readonly BatchMiddlewareOptions _options; 20 | 21 | public BatchMiddleware(RequestDelegate next, IHttpContextFactory factory, BatchMiddlewareOptions options) 22 | { 23 | _next = next; 24 | _factory = factory; 25 | _options = options; 26 | } 27 | 28 | 29 | public Task Invoke(HttpContext httpContext) 30 | { 31 | if (!httpContext.Request.Path.Equals(_options.Match)) 32 | { 33 | return _next.Invoke(httpContext); 34 | } 35 | 36 | return InvokeBatchAsync(httpContext); 37 | } 38 | 39 | private FeatureCollection CreateDefaultFeatures(IFeatureCollection input) 40 | { 41 | var output = new FeatureCollection(); 42 | output.Set(input.Get()); 43 | output.Set(input.Get()); 44 | output.Set(input.Get()); 45 | output.Set(input.Get()); 46 | output.Set(new ItemsFeature()); // per request? 47 | return output; 48 | } 49 | 50 | private async Task InvokeBatchAsync(HttpContext httpContext) 51 | { 52 | if (!httpContext.Request.IsMultiPartBatchRequest()) 53 | { 54 | httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; 55 | await httpContext.Response.WriteAsync("Invalid Content-Type.").ConfigureAwait(false); 56 | return; 57 | } 58 | 59 | var boundary = httpContext.Request.GetMultipartBoundary(); 60 | if (string.IsNullOrEmpty(boundary)) 61 | { 62 | httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; 63 | await httpContext.Response.WriteAsync("Invalid boundary in Content-Type.").ConfigureAwait(false); 64 | return; 65 | } 66 | 67 | var startContext = new BatchStartContext 68 | { 69 | Request = httpContext.Request 70 | }; 71 | var cancellationToken = httpContext.RequestAborted; 72 | await _options.Events.BatchStartAsync(startContext, cancellationToken).ConfigureAwait(false); 73 | Exception exception = null; 74 | var abort = false; 75 | var reader = new MultipartReader(boundary, httpContext.Request.Body); 76 | // PathString.StartsWithSegments that we use requires the base path to not end in a slash. 77 | var pathBase = httpContext.Request.PathBase; 78 | if (pathBase.HasValue && pathBase.Value.EndsWith("/")) 79 | { 80 | pathBase = new PathString(pathBase.Value.Substring(0, pathBase.Value.Length - 1)); 81 | } 82 | 83 | using (var writer = new MultipartWriter("batch", Guid.NewGuid().ToString())) 84 | { 85 | try 86 | { 87 | HttpApplicationRequestSection section; 88 | while ((section = await reader 89 | .ReadNextHttpApplicationRequestSectionAsync(pathBase, httpContext.Request.IsHttps, cancellationToken) 90 | .ConfigureAwait(false)) != null) 91 | { 92 | httpContext.RequestAborted.ThrowIfCancellationRequested(); 93 | var preparationContext = new BatchRequestPreparationContext 94 | { 95 | RequestFeature = section.RequestFeature, 96 | Features = CreateDefaultFeatures(httpContext.Features), 97 | State = startContext.State 98 | }; 99 | await _options.Events.BatchRequestPreparationAsync(preparationContext, cancellationToken) 100 | .ConfigureAwait(false); 101 | using (var state = 102 | new RequestState(section.RequestFeature, _factory, preparationContext.Features)) 103 | { 104 | using (httpContext.RequestAborted.Register(state.AbortRequest)) 105 | { 106 | var executedContext = new BatchRequestExecutedContext 107 | { 108 | Request = state.Context.Request, 109 | State = startContext.State 110 | }; 111 | try 112 | { 113 | var executingContext = new BatchRequestExecutingContext 114 | { 115 | Request = state.Context.Request, 116 | State = startContext.State 117 | }; 118 | await _options.Events 119 | .BatchRequestExecutingAsync(executingContext, cancellationToken) 120 | .ConfigureAwait(false); 121 | await _next.Invoke(state.Context).ConfigureAwait(false); 122 | var response = await state.ResponseTaskAsync().ConfigureAwait(false); 123 | executedContext.Response = state.Context.Response; 124 | writer.Add(new HttpApplicationMultipart(response)); 125 | } 126 | catch (Exception ex) 127 | { 128 | state.Abort(ex); 129 | executedContext.Exception = ex; 130 | } 131 | finally 132 | { 133 | await _options.Events.BatchRequestExecutedAsync(executedContext, cancellationToken) 134 | .ConfigureAwait(false); 135 | abort = executedContext.Abort; 136 | } 137 | 138 | if (abort) 139 | { 140 | break; 141 | } 142 | } 143 | } 144 | } 145 | } 146 | catch (Exception ex) 147 | { 148 | exception = ex; 149 | } 150 | finally 151 | { 152 | var endContext = new BatchEndContext 153 | { 154 | Exception = exception, 155 | State = startContext.State, 156 | IsAborted = abort, 157 | Response = httpContext.Response 158 | }; 159 | if (endContext.Exception != null) 160 | { 161 | endContext.Response.StatusCode = StatusCodes.Status500InternalServerError; 162 | } 163 | 164 | await _options.Events.BatchEndAsync(endContext, cancellationToken).ConfigureAwait(false); 165 | if (!endContext.IsHandled) 166 | { 167 | httpContext.Response.Headers.Add(HeaderNames.ContentType, writer.ContentType); 168 | await writer.CopyToAsync(httpContext.Response.Body, cancellationToken).ConfigureAwait(false); 169 | } 170 | } 171 | } 172 | } 173 | } 174 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/BatchMiddlewareExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using HttpBatchHandler.Events; 3 | using Microsoft.AspNetCore.Builder; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace HttpBatchHandler 8 | { 9 | public static class BatchMiddlewareExtensions 10 | { 11 | public static IApplicationBuilder UseBatchMiddleware(this IApplicationBuilder builder) => builder.UseBatchMiddleware(null); 12 | 13 | public static IApplicationBuilder UseBatchMiddleware(this IApplicationBuilder builder, 14 | Action configurationAction) 15 | { 16 | var factory = builder.ApplicationServices.GetRequiredService(); 17 | var options = new BatchMiddlewareOptions(); 18 | configurationAction?.Invoke(options); 19 | return builder.UseMiddleware(factory, options); 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Events/BatchEndContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Http; 3 | 4 | namespace HttpBatchHandler.Events 5 | { 6 | public class BatchEndContext 7 | { 8 | /// 9 | /// Possible exception 10 | /// 11 | public Exception Exception { get; set; } 12 | 13 | /// 14 | /// If not all requests were executed 15 | /// 16 | public bool IsAborted { get; set; } 17 | 18 | /// 19 | /// If true, then you need to populate the response yourself 20 | /// 21 | public bool IsHandled { get; set; } = false; 22 | 23 | /// 24 | /// The outgoing multipart response 25 | /// 26 | public HttpResponse Response { get; set; } 27 | 28 | /// 29 | /// State 30 | /// 31 | public object State { get; set; } 32 | } 33 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Events/BatchMiddlewareEvents.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace HttpBatchHandler.Events 6 | { 7 | public class BatchMiddlewareEvents 8 | { 9 | /// 10 | /// After all batch request are executed 11 | /// 12 | public Func OnBatchEndAsync { get; set; } = 13 | (context, token) => Task.CompletedTask; 14 | 15 | /// 16 | /// Before an individual request is created 17 | /// 18 | public Func OnBatchPreparationAsync { get; set; } = 19 | (context, token) => Task.CompletedTask; 20 | 21 | /// 22 | /// After an individual request in a batch is executed 23 | /// 24 | public Func OnBatchRequestExecutedAsync { get; set; } = 25 | (context, token) => Task.CompletedTask; 26 | 27 | /// 28 | /// Before an individual request in a batch is executed 29 | /// 30 | public Func OnBatchRequestExecutingAsync { get; set; } = 31 | (context, token) => Task.CompletedTask; 32 | 33 | /// 34 | /// Before any request in a batch are executed 35 | /// 36 | public Func OnBatchStartAsync { get; set; } = 37 | (context, token) => Task.CompletedTask; 38 | 39 | /// 40 | /// After all batch request are executed 41 | /// 42 | public virtual Task BatchEndAsync(BatchEndContext context, CancellationToken cancellationToken = default) => OnBatchEndAsync(context, cancellationToken); 43 | 44 | /// 45 | /// After an individual request in a batch is executed 46 | /// 47 | public virtual Task BatchRequestExecutedAsync(BatchRequestExecutedContext context, 48 | CancellationToken cancellationToken = default) => OnBatchRequestExecutedAsync(context, cancellationToken); 49 | 50 | /// 51 | /// Before an individual request in a batch is executed 52 | /// 53 | public virtual Task BatchRequestExecutingAsync(BatchRequestExecutingContext context, 54 | CancellationToken cancellationToken = default) => OnBatchRequestExecutingAsync(context, cancellationToken); 55 | 56 | /// 57 | /// Before an individual request in a batch is executed 58 | /// 59 | public virtual Task BatchRequestPreparationAsync(BatchRequestPreparationContext context, 60 | CancellationToken cancellationToken = default) => OnBatchPreparationAsync(context, cancellationToken); 61 | 62 | /// 63 | /// Before any request in a batch are executed 64 | /// 65 | public virtual Task BatchStartAsync(BatchStartContext context, CancellationToken cancellationToken = default) => OnBatchStartAsync(context, cancellationToken); 66 | } 67 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Events/BatchMiddlewareOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace HttpBatchHandler.Events 4 | { 5 | public class BatchMiddlewareOptions 6 | { 7 | /// 8 | /// Events 9 | /// 10 | public BatchMiddlewareEvents Events { get; set; } = new BatchMiddlewareEvents(); 11 | /// 12 | /// Endpoint 13 | /// 14 | public PathString Match { get; set; } = "/api/batch"; 15 | } 16 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Events/BatchRequestExecutedContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Http; 3 | 4 | namespace HttpBatchHandler.Events 5 | { 6 | public class BatchRequestExecutedContext 7 | { 8 | /// 9 | /// Abort after this request? 10 | /// 11 | public bool Abort { get; set; } 12 | 13 | /// 14 | /// Exception 15 | /// 16 | public Exception Exception { get; set; } 17 | 18 | /// 19 | /// The individual HttpRequest 20 | /// 21 | public HttpRequest Request { get; set; } 22 | 23 | /// 24 | /// The individual HttpResponse 25 | /// 26 | public HttpResponse Response { get; set; } 27 | 28 | /// 29 | /// State 30 | /// 31 | public object State { get; set; } 32 | } 33 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Events/BatchRequestExecutingContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace HttpBatchHandler.Events 4 | { 5 | public class BatchRequestExecutingContext 6 | { 7 | /// 8 | /// The individual request 9 | /// 10 | public HttpRequest Request { get; set; } 11 | 12 | /// 13 | /// State 14 | /// 15 | public object State { get; set; } 16 | } 17 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Events/BatchRequestPreparationContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http.Features; 2 | 3 | namespace HttpBatchHandler.Events 4 | { 5 | public class BatchRequestPreparationContext 6 | { 7 | /// 8 | /// Features which should be in the httpContext 9 | /// 10 | public IFeatureCollection Features { get; set; } 11 | 12 | /// 13 | /// The individual request, prior to context creation 14 | /// 15 | public IHttpRequestFeature RequestFeature { get; set; } 16 | 17 | /// 18 | /// State 19 | /// 20 | public object State { get; set; } 21 | } 22 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Events/BatchStartContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace HttpBatchHandler.Events 4 | { 5 | public class BatchStartContext 6 | { 7 | /// 8 | /// The incoming multipart request 9 | /// 10 | public HttpRequest Request { get; set; } 11 | 12 | /// 13 | /// State 14 | /// 15 | public object State { get; set; } 16 | } 17 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/HttpBatchHandler.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.0 5 | 0.8.1 6 | 0.8.1.0 7 | 0.8.1.0 8 | true 9 | HttpBatchHandler for ASP.NET Core 3.0 Contributors 2019 10 | HttpBatchHandler 11 | HttpBatchHandler for ASP.NET Core 3.0 Contributors 12 | 13 | $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb 14 | HttpBatchHandler for ASP.NET Core 3.0+ 15 | HttpBatchHandler for ASP.NET Core 3.0+ 16 | Apache-2.0 17 | https://github.com/Tornhoof/HttpBatchHandler/ 18 | https://github.com/Tornhoof/HttpBatchHandler 19 | git 20 | HttpBatchHandler 21 | An ASP.NET WebAPI 2 compatible BatchRequestHandler as described by: 22 | https://blogs.msdn.microsoft.com/webdev/2013/11/01/introducing-batch-support-in-web-api-and-web-api-odata 23 | Fix bad scheme propagation 24 | latest 25 | bin\$(Configuration)\netstandard2.0\HttpBatchHandler.xml 26 | 1701;1702;1705;1591 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/HttpBatchHandler/HttpRequestExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Http; 3 | 4 | namespace HttpBatchHandler 5 | { 6 | internal static class HttpRequestExtensions 7 | { 8 | public static bool IsMultiPartBatchRequest(this HttpRequest request) => request.ContentType?.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) ?? false; 9 | } 10 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/HttpResponseExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace HttpBatchHandler 4 | { 5 | internal static class HttpResponseExtensions 6 | { 7 | public static bool IsSuccessStatusCode(this HttpResponse response) => response.StatusCode >= 200 && response.StatusCode <= 299; 8 | } 9 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/HttpApplicationContent.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Net.Http.Headers; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.WebUtilities; 8 | 9 | namespace HttpBatchHandler.Multipart 10 | { 11 | public class HttpApplicationContent : HttpContent 12 | { 13 | private static readonly char[] Crlf = "\r\n".ToCharArray(); 14 | private readonly HttpRequestMessage _message; 15 | 16 | public HttpApplicationContent(HttpRequestMessage message) 17 | { 18 | _message = message; 19 | Headers.ContentType = new MediaTypeHeaderValue("application/http"); 20 | Headers.ContentType.Parameters.Add(new NameValueHeaderValue("msgtype","request")); 21 | } 22 | 23 | protected override void Dispose(bool disposing) 24 | { 25 | if (disposing) 26 | { 27 | _message.Dispose(); 28 | } 29 | 30 | base.Dispose(disposing); 31 | } 32 | 33 | protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) 34 | { 35 | using (var sb = new HttpResponseStreamWriter(stream, Encoding.ASCII)) 36 | { 37 | await sb.WriteAsync(_message.Method.Method).ConfigureAwait(false); 38 | await sb.WriteAsync(' ').ConfigureAwait(false); 39 | // ReSharper disable once ImpureMethodCallOnReadonlyValueField 40 | await sb.WriteAsync(_message.RequestUri.PathAndQuery).ConfigureAwait(false); 41 | await sb.WriteAsync(' ').ConfigureAwait(false); 42 | await sb.WriteAsync($"HTTP/{_message.Version}").ConfigureAwait(false); 43 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 44 | await sb.WriteAsync($"Host: {_message.RequestUri.Authority}").ConfigureAwait(false); 45 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 46 | foreach (var header in _message.Headers) 47 | { 48 | await sb.WriteAsync(header.Key).ConfigureAwait(false); 49 | await sb.WriteAsync(": ").ConfigureAwait(false); 50 | await sb.WriteAsync(string.Join(", ", header.Value)).ConfigureAwait(false); 51 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 52 | } 53 | 54 | if (_message.Content?.Headers != null) 55 | { 56 | foreach (var header in _message.Content?.Headers) 57 | { 58 | await sb.WriteAsync(header.Key).ConfigureAwait(false); 59 | await sb.WriteAsync(": ").ConfigureAwait(false); 60 | await sb.WriteAsync(string.Join(", ", header.Value)).ConfigureAwait(false); 61 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 62 | } 63 | } 64 | 65 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 66 | } 67 | 68 | if (_message.Content != null) 69 | { 70 | using (var contentStream = await _message.Content.ReadAsStreamAsync().ConfigureAwait(false)) 71 | { 72 | await contentStream.CopyToAsync(stream).ConfigureAwait(false); 73 | } 74 | } 75 | } 76 | 77 | protected override bool TryComputeLength(out long length) 78 | { 79 | length = 0; 80 | return false; 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/HttpApplicationMultipart.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.WebUtilities; 8 | 9 | namespace HttpBatchHandler.Multipart 10 | { 11 | public class HttpApplicationMultipart : IMultipart 12 | { 13 | private static readonly char[] Crlf = "\r\n".ToCharArray(); 14 | private readonly Stream _content; 15 | private readonly string _httpVersion; 16 | private readonly string _reasonPhrase; 17 | 18 | internal HttpApplicationMultipart(ResponseFeature responseFeature) : this(responseFeature.Protocol, 19 | responseFeature.StatusCode, responseFeature.ReasonPhrase, responseFeature.Stream, responseFeature.Headers) 20 | { 21 | } 22 | 23 | public HttpApplicationMultipart(string httpVersion, int statusCode, string reasonPhrase, Stream content, 24 | IHeaderDictionary headers) 25 | { 26 | _httpVersion = httpVersion; 27 | StatusCode = statusCode; 28 | _reasonPhrase = reasonPhrase; 29 | if (string.IsNullOrEmpty(_reasonPhrase)) 30 | { 31 | _reasonPhrase = ReasonPhrases.GetReasonPhrase(statusCode); 32 | } 33 | 34 | _content = content; 35 | Headers = headers; 36 | } 37 | 38 | public IHeaderDictionary Headers { get; } 39 | public int StatusCode { get; } 40 | 41 | public void Dispose() 42 | { 43 | Dispose(true); 44 | GC.SuppressFinalize(this); 45 | } 46 | 47 | public async Task CopyToAsync(Stream stream, CancellationToken cancellationToken = default) 48 | { 49 | using (var sb = new HttpResponseStreamWriter(stream, Encoding.ASCII)) 50 | { 51 | await sb.WriteAsync("Content-Type: application/http; msgtype=response").ConfigureAwait(false); 52 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 53 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 54 | await sb.WriteAsync(_httpVersion).ConfigureAwait(false); 55 | await sb.WriteAsync(' ').ConfigureAwait(false); 56 | // ReSharper disable once ImpureMethodCallOnReadonlyValueField 57 | await sb.WriteAsync(StatusCode.ToString()).ConfigureAwait(false); 58 | await sb.WriteAsync(' ').ConfigureAwait(false); 59 | await sb.WriteAsync(_reasonPhrase).ConfigureAwait(false); 60 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 61 | foreach (var header in Headers) 62 | { 63 | await sb.WriteAsync(header.Key).ConfigureAwait(false); 64 | await sb.WriteAsync(": ").ConfigureAwait(false); 65 | await sb.WriteAsync(header.Value).ConfigureAwait(false); 66 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 67 | } 68 | 69 | await sb.WriteAsync(Crlf).ConfigureAwait(false); 70 | await sb.FlushAsync().ConfigureAwait(false); 71 | } 72 | 73 | if (_content != null) 74 | { 75 | await _content.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); 76 | } 77 | } 78 | 79 | protected virtual void Dispose(bool disposing) 80 | { 81 | if (disposing) 82 | { 83 | _content.Dispose(); 84 | } 85 | } 86 | 87 | ~HttpApplicationMultipart() 88 | { 89 | Dispose(false); 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/HttpApplicationRequestSection.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http.Features; 2 | 3 | namespace HttpBatchHandler.Multipart 4 | { 5 | public class HttpApplicationRequestSection 6 | { 7 | public IHttpRequestFeature RequestFeature { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/HttpApplicationRequestSectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.Http.Features; 8 | using Microsoft.AspNetCore.WebUtilities; 9 | using Microsoft.Extensions.Primitives; 10 | using Microsoft.Net.Http.Headers; 11 | 12 | namespace HttpBatchHandler.Multipart 13 | { 14 | public static class HttpApplicationRequestSectionExtensions 15 | { 16 | private static readonly char[] SpaceArray = {' '}; 17 | 18 | public static async Task ReadNextHttpApplicationRequestSectionAsync( 19 | this MultipartReader reader, PathString pathBase = default, bool isHttps = false, CancellationToken cancellationToken = default) 20 | { 21 | var section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false); 22 | if (section == null) 23 | { 24 | return null; // if null we're done 25 | } 26 | 27 | var contentTypeHeader = MediaTypeHeaderValue.Parse(section.ContentType); 28 | if (!contentTypeHeader.MediaType.HasValue || 29 | !contentTypeHeader.MediaType.Equals("application/http", StringComparison.OrdinalIgnoreCase)) 30 | { 31 | throw new InvalidDataException("Invalid Content-Type."); 32 | } 33 | 34 | var param = contentTypeHeader.Parameters.SingleOrDefault(a => 35 | a.Name.HasValue && a.Value.HasValue && a.Name.Equals("msgtype", StringComparison.OrdinalIgnoreCase) && 36 | a.Value.Equals("request", StringComparison.OrdinalIgnoreCase)); 37 | if (param == null) 38 | { 39 | throw new InvalidDataException("Invalid Content-Type."); 40 | } 41 | 42 | var bufferedStream = new BufferedReadStream(section.Body, SectionHelper.DefaultBufferSize); 43 | var requestLineParts = await ReadRequestLineAsync(bufferedStream, cancellationToken).ConfigureAwait(false); 44 | if (requestLineParts.Length != 3) 45 | { 46 | throw new InvalidDataException("Invalid request line."); 47 | } 48 | 49 | // Validation of the request line parts necessary? 50 | var headers = await SectionHelper.ReadHeadersAsync(bufferedStream, cancellationToken).ConfigureAwait(false); 51 | if (!headers.TryGetValue(HeaderNames.Host, out var hostHeader)) 52 | { 53 | throw new InvalidDataException("No Host Header"); 54 | } 55 | 56 | var uri = BuildUri(isHttps, hostHeader, requestLineParts[1]); 57 | var fullPath = PathString.FromUriComponent(uri); 58 | var feature = new HttpRequestFeature 59 | { 60 | Body = bufferedStream, 61 | Headers = new HeaderDictionary(headers), 62 | Method = requestLineParts[0], 63 | Protocol = requestLineParts[2], 64 | Scheme = uri.Scheme, 65 | QueryString = uri.Query 66 | }; 67 | if (fullPath.StartsWithSegments(pathBase, out var remainder)) 68 | { 69 | feature.PathBase = pathBase.Value; 70 | feature.Path = remainder.Value; 71 | } 72 | else 73 | { 74 | feature.PathBase = string.Empty; 75 | feature.Path = fullPath.Value; 76 | } 77 | 78 | return new HttpApplicationRequestSection 79 | { 80 | RequestFeature = feature 81 | }; 82 | } 83 | 84 | private static Uri BuildUri(bool isHttps, StringValues hostHeader, string pathAndQuery) 85 | { 86 | if (hostHeader.Count != 1) 87 | { 88 | throw new InvalidOperationException("Invalid Host Header"); 89 | } 90 | 91 | var hostString = new HostString(hostHeader.Single()); 92 | if (!hostString.HasValue) 93 | { 94 | return null; 95 | } 96 | 97 | var scheme = isHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; 98 | var fullUri = $"{scheme}://{hostString.ToUriComponent()}{pathAndQuery}"; 99 | var uri = new Uri(fullUri); 100 | return uri; 101 | } 102 | 103 | 104 | private static async Task ReadRequestLineAsync(BufferedReadStream stream, 105 | CancellationToken cancellationToken) 106 | { 107 | var line = await stream.ReadLineAsync(MultipartReader.DefaultHeadersLengthLimit, cancellationToken) 108 | .ConfigureAwait(false); 109 | return line.Split(SpaceArray); 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/HttpApplicationResponseSection.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http.Features; 2 | 3 | namespace HttpBatchHandler.Multipart 4 | { 5 | public class HttpApplicationResponseSection 6 | { 7 | public IHttpResponseFeature ResponseFeature { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/HttpApplicationResponseSectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.WebUtilities; 8 | using Microsoft.Net.Http.Headers; 9 | 10 | namespace HttpBatchHandler.Multipart 11 | { 12 | public static class HttpApplicationResponseSectionExtensions 13 | { 14 | private static readonly char[] SpaceArray = {' '}; 15 | 16 | public static async Task ReadNextHttpApplicationResponseSectionAsync( 17 | this MultipartReader reader, CancellationToken cancellationToken = default) 18 | { 19 | var section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false); 20 | if (section == null) 21 | { 22 | return null; // if null we're done 23 | } 24 | 25 | var contentTypeHeader = MediaTypeHeaderValue.Parse(section.ContentType); 26 | if (!contentTypeHeader.MediaType.HasValue || 27 | !contentTypeHeader.MediaType.Equals("application/http", StringComparison.OrdinalIgnoreCase)) 28 | { 29 | throw new InvalidDataException("Invalid Content-Type."); 30 | } 31 | 32 | var param = contentTypeHeader.Parameters.SingleOrDefault(a => 33 | a.Name.HasValue && a.Value.HasValue && 34 | a.Name.Value.Equals("msgtype", StringComparison.OrdinalIgnoreCase) && 35 | a.Value.Equals("response", StringComparison.OrdinalIgnoreCase)); 36 | if (param == null) 37 | { 38 | throw new InvalidDataException("Invalid Content-Type."); 39 | } 40 | 41 | var bufferedStream = new BufferedReadStream(section.Body, SectionHelper.DefaultBufferSize); 42 | var responseLine = await ReadResponseLineAsync(bufferedStream, cancellationToken).ConfigureAwait(false); 43 | if (responseLine.Length != 3) 44 | { 45 | throw new InvalidDataException("Invalid request line."); 46 | } 47 | 48 | var headers = await SectionHelper.ReadHeadersAsync(bufferedStream, cancellationToken).ConfigureAwait(false); 49 | return new HttpApplicationResponseSection 50 | { 51 | ResponseFeature = new ResponseFeature(responseLine[0], int.Parse(responseLine[1]), responseLine[2], bufferedStream, new HeaderDictionary(headers)) 52 | }; 53 | } 54 | 55 | 56 | private static async Task ReadResponseLineAsync(BufferedReadStream stream, 57 | CancellationToken cancellationToken) 58 | { 59 | var line = await stream.ReadLineAsync(MultipartReader.DefaultHeadersLengthLimit, cancellationToken) 60 | .ConfigureAwait(false); 61 | return line.Split(SpaceArray, 3); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/HttpContentExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net.Http; 4 | using System.Net.Http.Headers; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.WebUtilities; 8 | 9 | namespace HttpBatchHandler.Multipart 10 | { 11 | public static class HttpContentExtensions 12 | { 13 | public static async Task ReadAsMultipartAsync(this HttpContent content, 14 | CancellationToken cancellationToken = default) 15 | { 16 | if (!content.Headers.IsMultipart()) 17 | { 18 | return null; 19 | } 20 | 21 | var boundary = content.Headers.GetMultipartBoundary(); 22 | if (string.IsNullOrEmpty(boundary)) 23 | { 24 | return null; 25 | } 26 | 27 | var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); 28 | var reader = new MultipartReader(boundary, stream); 29 | return reader; 30 | } 31 | 32 | private static string GetMultipartBoundary(this HttpContentHeaders headers) 33 | { 34 | if (headers == null) 35 | { 36 | throw new ArgumentNullException(nameof(headers)); 37 | } 38 | 39 | if (headers.IsMultipart()) 40 | { 41 | var boundaryParam = headers.ContentType.Parameters.FirstOrDefault(a => 42 | string.Equals(a.Name, "boundary", StringComparison.OrdinalIgnoreCase)); 43 | return boundaryParam?.Value.Trim('"'); 44 | } 45 | 46 | return null; 47 | } 48 | 49 | private static bool IsMultipart(this HttpContentHeaders headers) 50 | { 51 | if (headers == null) 52 | { 53 | throw new ArgumentNullException(nameof(headers)); 54 | } 55 | 56 | return headers.ContentType?.MediaType?.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase) ?? 57 | false; 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/IMultipart.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace HttpBatchHandler.Multipart 7 | { 8 | public interface IMultipart : IDisposable 9 | { 10 | Task CopyToAsync(Stream stream, CancellationToken cancellationToken = default); 11 | } 12 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/MultipartWriter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Text; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace HttpBatchHandler.Multipart 9 | { 10 | public class MultipartWriter : IDisposable 11 | { 12 | private readonly byte[] _endBoundary; 13 | private readonly Queue _parts = new Queue(); 14 | private readonly byte[] _startBoundary; 15 | private readonly byte[] _crlf; 16 | 17 | public MultipartWriter(string subType, string boundary) 18 | { 19 | _startBoundary = Encoding.ASCII.GetBytes($"--{boundary}\r\n"); 20 | _endBoundary = Encoding.ASCII.GetBytes($"--{boundary}--\r\n"); 21 | _crlf = Encoding.ASCII.GetBytes("\r\n"); 22 | 23 | ContentType = $"multipart/{subType}; boundary=\"{boundary}\""; 24 | } 25 | 26 | public string ContentType { get; } 27 | 28 | public void Dispose() 29 | { 30 | Dispose(true); 31 | GC.SuppressFinalize(this); 32 | } 33 | 34 | public void Add(IMultipart multipart) 35 | { 36 | _parts.Enqueue(multipart); 37 | } 38 | 39 | public async Task CopyToAsync(Stream stream, CancellationToken cancellationToken = default) 40 | { 41 | while (_parts.Count > 0) 42 | { 43 | using (var part = _parts.Dequeue()) 44 | { 45 | await stream.WriteAsync(_startBoundary, 0, _startBoundary.Length, cancellationToken) 46 | .ConfigureAwait(false); 47 | await part.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); 48 | await stream.WriteAsync(_crlf, 0, _crlf.Length, cancellationToken).ConfigureAwait(false); 49 | } 50 | } 51 | 52 | await stream.WriteAsync(_endBoundary, 0, _endBoundary.Length, cancellationToken).ConfigureAwait(false); 53 | } 54 | 55 | protected virtual void Dispose(bool disposing) 56 | { 57 | if (disposing) 58 | { 59 | while (_parts.Count > 0) 60 | { 61 | var part = _parts.Dequeue(); 62 | part.Dispose(); 63 | } 64 | } 65 | } 66 | 67 | ~MultipartWriter() 68 | { 69 | Dispose(false); 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/Multipart/SectionHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.WebUtilities; 6 | using Microsoft.Extensions.Primitives; 7 | 8 | namespace HttpBatchHandler.Multipart 9 | { 10 | internal static class SectionHelper 11 | { 12 | public const int DefaultBufferSize = 1024 * 4; 13 | 14 | public static async Task> ReadHeadersAsync(BufferedReadStream stream, 15 | CancellationToken cancellationToken = default) 16 | { 17 | var totalSize = 0; 18 | var accumulator = new KeyValueAccumulator(); 19 | var line = await stream.ReadLineAsync(MultipartReader.DefaultHeadersLengthLimit - totalSize, 20 | cancellationToken).ConfigureAwait(false); 21 | while (!string.IsNullOrEmpty(line)) 22 | { 23 | if (MultipartReader.DefaultHeadersLengthLimit - totalSize < line.Length) 24 | { 25 | throw new InvalidDataException( 26 | $"Multipart headers length limit {MultipartReader.DefaultHeadersLengthLimit} exceeded."); 27 | } 28 | 29 | totalSize += line.Length; 30 | var splitIndex = line.IndexOf(':'); 31 | if (splitIndex <= 0) 32 | { 33 | throw new InvalidDataException($"Invalid header line: {line}"); 34 | } 35 | 36 | var name = line.Substring(0, splitIndex); 37 | var value = line.Substring(splitIndex + 1, line.Length - splitIndex - 1).Trim(); 38 | accumulator.Append(name, value); 39 | if (accumulator.KeyCount > MultipartReader.DefaultHeadersCountLimit) 40 | { 41 | throw new InvalidDataException( 42 | $"Multipart headers count limit {MultipartReader.DefaultHeadersCountLimit} exceeded."); 43 | } 44 | 45 | line = await stream.ReadLineAsync(MultipartReader.DefaultHeadersLengthLimit - totalSize, 46 | cancellationToken).ConfigureAwait(false); 47 | } 48 | 49 | return accumulator.GetResults(); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/RequestState.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Http.Features; 6 | 7 | namespace HttpBatchHandler 8 | { 9 | internal class RequestState : IDisposable 10 | { 11 | private readonly IHttpContextFactory _factory; 12 | private readonly CancellationTokenSource _requestAbortedSource; 13 | private readonly ResponseFeature _responseFeature; 14 | private readonly WriteOnlyResponseStream _responseStream; 15 | private bool _pipelineFinished; 16 | 17 | internal RequestState(IHttpRequestFeature requestFeature, IHttpContextFactory factory, 18 | IFeatureCollection featureCollection) 19 | { 20 | _factory = factory; 21 | _requestAbortedSource = new CancellationTokenSource(); 22 | _pipelineFinished = false; 23 | 24 | var contextFeatures = new FeatureCollection(featureCollection); 25 | contextFeatures.Set(requestFeature); 26 | 27 | _responseStream = new WriteOnlyResponseStream(AbortRequest); 28 | _responseFeature = new ResponseFeature(requestFeature.Protocol, 200, null, _responseStream, new HeaderDictionary()) {Abort = Abort}; 29 | contextFeatures.Set(_responseFeature); 30 | contextFeatures.Set(new StreamResponseBodyFeature(_responseStream)); 31 | var requestLifetimeFeature = new HttpRequestLifetimeFeature(); 32 | contextFeatures.Set(requestLifetimeFeature); 33 | requestLifetimeFeature.RequestAborted = _requestAbortedSource.Token; 34 | 35 | Context = _factory.Create(contextFeatures); 36 | } 37 | 38 | public HttpContext Context { get; } 39 | 40 | public void Dispose() 41 | { 42 | Dispose(true); 43 | GC.SuppressFinalize(this); 44 | } 45 | 46 | protected virtual void Dispose(bool disposing) 47 | { 48 | if (disposing) 49 | { 50 | _factory.Dispose(Context); 51 | } 52 | } 53 | 54 | internal void Abort(Exception exception) 55 | { 56 | _pipelineFinished = true; 57 | _responseStream.Abort(exception); 58 | } 59 | 60 | internal void AbortRequest() 61 | { 62 | if (!_pipelineFinished) 63 | { 64 | _requestAbortedSource.Cancel(); 65 | } 66 | } 67 | 68 | /// 69 | /// FireOnSendingHeadersAsync is a bit late here, the remaining middlewares are already fully processed, the testhost 70 | /// does it on the first body stream write, which is more logical 71 | /// but I'm not certain about the added complexity 72 | /// 73 | internal async Task ResponseTaskAsync() 74 | { 75 | _pipelineFinished = true; 76 | await _responseFeature.FireOnSendingHeadersAsync().ConfigureAwait(false); 77 | await _responseFeature.FireOnResponseCompletedAsync().ConfigureAwait(false); 78 | _responseStream.Complete(); 79 | return _responseFeature; 80 | } 81 | 82 | ~RequestState() 83 | { 84 | Dispose(false); 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/ResponseFeature.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.IO.Pipelines; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.AspNetCore.Http.Features; 8 | 9 | namespace HttpBatchHandler 10 | { 11 | internal class ResponseFeature : IHttpResponseFeature 12 | { 13 | private Func _responseCompletedAsync = () => Task.FromResult(true); 14 | private Func _responseStartingAsync = () => Task.FromResult(true); 15 | 16 | internal ResponseFeature(string protocol, int statusCode, string reasonPhrase, Stream content, 17 | IHeaderDictionary headers) 18 | { 19 | Protocol = protocol; 20 | StatusCode = statusCode; 21 | ReasonPhrase = reasonPhrase; 22 | Body = content; 23 | Headers = headers; 24 | } 25 | 26 | public Stream Stream => Body; 27 | 28 | public Action Abort { get; set; } 29 | 30 | public Stream Body { get; set; } 31 | 32 | public bool HasStarted { get; private set; } 33 | 34 | public IHeaderDictionary Headers { get; set; } 35 | 36 | public string Protocol { get; set; } 37 | 38 | public string ReasonPhrase { get; set; } 39 | 40 | public int StatusCode { get; set; } 41 | 42 | public void OnCompleted(Func callback, object state) 43 | { 44 | var prior = _responseCompletedAsync; 45 | _responseCompletedAsync = async () => 46 | { 47 | try 48 | { 49 | await callback(state).ConfigureAwait(false); 50 | } 51 | finally 52 | { 53 | await prior().ConfigureAwait(false); 54 | } 55 | }; 56 | } 57 | 58 | public void OnStarting(Func callback, object state) 59 | { 60 | if (HasStarted) 61 | { 62 | throw new InvalidOperationException(); 63 | } 64 | 65 | var prior = _responseStartingAsync; 66 | _responseStartingAsync = async () => 67 | { 68 | await callback(state).ConfigureAwait(false); 69 | await prior().ConfigureAwait(false); 70 | }; 71 | } 72 | 73 | public Task FireOnResponseCompletedAsync() => _responseCompletedAsync(); 74 | 75 | public async Task FireOnSendingHeadersAsync() 76 | { 77 | if (!HasStarted) 78 | { 79 | try 80 | { 81 | await _responseStartingAsync().ConfigureAwait(false); 82 | } 83 | finally 84 | { 85 | HasStarted = true; 86 | if (Headers is HeaderDictionary hd) 87 | { 88 | hd.IsReadOnly = true; 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/StreamExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace HttpBatchHandler 6 | { 7 | internal static class StreamExtensions 8 | { 9 | public static async Task ReadAsStringAsync(this Stream stream, 10 | CancellationToken cancellationToken = default) 11 | { 12 | using (var tr = new StreamReader(stream)) 13 | { 14 | return await tr.ReadToEndAsync().ConfigureAwait(false); 15 | } 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/HttpBatchHandler/WriteOnlyResponseStream.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace HttpBatchHandler 9 | { 10 | internal class WriteOnlyResponseStream : Stream 11 | { 12 | private readonly Action _abortRequest; 13 | private readonly Queue> _data = new Queue>(); 14 | private bool _aborted; 15 | private Exception _abortException; 16 | private bool _complete; 17 | 18 | public WriteOnlyResponseStream(Action abortRequest) 19 | { 20 | _abortRequest = abortRequest; 21 | } 22 | 23 | public override bool CanRead { get; } = false; 24 | public override bool CanSeek { get; } = false; 25 | public override bool CanWrite { get; } = true; 26 | 27 | public override long Length 28 | => throw new NotSupportedException(); 29 | 30 | public override long Position 31 | { 32 | get => throw new NotSupportedException(); 33 | set => throw new NotSupportedException(); 34 | } 35 | 36 | public void Abort(Exception exception = null) 37 | { 38 | _aborted = true; 39 | _abortException = exception ?? new OperationCanceledException(); 40 | } 41 | 42 | public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback callback, 43 | object state) => throw new NotSupportedException(); 44 | 45 | public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, 46 | object state) 47 | { 48 | var task = WriteAsync(buffer, offset, count, default, state); 49 | if (callback != null) 50 | { 51 | task.ContinueWith(callback.Invoke); 52 | } 53 | 54 | return task; 55 | } 56 | 57 | public void Complete() 58 | { 59 | _complete = true; 60 | } 61 | 62 | public override async Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) 63 | { 64 | while (_data.Count > 0) 65 | { 66 | var buffer = _data.Dequeue(); 67 | await destination.WriteAsync(buffer.Array, buffer.Offset, buffer.Count, cancellationToken) 68 | .ConfigureAwait(false); 69 | ArrayPool.Shared.Return(buffer.Array); 70 | } 71 | } 72 | 73 | public override int EndRead(IAsyncResult asyncResult) => throw new NotSupportedException(); 74 | 75 | public override void EndWrite(IAsyncResult asyncResult) 76 | { 77 | ((Task) asyncResult).GetAwaiter().GetResult(); 78 | } 79 | 80 | public override void Flush() 81 | { 82 | CheckComplete(); 83 | CheckAborted(); 84 | } 85 | 86 | public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); 87 | 88 | public override Task 89 | ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => throw new NotSupportedException(); 90 | 91 | public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); 92 | 93 | public override void SetLength(long value) 94 | { 95 | throw new NotSupportedException(); 96 | } 97 | 98 | public override void Write(byte[] buffer, int offset, int count) 99 | { 100 | WriteAsync(buffer, offset, count, default).GetAwaiter().GetResult(); 101 | } 102 | 103 | public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) 104 | { 105 | CheckAborted(); 106 | CheckComplete(); 107 | var data = ArrayPool.Shared.Rent(count); 108 | Buffer.BlockCopy(buffer, offset, data, 0, count); 109 | var segment = new ArraySegment(data, 0, count); 110 | _data.Enqueue(segment); 111 | return Task.CompletedTask; 112 | } 113 | 114 | protected override void Dispose(bool disposing) 115 | { 116 | if (disposing) 117 | { 118 | while (_data.Count > 0) 119 | { 120 | var buffer = _data.Dequeue(); 121 | ArrayPool.Shared.Return(buffer.Array); 122 | } 123 | 124 | _abortRequest(); 125 | } 126 | 127 | base.Dispose(disposing); 128 | } 129 | 130 | private void CheckAborted() 131 | { 132 | if (_aborted) 133 | { 134 | throw new IOException("Aborted", _abortException); 135 | } 136 | } 137 | 138 | private void CheckComplete() 139 | { 140 | if (_complete) 141 | { 142 | throw new IOException("Completed"); 143 | } 144 | } 145 | 146 | private Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken, object state) 147 | { 148 | var tcs = new TaskCompletionSource(state); 149 | var task = WriteAsync(buffer, offset, count, cancellationToken); 150 | task.ContinueWith((task2, state2) => 151 | { 152 | var tcs2 = (TaskCompletionSource) state2; 153 | if (task2.IsCanceled) 154 | { 155 | tcs2.SetCanceled(); 156 | } 157 | else if (task2.IsFaulted) 158 | { 159 | tcs2.SetException(task2.Exception); 160 | } 161 | else 162 | { 163 | tcs2.SetResult(null); 164 | } 165 | }, tcs, cancellationToken); 166 | return tcs.Task; 167 | } 168 | } 169 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Benchmarks/HttpBatchHandler.Benchmarks.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/HttpBatchHandler.Benchmarks/KestrelBenchmark.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using BenchmarkDotNet.Attributes; 7 | using HttpBatchHandler.Benchmarks.Tools; 8 | using HttpBatchHandler.Multipart; 9 | using Microsoft.AspNetCore.WebUtilities; 10 | 11 | namespace HttpBatchHandler.Benchmarks 12 | { 13 | [MemoryDiagnoser] 14 | public class KestrelBenchmark 15 | { 16 | private KestrelHost _kestrelHost; 17 | [GlobalSetup] 18 | public void GlobalSetup() 19 | { 20 | _kestrelHost = new KestrelHost(); 21 | } 22 | 23 | [GlobalCleanup] 24 | public void GlobalCleanup() 25 | { 26 | _kestrelHost.Dispose(); 27 | } 28 | 29 | [Benchmark] 30 | public async Task Requests() 31 | { 32 | var count = 1000; 33 | var messages = new List(count); 34 | for (var j = 0; j < count; j++) 35 | { 36 | var message = new HttpRequestMessage(HttpMethod.Get, new Uri(_kestrelHost.BaseUri, "api/values")); 37 | messages.Add(message); 38 | } 39 | 40 | var result = await SendBatchRequestAsync(messages).ConfigureAwait(false); 41 | return result; 42 | } 43 | 44 | private async Task SendBatchRequestAsync(IEnumerable requestMessages, CancellationToken cancellationToken = default) 45 | { 46 | var batchUri = new Uri(_kestrelHost.BaseUri, "api/batch"); 47 | int count = 0; 48 | using (var requestContent = new MultipartContent("batch", "batch_" + Guid.NewGuid())) 49 | { 50 | var multipartContent = requestContent; 51 | foreach (var httpRequestMessage in requestMessages) 52 | { 53 | var content = new HttpApplicationContent(httpRequestMessage); 54 | multipartContent.Add(content); 55 | } 56 | 57 | using (var requestMessage = new HttpRequestMessage(HttpMethod.Post, batchUri) 58 | { 59 | Content = requestContent 60 | }) 61 | { 62 | using (var responseMessage = await _kestrelHost.HttpClient.SendAsync(requestMessage, cancellationToken) 63 | .ConfigureAwait(false)) 64 | { 65 | var reader = await Multipart.HttpContentExtensions.ReadAsMultipartAsync(responseMessage.Content, cancellationToken).ConfigureAwait(false); 66 | MultipartSection section; 67 | while ((section = await reader.ReadNextSectionAsync(cancellationToken)) != null) 68 | { 69 | count++; 70 | } 71 | } 72 | } 73 | } 74 | return count; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/HttpBatchHandler.Benchmarks/MultipartReaderBenchmark.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using BenchmarkDotNet.Attributes; 5 | using HttpBatchHandler.Multipart; 6 | using Microsoft.AspNetCore.WebUtilities; 7 | 8 | namespace HttpBatchHandler.Benchmarks 9 | { 10 | [MemoryDiagnoser] 11 | public class MultipartReaderBenchmark 12 | { 13 | private const string Boundary = "batch_45cdcaaf-774f-40c6-8a12-dbb835d3132e"; 14 | 15 | [Benchmark] 16 | public async Task ReadMultipart() 17 | { 18 | var counter = 0; 19 | using (var body = GetPayload()) 20 | { 21 | var reader = new MultipartReader(Boundary, body); 22 | HttpApplicationRequestSection section; 23 | while ((section = await reader 24 | .ReadNextHttpApplicationRequestSectionAsync(default, false, CancellationToken.None) 25 | .ConfigureAwait(false)) != null) 26 | counter++; 27 | } 28 | 29 | return counter; 30 | } 31 | 32 | private Stream GetPayload() 33 | { 34 | var type = typeof(MultipartReaderBenchmark); 35 | return type.Assembly.GetManifestResourceStream(type, "payload.bin"); 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Benchmarks/MultipartWriterBenchmark.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Text; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using BenchmarkDotNet.Attributes; 8 | using HttpBatchHandler.Multipart; 9 | using Microsoft.AspNetCore.Http; 10 | 11 | namespace HttpBatchHandler.Benchmarks 12 | { 13 | [MemoryDiagnoser] 14 | public class MultipartWriterBenchmark 15 | { 16 | private const string Boundary = "batch_45cdcaaf-774f-40c6-8a12-dbb835d3132e"; 17 | 18 | 19 | [Benchmark] 20 | public async Task WriteMultipart() 21 | { 22 | using (var writer = new MultipartWriter("mixed", Boundary)) 23 | { 24 | var headerDictionary = new HeaderDictionary {{"Content-Type", "application/json; charset=utf-8"}}; 25 | for (int i = 0; i < 1000; i++) 26 | { 27 | writer.Add(new HttpApplicationMultipart("2.0", 200, "Ok", 28 | new MemoryStream(Encoding.UTF8.GetBytes(i.ToString())), headerDictionary)); 29 | } 30 | await writer.CopyToAsync(Stream.Null, CancellationToken.None); 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/HttpBatchHandler.Benchmarks/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using BenchmarkDotNet.Running; 3 | 4 | namespace HttpBatchHandler.Benchmarks 5 | { 6 | internal class Program 7 | { 8 | private static void Main(string[] args) 9 | { 10 | //KestrelBenchmark kb = new KestrelBenchmark(); 11 | //kb.GlobalSetup(); 12 | //await kb.Requests().ConfigureAwait(false); 13 | //kb.GlobalCleanup(); 14 | BenchmarkSwitcher.FromAssemblies(new[] {typeof(Program).Assembly}).Run(); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Benchmarks/Tools/RandomPortHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.NetworkInformation; 5 | 6 | namespace HttpBatchHandler.Benchmarks.Tools 7 | { 8 | public static class RandomPortHelper 9 | { 10 | private static readonly object Lock = new object(); 11 | private static readonly Random Random = new Random(); 12 | 13 | public static int FindFreePort() 14 | { 15 | lock (Lock) 16 | { 17 | var ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties(); 18 | var tcpConnInfoArray = ipGlobalProperties.GetActiveTcpConnections(); 19 | var usedPorts = new HashSet(tcpConnInfoArray.Select(t => t.LocalEndPoint.Port)); 20 | int freePort; 21 | var counter = 0; 22 | while (usedPorts.Contains(freePort = GetRandomNumber(1025, 65535))) 23 | { 24 | counter++; 25 | if (counter > 1000) 26 | { 27 | throw new InvalidOperationException("Can't find port."); 28 | } 29 | } 30 | 31 | return freePort; 32 | } 33 | } 34 | 35 | private static int GetRandomNumber(int minValue, int maxValue) 36 | { 37 | lock (Lock) 38 | { 39 | return Random.Next(minValue, maxValue); 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Benchmarks/Tools/Setup.cs: -------------------------------------------------------------------------------- 1 | #if NETCOREAPP3_0 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Net.Http; 5 | using System.Text; 6 | using Microsoft.AspNetCore; 7 | using Microsoft.AspNetCore.Builder; 8 | using Microsoft.AspNetCore.Hosting; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.Extensions.Configuration; 11 | using Microsoft.Extensions.DependencyInjection; 12 | using Microsoft.Extensions.Hosting; 13 | using Microsoft.Extensions.Logging; 14 | 15 | namespace HttpBatchHandler.Benchmarks.Tools 16 | { 17 | public class Startup 18 | { 19 | public void Configure(IApplicationBuilder app) 20 | { 21 | app.UseBatchMiddleware(); 22 | app.UseRouting(); 23 | app.UseEndpoints(endpoints => 24 | { 25 | endpoints.MapDefaultControllerRoute(); 26 | }); 27 | } 28 | 29 | // This method gets called by the runtime. Use this method to add services to the container. 30 | public void ConfigureServices(IServiceCollection services) 31 | { 32 | services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0); 33 | services.AddControllers(); 34 | } 35 | } 36 | 37 | 38 | public class KestrelHost : IDisposable 39 | { 40 | private readonly IWebHost _disposable; 41 | 42 | public KestrelHost() 43 | { 44 | var port = RandomPortHelper.FindFreePort(); 45 | BaseUri = new Uri($"http://localhost:{port}"); 46 | var url = new UriBuilder(BaseUri) 47 | { 48 | Path = string.Empty 49 | }; 50 | 51 | _disposable = WebHost.CreateDefaultBuilder() 52 | .UseStartup() 53 | .UseUrls(url.Uri.ToString()) 54 | .ConfigureLogging(logging => 55 | { 56 | logging.ClearProviders(); 57 | }) 58 | .Build(); 59 | _disposable.Start(); 60 | } 61 | 62 | public Uri BaseUri { get; } 63 | 64 | public HttpClient HttpClient { get; } = new HttpClient(); 65 | 66 | public void Dispose() 67 | { 68 | Dispose(true); 69 | GC.SuppressFinalize(this); 70 | } 71 | 72 | public virtual void Dispose(bool dispose) 73 | { 74 | if (dispose) 75 | { 76 | _disposable?.Dispose(); 77 | HttpClient?.Dispose(); 78 | } 79 | } 80 | } 81 | } 82 | #endif -------------------------------------------------------------------------------- /test/HttpBatchHandler.Benchmarks/Tools/ValuesController.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace HttpBatchHandler.Benchmarks.Tools 5 | { 6 | [Route("api/[controller]")] 7 | public class ValuesController : Controller 8 | { 9 | // DELETE api/values/5 10 | [HttpDelete("{id}")] 11 | public void Delete(int id) 12 | { 13 | } 14 | 15 | // GET api/values 16 | [HttpGet] 17 | public IEnumerable Get() => new[] {"value1", "value2"}; 18 | 19 | // GET api/values/5 20 | [HttpGet("{id}", Name = "GetById")] 21 | public string Get(int id) => id.ToString(); 22 | 23 | // GET api/values/query?id=5 24 | [HttpGet("query")] 25 | public string GetFromQuery([FromQuery] int id) => id.ToString(); 26 | } 27 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/BaseServerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Net; 7 | using System.Net.Http; 8 | using System.Security.Cryptography; 9 | using System.Text; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using HttpBatchHandler.Multipart; 13 | using Microsoft.AspNetCore.Http; 14 | using Microsoft.AspNetCore.Http.Features; 15 | using Microsoft.AspNetCore.WebUtilities; 16 | using Microsoft.Net.Http.Headers; 17 | using Xunit; 18 | using Xunit.Abstractions; 19 | 20 | namespace HttpBatchHandler.Tests 21 | { 22 | public abstract class BaseServerTests : IClassFixture where TFixture : TestFixture 23 | { 24 | protected BaseServerTests(TFixture fixture, ITestOutputHelper outputHelper) 25 | { 26 | _fixture = fixture; 27 | _outputHelper = outputHelper; 28 | } 29 | 30 | protected readonly TFixture _fixture; 31 | 32 | 33 | private readonly ITestOutputHelper _outputHelper; 34 | 35 | private MultipartFormDataContent RandomStreamContent() 36 | { 37 | var random = new Random(); 38 | var ms = new MemoryStream(); 39 | for (var i = 0; i < 10; i++) 40 | { 41 | var buffer = new byte[1 << 16]; 42 | random.NextBytes(buffer); 43 | ms.Write(buffer, 0, buffer.Length); 44 | } 45 | 46 | string b64Name; 47 | ms.Position = 0; 48 | using (var md5 = MD5.Create()) 49 | { 50 | var hash = md5.ComputeHash(ms); 51 | b64Name = Convert.ToBase64String(hash); 52 | } 53 | 54 | ms.Position = 0; 55 | var streamContent = new StreamContent(ms); 56 | return new MultipartFormDataContent {{streamContent, b64Name, b64Name}}; 57 | } 58 | 59 | protected async Task SendBatchRequestAsync( 60 | IEnumerable requestMessages, 61 | CancellationToken cancellationToken = default) where TBatchResult : BatchResult, new() 62 | { 63 | var batchUri = new Uri(_fixture.BaseUri, "api/batch"); 64 | using (var requestContent = new MultipartContent("batch", "batch_" + Guid.NewGuid())) 65 | { 66 | var multipartContent = requestContent; 67 | foreach (var httpRequestMessage in requestMessages) 68 | { 69 | var content = new HttpApplicationContent(httpRequestMessage); 70 | multipartContent.Add(content); 71 | } 72 | 73 | using (var requestMessage = new HttpRequestMessage(HttpMethod.Post, batchUri) 74 | { 75 | Content = requestContent 76 | }) 77 | { 78 | using (var responseMessage = await _fixture.HttpClient.SendAsync(requestMessage, cancellationToken) 79 | .ConfigureAwait(false)) 80 | { 81 | var response = await responseMessage.Content.ReadAsMultipartAsync(cancellationToken) 82 | .ConfigureAwait(false); 83 | var responsePayload = 84 | await ReadResponseAsync(response, cancellationToken).ConfigureAwait(false); 85 | var statusCode = responseMessage.StatusCode; 86 | return new BatchResults {ResponsePayload = responsePayload, StatusCode = statusCode}; 87 | } 88 | } 89 | } 90 | } 91 | 92 | private async Task ReadResponseAsync(MultipartReader reader, 93 | CancellationToken cancellationToken = default) where TBatchResult : BatchResult, new() 94 | { 95 | var result = new List(); 96 | HttpApplicationResponseSection section; 97 | while ((section = await reader.ReadNextHttpApplicationResponseSectionAsync(cancellationToken) 98 | .ConfigureAwait(false)) != 99 | null) 100 | { 101 | var batchResult = new TBatchResult 102 | { 103 | StatusCode = section.ResponseFeature.StatusCode, 104 | Headers = section.ResponseFeature.Headers 105 | }; 106 | #pragma warning disable 618 107 | await batchResult.FillAsync(section.ResponseFeature.Body).ConfigureAwait(false); 108 | #pragma warning restore 618 109 | result.Add(batchResult); 110 | } 111 | 112 | return result.ToArray(); 113 | } 114 | 115 | protected abstract class BatchResult 116 | { 117 | public IHeaderDictionary Headers { get; set; } 118 | public bool IsSuccessStatusCode => StatusCode >= 200 && StatusCode <= 299; 119 | public int StatusCode { get; set; } 120 | 121 | public abstract Task FillAsync(Stream data); 122 | } 123 | 124 | protected class StringBatchResult : BatchResult 125 | { 126 | public string ResponsePayload { get; private set; } 127 | 128 | public override async Task FillAsync(Stream data) 129 | { 130 | using (data) 131 | { 132 | ResponsePayload = await data.ReadAsStringAsync().ConfigureAwait(false); 133 | } 134 | } 135 | } 136 | 137 | protected class StreamBatchResult : BatchResult 138 | { 139 | public Stream ResponsePayload { get; private set; } 140 | 141 | public override async Task FillAsync(Stream data) 142 | { 143 | ResponsePayload = new MemoryStream(); 144 | using (data) 145 | { 146 | await data.CopyToAsync(ResponsePayload).ConfigureAwait(false); 147 | } 148 | 149 | ResponsePayload.Position = 0; 150 | } 151 | } 152 | 153 | protected class BatchResults 154 | { 155 | public BatchResult[] ResponsePayload { get; set; } 156 | public HttpStatusCode StatusCode { get; set; } 157 | } 158 | 159 | /// 160 | /// This is basically to find out if the responseStream is correct and not extended with random data 161 | /// 162 | [Fact] 163 | public async Task FileDownload() 164 | { 165 | var messages = new[] 166 | { 167 | new HttpRequestMessage(HttpMethod.Get, new Uri(_fixture.BaseUri, "api/values/File")), 168 | new HttpRequestMessage(HttpMethod.Get, new Uri(_fixture.BaseUri, "api/values/File")), 169 | new HttpRequestMessage(HttpMethod.Get, new Uri(_fixture.BaseUri, "api/values/File")) 170 | }; 171 | var result = await SendBatchRequestAsync(messages).ConfigureAwait(false); 172 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 173 | Assert.Equal(3, result.ResponsePayload.Length); 174 | foreach (var batchResult in result.ResponsePayload) 175 | { 176 | Assert.True(batchResult.IsSuccessStatusCode); 177 | var streamBatchResult = (StreamBatchResult) batchResult; 178 | using (var md5 = MD5.Create()) 179 | { 180 | var hash = md5.ComputeHash(streamBatchResult.ResponsePayload); 181 | var b64 = Convert.ToBase64String(hash); 182 | Assert.True( 183 | streamBatchResult.Headers.TryGetValue(HeaderNames.ContentDisposition, out var contentDispo)); 184 | Assert.Single(contentDispo); 185 | Assert.Contains(b64, contentDispo.First()); 186 | } 187 | 188 | streamBatchResult.ResponsePayload.Dispose(); 189 | } 190 | } 191 | 192 | /// 193 | /// This is basically to find out if the requestStream is correct and not extended with random data 194 | /// 195 | [Fact] 196 | public async Task FileUpload() 197 | { 198 | var messages = new[] 199 | { 200 | new HttpRequestMessage(HttpMethod.Post, new Uri(_fixture.BaseUri, "api/values/File/1")) 201 | { 202 | Content = RandomStreamContent() 203 | }, 204 | new HttpRequestMessage(HttpMethod.Post, new Uri(_fixture.BaseUri, "api/values/File/2")) 205 | { 206 | Content = RandomStreamContent() 207 | }, 208 | new HttpRequestMessage(HttpMethod.Post, new Uri(_fixture.BaseUri, "api/values/File/3")) 209 | { 210 | Content = RandomStreamContent() 211 | } 212 | }; 213 | var result = await SendBatchRequestAsync(messages).ConfigureAwait(false); 214 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 215 | Assert.Equal(3, result.ResponsePayload.Length); 216 | foreach (var batchResult in result.ResponsePayload) 217 | { 218 | Assert.True(batchResult.IsSuccessStatusCode); 219 | } 220 | } 221 | 222 | [Fact] 223 | public async Task Performance() 224 | { 225 | for (var i = 0; i < 100; i++) 226 | { 227 | var sw = new Stopwatch(); 228 | sw.Start(); 229 | var count = 1000; 230 | var messages = new List(count); 231 | for (var j = 0; j < count; j++) 232 | { 233 | var message = new HttpRequestMessage(HttpMethod.Get, new Uri(_fixture.BaseUri, "api/values")); 234 | messages.Add(message); 235 | } 236 | 237 | var result = await SendBatchRequestAsync(messages).ConfigureAwait(false); 238 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 239 | Assert.Equal(1000, result.ResponsePayload.Length); 240 | sw.Stop(); 241 | _outputHelper.WriteLine("Time: {0}", sw.Elapsed.TotalMilliseconds); 242 | } 243 | } 244 | 245 | [Fact] 246 | public async Task Test() 247 | { 248 | var messages = new[] 249 | { 250 | new HttpRequestMessage(HttpMethod.Get, new Uri(_fixture.BaseUri, "api/values")), 251 | new HttpRequestMessage(HttpMethod.Get, new Uri(_fixture.BaseUri, "api/values/5")), 252 | new HttpRequestMessage(HttpMethod.Get, new Uri(_fixture.BaseUri, "api/values/query?id=5")), 253 | new HttpRequestMessage(HttpMethod.Post, new Uri(_fixture.BaseUri, "api/values")) 254 | { 255 | Content = new StringContent("{\"value\": \"Hello World\"}", Encoding.UTF8, "application/json") 256 | }, 257 | new HttpRequestMessage(HttpMethod.Put, new Uri(_fixture.BaseUri, "api/values/5")) 258 | { 259 | Content = new StringContent("{\"value\": \"Hello World\"}", Encoding.UTF8, "application/json") 260 | }, 261 | new HttpRequestMessage(HttpMethod.Delete, new Uri(_fixture.BaseUri, "api/values/5")) 262 | }; 263 | var result = await SendBatchRequestAsync(messages).ConfigureAwait(false); 264 | Assert.Equal(HttpStatusCode.OK, result.StatusCode); 265 | Assert.Equal(6, result.ResponsePayload.Length); 266 | foreach (var batchResult in result.ResponsePayload) 267 | { 268 | Assert.True(batchResult.IsSuccessStatusCode); 269 | } 270 | 271 | var createdResult = result.ResponsePayload[3]; 272 | Assert.Equal(201, createdResult.StatusCode); 273 | var locationHeader = createdResult.Headers["Location"]; 274 | var comparisonUri = new Uri(_fixture.BaseUri, "api/Values/5"); 275 | Assert.Equal(comparisonUri.ToString(), locationHeader); 276 | } 277 | } 278 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/BaseWriterTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Text; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.Net.Http.Headers; 5 | 6 | namespace HttpBatchHandler.Tests 7 | { 8 | public abstract class BaseWriterTests 9 | { 10 | internal ResponseFeature CreateFirstResponse() 11 | { 12 | var output = 13 | "[{\"Id\":1,\"Name\":\"Namefc4b8794-943b-487a-9049-a8559232b9dd\"},{\"Id\":2,\"Name\":\"Name244bbada-3e83-43c8-82f7-5b2c4d72f2ed\"},{\"Id\":3,\"Name\":\"Nameec11d080-7f2d-47df-a483-7ff251cdda7a\"},{\"Id\":4,\"Name\":\"Name14ff5a3d-ad92-41f6-b4f6-9b94622f4968\"},{\"Id\":5,\"Name\":\"Name00f9e4cc-673e-4139-ba30-bfc273844678\"},{\"Id\":6,\"Name\":\"Name01f6660c-d1de-4c05-8567-8ae2759c4117\"},{\"Id\":7,\"Name\":\"Name60030a17-6316-427c-a744-b2fff6d9fe11\"},{\"Id\":8,\"Name\":\"Namefa61eb4c-9f9e-47a2-8dc5-15d8afe33f2d\"},{\"Id\":9,\"Name\":\"Name9b680c10-1727-43f5-83cf-c8eda3a63790\"},{\"Id\":10,\"Name\":\"Name9e66d797-d3a9-44ec-814d-aecde8040ced\"}]"; 14 | var dictionary = new HeaderDictionary {{HeaderNames.ContentType, "application/json; charset=utf-8"}}; 15 | var response = new ResponseFeature("HTTP/1.1", 200, "OK", 16 | new MemoryStream(Encoding.ASCII.GetBytes(output)), dictionary); 17 | return response; 18 | } 19 | 20 | internal ResponseFeature CreateFourthResponse() 21 | { 22 | var dictionary = new HeaderDictionary(); 23 | var response = new ResponseFeature("HTTP/1.1", 204, "No Content", 24 | Stream.Null, dictionary); 25 | return response; 26 | } 27 | 28 | internal ResponseFeature CreateInternalServerResponse() 29 | { 30 | var dictionary = new HeaderDictionary(); 31 | var response = new ResponseFeature("HTTP/1.1", 500, "Internal Server Error", 32 | Stream.Null, dictionary); 33 | return response; 34 | } 35 | 36 | internal ResponseFeature CreateSecondResponse() 37 | { 38 | var dictionary = new HeaderDictionary 39 | { 40 | {HeaderNames.Location, "http://localhost:13245/api/ApiCustomers"}, 41 | {HeaderNames.ContentType, "application/json; charset=utf-8"} 42 | }; 43 | var output = "{\"Id\":21,\"Name\":\"Name4752cbf0-e365-43c3-aa8d-1bbc8429dbf8\"}"; 44 | var response = new ResponseFeature("HTTP/1.1", 201, "Created", 45 | new MemoryStream(Encoding.ASCII.GetBytes(output)), dictionary); 46 | return response; 47 | } 48 | 49 | internal ResponseFeature CreateThirdResponse() 50 | { 51 | var output = 52 | "{\"Id\":1,\"Name\":\"Peter\"}"; 53 | var dictionary = new HeaderDictionary {{HeaderNames.ContentType, "application/json; charset=utf-8"}}; 54 | var response = new ResponseFeature("HTTP/1.1", 200, "OK", 55 | new MemoryStream(Encoding.ASCII.GetBytes(output)), dictionary); 56 | return response; 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/BatchMiddlewareTestFixture.cs: -------------------------------------------------------------------------------- 1 | namespace HttpBatchHandler.Tests 2 | { 3 | public class BatchMiddlewareTestFixture 4 | { 5 | } 6 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/BatchMiddlewareTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Text.RegularExpressions; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using HttpBatchHandler.Events; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.AspNetCore.Http.Features; 10 | using Microsoft.Net.Http.Headers; 11 | using NSubstitute; 12 | using NSubstitute.Core; 13 | using Xunit; 14 | 15 | namespace HttpBatchHandler.Tests 16 | { 17 | public class BatchMiddlewareTests : BaseWriterTests, IClassFixture 18 | { 19 | public BatchMiddlewareTests(BatchMiddlewareTestFixture fixture) 20 | { 21 | _fixture = fixture; 22 | } 23 | 24 | private readonly BatchMiddlewareTestFixture _fixture; 25 | 26 | private async Task AssertExecution(IHttpRequestFeature requestFeature, IHttpResponseFeature responseFeature, 27 | Stream responseStream, 28 | BatchMiddlewareEvents batchMiddlewareEvents, 29 | params ResponseFeature[] responseFeatures) 30 | { 31 | var featureCollection = new FeatureCollection(); 32 | featureCollection.Set(requestFeature); 33 | featureCollection.Set(responseFeature); 34 | IHttpResponseBodyFeature responseBodyFeature = new StreamResponseBodyFeature(responseStream); 35 | featureCollection.Set(responseBodyFeature); 36 | var defaultContext = new DefaultHttpContext(featureCollection); 37 | var middleware = CreateMiddleware(CreateRequestDelegate(responseFeatures), batchMiddlewareEvents); 38 | await middleware.Invoke(defaultContext).ConfigureAwait(false); 39 | } 40 | 41 | 42 | private RequestDelegate CreateRequestDelegate(ResponseFeature[] responseFeatures) 43 | { 44 | var requestDelegate = Substitute.For(); 45 | var functorArray = new Func[responseFeatures.Length]; 46 | for (var i = 0; i < responseFeatures.Length; i++) 47 | { 48 | var index = i; 49 | functorArray[i] = ci => ReturnThis(ci.Arg(), responseFeatures[index]); 50 | } 51 | 52 | requestDelegate.Invoke(null).ReturnsForAnyArgs(functorArray.First(), functorArray.Skip(1).ToArray()); 53 | return requestDelegate; 54 | } 55 | 56 | private async Task ReturnThis(HttpContext context, ResponseFeature responseFeature) 57 | { 58 | await responseFeature.Stream.CopyToAsync(context.Response.Body).ConfigureAwait(false); 59 | foreach (var kval in responseFeature.Headers) 60 | { 61 | context.Response.Headers.Add(kval); 62 | } 63 | 64 | context.Response.StatusCode = responseFeature.StatusCode; 65 | context.Response.HttpContext.Features.Get().ReasonPhrase = 66 | responseFeature.ReasonPhrase; 67 | } 68 | 69 | 70 | private BatchMiddleware CreateMiddleware(RequestDelegate next, BatchMiddlewareEvents eventHandler) => new BatchMiddleware(next, new MockHttpContextFactory(), 71 | new BatchMiddlewareOptions {Events = eventHandler}); 72 | 73 | private class MockHttpContextFactory : IHttpContextFactory 74 | { 75 | public HttpContext Create(IFeatureCollection featureCollection) => new DefaultHttpContext(featureCollection); 76 | 77 | public void Dispose(HttpContext httpContext) 78 | { 79 | } 80 | } 81 | 82 | 83 | private class ThrowExceptionEventHandler : MockedBatchEventHandler 84 | { 85 | public override Task BatchRequestExecutedAsync(BatchRequestExecutedContext context, 86 | CancellationToken cancellationToken = default) 87 | { 88 | if (BatchRequestExecutedCount == 2) 89 | { 90 | throw new InvalidOperationException(); 91 | } 92 | 93 | return base.BatchRequestExecutedAsync(context, cancellationToken); 94 | } 95 | } 96 | 97 | 98 | private class MockedBatchEventHandler : BatchMiddlewareEvents 99 | { 100 | private readonly Guid _state = Guid.NewGuid(); 101 | public int BatchEndCount { get; private set; } 102 | public int BatchRequestExecutedCount { get; private set; } 103 | public int BatchRequestExecutingCount { get; private set; } 104 | public int BatchRequestPreparationCount { get; private set; } 105 | public int BatchStartCount { get; private set; } 106 | 107 | public override async Task BatchEndAsync(BatchEndContext context, 108 | CancellationToken cancellationToken = default) 109 | { 110 | BatchEndCount += 1; 111 | if (context.IsAborted) 112 | { 113 | context.Response.StatusCode = StatusCodes.Status500InternalServerError; 114 | } 115 | 116 | if (context.Exception != null) 117 | { 118 | context.Response.StatusCode = StatusCodes.Status500InternalServerError; 119 | context.Response.Headers.Add(HeaderNames.ContentType, "text/plain"); 120 | context.IsHandled = true; 121 | await context.Response.WriteAsync("Something went wrong.", cancellationToken).ConfigureAwait(false); 122 | } 123 | 124 | Assert.Equal(_state, context.State); 125 | await base.BatchEndAsync(context, cancellationToken).ConfigureAwait(false); 126 | } 127 | 128 | public override Task BatchRequestExecutedAsync(BatchRequestExecutedContext context, 129 | CancellationToken cancellationToken = default) 130 | { 131 | BatchRequestExecutedCount += 1; 132 | if (!context.Response.IsSuccessStatusCode()) 133 | { 134 | context.Abort = true; 135 | context.Exception = new InvalidOperationException(); 136 | } 137 | 138 | Assert.Equal(_state, context.State); 139 | return base.BatchRequestExecutedAsync(context, cancellationToken); 140 | } 141 | 142 | public override Task BatchRequestExecutingAsync(BatchRequestExecutingContext context, 143 | CancellationToken cancellationToken = default) 144 | { 145 | BatchRequestExecutingCount += 1; 146 | Assert.Equal(_state, context.State); 147 | return base.BatchRequestExecutingAsync(context, cancellationToken); 148 | } 149 | 150 | public override Task BatchRequestPreparationAsync(BatchRequestPreparationContext context, 151 | CancellationToken cancellationToken = default) 152 | { 153 | BatchRequestPreparationCount += 1; 154 | Assert.Equal(_state, context.State); 155 | return base.BatchRequestPreparationAsync(context, cancellationToken); 156 | } 157 | 158 | public override Task BatchStartAsync(BatchStartContext context, 159 | CancellationToken cancellationToken = default) 160 | { 161 | BatchStartCount += 1; 162 | context.State = _state; 163 | return base.BatchStartAsync(context, cancellationToken); 164 | } 165 | } 166 | 167 | [Fact] 168 | public async Task AbortException() 169 | { 170 | var requestFeature = new HttpRequestFeature {Path = "/api/batch"}; 171 | requestFeature.Headers.Add(HeaderNames.ContentType, 172 | "multipart/mixed; boundary=\"batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e\""); 173 | requestFeature.Body = TestUtilities.GetNormalizedContentStream("MultipartRequest.txt"); 174 | var responseFeature = new HttpResponseFeature(); 175 | var mockedEvents = new ThrowExceptionEventHandler(); 176 | using (responseFeature.Body = new MemoryStream()) 177 | { 178 | await AssertExecution(requestFeature, responseFeature, responseFeature.Body, mockedEvents, 179 | CreateFirstResponse(), 180 | CreateSecondResponse(), 181 | CreateThirdResponse(), 182 | CreateFourthResponse()).ConfigureAwait(false); 183 | Assert.Equal(StatusCodes.Status500InternalServerError, responseFeature.StatusCode); 184 | Assert.Equal(1, mockedEvents.BatchEndCount); 185 | Assert.Equal(1, mockedEvents.BatchStartCount); 186 | Assert.Equal(3, mockedEvents.BatchRequestPreparationCount); 187 | Assert.Equal(3, mockedEvents.BatchRequestExecutingCount); 188 | Assert.Equal(2, mockedEvents.BatchRequestExecutedCount); 189 | } 190 | } 191 | 192 | [Fact] 193 | public async Task AbortNonSuccessStatusCode() 194 | { 195 | var requestFeature = new HttpRequestFeature {Path = "/api/batch"}; 196 | requestFeature.Headers.Add(HeaderNames.ContentType, 197 | "multipart/mixed; boundary=\"batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e\""); 198 | requestFeature.Body = TestUtilities.GetNormalizedContentStream("MultipartRequest.txt"); 199 | var responseFeature = new HttpResponseFeature(); 200 | var mockedEvents = new MockedBatchEventHandler(); 201 | using (responseFeature.Body = new MemoryStream()) 202 | { 203 | await AssertExecution(requestFeature, responseFeature, responseFeature.Body, mockedEvents, 204 | CreateFirstResponse(), 205 | CreateInternalServerResponse(), 206 | CreateThirdResponse(), 207 | CreateFourthResponse()).ConfigureAwait(false); 208 | Assert.Equal(StatusCodes.Status500InternalServerError, responseFeature.StatusCode); 209 | Assert.Equal(1, mockedEvents.BatchEndCount); 210 | Assert.Equal(1, mockedEvents.BatchStartCount); 211 | Assert.Equal(2, mockedEvents.BatchRequestPreparationCount); 212 | Assert.Equal(2, mockedEvents.BatchRequestExecutingCount); 213 | Assert.Equal(2, mockedEvents.BatchRequestExecutedCount); 214 | } 215 | } 216 | 217 | [Fact] 218 | public async Task BadContentType() 219 | { 220 | var requestFeature = new HttpRequestFeature {Path = "/api/batch"}; 221 | requestFeature.Headers.Add(HeaderNames.ContentType, "application/json"); 222 | requestFeature.Body = Stream.Null; 223 | var responseFeature = new HttpResponseFeature(); 224 | var mockedEvents = new MockedBatchEventHandler(); 225 | using (responseFeature.Body = new MemoryStream()) 226 | { 227 | await AssertExecution(requestFeature, responseFeature, responseFeature.Body, mockedEvents, 228 | CreateFirstResponse(), 229 | CreateSecondResponse(), 230 | CreateThirdResponse(), 231 | CreateFourthResponse()).ConfigureAwait(false); 232 | Assert.Equal(StatusCodes.Status400BadRequest, responseFeature.StatusCode); 233 | Assert.Equal(0, mockedEvents.BatchStartCount); 234 | } 235 | } 236 | 237 | [Fact] 238 | public async Task CompareRequestResponse() 239 | { 240 | var requestFeature = new HttpRequestFeature {Path = "/api/batch"}; 241 | requestFeature.Headers.Add(HeaderNames.ContentType, 242 | "multipart/mixed; boundary=\"batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e\""); 243 | requestFeature.Body = TestUtilities.GetNormalizedContentStream("MultipartRequest.txt"); 244 | var responseFeature = new HttpResponseFeature(); 245 | var mockedEvents = new MockedBatchEventHandler(); 246 | using (responseFeature.Body = new MemoryStream()) 247 | { 248 | await AssertExecution(requestFeature, responseFeature, responseFeature.Body, mockedEvents, 249 | CreateFirstResponse(), 250 | CreateSecondResponse(), 251 | CreateThirdResponse(), 252 | CreateFourthResponse()).ConfigureAwait(false); 253 | Assert.Equal(StatusCodes.Status200OK, responseFeature.StatusCode); 254 | var refText = await TestUtilities.GetNormalizedContentStream("MultipartResponse.txt") 255 | .ReadAsStringAsync().ConfigureAwait(false); 256 | responseFeature.Body.Position = 0; 257 | var outputText = await responseFeature.Body.ReadAsStringAsync().ConfigureAwait(false); 258 | var boundary = Regex.Match(outputText, "--(.+?)--").Groups[1].Value; 259 | refText = refText.Replace("61cfbe41-7ea6-4771-b1c5-b43564208ee5", 260 | boundary); // replace with current boundary; 261 | Assert.Equal(refText, outputText); 262 | Assert.Equal(1, mockedEvents.BatchEndCount); 263 | Assert.Equal(1, mockedEvents.BatchStartCount); 264 | Assert.Equal(4, mockedEvents.BatchRequestPreparationCount); 265 | Assert.Equal(4, mockedEvents.BatchRequestExecutingCount); 266 | Assert.Equal(4, mockedEvents.BatchRequestExecutedCount); 267 | } 268 | } 269 | 270 | 271 | [Fact] 272 | public async Task NoBoundary() 273 | { 274 | var requestFeature = new HttpRequestFeature {Path = "/api/batch"}; 275 | requestFeature.Headers.Add(HeaderNames.ContentType, "multipart/mixed"); 276 | requestFeature.Body = Stream.Null; 277 | var responseFeature = new HttpResponseFeature(); 278 | var mockedEvents = new MockedBatchEventHandler(); 279 | using (responseFeature.Body = new MemoryStream()) 280 | { 281 | await AssertExecution(requestFeature, responseFeature, responseFeature.Body, mockedEvents, 282 | CreateFirstResponse(), 283 | CreateSecondResponse(), 284 | CreateThirdResponse(), 285 | CreateFourthResponse()).ConfigureAwait(false); 286 | Assert.Equal(StatusCodes.Status400BadRequest, responseFeature.StatusCode); 287 | Assert.Equal(0, mockedEvents.BatchStartCount); 288 | } 289 | } 290 | } 291 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/HttpBatchHandler.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | netcoreapp3.0 4 | false 5 | x64 6 | latest 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | all 21 | runtime; build; native; contentfiles; analyzers 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/MultipartContentTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Net.Http; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using HttpBatchHandler.Multipart; 6 | using Xunit; 7 | 8 | namespace HttpBatchHandler.Tests 9 | { 10 | public class MultipartContentTests 11 | { 12 | [Fact] 13 | public async Task CompareExample() 14 | { 15 | var mw = new MultipartContent("mixed", "batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e") 16 | { 17 | new HttpApplicationContent(new HttpRequestMessage(HttpMethod.Get, 18 | "http://localhost:12345/api/WebCustomers?Query=Parts")), 19 | new HttpApplicationContent( 20 | new HttpRequestMessage(HttpMethod.Post, "http://localhost:12345/api/WebCustomers") 21 | { 22 | Content = new StringContent( 23 | "{\"Id\":129,\"Name\":\"Name4752cbf0-e365-43c3-aa8d-1bbc8429dbf8\"}", Encoding.UTF8, 24 | "application/json") 25 | }), 26 | new HttpApplicationContent( 27 | new HttpRequestMessage(HttpMethod.Put, "http://localhost:12345/api/WebCustomers/1") 28 | { 29 | Content = new StringContent("{\"Id\":1,\"Name\":\"Peter\"}", Encoding.UTF8, "application/json") 30 | }), 31 | new HttpApplicationContent(new HttpRequestMessage(HttpMethod.Delete, 32 | "http://localhost:12345/api/WebCustomers/2")) 33 | }; 34 | string output; 35 | using (var memoryStream = new MemoryStream()) 36 | { 37 | await mw.CopyToAsync(memoryStream).ConfigureAwait(false); 38 | memoryStream.Position = 0; 39 | output = Encoding.ASCII.GetString(memoryStream.ToArray()); 40 | } 41 | 42 | string input; 43 | 44 | using (var refTextStream = TestUtilities.GetNormalizedContentStream("MultipartRequest.txt")) 45 | { 46 | Assert.NotNull(refTextStream); 47 | input = await refTextStream.ReadAsStringAsync().ConfigureAwait(false); 48 | } 49 | 50 | Assert.Equal(input, output); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/MultipartParserTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Threading.Tasks; 5 | using HttpBatchHandler.Multipart; 6 | using Microsoft.AspNetCore.WebUtilities; 7 | using Microsoft.Net.Http.Headers; 8 | using Newtonsoft.Json; 9 | using Xunit; 10 | 11 | namespace HttpBatchHandler.Tests 12 | { 13 | // https://blogs.msdn.microsoft.com/webdev/2013/11/01/introducing-batch-support-in-web-api-and-web-api-odata/ 14 | public class MultipartParserTests 15 | { 16 | [Theory] 17 | [InlineData(null, "MultipartRequest.txt", true)] 18 | [InlineData("/path/base", "MultipartRequestPathBase.txt", true)] 19 | [InlineData(null, "MultipartRequest.txt", false)] 20 | [InlineData("/path/base", "MultipartRequestPathBase.txt", false)] 21 | public async Task Parse(string path, string file, bool isHttps) 22 | { 23 | var reader = new MultipartReader("batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e", 24 | TestUtilities.GetNormalizedContentStream(file)); 25 | var sections = new List(); 26 | 27 | HttpApplicationRequestSection section; 28 | while ((section = await reader.ReadNextHttpApplicationRequestSectionAsync(path, isHttps).ConfigureAwait(false)) != 29 | null) 30 | { 31 | sections.Add(section); 32 | } 33 | 34 | string scheme = isHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; 35 | Assert.Equal(4, sections.Count); 36 | Assert.Collection(sections, 37 | x => InspectFirstRequest(x, path, scheme), 38 | x => InspectSecondRequest(x, path, scheme), 39 | x => InspectThirdRequest(x, path, scheme), 40 | x => InspectFourthRequest(x, path, scheme)); 41 | } 42 | 43 | private void InspectFirstRequest(HttpApplicationRequestSection obj, string pathBase, string scheme) 44 | { 45 | Assert.Equal("GET", obj.RequestFeature.Method); 46 | Assert.Equal("/api/WebCustomers", obj.RequestFeature.Path); 47 | Assert.Equal(pathBase, obj.RequestFeature.PathBase); 48 | Assert.Equal("HTTP/1.1", obj.RequestFeature.Protocol); 49 | Assert.Equal(scheme, obj.RequestFeature.Scheme); 50 | Assert.Equal("?Query=Parts", obj.RequestFeature.QueryString); 51 | Assert.Equal("localhost:12345", obj.RequestFeature.Headers[HeaderNames.Host]); 52 | } 53 | 54 | private void InspectFourthRequest(HttpApplicationRequestSection obj, string pathBase, string scheme) 55 | { 56 | Assert.Equal("DELETE", obj.RequestFeature.Method); 57 | Assert.Equal("/api/WebCustomers/2", obj.RequestFeature.Path); 58 | Assert.Equal(pathBase, obj.RequestFeature.PathBase); 59 | Assert.Equal("HTTP/1.1", obj.RequestFeature.Protocol); 60 | Assert.Equal(scheme, obj.RequestFeature.Scheme); 61 | Assert.Equal("localhost:12345", obj.RequestFeature.Headers[HeaderNames.Host]); 62 | } 63 | 64 | private void InspectSecondRequest(HttpApplicationRequestSection obj, string pathBase, string scheme) 65 | { 66 | Assert.Equal("POST", obj.RequestFeature.Method); 67 | Assert.Equal("/api/WebCustomers", obj.RequestFeature.Path); 68 | Assert.Equal(pathBase, obj.RequestFeature.PathBase); 69 | Assert.Equal("HTTP/1.1", obj.RequestFeature.Protocol); 70 | Assert.Equal(scheme, obj.RequestFeature.Scheme); 71 | Assert.Equal("localhost:12345", obj.RequestFeature.Headers[HeaderNames.Host]); 72 | var serializer = JsonSerializer.Create(); 73 | dynamic deserialized = 74 | serializer.Deserialize(new JsonTextReader(new StreamReader(obj.RequestFeature.Body))); 75 | Assert.Equal("129", deserialized.Id.ToString()); 76 | Assert.Equal("Name4752cbf0-e365-43c3-aa8d-1bbc8429dbf8", deserialized.Name.ToString()); 77 | } 78 | 79 | 80 | private void InspectThirdRequest(HttpApplicationRequestSection obj, string pathBase, string scheme) 81 | { 82 | Assert.Equal("PUT", obj.RequestFeature.Method); 83 | Assert.Equal("/api/WebCustomers/1", obj.RequestFeature.Path); 84 | Assert.Equal(pathBase, obj.RequestFeature.PathBase); 85 | Assert.Equal("HTTP/1.1", obj.RequestFeature.Protocol); 86 | Assert.Equal(scheme, obj.RequestFeature.Scheme); 87 | Assert.Equal("localhost:12345", obj.RequestFeature.Headers[HeaderNames.Host]); 88 | var serializer = JsonSerializer.Create(); 89 | dynamic deserialized = 90 | serializer.Deserialize(new JsonTextReader(new StreamReader(obj.RequestFeature.Body))); 91 | Assert.Equal("1", deserialized.Id.ToString()); 92 | Assert.Equal("Peter", deserialized.Name.ToString()); 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/MultipartRequest.txt: -------------------------------------------------------------------------------- 1 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e 2 | Content-Type: application/http; msgtype=request 3 | 4 | GET /api/WebCustomers?Query=Parts HTTP/1.1 5 | Host: localhost:12345 6 | 7 | 8 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e 9 | Content-Type: application/http; msgtype=request 10 | 11 | POST /api/WebCustomers HTTP/1.1 12 | Host: localhost:12345 13 | Content-Type: application/json; charset=utf-8 14 | 15 | {"Id":129,"Name":"Name4752cbf0-e365-43c3-aa8d-1bbc8429dbf8"} 16 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e 17 | Content-Type: application/http; msgtype=request 18 | 19 | PUT /api/WebCustomers/1 HTTP/1.1 20 | Host: localhost:12345 21 | Content-Type: application/json; charset=utf-8 22 | 23 | {"Id":1,"Name":"Peter"} 24 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e 25 | Content-Type: application/http; msgtype=request 26 | 27 | DELETE /api/WebCustomers/2 HTTP/1.1 28 | Host: localhost:12345 29 | 30 | 31 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e-- 32 | -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/MultipartRequestPathBase.txt: -------------------------------------------------------------------------------- 1 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e 2 | Content-Type: application/http; msgtype=request 3 | 4 | GET /path/base/api/WebCustomers?Query=Parts HTTP/1.1 5 | Host: localhost:12345 6 | 7 | 8 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e 9 | Content-Type: application/http; msgtype=request 10 | 11 | POST /path/base/api/WebCustomers HTTP/1.1 12 | Host: localhost:12345 13 | Content-Type: application/json; charset=utf-8 14 | 15 | {"Id":129,"Name":"Name4752cbf0-e365-43c3-aa8d-1bbc8429dbf8"} 16 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e 17 | Content-Type: application/http; msgtype=request 18 | 19 | PUT /path/base/api/WebCustomers/1 HTTP/1.1 20 | Host: localhost:12345 21 | Content-Type: application/json; charset=utf-8 22 | 23 | {"Id":1,"Name":"Peter"} 24 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e 25 | Content-Type: application/http; msgtype=request 26 | 27 | DELETE /path/base/api/WebCustomers/2 HTTP/1.1 28 | Host: localhost:12345 29 | 30 | 31 | --batch_357647d1-a6b5-4e6a-aa73-edfc88d8866e-- 32 | -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/MultipartResponse.txt: -------------------------------------------------------------------------------- 1 | --61cfbe41-7ea6-4771-b1c5-b43564208ee5 2 | Content-Type: application/http; msgtype=response 3 | 4 | HTTP/1.1 200 OK 5 | Content-Type: application/json; charset=utf-8 6 | 7 | [{"Id":1,"Name":"Namefc4b8794-943b-487a-9049-a8559232b9dd"},{"Id":2,"Name":"Name244bbada-3e83-43c8-82f7-5b2c4d72f2ed"},{"Id":3,"Name":"Nameec11d080-7f2d-47df-a483-7ff251cdda7a"},{"Id":4,"Name":"Name14ff5a3d-ad92-41f6-b4f6-9b94622f4968"},{"Id":5,"Name":"Name00f9e4cc-673e-4139-ba30-bfc273844678"},{"Id":6,"Name":"Name01f6660c-d1de-4c05-8567-8ae2759c4117"},{"Id":7,"Name":"Name60030a17-6316-427c-a744-b2fff6d9fe11"},{"Id":8,"Name":"Namefa61eb4c-9f9e-47a2-8dc5-15d8afe33f2d"},{"Id":9,"Name":"Name9b680c10-1727-43f5-83cf-c8eda3a63790"},{"Id":10,"Name":"Name9e66d797-d3a9-44ec-814d-aecde8040ced"}] 8 | --61cfbe41-7ea6-4771-b1c5-b43564208ee5 9 | Content-Type: application/http; msgtype=response 10 | 11 | HTTP/1.1 201 Created 12 | Location: http://localhost:13245/api/ApiCustomers 13 | Content-Type: application/json; charset=utf-8 14 | 15 | {"Id":21,"Name":"Name4752cbf0-e365-43c3-aa8d-1bbc8429dbf8"} 16 | --61cfbe41-7ea6-4771-b1c5-b43564208ee5 17 | Content-Type: application/http; msgtype=response 18 | 19 | HTTP/1.1 200 OK 20 | Content-Type: application/json; charset=utf-8 21 | 22 | {"Id":1,"Name":"Peter"} 23 | --61cfbe41-7ea6-4771-b1c5-b43564208ee5 24 | Content-Type: application/http; msgtype=response 25 | 26 | HTTP/1.1 204 No Content 27 | 28 | 29 | --61cfbe41-7ea6-4771-b1c5-b43564208ee5-- 30 | -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/MultipartWriterTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using HttpBatchHandler.Multipart; 6 | using Microsoft.AspNetCore.Http.Features; 7 | using Microsoft.AspNetCore.WebUtilities; 8 | using Xunit; 9 | 10 | namespace HttpBatchHandler.Tests 11 | { 12 | // https://blogs.msdn.microsoft.com/webdev/2013/11/01/introducing-batch-support-in-web-api-and-web-api-odata/ 13 | public class MultipartWriterTests : BaseWriterTests 14 | { 15 | private void ElementInspector(IHttpResponseFeature input, ResponseFeature comparison) 16 | { 17 | Assert.Equal(comparison.StatusCode, input.StatusCode); 18 | Assert.Equal(comparison.ReasonPhrase, input.ReasonPhrase); 19 | Assert.Equal(comparison.Headers, input.Headers); 20 | } 21 | 22 | [Fact] 23 | public async Task CompareExample() 24 | { 25 | var mw = new MultipartWriter("mixed", "61cfbe41-7ea6-4771-b1c5-b43564208ee5"); 26 | mw.Add(new HttpApplicationMultipart(CreateFirstResponse())); 27 | mw.Add(new HttpApplicationMultipart(CreateSecondResponse())); 28 | mw.Add(new HttpApplicationMultipart(CreateThirdResponse())); 29 | mw.Add(new HttpApplicationMultipart(CreateFourthResponse())); 30 | string output; 31 | using (var memoryStream = new MemoryStream()) 32 | { 33 | await mw.CopyToAsync(memoryStream).ConfigureAwait(false); 34 | memoryStream.Position = 0; 35 | output = Encoding.ASCII.GetString(memoryStream.ToArray()); 36 | } 37 | 38 | string input; 39 | 40 | using (var refTextStream = TestUtilities.GetNormalizedContentStream("MultipartResponse.txt")) 41 | { 42 | Assert.NotNull(refTextStream); 43 | input = await refTextStream.ReadAsStringAsync().ConfigureAwait(false); 44 | } 45 | 46 | Assert.Equal(input, output); 47 | } 48 | 49 | 50 | [Fact] 51 | public async Task ParseExample() 52 | { 53 | var reader = new MultipartReader("61cfbe41-7ea6-4771-b1c5-b43564208ee5", 54 | TestUtilities.GetNormalizedContentStream("MultipartResponse.txt")); 55 | var sections = new List(); 56 | 57 | HttpApplicationResponseSection section; 58 | while ((section = await reader.ReadNextHttpApplicationResponseSectionAsync().ConfigureAwait(false)) != null) 59 | { 60 | sections.Add(section); 61 | } 62 | 63 | Assert.Equal(4, sections.Count); 64 | Assert.Collection(sections, x => ElementInspector(x.ResponseFeature, CreateFirstResponse()), 65 | x => ElementInspector(x.ResponseFeature, CreateSecondResponse()), 66 | x => ElementInspector(x.ResponseFeature, CreateThirdResponse()), 67 | x => ElementInspector(x.ResponseFeature, CreateFourthResponse())); 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/RandomPortHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.NetworkInformation; 5 | 6 | namespace HttpBatchHandler.Tests 7 | { 8 | public static class RandomPortHelper 9 | { 10 | private static readonly object Lock = new object(); 11 | private static readonly Random Random = new Random(); 12 | 13 | public static int FindFreePort() 14 | { 15 | lock (Lock) 16 | { 17 | var ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties(); 18 | var tcpConnInfoArray = ipGlobalProperties.GetActiveTcpConnections(); 19 | var usedPorts = new HashSet(tcpConnInfoArray.Select(t => t.LocalEndPoint.Port)); 20 | int freePort; 21 | var counter = 0; 22 | while (usedPorts.Contains(freePort = GetRandomNumber(1025, 65535))) 23 | { 24 | counter++; 25 | if (counter > 1000) 26 | { 27 | throw new InvalidOperationException("Can't find port."); 28 | } 29 | } 30 | 31 | return freePort; 32 | } 33 | } 34 | 35 | private static int GetRandomNumber(int minValue, int maxValue) 36 | { 37 | lock (Lock) 38 | { 39 | return Random.Next(minValue, maxValue); 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/ServerTestsWithPathBase.cs: -------------------------------------------------------------------------------- 1 | using Xunit.Abstractions; 2 | 3 | namespace HttpBatchHandler.Tests 4 | { 5 | public class ServerTestsWithPathBase : BaseServerTests 6 | { 7 | public ServerTestsWithPathBase(TestFixtureWithPathBase fixture, ITestOutputHelper outputHelper) : base(fixture, 8 | outputHelper) 9 | { 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/ServerTestsWithoutPathBase.cs: -------------------------------------------------------------------------------- 1 | using Xunit.Abstractions; 2 | 3 | namespace HttpBatchHandler.Tests 4 | { 5 | public class ServerTestsWithoutPathBase : BaseServerTests 6 | { 7 | public ServerTestsWithoutPathBase(TestFixtureWithoutPathBase fixture, ITestOutputHelper outputHelper) : base( 8 | fixture, outputHelper) 9 | { 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/TestFixture.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using HttpBatchHandler.Website; 4 | using Microsoft.AspNetCore; 5 | using Microsoft.AspNetCore.Hosting; 6 | 7 | namespace HttpBatchHandler.Tests 8 | { 9 | public abstract class TestFixture : IDisposable 10 | { 11 | private readonly IWebHost _disposable; 12 | 13 | public TestFixture(string pathBase) 14 | { 15 | var port = RandomPortHelper.FindFreePort(); 16 | BaseUri = new Uri($"http://localhost:{port}" + pathBase); 17 | var url = new UriBuilder(BaseUri) 18 | { 19 | Path = string.Empty 20 | }; 21 | 22 | _disposable = WebHost.CreateDefaultBuilder() 23 | .UseStartup() 24 | .UseUrls(url.Uri.ToString()) 25 | .UseSetting("pathBase", pathBase) 26 | .Build(); 27 | _disposable.Start(); 28 | } 29 | 30 | public Uri BaseUri { get; } 31 | 32 | public HttpClient HttpClient { get; } = new HttpClient(); 33 | 34 | public void Dispose() 35 | { 36 | Dispose(true); 37 | GC.SuppressFinalize(this); 38 | } 39 | 40 | public virtual void Dispose(bool dispose) 41 | { 42 | if (dispose) 43 | { 44 | _disposable?.Dispose(); 45 | HttpClient?.Dispose(); 46 | } 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/TestFixtureWithPathBase.cs: -------------------------------------------------------------------------------- 1 | namespace HttpBatchHandler.Tests 2 | { 3 | public class TestFixtureWithPathBase : TestFixture 4 | { 5 | public TestFixtureWithPathBase() : base("/path/base/") 6 | { 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/TestFixtureWithoutPathBase.cs: -------------------------------------------------------------------------------- 1 | namespace HttpBatchHandler.Tests 2 | { 3 | public class TestFixtureWithoutPathBase : TestFixture 4 | { 5 | public TestFixtureWithoutPathBase() : base(null) 6 | { 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/TestUtilities.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace HttpBatchHandler.Tests 4 | { 5 | public static class TestUtilities 6 | { 7 | public static Stream GetNormalizedContentStream(string file) 8 | { 9 | var originalStream = 10 | typeof(TestUtilities).Assembly.GetManifestResourceStream(typeof(MultipartParserTests), file); 11 | 12 | // It's possible that git (or other tools) will automatically convert crlf line endings from the 13 | // text files to lf on linux/mac machines. The Multipart spec is looking for crlf and thus line endings 14 | // of lf are invalid. Therefore, for these tests to be valid we need to make sure all line endings are 15 | // crlf and not just lf. 16 | using (var reader = new StreamReader(originalStream)) 17 | { 18 | var content = reader.ReadToEnd(); 19 | content = content.Replace("\r\n", "\n").Replace("\n", "\r\n"); 20 | var normalizedStream = new MemoryStream(); 21 | var writer = new StreamWriter(normalizedStream); 22 | writer.Write(content); 23 | writer.Flush(); 24 | normalizedStream.Position = 0; 25 | return normalizedStream; 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Tests/WriteOnlyResponseStreamTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Buffers; 3 | using System.IO; 4 | using System.Security.Cryptography; 5 | using System.Threading.Tasks; 6 | using Xunit; 7 | 8 | namespace HttpBatchHandler.Tests 9 | { 10 | public class WriteOnlyResponseStreamTests 11 | { 12 | private async Task GetBuffer(WriteOnlyResponseStream writeOnlyStream) 13 | { 14 | using (var ms = new MemoryStream()) 15 | { 16 | await writeOnlyStream.CopyToAsync(ms).ConfigureAwait(false); 17 | return ms.ToArray(); 18 | } 19 | } 20 | 21 | private void CompareStreams(MemoryStream ms1, MemoryStream ms2) 22 | { 23 | var md5 = MD5.Create(); 24 | var h1 = md5.ComputeHash(ms1); 25 | var h2 = md5.ComputeHash(ms2); 26 | Assert.Equal(h1, h2); 27 | } 28 | 29 | [Fact] 30 | public async Task BeginEndWrite() 31 | { 32 | var isAborted = false; 33 | Action abortRequest = () => isAborted = true; 34 | using (var writeOnlyStream = new WriteOnlyResponseStream(abortRequest)) 35 | { 36 | var buffer = new byte[] {1, 2, 3, 4, 5}; 37 | var iar = writeOnlyStream.BeginWrite(buffer, 0, buffer.Length, null, null); 38 | var task = Task.Factory.FromAsync(iar, writeOnlyStream.EndWrite); 39 | await task.ConfigureAwait(false); 40 | var writtenBuffer = await GetBuffer(writeOnlyStream).ConfigureAwait(false); 41 | Assert.Equal(buffer, writtenBuffer); 42 | } 43 | 44 | Assert.True(isAborted); 45 | } 46 | 47 | [Fact] 48 | public void IsAborted() 49 | { 50 | var isAborted = false; 51 | Action abortRequest = () => isAborted = true; 52 | using (var writeOnlyStream = new WriteOnlyResponseStream(abortRequest)) 53 | { 54 | writeOnlyStream.WriteByte(0); 55 | } 56 | 57 | Assert.True(isAborted); 58 | } 59 | 60 | [Fact] 61 | public async Task WriteAsync() 62 | { 63 | var isAborted = false; 64 | Action abortRequest = () => isAborted = true; 65 | using (var writeOnlyStream = new WriteOnlyResponseStream(abortRequest)) 66 | { 67 | var buffer = new byte[] {1, 2, 3, 4, 5}; 68 | await writeOnlyStream.WriteAsync(buffer, 0, buffer.Length).ConfigureAwait(false); 69 | var writtenBuffer = await GetBuffer(writeOnlyStream).ConfigureAwait(false); 70 | Assert.Equal(buffer, writtenBuffer); 71 | } 72 | 73 | Assert.True(isAborted); 74 | } 75 | 76 | [Fact] 77 | public async Task WriteRandom() 78 | { 79 | var isAborted = false; 80 | Action abortRequest = () => isAborted = true; 81 | using (var ms1 = new MemoryStream()) 82 | { 83 | using (var ms2 = new MemoryStream()) 84 | { 85 | using (var writeOnlyStream = new WriteOnlyResponseStream(abortRequest)) 86 | { 87 | var random = new Random(); 88 | for (var i = 0; i < 10; i++) 89 | { 90 | var buffer = ArrayPool.Shared.Rent(random.Next(50000)); 91 | random.NextBytes(buffer); 92 | await writeOnlyStream.WriteAsync(buffer, 0, buffer.Length).ConfigureAwait(false); 93 | await ms1.WriteAsync(buffer, 0, buffer.Length).ConfigureAwait(false); 94 | ArrayPool.Shared.Return(buffer); 95 | } 96 | 97 | await writeOnlyStream.CopyToAsync(ms2).ConfigureAwait(false); 98 | ms1.Position = 0; 99 | ms2.Position = 0; 100 | CompareStreams(ms1, ms2); 101 | } 102 | } 103 | } 104 | 105 | Assert.True(isAborted); 106 | } 107 | 108 | [Fact] 109 | public async Task WriteSync() 110 | { 111 | var isAborted = false; 112 | Action abortRequest = () => isAborted = true; 113 | using (var writeOnlyStream = new WriteOnlyResponseStream(abortRequest)) 114 | { 115 | var buffer = new byte[] {1, 2, 3, 4, 5}; 116 | writeOnlyStream.Write(buffer, 0, buffer.Length); 117 | var writtenBuffer = await GetBuffer(writeOnlyStream).ConfigureAwait(false); 118 | Assert.Equal(buffer, writtenBuffer); 119 | } 120 | 121 | Assert.True(isAborted); 122 | } 123 | } 124 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Website/Controllers/ValuesController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Security.Cryptography; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.Mvc; 8 | 9 | namespace HttpBatchHandler.Website.Controllers 10 | { 11 | [Route("api/[controller]")] 12 | public class ValuesController : Controller 13 | { 14 | // DELETE api/values/5 15 | [HttpDelete("{id}")] 16 | public void Delete(int id) 17 | { 18 | } 19 | 20 | // GET api/values 21 | [HttpGet] 22 | public IEnumerable Get() => new[] {"value1", "value2"}; 23 | 24 | // GET api/values/5 25 | [HttpGet("{id}", Name = "GetById")] 26 | public string Get(int id) => id.ToString(); 27 | 28 | // GET api/values/query?id=5 29 | [HttpGet("query")] 30 | public string GetFromQuery([FromQuery] int id) => id.ToString(); 31 | 32 | // POST api/values 33 | [HttpPost] 34 | public IActionResult Post([FromBody] string value) 35 | { 36 | var uri = Url.Link("GetById", new {id = 5}); 37 | return Created(uri, null); 38 | } 39 | 40 | // PUT api/values/5 41 | [HttpPut("{id}")] 42 | public void Put(int id, [FromBody] string value) 43 | { 44 | } 45 | 46 | // GET api/values/File/1 47 | [HttpPost("File/{id}")] 48 | public IActionResult UploadFile(int id) 49 | { 50 | if (HttpContext.Request.HasFormContentType) 51 | { 52 | var file = HttpContext.Request.Form.Files.Single(); 53 | string b64Name; 54 | using (var md5 = MD5.Create()) 55 | { 56 | var hash = md5.ComputeHash(file.OpenReadStream()); 57 | b64Name = Convert.ToBase64String(hash); 58 | } 59 | 60 | if (b64Name == file.Name) 61 | { 62 | return Ok(); 63 | } 64 | } 65 | 66 | return BadRequest(); 67 | } 68 | 69 | // GET api/values/File 70 | [HttpGet("File")] 71 | public async Task GetFile() 72 | { 73 | var random = new Random(); 74 | var buffer = new byte[1 << 16]; 75 | string b64Name; 76 | var ms = new MemoryStream(); 77 | for (var i = 0; i < 10; i++) 78 | { 79 | random.NextBytes(buffer); 80 | await ms.WriteAsync(buffer, 0, buffer.Length).ConfigureAwait(false); 81 | } 82 | 83 | ms.Position = 0; 84 | using (var md5 = MD5.Create()) 85 | { 86 | md5.ComputeHash(ms); 87 | b64Name = Convert.ToBase64String(md5.Hash); 88 | } 89 | 90 | ms.Position = 0; 91 | return File(ms, "application/octet-stream", b64Name); 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Website/HttpBatchHandler.Website.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.0 5 | x64 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/HttpBatchHandler.Website/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore; 2 | using Microsoft.AspNetCore.Hosting; 3 | 4 | namespace HttpBatchHandler.Website 5 | { 6 | public class Program 7 | { 8 | public static IWebHost BuildWebHost(string[] args) => WebHost.CreateDefaultBuilder(args) 9 | .UseStartup() 10 | .UseUrls("http://*:5123") 11 | .Build(); 12 | 13 | public static void Main(string[] args) 14 | { 15 | BuildWebHost(args).Run(); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Website/Startup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Hosting; 7 | 8 | namespace HttpBatchHandler.Website 9 | { 10 | public class Startup 11 | { 12 | private readonly string _pathBase; 13 | 14 | public Startup(IConfiguration configuration) 15 | { 16 | Configuration = configuration; 17 | _pathBase = configuration.GetValue("pathBase"); 18 | } 19 | 20 | public IConfiguration Configuration { get; } 21 | 22 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 23 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 24 | { 25 | if (_pathBase != "/") 26 | { 27 | app.UsePathBase(_pathBase); 28 | } 29 | 30 | if (env.IsDevelopment()) 31 | { 32 | app.UseDeveloperExceptionPage(); 33 | } 34 | 35 | app.UseBatchMiddleware(); 36 | app.UseRouting(); 37 | app.UseEndpoints(a => a.MapControllers()); 38 | } 39 | 40 | // This method gets called by the runtime. Use this method to add services to the container. 41 | public void ConfigureServices(IServiceCollection services) 42 | { 43 | services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0); 44 | services.AddControllers(); 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Website/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "LogLevel": { 5 | "Default": "Debug", 6 | "System": "Information", 7 | "Microsoft": "Information" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /test/HttpBatchHandler.Website/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "Debug": { 5 | "LogLevel": { 6 | "Default": "Warning" 7 | } 8 | }, 9 | "Console": { 10 | "LogLevel": { 11 | "Default": "Warning" 12 | } 13 | } 14 | } 15 | } --------------------------------------------------------------------------------