Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ namespace Mockly
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, Mockly.IResponseBuilder<T> builder) { }
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable<Mockly.IResponseBuilder<T>> builders) { }
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable<Mockly.IResponseBuilder<T>> builders, string odataContext) { }
public Mockly.RequestMockResponseBuilder RespondsWithProblemDetails(System.Net.HttpStatusCode statusCode, string? title = null, string? detail = null, string? type = null, string? instance = null, System.Collections.Generic.IDictionary<string, object?>? extensions = null) { }
public Mockly.RequestMockResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { }
public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ namespace Mockly
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, Mockly.IResponseBuilder<T> builder) { }
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable<Mockly.IResponseBuilder<T>> builders) { }
public Mockly.RequestMockResponseBuilder RespondsWithODataResult<T>(System.Net.HttpStatusCode statusCode, System.Collections.Generic.IEnumerable<Mockly.IResponseBuilder<T>> builders, string odataContext) { }
public Mockly.RequestMockResponseBuilder RespondsWithProblemDetails(System.Net.HttpStatusCode statusCode, string? title = null, string? detail = null, string? type = null, string? instance = null, System.Collections.Generic.IDictionary<string, object?>? extensions = null) { }
public Mockly.RequestMockResponseBuilder RespondsWithStatus(System.Net.HttpStatusCode statusCode) { }
public Mockly.RequestMockBuilder Using(System.Text.Json.JsonSerializerOptions options) { }
public Mockly.RequestMockBuilder With(System.Func<Mockly.RequestInfo, System.Threading.Tasks.Task<bool>> matcher, [System.Runtime.CompilerServices.CallerArgumentExpression("matcher")] string? matcherText = null) { }
Expand Down
110 changes: 110 additions & 0 deletions Mockly.Specs/HttpMockSpecs.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
Expand Down Expand Up @@ -2789,4 +2790,113 @@
}
}

#nullable enable
public class WhenRespondingWithProblemDetails
{
[Fact]
public async Task Uses_the_problem_json_content_type()
{
// Arrange
var mock = new HttpMock();
mock.ForGet().WithPath("/api/users/999")
.RespondsWithProblemDetails(HttpStatusCode.NotFound);

// Act
var response = await mock.GetClient().GetAsync("https://localhost/api/users/999");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json");
}

[Fact]
public async Task Defaults_the_title_to_the_reason_phrase_and_includes_the_status()
{
// Arrange
var mock = new HttpMock();
mock.ForGet().WithPath("/api/users/999")
.RespondsWithProblemDetails(HttpStatusCode.NotFound);

// Act
var response = await mock.GetClient().GetAsync("https://localhost/api/users/999");

// Assert
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
document.RootElement.GetProperty("title").GetString().Should().Be("Not Found");
document.RootElement.GetProperty("status").GetInt32().Should().Be(404);
}

[Fact]
public async Task Uses_the_supplied_title_detail_type_and_instance()
{
// Arrange
var mock = new HttpMock();
mock.ForGet().WithPath("/api/users/999")
.RespondsWithProblemDetails(
HttpStatusCode.NotFound,
title: "User not found",
detail: "No user exists with id 999",
type: "https://example.com/problems/not-found",
instance: "/api/users/999");

// Act
var response = await mock.GetClient().GetAsync("https://localhost/api/users/999");

// Assert
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = document.RootElement;
root.GetProperty("title").GetString().Should().Be("User not found");
root.GetProperty("detail").GetString().Should().Be("No user exists with id 999");
root.GetProperty("type").GetString().Should().Be("https://example.com/problems/not-found");
root.GetProperty("instance").GetString().Should().Be("/api/users/999");
}

[Fact]
public async Task Serializes_extension_members()
{
// Arrange
var mock = new HttpMock();
mock.ForGet().WithPath("/api/users/999")
.RespondsWithProblemDetails(
HttpStatusCode.BadRequest,
extensions: new Dictionary<string, object?>
{
["traceId"] = "00-abc-def-01",
["errors"] = new[] { "name is required" }
});

// Act
var response = await mock.GetClient().GetAsync("https://localhost/api/users/999");

// Assert
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = document.RootElement;
root.GetProperty("traceId").GetString().Should().Be("00-abc-def-01");
root.GetProperty("errors")[0].GetString().Should().Be("name is required");
}

[Fact]
public async Task Works_with_times()
{
// Arrange
var mock = new HttpMock();
mock.ForGet().WithPath("/api/users/999")
.RespondsWithProblemDetails(HttpStatusCode.NotFound, title: "User not found")
.Times(2);

var client = mock.GetClient();

// Act
var first = await client.GetAsync("https://localhost/api/users/999");
var second = await client.GetAsync("https://localhost/api/users/999");

// Assert
first.StatusCode.Should().Be(HttpStatusCode.NotFound);
second.StatusCode.Should().Be(HttpStatusCode.NotFound);
second.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json");
mock.AllMocksInvoked.Should().BeTrue();
}
}
#nullable restore

Check warning

Code scanning / InspectCode

Unused nullable directive Warning

Unused nullable directive

}
88 changes: 88 additions & 0 deletions Mockly/RequestMockBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,94 @@ public RequestMockResponseBuilder RespondsWithJsonContent<T>(HttpStatusCode stat
return RespondsWithJsonContent(statusCode, content);
}

/// <summary>
/// Responds with an RFC 7807 <c>application/problem+json</c> payload describing the specified problem.
/// </summary>
/// <param name="statusCode">The HTTP status code to respond with and to include as the <c>status</c> member.</param>
/// <param name="title">
/// A short, human-readable summary of the problem type. When <see langword="null"/>, the reason phrase of
/// <paramref name="statusCode"/> is used.
/// </param>
/// <param name="detail">A human-readable explanation specific to this occurrence of the problem.</param>
/// <param name="type">A URI reference that identifies the problem type.</param>
/// <param name="instance">A URI reference that identifies the specific occurrence of the problem.</param>
/// <param name="extensions">Additional members to include in the problem details payload.</param>
/// <remarks>
/// The payload is serialized using the same <see cref="JsonSerializerOptions"/> as the other JSON responders
/// (configurable through <see cref="Using"/>) and the response <c>Content-Type</c> is set to
/// <c>application/problem+json</c>.
/// </remarks>
[SuppressMessage("Maintainability", "AV1561:Signature contains too many parameters",
Justification = "The parameters mirror the RFC 7807 problem details members for an ergonomic single-call API.")]
[SuppressMessage("Member Design", "AV1553:Do not use optional parameters with default value null",
Justification = "The RFC 7807 members are all optional, so null is the natural way to omit them.")]
public RequestMockResponseBuilder RespondsWithProblemDetails(
HttpStatusCode statusCode,
string? title = null,
string? detail = null,
string? type = null,
string? instance = null,
IDictionary<string, object?>? extensions = null)
{
var options = jsonSerializerOptions;

var problemDetails = new Dictionary<string, object?>(StringComparer.Ordinal);

if (type is not null)
{
problemDetails["type"] = type;
}

problemDetails["title"] = title ?? GetReasonPhrase(statusCode);
problemDetails["status"] = (int)statusCode;

if (detail is not null)
{
problemDetails["detail"] = detail;
}

if (instance is not null)
{
problemDetails["instance"] = instance;
}

if (extensions is not null)
{
foreach (var extension in extensions)
{
problemDetails[extension.Key] = extension.Value;
}
}

var mock = new RequestMock
{
Method = Method,
PathPattern = pathPattern,
QueryPattern = queryPattern,
Scheme = scheme,
HostPattern = hostPattern,
CustomMatchers = customMatchers,
RequestCollection = requestCollection,
Responder = _ =>
{
var json = JsonSerializer.Serialize(problemDetails, options);
return new HttpResponseMessage(statusCode)
{
Content = new StringContent(json, Encoding.UTF8, "application/problem+json")
};
}
};

mockBuilder.AddMock(mock);
return new RequestMockResponseBuilder(mock);
}

private static string? GetReasonPhrase(HttpStatusCode statusCode)
{
using var message = new HttpResponseMessage(statusCode);
return message.ReasonPhrase;
}

/// <summary>
/// Configures an HTTP response with an OData v4 result envelope containing a single entity of the specified type
/// and status code 200 (OK).
Expand Down
Loading