Refactor MediatorExtensions to fluent GetOr/Throw API
Replaced GetOrThrow methods with a fluent GetOr/Throw pattern for handling null or empty MediatR responses. Introduced GetOrContext<TResponse> struct with Throw, ThrowNotFound, ThrowInvalidOperation, and ThrowBadRequest methods. Updated all tests to use the new API and added coverage for new exception types. Improved XML docs and performed minor code cleanup.
This commit is contained in:
@@ -1,31 +1,53 @@
|
||||
using System.Collections;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using DigitalData.Core.Exceptions;
|
||||
using MediatR;
|
||||
using System.Collections;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IMediator"/> that enforce non-null and non-empty responses.
|
||||
/// Extension methods for <see cref="ISender"/> that provide a fluent API for enforcing non-null responses.
|
||||
/// </summary>
|
||||
public static class MediatorExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a request via MediatR and throws a custom exception produced by <paramref name="exceptionFactory"/>
|
||||
/// Begins a fluent chain that sends <paramref name="request"/> and lets you choose how to handle a <c>null</c> or empty response.
|
||||
/// <para>Usage:</para>
|
||||
/// <code>
|
||||
/// await sender.GetOr(query).ThrowNotFound();
|
||||
/// await sender.GetOr(query, cancel).Throw(() => new MyException());
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public static GetOrContext<TResponse> GetOr<TResponse>(this ISender sender, IRequest<TResponse?> request, CancellationToken cancel = default)
|
||||
=> new(sender, request, cancel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds a pending MediatR request and exposes <c>Throw…</c> methods that send the request
|
||||
/// and throw a chosen exception when the response is <c>null</c> or an empty collection.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">The expected response type.</typeparam>
|
||||
public readonly struct GetOrContext<TResponse>
|
||||
{
|
||||
private readonly ISender _sender;
|
||||
private readonly IRequest<TResponse?> _request;
|
||||
private readonly CancellationToken _cancel;
|
||||
|
||||
internal GetOrContext(ISender sender, IRequest<TResponse?> request, CancellationToken cancel)
|
||||
{
|
||||
_sender = sender;
|
||||
_request = request;
|
||||
_cancel = cancel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the request and throws the exception produced by <paramref name="exceptionFactory"/>
|
||||
/// when the response is <c>null</c> or an empty collection.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">The expected response type.</typeparam>
|
||||
/// <typeparam name="TException">The exception type to throw.</typeparam>
|
||||
/// <param name="sender">The mediator instance.</param>
|
||||
/// <param name="request">The MediatR request whose response may be <c>null</c>.</param>
|
||||
/// <param name="exceptionFactory">A factory that creates the exception to throw when the response is absent.</param>
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
/// <returns>A guaranteed non-null <typeparamref name="TResponse"/>.</returns>
|
||||
/// <exception cref="Exception">The exception produced by <paramref name="exceptionFactory"/>.</exception>
|
||||
public static async Task<TResponse> GetOrThrow<TResponse, TException>(this ISender sender, IRequest<TResponse?> request, Func<TException> exceptionFactory, CancellationToken cancel = default)
|
||||
where TException : Exception
|
||||
public async Task<TResponse> Throw(Func<Exception> exceptionFactory)
|
||||
{
|
||||
if (await sender.Send(request, cancel) is TResponse res)
|
||||
if (await _sender.Send(_request, _cancel) is TResponse res)
|
||||
{
|
||||
// string implements IEnumerable<char>, so "" would be treated as an empty collection without this guard.
|
||||
if (res is not string && res is IEnumerable enumerable && !enumerable.Cast<object>().Any())
|
||||
throw exceptionFactory();
|
||||
|
||||
@@ -36,19 +58,20 @@ public static class MediatorExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request via MediatR and throws <see cref="NotFoundException"/> when the response is <c>null</c> or an empty collection.
|
||||
/// Sends the request and throws <see cref="NotFoundException"/> when the response is <c>null</c> or an empty collection.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResponse">The expected response type.</typeparam>
|
||||
/// <param name="sender">The mediator instance.</param>
|
||||
/// <param name="request">The MediatR request whose response may be <c>null</c>.</param>
|
||||
/// <param name="exceptionMessage">Optional message for the <see cref="NotFoundException"/>.</param>
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
/// <returns>A guaranteed non-null <typeparamref name="TResponse"/>.</returns>
|
||||
/// <exception cref="NotFoundException">Thrown when the response is <c>null</c> or an empty collection.</exception>
|
||||
public static async Task<TResponse> GetOrThrow<TResponse>(this ISender sender, IRequest<TResponse?> request, string? exceptionMessage, CancellationToken cancel = default)
|
||||
=> await sender.GetOrThrow(request, () => new NotFoundException(exceptionMessage ?? $"The requested resource of type {typeof(TResponse).Name} was not found."), cancel);
|
||||
public Task<TResponse> ThrowNotFound(string? message = null)
|
||||
=> Throw(() => new NotFoundException(message ?? $"The requested resource of type {typeof(TResponse).Name} was not found."));
|
||||
|
||||
/// <inheritdoc cref="GetOrThrow{TResponse}(ISender, IRequest{TResponse}, string, CancellationToken)"/>
|
||||
public static async Task<TResponse> GetOrThrow<TResponse>(this ISender sender, IRequest<TResponse?> request, CancellationToken cancel = default)
|
||||
=> await sender.GetOrThrow(request, null, cancel);
|
||||
/// <summary>
|
||||
/// Sends the request and throws <see cref="InvalidOperationException"/> when the response is <c>null</c> or an empty collection.
|
||||
/// </summary>
|
||||
public Task<TResponse> ThrowInvalidOperation(string? message = null)
|
||||
=> Throw(() => new InvalidOperationException(message ?? $"The operation for {typeof(TResponse).Name} returned no result."));
|
||||
|
||||
/// <summary>
|
||||
/// Sends the request and throws <see cref="BadRequestException"/> when the response is <c>null</c> or an empty collection.
|
||||
/// </summary>
|
||||
public Task<TResponse> ThrowBadRequest(string? message = null)
|
||||
=> Throw(() => new BadRequestException(message ?? $"The request for {typeof(TResponse).Name} is invalid."));
|
||||
}
|
||||
@@ -49,212 +49,239 @@ public class MediatorExtensionsTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrThrow<TResponse, TException> — non-null scalar
|
||||
#region Throw — non-null scalar
|
||||
|
||||
[Test]
|
||||
public async Task GetOrThrow_WithNonNullResponse_ReturnsResponse()
|
||||
public async Task Throw_WithNonNullResponse_ReturnsResponse()
|
||||
{
|
||||
var sender = CreateSender<string>("hello");
|
||||
var request = new StubRequest<string?>();
|
||||
|
||||
var result = await sender.GetOrThrow(request, () => new InvalidOperationException());
|
||||
var result = await sender.GetOr(request).Throw(() => new InvalidOperationException());
|
||||
|
||||
Assert.That(result, Is.EqualTo("hello"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrThrow<TResponse, TException> — null response
|
||||
#region Throw — null response
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_WithNullResponse_ThrowsCustomException()
|
||||
public void Throw_WithNullResponse_ThrowsCustomException()
|
||||
{
|
||||
var sender = CreateSender<string>(null);
|
||||
var request = new StubRequest<string?>();
|
||||
|
||||
var ex = Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sender.GetOrThrow(request, () => new InvalidOperationException("custom")));
|
||||
() => sender.GetOr(request).Throw(() => new InvalidOperationException("custom")));
|
||||
|
||||
Assert.That(ex!.Message, Is.EqualTo("custom"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrThrow<TResponse, TException> — empty collection
|
||||
#region Throw — empty collection
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_WithEmptyList_ThrowsCustomException()
|
||||
public void Throw_WithEmptyList_ThrowsCustomException()
|
||||
{
|
||||
var sender = CreateSender<List<string>>(new List<string>());
|
||||
var request = new StubRequest<List<string>?>();
|
||||
|
||||
Assert.ThrowsAsync<ArgumentException>(
|
||||
() => sender.GetOrThrow(request, () => new ArgumentException("empty")));
|
||||
() => sender.GetOr(request).Throw(() => new ArgumentException("empty")));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrThrow<TResponse, TException> — non-empty collection
|
||||
#region Throw — non-empty collection
|
||||
|
||||
[Test]
|
||||
public async Task GetOrThrow_WithNonEmptyList_ReturnsResponse()
|
||||
public async Task Throw_WithNonEmptyList_ReturnsResponse()
|
||||
{
|
||||
var expected = new List<int> { 1, 2 };
|
||||
var sender = CreateSender<List<int>>(expected);
|
||||
var request = new StubRequest<List<int>?>();
|
||||
|
||||
var result = await sender.GetOrThrow(request, () => new InvalidOperationException());
|
||||
var result = await sender.GetOr(request).Throw(() => new InvalidOperationException());
|
||||
|
||||
Assert.That(result, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrThrow<TResponse, TException> — string edge case (string implements IEnumerable)
|
||||
#region Throw — string edge case (string implements IEnumerable)
|
||||
|
||||
[Test]
|
||||
public async Task GetOrThrow_WithEmptyString_ReturnsEmptyString()
|
||||
public async Task Throw_WithEmptyString_ReturnsEmptyString()
|
||||
{
|
||||
var sender = CreateSender<string>("");
|
||||
var request = new StubRequest<string?>();
|
||||
|
||||
var result = await sender.GetOrThrow(request, () => new InvalidOperationException("should not throw"));
|
||||
var result = await sender.GetOr(request).Throw(() => new InvalidOperationException("should not throw"));
|
||||
|
||||
Assert.That(result, Is.EqualTo(""));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrThrow<TResponse> (NotFoundException) — non-null scalar
|
||||
#region ThrowNotFound — non-null scalar
|
||||
|
||||
[Test]
|
||||
public async Task GetOrThrow_NotFound_WithNonNullResponse_ReturnsResponse()
|
||||
public async Task ThrowNotFound_WithNonNullResponse_ReturnsResponse()
|
||||
{
|
||||
var sender = CreateSender<string>("hello");
|
||||
var request = new StubRequest<string?>();
|
||||
|
||||
var result = await sender.GetOrThrow(request);
|
||||
var result = await sender.GetOr(request).ThrowNotFound();
|
||||
|
||||
Assert.That(result, Is.EqualTo("hello"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetOrThrow_NotFound_WithExceptionMessage_AndNonNullResponse_ReturnsResponse()
|
||||
public async Task ThrowNotFound_WithExceptionMessage_AndNonNullResponse_ReturnsResponse()
|
||||
{
|
||||
var sender = CreateSender<int>(42);
|
||||
var request = new StubRequest<int?>();
|
||||
|
||||
var result = await sender.GetOrThrow(request, "not found", CancellationToken.None);
|
||||
var result = await sender.GetOr(request).ThrowNotFound("not found");
|
||||
|
||||
Assert.That(result, Is.EqualTo(42));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrThrow<TResponse> (NotFoundException) — null response
|
||||
#region ThrowNotFound — null response
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_NotFound_WithNullResponse_ThrowsNotFoundException()
|
||||
public void ThrowNotFound_WithNullResponse_ThrowsNotFoundException()
|
||||
{
|
||||
var sender = CreateSender<string>(null);
|
||||
var request = new StubRequest<string?>();
|
||||
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sender.GetOrThrow(request));
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sender.GetOr(request).ThrowNotFound());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_NotFound_WithNullResponse_AndCustomMessage_ContainsMessage()
|
||||
public void ThrowNotFound_WithNullResponse_AndCustomMessage_ContainsMessage()
|
||||
{
|
||||
const string message = "Entity not found";
|
||||
var sender = CreateSender<string>(null);
|
||||
var request = new StubRequest<string?>();
|
||||
|
||||
var ex = Assert.ThrowsAsync<NotFoundException>(
|
||||
() => sender.GetOrThrow(request, message, CancellationToken.None));
|
||||
() => sender.GetOr(request).ThrowNotFound(message));
|
||||
|
||||
Assert.That(ex!.Message, Does.Contain(message));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_NotFound_WithNullResponse_HasDefaultMessageWithTypeName()
|
||||
public void ThrowNotFound_WithNullResponse_HasDefaultMessageWithTypeName()
|
||||
{
|
||||
var sender = CreateSender<string>(null);
|
||||
var request = new StubRequest<string?>();
|
||||
|
||||
var ex = Assert.ThrowsAsync<NotFoundException>(() => sender.GetOrThrow(request));
|
||||
var ex = Assert.ThrowsAsync<NotFoundException>(() => sender.GetOr(request).ThrowNotFound());
|
||||
|
||||
Assert.That(ex!.Message, Does.Contain(nameof(String)));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrThrow<TResponse> (NotFoundException) — empty collection
|
||||
#region ThrowNotFound — empty collection
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_NotFound_WithEmptyList_ThrowsNotFoundException()
|
||||
public void ThrowNotFound_WithEmptyList_ThrowsNotFoundException()
|
||||
{
|
||||
var sender = CreateSender<List<string>>(new List<string>());
|
||||
var request = new StubRequest<List<string>?>();
|
||||
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sender.GetOrThrow(request));
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sender.GetOr(request).ThrowNotFound());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_NotFound_WithEmptyArray_ThrowsNotFoundException()
|
||||
public void ThrowNotFound_WithEmptyArray_ThrowsNotFoundException()
|
||||
{
|
||||
var sender = CreateSender<int[]>(Array.Empty<int>());
|
||||
var request = new StubRequest<int[]?>();
|
||||
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sender.GetOrThrow(request));
|
||||
Assert.ThrowsAsync<NotFoundException>(() => sender.GetOr(request).ThrowNotFound());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_NotFound_WithEmptyCollection_AndCustomMessage_ContainsMessage()
|
||||
public void ThrowNotFound_WithEmptyCollection_AndCustomMessage_ContainsMessage()
|
||||
{
|
||||
const string message = "No items found";
|
||||
var sender = CreateSender<List<int>>(new List<int>());
|
||||
var request = new StubRequest<List<int>?>();
|
||||
|
||||
var ex = Assert.ThrowsAsync<NotFoundException>(
|
||||
() => sender.GetOrThrow(request, message, CancellationToken.None));
|
||||
() => sender.GetOr(request).ThrowNotFound(message));
|
||||
|
||||
Assert.That(ex!.Message, Does.Contain(message));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetOrThrow<TResponse> (NotFoundException) — non-empty collection
|
||||
#region ThrowNotFound — non-empty collection
|
||||
|
||||
[Test]
|
||||
public async Task GetOrThrow_NotFound_WithNonEmptyList_ReturnsResponse()
|
||||
public async Task ThrowNotFound_WithNonEmptyList_ReturnsResponse()
|
||||
{
|
||||
var expected = new List<string> { "a", "b" };
|
||||
var sender = CreateSender<List<string>>(expected);
|
||||
var request = new StubRequest<List<string>?>();
|
||||
|
||||
var result = await sender.GetOrThrow(request);
|
||||
var result = await sender.GetOr(request).ThrowNotFound();
|
||||
|
||||
Assert.That(result, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetOrThrow_NotFound_WithNonEmptyArray_ReturnsResponse()
|
||||
public async Task ThrowNotFound_WithNonEmptyArray_ReturnsResponse()
|
||||
{
|
||||
var expected = new[] { 1, 2, 3 };
|
||||
var sender = CreateSender<int[]>(expected);
|
||||
var request = new StubRequest<int[]?>();
|
||||
|
||||
var result = await sender.GetOrThrow(request);
|
||||
var result = await sender.GetOr(request).ThrowNotFound();
|
||||
|
||||
Assert.That(result, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ThrowInvalidOperation — null response
|
||||
|
||||
[Test]
|
||||
public void ThrowInvalidOperation_WithNullResponse_ThrowsInvalidOperationException()
|
||||
{
|
||||
var sender = CreateSender<string>(null);
|
||||
var request = new StubRequest<string?>();
|
||||
|
||||
Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sender.GetOr(request).ThrowInvalidOperation());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ThrowInvalidOperation_WithNullResponse_AndCustomMessage_ContainsMessage()
|
||||
{
|
||||
const string message = "Something went wrong";
|
||||
var sender = CreateSender<string>(null);
|
||||
var request = new StubRequest<string?>();
|
||||
|
||||
var ex = Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sender.GetOr(request).ThrowInvalidOperation(message));
|
||||
|
||||
Assert.That(ex!.Message, Does.Contain(message));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CancellationToken
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_WithCancelledToken_ThrowsOperationCanceledException()
|
||||
public void Throw_WithCancelledToken_ThrowsOperationCanceledException()
|
||||
{
|
||||
var sender = CreateSender<string>("value");
|
||||
var request = new StubRequest<string?>();
|
||||
@@ -262,11 +289,11 @@ public class MediatorExtensionsTests
|
||||
cts.Cancel();
|
||||
|
||||
Assert.ThrowsAsync<OperationCanceledException>(
|
||||
() => sender.GetOrThrow(request, () => new InvalidOperationException(), cts.Token));
|
||||
() => sender.GetOr(request, cts.Token).Throw(() => new InvalidOperationException()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetOrThrow_NotFound_WithCancelledToken_ThrowsOperationCanceledException()
|
||||
public void ThrowNotFound_WithCancelledToken_ThrowsOperationCanceledException()
|
||||
{
|
||||
var sender = CreateSender<string>("value");
|
||||
var request = new StubRequest<string?>();
|
||||
@@ -274,7 +301,7 @@ public class MediatorExtensionsTests
|
||||
cts.Cancel();
|
||||
|
||||
Assert.ThrowsAsync<OperationCanceledException>(
|
||||
() => sender.GetOrThrow(request, cts.Token));
|
||||
() => sender.GetOr(request, cts.Token).ThrowNotFound());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
Reference in New Issue
Block a user