diff --git a/.spelling b/.spelling index 77cc97a19fd..64e31b6317d 100644 --- a/.spelling +++ b/.spelling @@ -1104,6 +1104,8 @@ v6.0. #region test/tools/WebListener/README.md Overrides - test/tools/WebListener/README.md Auth +NoResume NTLM +NumberBytes ResponseHeaders #endregion diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs index f5c3cd3d322..ba03ffe2468 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs @@ -354,6 +354,12 @@ public virtual string CustomMethod [Parameter] public virtual SwitchParameter PassThru { get; set; } + /// + /// Resumes downloading a partial or incomplete file. OutFile is required. + /// + [Parameter] + public virtual SwitchParameter Resume { get; set; } + #endregion #endregion Virtual Properties @@ -512,7 +518,15 @@ internal virtual void ValidateParameters() if (PassThru && (OutFile == null)) { ErrorRecord error = GetValidationError(WebCmdletStrings.OutFileMissing, - "WebCmdletOutFileMissingException"); + "WebCmdletOutFileMissingException", nameof(PassThru)); + ThrowTerminatingError(error); + } + + // Resume requires OutFile. + if (Resume.IsPresent && OutFile == null) + { + ErrorRecord error = GetValidationError(WebCmdletStrings.OutFileMissing, + "WebCmdletOutFileMissingException", nameof(Resume)); ThrowTerminatingError(error); } } @@ -637,6 +651,14 @@ internal bool ShouldWriteToPipeline get { return (!ShouldSaveToOutFile || PassThru); } } + /// + /// Determines whether writing to a file should Resume and append rather than overwrite. + /// + internal bool ShouldResume + { + get { return (Resume.IsPresent && _resumeSuccess); } + } + #endregion Helper Properties #region Helper Methods @@ -860,6 +882,16 @@ public abstract partial class WebRequestPSCmdlet : PSCmdlet /// internal int _maximumFollowRelLink = Int32.MaxValue; + /// + /// The remote endpoint returned a 206 status code indicating successful resume. + /// + private bool _resumeSuccess = false; + + /// + /// The current size of the local file being resumed. + /// + private long _resumeFileSize = 0; + private HttpMethod GetHttpMethod(WebRequestMethod method) { switch (Method) @@ -1062,6 +1094,22 @@ internal virtual HttpRequestMessage GetRequest(Uri uri, bool stripAuthorization) } } + // If the file to resume downloading exists, create the Range request header using the file size. + // If not, create a Range to request the entire file. + if (Resume.IsPresent) + { + var fileInfo = new FileInfo(QualifiedOutFile); + if (fileInfo.Exists) + { + request.Headers.Range = new RangeHeaderValue(fileInfo.Length, null); + _resumeFileSize = fileInfo.Length; + } + else + { + request.Headers.Range = new RangeHeaderValue(0, null); + } + } + // Some web sites (e.g. Twitter) will return exception on POST when Expect100 is sent // Default behavior is continue to send body content anyway after a short period // Here it send the two part as a whole. @@ -1233,6 +1281,9 @@ internal virtual HttpResponseMessage GetResponse(HttpClient client, HttpRequestM if (client == null) { throw new ArgumentNullException("client"); } if (request == null) { throw new ArgumentNullException("request"); } + // Track the current URI being used by various requests and re-requests. + var currentUri = request.RequestUri; + _cancelToken = new CancellationTokenSource(); HttpResponseMessage response = client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancelToken.Token).GetAwaiter().GetResult(); @@ -1256,14 +1307,51 @@ internal virtual HttpResponseMessage GetResponse(HttpClient client, HttpRequestM } // recreate the HttpClient with redirection enabled since the first call suppressed redirection + currentUri = new Uri(request.RequestUri, response.Headers.Location); using (client = GetHttpClient(false)) - using (HttpRequestMessage redirectRequest = GetRequest(new Uri(request.RequestUri, response.Headers.Location), stripAuthorization:true)) + using (HttpRequestMessage redirectRequest = GetRequest(currentUri, stripAuthorization:true)) { FillRequestStream(redirectRequest); _cancelToken = new CancellationTokenSource(); response = client.SendAsync(redirectRequest, HttpCompletionOption.ResponseHeadersRead, _cancelToken.Token).GetAwaiter().GetResult(); } } + + // Request again without the Range header because the server indicated the range was not satisfiable. + // This happens when the local file is larger than the remote file. + // If the size of the remote file is the same as the local file, there is nothing to resume. + if (Resume.IsPresent && + response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && + (response.Content.Headers.ContentRange.HasLength && + response.Content.Headers.ContentRange.Length != _resumeFileSize)) + { + _cancelToken.Cancel(); + + WriteVerbose(WebCmdletStrings.WebMethodResumeFailedVerboseMsg); + + // Disable the Resume switch so the subsequent calls to GetResponse() and FillRequestStream() + // are treated as a standard -OutFile request. This also disables appending local file. + Resume = new SwitchParameter(false); + + using (HttpRequestMessage requestWithoutRange = GetRequest(currentUri, stripAuthorization:false)) + { + FillRequestStream(requestWithoutRange); + long requestContentLength = 0; + if (requestWithoutRange.Content != null) + requestContentLength = requestWithoutRange.Content.Headers.ContentLength.Value; + + string reqVerboseMsg = String.Format(CultureInfo.CurrentCulture, + WebCmdletStrings.WebMethodInvocationVerboseMsg, + requestWithoutRange.Method, + requestWithoutRange.RequestUri, + requestContentLength); + WriteVerbose(reqVerboseMsg); + + return GetResponse(client, requestWithoutRange, stripAuthorization); + } + } + + _resumeSuccess = response.StatusCode == HttpStatusCode.PartialContent; return response; } @@ -1336,7 +1424,22 @@ protected override void ProcessRecord() contentType); WriteVerbose(respVerboseMsg); - if (!response.IsSuccessStatusCode) + bool _isSuccess = response.IsSuccessStatusCode; + + // Check if the Resume range was not satisfiable because the file already completed downloading. + // This happens when the local file is the same size as the remote file. + if (Resume.IsPresent && + response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && + response.Content.Headers.ContentRange.HasLength && + response.Content.Headers.ContentRange.Length == _resumeFileSize) + { + _isSuccess = true; + WriteVerbose(String.Format(CultureInfo.CurrentCulture, WebCmdletStrings.OutFileWritingSkipped, OutFile)); + // Disable writing to the OutFile. + OutFile = null; + } + + if (!_isSuccess) { string message = String.Format(CultureInfo.CurrentCulture, WebCmdletStrings.ResponseStatusCodeFailure, (int)response.StatusCode, response.ReasonPhrase); diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/StreamHelper.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/StreamHelper.cs index cd02e225332..5c60c4058a1 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/StreamHelper.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/StreamHelper.cs @@ -342,9 +342,20 @@ internal static void WriteToStream(byte[] input, Stream output) /// internal static void SaveStreamToFile(Stream stream, string filePath, PSCmdlet cmdlet) { - using (FileStream output = File.Create(filePath)) + // If the web cmdlet should resume, append the file instead of overwriting. + if(cmdlet is WebRequestPSCmdlet webCmdlet && webCmdlet.ShouldResume) { - WriteToStream(stream, output, cmdlet); + using (FileStream output = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.Read)) + { + WriteToStream(stream, output, cmdlet); + } + } + else + { + using (FileStream output = File.Create(filePath)) + { + WriteToStream(stream, output, cmdlet); + } } } diff --git a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx index 82958bbe76a..f22655aaf56 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx +++ b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx @@ -187,7 +187,10 @@ Path '{0}' is not a file system path. Please specify the path to a file in the file system. - The cmdlet cannot run because the following parameter is missing: OutFile. Provide a valid OutFile parameter value when using the PassThru parameter, then retry. + The cmdlet cannot run because the following parameter is missing: OutFile. Provide a valid OutFile parameter value when using the {0} parameter, then retry. + + + The file will not be re-downloaded because the remote file is the same size as the OutFile: {0} The cmdlet cannot run because the following conflicting parameters are specified: ProxyCredential and ProxyUseDefaultCredentials. Specify either ProxyCredential or ProxyUseDefaultCredentials, then retry. @@ -249,6 +252,9 @@ {0} {1} with {2}-byte payload + + The remote server indicated it could not resume downloading. The local file will be overwritten. + received {0}-byte response of content type {1} diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index 350cc1f07cd..d60a2187f82 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -1618,6 +1618,116 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { } } + Context "Invoke-WebRequest File Resume Feature" { + BeforeAll { + $outFile = Join-Path $TestDrive "resume.txt" + + # Download the entire file to reference in tests + $referenceFile = Join-Path $TestDrive "reference.txt" + $resumeUri = Get-WebListenerUrl -Test 'Resume' + Invoke-WebRequest -uri $resumeUri -OutFile $referenceFile -ErrorAction Stop + $referenceFileHash = Get-FileHash -Algorithm SHA256 -Path $referenceFile + $referenceFileSize = Get-Item $referenceFile | Select-Object -ExpandProperty Length + } + + AfterEach { + Remove-Item -Force -ErrorAction 'SilentlyContinue' -Path $outFile + } + + It "Invoke-WebRequest -Resume requires -OutFile" { + { Invoke-WebRequest -Resume -Uri $resumeUri -ErrorAction Stop } | + Should -Throw -ErrorId 'WebCmdletOutFileMissingException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand' + } + + It "Invoke-WebRequest -Resume Downloads the whole file when the file does not exist" { + $response = Invoke-WebRequest -uri $resumeUri -OutFile $outFile -Resume -PassThru + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + $response.Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $response.Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly 'bytes=0-' + $response.StatusCode | Should -Be 206 + $response.Headers.'Content-Range'[0] | Should -BeExactly "bytes 0-$($referenceFileSize-1)/$referenceFileSize" + } + + It "Invoke-WebRequest -Resume overwrites an existing file that is larger than the remote file" { + # Create a file larger than the download file + $largerFileSize = $referenceFileSize + 20 + 1..$largerFileSize | ForEach-Object { [Byte]$_ } | Set-Content -AsByteStream $outFile + $largerFileSize = Get-Item $outFile | Select-Object -ExpandProperty Length + + $response = Invoke-WebRequest -uri $resumeUri -OutFile $outFile -Resume -PassThru + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -BeLessThan $largerFileSize + $response.Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'false' + $response.StatusCode | Should -Be 200 + $response.Headers.ContainsKey('Content-Range') | Should -BeFalse + } + + It "Invoke-WebRequest -Resume overwrites existing file when remote server does not support resume" { + # Create a file larger than the download file + $largerFileSize = $referenceFileSize + 20 + 1..$largerFileSize | ForEach-Object { [Byte]$_ } | Set-Content -AsByteStream $outFile + $largerFileSize = Get-Item $outFile | Select-Object -ExpandProperty Length + + $uri = Get-WebListenerUrl -Test 'Resume' -TestValue 'NoResume' + $response = Invoke-WebRequest -Uri $uri -OutFile $outFile -Resume -PassThru + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + $response.Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $response.Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly "bytes=$largerFileSize-" + $response.StatusCode | Should -Be 200 + $response.Headers.ContainsKey('Content-Range') | Should -BeFalse + } + + It "Invoke-WebRequest -Resume resumes downloading from bytes" -TestCases @( + @{bytes = 4} + @{bytes = 8} + @{bytes = 12} + @{bytes = 16} + ) { + param($bytes, $statuscode) + # Simulate partial download + $uri = Get-WebListenerUrl -Test 'Resume' -TestValue "Bytes/$bytes" + $null = Invoke-WebRequest -uri $uri -OutFile $outFile + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $bytes + + $response = Invoke-WebRequest -uri $resumeUri -OutFile $outFile -Resume -PassThru + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + $response.Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $response.Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly "bytes=$bytes-" + $response.StatusCode | Should -Be 206 + $response.Headers.'Content-Range'[0] | Should -BeExactly "bytes $bytes-$($referenceFileSize-1)/$referenceFileSize" + } + + It "Invoke-WebRequest -Resume assumes the file was successfully completed when the local and remote file are the same size." { + # Download the entire file + $uri = Get-WebListenerUrl -Test 'Resume' -TestValue 'NoResume' + $null = Invoke-WebRequest -uri $uri -OutFile $outFile + $fileSize = Get-Item $outFile | Select-Object -ExpandProperty Length + + $response = Invoke-WebRequest -uri $resumeUri -OutFile $outFile -Resume -PassThru + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + $response.Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $response.Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly "bytes=$fileSize-" + # The web cmdlets special case 416 as a success code when the local file and remote file are the same size + $response.StatusCode | Should -Be 416 + $response.Headers.'Content-Range'[0] | Should -BeExactly "bytes */$referenceFileSize" + } + } + BeforeEach { if ($env:http_proxy) { $savedHttpProxy = $env:http_proxy @@ -2779,6 +2889,114 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" { } } + Context "Invoke-RestMethod File Resume Feature" { + BeforeAll { + $outFile = Join-Path $TestDrive "resume.txt" + + # Download the entire file to reference in tests + $referenceFile = Join-Path $TestDrive "reference.txt" + $resumeUri = Get-WebListenerUrl -Test 'Resume' + Invoke-RestMethod -uri $resumeUri -OutFile $referenceFile -ErrorAction Stop + $referenceFileHash = Get-FileHash -Algorithm SHA256 -Path $referenceFile + $referenceFileSize = Get-Item $referenceFile | Select-Object -ExpandProperty Length + } + + AfterEach { + Remove-Item -Force -ErrorAction 'SilentlyContinue' -Path $outFile + } + + It "Invoke-RestMethod -Resume requires -OutFile" { + { Invoke-RestMethod -Resume -Uri $resumeUri -ErrorAction Stop } | + Should -Throw -ErrorId 'WebCmdletOutFileMissingException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand' + } + + It "Invoke-RestMethod -Resume Downloads the whole file when the file does not exist" { + # ensure the file does not exist + Remove-Item -Force -ErrorAction 'SilentlyContinue' -Path $outFile + + Invoke-RestMethod -uri $resumeUri -OutFile $outFile -ResponseHeadersVariable 'Headers' -Resume + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + $Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly 'bytes=0-' + $Headers.'Content-Range'[0] | Should -BeExactly "bytes 0-$($referenceFileSize-1)/$referenceFileSize" + } + + It "Invoke-RestMethod -Resume overwrites an existing file that is larger than the remote file" { + # Create a file larger than the download file + $largerFileSize = $referenceFileSize + 20 + 1..$largerFileSize | ForEach-Object { [Byte]$_ } | Set-Content -AsByteStream $outFile + $largerFileSize = Get-Item $outFile | Select-Object -ExpandProperty Length + + $response = Invoke-RestMethod -uri $resumeUri -OutFile $outFile -ResponseHeadersVariable 'Headers' -Resume + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -BeLessThan $largerFileSize + $Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'false' + $Headers.ContainsKey('Content-Range') | Should -BeFalse + } + + It "Invoke-RestMethod -Resume overwrites existing file when remote server does not support resume" { + # Create a file larger than the download file + $largerFileSize = $referenceFileSize + 20 + 1..$largerFileSize | ForEach-Object { [Byte]$_ } | Set-Content -AsByteStream $outFile + $largerFileSize = Get-Item $outFile | Select-Object -ExpandProperty Length + + $uri = Get-WebListenerUrl -Test 'Resume' -TestValue 'NoResume' + $response = Invoke-RestMethod -uri $uri -OutFile $outFile -ResponseHeadersVariable 'Headers' -Resume + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -BeLessThan $largerFileSize + $Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly "bytes=$largerFileSize-" + $Headers.ContainsKey('Content-Range') | Should -BeFalse + } + + It "Invoke-RestMethod -Resume resumes downloading from bytes" -TestCases @( + @{bytes = 4} + @{bytes = 8} + @{bytes = 12} + @{bytes = 16} + ) { + param($bytes) + # Simulate partial download + $uri = Get-WebListenerUrl -Test 'Resume' -TestValue "Bytes/$bytes" + $null = Invoke-RestMethod -uri $uri -OutFile $outFile + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $bytes + + $response = Invoke-RestMethod -uri $resumeUri -OutFile $outFile -ResponseHeadersVariable 'Headers' -Resume + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + $Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly "bytes=$bytes-" + $Headers.'Content-Range'[0] | Should -BeExactly "bytes $bytes-$($referenceFileSize-1)/$referenceFileSize" + } + + It "Invoke-RestMethod -Resume assumes the file was successfully completed when the local and remote file are the same size." { + # Download the entire file + $uri = Get-WebListenerUrl -Test 'Resume' -TestValue 'NoResume' + $null = Invoke-RestMethod -uri $uri -OutFile $outFile + $fileSize = Get-Item $outFile | Select-Object -ExpandProperty Length + + $response = Invoke-RestMethod -uri $resumeUri -OutFile $outFile -ResponseHeadersVariable 'Headers' -Resume + + $outFileHash = Get-FileHash -Algorithm SHA256 -Path $outFile + $outFileHash.Hash | Should -BeExactly $referenceFileHash.Hash + Get-Item $outFile | Select-Object -ExpandProperty Length | Should -Be $referenceFileSize + $Headers.'X-WebListener-Has-Range'[0] | Should -BeExactly 'true' + $Headers.'X-WebListener-Request-Range'[0] | Should -BeExactly "bytes=$fileSize-" + $Headers.'Content-Range'[0] | Should -BeExactly "bytes */$referenceFileSize" + } + } + BeforeEach { if ($env:http_proxy) { $savedHttpProxy = $env:http_proxy diff --git a/test/tools/Modules/WebListener/WebListener.psm1 b/test/tools/Modules/WebListener/WebListener.psm1 index 95856f0e208..c477067ee29 100644 --- a/test/tools/Modules/WebListener/WebListener.psm1 +++ b/test/tools/Modules/WebListener/WebListener.psm1 @@ -161,6 +161,7 @@ function Get-WebListenerUrl { 'Redirect', 'Response', 'ResponseHeaders', + 'Resume', '/' )] [String]$Test, diff --git a/test/tools/WebListener/Constants.cs b/test/tools/WebListener/Constants.cs index ea4276318da..db4585163a0 100644 --- a/test/tools/WebListener/Constants.cs +++ b/test/tools/WebListener/Constants.cs @@ -14,9 +14,4 @@ internal static class Constants public const string NoUrlLinkHeader = "<>; rel=\"next\""; } - - internal static class StatusCodes - { - public const Int32 ApplicationError = 500; - } } diff --git a/test/tools/WebListener/Controllers/ResponseController.cs b/test/tools/WebListener/Controllers/ResponseController.cs index 40bf06a2cc6..23c0a461ff9 100644 --- a/test/tools/WebListener/Controllers/ResponseController.cs +++ b/test/tools/WebListener/Controllers/ResponseController.cs @@ -74,7 +74,7 @@ public String Index() catch (Exception ex) { output = JsonConvert.SerializeObject(ex); - Response.StatusCode = StatusCodes.ApplicationError; + Response.StatusCode = StatusCodes.Status500InternalServerError; contentType = Constants.ApplicationJson; } } diff --git a/test/tools/WebListener/Controllers/ResumeController.cs b/test/tools/WebListener/Controllers/ResumeController.cs new file mode 100644 index 00000000000..3d0de50bd91 --- /dev/null +++ b/test/tools/WebListener/Controllers/ResumeController.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.Linq; +using System.Net.Http.Headers; +using System.Net.Mime; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; +using mvc.Models; + +using RangeItemHeaderValue = System.Net.Http.Headers.RangeItemHeaderValue; +using RangeHeaderValue = System.Net.Http.Headers.RangeHeaderValue; + +namespace mvc.Controllers +{ + public class ResumeController : Controller + { + private static Byte[] FileBytes = new Byte[]{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20}; + + public async void Index() + { + SetResumeResponseHeaders(); + string rangeHeader; + int from = 0; + int to = FileBytes.Length - 1; + if (TryGetRangeHeader(out rangeHeader)) + { + var range = GetRange(rangeHeader); + if(range.From != null) + { + from = (int)range.From; + } + if(range.To != null) + { + to = (int)range.To; + } + + } + else + { + Response.ContentType = MediaTypeNames.Application.Octet; + Response.StatusCode = StatusCodes.Status200OK; + await Response.Body.WriteAsync(FileBytes, 0, FileBytes.Length); + return; + } + + if(to >= FileBytes.Length || from >= FileBytes.Length) + { + Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable; + Response.Headers[HeaderNames.ContentRange] = $"bytes */{FileBytes.Length}"; + return; + } + else + { + Response.ContentType = MediaTypeNames.Application.Octet; + Response.ContentLength = to - from + 1; + Response.Headers[HeaderNames.ContentRange] = $"bytes {from}-{to}/{FileBytes.Length}"; + Response.StatusCode = StatusCodes.Status206PartialContent; + await Response.Body.WriteAsync(FileBytes, from, (int)Response.ContentLength); + } + } + + public async void NoResume() + { + SetResumeResponseHeaders(); + Response.ContentType = MediaTypeNames.Application.Octet; + Response.ContentLength = FileBytes.Length; + Response.StatusCode = StatusCodes.Status200OK; + await Response.Body.WriteAsync(FileBytes, 0, FileBytes.Length); + } + + public async void Bytes(int NumberBytes) + { + if (NumberBytes > FileBytes.Length || NumberBytes < 0) + { + NumberBytes = FileBytes.Length; + } + Response.ContentType = MediaTypeNames.Application.Octet; + Response.ContentLength = NumberBytes; + await Response.Body.WriteAsync(FileBytes, 0, NumberBytes); + } + + public IActionResult Error() + { + return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + } + + private RangeItemHeaderValue GetRange(string rangeHeader) + { + return RangeHeaderValue.Parse(rangeHeader).Ranges.FirstOrDefault(); + } + + private void SetResumeResponseHeaders() + { + string rangeHeader; + if (TryGetRangeHeader(out rangeHeader)) + { + Response.Headers["X-WebListener-Has-Range"] = "true"; + Response.Headers["X-WebListener-Request-Range"] = rangeHeader; + } + else + { + Response.Headers["X-WebListener-Has-Range"] = "false"; + } + } + + private bool TryGetRangeHeader(out string rangeHeader) + { + var rangeHeaderSv = new StringValues(); + if(Request.Headers.TryGetValue("Range", out rangeHeaderSv)) + { + rangeHeader = rangeHeaderSv.FirstOrDefault(); + return true; + } + else + { + rangeHeader = string.Empty; + return false; + } + } + } +} diff --git a/test/tools/WebListener/README.md b/test/tools/WebListener/README.md index bc5cfc98e33..1e961542eda 100644 --- a/test/tools/WebListener/README.md +++ b/test/tools/WebListener/README.md @@ -613,3 +613,72 @@ Body: "x-header-01": "value01" } ``` + +### /Resume/ + +This endpoint simulates the download of a 20 byte file with support for resuming with the use of the `Range` HTTP request header. +The bytes returned are numbered 1 to 20 inclusive. +If the `Range` header is greater than 20, the endpoint will return a `416 Requested Range Not Satisfiable` response. +The endpoint also returns an `X-WebListener-Has-Range` response header containing `true` or `false` if the HTTP Request contains a `Range` request header. +The endpoint will also return an `X-WebListener-Request-Range` response header which contains the `Range` header value if one was present. + +```powershell +$uri = Get-WebListenerUrl -Test 'Resume' +$response = Invoke-WebRequest -Uri $uri -Headers @{"Range" = "bytes=0-"} +``` + +Response Headers: + +```none +HTTP/1.1 206 PartialContent +Date: Tue, 20 Mar 2018 08:45:42 GMT +Server: Kestrel +X-WebListener-Has-Range: true +X-WebListener-Request-Range: bytes=0- +Content-Length: 20 +Content-Type: application/octet-stream +Content-Range: bytes 0-19/20 +``` + +### /Resume/Bytes/{NumberBytes} + +This endpoint emulates a partial download of the same 20 bytes provided by the `/Resume/` endpoint. +The endpoint will return `{NumberBytes}` bytes of the 20 bytes. +For example `/Resume/Bytes/5` will return bytes 1 through 5 inclusive of the 20 byte file. + +```powershell +$uri = Get-WebListenerUrl -Test 'Resume' -TestValue 'Bytes/5' +$response = Invoke-WebRequest -Uri $uri +``` + +Response Headers: + +```none +HTTP/1.1 200 OK +Date: Tue, 20 Mar 2018 08:50:57 GMT +Server: Kestrel +Content-Length: 5 +Content-Type: application/octet-stream +``` + +### /Resume/NoResume + +This endpoint is the same as `/Resume/` with the exception that it ignores the `Range` HTTP request header. +This endpoint always returns the full 20 bytes and a `200` status. +The `X-WebListener-Has-Range` and `X-WebListener-Request-Range` headers are also returned the same as the `/Resume/` endpoint. + +```powershell +$uri = Get-WebListenerUrl -Test 'Resume' -TestValue 'NoResume' +$response = Invoke-WebRequest -Uri $uri +``` + +Response Headers: + +```none +HTTP/1.1 200 OK +Date: Tue, 20 Mar 2018 08:48:21 GMT +Server: Kestrel +X-WebListener-Has-Range: false +Content-Length: 20 +Content-Type: application/octet-stream +``` diff --git a/test/tools/WebListener/Startup.cs b/test/tools/WebListener/Startup.cs index 9cf4844d578..775357d1814 100644 --- a/test/tools/WebListener/Startup.cs +++ b/test/tools/WebListener/Startup.cs @@ -44,6 +44,10 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env) app.UseMvc(routes => { + routes.MapRoute( + name: "resume_bytes", + template: "Resume/Bytes/{NumberBytes?}", + defaults: new {controller = "Resume", action = "Bytes"}); routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}");