Skip to content

Commit

Permalink
Add support for uploading files, defining params and multi parts in H…
Browse files Browse the repository at this point in the history
…ttpSampler
  • Loading branch information
rabelenda committed Mar 13, 2024
1 parent 76a5c97 commit e4775d2
Show file tree
Hide file tree
Showing 13 changed files with 334 additions and 10 deletions.
3 changes: 3 additions & 0 deletions Abstracta.JmeterDsl.Tests/Abstracta.JmeterDsl.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@
<None Update="Core\Configs\data.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Http\sample.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
77 changes: 75 additions & 2 deletions Abstracta.JmeterDsl.Tests/Http/DslHttpSamplerTest.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using System.Net.Http.Headers;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Text.RegularExpressions;
using System.Web;
using WireMock.FluentAssertions;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
Expand All @@ -11,6 +15,9 @@ namespace Abstracta.JmeterDsl.Http

public class DslHttpSamplerTest
{
private static readonly string _contentTypeHeader = "Content-Type";
private static readonly string _multipartBoundaryPattern = "[\\w-]+";
private static readonly string _crln = "\r\n";
private WireMockServer _wiremock;

[SetUp]
Expand Down Expand Up @@ -54,7 +61,7 @@ public void ShouldMakeHttpRequestWithBodyAndHeadersWhenHttpPost()
.HaveReceivedACall()
.UsingPost()
.And
.WithHeader("Content-Type", "application/json")
.WithHeader(_contentTypeHeader, "application/json")
.And
.WithHeader(customHeaderName, customHeaderValue)
.And
Expand Down Expand Up @@ -135,5 +142,71 @@ private void SetupCacheableHttpResponse()
*/
private HttpHeaders BuildHeadersToFixHttpCaching() =>
HttpHeaders().Header("User-Agent", "jmeter-java-dsl");

[Test]
public void ShouldSendQueryParametersWhenGetRequestWithParameters()
{
var param1Name = "par+am1";
var param1Value = "MY+VALUE";
var param2Name = "par+am2";
var param2Value = "OTHER+VALUE";
TestPlan(
ThreadGroup(1, 1,
HttpSampler(_wiremock.Url)
.Param(param1Name, param1Value)
.RawParam(param2Name, param2Value)
)
).Run();
_wiremock.Should()
.HaveReceivedACall()
.AtUrl(_wiremock.Url + "/?" + HttpUtility.UrlEncode(param1Name) + "=" + HttpUtility.UrlEncode(param1Value) + "&" + param2Name + "=" + param2Value);
}

[Test]
public void ShouldSendMultiPartFormWhenPostRequestWithBodyParts()
{
var part1Name = "part1";
var part1Value = "value1";
var part1Encoding = MediaTypeHeaderValue.Parse(MediaTypeNames.Text.Plain + "; charset=US-ASCII");
var part2Name = "part2";
var part2File = "Http/sample.xml";
var part2Encoding = new MediaTypeHeaderValue(MediaTypeNames.Text.Xml);

TestPlan(
ThreadGroup(1, 1,
HttpSampler(_wiremock.Url)
.Method(HttpMethod.Post.Method)
.BodyPart(part1Name, part1Value, part1Encoding)
.BodyFilePart(part2Name, part2File, part2Encoding)
)
).Run();
_wiremock.Should()
.HaveReceivedACall()
.UsingPost()
.And
.WithHeader(_contentTypeHeader, new Regex("multipart/form-data; boundary=" + _multipartBoundaryPattern))
.And
.WithBody(new Regex(BuildMultiPartBodyPattern(part1Name, part1Value, part1Encoding, part2Name, part2File, part2Encoding)));
}

private string BuildMultiPartBodyPattern(string part1Name, string part1Value, MediaTypeHeaderValue part1Encoding, string part2Name, string part2File, MediaTypeHeaderValue part2Encoding)
{
var separatorPattern = "--" + _multipartBoundaryPattern;
return separatorPattern + _crln
+ Regex.Escape(BuildBodyPart(part1Name, null, part1Value, part1Encoding, "8bit"))
+ separatorPattern + _crln
+ Regex.Escape(BuildBodyPart(part2Name, Path.GetFileName(part2File), File.ReadAllText(part2File), part2Encoding, "binary"))
+ separatorPattern + "--" + _crln;
}

private string BuildBodyPart(string name, string fileName, string value, MediaTypeHeaderValue contentType, string transferEncoding)
{
return "Content-Disposition: form-data; name=\"" + name + "\""
+ (fileName != null ? "; filename=\"" + fileName + "\"" : string.Empty) + _crln
+ "Content-Type" + ": " + contentType + _crln
+ "Content-Transfer-Encoding: " + transferEncoding + _crln
+ _crln
+ value + _crln;
}
}
}
7 changes: 7 additions & 0 deletions Abstracta.JmeterDsl.Tests/Http/sample.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<testResults version="1.2">
<sample rc="202">
<responseData class="java.lang.String">Tested</responseData>
</sample>

</testResults>
26 changes: 22 additions & 4 deletions Abstracta.JmeterDsl.Tests/WireMockAssertionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using FluentAssertions;
using FluentAssertions.Execution;
using WireMock;
Expand All @@ -12,12 +13,14 @@ namespace Abstracta.JmeterDsl
{
public static class WireMockAssertionsExtensions
{
public static AndConstraint<WireMockAssertions> WithBody(this WireMockAssertions instance, string body)
public static AndConstraint<WireMockAssertions> WithBody(this WireMockAssertions instance, string body) =>
WithBody(instance, request => string.Equals(request.Body, body, StringComparison.OrdinalIgnoreCase), body);

private static AndConstraint<WireMockAssertions> WithBody(WireMockAssertions instance, Func<IRequestMessage, bool> predicate, object body)
{
var requestsField = GetPrivateField("_requestMessages", instance);
var requests = (IReadOnlyList<IRequestMessage>)requestsField.GetValue(instance)!;
var callsCount = (int?)GetPrivateField("_callsCount", instance).GetValue(instance);
Func<IRequestMessage, bool> predicate = request => string.Equals(request.Body, body, StringComparison.OrdinalIgnoreCase);
Func<IReadOnlyList<IRequestMessage>, IReadOnlyList<IRequestMessage>> filter = requests => requests.Where(predicate).ToList();
Func<IReadOnlyList<IRequestMessage>, bool> condition = requests => (callsCount is null && filter(requests).Any()) || callsCount == filter(requests).Count;

Expand All @@ -26,13 +29,13 @@ public static AndConstraint<WireMockAssertions> WithBody(this WireMockAssertions
.Given(() => requests)
.ForCondition(requests => callsCount == 0 || requests.Any())
.FailWith(
"Expected {context:wiremockserver} to have been called using body {0}{reason}, but no calls were made.",
"Expected {context:wiremockserver} to have been called using body " + (body is Regex ? "matching " : string.Empty) + "{0}{reason}, but no calls were made.",
body
)
.Then
.ForCondition(condition)
.FailWith(
"Expected {context:wiremockserver} to have been called using body {0}{reason}, but didn't find it among the bodies {1}.",
"Expected {context:wiremockserver} to have been called using body " + (body is Regex ? "matching " : string.Empty) + "{0}{reason}, but didn't find it among the bodies {1}.",
_ => body,
requests => requests.Select(request => request.Body)
);
Expand All @@ -53,5 +56,20 @@ public static AndConstraint<WireMockAssertions> WithoutHeader(this WireMockAsser
}
return new AndConstraint<WireMockAssertions>(instance);
}

public static AndConstraint<WireMockAssertions> WithHeader(this WireMockAssertions instance, string headerName, Regex valueRegex)
{
var headersField = GetPrivateField("_headers", instance);
var headers = (IReadOnlyList<KeyValuePair<string, WireMockList<string>>>)headersField.GetValue(instance)!;
using (new AssertionScope("headers from requests sent"))
{
headers.Should()
.ContainSingle(h => h.Key == headerName && h.Value.Count == 1 && valueRegex.IsMatch(h.Value[0]));
}
return new AndConstraint<WireMockAssertions>(instance);
}

public static AndConstraint<WireMockAssertions> WithBody(this WireMockAssertions instance, Regex bodyRegex) =>
WithBody(instance, request => bodyRegex.IsMatch(request.Body), bodyRegex);
}
}
1 change: 1 addition & 0 deletions Abstracta.JmeterDsl/Core/Bridge/BridgedObjectConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class BridgedObjectConverter : IYamlTypeConverter

public bool Accepts(Type type) =>
typeof(IDslTestElement).IsAssignableFrom(type)
|| typeof(IDslProperty).IsAssignableFrom(type)
|| typeof(IDslJmeterEngine).IsAssignableFrom(type)
|| typeof(TestPlanExecution).IsAssignableFrom(type);

Expand Down
13 changes: 13 additions & 0 deletions Abstracta.JmeterDsl/Core/Bridge/IDslProperty.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Abstracta.JmeterDsl.Core.Bridge
{
/// <summary>
/// This is just a marker interface to properly serialize properties that can include multiple values.
/// <br/>
/// Such properties are added to a __propList c# class property and a class implementing IDslProperty is used to define the name of the property.
/// <br/>
/// <see cref="ThreadGroups.DslThreadGroup"/> and <see cref="Http.DslHttpSampler"/> for examples of classes using __propList and IDslProperty interface.
/// </summary>
public interface IDslProperty
{
}
}
3 changes: 2 additions & 1 deletion Abstracta.JmeterDsl/Core/ThreadGroups/DslThreadGroup.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Abstracta.JmeterDsl.Core.Bridge;
using YamlDotNet.Serialization;

namespace Abstracta.JmeterDsl.Core.ThreadGroups
Expand Down Expand Up @@ -327,7 +328,7 @@ public DslThreadGroup HoldIterating(string iterations)
public new DslThreadGroup Children(params IThreadGroupChild[] children) =>
base.Children(children);

internal abstract class Stage : IDslTestElement
internal abstract class Stage : IDslProperty
{
internal readonly object _threadCount;
internal readonly object _duration;
Expand Down
144 changes: 142 additions & 2 deletions Abstracta.JmeterDsl/Http/DslHttpSampler.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System.Linq;
using System.Net.Http;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using Abstracta.JmeterDsl.Core.Bridge;
using Abstracta.JmeterDsl.Core.Samplers;

namespace Abstracta.JmeterDsl.Http
Expand All @@ -11,6 +14,7 @@ namespace Abstracta.JmeterDsl.Http
public class DslHttpSampler : BaseSampler<DslHttpSampler>
{
private readonly string _url;
private readonly List<HttpSamplerProperty> __propsList = new List<HttpSamplerProperty>();
private string _method;
private string _body;

Expand Down Expand Up @@ -64,6 +68,82 @@ public DslHttpSampler ContentType(MediaTypeHeaderValue contentType)
return this;
}

/// <summary>
/// Specifies a file to be sent as body of the request.
/// <br/>
/// This method is useful to send binary data in request (eg: uploading an image to a server).
/// </summary>
/// <param name="filePath">is path to the file to be sent as request body.</param>
/// <returns>the sampler for further configuration or usage.</returns>
public DslHttpSampler BodyFile(string filePath)
{
__propsList.Add(new DslBodyFile(filePath));
return this;
}

/// <summary>
/// Allows specifying a query parameter or url encoded form body parameter.
/// <br/>
/// JMeter will automatically URL encode provided parameters names and values. Use
/// <see cref="RawParam(string, string)"/> to send parameters values which are already encoded and
/// should be sent as is by JMeter.
/// <br/>
/// JMeter will use provided parameter in query string if method is GET, DELETE or OPTIONS,
/// otherwise it will use them in url encoded form body.
/// <br/>
/// If you set a parameter with empty string name, it results in same behavior as using
/// <see cref="Body(string)"/> method. In general, you either use body function or parameters
/// functions, but don't use both of them in same sampler.
/// </summary>
/// <param name="name">specifies the name of the parameter.</param>
/// <param name="value">specifies the value of the parameter to be URL encoded to include in URL</param>
/// <returns>the sampler for further configuration or usage.</returns>
public DslHttpSampler Param(string name, string value)
{
__propsList.Add(new DslParam(name, value));
return this;
}

/// <summary>
/// Same as <see cref="Param(string, string)"/> but param name and value will be sent with no additional
/// encoding.
/// </summary>
/// <see cref="Param(string, string)"/>
public DslHttpSampler RawParam(string name, string value)
{
__propsList.Add(new DslRawParam(name, value));
return this;
}

/// <summary>
/// Specifies a part of a multipart form body.
/// <br/>
/// In general, samplers should not use this method in combination with
/// <see cref="Param(string, string)"/> or <see cref="RawParam(string, string)"/>.
/// </summary>
/// <param name="name">specifies the name of the part.</param>
/// <param name="value">specifies the string to be sent in the part.</param>
/// <param name="contentType">specifies the content-type associated to the part.</param>
/// <returns>the sampler for further configuration or usage.</returns>
public DslHttpSampler BodyPart(string name, string value, MediaTypeHeaderValue contentType)
{
__propsList.Add(new DslBodyPart(name, value, contentType.ToString()));
return this;
}

/// <summary>
/// Specifies a file to be sent in a multipart form body.
/// </summary>
/// <param name="name">is the name to be assigned to the file part.</param>
/// <param name="filePath">is path to the file to be sent in the multipart form body.</param>
/// <param name="contentType">the content type associated to the part.</param>
/// <returns>the sampler for further configuration or usage.</returns>
public DslHttpSampler BodyFilePart(string name, string filePath, MediaTypeHeaderValue contentType)
{
__propsList.Add(new DslBodyFilePart(name, filePath, contentType.ToString()));
return this;
}

private HttpHeaders FindHeaders()
{
var ret = (from c in _children
Expand Down Expand Up @@ -91,5 +171,65 @@ public DslHttpSampler Header(string name, string value)
FindHeaders().Header(name, value);
return this;
}

internal abstract class HttpSamplerProperty : IDslProperty
{
public void ShowInGui() => throw new NotImplementedException();
}

internal class DslBodyFile : HttpSamplerProperty
{
internal readonly string _filePath;

public DslBodyFile(string filePath)
{
_filePath = filePath;
}
}

internal class DslParam : HttpSamplerProperty
{
internal readonly string _name;
internal readonly string _value;

public DslParam(string name, string value)
{
_name = name;
_value = value;
}
}

internal class DslRawParam : DslParam
{
public DslRawParam(string name, string value)
: base(name, value)
{
}
}

internal class DslBodyPart : DslParam
{
internal readonly string _contentType;

public DslBodyPart(string name, string value, string contentType)
: base(name, value)
{
_contentType = contentType;
}
}

internal class DslBodyFilePart : HttpSamplerProperty
{
internal readonly string _name;
internal readonly string _filePath;
internal readonly string _contentType;

public DslBodyFilePart(string name, string filePath, string contentType)
{
_name = name;
_filePath = filePath;
_contentType = contentType;
}
}
}
}
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Here are the main rules when defining a test element class in the .Net DSL:
* Class declares constructor with required properties (same as Java DSL).
* Class declares optional properties methods that allow setting optional properties and return an instance of the test element for fluent API usage (same as Java DSL). Eg: `DslHttpSampler` declares `Method` and `Body` methods.
* Class declares additional optional property methods which are just abstractions and simplifications for setting some test element properties. Eg: `DslHttpSampler` declares `Post`, `Header`, and `ContentType` methods. `Post` is just a simplification that actually uses `Method`, `ContentType`, and `Body` methods. `Header` simplifies setting children elements. `ContentType` is a simplified way of using the `Header` method.
* If a Java class has a method to set a multi valued property (invoking same method several times, like `rampToAndHold` in `threadGroup`, or `bodyPart` and `bodyFile` and similar methods in `httpSampler`), then define a property `__propList` in the .Net class which contains a list of objects which class names match the property name, or optionally add `Dsl` preffix, (eg: `DslRampToAndHold`, `DslBodyPart`, etc). These classes should implement `IDslProperty`. For some examples check [DslThreadGroup](Abstracta.JmeterDsl/Core/ThreadGroups/DslThreadGroup.cs) and [DslHttpSampler](Abstracta.JmeterDsl/Http/DslHttpSampler.cs).
* Include xmldoc documentation which contains most of the already contained documentation in the Java docs analogous class, with potential clarifications for the .Net ecosystem.
* Include builder methods in the `JmeterDsl` class to ease the creation of test elements and require the user to just import one namespace and class (`JmeterDsl`).
* Include the test element in a package that is analogous to the Jmeter Java DSL modules. Eg: `DslHttpSampler` is included in `Abstracta.JmeterDsl` as is in Java in `jmeter-java-dsl`. `AzureEngine` is included in `Abstracta.JmeterDsl.Azure` as is in Java in `jmeter-java-dsl-azure`.
Expand Down
Loading

0 comments on commit e4775d2

Please sign in to comment.