From be2ea67b89e32376432953c41339b115cf2a69a6 Mon Sep 17 00:00:00 2001 From: Gennadii Saltyshchak Date: Mon, 18 Aug 2025 20:52:42 +0300 Subject: [PATCH] Add new package WireMock.Net.Extensions.Routing which provides minimal-API-style routing for WireMock.Net (#1344) * Add new package WireMock.Net.Extensions.Routing * Update documentation for WireMock.Net.Extensions.Routing * Cleanup imports * Add header to all source files inside WireMock.Net.Extensions.Routing * Add header to all source files inside WireMock.Net.Extensions.Routing.Tests * Revert unintended changes * Remove redundant build configurations * Remove incorrect links from documentation * Update nuget package references * Revert unintended changes * Migrate to AwesomeAssertions * Remove redundant project reference * Adjust formatting * Migrate to primary constructor * Refactoring: rename delegate parameter * Abstract over JSON converter * Replace WireMock with WireMock.Net in comments * Move local functions to the bottom of the methods --- WireMock.Net Solution.sln | 14 ++ .../Delegates/WireMockHttpRequestHandler.cs | 10 + .../Delegates/WireMockMiddleware.cs | 10 + .../Extensions/DictionaryExtensions.cs | 19 ++ .../Extensions/HttpResponseExtensions.cs | 34 +++ .../Extensions/RequestMessageExtensions.cs | 16 ++ .../Extensions/StringExtensions.cs | 9 + .../Extensions/TaskExtensions.cs | 39 +++ .../WireMockHttpRequestHandlerExtensions.cs | 17 ++ .../Extensions/WireMockRouterExtensions.cs | 224 ++++++++++++++++++ .../Extensions/WireMockServerExtensions.cs | 35 +++ .../Models/WireMockRequestInfo.cs | 29 +++ .../Models/WireMockRequestInfo{TBody}.cs | 24 ++ src/WireMock.Net.Extensions.Routing/README.md | 138 +++++++++++ .../Utils/RoutePattern.cs | 107 +++++++++ .../WireMock.Net.Extensions.Routing.csproj | 30 +++ .../WireMockRouter.cs | 153 ++++++++++++ .../WireMockServerRouterBuilder.cs | 73 ++++++ .../Tests/WireMockRouterTests.cs | 100 ++++++++ ...reMock.Net.Extensions.Routing.Tests.csproj | 37 +++ 20 files changed, 1118 insertions(+) create mode 100644 src/WireMock.Net.Extensions.Routing/Delegates/WireMockHttpRequestHandler.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Delegates/WireMockMiddleware.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Extensions/DictionaryExtensions.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Extensions/HttpResponseExtensions.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Extensions/RequestMessageExtensions.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Extensions/StringExtensions.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Extensions/TaskExtensions.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Extensions/WireMockHttpRequestHandlerExtensions.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Extensions/WireMockRouterExtensions.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Extensions/WireMockServerExtensions.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Models/WireMockRequestInfo.cs create mode 100644 src/WireMock.Net.Extensions.Routing/Models/WireMockRequestInfo{TBody}.cs create mode 100644 src/WireMock.Net.Extensions.Routing/README.md create mode 100644 src/WireMock.Net.Extensions.Routing/Utils/RoutePattern.cs create mode 100644 src/WireMock.Net.Extensions.Routing/WireMock.Net.Extensions.Routing.csproj create mode 100644 src/WireMock.Net.Extensions.Routing/WireMockRouter.cs create mode 100644 src/WireMock.Net.Extensions.Routing/WireMockServerRouterBuilder.cs create mode 100644 test/WireMock.Net.Extensions.Routing.Tests/Tests/WireMockRouterTests.cs create mode 100644 test/WireMock.Net.Extensions.Routing.Tests/WireMock.Net.Extensions.Routing.Tests.csproj diff --git a/WireMock.Net Solution.sln b/WireMock.Net Solution.sln index 86b5368b..2fd76f42 100644 --- a/WireMock.Net Solution.sln +++ b/WireMock.Net Solution.sln @@ -138,6 +138,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WireMock.Net.Tests.UsingNuG EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WireMock.Net.GraphQL", "src\WireMock.Net.GraphQL\WireMock.Net.GraphQL.csproj", "{B6269AAC-170A-4346-8B9A-444DED3D9A45}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WireMock.Net.Extensions.Routing.Tests", "test\WireMock.Net.Extensions.Routing.Tests\WireMock.Net.Extensions.Routing.Tests.csproj", "{3FCBCA9C-9DB0-4A96-B47E-30470764CC9C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WireMock.Net.Extensions.Routing", "src\WireMock.Net.Extensions.Routing\WireMock.Net.Extensions.Routing.csproj", "{1E874C8F-08A2-493B-8421-619F9A6E9E77}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -332,6 +336,14 @@ Global {B6269AAC-170A-4346-8B9A-444DED3D9A45}.Debug|Any CPU.Build.0 = Debug|Any CPU {B6269AAC-170A-4346-8B9A-444DED3D9A45}.Release|Any CPU.ActiveCfg = Release|Any CPU {B6269AAC-170A-4346-8B9A-444DED3D9A45}.Release|Any CPU.Build.0 = Release|Any CPU + {3FCBCA9C-9DB0-4A96-B47E-30470764CC9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FCBCA9C-9DB0-4A96-B47E-30470764CC9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FCBCA9C-9DB0-4A96-B47E-30470764CC9C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FCBCA9C-9DB0-4A96-B47E-30470764CC9C}.Release|Any CPU.Build.0 = Release|Any CPU + {1E874C8F-08A2-493B-8421-619F9A6E9E77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E874C8F-08A2-493B-8421-619F9A6E9E77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E874C8F-08A2-493B-8421-619F9A6E9E77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E874C8F-08A2-493B-8421-619F9A6E9E77}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -386,6 +398,8 @@ Global {1F80A6E6-D146-4E40-9EA8-49DB8494239F} = {985E0ADB-D4B4-473A-AA40-567E279B7946} {BBA332C6-28A9-42E7-9C4D-A0816E52A198} = {0BB8B634-407A-4610-A91F-11586990767A} {B6269AAC-170A-4346-8B9A-444DED3D9A45} = {8F890C6F-9ACC-438D-928A-AD61CDA862F2} + {3FCBCA9C-9DB0-4A96-B47E-30470764CC9C} = {0BB8B634-407A-4610-A91F-11586990767A} + {1E874C8F-08A2-493B-8421-619F9A6E9E77} = {8F890C6F-9ACC-438D-928A-AD61CDA862F2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC539027-9852-430C-B19F-FD035D018458} diff --git a/src/WireMock.Net.Extensions.Routing/Delegates/WireMockHttpRequestHandler.cs b/src/WireMock.Net.Extensions.Routing/Delegates/WireMockHttpRequestHandler.cs new file mode 100644 index 00000000..d094aac8 --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Delegates/WireMockHttpRequestHandler.cs @@ -0,0 +1,10 @@ +// Copyright © WireMock.Net + +namespace WireMock.Net.Extensions.Routing.Delegates; + +/// +/// Represents a handler for processing WireMock.Net HTTP requests and returning a response asynchronously. +/// +/// The incoming request message. +/// A task that resolves to a . +public delegate Task WireMockHttpRequestHandler(IRequestMessage requestMessage); diff --git a/src/WireMock.Net.Extensions.Routing/Delegates/WireMockMiddleware.cs b/src/WireMock.Net.Extensions.Routing/Delegates/WireMockMiddleware.cs new file mode 100644 index 00000000..2ef19d2e --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Delegates/WireMockMiddleware.cs @@ -0,0 +1,10 @@ +// Copyright © WireMock.Net + +namespace WireMock.Net.Extensions.Routing.Delegates; + +/// +/// Represents a middleware component for WireMock.Net HTTP request handling. +/// +/// The next request handler in the middleware pipeline. +/// A that processes the request. +public delegate WireMockHttpRequestHandler WireMockMiddleware(WireMockHttpRequestHandler next); diff --git a/src/WireMock.Net.Extensions.Routing/Extensions/DictionaryExtensions.cs b/src/WireMock.Net.Extensions.Routing/Extensions/DictionaryExtensions.cs new file mode 100644 index 00000000..b3db898a --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Extensions/DictionaryExtensions.cs @@ -0,0 +1,19 @@ +// Copyright © WireMock.Net + +using System.Collections.Immutable; + +namespace WireMock.Net.Extensions.Routing.Extensions; + +internal static class DictionaryExtensions +{ + public static IDictionary AddIf( + this IDictionary source, + bool condition, + TKey key, + TValue value, + IEqualityComparer? keyComparer = null) + where TKey : notnull => + condition + ? source.ToImmutableDictionary(keyComparer).Add(key, value) + : source; +} diff --git a/src/WireMock.Net.Extensions.Routing/Extensions/HttpResponseExtensions.cs b/src/WireMock.Net.Extensions.Routing/Extensions/HttpResponseExtensions.cs new file mode 100644 index 00000000..011b1b7e --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Extensions/HttpResponseExtensions.cs @@ -0,0 +1,34 @@ +// Copyright © WireMock.Net + +using Microsoft.AspNetCore.Http; +using WireMock.Types; +using WireMock.Util; + +namespace WireMock.Net.Extensions.Routing.Extensions; + +internal static class HttpResponseExtensions +{ + public static async Task ToResponseMessageAsync( + this HttpResponse response) + { + var headers = response.Headers.ToDictionary( + header => header.Key, header => new WireMockList(header.Value.ToArray())); + return new() + { + Headers = headers!, + BodyData = new BodyData + { + DetectedBodyType = BodyType.String, + BodyAsString = await response.ReadBodyAsStringAsync(), + }, + StatusCode = response.StatusCode, + }; + } + + public static async Task ReadBodyAsStringAsync(this HttpResponse response) + { + response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(response.Body); + return await reader.ReadToEndAsync(); + } +} diff --git a/src/WireMock.Net.Extensions.Routing/Extensions/RequestMessageExtensions.cs b/src/WireMock.Net.Extensions.Routing/Extensions/RequestMessageExtensions.cs new file mode 100644 index 00000000..f0f13d84 --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Extensions/RequestMessageExtensions.cs @@ -0,0 +1,16 @@ +// Copyright © WireMock.Net + +using JsonConverter.Abstractions; + +namespace WireMock.Net.Extensions.Routing.Extensions; + +internal static class RequestMessageExtensions +{ + public static T? GetBodyAsJson( + this IRequestMessage requestMessage, + IJsonConverter jsonConverter, + JsonConverterOptions? jsonOptions = null) => + requestMessage.Body is not null + ? jsonConverter.Deserialize(requestMessage.Body, jsonOptions) + : default; +} diff --git a/src/WireMock.Net.Extensions.Routing/Extensions/StringExtensions.cs b/src/WireMock.Net.Extensions.Routing/Extensions/StringExtensions.cs new file mode 100644 index 00000000..d55adf19 --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Extensions/StringExtensions.cs @@ -0,0 +1,9 @@ +// Copyright © WireMock.Net + +namespace WireMock.Net.Extensions.Routing.Extensions; + +internal static class StringExtensions +{ + public static string ToMatchFullStringRegex(this string regex) => + $"^{regex}$"; +} diff --git a/src/WireMock.Net.Extensions.Routing/Extensions/TaskExtensions.cs b/src/WireMock.Net.Extensions.Routing/Extensions/TaskExtensions.cs new file mode 100644 index 00000000..308fef3b --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Extensions/TaskExtensions.cs @@ -0,0 +1,39 @@ +// Copyright © WireMock.Net + +using System.Reflection; + +namespace WireMock.Net.Extensions.Routing.Extensions; + +internal static class TaskExtensions +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "VSTHRD003:Avoid awaiting foreign Tasks", + Justification = "Await is required here to transform base task to generic one.")] + public static async Task ToGenericTaskAsync(this Task task) + { + await task; + var taskType = task.GetType(); + if (!IsAssignableToGenericTaskType(taskType)) + { + return null; + } + + return task + .GetType() + .GetProperty("Result", BindingFlags.Instance | BindingFlags.Public)! + .GetValue(task); + } + + private static bool IsAssignableToGenericTaskType(Type type) + { + if (type.IsGenericType && + type.GetGenericTypeDefinition() == typeof(Task<>) && + type.GetGenericArguments()[0] != Type.GetType("System.Threading.Tasks.VoidTaskResult")) + { + return true; + } + + return type.BaseType is not null && IsAssignableToGenericTaskType(type.BaseType); + } +} diff --git a/src/WireMock.Net.Extensions.Routing/Extensions/WireMockHttpRequestHandlerExtensions.cs b/src/WireMock.Net.Extensions.Routing/Extensions/WireMockHttpRequestHandlerExtensions.cs new file mode 100644 index 00000000..0f6589dd --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Extensions/WireMockHttpRequestHandlerExtensions.cs @@ -0,0 +1,17 @@ +// Copyright © WireMock.Net + +using WireMock.Net.Extensions.Routing.Delegates; + +namespace WireMock.Net.Extensions.Routing.Extensions; + +internal static class WireMockHttpRequestHandlerExtensions +{ + public static WireMockHttpRequestHandler UseMiddleware( + this WireMockHttpRequestHandler handler, WireMockMiddleware middleware) => + middleware(handler); + + public static WireMockHttpRequestHandler UseMiddlewareCollection( + this WireMockHttpRequestHandler handler, + IReadOnlyCollection middlewareCollection) => + middlewareCollection.Aggregate(handler, UseMiddleware); +} diff --git a/src/WireMock.Net.Extensions.Routing/Extensions/WireMockRouterExtensions.cs b/src/WireMock.Net.Extensions.Routing/Extensions/WireMockRouterExtensions.cs new file mode 100644 index 00000000..99d57e51 --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Extensions/WireMockRouterExtensions.cs @@ -0,0 +1,224 @@ +// Copyright © WireMock.Net + +using JsonConverter.Abstractions; +using WireMock.Net.Extensions.Routing.Models; + +namespace WireMock.Net.Extensions.Routing.Extensions; + +/// +/// Provides extension methods for mapping HTTP routes to handlers in . +/// +public static class WireMockRouterExtensions +{ + /// + /// Maps a GET request to a synchronous request handler. + /// + /// The router to extend. + /// The route pattern. + /// The request handler function. + /// The current instance. + public static WireMockRouter MapGet( + this WireMockRouter source, + string pattern, + Func requestHandler) => + source.Map(HttpMethod.Get.Method, pattern, requestHandler); + + /// + /// Maps a GET request to an asynchronous request handler. + /// + /// The router to extend. + /// The route pattern. + /// The asynchronous request handler function. + /// The current instance. + public static WireMockRouter MapGet( + this WireMockRouter source, + string pattern, + Func> requestHandler) => + source.Map(HttpMethod.Get.Method, pattern, requestHandler); + + /// + /// Maps a POST request to a synchronous request handler. + /// + /// The router to extend. + /// The route pattern. + /// The request handler function. + /// The current instance. + public static WireMockRouter MapPost( + this WireMockRouter source, + string pattern, + Func requestHandler) => + source.Map(HttpMethod.Post.Method, pattern, requestHandler); + + /// + /// Maps a POST request to an asynchronous request handler. + /// + /// The router to extend. + /// The route pattern. + /// The asynchronous request handler function. + /// The current instance. + public static WireMockRouter MapPost( + this WireMockRouter source, + string pattern, + Func> requestHandler) => + source.Map(HttpMethod.Post.Method, pattern, requestHandler); + + /// + /// Maps a POST request to a synchronous request handler with a typed body. + /// + /// The type of the request body. + /// The router to extend. + /// The route pattern. + /// The request handler function. + /// The [optional]. Default value is NewtonsoftJsonConverter. + /// The [optional]. + /// The current instance. + public static WireMockRouter MapPost( + this WireMockRouter source, + string pattern, + Func, object?> requestHandler, + IJsonConverter? jsonConverter = null, + JsonConverterOptions? jsonOptions = null) => + source.Map(HttpMethod.Post.Method, pattern, requestHandler, jsonConverter, jsonOptions); + + /// + /// Maps a POST request to an asynchronous request handler with a typed body. + /// + /// The type of the request body. + /// The router to extend. + /// The route pattern. + /// The asynchronous request handler function. + /// The [optional]. Default value is NewtonsoftJsonConverter. + /// The [optional]. + /// The current instance. + public static WireMockRouter MapPost( + this WireMockRouter source, + string pattern, + Func, Task> requestHandler, + IJsonConverter? jsonConverter = null, + JsonConverterOptions? jsonOptions = null) => + source.Map(HttpMethod.Post.Method, pattern, requestHandler, jsonConverter, jsonOptions); + + /// + /// Maps a PUT request to a synchronous request handler. + /// + /// The router to extend. + /// The route pattern. + /// The request handler function. + /// The current instance. + public static WireMockRouter MapPut( + this WireMockRouter source, + string pattern, + Func requestHandler) => + source.Map(HttpMethod.Put.Method, pattern, requestHandler); + + /// + /// Maps a PUT request to an asynchronous request handler. + /// + /// The router to extend. + /// The route pattern. + /// The asynchronous request handler function. + /// The current instance. + public static WireMockRouter MapPut( + this WireMockRouter source, + string pattern, + Func> requestHandler) => + source.Map(HttpMethod.Put.Method, pattern, requestHandler); + + /// + /// Maps a PUT request to a synchronous request handler with a typed body. + /// + /// The type of the request body. + /// The router to extend. + /// The route pattern. + /// The request handler function. + /// The [optional]. Default value is NewtonsoftJsonConverter. + /// The [optional]. + /// The current instance. + public static WireMockRouter MapPut( + this WireMockRouter source, + string pattern, + Func, object?> requestHandler, + IJsonConverter? jsonConverter = null, + JsonConverterOptions? jsonOptions = null) => + source.Map(HttpMethod.Put.Method, pattern, requestHandler, jsonConverter, jsonOptions); + + /// + /// Maps a PUT request to an asynchronous request handler with a typed body. + /// + /// The type of the request body. + /// The router to extend. + /// The route pattern. + /// The asynchronous request handler function. + /// The [optional]. Default value is NewtonsoftJsonConverter. + /// The [optional]. + /// The current instance. + public static WireMockRouter MapPut( + this WireMockRouter source, + string pattern, + Func, Task> requestHandler, + IJsonConverter? jsonConverter = null, + JsonConverterOptions? jsonOptions = null) => + source.Map(HttpMethod.Put.Method, pattern, requestHandler, jsonConverter, jsonOptions); + + /// + /// Maps a DELETE request to a synchronous request handler. + /// + /// The router to extend. + /// The route pattern. + /// The request handler function. + /// The current instance. + public static WireMockRouter MapDelete( + this WireMockRouter source, + string pattern, + Func requestHandler) => + source.Map(HttpMethod.Delete.Method, pattern, requestHandler); + + /// + /// Maps a DELETE request to an asynchronous request handler. + /// + /// The router to extend. + /// The route pattern. + /// The asynchronous request handler function. + /// The current instance. + public static WireMockRouter MapDelete( + this WireMockRouter source, + string pattern, + Func> requestHandler) => + source.Map(HttpMethod.Delete.Method, pattern, requestHandler); + + /// + /// Maps a DELETE request to a synchronous request handler with a typed body. + /// + /// The type of the request body. + /// The router to extend. + /// The route pattern. + /// The request handler function. + /// The [optional]. Default value is NewtonsoftJsonConverter. + /// The [optional]. + /// The current instance. + public static WireMockRouter MapDelete( + this WireMockRouter source, + string pattern, + Func, object?> requestHandler, + IJsonConverter? jsonConverter = null, + JsonConverterOptions? jsonOptions = null) => + source.Map(HttpMethod.Delete.Method, pattern, requestHandler, jsonConverter, jsonOptions); + + /// + /// Maps a DELETE request to an asynchronous request handler with a typed body. + /// + /// The type of the request body. + /// The router to extend. + /// The route pattern. + /// The asynchronous request handler function. + /// The [optional]. Default value is NewtonsoftJsonConverter. + /// The [optional]. + /// The current instance. + public static WireMockRouter MapDelete( + this WireMockRouter source, + string pattern, + Func, Task> requestHandler, + IJsonConverter? jsonConverter = null, + JsonConverterOptions? jsonOptions = null) => + source.Map(HttpMethod.Delete.Method, pattern, requestHandler, jsonConverter, jsonOptions); +} diff --git a/src/WireMock.Net.Extensions.Routing/Extensions/WireMockServerExtensions.cs b/src/WireMock.Net.Extensions.Routing/Extensions/WireMockServerExtensions.cs new file mode 100644 index 00000000..6919d370 --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Extensions/WireMockServerExtensions.cs @@ -0,0 +1,35 @@ +// Copyright © WireMock.Net + +using WireMock.Matchers; +using WireMock.Net.Extensions.Routing.Delegates; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace WireMock.Net.Extensions.Routing.Extensions; + +/// +/// Provides extension methods for mapping HTTP requests to handlers in . +/// +public static class WireMockServerExtensions +{ + /// + /// Maps a request to a WireMock.Net server using the specified method, path matcher, and request handler. + /// + /// The WireMock.Net server to extend. + /// The HTTP method to match. + /// The matcher for the request path. + /// The handler to process the request. + /// The current instance. + public static WireMockServer Map( + this WireMockServer source, + string method, + IStringMatcher pathMatcher, + WireMockHttpRequestHandler httpRequestHandler) + { + source + .Given(Request.Create().WithPath(pathMatcher).UsingMethod(method)) + .RespondWith(Response.Create().WithCallback(req => httpRequestHandler(req))); + return source; + } +} diff --git a/src/WireMock.Net.Extensions.Routing/Models/WireMockRequestInfo.cs b/src/WireMock.Net.Extensions.Routing/Models/WireMockRequestInfo.cs new file mode 100644 index 00000000..feae2443 --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Models/WireMockRequestInfo.cs @@ -0,0 +1,29 @@ +// Copyright © WireMock.Net + +namespace WireMock.Net.Extensions.Routing.Models; + +/// +/// Represents request information for WireMock.Net routing, including the request message and route arguments. +/// +public class WireMockRequestInfo +{ + /// + /// Initializes a new instance of the class. + /// + /// The incoming request message. + public WireMockRequestInfo(IRequestMessage request) + { + Request = request; + } + + /// + /// Gets the incoming request message. + /// + public IRequestMessage Request { get; } + + /// + /// Gets or initializes the route arguments extracted from the request path. + /// + public IDictionary RouteArgs { get; init; } = + new Dictionary(); +} diff --git a/src/WireMock.Net.Extensions.Routing/Models/WireMockRequestInfo{TBody}.cs b/src/WireMock.Net.Extensions.Routing/Models/WireMockRequestInfo{TBody}.cs new file mode 100644 index 00000000..e803a3cb --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Models/WireMockRequestInfo{TBody}.cs @@ -0,0 +1,24 @@ +// Copyright © WireMock.Net + +namespace WireMock.Net.Extensions.Routing.Models; + +/// +/// Represents request information with a strongly-typed deserialized body for WireMock.Net routing. +/// +/// The type of the deserialized request body. +public sealed class WireMockRequestInfo : WireMockRequestInfo +{ + /// + /// Initializes a new instance of the class. + /// + /// The incoming request message. + public WireMockRequestInfo(IRequestMessage request) + : base(request) + { + } + + /// + /// Gets or initializes the deserialized request body. + /// + public TBody? Body { get; init; } +} diff --git a/src/WireMock.Net.Extensions.Routing/README.md b/src/WireMock.Net.Extensions.Routing/README.md new file mode 100644 index 00000000..d92db78b --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/README.md @@ -0,0 +1,138 @@ +# WireMock.Net.Extensions.Routing + +**WireMock.Net.Extensions.Routing** extends [WireMock.Net](https://github.com/wiremock/wiremock) with modern, minimal-API-style routing for .NET. It provides extension methods for expressive, maintainable, and testable HTTP routing, inspired by [ASP.NET Core Minimal APIs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-9.0). + +--- + +## Motivation + +While [WireMock.Net](https://github.com/WireMock-Net/WireMock.Net) is a powerful tool for HTTP mocking in .NET, its native API for defining routes and request handlers can be verbose and require significant boilerplate. Setting up even simple endpoints often involves multiple chained method calls, manual parsing of request data, and repetitive configuration, which can make tests harder to read and maintain. + +**WireMock.Net.Extensions.Routing** addresses these pain points by introducing a concise, fluent, and minimal-API-inspired approach to routing. This makes your test code: + +- **More readable:** Route definitions are clear and expressive, closely resembling production minimal APIs. +- **Easier to maintain:** Less boilerplate means fewer places for errors and easier refactoring. +- **Faster to write:** Define routes and handlers in a single line, with strong typing and async support. + +### Example: Native WireMock.Net vs. WireMock.Net.Extensions.Routing + +#### Native WireMock.Net + +```csharp +server.Given( + Request.Create().WithPath("/hello").UsingGet() +) +.RespondWith( + Response.Create().WithBody("Hello, world!") +); + +server.Given( + Request.Create().WithPath("/user/*").UsingGet() +) +.RespondWith( + Response.Create().WithCallback(request => + { + var id = request.PathSegments[1]; + // ...fetch user by id... + return new ResponseMessage { Body = $"User: {id}" }; + }) +); +``` + +#### With WireMock.Net.Extensions.Routing + +```csharp +router.MapGet("/hello", _ => "Hello, world!"); + +router.MapGet("/user/{id:int}", requestInfo => +{ + var id = requestInfo.RouteArgs["id"]; + // ...fetch user by id... + return $"User: {id}"; +}); +``` + +With **WireMock.Net.Extensions.Routing**, you get: + +- Minimal, one-line route definitions +- Typed route parameters (e.g., `{id:int}`) +- Direct access to parsed route arguments and request bodies +- Async handler support + +This leads to more maintainable, scalable, and production-like test code. + +--- + +## Features + +- Minimal API-style route definitions for WireMock.Net +- Strongly-typed request handling +- Routing parameters with constraints (`int` and `string` are currently supported) +- Asynchronous handlers +- Fluent, composable routing extensions +- Easy integration with existing WireMock.Net servers +- .NET 8+ support + +--- + +## Installation + +Install from NuGet: + +```shell +dotnet add package WireMock.Net.Extensions.Routing +``` + +--- + +## Quick Start + +```csharp +using System.Net.Http.Json; +using WireMock.Net.Extensions.Routing; +using WireMock.Net.Extensions.Routing.Extensions; +using WireMock.Server; + +var server = WireMockServer.Start(); +var router = new WireMockRouter(server); + +router.MapGet("/hello", _ => "Hello, world!"); + +using var client = server.CreateClient(); +var result = await client.GetFromJsonAsync("/hello"); +// Hello, world! +``` + +--- + +## Usage + +### Routing with route parameters + +```csharp +router.MapGet("/user/{id:int}", async requestInfo => { + var userId = requestInfo.RouteArgs["id"]; + // var user = await ... + return user; +}); +``` + +### Strongly-Typed Request Info + +```csharp +router.MapPost("/api/items", requestInfo => { + var item = requestInfo.Body!; + // process item + return Results.Json(new { success = true }); +}); +``` + +### Supported Methods + +- `MapGet`, `MapPost`, `MapPut`, `MapDelete` +--- + +## Documentation + +- [API Reference](./src/WireMock.Net.Extensions.Routing/) +- [WireMock.Net Documentation](https://github.com/WireMock-Net/WireMock.Net) diff --git a/src/WireMock.Net.Extensions.Routing/Utils/RoutePattern.cs b/src/WireMock.Net.Extensions.Routing/Utils/RoutePattern.cs new file mode 100644 index 00000000..fdce8ffc --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/Utils/RoutePattern.cs @@ -0,0 +1,107 @@ +// Copyright © WireMock.Net + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.RegularExpressions; +using WireMock.Net.Extensions.Routing.Extensions; + +namespace WireMock.Net.Extensions.Routing.Utils; + +internal static class RoutePattern +{ + private static readonly Regex ArgRegex = + new(@"{(?'name'\w+)(?::(?'type'\w+))?}", RegexOptions.Compiled); + + public static IDictionary GetArgs(string pattern, string route) => + TryGetArgs(pattern, route, out var args) + ? args + : throw new InvalidOperationException( + $"Url {route} does not match route pattern {pattern}"); + + public static bool TryGetArgs( + string pattern, string route, [NotNullWhen(true)] out IDictionary? args) + { + var regex = new Regex(ToRegex(pattern), RegexOptions.IgnoreCase); + var match = regex.Match(route); + if (!match.Success) + { + args = null; + return false; + } + + var routeArgTypeMap = GetArgTypeMap(pattern); + args = match.Groups + .Cast() + .Where(g => g.Index > 0) + .ToDictionary(g => g.Name, g => routeArgTypeMap[g.Name].Parse(g.Value)); + return true; + } + + public static string ToRegex(string pattern) + { + return ArgRegex + .Replace(pattern, m => $"(?'{m.Groups["name"].Value}'{GetArgMatchingRegex(m)})") + .ToMatchFullStringRegex(); + + static string GetArgMatchingRegex(Match match) => + ArgType.GetByName(match.Groups["type"].Value).GetRegex(); + } + + private static IDictionary GetArgTypeMap(string pattern) => + ArgRegex + .Matches(pattern) + .ToDictionary( + m => m.Groups["name"].Value, m => ArgType.GetByName(m.Groups["type"].Value)); + + private abstract record ArgType + { + private ArgType(string name) + { + Name = name; + } + + public string Name { get; } + + public static ArgType String { get; } = new StringArgType(); + + public static ArgType Int { get; } = new IntArgType(); + + private static IReadOnlyCollection All { get; } = + typeof(ArgType) + .GetProperties(BindingFlags.Public | BindingFlags.Static) + .Where(p => p.PropertyType.IsAssignableTo(typeof(ArgType))) + .Select(p => p.GetValue(null)) + .Cast() + .ToList(); + + private static IReadOnlyDictionary MapByName { get; } = + All.ToDictionary(x => x.Name); + + public static ArgType GetByName(string name) => + GetByNameOrDefault(name) + ?? throw new InvalidOperationException($"Route argument type {name} is not found"); + + public static ArgType? GetByNameOrDefault(string name) => + !string.IsNullOrEmpty(name) + ? MapByName.GetValueOrDefault(name) + : String; + + public abstract object Parse(string input); + + public abstract string GetRegex(); + + private sealed record StringArgType() : ArgType("string") + { + public override object Parse(string input) => input; + + public override string GetRegex() => @".*"; + } + + private sealed record IntArgType() : ArgType("int") + { + public override object Parse(string input) => int.Parse(input); + + public override string GetRegex() => @"-?\d+"; + } + } +} diff --git a/src/WireMock.Net.Extensions.Routing/WireMock.Net.Extensions.Routing.csproj b/src/WireMock.Net.Extensions.Routing/WireMock.Net.Extensions.Routing.csproj new file mode 100644 index 00000000..1c0e7702 --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/WireMock.Net.Extensions.Routing.csproj @@ -0,0 +1,30 @@ + + + WireMock.Net.Routing extends WireMock.Net with modern, minimal-API-style routing for .NET + Gennadii Saltyshchak + net8.0 + true + tdd;mock;http;wiremock;test;server;unittest;routing;minimalapi + true + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + true + true + ../WireMock.Net/WireMock.Net.snk + true + README.md + enable + true + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/WireMock.Net.Extensions.Routing/WireMockRouter.cs b/src/WireMock.Net.Extensions.Routing/WireMockRouter.cs new file mode 100644 index 00000000..8661e314 --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/WireMockRouter.cs @@ -0,0 +1,153 @@ +// Copyright © WireMock.Net + +using JsonConverter.Abstractions; +using JsonConverter.Newtonsoft.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using WireMock.Matchers; +using WireMock.Net.Extensions.Routing.Delegates; +using WireMock.Net.Extensions.Routing.Extensions; +using WireMock.Net.Extensions.Routing.Models; +using WireMock.Net.Extensions.Routing.Utils; +using WireMock.Server; + +namespace WireMock.Net.Extensions.Routing; + +/// +/// Provides routing and request mapping functionality for WireMock.Net, +/// mimicking ASP.NET Core Minimal APIs routing style. +/// +/// +/// Initializes a new instance of the class. +/// +/// The WireMock.Net server instance. +public sealed class WireMockRouter(WireMockServer server) +{ + private readonly WireMockServer _server = server; + + /// + /// Gets or initializes the collection of middleware for the router. + /// + public IReadOnlyCollection MiddlewareCollection { get; init; } = []; + + /// + /// Gets or initializes the default [optional]. + /// + public IJsonConverter? DefaultJsonConverter { get; init; } + + /// + /// Gets or initializes the default [optional]. + /// + public JsonConverterOptions? DefaultJsonOptions { get; init; } + + /// + /// Maps a route to a synchronous request handler. + /// + /// The HTTP method. + /// The route pattern. + /// The request handler function. + /// The current instance. + public WireMockRouter Map( + string method, string pattern, Func requestHandler) + { + return Map(method, pattern, CreateResponse); + + object? CreateResponse(IRequestMessage request) => + requestHandler(CreateRequestInfo(request, pattern)); + } + + /// + /// Maps a route to an asynchronous request handler. + /// + /// The HTTP method. + /// The route pattern. + /// The asynchronous request handler function. + /// The current instance. + public WireMockRouter Map( + string method, string pattern, Func> requestHandler) + { + return Map(method, pattern, CreateResponseAsync); + + Task CreateResponseAsync(IRequestMessage request) => + requestHandler(CreateRequestInfo(request, pattern)); + } + + /// + /// Maps a route to a request handler with a typed body. + /// + /// The type of the request body. + /// The HTTP method. + /// The route pattern. + /// The request handler function. + /// The [optional]. Default value is NewtonsoftJsonConverter. + /// The [optional]. + /// The current instance. + public WireMockRouter Map( + string method, + string pattern, + Func, object?> requestHandler, + IJsonConverter? jsonConverter = null, + JsonConverterOptions? jsonOptions = null) + { + return Map(method, pattern, CreateBody); + + object? CreateBody(IRequestMessage request) => + requestHandler(CreateRequestInfo(request, pattern, jsonConverter, jsonOptions)); + } + + private static WireMockRequestInfo CreateRequestInfo(IRequestMessage request, string pattern) => + new(request) + { + RouteArgs = RoutePattern.GetArgs(pattern, request.Path), + }; + + private static WireMockHttpRequestHandler CreateHttpRequestHandler( + Func requestHandler) => + request => CreateResponseMessageAsync(requestHandler(request)); + + private static async Task CreateResponseMessageAsync(object? response) + { + var awaitedResponse = response is Task task + ? await task.ToGenericTaskAsync() + : response; + var result = awaitedResponse as IResult ?? Results.Ok(awaitedResponse); + var httpContext = CreateHttpContext(); + await result.ExecuteAsync(httpContext); + return await httpContext.Response.ToResponseMessageAsync(); + } + + private static HttpContext CreateHttpContext() => + new DefaultHttpContext + { + RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(), + Response = { Body = new MemoryStream() }, + }; + + private WireMockRequestInfo CreateRequestInfo( + IRequestMessage request, + string pattern, + IJsonConverter? jsonConverter = null, + JsonConverterOptions? jsonOptions = null) + { + var requestInfo = CreateRequestInfo(request, pattern); + var establishedJsonConverter = + jsonConverter ?? DefaultJsonConverter ?? new NewtonsoftJsonConverter(); + var establishedJsonOptions = jsonOptions ?? DefaultJsonOptions; + return new WireMockRequestInfo(requestInfo.Request) + { + RouteArgs = requestInfo.RouteArgs, + Body = requestInfo.Request.GetBodyAsJson( + establishedJsonConverter, establishedJsonOptions), + }; + } + + private WireMockRouter Map( + string method, string pattern, Func requestHandler) + { + var matcher = new RegexMatcher(RoutePattern.ToRegex(pattern), ignoreCase: true); + var httpRequestHandler = + CreateHttpRequestHandler(requestHandler).UseMiddlewareCollection(MiddlewareCollection); + _server.Map(method, matcher, httpRequestHandler); + return this; + } +} diff --git a/src/WireMock.Net.Extensions.Routing/WireMockServerRouterBuilder.cs b/src/WireMock.Net.Extensions.Routing/WireMockServerRouterBuilder.cs new file mode 100644 index 00000000..c67f2996 --- /dev/null +++ b/src/WireMock.Net.Extensions.Routing/WireMockServerRouterBuilder.cs @@ -0,0 +1,73 @@ +// Copyright © WireMock.Net + +using System.Collections.Concurrent; +using JsonConverter.Abstractions; +using WireMock.Net.Extensions.Routing.Delegates; +using WireMock.Server; + +namespace WireMock.Net.Extensions.Routing; + +/// +/// Provides a builder for configuring and creating a with middleware and JSON settings. +/// +/// +/// Initializes a new instance of the class. +/// +/// The WireMock.Net server instance. +public sealed class WireMockServerRouterBuilder(WireMockServer server) +{ + private readonly WireMockServer _server = server; + + private readonly ConcurrentQueue _middlewareCollection = new(); + + private IJsonConverter? _defaultJsonConverter; + + private JsonConverterOptions? _defaultJsonOptions; + + /// + /// Builds a with the configured middleware and JSON settings. + /// + /// The configured . + public WireMockRouter Build() => + new(_server) + { + MiddlewareCollection = _middlewareCollection, + DefaultJsonConverter = _defaultJsonConverter, + DefaultJsonOptions = _defaultJsonOptions, + }; + + /// + /// Adds a middleware to the router builder. + /// + /// The middleware to add. + /// The current instance. + public WireMockServerRouterBuilder Use(WireMockMiddleware middleware) + { + _middlewareCollection.Enqueue(middleware); + return this; + } + + /// + /// Sets the default . + /// + /// the default + /// The current instance. + public WireMockServerRouterBuilder WithDefaultJsonConverter( + IJsonConverter? defaultJsonConverter) + { + _defaultJsonConverter = defaultJsonConverter; + return this; + } + + /// + /// Sets the default [optional]. + /// + /// the default [optional] + /// The current instance. + public WireMockServerRouterBuilder WithDefaultJsonOptions( + JsonConverterOptions? defaultJsonOptions) + { + _defaultJsonOptions = defaultJsonOptions; + return this; + } +} diff --git a/test/WireMock.Net.Extensions.Routing.Tests/Tests/WireMockRouterTests.cs b/test/WireMock.Net.Extensions.Routing.Tests/Tests/WireMockRouterTests.cs new file mode 100644 index 00000000..9f784c81 --- /dev/null +++ b/test/WireMock.Net.Extensions.Routing.Tests/Tests/WireMockRouterTests.cs @@ -0,0 +1,100 @@ +// Copyright © WireMock.Net + +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using AwesomeAssertions; +using WireMock.Net.Extensions.Routing.Extensions; +using WireMock.Server; + +namespace WireMock.Net.Extensions.Routing.Tests.Tests; + +public sealed class WireMockRouterTests +{ + private const string DefaultUrlPattern = "/test"; + + private readonly WireMockServer _server = WireMockServer.Start(); + private readonly WireMockRouter _sut; + + public WireMockRouterTests() + { + _sut = new WireMockRouter(_server); + } + + [Fact] + public async Task Map_ShouldReturnResultFromHandler_ForGetMethod() + { + const int handlerResult = 5; + _sut.Map(HttpMethod.Get.ToString(), DefaultUrlPattern, _ => handlerResult); + using var client = _server.CreateClient(); + + var result = await client.GetFromJsonAsync(DefaultUrlPattern); + + result.Should().Be(handlerResult); + } + + [Fact] + public async Task Map_ShouldReturnResultFromAsyncHandler_ForGetMethod() + { + const int handlerResult = 5; + _sut.Map(HttpMethod.Get.ToString(), DefaultUrlPattern, _ => Task.FromResult(handlerResult)); + using var client = _server.CreateClient(); + + var result = await client.GetFromJsonAsync(DefaultUrlPattern); + + result.Should().Be(handlerResult); + } + + [Fact] + public async Task Map_ShouldReturnResultFromAsyncHandlerWithAwait_ForGetMethod() + { + const int handlerResult = 5; + _sut.Map( + HttpMethod.Get.ToString(), + DefaultUrlPattern, + async _ => await Task.FromResult(handlerResult)); + using var client = _server.CreateClient(); + + var result = await client.GetFromJsonAsync(DefaultUrlPattern); + + result.Should().Be(handlerResult); + } + + [Fact] + public async Task Map_ShouldReturnResultFromAsyncHandlerWithDelayAndAwait_ForGetMethod() + { + const int handlerResult = 5; + + async Task HandleRequestAsync() + { + await Task.Delay(1); + return handlerResult; + } + + _sut.Map(HttpMethod.Get.ToString(), DefaultUrlPattern, _ => HandleRequestAsync()); + using var client = _server.CreateClient(); + + var result = await client.GetFromJsonAsync(DefaultUrlPattern); + + result.Should().Be(handlerResult); + } + + [Fact] + public async Task MapGet_ShouldReturnResultFromAsyncHandlerWithDelayAwait() + { + const int handlerResult = 5; + + async Task HandleRequestAsync() + { + await Task.Delay(1); + return handlerResult; + } + + _sut.MapGet(DefaultUrlPattern, _ => HandleRequestAsync()); + using var client = _server.CreateClient(); + + var result = await client.GetFromJsonAsync(DefaultUrlPattern); + + result.Should().Be(handlerResult); + } +} diff --git a/test/WireMock.Net.Extensions.Routing.Tests/WireMock.Net.Extensions.Routing.Tests.csproj b/test/WireMock.Net.Extensions.Routing.Tests/WireMock.Net.Extensions.Routing.Tests.csproj new file mode 100644 index 00000000..cd5de199 --- /dev/null +++ b/test/WireMock.Net.Extensions.Routing.Tests/WireMock.Net.Extensions.Routing.Tests.csproj @@ -0,0 +1,37 @@ + + + Gennadii Saltyshchak + net8.0 + enable + false + full + true + true + true + true + ../../src/WireMock.Net/WireMock.Net.snk + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + +