Add Multipart Support to Web Cmdlets (#4782)

Partially implements  #2112
- Adds `System.Net.Http.MultipartFormDataContent` as a possible type for `-Body`
- Adds `/Multipart/` test to WebListener 

This allows for the user to create their own `Http.MultipartFormDataContent` object and submit it. Since `multipart/form-data` submissions are highly flexible, adding direct support for it to the CmdLets may over-complicate the command parameters and a limited implementation would not address the broad scope of use cases. This at least allows the user to submit multipart forms using the Web Cmdlets and not have to manage their own `HttpClient`. Once this is introduced, limited multipart implementations can be expanded to use the code in this PR.
This commit is contained in:
Mark Kraus 2017-09-12 11:41:36 -05:00 committed by Dongbo Wang
parent 1e4cbe15ff
commit fd3a003765
8 changed files with 280 additions and 0 deletions

View File

@ -67,6 +67,7 @@ macOS
Microsoft.PowerShell.Archive
MS-PSRP
myget
Multipart
New-PSSessionOption
New-PSTransportOption
NuGet

View File

@ -389,6 +389,11 @@ namespace Microsoft.PowerShell.Commands
byte[] bytes = content as byte[];
SetRequestContent(request, bytes);
}
else if (content is MultipartFormDataContent multipartFormDataContent)
{
WebSession.ContentHeaders.Clear();
SetRequestContent(request, multipartFormDataContent);
}
else
{
SetRequestContent(request,
@ -787,6 +792,32 @@ namespace Microsoft.PowerShell.Commands
return streamContent.Headers.ContentLength.Value;
}
/// <summary>
/// Sets the ContentLength property of the request and writes the specified content to the request's RequestStream.
/// </summary>
/// <param name="request">The WebRequest who's content is to be set</param>
/// <param name="multipartContent">A MultipartFormDataContent object containing multipart/form-data content.</param>
/// <returns>The number of bytes written to the requests RequestStream (and the new value of the request's ContentLength property</returns>
/// <remarks>
/// Because this function sets the request's ContentLength property and writes content data into the requests's stream,
/// it should be called one time maximum on a given request.
/// </remarks>
internal long SetRequestContent(HttpRequestMessage request, MultipartFormDataContent multipartContent)
{
if (request == null)
{
throw new ArgumentNullException("request");
}
if (multipartContent == null)
{
throw new ArgumentNullException("multipartContent");
}
request.Content = multipartContent;
return multipartContent.Headers.ContentLength.Value;
}
internal long SetRequestContent(HttpRequestMessage request, IDictionary content)
{
if (request == null)

View File

@ -359,6 +359,39 @@ function ExecuteRestMethod
return $result
}
function GetMultipartBody
{
param
(
[Switch]$String,
[Switch]$File
)
$multipartContent = [System.Net.Http.MultipartFormDataContent]::new()
if ($String.IsPresent)
{
$stringHeader = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new("form-data")
$stringHeader.Name = "TestString"
$StringContent = [System.Net.Http.StringContent]::new("TestValue")
$StringContent.Headers.ContentDisposition = $stringHeader
$multipartContent.Add($stringContent)
}
if ($File.IsPresent)
{
$multipartFile = Join-Path $TestDrive 'multipart.txt'
"TestContent" | Set-Content $multipartFile
$FileStream = [System.IO.FileStream]::new($multipartFile, [System.IO.FileMode]::Open)
$fileHeader = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new("form-data")
$fileHeader.Name = "TestFile"
$fileHeader.FileName = 'multipart.txt'
$fileContent = [System.Net.Http.StreamContent]::new($FileStream)
$fileContent.Headers.ContentDisposition = $fileHeader
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("text/plain")
$multipartContent.Add($fileContent)
}
# unary comma required to prevent $multipartContent from being unwrapped/enumerated
return ,$multipartContent
}
<#
Defines the list of redirect codes to test as well as the
expected Method when the redirection is handled.
@ -1200,6 +1233,41 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" {
}
}
Context "Multipart/form-data Tests" {
It "Verifies Invoke-WebRequest Supports Multipart String Values" {
$body = GetMultipartBody -String
$uri = Get-WebListenerUrl -Test 'Multipart'
$response = Invoke-WebRequest -Uri $uri -Body $body -Method 'POST'
$result = $response.Content | ConvertFrom-Json
$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'
$response = Invoke-WebRequest -Uri $uri -Body $body -Method 'POST'
$result = $response.Content | ConvertFrom-Json
$result.Headers.'Content-Type' | Should Match 'multipart/form-data'
$result.Files[0].FileName | Should Be 'multipart.txt'
$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'
$response = Invoke-WebRequest -Uri $uri -Body $body -Method 'POST'
$result = $response.Content | ConvertFrom-Json
$result.Headers.'Content-Type' | Should Match 'multipart/form-data'
$result.Items.TestString[0] | Should Be 'TestValue'
$result.Files[0].FileName | Should Be 'multipart.txt'
$result.Files[0].ContentType | Should Be 'text/plain'
$result.Files[0].Content | Should Match 'TestContent'
}
}
BeforeEach {
if ($env:http_proxy) {
$savedHttpProxy = $env:http_proxy
@ -1737,6 +1805,38 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" {
}
}
Context "Multipart/form-data Tests" {
It "Verifies Invoke-RestMethod Supports Multipart String Values" {
$body = GetMultipartBody -String
$uri = Get-WebListenerUrl -Test 'Multipart'
$result = Invoke-RestMethod -Uri $uri -Body $body -Method 'POST'
$result.Headers.'Content-Type' | Should Match 'multipart/form-data'
$result.Items.TestString[0] | Should Be 'TestValue'
}
It "Verifies Invoke-RestMethod Supports Multipart File Values" {
$body = GetMultipartBody -File
$uri = Get-WebListenerUrl -Test 'Multipart'
$result = Invoke-RestMethod -Uri $uri -Body $body -Method 'POST'
$result.Headers.'Content-Type' | Should Match 'multipart/form-data'
$result.Files[0].FileName | Should Be 'multipart.txt'
$result.Files[0].ContentType | Should Be 'text/plain'
$result.Files[0].Content | Should Match 'TestContent'
}
It "Verifies Invoke-RestMethod Supports Mixed Multipart String and File Values" {
$body = GetMultipartBody -String -File
$uri = Get-WebListenerUrl -Test 'Multipart'
$result = Invoke-RestMethod -Uri $uri -Body $body -Method 'POST'
$result.Headers.'Content-Type' | Should Match 'multipart/form-data'
$result.Items.TestString[0] | Should Be 'TestValue'
$result.Files[0].FileName | Should Be 'multipart.txt'
$result.Files[0].ContentType | Should Be 'text/plain'
$result.Files[0].Content | Should Match 'TestContent'
}
}
#region charset encoding tests
Context "Invoke-RestMethod Encoding tests with BasicHtmlWebResponseObject response" {

View File

@ -116,6 +116,7 @@ function Get-WebListenerUrl {
'Cert',
'Get',
'Home',
'Multipart',
'/'
)]
[String]$Test

View File

@ -0,0 +1,89 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using mvc.Models;
namespace mvc.Controllers
{
public class MultipartController : Controller
{
private IHostingEnvironment _environment;
public MultipartController(IHostingEnvironment environment)
{
_environment = environment;
}
public ActionResult Index()
{
return View();
}
[HttpPost]
public JsonResult Index(IFormCollection collection)
{
if (!Request.HasFormContentType)
{
Response.StatusCode = 415;
Hashtable error = new Hashtable {{"error","Unsupported media type"}};
return Json(error);
}
List<Hashtable> fileList = new List<Hashtable>();
foreach (var file in collection.Files)
{
string result = string.Empty;
if (file.Length > 0)
{
using (var reader = new StreamReader(file.OpenReadStream()))
{
result = reader.ReadToEnd();
}
}
Hashtable fileHash = new Hashtable
{
{"ContentDisposition" , file.ContentDisposition},
{"ContentType" , file.ContentType},
{"FileName" , file.FileName},
{"Length" , file.Length},
{"Name" , file.Name},
{"Content" , result},
{"Headers" , file.Headers}
};
fileList.Add(fileHash);
}
Hashtable itemsHash = new Hashtable();
foreach (var key in collection.Keys)
{
itemsHash.Add(key,collection[key]);
}
MediaTypeHeaderValue mediaContentType = MediaTypeHeaderValue.Parse(Request.ContentType);
Hashtable headers = new Hashtable();
foreach (var key in Request.Headers.Keys)
{
headers.Add(key, String.Join(Constants.HeaderSeparator, Request.Headers[key]));
}
Hashtable output = new Hashtable
{
{"Files" , fileList},
{"Items" , itemsHash},
{"Boundary", HeaderUtilities.RemoveQuotes(mediaContentType.Boundary).Value},
{"Headers" , headers}
};
return Json(output);
}
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}
}

View File

@ -82,3 +82,47 @@ Invoke-WebRequest -Uri 'http://localhost:8083/Get/' -Body @{TestField = 'TestVal
"origin": "127.0.0.1"
}
```
## /Multipart/
### GET
Provides an HTML form for `multipart/form-data` submission.
### POST
Accepts a `multipart/form-data` submission and returns a JSON object containing information about the submission including the items and files submitted.
```json
{
"Files": [
{
"ContentDisposition": "form-data; name=fileData; filename=test.txt",
"Headers": {
"Content-Disposition": [
"form-data; name=fileData; filename=test.txt"
],
"Content-Type": [
"text/plain"
]
},
"FileName": "test.txt",
"Length": 15,
"ContentType": "text/plain",
"Name": "fileData",
"Content": "Test Contents\r\n"
}
],
"Items": {
"stringData": [
"TestValue"
]
},
"Boundary": "83027bde-fd9b-4ea0-b1ca-a1f661d01ada",
"Headers": {
"Content-Type": "multipart/form-data; boundary=\"83027bde-fd9b-4ea0-b1ca-a1f661d01ada\"",
"Connection": "Keep-Alive",
"Content-Length": "336",
"Host": "localhost:8083",
"User-Agent": "Mozilla/5.0 (Windows NT; Microsoft Windows 10.0.15063 ; en-US) WindowsPowerShell/6.0.0"
}
}
```

View File

@ -3,4 +3,5 @@
<li><a href="/">/</a> - This page</li>
<li><a href="/Cert/">/Cert/</a> - Client Certificate Details</li>
<li><a href="/Get/">/Get/</a> - Emulates functionality of https://httpbin.org/get by returning GET headers, Arguments, and Request URL</li>
<li><a href="/Multipart/">/Multipart/</a> - Multipart/form-data submission testing</li>
</ul>

View File

@ -0,0 +1,13 @@
<form name="MultipartForm" method="post" enctype="multipart/form-data">
<div>
<label for="TextBox">TextBox text field</label>
<input name="TextBox" type="text" />
</div>
<div>
<label for="TextFile">TextFile file field</label>
<input name="TextFile" type="file" />
</div>
<div>
<input type="submit" value="Submit" />
</div>
</form>