Add Simplified multipart/form-data Support to Web Cmdlets Through -Form Parameter (#5972)

- Adds -Form Parameter to Invoke-WebRequest and Invoke-RestMethod.
- Form Accepts any IDictionary.
- Keys are used as multipart/form-data field names (PSObject unwrapped and ConvertTo<String>(Object)).
- FileInfo values are added as StreamContent with application/octet-stream content type and the FileInfo.Name as the file name.
- Strings are treated as StringContent.
- Singe values are converted to string with ConvertTo<String>(Object) and treated as StringContent
- Top level collections are enumerated and converted as above. Nested collections are treated as a single value and converted accordingly.
- Form is mutually exclusive with -Body and -InFile.
- Per PowerShell-Committee decision, -Form makes no assumptions about the HTTP method used. It can theoretically be used with any method. User will need to manually supply -Method POST to post the form.
- ContentType and content related headers supplied to -Headers will be ignored/cleared as MultipartFormDataContent requires control of these headers.
This commit is contained in:
Mark Kraus
2018-01-25 09:17:55 +04:00
committed by Ilya
parent f86371e347
commit a36d58cf09
3 changed files with 389 additions and 1 deletions
@@ -306,6 +306,14 @@ namespace Microsoft.PowerShell.Commands
[Parameter(ValueFromPipeline = true)]
public virtual object Body { get; set; }
/// <summary>
/// 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.
/// </summary>
[Parameter]
public virtual IDictionary Form {get; set;}
/// <summary>
/// gets or sets the ContentType property
/// </summary>
@@ -430,6 +438,18 @@ namespace Microsoft.PowerShell.Commands
"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 @@ namespace Microsoft.PowerShell.Commands
}
}
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 @@ namespace Microsoft.PowerShell.Commands
}
}
/// <summary>
/// Adds content to a <see cref="MultipartFormDataContent" />. Object type detection is used to determine if the value is String, File, or Collection.
/// </summary>
/// <param name="fieldName">The Field Name to use.</param>
/// <param name="fieldValue">The Field Value to use.</param>
/// <param name="formData">The <see cref="MultipartFormDataContent" />> to update.</param>
/// <param name="enumerate">If true, collection types in <paramref name="fieldValue" /> will be enumerated. If false, collections will be treated as single value.</param>
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);
}
}
}
/// <summary>
/// Gets a <see cref="StringContent" /> from the supplied field name and field value. Uses <see cref="ConvertTo<T>(Object)" /> to convert the objects to strings.
/// </summary>
/// <param name="fieldName">The Field Name to use for the <see cref="StringContent" />.</param>
/// <param name="fieldValue">The Field Value to use for the <see cref="StringContent" />.</param>
private StringContent GetMultipartStringContent(Object fieldName, Object fieldValue)
{
var contentDisposition = new ContentDispositionHeaderValue("form-data");
contentDisposition.Name = LanguagePrimitives.ConvertTo<String>(fieldName);
var result = new StringContent(LanguagePrimitives.ConvertTo<String>(fieldValue));
result.Headers.ContentDisposition = contentDisposition;
return result;
}
/// <summary>
/// Gets a <see cref="StreamContent" /> from the supplied field name and <see cref="Stream" />. Uses <see cref="ConvertTo<T>(Object)" /> to convert the fieldname to a string.
/// </summary>
/// <param name="fieldName">The Field Name to use for the <see cref="StreamContent" />.</param>
/// <param name="stream">The <see cref="Stream" /> to use for the <see cref="StreamContent" />.</param>
private StreamContent GetMultipartStreamContent(Object fieldName, Stream stream)
{
var contentDisposition = new ContentDispositionHeaderValue("form-data");
contentDisposition.Name = LanguagePrimitives.ConvertTo<String>(fieldName);
var result = new StreamContent(stream);
result.Headers.ContentDisposition = contentDisposition;
result.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
return result;
}
/// <summary>
/// Gets a <see cref="StreamContent" /> from the supplied field name and file. Calls <see cref="GetMultipartStreamContent(Object, Stream)" /> to create the <see cref="StreamContent" /> and then sets the file name.
/// </summary>
/// <param name="fieldName">The Field Name to use for the <see cref="StreamContent" />.</param>
/// <param name="file">The file to use for the <see cref="StreamContent" />.</param>
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
}
}
@@ -138,6 +138,12 @@
<data name="BodyConflict" xml:space="preserve">
<value>The cmdlet cannot run because the following conflicting parameters are specified: Body and InFile. Specify either Body or Infile, then retry. </value>
</data>
<data name="BodyFormConflict" xml:space="preserve">
<value>The cmdlet cannot run because the following conflicting parameters are specified: Body and Form. Specify either Body or Form, then retry. </value>
</data>
<data name="FormInFileConflict" xml:space="preserve">
<value>The cmdlet cannot run because the following conflicting parameters are specified: InFile and Form. Specify either InFile or Form, then retry. </value>
</data>
<data name="CredentialConflict" xml:space="preserve">
<value>The cmdlet cannot run because the following conflicting parameters are specified: Credential and UseDefaultCredentials. Specify either Credential or UseDefaultCredentials, then retry.</value>
</data>
@@ -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