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