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 2b2dc46a6c3..748732d7c6b 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 @@ -306,6 +306,14 @@ public virtual string CustomMethod [Parameter(ValueFromPipeline = true)] public virtual object Body { get; set; } + /// + /// Dictionary for use with RFC-7578 multipart/form-data submissions. + /// Keys are form fields and their respective values are form values. + /// A value may be a collection of form values or single form value. + /// + [Parameter] + public virtual IDictionary Form {get; set;} + /// /// gets or sets the ContentType property /// @@ -430,6 +438,18 @@ internal virtual void ValidateParameters() "WebCmdletBodyConflictException"); ThrowTerminatingError(error); } + if ((null != Body) && (null != Form)) + { + ErrorRecord error = GetValidationError(WebCmdletStrings.BodyFormConflict, + "WebCmdletBodyFormConflictException"); + ThrowTerminatingError(error); + } + if ((null != InFile) && (null != Form)) + { + ErrorRecord error = GetValidationError(WebCmdletStrings.FormInFileConflict, + "WebCmdletFormInFileConflictException"); + ThrowTerminatingError(error); + } // validate InFile path if (InFile != null) @@ -1075,8 +1095,21 @@ internal virtual void FillRequestStream(HttpRequestMessage request) } } + if (null != Form) + { + // Content headers will be set by MultipartFormDataContent which will throw unless we clear them first + WebSession.ContentHeaders.Clear(); + + var formData = new MultipartFormDataContent(); + foreach (DictionaryEntry formEntry in Form) + { + // AddMultipartContent will handle PSObject unwrapping, Object type determination and enumerateing top level IEnumerables. + AddMultipartContent(fieldName: formEntry.Key, fieldValue: formEntry.Value, formData: formData, enumerate: true); + } + SetRequestContent(request, formData); + } // coerce body into a usable form - if (Body != null) + else if (Body != null) { object content = Body; @@ -1590,6 +1623,108 @@ internal void ParseLinkHeader(HttpResponseMessage response, System.Uri requestUr } } + /// + /// Adds content to a . Object type detection is used to determine if the value is String, File, or Collection. + /// + /// The Field Name to use. + /// The Field Value to use. + /// The > to update. + /// If true, collection types in will be enumerated. If false, collections will be treated as single value. + private void AddMultipartContent(object fieldName, object fieldValue, MultipartFormDataContent formData, bool enumerate) + { + if (null == formData) + { + throw new ArgumentNullException("formDate"); + } + + // It is possible that the dictionary keys or values are PSObject wrapped depending on how the dictionary is defined and assigned. + // Before processing the field name and value we need to ensure we are working with the base objects and not the PSObject wrappers. + + // Unwrap fieldName PSObjects + if (fieldName is PSObject namePSObject) + { + fieldName = namePSObject.BaseObject; + } + + // Unwrap fieldValue PSObjects + if (fieldValue is PSObject valuePSObject) + { + fieldValue = valuePSObject.BaseObject; + } + + // Treat a single FileInfo as a FileContent + if (fieldValue is FileInfo file) + { + formData.Add(GetMultipartFileContent(fieldName: fieldName, file: file)); + return; + } + + // Treat Strings and other single values as a StringContent. + // If enumeration is false, also treat IEnumerables as StringContents. + // String implements IEnumerable so the explicit check is required. + if (enumerate == false || fieldValue is String || !(fieldValue is IEnumerable)) + { + formData.Add(GetMultipartStringContent(fieldName: fieldName, fieldValue: fieldValue)); + return; + } + + // Treat the value as a collection and enumerate it if enumeration is true + if (enumerate == true && fieldValue is IEnumerable items) + { + foreach (var item in items) + { + // Recruse, but do not enumerate the next level. IEnumerables will be treated as single values. + AddMultipartContent(fieldName: fieldName, fieldValue: item, formData: formData, enumerate: false); + } + } + } + + /// + /// Gets a from the supplied field name and field value. Uses to convert the objects to strings. + /// + /// The Field Name to use for the . + /// The Field Value to use for the . + private StringContent GetMultipartStringContent(Object fieldName, Object fieldValue) + { + var contentDisposition = new ContentDispositionHeaderValue("form-data"); + contentDisposition.Name = LanguagePrimitives.ConvertTo(fieldName); + + var result = new StringContent(LanguagePrimitives.ConvertTo(fieldValue)); + result.Headers.ContentDisposition = contentDisposition; + + return result; + } + + /// + /// Gets a from the supplied field name and . Uses to convert the fieldname to a string. + /// + /// The Field Name to use for the . + /// The to use for the . + private StreamContent GetMultipartStreamContent(Object fieldName, Stream stream) + { + var contentDisposition = new ContentDispositionHeaderValue("form-data"); + contentDisposition.Name = LanguagePrimitives.ConvertTo(fieldName); + + var result = new StreamContent(stream); + result.Headers.ContentDisposition = contentDisposition; + result.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + return result; + } + + /// + /// Gets a from the supplied field name and file. Calls to create the and then sets the file name. + /// + /// The Field Name to use for the . + /// The file to use for the . + private StreamContent GetMultipartFileContent(Object fieldName, FileInfo file) + { + var result = GetMultipartStreamContent(fieldName: fieldName, stream: new FileStream(file.FullName, FileMode.Open)); + result.Headers.ContentDisposition.FileName = file.Name; + + return result; + } + #endregion Helper Methods } } diff --git a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx index a1304e38da4..0c50ddd38ca 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx +++ b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx @@ -138,6 +138,12 @@ The cmdlet cannot run because the following conflicting parameters are specified: Body and InFile. Specify either Body or Infile, then retry. + + The cmdlet cannot run because the following conflicting parameters are specified: Body and Form. Specify either Body or Form, then retry. + + + The cmdlet cannot run because the following conflicting parameters are specified: InFile and Form. Specify either InFile or Form, then retry. + The cmdlet cannot run because the following conflicting parameters are specified: Credential and UseDefaultCredentials. Specify either Credential or UseDefaultCredentials, then retry. diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index 8d0856b8af2..05356b2edf8 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -1098,6 +1098,29 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { } Context "Multipart/form-data Tests" { + <# + Content-Type request headers for multipart/form-data appear as: + multipart/form-data; boundary="0ab0cb90-f01b-4c15-bd4d-53d073efcc1d" + MultipartFormDataContent sets a random GUID for the boundary before submitting the request + to the remote endpoint. Tests in this context for Content-Type match 'multipart/form-data' + as we do not have access to the random GUID. + #> + <# + Kestrel/ASP.NET inconsistently renders the new line for uploaded text files. + File content tests in this context use match as a workaround. + #> + BeforeAll { + $file1Name = "testfile1.txt" + $file1Path = Join-Path $testdrive $file1Name + $file1Contents = "Test123" + $file1Contents | Set-Content $file1Path -Force + + $file2Name = "testfile2.txt" + $file2Path = Join-Path $testdrive $file2Name + $file2Contents = "Test456" + $file2Contents | Set-Content $file2Path -Force + } + It "Verifies Invoke-WebRequest Supports Multipart String Values" { $body = GetMultipartBody -String $uri = Get-WebListenerUrl -Test 'Multipart' @@ -1107,6 +1130,7 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { $result.Headers.'Content-Type' | Should Match 'multipart/form-data' $result.Items.TestString[0] | Should Be 'TestValue' } + It "Verifies Invoke-WebRequest Supports Multipart File Values" { $body = GetMultipartBody -File $uri = Get-WebListenerUrl -Test 'Multipart' @@ -1118,6 +1142,7 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { $result.Files[0].ContentType | Should Be 'text/plain' $result.Files[0].Content | Should Match 'TestContent' } + It "Verifies Invoke-WebRequest Supports Mixed Multipart String and File Values" { $body = GetMultipartBody -String -File $uri = Get-WebListenerUrl -Test 'Multipart' @@ -1130,6 +1155,108 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { $result.Files[0].ContentType | Should Be 'text/plain' $result.Files[0].Content | Should Match 'TestContent' } + + It "Verifies Invoke-WebRequest -Form supports string values" { + $form = @{TestString = "TestValue"} + $uri = Get-WebListenerUrl -Test 'Multipart' + $response = Invoke-WebRequest -Uri $uri -Form $form -Method 'POST' + $result = $response.Content | ConvertFrom-Json + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestString.Count | Should Be 1 + $result.Items.TestString[0] | Should BeExactly 'TestValue' + } + + It "Verifies Invoke-WebRequest -Form supports a collection of string values" { + $form = @{TestStrings = "TestValue", "TestValue2"} + $uri = Get-WebListenerUrl -Test 'Multipart' + $response = Invoke-WebRequest -Uri $uri -Form $form -Method 'POST' + $result = $response.Content | ConvertFrom-Json + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestStrings.Count | Should Be 2 + $result.Items.TestStrings[0] | Should BeExactly 'TestValue' + $result.Items.TestStrings[1] | Should BeExactly 'TestValue2' + } + + It "Verifies Invoke-WebRequest -Form supports file values" { + $form = @{TestFile = [System.IO.FileInfo]$file1Path} + $uri = Get-WebListenerUrl -Test 'Multipart' + $response = Invoke-WebRequest -Uri $uri -Form $form -Method 'POST' + $result = $response.Content | ConvertFrom-Json + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Files.Count | Should Be 1 + + $result.Files[0].Name | Should BeExactly "TestFile" + $result.Files[0].FileName | Should BeExactly $file1Name + $result.Files[0].ContentType | Should BeExactly 'application/octet-stream' + $result.Files[0].Content | Should Match $file1Contents + } + + It "Verifies Invoke-WebRequest -Form supports a collection of file values" { + $form = @{TestFiles = [System.IO.FileInfo]$file1Path, [System.IO.FileInfo]$file2Path} + $uri = Get-WebListenerUrl -Test 'Multipart' + $response = Invoke-WebRequest -Uri $uri -Form $form -Method 'POST' + $result = $response.Content | ConvertFrom-Json + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Files.Count | Should Be 2 + + $result.Files[0].Name | Should BeExactly "TestFiles" + $result.Files[0].FileName | Should BeExactly $file1Name + $result.Files[0].ContentType | Should BeExactly 'application/octet-stream' + $result.Files[0].Content | Should Match $file1Contents + + $result.Files[1].Name | Should BeExactly "TestFiles" + $result.Files[1].FileName | Should BeExactly $file2Name + $result.Files[1].ContentType | Should BeExactly 'application/octet-stream' + $result.Files[1].Content | Should Match $file2Contents + } + + It "Verifies Invoke-WebRequest -Form supports combinations of strings and files" { + $form = @{ + TestStrings = "TestValue", "TestValue2" + TestFiles = [System.IO.FileInfo]$file1Path, [System.IO.FileInfo]$file2Path + } + $uri = Get-WebListenerUrl -Test 'Multipart' + $response = Invoke-WebRequest -Uri $uri -Form $form -Method 'POST' + $result = $response.Content | ConvertFrom-Json + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestStrings.Count | Should Be 2 + $result.Files.Count | Should Be 2 + + $result.Items.TestStrings[0] | Should BeExactly 'TestValue' + $result.Items.TestStrings[1] | Should BeExactly 'TestValue2' + + $result.Files[0].Name | Should Be "TestFiles" + $result.Files[0].FileName | Should BeExactly $file1Name + $result.Files[0].ContentType | Should BeExactly 'application/octet-stream' + $result.Files[0].Content | Should Match $file1Contents + + $result.Files[1].Name | Should BeExactly "TestFiles" + $result.Files[1].FileName | Should BeExactly $file2Name + $result.Files[1].ContentType | Should BeExactly 'application/octet-stream' + $result.Files[1].Content | Should Match $file2Contents + } + + It "Verifies Invoke-WebRequest -Form is mutually exclusive with -Body" { + $form = @{TestString = "TestValue"} + $body = "test" + $uri = Get-WebListenerUrl -Test 'Multipart' + + {Invoke-WebRequest -Uri $uri -Form $form -Body $Body -ErrorAction 'Stop'} | + ShouldBeErrorId 'WebCmdletBodyFormConflictException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand' + } + + It "Verifies Invoke-WebRequest -Form is mutually exclusive with -InFile" { + $form = @{TestString = "TestValue"} + $uri = Get-WebListenerUrl -Test 'Multipart' + + {Invoke-WebRequest -Uri $uri -Form $form -InFile $file1Path -ErrorAction 'Stop'} | + ShouldBeErrorId 'WebCmdletFormInFileConflictException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand' + } } Context "Invoke-WebRequest -Authentication tests" { @@ -1902,6 +2029,29 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" { } Context "Multipart/form-data Tests" { + <# + Content-Type request headers for multipart/form-data appear as: + multipart/form-data; boundary="0ab0cb90-f01b-4c15-bd4d-53d073efcc1d" + MultipartFormDataContent sets a random GUID for the boundary before submitting the request + to the remote endpoint. Tests in this context for Content-Type match 'multipart/form-data' + as we do not have access to the random GUID. + #> + <# + Kestrel/ASP.NET inconsistently renders the new line for uploaded text files. + File content tests in this context use match as a workaround. + #> + BeforeAll { + $file1Name = "testfile1.txt" + $file1Path = Join-Path $testdrive $file1Name + $file1Contents = "Test123" + $file1Contents | Set-Content $file1Path -Force + + $file2Name = "testfile2.txt" + $file2Path = Join-Path $testdrive $file2Name + $file2Contents = "Test456" + $file2Contents | Set-Content $file2Path -Force + } + It "Verifies Invoke-RestMethod Supports Multipart String Values" { $body = GetMultipartBody -String $uri = Get-WebListenerUrl -Test 'Multipart' @@ -1933,6 +2083,103 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" { $result.Files[0].ContentType | Should Be 'text/plain' $result.Files[0].Content | Should Match 'TestContent' } + + It "Verifies Invoke-RestMethod -Form supports string values" { + $form = @{TestString = "TestValue"} + $uri = Get-WebListenerUrl -Test 'Multipart' + $result = Invoke-RestMethod -Uri $uri -Form $form -Method 'POST' + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestString.Count | Should Be 1 + $result.Items.TestString[0] | Should Be 'TestValue' + } + + It "Verifies Invoke-RestMethod -Form supports a collection of string values" { + $form = @{TestStrings = "TestValue", "TestValue2"} + $uri = Get-WebListenerUrl -Test 'Multipart' + $result = Invoke-RestMethod -Uri $uri -Form $form -Method 'POST' + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestStrings.Count | Should Be 2 + $result.Items.TestStrings[0] | Should Be 'TestValue' + $result.Items.TestStrings[1] | Should Be 'TestValue2' + } + + It "Verifies Invoke-RestMethod -Form supports file values" { + $form = @{TestFile = [System.IO.FileInfo]$file1Path} + $uri = Get-WebListenerUrl -Test 'Multipart' + $result = Invoke-RestMethod -Uri $uri -Form $form -Method 'POST' + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Files.Count | Should Be 1 + + $result.Files[0].Name | Should Be "TestFile" + $result.Files[0].FileName | Should Be $file1Name + $result.Files[0].ContentType | Should Be 'application/octet-stream' + $result.Files[0].Content | Should Match $file1Contents + } + + It "Verifies Invoke-RestMethod -Form supports a collection of file values" { + $form = @{TestFiles = [System.IO.FileInfo]$file1Path, [System.IO.FileInfo]$file2Path} + $uri = Get-WebListenerUrl -Test 'Multipart' + $result = Invoke-RestMethod -Uri $uri -Form $form -Method 'POST' + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Files.Count | Should Be 2 + + $result.Files[0].Name | Should Be "TestFiles" + $result.Files[0].FileName | Should Be $file1Name + $result.Files[0].ContentType | Should Be 'application/octet-stream' + $result.Files[0].Content | Should Match $file1Contents + + $result.Files[1].Name | Should Be "TestFiles" + $result.Files[1].FileName | Should Be $file2Name + $result.Files[1].ContentType | Should Be 'application/octet-stream' + $result.Files[1].Content | Should Match $file2Contents + } + + It "Verifies Invoke-RestMethod -Form supports combinations of strings and files" { + $form = @{ + TestStrings = "TestValue", "TestValue2" + TestFiles = [System.IO.FileInfo]$file1Path, [System.IO.FileInfo]$file2Path + } + $uri = Get-WebListenerUrl -Test 'Multipart' + $result = Invoke-RestMethod -Uri $uri -Form $form -Method 'POST' + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestStrings.Count | Should Be 2 + $result.Files.Count | Should Be 2 + + $result.Items.TestStrings[0] | Should Be 'TestValue' + $result.Items.TestStrings[1] | Should Be 'TestValue2' + + $result.Files[0].Name | Should Be "TestFiles" + $result.Files[0].FileName | Should Be $file1Name + $result.Files[0].ContentType | Should Be 'application/octet-stream' + $result.Files[0].Content | Should Match $file1Contents + + $result.Files[1].Name | Should Be "TestFiles" + $result.Files[1].FileName | Should Be $file2Name + $result.Files[1].ContentType | Should Be 'application/octet-stream' + $result.Files[1].Content | Should Match $file2Contents + } + + It "Verifies Invoke-RestMethod -Form is mutually exclusive with -Body" { + $form = @{TestString = "TestValue"} + $body = "test" + $uri = Get-WebListenerUrl -Test 'Multipart' + + {Invoke-RestMethod -Uri $uri -Form $form -Body $Body -ErrorAction 'Stop'} | + ShouldBeErrorId 'WebCmdletBodyFormConflictException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand' + } + + It "Verifies Invoke-RestMethod -Form is mutually exclusive with -InFile" { + $form = @{TestString = "TestValue"} + $uri = Get-WebListenerUrl -Test 'Multipart' + + {Invoke-RestMethod -Uri $uri -Form $form -InFile $file1Path -ErrorAction 'Stop'} | + ShouldBeErrorId 'WebCmdletFormInFileConflictException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand' + } } #region charset encoding tests