From f57a93048c94ed191b40ba7a231616ddfa98a65b Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Thu, 14 Nov 2024 12:11:27 -0600 Subject: [PATCH] better problem detail support in endpoints --- .../Domain/DomainJsonContext.cs | 4 +- .../Endpoints/AuditEndpoint.cs | 6 +- .../Endpoints/PriorityEndpoint.cs | 5 +- .../Endpoints/RoleEndpoint.cs | 6 +- .../Endpoints/StatusEndpoint.cs | 6 +- .../Endpoints/TaskEndpoint.cs | 6 +- .../Endpoints/UserEndpoint.cs | 6 +- .../Endpoints/UserLoginEndpoint.cs | 6 +- .../generation.yml | 2 + src/Directory.Build.props | 2 +- .../DispatcherEndpoint.cs | 21 +- .../EntityCommandEndpointBase.cs | 146 +++++++---- .../EntityQueryEndpointBase.cs | 233 ++++++++++++------ .../FeatureEndpointExtensions.cs | 2 +- .../ProblemDetailsCustomizer.cs | 9 +- .../RouteHandlerBuilderExtensions.cs | 16 ++ .../Converters/PolymorphicConverter.cs | 17 +- .../Dispatcher/RemoteDispatcher.cs | 2 +- templates/EntityEndpoint.csx | 4 +- .../SerializationTests.cs | 17 ++ 20 files changed, 364 insertions(+), 152 deletions(-) create mode 100644 src/MediatR.CommandQuery.Endpoints/RouteHandlerBuilderExtensions.cs diff --git a/samples/Tracker.WebService.EntityFrameworkCore/Domain/DomainJsonContext.cs b/samples/Tracker.WebService.EntityFrameworkCore/Domain/DomainJsonContext.cs index 4c62c173..6e759370 100644 --- a/samples/Tracker.WebService.EntityFrameworkCore/Domain/DomainJsonContext.cs +++ b/samples/Tracker.WebService.EntityFrameworkCore/Domain/DomainJsonContext.cs @@ -11,7 +11,7 @@ namespace Tracker.WebService.Domain; [JsonSourceGenerationOptions( - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] #region Generated Attributes [JsonSerializable(typeof(AuditReadModel))] @@ -51,7 +51,7 @@ namespace Tracker.WebService.Domain; [JsonSerializable(typeof(UserUpdateModel))] [JsonSerializable(typeof(EntityQuery))] [JsonSerializable(typeof(EntitySelect))] -[JsonSerializable(typeof(JsonPatchDocument))] +[JsonSerializable(typeof(IJsonPatchDocument))] #endregion public partial class DomainJsonContext : JsonSerializerContext { diff --git a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/AuditEndpoint.cs b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/AuditEndpoint.cs index 88a052f8..9c6d5ac6 100644 --- a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/AuditEndpoint.cs +++ b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/AuditEndpoint.cs @@ -1,6 +1,9 @@ using System; using MediatR; using MediatR.CommandQuery.Endpoints; + +using Microsoft.Extensions.Logging; + using Tracker.WebService.Domain.Models; namespace Tracker.WebService.Endpoints; @@ -8,7 +11,8 @@ namespace Tracker.WebService.Endpoints; [RegisterTransient(Duplicate = DuplicateStrategy.Append)] public class AuditEndpoint : EntityCommandEndpointBase { - public AuditEndpoint(IMediator mediator) : base(mediator, "Audit") + public AuditEndpoint(ILoggerFactory loggerFactory, IMediator mediator) + : base(loggerFactory, mediator, "Audit") { } diff --git a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/PriorityEndpoint.cs b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/PriorityEndpoint.cs index 003449d6..02f6b7aa 100644 --- a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/PriorityEndpoint.cs +++ b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/PriorityEndpoint.cs @@ -1,6 +1,8 @@ using System; using MediatR; using MediatR.CommandQuery.Endpoints; +using Microsoft.Extensions.Logging; + using Tracker.WebService.Domain.Models; namespace Tracker.WebService.Endpoints; @@ -8,7 +10,8 @@ namespace Tracker.WebService.Endpoints; [RegisterTransient(Duplicate = DuplicateStrategy.Append)] public class PriorityEndpoint : EntityCommandEndpointBase { - public PriorityEndpoint(IMediator mediator) : base(mediator, "Priority") + public PriorityEndpoint(ILoggerFactory loggerFactory, IMediator mediator) + : base(loggerFactory, mediator, "Priority") { } diff --git a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/RoleEndpoint.cs b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/RoleEndpoint.cs index e79d37c7..0ebdecaf 100644 --- a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/RoleEndpoint.cs +++ b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/RoleEndpoint.cs @@ -1,6 +1,9 @@ using System; using MediatR; using MediatR.CommandQuery.Endpoints; + +using Microsoft.Extensions.Logging; + using Tracker.WebService.Domain.Models; namespace Tracker.WebService.Endpoints; @@ -8,7 +11,8 @@ namespace Tracker.WebService.Endpoints; [RegisterTransient(Duplicate = DuplicateStrategy.Append)] public class RoleEndpoint : EntityCommandEndpointBase { - public RoleEndpoint(IMediator mediator) : base(mediator, "Role") + public RoleEndpoint(ILoggerFactory loggerFactory, IMediator mediator) + : base(loggerFactory, mediator, "Role") { } diff --git a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/StatusEndpoint.cs b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/StatusEndpoint.cs index fd95372f..50041c54 100644 --- a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/StatusEndpoint.cs +++ b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/StatusEndpoint.cs @@ -1,6 +1,9 @@ using System; using MediatR; using MediatR.CommandQuery.Endpoints; + +using Microsoft.Extensions.Logging; + using Tracker.WebService.Domain.Models; namespace Tracker.WebService.Endpoints; @@ -8,7 +11,8 @@ namespace Tracker.WebService.Endpoints; [RegisterTransient(Duplicate = DuplicateStrategy.Append)] public class StatusEndpoint : EntityCommandEndpointBase { - public StatusEndpoint(IMediator mediator) : base(mediator, "Status") + public StatusEndpoint(ILoggerFactory loggerFactory, IMediator mediator) + : base(loggerFactory, mediator, "Status") { } diff --git a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/TaskEndpoint.cs b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/TaskEndpoint.cs index cbb61825..527fea5e 100644 --- a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/TaskEndpoint.cs +++ b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/TaskEndpoint.cs @@ -1,6 +1,9 @@ using System; using MediatR; using MediatR.CommandQuery.Endpoints; + +using Microsoft.Extensions.Logging; + using Tracker.WebService.Domain.Models; namespace Tracker.WebService.Endpoints; @@ -8,7 +11,8 @@ namespace Tracker.WebService.Endpoints; [RegisterTransient(Duplicate = DuplicateStrategy.Append)] public class TaskEndpoint : EntityCommandEndpointBase { - public TaskEndpoint(IMediator mediator) : base(mediator, "Task") + public TaskEndpoint(ILoggerFactory loggerFactory, IMediator mediator) + : base(loggerFactory, mediator, "Task") { } diff --git a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/UserEndpoint.cs b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/UserEndpoint.cs index 65b158a7..e6bcffa4 100644 --- a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/UserEndpoint.cs +++ b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/UserEndpoint.cs @@ -1,6 +1,9 @@ using System; using MediatR; using MediatR.CommandQuery.Endpoints; + +using Microsoft.Extensions.Logging; + using Tracker.WebService.Domain.Models; namespace Tracker.WebService.Endpoints; @@ -8,7 +11,8 @@ namespace Tracker.WebService.Endpoints; [RegisterTransient(Duplicate = DuplicateStrategy.Append)] public class UserEndpoint : EntityCommandEndpointBase { - public UserEndpoint(IMediator mediator) : base(mediator, "User") + public UserEndpoint(ILoggerFactory loggerFactory, IMediator mediator) + : base(loggerFactory, mediator, "User") { } diff --git a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/UserLoginEndpoint.cs b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/UserLoginEndpoint.cs index 1f9c4430..b8960428 100644 --- a/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/UserLoginEndpoint.cs +++ b/samples/Tracker.WebService.EntityFrameworkCore/Endpoints/UserLoginEndpoint.cs @@ -1,6 +1,9 @@ using System; using MediatR; using MediatR.CommandQuery.Endpoints; + +using Microsoft.Extensions.Logging; + using Tracker.WebService.Domain.Models; namespace Tracker.WebService.Endpoints; @@ -8,7 +11,8 @@ namespace Tracker.WebService.Endpoints; [RegisterTransient(Duplicate = DuplicateStrategy.Append)] public class UserLoginEndpoint : EntityCommandEndpointBase { - public UserLoginEndpoint(IMediator mediator) : base(mediator, "UserLogin") + public UserLoginEndpoint(ILoggerFactory loggerFactory, IMediator mediator) + : base(loggerFactory, mediator, "UserLogin") { } diff --git a/samples/Tracker.WebService.EntityFrameworkCore/generation.yml b/samples/Tracker.WebService.EntityFrameworkCore/generation.yml index 4a620ec8..da825ad1 100644 --- a/samples/Tracker.WebService.EntityFrameworkCore/generation.yml +++ b/samples/Tracker.WebService.EntityFrameworkCore/generation.yml @@ -6,6 +6,8 @@ project: database: provider: SqlServer connectionString: 'Data Source=(local);Initial Catalog=TrackerService;Integrated Security=True;TrustServerCertificate=True' + schemas: + - dbo data: context: name: '{Database.Name}Context' diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 408ada7b..1135b91d 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -40,7 +40,7 @@ - + diff --git a/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs b/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs index 0e17185b..fc2304b5 100644 --- a/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs +++ b/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs @@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace MediatR.CommandQuery.Endpoints; @@ -14,11 +16,17 @@ public class DispatcherEndpoint : IFeatureEndpoint { private readonly ISender _sender; private readonly DispatcherOptions _dispatcherOptions; + private readonly ILogger _logger; - public DispatcherEndpoint(ISender sender, IOptions dispatcherOptions) + public DispatcherEndpoint(ILogger logger, ISender sender, IOptions dispatcherOptions) { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(sender); + ArgumentNullException.ThrowIfNull(dispatcherOptions); + _sender = sender; _dispatcherOptions = dispatcherOptions.Value; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public void AddRoutes(IEndpointRouteBuilder app) @@ -28,13 +36,13 @@ public void AddRoutes(IEndpointRouteBuilder app) group .MapPost(_dispatcherOptions.SendRoute, Send) - .WithTags("Dispatcher") + .WithEntityMetadata("Dispatcher") .WithName($"Send") .WithSummary("Send Mediator command") .WithDescription("Send Mediator command"); } - protected virtual async Task Send( + protected virtual async Task, ProblemHttpResult>> Send( [FromBody] DispatchRequest dispatchRequest, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) @@ -42,13 +50,16 @@ protected virtual async Task Send( try { var request = dispatchRequest.Request; + var result = await _sender.Send(request, cancellationToken).ConfigureAwait(false); - return Results.Ok(result); + return TypedResults.Ok(result); } catch (Exception ex) { + _logger.LogError(ex, "Error dispatching request: {ErrorMessage}", ex.Message); + var details = ex.ToProblemDetails(); - return Results.Problem(details); + return TypedResults.Problem(details); } } } diff --git a/src/MediatR.CommandQuery.Endpoints/EntityCommandEndpointBase.cs b/src/MediatR.CommandQuery.Endpoints/EntityCommandEndpointBase.cs index e68a80a3..a54f7503 100644 --- a/src/MediatR.CommandQuery.Endpoints/EntityCommandEndpointBase.cs +++ b/src/MediatR.CommandQuery.Endpoints/EntityCommandEndpointBase.cs @@ -1,12 +1,15 @@ using System.Security.Claims; +using MediatR; using MediatR.CommandQuery.Commands; using MediatR.CommandQuery.Queries; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; using SystemTextJsonPatch; @@ -15,132 +18,185 @@ namespace MediatR.CommandQuery.Endpoints; public abstract class EntityCommandEndpointBase : EntityQueryEndpointBase { - protected EntityCommandEndpointBase(IMediator mediator, string entityName) : base(mediator, entityName) + protected EntityCommandEndpointBase(ILoggerFactory loggerFactory, IMediator mediator, string entityName, string? routePrefix = null) + : base(loggerFactory, mediator, entityName, routePrefix) { } -#pragma warning disable MA0051 // Method is too long protected override void MapGroup(RouteGroupBuilder group) -#pragma warning restore MA0051 // Method is too long { base.MapGroup(group); group .MapGet("{id}/update", GetUpdateQuery) - .Produces() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Get{EntityName}Update") .WithSummary("Get an entity for update by id") .WithDescription("Get an entity for update by id"); group .MapPost("", CreateCommand) - .Produces() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Create{EntityName}") .WithSummary("Create new entity") .WithDescription("Create new entity"); group .MapPost("{id}", UpsertCommand) - .Produces() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Upsert{EntityName}") .WithSummary("Create new or update entity") .WithDescription("Create new or update entity"); group .MapPut("{id}", UpdateCommand) - .Produces() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Update{EntityName}") .WithSummary("Update entity") .WithDescription("Update entity"); group .MapPatch("{id}", PatchCommand) - .Produces() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Patch{EntityName}") .WithSummary("Patch entity") .WithDescription("Patch entity"); group .MapDelete("{id}", DeleteCommand) - .Produces() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Delete{EntityName}") .WithSummary("Delete entity") .WithDescription("Delete entity"); } - protected virtual async Task GetUpdateQuery( + protected virtual async Task, ProblemHttpResult>> GetUpdateQuery( [FromRoute] TKey id, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntityIdentifierQuery(user, id); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntityIdentifierQuery(user, id); + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error GetUpdateQuery: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } } - protected virtual async Task CreateCommand( + protected virtual async Task, ProblemHttpResult>> CreateCommand( [FromBody] TCreateModel createModel, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntityCreateCommand(user, createModel); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntityCreateCommand(user, createModel); + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error CreateCommand: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } } - protected virtual async Task UpdateCommand( + protected virtual async Task, ProblemHttpResult>> UpdateCommand( [FromRoute] TKey id, [FromBody] TUpdateModel updateModel, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntityUpdateCommand(user, id, updateModel); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntityUpdateCommand(user, id, updateModel); + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error UpdateCommand: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } } - protected virtual async Task UpsertCommand( + protected virtual async Task, ProblemHttpResult>> UpsertCommand( [FromRoute] TKey id, [FromBody] TUpdateModel updateModel, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntityUpsertCommand(user, id, updateModel); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntityUpsertCommand(user, id, updateModel); + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error UpsertCommand: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } } - protected virtual async Task PatchCommand( + protected virtual async Task, ProblemHttpResult>> PatchCommand( [FromRoute] TKey id, [FromBody] JsonPatchDocument jsonPatch, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntityPatchCommand(user, id, jsonPatch); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntityPatchCommand(user, id, jsonPatch); + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error PatchCommand: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } } - protected virtual async Task DeleteCommand( + protected virtual async Task, ProblemHttpResult>> DeleteCommand( [FromRoute] TKey id, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntityDeleteCommand(user, id); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntityDeleteCommand(user, id); + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error DeleteCommand: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } } } diff --git a/src/MediatR.CommandQuery.Endpoints/EntityQueryEndpointBase.cs b/src/MediatR.CommandQuery.Endpoints/EntityQueryEndpointBase.cs index ee3def86..ab9ae87d 100644 --- a/src/MediatR.CommandQuery.Endpoints/EntityQueryEndpointBase.cs +++ b/src/MediatR.CommandQuery.Endpoints/EntityQueryEndpointBase.cs @@ -10,18 +10,27 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; namespace MediatR.CommandQuery.Endpoints; public abstract class EntityQueryEndpointBase : MediatorEndpointBase { - protected EntityQueryEndpointBase(IMediator mediator, string entityName) : base(mediator) + protected EntityQueryEndpointBase(ILoggerFactory loggerFactory, IMediator mediator, string entityName, string? routePrefix = null) + : base(mediator) { + ArgumentNullException.ThrowIfNull(loggerFactory); + ArgumentNullException.ThrowIfNull(mediator); + ArgumentException.ThrowIfNullOrEmpty(entityName); + + Logger = loggerFactory.CreateLogger(GetType()); + EntityName = entityName; - RoutePrefix = EntityName; + RoutePrefix = routePrefix ?? EntityName; } public string EntityName { get; } @@ -29,6 +38,9 @@ protected EntityQueryEndpointBase(IMediator mediator, string entityName) : base( public string RoutePrefix { get; } + protected ILogger Logger { get; } + + public override void AddRoutes(IEndpointRouteBuilder app) { var group = app.MapGroup(RoutePrefix); @@ -37,92 +49,82 @@ public override void AddRoutes(IEndpointRouteBuilder app) } -#pragma warning disable MA0051 // Method is too long protected virtual void MapGroup(RouteGroupBuilder group) -#pragma warning restore MA0051 // Method is too long { group .MapGet("{id}", GetQuery) - .Produces() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Get{EntityName}") .WithSummary("Get an entity by id") .WithDescription("Get an entity by id"); group .MapGet("page", GetPagedQuery) - .Produces>() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Get{EntityName}Page") .WithSummary("Get a page of entities") .WithDescription("Get a page of entities"); group .MapPost("page", PostPagedQuery) - .Produces>() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Query{EntityName}Page") .WithSummary("Get a page of entities") .WithDescription("Get a page of entities"); group .MapGet("", GetSelectQuery) - .Produces>() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Get{EntityName}List") .WithSummary("Get entities by query") .WithDescription("Get entities by query"); group .MapPost("query", PostSelectQuery) - .Produces>() - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Query{EntityName}List") .WithSummary("Get entities by query") .WithDescription("Get entities by query"); group .MapPost("export", PostExportQuery) - .Produces(200, "text/csv") - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"Export{EntityName}List") .WithSummary("Export entities by query") .WithDescription("Export entities by query"); group .MapGet("export", GetExportQuery) - .Produces(200, "text/csv") - .ProducesValidationProblem() - .ProducesProblem(StatusCodes.Status500InternalServerError) - .WithTags(EntityName) + .WithEntityMetadata(EntityName) .WithName($"GetExport{EntityName}List") .WithSummary("Get Export entities by query") .WithDescription("Get Export entities by query"); } - protected virtual async Task GetQuery( + protected virtual async Task, ProblemHttpResult>> GetQuery( [FromRoute] TKey id, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntityIdentifierQuery(user, id); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntityIdentifierQuery(user, id); + + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error GetQuery: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } } - protected virtual async Task> GetPagedQuery( + protected virtual async Task>, ProblemHttpResult>> GetPagedQuery( [FromQuery] string? q = null, [FromQuery] string? sort = null, [FromQuery] int? page = 1, @@ -130,79 +132,162 @@ protected virtual async Task> GetPagedQuery( ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var entityQuery = new EntityQuery(q, page ?? 1, size ?? 20, sort); - var command = new EntityPagedQuery(user, entityQuery); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var entityQuery = new EntityQuery(q, page ?? 1, size ?? 20, sort); + var command = new EntityPagedQuery(user, entityQuery); + + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error GetPagedQuery: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } + } - protected virtual async Task> PostPagedQuery( + protected virtual async Task>, ProblemHttpResult>> PostPagedQuery( [FromBody] EntityQuery entityQuery, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntityPagedQuery(user, entityQuery); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntityPagedQuery(user, entityQuery); + + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error PostPagedQuery: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } + } - protected virtual async Task> GetSelectQuery( + protected virtual async Task>, ProblemHttpResult>> GetSelectQuery( [FromQuery] string? q = null, [FromQuery] string? sort = null, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var entitySelect = new EntitySelect(q, sort); - var command = new EntitySelectQuery(user, entitySelect); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var entitySelect = new EntitySelect(q, sort); + + var command = new EntitySelectQuery(user, entitySelect); + + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error GetSelectQuery: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } + } - protected virtual async Task> PostSelectQuery( + protected virtual async Task>, ProblemHttpResult>> PostSelectQuery( [FromBody] EntitySelect entitySelect, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntitySelectQuery(user, entitySelect); - return await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntitySelectQuery(user, entitySelect); + + var result = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return TypedResults.Ok(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error PostSelectQuery: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } + } - protected virtual async Task PostExportQuery( + protected virtual async Task> PostExportQuery( [FromBody] EntitySelect entitySelect, [FromServices] CsvConfiguration? csvConfiguration = default, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - var command = new EntitySelectQuery(user, entitySelect); - var results = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + try + { + var command = new EntitySelectQuery(user, entitySelect); + var results = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); - csvConfiguration ??= new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true }; + csvConfiguration ??= new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true }; -#pragma warning disable MA0004 // Use Task.ConfigureAwait - await using var memoryStream = new MemoryStream(); - await using var streamWriter = new StreamWriter(memoryStream); - await using var csvWriter = new CsvWriter(streamWriter, csvConfiguration); -#pragma warning restore MA0004 // Use Task.ConfigureAwait + var buffer = await ConvertToCsv(results, csvConfiguration, cancellationToken).ConfigureAwait(false); - WriteExportData(csvWriter, results); - - streamWriter.Flush(); + return TypedResults.File(buffer, "text/csv"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error PostExportQuery: {ErrorMessage}", ex.Message); - var buffer = memoryStream.ToArray(); + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } - return Results.File(buffer, "text/csv"); } - protected virtual async Task GetExportQuery( + protected virtual async Task> GetExportQuery( [FromQuery] string? encodedQuery = null, [FromServices] CsvConfiguration? csvConfiguration = default, [FromServices] JsonSerializerOptions? jsonSerializerOptions = default, ClaimsPrincipal? user = default, CancellationToken cancellationToken = default) { - jsonSerializerOptions ??= new JsonSerializerOptions(JsonSerializerDefaults.Web); + try + { + jsonSerializerOptions ??= new JsonSerializerOptions(JsonSerializerDefaults.Web); + + var entitySelect = QueryStringEncoder.Decode(encodedQuery, jsonSerializerOptions) ?? new EntitySelect(); + var command = new EntitySelectQuery(user, entitySelect); + var results = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); - var entitySelect = QueryStringEncoder.Decode(encodedQuery, jsonSerializerOptions) ?? new EntitySelect(); - var command = new EntitySelectQuery(user, entitySelect); - var results = await Mediator.Send(command, cancellationToken).ConfigureAwait(false); + csvConfiguration ??= new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true }; + var buffer = await ConvertToCsv(results, csvConfiguration, cancellationToken).ConfigureAwait(false); + + return TypedResults.File(buffer, "text/csv"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error GetExportQuery: {ErrorMessage}", ex.Message); + + var details = ex.ToProblemDetails(); + return TypedResults.Problem(details); + } + } + + + protected virtual void WriteExportData(CsvWriter csvWriter, IReadOnlyCollection results) + { + csvWriter.WriteRecords(results); + } + + private async Task ConvertToCsv(IReadOnlyCollection results, CsvConfiguration? csvConfiguration, CancellationToken cancellationToken = default) + { csvConfiguration ??= new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true }; #pragma warning disable MA0004 // Use Task.ConfigureAwait @@ -213,16 +298,8 @@ protected virtual async Task GetExportQuery( WriteExportData(csvWriter, results); - streamWriter.Flush(); - - var buffer = memoryStream.ToArray(); + await streamWriter.FlushAsync(cancellationToken).ConfigureAwait(false); - return Results.File(buffer, "text/csv"); - } - - - protected virtual void WriteExportData(CsvWriter csvWriter, IReadOnlyCollection results) - { - csvWriter.WriteRecords(results); + return memoryStream.ToArray(); } } diff --git a/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs b/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs index 77c778f4..ca7bca3d 100644 --- a/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs +++ b/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs @@ -20,7 +20,7 @@ public static IEndpointConventionBuilder MapFeatureEndpoints(this IEndpointRoute var features = builder.ServiceProvider.GetServices(); foreach (var feature in features) feature.AddRoutes(featureGroup); - + return featureGroup; } } diff --git a/src/MediatR.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs b/src/MediatR.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs index dd7e1ff5..1233db09 100644 --- a/src/MediatR.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs +++ b/src/MediatR.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs @@ -111,8 +111,10 @@ public static ProblemDetails ToProblemDetails(this Exception exception) var errors = new Dictionary(StringComparer.Ordinal); if (validationException.ValidationResult.ErrorMessage != null) + { foreach (var memberName in validationException.ValidationResult.MemberNames) errors[memberName] = [validationException.ValidationResult.ErrorMessage]; + } problemDetails.Title = "One or more validation errors occurred."; problemDetails.Status = StatusCodes.Status400BadRequest; @@ -133,8 +135,11 @@ public static ProblemDetails ToProblemDetails(this Exception exception) } } - problemDetails.Detail = exception?.Message; - problemDetails.Extensions.Add("exception", exception?.ToString()); + if (exception != null) + { + problemDetails.Detail = exception.Message; + problemDetails.Extensions.Add("exception", exception.ToString()); + } return problemDetails; } diff --git a/src/MediatR.CommandQuery.Endpoints/RouteHandlerBuilderExtensions.cs b/src/MediatR.CommandQuery.Endpoints/RouteHandlerBuilderExtensions.cs new file mode 100644 index 00000000..b6700293 --- /dev/null +++ b/src/MediatR.CommandQuery.Endpoints/RouteHandlerBuilderExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace MediatR.CommandQuery.Endpoints; + +public static class RouteHandlerBuilderExtensions +{ + public static RouteHandlerBuilder WithEntityMetadata(this RouteHandlerBuilder builder, string entityName) + { + return builder + .ProducesProblem(StatusCodes.Status500InternalServerError) + .ProducesValidationProblem() + .WithTags(entityName); + } + +} diff --git a/src/MediatR.CommandQuery/Converters/PolymorphicConverter.cs b/src/MediatR.CommandQuery/Converters/PolymorphicConverter.cs index 6d38488d..74693ff5 100644 --- a/src/MediatR.CommandQuery/Converters/PolymorphicConverter.cs +++ b/src/MediatR.CommandQuery/Converters/PolymorphicConverter.cs @@ -19,34 +19,33 @@ public override bool CanConvert(Type typeToConvert) public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) - throw new JsonException(); + throw new JsonException("JsonReader expected start object token type"); reader.Read(); if (reader.TokenType != JsonTokenType.PropertyName) - throw new JsonException(); - + throw new JsonException("JsonReader expected property name token type"); if (!reader.ValueTextEquals(TypeDiscriminator.EncodedUtf8Bytes)) - throw new JsonException(); + throw new JsonException("JsonReader expected '$type' property name"); reader.Read(); if (reader.TokenType != JsonTokenType.String) - throw new JsonException(); + throw new JsonException("JsonReader expected string token type"); var typeDiscriminator = reader.GetString(); if (typeDiscriminator == null) - throw new JsonException(); + throw new JsonException("JsonReader expected non null string value"); var type = Type.GetType(typeDiscriminator); if (type == null) - throw new JsonException(); + throw new JsonException($"JsonReader could not resolve type {typeDiscriminator}"); reader.Read(); if (reader.TokenType != JsonTokenType.PropertyName) - throw new JsonException(); + throw new JsonException("JsonReader expected property name token type"); if (!reader.ValueTextEquals(TypeInstance.EncodedUtf8Bytes)) - throw new JsonException(); + throw new JsonException("JsonReader expected '$instance' property name"); var instance = JsonSerializer.Deserialize(ref reader, type, options); diff --git a/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs b/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs index 1ecfc063..504d4d0b 100644 --- a/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs +++ b/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs @@ -43,7 +43,7 @@ public RemoteDispatcher(HttpClient httpClient, JsonSerializerOptions serializerO var cacheTag = cacheRequest.GetCacheTag(); var cacheOptions = new HybridCacheEntryOptions { - Expiration = cacheRequest.SlidingExpiration() + Expiration = cacheRequest.SlidingExpiration(), }; return await _hybridCache diff --git a/templates/EntityEndpoint.csx b/templates/EntityEndpoint.csx index 9cd5e370..0c6c1087 100644 --- a/templates/EntityEndpoint.csx +++ b/templates/EntityEndpoint.csx @@ -32,6 +32,7 @@ public string WriteCode() CodeBuilder.AppendLine("using System;"); CodeBuilder.AppendLine("using MediatR;"); CodeBuilder.AppendLine("using MediatR.CommandQuery.Endpoints;"); + CodeBuilder.AppendLine("using Microsoft.Extensions.Logging;"); if (!string.IsNullOrEmpty(modelNamespace)) CodeBuilder.AppendLine($"using {modelNamespace};"); @@ -73,7 +74,8 @@ private void GenerateClass(string readModel, string createModel, string updateMo private void GenerateConstructor(string className) { - CodeBuilder.AppendLine($"public {className}(IMediator mediator) : base(mediator, \"{Entity.EntityClass}\")"); + CodeBuilder.AppendLine($"public {className}(ILoggerFactory loggerFactory, IMediator mediator)"); + CodeBuilder.AppendLine($" : base(loggerFactory, mediator, \"{Entity.EntityClass}\")"); CodeBuilder.AppendLine("{"); CodeBuilder.AppendLine(); CodeBuilder.AppendLine("}"); diff --git a/test/MediatR.CommandQuery.EntityFrameworkCore.SqlServer.Tests/SerializationTests.cs b/test/MediatR.CommandQuery.EntityFrameworkCore.SqlServer.Tests/SerializationTests.cs index 984fe0b2..98d7df14 100644 --- a/test/MediatR.CommandQuery.EntityFrameworkCore.SqlServer.Tests/SerializationTests.cs +++ b/test/MediatR.CommandQuery.EntityFrameworkCore.SqlServer.Tests/SerializationTests.cs @@ -3,6 +3,8 @@ using MediatR.CommandQuery.Commands; using MediatR.CommandQuery.Converters; using MediatR.CommandQuery.EntityFrameworkCore.SqlServer.Tests.Domain.Audit.Models; +using MediatR.CommandQuery.EntityFrameworkCore.SqlServer.Tests.Domain.Priority.Models; +using MediatR.CommandQuery.Queries; namespace MediatR.CommandQuery.EntityFrameworkCore.SqlServer.Tests; @@ -65,6 +67,21 @@ public void PolymorphicConverterTest() deserializeCommand.Should().BeAssignableTo(); } + [Fact] + public void PolymorphicConverterEntityIdentifierQueryTest() + { + var queryCommand = new EntityIdentifierQuery(null, Constants.PriorityConstants.Normal); + + var options = SerializerOptions(); + + var json = JsonSerializer.Serialize(queryCommand, options); + json.Should().NotBeNullOrEmpty(); + + var deserializeCommand = JsonSerializer.Deserialize(json, typeof(IBaseRequest), options); + deserializeCommand.Should().NotBeNull(); + deserializeCommand.Should().BeAssignableTo(); + } + [Fact] public void PolymorphicConverterNullTest() {