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

Feat multipart file upload support #447

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/PactNet.Abstractions/IRequestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,14 @@ public interface IRequestBuilderV3
/// <param name="body">Request body</param>
/// <param name="contentType">Content type</param>
/// <returns>Fluent builder</returns>
IRequestBuilderV3 WithBody(string body, string contentType);
IRequestBuilderV3 WithBody(string body, string contentType);

/// <summary>
/// A Multipart body containing a single part, which is an uploaded file
/// </summary>
/// <param name="filePath">The absolute path of the file being uploaded</param>
/// <returns>Fluent builder</returns>
IRequestBuilderV3 WithMultipartSingleFileUpload(string filePath);
mefellows marked this conversation as resolved.
Show resolved Hide resolved
Inksprout marked this conversation as resolved.
Show resolved Hide resolved

// TODO: Support binary and multi-part body

Expand Down
11 changes: 10 additions & 1 deletion src/PactNet/Drivers/HttpInteractionDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ public void WithRequestBody(string contentType, string body)
/// <param name="contentType">Context type</param>
/// <param name="body">Serialised body</param>
public void WithResponseBody(string contentType, string body)
=> NativeInterop.WithBody(this.interaction, InteractionPart.Response, contentType, body).CheckInteropSuccess();
=> NativeInterop.WithBody(this.interaction, InteractionPart.Response, contentType, body).CheckInteropSuccess();

/// <summary>
/// Set the request body to multipart/form-data for file upload
/// </summary>
/// <param name="contentType">Content type override</param>
/// <param name="filePath">path to file being uploaded</param>
/// <param name="mimePartName">the name of the mime part being uploaded</param>
public void WithMultipartSingleFileUpload(string contentType, string filePath, string mimePartName)
=> NativeInterop.WithMultipartSingleFileUpload(this.interaction, InteractionPart.Request, contentType, filePath, mimePartName).CheckInteropSuccess();
}
}
10 changes: 9 additions & 1 deletion src/PactNet/Drivers/IHttpInteractionDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ internal interface IHttpInteractionDriver : IProviderStateDriver
/// </summary>
/// <param name="contentType">Context type</param>
/// <param name="body">Serialised body</param>
void WithResponseBody(string contentType, string body);
void WithResponseBody(string contentType, string body);

/// <summary>
/// Set the response body for a single file to be uploaded as a multipart/form-data content type
/// </summary>
/// <param name="filePath">path to file being uploaded</param>
/// <param name="contentType">Content type override</param>
/// <param name="mimePartName">the name of the mime part being uploaded</param>
void WithMultipartSingleFileUpload(string filePath, string contentType, string mimePartName);
}
}
15 changes: 13 additions & 2 deletions src/PactNet/Drivers/InteropActionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using PactNet.Exceptions;

using System.Runtime.InteropServices;
using PactNet.Exceptions;
using PactNet.Interop;

namespace PactNet.Drivers
{
/// <summary>
Expand All @@ -19,5 +21,14 @@ public static void CheckInteropSuccess(this bool success)
throw new PactFailureException("Unable to perform the given action. The interop call indicated failure");
}
}

public static void CheckInteropSuccess(this StringResult success)
Inksprout marked this conversation as resolved.
Show resolved Hide resolved
{
if (success.tag != StringResult.Tag.StringResult_Ok)
{
string errorMsg = Marshal.PtrToStringAnsi(success.failed._0);
throw new PactFailureException($"Unable to perform the given action. The interop call returned failure: {errorMsg}");
}
}
}
}
5 changes: 4 additions & 1 deletion src/PactNet/Interop/NativeInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ internal static class NativeInterop
public static extern bool ResponseStatus(InteractionHandle interaction, ushort status);

[DllImport(DllName, EntryPoint = "pactffi_with_body")]
public static extern bool WithBody(InteractionHandle interaction, InteractionPart part, string contentType, string body);
public static extern bool WithBody(InteractionHandle interaction, InteractionPart part, string contentType, string body);

[DllImport(DllName, EntryPoint = "pactffi_with_multipart_file")]
public static extern StringResult WithMultipartSingleFileUpload(InteractionHandle interaction, InteractionPart part, string contentType, string filePath, string mimePartName );
Inksprout marked this conversation as resolved.
Show resolved Hide resolved

[DllImport(DllName, EntryPoint = "pactffi_free_string")]
public static extern void FreeString(IntPtr s);
Expand Down
36 changes: 36 additions & 0 deletions src/PactNet/Interop/StringResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
Inksprout marked this conversation as resolved.
Show resolved Hide resolved
using System.Runtime.InteropServices;
namespace PactNet.Interop
{

[StructLayout(LayoutKind.Explicit)]
internal struct StringResult
{
public enum Tag
{
StringResult_Ok,
StringResult_Failed,
};

[FieldOffset(0)]
public Tag tag;

[FieldOffset(8)]
public StringResult_Ok_Body ok;

[FieldOffset(8)]
public StringResult_Failed_Body failed;
}

[StructLayout(LayoutKind.Sequential)]
internal struct StringResult_Ok_Body
{
public IntPtr _0;
}

[StructLayout(LayoutKind.Sequential)]
internal struct StringResult_Failed_Body
{
public IntPtr _0;
}
}
26 changes: 23 additions & 3 deletions src/PactNet/RequestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,16 @@ IRequestBuilderV3 IRequestBuilderV3.WithJsonBody(dynamic body, JsonSerializerSet
/// <param name="contentType">Content type override</param>
/// <returns>Fluent builder</returns>
IRequestBuilderV3 IRequestBuilderV3.WithJsonBody(dynamic body, JsonSerializerSettings settings, string contentType)
=> this.WithJsonBody(body, settings, contentType);

=> this.WithJsonBody(body, settings, contentType);
Inksprout marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// Set a body which is multipart/form-data but contains only one part, which is a file upload
/// </summary>
/// <param name="filePath">Path to the file being uploaded</param>
/// <returns>Fluent builder</returns>
IRequestBuilderV3 IRequestBuilderV3.WithMultipartSingleFileUpload(string filePath)
=> this.WithMultipartSingleFileUpload(filePath, "multipart/form-data", "file");
Inksprout marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// A pre-formatted body which should be used as-is for the request
/// </summary>
Expand Down Expand Up @@ -390,8 +398,20 @@ internal RequestBuilder WithJsonBody(dynamic body, JsonSerializerSettings settin
{
string serialised = JsonConvert.SerializeObject(body, settings);
return this.WithBody(serialised, contentType);
}

/// <summary>
/// Set a body which is multipart/form-data but contains only one part, which is a file upload
Inksprout marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
/// <param name="filePath">path to file being uploaded</param>
/// <param name="contentType">Content type override</param>
/// <param name="mimePartName">The name of the mime part being uploaded</param>
/// <returns>Fluent builder</returns>
internal RequestBuilder WithMultipartSingleFileUpload(string filePath, string contentType, string mimePartName)
{
this.driver.WithMultipartSingleFileUpload(filePath, contentType, mimePartName);
return this;
}

/// <summary>
/// A pre-formatted body which should be used as-is for the request
/// </summary>
Expand Down
139 changes: 119 additions & 20 deletions tests/PactNet.Tests/Drivers/FfiIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using PactNet.Drivers;
using PactNet.Interop;
using Xunit;
Expand All @@ -24,28 +27,29 @@ public FfiIntegrationTests(ITestOutputHelper output)
this.output = output;

NativeInterop.LogToBuffer(LevelFilter.Trace);
}

}
[Fact]
public async Task HttpInteraction_v3_CreatesPactFile()
Inksprout marked this conversation as resolved.
Show resolved Hide resolved
public async Task HttpInteraction_v3_CreatesPactFile_WithMultiPartRequest()
{
var driver = new PactDriver();

try
{
IHttpPactDriver pact = driver.NewHttpPact("NativeDriverTests-Consumer-V3",
"NativeDriverTests-Provider",
PactSpecification.V3);

IHttpInteractionDriver interaction = pact.NewHttpInteraction("a sample interaction");
"NativeDriverTests-Provider-Multipart",
PactSpecification.V3);

IHttpInteractionDriver interaction = pact.NewHttpInteraction("a sample interaction");

string contentType = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "application/octet-stream" : "image/jpeg";

interaction.Given("provider state");
interaction.GivenWithParam("state with param", "foo", "bar");
interaction.WithRequest("POST", "/path");
interaction.WithRequestHeader("X-Request-Header", "request1", 0);
interaction.WithRequestHeader("X-Request-Header", "request2", 1);
interaction.WithQueryParameter("param", "value", 0);
interaction.WithRequestBody("application/json", @"{""foo"":42}");
interaction.WithRequest("POST", "/path");
var path = Path.GetFullPath("data/test_file.jpeg");
Assert.True(File.Exists(path));

interaction.WithMultipartSingleFileUpload(contentType, path, "file");

interaction.WithResponseStatus((ushort)HttpStatusCode.Created);
interaction.WithResponseHeader("X-Response-Header", "value1", 0);
Expand All @@ -54,19 +58,114 @@ public async Task HttpInteraction_v3_CreatesPactFile()

using IMockServerDriver mockServer = pact.CreateMockServer("127.0.0.1", null, false);

var client = new HttpClient { BaseAddress = mockServer.Uri };
client.DefaultRequestHeaders.Add("X-Request-Header", new[] { "request1", "request2" });

HttpResponseMessage result = await client.PostAsync("/path?param=value", new StringContent(@"{""foo"":42}", Encoding.UTF8, "application/json"));
result.StatusCode.Should().Be(HttpStatusCode.Created);
result.Headers.GetValues("X-Response-Header").Should().BeEquivalentTo("value1", "value2");
var client = new HttpClient { BaseAddress = mockServer.Uri };

using var fileStream = new FileStream("data/test_file.jpeg", FileMode.Open, FileAccess.Read);

var upload = new MultipartFormDataContent();
upload.Headers.ContentType.MediaType = "multipart/form-data";

var fileContent = new StreamContent(fileStream);
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("image/jpeg");

var fileName = Path.GetFileName(path);
var fileNameBytes = Encoding.UTF8.GetBytes(fileName);
var encodedFileName = Convert.ToBase64String(fileNameBytes);
upload.Add(fileContent, "file", fileName);
upload.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
{
Name = "file",
FileName = fileName,
FileNameStar = $"utf-8''{encodedFileName}"
};

HttpResponseMessage result = await client.PostAsync("/path", upload);
result.StatusCode.Should().Be(HttpStatusCode.Created);

string logs = mockServer.MockServerLogs();

string content = await result.Content.ReadAsStringAsync();
content.Should().Be(@"{""foo"":42}");

mockServer.MockServerMismatches().Should().Be("[]");

string logs = mockServer.MockServerLogs();
logs.Should().NotBeEmpty();

this.output.WriteLine("Mock Server Logs");
this.output.WriteLine("----------------");
this.output.WriteLine(logs);

pact.WritePactFile(Environment.CurrentDirectory);
}
finally
{
this.WriteDriverLogs(driver);
}
// The body and boundry will be different, so test the header and matching rules are multipart/form-data
var file = new FileInfo("NativeDriverTests-Consumer-V3-NativeDriverTests-Provider-Multipart.json");
file.Exists.Should().BeTrue();

string pactContents = File.ReadAllText(file.FullName).TrimEnd();
JObject pactObject = JObject.Parse(pactContents);

string expectedPactContent = File.ReadAllText("data/v3-server-integration-MultipartFormDataBody.json").TrimEnd();
JObject expectedPactObject = JObject.Parse(pactContents);


string contentTypeHeader = (string)pactObject["interactions"][0]["request"]["headers"]["Content-Type"];
Assert.Contains("multipart/form-data;", contentTypeHeader);


JArray integrationsArray = (JArray)pactObject["interactions"];
JToken matchingRules = integrationsArray.First["request"]["matchingRules"];

JArray expecteIntegrationsArray = (JArray)expectedPactObject["interactions"];
JToken expectedMatchingRules = expecteIntegrationsArray.First["request"]["matchingRules"];

Assert.True(JToken.DeepEquals(matchingRules, expectedMatchingRules));
}

[Fact]
public async Task HttpInteraction_v3_CreatesPactFile()
{
var driver = new PactDriver();

try
{
IHttpPactDriver pact = driver.NewHttpPact("NativeDriverTests-Consumer-V3",
"NativeDriverTests-Provider",
PactSpecification.V3);

IHttpInteractionDriver interaction = pact.NewHttpInteraction("a sample interaction");

interaction.Given("provider state");
interaction.GivenWithParam("state with param", "foo", "bar");
interaction.WithRequest("POST", "/path");
interaction.WithRequestHeader("X-Request-Header", "request1", 0);
interaction.WithRequestHeader("X-Request-Header", "request2", 1);
interaction.WithQueryParameter("param", "value", 0);
interaction.WithRequestBody("application/json", @"{""foo"":42}");

interaction.WithResponseStatus((ushort)HttpStatusCode.Created);
interaction.WithResponseHeader("X-Response-Header", "value1", 0);
interaction.WithResponseHeader("X-Response-Header", "value2", 1);
interaction.WithResponseBody("application/json", @"{""foo"":42}");

using IMockServerDriver mockServer = pact.CreateMockServer("127.0.0.1", null, false);

var client = new HttpClient { BaseAddress = mockServer.Uri };
client.DefaultRequestHeaders.Add("X-Request-Header", new[] { "request1", "request2" });

HttpResponseMessage result = await client.PostAsync("/path?param=value", new StringContent(@"{""foo"":42}", Encoding.UTF8, "application/json"));
result.StatusCode.Should().Be(HttpStatusCode.Created);
result.Headers.GetValues("X-Response-Header").Should().BeEquivalentTo("value1", "value2");

string content = await result.Content.ReadAsStringAsync();
content.Should().Be(@"{""foo"":42}");

mockServer.MockServerMismatches().Should().Be("[]");

string logs = mockServer.MockServerLogs();
logs.Should().NotBeEmpty();

this.output.WriteLine("Mock Server Logs");
Expand Down
9 changes: 9 additions & 0 deletions tests/PactNet.Tests/PactNet.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,13 @@
<ProjectReference Include="..\..\src\PactNet\PactNet.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="data\test_file.jpeg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="data\v3-server-integration-MultipartFormDataBody.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
11 changes: 11 additions & 0 deletions tests/PactNet.Tests/RequestBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using FluentAssertions;
using Moq;
Expand Down Expand Up @@ -194,6 +195,16 @@ public void WillRespond_RequestNotConfigured_ThrowsInvalidOperationException()
Action action = () => this.builder.WillRespond();

action.Should().Throw<InvalidOperationException>("because the request has not been configured");
}

[Fact]
public void WithMultipartSingleFileUpload_AddsRequestBody()
{
var path = Path.GetFullPath("data/test_file.jpeg");

this.builder.WithMultipartSingleFileUpload(path,"multipart/form-data", "file");

this.mockDriver.Verify(s => s.WithMultipartSingleFileUpload(path, "multipart/form-data", "file"));
}
}
}
Binary file added tests/PactNet.Tests/data/test_file.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading