Skip to content

Commit

Permalink
Support matching request content
Browse files Browse the repository at this point in the history
  • Loading branch information
sandermvanvliet committed Apr 15, 2024
1 parent 6413070 commit 53d29b3
Show file tree
Hide file tree
Showing 13 changed files with 211 additions and 13 deletions.
20 changes: 20 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Codenizer.HttpClient.Testable Changelog

## 2.9.0.0

Add support to configure expected request content.
This can be particularly useful if you want to match a route for a specific request body.

For example:

```csharp
handler
.RespondTo()
.Post()
.ForUrl("/search")
.ForContent(@"{""params"":{""foo"":""bar""}}")
.With(HttpStatusCode.OK);
```

will only match when the request body is _exactly_ `{"params":{"foo":"bar"}}`.

Currently only string content is supported.

## 2.8.0.0

Add support to supply a lambda to create the response that's sent to the client. It allows you to generate a more dynamic response if the path you're configuring requires that.
Expand Down
2 changes: 2 additions & 0 deletions Codenizer.HttpClient.Testable.v3.ncrunchsolution
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<SolutionConfiguration>
<Settings>
<AllowParallelTestExecution>True</AllowParallelTestExecution>
<EnableRDI>False</EnableRDI>
<RdiConfigured>True</RdiConfigured>
<SolutionConfigured>True</SolutionConfigured>
</Settings>
</SolutionConfiguration>
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

<Project>
<PropertyGroup>
<Version>2.8.0.0</Version>
<Version>2.9.0.0</Version>
<Authors>Sander van Vliet</Authors>
<Company>Codenizer BV</Company>
<Copyright>2024 Sander van Vliet</Copyright>
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,34 @@ await httpClient.PostAsync("/api/infos", new StringContent("test", Encoding.ASCI

the response returned from the handler will be `415 Unsupported Media Type`

### Handle requests only if they match the request body

For some tests you might want to configure the handler to return certain responses based on the request content.

To do this you can configure the handler like so:

```csharp
handler
.RespondTo()
.Post()
.ForUrl("/search")
.ForContent(@"{""params"":{""match"":""bar""}}")
.With(HttpStatusCode.BadRequest);
```

and a second one like so:

```csharp
handler
.RespondTo()
.Post()
.ForUrl("/search")
.ForContent(@"{""params"":{""match"":false}}")
.With(HttpStatusCode.OK);
```

Here you can see that the same endpoint returns two different responses based on the request content.

### A sequence of responses

In some cases you might want to have multiple responses configured for the same endpoint, for example when you call a status endpoint of a job.
Expand Down
5 changes: 5 additions & 0 deletions src/Codenizer.HttpClient.Testable/ConfigurationDumpVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,10 @@ public override void Response(RequestBuilder requestBuilder)
}
}
}

public override void Content(string expectedContent)
{
_indentedWriter.WriteLine($"Content: {expectedContent}");
}
}
}
8 changes: 6 additions & 2 deletions src/Codenizer.HttpClient.Testable/ConfiguredRequests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ public ConfiguredRequests(IEnumerable<RequestBuilder> requestBuilders)

var headersNode = queryNode.Add(headers);

headersNode.SetRequestBuilder(requestBuilder);
var contentNode = headersNode.Add(requestBuilder.ExpectedContent);

contentNode.SetRequestBuilder(requestBuilder);

Count++;
}
Expand Down Expand Up @@ -124,7 +126,9 @@ private static void ThrowIfRouteIsNotFullyConfigured(RequestBuilder route)

var headersNode = queryNode.Match(httpRequestMessage.Headers);

return headersNode?.RequestBuilder;
var contentNode = headersNode?.Match(httpRequestMessage.Content);

return contentNode?.RequestBuilder;
}

internal static ConfiguredRequests FromRequestBuilders(IEnumerable<RequestBuilder> requestBuilders)
Expand Down
7 changes: 7 additions & 0 deletions src/Codenizer.HttpClient.Testable/IRequestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,12 @@ public interface IRequestBuilder
/// <param name="mimeType">A MIME content type (for example text/plain)</param>
/// <returns>The current <see cref="IRequestBuilder"/> instance</returns>
IRequestBuilder Accepting(string mimeType);

/// <summary>
/// Respond to a request that matches the request content
/// </summary>
/// <param name="content"></param>
/// <returns>The current <see cref="IRequestBuilder"/> instance</returns>
IRequestBuilder ForContent(string content);
}
}
11 changes: 11 additions & 0 deletions src/Codenizer.HttpClient.Testable/RequestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ internal RequestBuilder(HttpMethod method, string pathAndQuery, string? contentT
/// </summary>
public string? Accept { get; private set; }

/// <summary>
/// Optional. The expected content of the request.
/// </summary>
public string? ExpectedContent { get; private set; }

/// <inheritdoc />
public IResponseBuilder With(HttpStatusCode statusCode)
{
Expand Down Expand Up @@ -247,6 +252,12 @@ public IRequestBuilder Accepting(string mimeType)
return this;
}

public IRequestBuilder ForContent(string content)
{
ExpectedContent = content;
return this;
}

/// <inheritdoc />
public IResponseBuilder AndContent(string mimeType, object data)
{
Expand Down
60 changes: 60 additions & 0 deletions src/Codenizer.HttpClient.Testable/RequestContentNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Net.Http;

namespace Codenizer.HttpClient.Testable;

internal class RequestContentNode : RequestNode
{
private readonly string? _expectedContent;

public RequestContentNode(string? expectedContent)
{
_expectedContent = expectedContent;
}

public override void Accept(RequestNodeVisitor visitor)
{
if (_expectedContent != null)
{
visitor.Content(_expectedContent);
}

if (RequestBuilder != null)
{
visitor.Response(RequestBuilder);
}
}

public RequestBuilder? RequestBuilder { get; private set; }

public void SetRequestBuilder(RequestBuilder requestBuilder)
{
if (RequestBuilder != null)
{
throw new MultipleResponsesConfiguredException(2, requestBuilder.PathAndQuery!);
}

RequestBuilder = requestBuilder;
}

public bool Match(HttpContent content)
{
if (_expectedContent == null)
{
return true;
}

if (content is StringContent stringContent)
{
var requestContent = stringContent.ReadAsStringAsync().GetAwaiter().GetResult();

return string.Equals(_expectedContent, requestContent);
}

return false;
}

public bool Match(string? expectedContent)
{
return string.Equals(_expectedContent, expectedContent);
}
}
31 changes: 22 additions & 9 deletions src/Codenizer.HttpClient.Testable/RequestHeadersNode.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;

namespace Codenizer.HttpClient.Testable
{
internal class RequestHeadersNode : RequestNode
{
private readonly Dictionary<string, string> _headers;
private readonly List<RequestContentNode> _requestContentNodes = new();

public RequestHeadersNode(Dictionary<string, string> headers)
{
Expand Down Expand Up @@ -63,14 +65,9 @@ public bool Match(HttpRequestHeaders headers)
return true;
}

public void SetRequestBuilder(RequestBuilder requestBuilder)
public RequestContentNode? Match(HttpContent content)
{
if(RequestBuilder != null)
{
throw new MultipleResponsesConfiguredException(2, requestBuilder.PathAndQuery!);
}

RequestBuilder = requestBuilder;
return _requestContentNodes.SingleOrDefault(node => node.Match(content));
}

public override void Accept(RequestNodeVisitor visitor)
Expand All @@ -80,10 +77,26 @@ public override void Accept(RequestNodeVisitor visitor)
visitor.Header(header.Key, header.Value);
}

if (RequestBuilder != null)
foreach (var node in _requestContentNodes)
{
node.Accept(visitor);
}
}

public RequestContentNode Add(string? expectedContent)
{
var existingContentNode = _requestContentNodes.SingleOrDefault(node => node.Match(expectedContent));

if (existingContentNode == null)
{
visitor.Response(RequestBuilder);
var requestContentNode = new RequestContentNode(expectedContent);

_requestContentNodes.Add(requestContentNode);

return requestContentNode;
}

return existingContentNode;
}
}
}
1 change: 1 addition & 0 deletions src/Codenizer.HttpClient.Testable/RequestNodeVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ internal abstract class RequestNodeVisitor
public abstract void Scheme(string scheme);
public abstract void Method(HttpMethod method);
public abstract void Response(RequestBuilder requestBuilder);
public abstract void Content(string expectedContent);
}
}
3 changes: 2 additions & 1 deletion src/Codenizer.HttpClient.Testable/RequestQueryNode.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;

namespace Codenizer.HttpClient.Testable
Expand Down Expand Up @@ -103,7 +104,7 @@ public RequestHeadersNode Add(Dictionary<string, string> headers)
{
return _headersNodes.SingleOrDefault(node => node.Match(headers));
}

public override void Accept(RequestNodeVisitor visitor)
{
if (_queryParameters.Any())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -612,5 +612,51 @@ public async Task GivenAsyncHandlerOnRequestThatReadsRequestContent_ContentCanSt

content.Should().Be("HELLO WORLD!");
}

[Fact]
public async Task GivenExpectationForContentAndContentMatches_ConfiguredResponseIsReturned()
{
var handler = new TestableMessageHandler();
var client = new System.Net.Http.HttpClient(handler);

handler
.RespondTo()
.Post()
.ForUrl("/search")
.ForContent(@"{""params"":{""query_string"":""confetti""}}")
.With(HttpStatusCode.OK);

var response = await client.PostAsync(
"https://tempuri.org/search",
new StringContent(@"{""params"":{""query_string"":""confetti""}}"));

response
.StatusCode
.Should()
.Be(HttpStatusCode.OK);
}

[Fact]
public async Task GivenExpectationWithContentAndContentDoesNotMatch_InternalServerErrorIsReturned()
{
var handler = new TestableMessageHandler();
var client = new System.Net.Http.HttpClient(handler);

handler
.RespondTo()
.Post()
.ForUrl("/search")
.ForContent(@"{""params"":{""query_string"":""confetti""}}")
.With(HttpStatusCode.OK);

var response = await client.PostAsync(
"https://tempuri.org/search",
new StringContent(@"{""boo"":""baz""}"));

response
.StatusCode
.Should()
.Be(HttpStatusCode.InternalServerError);
}
}
}

0 comments on commit 53d29b3

Please sign in to comment.