Skip to content

Commit 06172d8

Browse files
authored
Add response routing (#187)
1 parent c9d562b commit 06172d8

30 files changed

+923
-38
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [0.9] - unplanned
8+
### Deprecated
9+
- `Responses.NoContent()` has been deprecated, since it doesn't fit well with the rest of the API. Please use `Responses.StatusCode(HttpStatusCode.NoContent)` instead.
10+
811
### Removed
912
- Official support for .NET Core 3.1 has been removed. This means we no longer provide a specific version for .NET Core 3.0 and we no longer test this version explicitly. Since we support .NET Standard 2.0, the library could still be used.
1013

14+
### Added
15+
- Added `Responses.Route` that allows changing the response based on the url. The url supports patterns.
16+
1117
## [0.8] - 2022-11-08
1218
### Deprecated
1319
- `TestableHttpMessageHandler.SimulateTimeout` is deprecated, and can be replaced with `RespondWith(Responses.Timeout())`.
Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
11
using System.Diagnostics.CodeAnalysis;
2-
using System.Runtime.Serialization;
32

43
namespace TestableHttpClient;
54

6-
[Serializable]
7-
[SuppressMessage("Design", "CA1032:Implement standard exception constructors", Justification = "Not intended for public usage, but could be used for catching.")]
5+
/// <summary>
6+
/// Exception thrown when the request assertion failed.
7+
/// </summary>
8+
[SuppressMessage("SonarSource", "S3925", Justification = "These exceptions don't need to be serialized.")]
89
public sealed class HttpRequestMessageAssertionException : Exception
910
{
10-
internal HttpRequestMessageAssertionException(string message) : base(message)
11+
/// <summary>
12+
/// Initializes a new instance of the <see cref="HttpRequestMessageAssertionException"/> class with the default error message.
13+
/// </summary>
14+
public HttpRequestMessageAssertionException()
15+
: this("Assertion failed.")
1116
{
1217
}
1318

14-
[ExcludeFromCodeCoverage]
15-
private HttpRequestMessageAssertionException(SerializationInfo info, StreamingContext context) : base(info, context)
19+
/// <summary>
20+
/// Initializes a new instance of the <see cref="HttpRequestMessageAssertionException"/> class with a specified error message.
21+
/// </summary>
22+
/// <param name="message">The error message that explains the reason for the exception.</param>
23+
public HttpRequestMessageAssertionException(string message) : base(message)
24+
{
25+
}
26+
27+
/// <summary>
28+
/// Initializes a new instance of the <see cref="HttpRequestMessageAssertionException"/>
29+
/// class with a specified error message and a reference to the inner exception that is the cause of
30+
/// this exception.
31+
/// </summary>
32+
/// <param name="message">The error message that explains the reason for the exception.</param>
33+
/// <param name="innerException">The exception that is the cause of the current exception.</param>
34+
public HttpRequestMessageAssertionException(string message, Exception innerException) : base(message, innerException)
1635
{
1736
}
1837
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace TestableHttpClient;
2+
3+
/// <summary>
4+
/// A builder for routing responses.
5+
/// </summary>
6+
public interface IRoutingResponseBuilder
7+
{
8+
/// <summary>
9+
/// Maps a route to a specified response.
10+
/// </summary>
11+
/// <param name="route">The route pattern.</param>
12+
/// <param name="response">The response the route should return.</param>
13+
/// <example>x.Map("*", Responses.StatusCode(HttpStatusCode.OK))</example>
14+
void Map(string route, IResponse response);
15+
/// <summary>
16+
/// Maps a custom response for when a request did't match any route. Defaults to Responses.StatusCode(HttpStatusCode.NotFound).
17+
/// </summary>
18+
/// <param name="fallBackResponse">The response that should be returned when no route matches.</param>
19+
void MapFallBackResponse(IResponse fallBackResponse);
20+
}
Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,20 @@
1-

1+
static TestableHttpClient.Responses.Route(System.Action<TestableHttpClient.IRoutingResponseBuilder!>! builder) -> TestableHttpClient.IResponse!
2+
TestableHttpClient.HttpRequestMessageAssertionException.HttpRequestMessageAssertionException() -> void
3+
TestableHttpClient.HttpRequestMessageAssertionException.HttpRequestMessageAssertionException(string! message) -> void
4+
TestableHttpClient.HttpRequestMessageAssertionException.HttpRequestMessageAssertionException(string! message, System.Exception! innerException) -> void
5+
TestableHttpClient.IRoutingResponseBuilder
6+
TestableHttpClient.IRoutingResponseBuilder.Map(string! route, TestableHttpClient.IResponse! response) -> void
7+
TestableHttpClient.IRoutingResponseBuilder.MapFallBackResponse(TestableHttpClient.IResponse! fallBackResponse) -> void
8+
TestableHttpClient.RoutingOptions
9+
TestableHttpClient.RoutingOptions.HostCaseInsensitive.get -> bool
10+
TestableHttpClient.RoutingOptions.HostCaseInsensitive.set -> void
11+
TestableHttpClient.RoutingOptions.PathCaseInsensitive.get -> bool
12+
TestableHttpClient.RoutingOptions.PathCaseInsensitive.set -> void
13+
TestableHttpClient.RoutingOptions.RoutingOptions() -> void
14+
TestableHttpClient.RoutingOptions.SchemeCaseInsensitive.get -> bool
15+
TestableHttpClient.RoutingOptions.SchemeCaseInsensitive.set -> void
16+
TestableHttpClient.TestableHttpMessageHandlerOptions.RoutingOptions.get -> TestableHttpClient.RoutingOptions!
17+
TestableHttpClient.Utils.RouteParserException
18+
TestableHttpClient.Utils.RouteParserException.RouteParserException() -> void
19+
TestableHttpClient.Utils.RouteParserException.RouteParserException(string! message) -> void
20+
TestableHttpClient.Utils.RouteParserException.RouteParserException(string! message, System.Exception! innerException) -> void
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using static System.Net.HttpStatusCode;
2+
using static TestableHttpClient.Responses;
3+
4+
namespace TestableHttpClient.Response;
5+
internal class RoutingResponse : IResponse
6+
{
7+
public Task ExecuteAsync(HttpResponseContext context, CancellationToken cancellationToken)
8+
{
9+
IResponse response = FallBackResponse;
10+
11+
if (context.HttpRequestMessage.RequestUri is not null)
12+
{
13+
response = GetResponseForRequest(context.HttpRequestMessage.RequestUri, context.Options.RoutingOptions);
14+
}
15+
16+
return response.ExecuteAsync(context, cancellationToken);
17+
}
18+
19+
public Dictionary<RouteDefinition, IResponse> ResponseMap { get; init; } = new();
20+
public IResponse FallBackResponse { get; internal set; } = StatusCode(NotFound);
21+
22+
private IResponse GetResponseForRequest(Uri requestUri, RoutingOptions routingOptions)
23+
{
24+
var matchingResponse = ResponseMap.FirstOrDefault(x => x.Key.Matches(requestUri, routingOptions));
25+
26+
return matchingResponse.Value switch
27+
{
28+
null => FallBackResponse,
29+
_ => matchingResponse.Value
30+
};
31+
}
32+
}

src/TestableHttpClient/Responses.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public static class Responses
5555
/// Create a response with the NoContent status code.
5656
/// </summary>
5757
/// <returns>An HttpResponse with the configured StatusCode.</returns>
58+
[Obsolete("Please use StatusCode(HttpStatusCode.NoContent) instead.")]
5859
public static IResponse NoContent() => StatusCode(HttpStatusCode.NoContent);
5960
/// <summary>
6061
/// Create a response with some text content.
@@ -80,6 +81,23 @@ public static class Responses
8081
/// <returns>A response with specific content.</returns>
8182
public static IResponse Json(object? content, HttpStatusCode statusCode, string? contentType = null, JsonSerializerOptions? jsonSerializerOptions = null) => new JsonResponse(content, contentType) { StatusCode = statusCode, JsonSerializerOptions = jsonSerializerOptions };
8283
/// <summary>
84+
/// Create a response for several routes.
85+
/// </summary>
86+
/// <param name="builder">The route builder that can be used to configure multiple routes.</param>
87+
/// <returns>A response with routing capabilities.</returns>
88+
/// <exception cref="ArgumentNullException">Thrown when the builder paramater is null.</exception>
89+
public static IResponse Route(Action<IRoutingResponseBuilder> builder)
90+
{
91+
if (builder is null)
92+
{
93+
throw new ArgumentNullException(nameof(builder));
94+
}
95+
96+
RoutingResponseBuilder routingResponseBuilder = new();
97+
builder(routingResponseBuilder);
98+
return routingResponseBuilder.RoutingResponse;
99+
}
100+
/// <summary>
83101
/// Entrypoint for extensions.
84102
/// </summary>
85103
public static IResponsesExtensions Extensions { get; } = new ResponseExtensions();
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace TestableHttpClient;
2+
3+
/// <summary>
4+
/// Options specific for routing.
5+
/// </summary>
6+
public class RoutingOptions
7+
{
8+
/// <summary>
9+
/// Indeicates whether or not the scheme of a route should be treated as case insensitive. Default: true
10+
/// </summary>
11+
public bool SchemeCaseInsensitive { get; set; } = true;
12+
/// <summary>
13+
/// Indeicates whether or not the host of a route should be treated as case insensitive. Default: true
14+
/// </summary>
15+
public bool HostCaseInsensitive { get; set; } = true;
16+
/// <summary>
17+
/// Indeicates whether or not the path of a route should be treated as case insensitive. Default: true
18+
/// </summary>
19+
public bool PathCaseInsensitive { get; set; } = true;
20+
}

src/TestableHttpClient/TestableHttpClient.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
1111
<PackageReference Include="System.Net.Http" Version="4.3.4" />
1212
<PackageReference Include="System.Text.Json" Version="4.6.0" />
13+
<PackageReference Include="IndexRange" Version="1.0.2" />
1314
</ItemGroup>
1415

1516
<ItemGroup>

src/TestableHttpClient/TestableHttpMessageHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
5656
/// <param name="response">The response that should be created.</param>
5757
/// <remarks>By default each request will receive a new response, however this is dependend on the implementation.</remarks>
5858
/// <example>
59-
/// testableHttpMessageHander.RespondWith(Responses.NoContent());
59+
/// testableHttpMessageHander.RespondWith(Responses.StatusCode(HttpStatusCode.OK));
6060
/// </example>
6161
public void RespondWith(IResponse response)
6262
{

src/TestableHttpClient/TestableHttpMessageHandlerOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ public sealed class TestableHttpMessageHandlerOptions
77
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
88
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
99
};
10+
11+
public RoutingOptions RoutingOptions { get; } = new RoutingOptions();
1012
}

0 commit comments

Comments
 (0)