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