From 9bdf24d7d5bae98d0bb25b0ee64b149c142d0ff5 Mon Sep 17 00:00:00 2001 From: TekH Date: Wed, 8 Apr 2026 15:26:23 +0200 Subject: [PATCH] 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 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. --- .../Common/Extensions/MediatorExtensions.cs | 79 +++++++----- .../Application/MediatorExtensionsTests.cs | 113 +++++++++++------- 2 files changed, 121 insertions(+), 71 deletions(-) diff --git a/EnvelopeGenerator.Application/Common/Extensions/MediatorExtensions.cs b/EnvelopeGenerator.Application/Common/Extensions/MediatorExtensions.cs index 9a6a35dc..010c3154 100644 --- a/EnvelopeGenerator.Application/Common/Extensions/MediatorExtensions.cs +++ b/EnvelopeGenerator.Application/Common/Extensions/MediatorExtensions.cs @@ -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; /// -/// Extension methods for that enforce non-null and non-empty responses. +/// Extension methods for that provide a fluent API for enforcing non-null responses. /// public static class MediatorExtensions { /// - /// Sends a request via MediatR and throws a custom exception produced by + /// Begins a fluent chain that sends and lets you choose how to handle a null or empty response. + /// Usage: + /// + /// await sender.GetOr(query).ThrowNotFound(); + /// await sender.GetOr(query, cancel).Throw(() => new MyException()); + /// + /// + public static GetOrContext GetOr(this ISender sender, IRequest request, CancellationToken cancel = default) + => new(sender, request, cancel); +} + +/// +/// Holds a pending MediatR request and exposes Throw… methods that send the request +/// and throw a chosen exception when the response is null or an empty collection. +/// +/// The expected response type. +public readonly struct GetOrContext +{ + private readonly ISender _sender; + private readonly IRequest _request; + private readonly CancellationToken _cancel; + + internal GetOrContext(ISender sender, IRequest request, CancellationToken cancel) + { + _sender = sender; + _request = request; + _cancel = cancel; + } + + /// + /// Sends the request and throws the exception produced by /// when the response is null or an empty collection. /// - /// The expected response type. - /// The exception type to throw. - /// The mediator instance. - /// The MediatR request whose response may be null. - /// A factory that creates the exception to throw when the response is absent. - /// Cancellation token. - /// A guaranteed non-null . - /// The exception produced by . - public static async Task GetOrThrow(this ISender sender, IRequest request, Func exceptionFactory, CancellationToken cancel = default) - where TException : Exception + public async Task Throw(Func exceptionFactory) { - if (await sender.Send(request, cancel) is TResponse res) + if (await _sender.Send(_request, _cancel) is TResponse res) { + // string implements IEnumerable, so "" would be treated as an empty collection without this guard. if (res is not string && res is IEnumerable enumerable && !enumerable.Cast().Any()) throw exceptionFactory(); @@ -36,19 +58,20 @@ public static class MediatorExtensions } /// - /// Sends a request via MediatR and throws when the response is null or an empty collection. + /// Sends the request and throws when the response is null or an empty collection. /// - /// The expected response type. - /// The mediator instance. - /// The MediatR request whose response may be null. - /// Optional message for the . - /// Cancellation token. - /// A guaranteed non-null . - /// Thrown when the response is null or an empty collection. - public static async Task GetOrThrow(this ISender sender, IRequest 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 ThrowNotFound(string? message = null) + => Throw(() => new NotFoundException(message ?? $"The requested resource of type {typeof(TResponse).Name} was not found.")); - /// - public static async Task GetOrThrow(this ISender sender, IRequest request, CancellationToken cancel = default) - => await sender.GetOrThrow(request, null, cancel); + /// + /// Sends the request and throws when the response is null or an empty collection. + /// + public Task ThrowInvalidOperation(string? message = null) + => Throw(() => new InvalidOperationException(message ?? $"The operation for {typeof(TResponse).Name} returned no result.")); + + /// + /// Sends the request and throws when the response is null or an empty collection. + /// + public Task ThrowBadRequest(string? message = null) + => Throw(() => new BadRequestException(message ?? $"The request for {typeof(TResponse).Name} is invalid.")); } \ No newline at end of file diff --git a/EnvelopeGenerator.Tests/Application/MediatorExtensionsTests.cs b/EnvelopeGenerator.Tests/Application/MediatorExtensionsTests.cs index bec0c4a1..9c4722cd 100644 --- a/EnvelopeGenerator.Tests/Application/MediatorExtensionsTests.cs +++ b/EnvelopeGenerator.Tests/Application/MediatorExtensionsTests.cs @@ -49,212 +49,239 @@ public class MediatorExtensionsTests #endregion - #region GetOrThrow — non-null scalar + #region Throw — non-null scalar [Test] - public async Task GetOrThrow_WithNonNullResponse_ReturnsResponse() + public async Task Throw_WithNonNullResponse_ReturnsResponse() { var sender = CreateSender("hello"); var request = new StubRequest(); - 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 — null response + #region Throw — null response [Test] - public void GetOrThrow_WithNullResponse_ThrowsCustomException() + public void Throw_WithNullResponse_ThrowsCustomException() { var sender = CreateSender(null); var request = new StubRequest(); var ex = Assert.ThrowsAsync( - () => sender.GetOrThrow(request, () => new InvalidOperationException("custom"))); + () => sender.GetOr(request).Throw(() => new InvalidOperationException("custom"))); Assert.That(ex!.Message, Is.EqualTo("custom")); } #endregion - #region GetOrThrow — empty collection + #region Throw — empty collection [Test] - public void GetOrThrow_WithEmptyList_ThrowsCustomException() + public void Throw_WithEmptyList_ThrowsCustomException() { var sender = CreateSender>(new List()); var request = new StubRequest?>(); Assert.ThrowsAsync( - () => sender.GetOrThrow(request, () => new ArgumentException("empty"))); + () => sender.GetOr(request).Throw(() => new ArgumentException("empty"))); } #endregion - #region GetOrThrow — 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 { 1, 2 }; var sender = CreateSender>(expected); var request = new StubRequest?>(); - 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 — 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(""); var request = new StubRequest(); - 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 (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("hello"); var request = new StubRequest(); - 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(42); var request = new StubRequest(); - 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 (NotFoundException) — null response + #region ThrowNotFound — null response [Test] - public void GetOrThrow_NotFound_WithNullResponse_ThrowsNotFoundException() + public void ThrowNotFound_WithNullResponse_ThrowsNotFoundException() { var sender = CreateSender(null); var request = new StubRequest(); - Assert.ThrowsAsync(() => sender.GetOrThrow(request)); + Assert.ThrowsAsync(() => 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(null); var request = new StubRequest(); var ex = Assert.ThrowsAsync( - () => 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(null); var request = new StubRequest(); - var ex = Assert.ThrowsAsync(() => sender.GetOrThrow(request)); + var ex = Assert.ThrowsAsync(() => sender.GetOr(request).ThrowNotFound()); Assert.That(ex!.Message, Does.Contain(nameof(String))); } #endregion - #region GetOrThrow (NotFoundException) — empty collection + #region ThrowNotFound — empty collection [Test] - public void GetOrThrow_NotFound_WithEmptyList_ThrowsNotFoundException() + public void ThrowNotFound_WithEmptyList_ThrowsNotFoundException() { var sender = CreateSender>(new List()); var request = new StubRequest?>(); - Assert.ThrowsAsync(() => sender.GetOrThrow(request)); + Assert.ThrowsAsync(() => sender.GetOr(request).ThrowNotFound()); } [Test] - public void GetOrThrow_NotFound_WithEmptyArray_ThrowsNotFoundException() + public void ThrowNotFound_WithEmptyArray_ThrowsNotFoundException() { var sender = CreateSender(Array.Empty()); var request = new StubRequest(); - Assert.ThrowsAsync(() => sender.GetOrThrow(request)); + Assert.ThrowsAsync(() => 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>(new List()); var request = new StubRequest?>(); var ex = Assert.ThrowsAsync( - () => sender.GetOrThrow(request, message, CancellationToken.None)); + () => sender.GetOr(request).ThrowNotFound(message)); Assert.That(ex!.Message, Does.Contain(message)); } #endregion - #region GetOrThrow (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 { "a", "b" }; var sender = CreateSender>(expected); var request = new StubRequest?>(); - 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(expected); var request = new StubRequest(); - 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(null); + var request = new StubRequest(); + + Assert.ThrowsAsync( + () => sender.GetOr(request).ThrowInvalidOperation()); + } + + [Test] + public void ThrowInvalidOperation_WithNullResponse_AndCustomMessage_ContainsMessage() + { + const string message = "Something went wrong"; + var sender = CreateSender(null); + var request = new StubRequest(); + + var ex = Assert.ThrowsAsync( + () => 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("value"); var request = new StubRequest(); @@ -262,11 +289,11 @@ public class MediatorExtensionsTests cts.Cancel(); Assert.ThrowsAsync( - () => 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("value"); var request = new StubRequest(); @@ -274,7 +301,7 @@ public class MediatorExtensionsTests cts.Cancel(); Assert.ThrowsAsync( - () => sender.GetOrThrow(request, cts.Token)); + () => sender.GetOr(request, cts.Token).ThrowNotFound()); } #endregion