diff --git a/Abstracta.JmeterDsl.Tests/Abstracta.JmeterDsl.Tests.csproj b/Abstracta.JmeterDsl.Tests/Abstracta.JmeterDsl.Tests.csproj
index 6eedd4e..82cd741 100644
--- a/Abstracta.JmeterDsl.Tests/Abstracta.JmeterDsl.Tests.csproj
+++ b/Abstracta.JmeterDsl.Tests/Abstracta.JmeterDsl.Tests.csproj
@@ -35,5 +35,8 @@
PreserveNewest
+
+ PreserveNewest
+
\ No newline at end of file
diff --git a/Abstracta.JmeterDsl.Tests/Http/DslHttpSamplerTest.cs b/Abstracta.JmeterDsl.Tests/Http/DslHttpSamplerTest.cs
index 7981ceb..770b2be 100644
--- a/Abstracta.JmeterDsl.Tests/Http/DslHttpSamplerTest.cs
+++ b/Abstracta.JmeterDsl.Tests/Http/DslHttpSamplerTest.cs
@@ -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;
@@ -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]
@@ -54,7 +61,7 @@ public void ShouldMakeHttpRequestWithBodyAndHeadersWhenHttpPost()
.HaveReceivedACall()
.UsingPost()
.And
- .WithHeader("Content-Type", "application/json")
+ .WithHeader(_contentTypeHeader, "application/json")
.And
.WithHeader(customHeaderName, customHeaderValue)
.And
@@ -135,5 +142,71 @@ it doesn't match since same header is not yet set at check time.
*/
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;
+ }
}
}
diff --git a/Abstracta.JmeterDsl.Tests/Http/sample.xml b/Abstracta.JmeterDsl.Tests/Http/sample.xml
new file mode 100644
index 0000000..bf66f00
--- /dev/null
+++ b/Abstracta.JmeterDsl.Tests/Http/sample.xml
@@ -0,0 +1,7 @@
+
+
+
+ Tested
+
+
+
\ No newline at end of file
diff --git a/Abstracta.JmeterDsl.Tests/WireMockAssertionsExtensions.cs b/Abstracta.JmeterDsl.Tests/WireMockAssertionsExtensions.cs
index 12f8149..f835c58 100644
--- a/Abstracta.JmeterDsl.Tests/WireMockAssertionsExtensions.cs
+++ b/Abstracta.JmeterDsl.Tests/WireMockAssertionsExtensions.cs
@@ -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;
@@ -12,12 +13,14 @@ namespace Abstracta.JmeterDsl
{
public static class WireMockAssertionsExtensions
{
- public static AndConstraint WithBody(this WireMockAssertions instance, string body)
+ public static AndConstraint WithBody(this WireMockAssertions instance, string body) =>
+ WithBody(instance, request => string.Equals(request.Body, body, StringComparison.OrdinalIgnoreCase), body);
+
+ private static AndConstraint WithBody(WireMockAssertions instance, Func predicate, object body)
{
var requestsField = GetPrivateField("_requestMessages", instance);
var requests = (IReadOnlyList)requestsField.GetValue(instance)!;
var callsCount = (int?)GetPrivateField("_callsCount", instance).GetValue(instance);
- Func predicate = request => string.Equals(request.Body, body, StringComparison.OrdinalIgnoreCase);
Func, IReadOnlyList> filter = requests => requests.Where(predicate).ToList();
Func, bool> condition = requests => (callsCount is null && filter(requests).Any()) || callsCount == filter(requests).Count;
@@ -26,13 +29,13 @@ public static AndConstraint 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)
);
@@ -53,5 +56,20 @@ public static AndConstraint WithoutHeader(this WireMockAsser
}
return new AndConstraint(instance);
}
+
+ public static AndConstraint WithHeader(this WireMockAssertions instance, string headerName, Regex valueRegex)
+ {
+ var headersField = GetPrivateField("_headers", instance);
+ var headers = (IReadOnlyList>>)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(instance);
+ }
+
+ public static AndConstraint WithBody(this WireMockAssertions instance, Regex bodyRegex) =>
+ WithBody(instance, request => bodyRegex.IsMatch(request.Body), bodyRegex);
}
}
diff --git a/Abstracta.JmeterDsl/Core/Bridge/BridgedObjectConverter.cs b/Abstracta.JmeterDsl/Core/Bridge/BridgedObjectConverter.cs
index 8e90ef0..a789af4 100644
--- a/Abstracta.JmeterDsl/Core/Bridge/BridgedObjectConverter.cs
+++ b/Abstracta.JmeterDsl/Core/Bridge/BridgedObjectConverter.cs
@@ -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);
diff --git a/Abstracta.JmeterDsl/Core/Bridge/IDslProperty.cs b/Abstracta.JmeterDsl/Core/Bridge/IDslProperty.cs
new file mode 100644
index 0000000..f0d0af2
--- /dev/null
+++ b/Abstracta.JmeterDsl/Core/Bridge/IDslProperty.cs
@@ -0,0 +1,13 @@
+namespace Abstracta.JmeterDsl.Core.Bridge
+{
+ ///
+ /// This is just a marker interface to properly serialize properties that can include multiple values.
+ ///
+ /// Such properties are added to a __propList c# class property and a class implementing IDslProperty is used to define the name of the property.
+ ///
+ /// and for examples of classes using __propList and IDslProperty interface.
+ ///
+ public interface IDslProperty
+ {
+ }
+}
\ No newline at end of file
diff --git a/Abstracta.JmeterDsl/Core/ThreadGroups/DslThreadGroup.cs b/Abstracta.JmeterDsl/Core/ThreadGroups/DslThreadGroup.cs
index 3ed10b6..9b484da 100644
--- a/Abstracta.JmeterDsl/Core/ThreadGroups/DslThreadGroup.cs
+++ b/Abstracta.JmeterDsl/Core/ThreadGroups/DslThreadGroup.cs
@@ -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
@@ -327,7 +328,7 @@ public DslThreadGroup RampToAndHold(string threads, string rampDuration, string
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;
diff --git a/Abstracta.JmeterDsl/Http/DslHttpSampler.cs b/Abstracta.JmeterDsl/Http/DslHttpSampler.cs
index bc72122..1a4ea83 100644
--- a/Abstracta.JmeterDsl/Http/DslHttpSampler.cs
+++ b/Abstracta.JmeterDsl/Http/DslHttpSampler.cs
@@ -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
@@ -11,6 +14,7 @@ namespace Abstracta.JmeterDsl.Http
public class DslHttpSampler : BaseSampler
{
private readonly string _url;
+ private readonly List __propsList = new List();
private string _method;
private string _body;
@@ -64,6 +68,82 @@ public DslHttpSampler ContentType(MediaTypeHeaderValue contentType)
return this;
}
+ ///
+ /// Specifies a file to be sent as body of the request.
+ ///
+ /// This method is useful to send binary data in request (eg: uploading an image to a server).
+ ///
+ /// is path to the file to be sent as request body.
+ /// the sampler for further configuration or usage.
+ public DslHttpSampler BodyFile(string filePath)
+ {
+ __propsList.Add(new DslBodyFile(filePath));
+ return this;
+ }
+
+ ///
+ /// Allows specifying a query parameter or url encoded form body parameter.
+ ///
+ /// JMeter will automatically URL encode provided parameters names and values. Use
+ /// to send parameters values which are already encoded and
+ /// should be sent as is by JMeter.
+ ///
+ /// 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.
+ ///
+ /// If you set a parameter with empty string name, it results in same behavior as using
+ /// method. In general, you either use body function or parameters
+ /// functions, but don't use both of them in same sampler.
+ ///
+ /// specifies the name of the parameter.
+ /// specifies the value of the parameter to be URL encoded to include in URL
+ /// the sampler for further configuration or usage.
+ public DslHttpSampler Param(string name, string value)
+ {
+ __propsList.Add(new DslParam(name, value));
+ return this;
+ }
+
+ ///
+ /// Same as but param name and value will be sent with no additional
+ /// encoding.
+ ///
+ ///
+ public DslHttpSampler RawParam(string name, string value)
+ {
+ __propsList.Add(new DslRawParam(name, value));
+ return this;
+ }
+
+ ///
+ /// Specifies a part of a multipart form body.
+ ///
+ /// In general, samplers should not use this method in combination with
+ /// or .
+ ///
+ /// specifies the name of the part.
+ /// specifies the string to be sent in the part.
+ /// specifies the content-type associated to the part.
+ /// the sampler for further configuration or usage.
+ public DslHttpSampler BodyPart(string name, string value, MediaTypeHeaderValue contentType)
+ {
+ __propsList.Add(new DslBodyPart(name, value, contentType.ToString()));
+ return this;
+ }
+
+ ///
+ /// Specifies a file to be sent in a multipart form body.
+ ///
+ /// is the name to be assigned to the file part.
+ /// is path to the file to be sent in the multipart form body.
+ /// the content type associated to the part.
+ /// the sampler for further configuration or usage.
+ 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
@@ -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;
+ }
+ }
}
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 645cea9..7f96bd2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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`.
diff --git a/docs/guide/protocols/http/index.md b/docs/guide/protocols/http/index.md
index 4eca1d7..7b2e827 100644
--- a/docs/guide/protocols/http/index.md
+++ b/docs/guide/protocols/http/index.md
@@ -5,5 +5,7 @@ Throughout this guide, several examples have been shown for simple cases of HTTP
Here we show some of them, but check [JmeterDsl](/Abstracta.JmeterDsl/JmeterDsl.cs) and [DslHttpSampler](/Abstracta.JmeterDsl/Http/DslHttpSampler.cs) to explore all available features.
+
+
diff --git a/docs/guide/protocols/http/multipart.md b/docs/guide/protocols/http/multipart.md
new file mode 100644
index 0000000..869e71f
--- /dev/null
+++ b/docs/guide/protocols/http/multipart.md
@@ -0,0 +1,26 @@
+#### Multipart requests
+
+When you need to upload files to an HTTP server or need to send a complex request body, you will in many cases require sending multipart requests. To send a multipart request just use `BodyPart` and `BodyFilePart` methods like in the following example:
+
+```cs
+using static Abstracta.JmeterDsl.JmeterDsl;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Net.Mime;
+
+public class PerformanceTest
+{
+ [Test]
+ public void LoadTest()
+ {
+ TestPlan(
+ ThreadGroup(1, 1,
+ HttpSampler("https://myservice.com/report"),
+ .Method(HttpMethod.Post.Method)
+ .BodyPart("myText", "Hello World", new MediaTypeHeaderValue(MediaTypeNames.Text.Plain))
+ .BodyFilePart("myFile", "myReport.xml", new MediaTypeHeaderValue(MediaTypeNames.Text.Xml))
+ )
+ ).Run();
+ }
+}
+```
diff --git a/docs/guide/protocols/http/parameters.md b/docs/guide/protocols/http/parameters.md
new file mode 100644
index 0000000..9059aef
--- /dev/null
+++ b/docs/guide/protocols/http/parameters.md
@@ -0,0 +1,39 @@
+#### Parameters
+
+In many cases, you will need to specify some URL query string parameters or URL encoded form bodies. For these cases, you can use `Param` method as in the following example:
+
+```cs
+using static Abstracta.JmeterDsl.JmeterDsl;
+using System.Net.Http;
+
+public class PerformanceTest
+{
+ [Test]
+ public void LoadTest()
+ {
+ var baseUrl = "https://myservice.com/products";
+ TestPlan(
+ ThreadGroup(1, 1,
+ // GET https://myservice.com/products?name=iron+chair
+ HttpSampler("GetIronChair", baseUrl)
+ .Param("name", "iron chair"),
+ /*
+ * POST https://myservice.com/products
+ * Content-Type: application/x-www-form-urlencoded
+ *
+ * name=wooden+chair
+ */
+ HttpSampler("CreateWoodenChair", baseUrl)
+ .Method(HttpMethod.Post.Method) // POST
+ .Param("name", "wooden chair")
+ )
+ ).Run();
+ }
+}
+```
+
+::: tip
+JMeter automatically URL encodes parameters, so you don't need to worry about special characters in parameter names or values.
+
+If you want to use some custom encoding or have an already encoded value that you want to use, then you can use `RawParam` method instead which does not apply any encoding to the parameter name or value, and send it as is.
+:::
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 9e5283d..63dbf3f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,6 +11,6 @@
This pom is only needed to be able to copy jmeter-java-dsl jars and dependencies with dependency:copy-dependencies maven plugin goal in child projects
- 1.25.3
+ 1.26
\ No newline at end of file