diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CommandResponse.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CommandResponse.cs index feb4507..89fab2e 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CommandResponse.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CommandResponse.cs @@ -5,7 +5,7 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// /// Response returned by . /// -public abstract record CommandResponse : IValidationResponse, ILockableResponse +public record CommandResponse : IValidationResponse, ILockableResponse { /// /// Check if validation fails. @@ -69,6 +69,7 @@ public CommandResponse() public CommandResponse(TError errorCode) { ErrorCode = errorCode; + ErrorMessage = errorCode.Name; } /// @@ -173,9 +174,9 @@ private CommandResponse(TView response) /// /// The model to return. /// A with given result. - public static CommandResponse Success(TView view) + public static CommandResponse Success(TView? view) { - return new CommandResponse(view); + return view is null ? Success() : new CommandResponse(view); } /// diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs index d25e7c3..a60a200 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs @@ -61,10 +61,17 @@ protected IActionResult HandleCommandResponse(CommandResponse private IActionResult HandleErrorCommandResponse(CommandResponse response) where TError : Enumeration { - return CqrsHttpOptions.CommandErrorResponseType switch + var errorResponseType = CqrsHttpOptions.CommandErrorResponseType; + if (Request.Headers.Accept.Contains("application/cqrs")) + { + errorResponseType = ErrorResponseType.Cqrs; + } + + return errorResponseType switch { ErrorResponseType.PlainText => MapErrorCommandResponseToPlainText(response), ErrorResponseType.ProblemDetails => MapErrorCommandResponseToProblemDetails(response), + ErrorResponseType.Cqrs => MapErrorCommandResponseToCqrsResponse(response), ErrorResponseType.Custom => CustomErrorCommandResponseMap(response), _ => throw new ArgumentOutOfRangeException( $"Unsupported CommandErrorResponseType: {CqrsHttpOptions.CommandErrorResponseType}") @@ -90,6 +97,12 @@ protected virtual IActionResult CustomErrorCommandResponseMap(CommandRes return MapErrorCommandResponseToPlainText(response); } + private IActionResult MapErrorCommandResponseToCqrsResponse(CommandResponse response) + where TError : Enumeration + { + return BadRequest(response); + } + private IActionResult MapErrorCommandResponseToProblemDetails(CommandResponse response) where TError : Enumeration { diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs index 0616ee6..359ba80 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs @@ -69,10 +69,17 @@ public CommandEndpointHandler(IMediator mediator, IOptions opti private IResult HandleErrorCommandResponse(CommandResponse response, HttpContext context) { - return _options.CommandErrorResponseType switch + var errorResponseType = _options.CommandErrorResponseType; + if (context.Request.Headers.Accept.Contains("application/cqrs")) + { + errorResponseType = ErrorResponseType.Cqrs; + } + + return errorResponseType switch { ErrorResponseType.PlainText => HandleErrorCommandResponseWithPlainText(response), ErrorResponseType.ProblemDetails => HandleErrorCommandResponseWithProblemDetails(response), + ErrorResponseType.Cqrs => HandleErrorCommandResponseWithCqrs(response), ErrorResponseType.Custom => _options.CustomCommandErrorResponseMapper?.Invoke(response, context) ?? HandleErrorCommandResponseWithPlainText(response), _ => throw new ArgumentOutOfRangeException( @@ -80,6 +87,11 @@ private IResult HandleErrorCommandResponse(CommandResponse response, HttpContext }; } + private static IResult HandleErrorCommandResponseWithCqrs(CommandResponse response) + { + return Results.BadRequest(response); + } + private static IResult HandleErrorCommandResponseWithPlainText(CommandResponse response) { if (response.IsValidationError) diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ErrorResponseType.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ErrorResponseType.cs index bb7cf82..3b569f1 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ErrorResponseType.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ErrorResponseType.cs @@ -1,3 +1,5 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; + namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// @@ -15,6 +17,11 @@ public enum ErrorResponseType /// ProblemDetails, + /// + /// Returns + /// + Cqrs, + /// /// Handles command error by custom logic. /// diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj index 7802fbf..8115e6f 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj @@ -5,9 +5,9 @@ - + - + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/CqrsServiceAgent.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/CqrsServiceAgent.cs new file mode 100644 index 0000000..c82684b --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/CqrsServiceAgent.cs @@ -0,0 +1,261 @@ +using System.Net; +using System.Net.Http.Json; +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; + +namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; + +/// +/// Service Agent for CQRS +/// +public abstract class CqrsServiceAgent +{ + /// + /// The underlying . + /// + protected HttpClient HttpClient { get; } + + /// + /// Create a service agent for cqrs api. + /// + /// The underlying HttpClient. + protected CqrsServiceAgent(HttpClient httpClient) + { + HttpClient = httpClient; + } + + /// + /// Execute a command with DELETE method. + /// + /// The url. + /// Response type. + /// The response. + public async Task> DeleteCommandAsync(string url) + { + var response = await HttpClient.DeleteAsync(url); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with DELETE method. + /// + /// The route of the API. + public async Task> DeleteCommandAsync(string url) + { + var response = await HttpClient.DeleteAsync(url); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with POST method. + /// + /// The route of the API. + public async Task> PostCommandAsync(string url) + { + var response = await HttpClient.PostAsync(url, new StringContent(string.Empty)); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with POST method and payload. + /// + /// The route of the API. + /// The request body. + /// The type of request body. + public async Task> PostCommandAsync(string url, TPayload payload) + { + var response = await HttpClient.PostAsJsonAsync(url, payload); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with POST method and payload. + /// + /// The route of the API. + /// The request body. + /// The type of response body. + /// The type of request body. + /// The response body. + public async Task> PostCommandAsync( + string url, + TPayload payload) + { + var response = await HttpClient.PostAsJsonAsync(url, payload); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with PUT method and payload. + /// + /// The route of API. + public async Task> PutCommandAsync(string url) + { + var response = await HttpClient.PutAsync(url, new StringContent(string.Empty)); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with PUT method and payload. + /// + /// The route of API. + /// The request body. + /// The type of request body. + /// The command response. + public async Task> PutCommandAsync(string url, TPayload payload) + { + var response = await HttpClient.PutAsJsonAsync(url, payload); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with PUT method and payload. + /// + /// The route of API. + /// The request body. + /// The type of response body. + /// The type of request body. + /// The response body. + public async Task> PutCommandAsync( + string url, + TPayload payload) + { + var response = await HttpClient.PutAsJsonAsync(url, payload); + return await HandleCommandResponseAsync(response); + } + + /// + /// Query item with GET method. + /// + /// The route of the API. + /// The type of item to get. + /// The query result, can be null if item does not exists or status code is 404. + public async Task GetItemAsync(string url) + { + try + { + return await HttpClient.GetFromJsonAsync(url); + } + catch (HttpRequestException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + return default; + } + + throw; + } + } + + /// + /// Batch get items with GET method. + /// + /// The route of the API. + /// The name of id field. + /// The id list. + /// The type of the query result item. + /// The type of the id. + /// A list of items that contains id that in , the order or count of the items are not guaranteed. + public async Task> BatchGetItemsAsync( + string url, + string paramName, + IEnumerable ids) + where TId : notnull + { + var query = string.Join( + '&', + ids.Select(i => $"{WebUtility.UrlEncode(paramName)}={WebUtility.UrlEncode(i.ToString())}")); + url = $"{url}{(url.Contains('?') ? '&' : '?')}{query}"; + return await HttpClient.GetFromJsonAsync>(url) ?? new List(); + } + + /// + /// Get paged list of items based on url. + /// + /// The route of the API. + /// The paging parameters, including page size and page index. + /// Specifies the order of items to return. + /// The type of items to query. + /// The paged list of items. An empty list is returned when there is no result. + public async Task> ListPagedItemsAsync( + string url, + PagingParams? pagingParams = null, + string? orderByString = null) + { + return await ListPagedItemsAsync(url, pagingParams?.PageIndex, pagingParams?.PageSize, orderByString); + } + + /// + /// Get paged list of items based on url. + /// + /// The route of the API. + /// The page index. + /// The page size. + /// Specifies the order of items to return. + /// The type of items to query. + /// The paged list of items. An empty list is returned when there is no result. + public async Task> ListPagedItemsAsync( + string url, + int? pageIndex, + int? pageSize, + string? orderByString = null) + { + if (pageIndex.HasValue && pageSize.HasValue) + { + var query = $"pageIndex={pageIndex}&pageSize={pageSize}&orderByString={orderByString}"; + url = url.Contains('?') ? url + "&" + query : url + "?" + query; + } + + return await HttpClient.GetFromJsonAsync>(url) ?? new PagedList(); + } + + private static async Task> HandleCommandResponseAsync( + HttpResponseMessage httpResponseMessage) + { + if (httpResponseMessage.IsSuccessStatusCode) + { + var result = await httpResponseMessage.Content.ReadFromJsonAsync(); + return CommandResponse.Success(result); + } + + var response = await httpResponseMessage.Content.ReadFromJsonAsync(); + if (response is null) + { + return CommandResponse.Fail(ServiceAgentError.UnknownError); + } + + return new CommandResponse + { + IsConcurrentError = response.IsConcurrentError, + IsValidationError = response.IsValidationError, + ErrorMessage = response.ErrorMessage, + LockAcquired = response.LockAcquired, + ValidationErrors = response.ValidationErrors, + ErrorCode = new ServiceAgentError(1, response.ErrorMessage) + }; + } + + private static async Task> HandleCommandResponseAsync( + HttpResponseMessage message) + { + if (message.IsSuccessStatusCode) + { + return CommandResponse.Success(); + } + + var response = await message.Content.ReadFromJsonAsync(); + if (response is null) + { + return CommandResponse.Fail(ServiceAgentError.UnknownError); + } + + return new CommandResponse + { + IsConcurrentError = response.IsConcurrentError, + IsValidationError = response.IsValidationError, + ErrorMessage = response.ErrorMessage, + LockAcquired = response.LockAcquired, + ValidationErrors = response.ValidationErrors, + ErrorCode = new ServiceAgentError(1, response.ErrorMessage) + }; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/IApiException.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/IApiException.cs index ce4db8f..47f3328 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/IApiException.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/IApiException.cs @@ -6,6 +6,7 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; /// Defines exceptions threw when doing an API call. /// /// The type of this API exception. +[Obsolete("Try migrate to CqrsServiceAgent")] public interface IApiException where TException : Exception, IApiException { diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/InjectExtensions.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/InjectExtensions.cs new file mode 100644 index 0000000..a950eb4 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/InjectExtensions.cs @@ -0,0 +1,50 @@ +using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; + +namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; + +/// +/// Inject helper for service agent +/// +public static class InjectExtensions +{ + /// + /// Inject a service agent to services. + /// + /// The . + /// The base uri for api. + /// The type of service agent + /// + public static IHttpClientBuilder AddServiceAgent(this IServiceCollection services, string baseUri) + where T : CqrsServiceAgent + { + return services.AddHttpClient( + h => + { + h.BaseAddress = new Uri(baseUri); + h.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + }); + } + + /// + /// Inject a service agent to services. + /// + /// The . + /// The base uri for api. + /// The type of api client. + /// The type of service agent + /// + public static IHttpClientBuilder AddServiceAgent( + this IServiceCollection services, + string baseUri) + where TClient : class + where TImplementation : CqrsServiceAgent, TClient + { + return services.AddHttpClient( + h => + { + h.BaseAddress = new Uri(baseUri); + h.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + }); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentBase.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentBase.cs index fd568a1..2d60860 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentBase.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentBase.cs @@ -9,6 +9,7 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; /// Base class for service agent. /// /// The type of exception that this service agent throws. +[Obsolete("Try migrate to CqrsServiceAgent")] public abstract class ServiceAgentBase where TException : Exception, IApiException { diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentError.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentError.cs new file mode 100644 index 0000000..d014f7c --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentError.cs @@ -0,0 +1,24 @@ +using Cnblogs.Architecture.Ddd.Domain.Abstractions; + +namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; + +/// +/// ServiceAgent errors. +/// +public class ServiceAgentError : Enumeration +{ + /// + /// The default error code. + /// + public static readonly ServiceAgentError UnknownError = new(-1, "Unknown error"); + + /// + /// Create a service agent error. + /// + /// The error code. + /// The error name. + public ServiceAgentError(int id, string name) + : base(id, name) + { + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTests/CommandResponseHandlerTests.cs b/test/Cnblogs.Architecture.IntegrationTests/CommandResponseHandlerTests.cs index 121c7c9..e6cfe28 100644 --- a/test/Cnblogs.Architecture.IntegrationTests/CommandResponseHandlerTests.cs +++ b/test/Cnblogs.Architecture.IntegrationTests/CommandResponseHandlerTests.cs @@ -1,4 +1,6 @@ +using System.Net.Http.Headers; using System.Net.Http.Json; +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; using Cnblogs.Architecture.IntegrationTestProject; using Cnblogs.Architecture.IntegrationTestProject.Application.Commands; @@ -31,7 +33,7 @@ public async Task MinimalApi_HavingError_BadRequestAsync(bool needValidationErro // Act var response = await builder.CreateClient().PutAsJsonAsync( "/api/v1/strings/1", - new UpdatePayload(needValidationError, needExecutionError)); + new UpdatePayload(needExecutionError, needValidationError)); var content = await response.Content.ReadAsStringAsync(); // Assert @@ -63,7 +65,7 @@ public async Task MinimalApi_HavingError_ProblemDetailsAsync(bool needValidation // Act var response = await builder.CreateClient().PutAsJsonAsync( "/api/v1/strings/1", - new UpdatePayload(needValidationError, needExecutionError)); + new UpdatePayload(needExecutionError, needValidationError)); var content = await response.Content.ReadFromJsonAsync(); // Assert @@ -71,6 +73,27 @@ public async Task MinimalApi_HavingError_ProblemDetailsAsync(bool needValidation content.Should().NotBeNull(); } + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task MinimalApi_HavingError_CommandResponseAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + var response = await client.PutAsJsonAsync( + "/api/v1/strings/1", + new UpdatePayload(needExecutionError, needValidationError)); + var commandResponse = await response.Content.ReadFromJsonAsync(); + + // Assert + response.Should().HaveClientError(); + commandResponse.Should().NotBeNull(); + commandResponse!.IsSuccess().Should().BeFalse(); + } + [Theory] [MemberData(nameof(ErrorPayloads))] public async Task MinimalApi_HavingError_CustomContentAsync(bool needValidationError, bool needExecutionError) @@ -143,6 +166,27 @@ public async Task Mvc_HavingError_ProblemDetailAsync(bool needValidationError, b content.Should().NotBeNull(); } + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task Mvc_HavingError_CommandResponseAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + var response = await client.PutAsJsonAsync( + "/api/v1/mvc/strings/1", + new UpdatePayload(needValidationError, needExecutionError)); + var content = await response.Content.ReadFromJsonAsync(); + + // Assert + response.Should().HaveClientError(); + content.Should().NotBeNull(); + content!.IsSuccess().Should().BeFalse(); + } + [Theory] [MemberData(nameof(ErrorPayloads))] public async Task Mvc_HavingError_CustomContentAsync(bool needValidationError, bool needExecutionError)