Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Multipart Support to Web Cmdlets #4782

Merged
merged 4 commits into from Sep 12, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .spelling
Expand Up @@ -67,6 +67,7 @@ macOS
Microsoft.PowerShell.Archive
MS-PSRP
myget
Multipart
New-PSSessionOption
New-PSTransportOption
NuGet
Expand Down
Expand Up @@ -389,6 +389,11 @@ internal virtual void FillRequestStream(HttpRequestMessage request)
byte[] bytes = content as byte[];
SetRequestContent(request, bytes);
}
else if (content is MultipartFormDataContent multipartFormDataContent)
{
WebSession.ContentHeaders.Clear();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebSession.ContentHeader is set at line 325 when ContentType is specified. Is it OK to ignore that as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, ContentType must be set by the MultipartFormDataContent object as it controls the boundary in the Content-Type header used to distinguish between the various fields supplied. Documentation will probably need to make clear that -ContentType will be ignored when a MultipartFormDataContent object is supplied. It doesn't make sense to use any other Content-Type header when doing multipart/form-data anyway. If this isn't done here, the Foreach on line 421 throws. when Content is set with a MultipartFormDataContent it overwrites all the content headers, so trying to add content headers again causes an exception about ContentType already existing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification. I updated the PR description to capture your words.

Then there is a potential problem at line 239
Nevermind, GetRequest happens before FillRequestStream, so we are good. Updated the PR description to mention -Header and -WebSession will be ignored as well.

SetRequestContent(request, multipartFormDataContent);
}
else
{
SetRequestContent(request,
Expand Down Expand Up @@ -787,6 +792,32 @@ internal long SetRequestContent(HttpRequestMessage request, Stream contentStream
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)
Expand Down
Expand Up @@ -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.
Expand Down Expand Up @@ -1200,6 +1233,41 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" {
}
}

Context "Multipart/form-data Tests" {
It "Verifies Invoke-WebRequest Supports Multipart String Values" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use the same Context header 😕
I believe we should add cmdlet name Context "Invoke-WebRequest Multipart/form-data Tests"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because they are under different describe blocks. We did the same thing with HTTPS

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's somewhat confusing, but we can leave it for Context.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity, would you like me to change it or leave it as is?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leave it as is.

$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
Expand Down Expand Up @@ -1737,6 +1805,38 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" {
}
}

Context "Multipart/form-data Tests" {
It "Verifies Invoke-RestMethod Supports Multipart String Values" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Context "Invoke-RestMethod Multipart/form-data Tests"

$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" {
Expand Down
1 change: 1 addition & 0 deletions test/tools/Modules/WebListener/WebListener.psm1
Expand Up @@ -116,6 +116,7 @@ function Get-WebListenerUrl {
'Cert',
'Get',
'Home',
'Multipart',
'/'
)]
[String]$Test
Expand Down
89 changes: 89 additions & 0 deletions test/tools/WebListener/Controllers/MultipartController.cs
@@ -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 });
}
}
}
44 changes: 44 additions & 0 deletions test/tools/WebListener/README.md
Expand Up @@ -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"
}
}
```
1 change: 1 addition & 0 deletions test/tools/WebListener/Views/Home/Index.cshtml
Expand Up @@ -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>
13 changes: 13 additions & 0 deletions test/tools/WebListener/Views/Multipart/Index.cshtml
@@ -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>