diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs index 0b3b675..41b628c 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs @@ -8,21 +8,9 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// /// Execute command returned by endpoint handler, and then map command response to HTTP response. /// -public class CommandEndpointHandler : IEndpointFilter +public class CommandEndpointHandler(IMediator mediator, IOptions options) : IEndpointFilter { - private readonly IMediator _mediator; - private readonly CqrsHttpOptions _options; - - /// - /// Create a command endpoint handler. - /// - /// - /// The options for command response handling. - public CommandEndpointHandler(IMediator mediator, IOptions options) - { - _mediator = mediator; - _options = options.Value; - } + private readonly CqrsHttpOptions _options = options.Value; /// public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) @@ -40,7 +28,7 @@ public CommandEndpointHandler(IMediator mediator, IOptions opti return command; } - var response = await _mediator.Send(command); + var response = await mediator.Send(command); if (response is null) { // should not be null @@ -59,8 +47,8 @@ public CommandEndpointHandler(IMediator mediator, IOptions opti if (commandResponse is IObjectResponse objectResponse) { return context.HttpContext.Request.Headers.CqrsVersion() > 1 - ? Results.Extensions.Cqrs(response) - : Results.Ok(objectResponse.GetResult()); + ? Results.Extensions.Cqrs(response, _options.DefaultJsonSerializerOptions) + : Results.Json(objectResponse.GetResult(), _options.DefaultJsonSerializerOptions); } return Results.NoContent(); diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ControllerOptionInjector.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ControllerOptionInjector.cs index b8c8d1a..2b5ebc8 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ControllerOptionInjector.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ControllerOptionInjector.cs @@ -23,8 +23,17 @@ public static void AddCqrsModelBinderProvider(this MvcOptions options) /// Add custom model binder used for CQRS, like model binder for . /// /// - public static void AddCqrsModelBinderProvider(this IMvcBuilder builder) + public static IMvcBuilder AddCqrsModelBinderProvider(this IMvcBuilder builder) { - builder.AddMvcOptions(options => options.AddCqrsModelBinderProvider()); + return builder.AddMvcOptions(options => options.AddCqrsModelBinderProvider()); + } + + /// + /// Add long to string json converter. + /// + /// . + public static IMvcBuilder AddLongToStringJsonConverter(this IMvcBuilder builder) + { + return builder.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new LongToStringConverter())); } } diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptions.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptions.cs index ba5a454..71ea276 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptions.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptions.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; using Microsoft.AspNetCore.Http; @@ -17,4 +18,12 @@ public class CqrsHttpOptions /// Custom logic to handle error command response. /// public Func? CustomCommandErrorResponseMapper { get; set; } + + /// + /// Default json serializer options for minimal api. + /// + /// + /// For Controllers, please use builder.AddControllers().AddLongToStringJsonConverter(); + /// + public JsonSerializerOptions DefaultJsonSerializerOptions { get; set; } = new(JsonSerializerDefaults.Web); } diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptionsInjector.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptionsInjector.cs index 4b61921..8de27bc 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptionsInjector.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptionsInjector.cs @@ -42,4 +42,16 @@ public static CqrsInjector UseCustomCommandErrorResponseMapper( }); return injector; } + + /// + /// Serialize long to string for all json output. + /// + /// + /// + public static CqrsInjector AddLongToStringJsonConverter(this CqrsInjector injector) + { + injector.Services.Configure( + o => o.DefaultJsonSerializerOptions.Converters.Add(new LongToStringConverter())); + return injector; + } } diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResult.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResult.cs index e391fd0..8dfe755 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResult.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResult.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http; +using System.Text.Json; +using Microsoft.AspNetCore.Http; namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; @@ -6,12 +7,12 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// Send object as json and append X-Cqrs-Version header /// /// -public class CqrsResult(object commandResponse) : IResult +public class CqrsResult(object commandResponse, JsonSerializerOptions? options = null) : IResult { /// public Task ExecuteAsync(HttpContext httpContext) { httpContext.Response.Headers.Append("X-Cqrs-Version", "2"); - return httpContext.Response.WriteAsJsonAsync(commandResponse); + return httpContext.Response.WriteAsJsonAsync(commandResponse, options); } } diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResultExtensions.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResultExtensions.cs index cb6a2ef..8f46e56 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResultExtensions.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResultExtensions.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Http; namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; @@ -12,10 +13,11 @@ public static class CqrsResultExtensions /// /// /// The command response. + /// Optional json serializer options. /// - public static IResult Cqrs(this IResultExtensions extensions, object result) + public static IResult Cqrs(this IResultExtensions extensions, object result, JsonSerializerOptions? options = null) { ArgumentNullException.ThrowIfNull(extensions); - return new CqrsResult(result); + return new CqrsResult(result, options); } } diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/LongToStringConverter.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/LongToStringConverter.cs new file mode 100644 index 0000000..6bf7781 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/LongToStringConverter.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Converter between long and string +/// +internal class LongToStringConverter : JsonConverter +{ + /// + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + return reader.GetInt64(); + } + + var raw = reader.GetString(); + if (string.IsNullOrWhiteSpace(raw)) + { + throw new JsonException("string is empty"); + } + + var success = long.TryParse(raw, out var parsed); + if (success == false) + { + throw new JsonException("string value can't be converted to long"); + } + + return parsed; + } + + /// + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/QueryEndpointHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/QueryEndpointHandler.cs index f16a7e7..0316557 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/QueryEndpointHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/QueryEndpointHandler.cs @@ -1,25 +1,14 @@ using MediatR; - using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// /// The query executor, auto send query to . /// -public class QueryEndpointHandler : IEndpointFilter +public class QueryEndpointHandler(IMediator mediator, IOptions cqrsHttpOptions) : IEndpointFilter { - private readonly IMediator _mediator; - - /// - /// Create a . - /// - /// The mediator to use. - public QueryEndpointHandler(IMediator mediator) - { - _mediator = mediator; - } - /// public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { @@ -34,7 +23,9 @@ public QueryEndpointHandler(IMediator mediator) return query; } - var response = await _mediator.Send(query); - return response == null ? Results.NotFound() : Results.Ok(response); + var response = await mediator.Send(query); + return response == null + ? Results.NotFound() + : Results.Json(response, cqrsHttpOptions.Value.DefaultJsonSerializerOptions); } } diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommand.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommand.cs new file mode 100644 index 0000000..4c139a9 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommand.cs @@ -0,0 +1,7 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject.Application.Errors; +using Cnblogs.Architecture.IntegrationTestProject.Models; + +namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands; + +public record CreateLongToStringCommand(long Id, bool ValidateOnly = false) : ICommand; diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommandHandler.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommandHandler.cs new file mode 100644 index 0000000..10a1b3d --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommandHandler.cs @@ -0,0 +1,16 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject.Application.Errors; +using Cnblogs.Architecture.IntegrationTestProject.Models; + +namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands; + +public class CreateLongToStringCommandHandler : ICommandHandler +{ + /// + public Task> Handle( + CreateLongToStringCommand request, + CancellationToken cancellationToken) + { + return Task.FromResult(CommandResponse.Success(new LongToStringModel() { Id = request.Id })); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQuery.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQuery.cs new file mode 100644 index 0000000..ac2ac59 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQuery.cs @@ -0,0 +1,6 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject.Models; + +namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries; + +public record GetLongToStringQuery(long Id) : IQuery; diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQueryHandler.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQueryHandler.cs new file mode 100644 index 0000000..3583d8e --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQueryHandler.cs @@ -0,0 +1,13 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject.Models; + +namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries; + +public class GetLongToStringQueryHandler : IQueryHandler +{ + /// + public Task Handle(GetLongToStringQuery request, CancellationToken cancellationToken) + { + return Task.FromResult((LongToStringModel?)new LongToStringModel() { Id = request.Id }); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Controllers/TestController.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Controllers/TestController.cs index 9207d1f..621e2e5 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Controllers/TestController.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Controllers/TestController.cs @@ -2,6 +2,8 @@ using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; using Cnblogs.Architecture.IntegrationTestProject.Application.Commands; +using Cnblogs.Architecture.IntegrationTestProject.Application.Queries; +using Cnblogs.Architecture.IntegrationTestProject.Models; using Cnblogs.Architecture.IntegrationTestProject.Payloads; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -10,15 +12,8 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Controllers; [ApiVersion("1")] [Route("/api/v{version:apiVersion}/mvc")] -public class TestController : ApiControllerBase +public class TestController(IMediator mediator) : ApiControllerBase { - private readonly IMediator _mediator; - - public TestController(IMediator mediator) - { - _mediator = mediator; - } - [HttpGet("paging")] public Task PagingParamsAsync([FromQuery] PagingParams? pagingParams) { @@ -29,7 +24,20 @@ public TestController(IMediator mediator) public async Task PutStringAsync(int id, [FromBody] UpdatePayload payload) { var response = - await _mediator.Send(new UpdateCommand(id, payload.NeedValidationError, payload.NeedExecutionError)); + await mediator.Send(new UpdateCommand(id, payload.NeedValidationError, payload.NeedExecutionError)); + return HandleCommandResponse(response); + } + + [HttpGet("json/long-to-string/{id:long}")] + public async Task GetLongToStringModelAsync(long id) + { + return await mediator.Send(new GetLongToStringQuery(id)); + } + + [HttpPost("json/long-to-string")] + public async Task CreateLongToStringModelAsync([FromBody] LongToStringModel model) + { + var response = await mediator.Send(new CreateLongToStringCommand(model.Id)); return HandleCommandResponse(response); } } diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Models/LongToStringModel.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Models/LongToStringModel.cs new file mode 100644 index 0000000..7b543b5 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Models/LongToStringModel.cs @@ -0,0 +1,6 @@ +namespace Cnblogs.Architecture.IntegrationTestProject.Models; + +public class LongToStringModel +{ + public long Id { get; set; } +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs index 6c9aa95..45aef48 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs @@ -12,9 +12,10 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddCqrs(Assembly.GetExecutingAssembly(), typeof(TestIntegrationEvent).Assembly) + .AddLongToStringJsonConverter() .AddDefaultDateTimeAndRandomProvider() .AddEventBus(o => o.UseDapr(Constants.AppName)); -builder.Services.AddControllers().AddCqrsModelBinderProvider(); +builder.Services.AddControllers().AddCqrsModelBinderProvider().AddLongToStringJsonConverter(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddCnblogsApiVersioning(); @@ -46,6 +47,8 @@ async (int stringId, [FromQuery] bool found = true) => await Task.FromResult(new GetStringQuery(StringId: stringId, Found: found))); v1.MapQuery("strings"); +v1.MapQuery("long-to-string/{id:long}"); +v1.MapCommand("long-to-string"); v1.MapCommand( "strings", (CreatePayload payload) => Task.FromResult(new CreateCommand(payload.NeedError, payload.Data))); diff --git a/test/Cnblogs.Architecture.IntegrationTests/CustomJsonConverterTests.cs b/test/Cnblogs.Architecture.IntegrationTests/CustomJsonConverterTests.cs new file mode 100644 index 0000000..dd7aa41 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/CustomJsonConverterTests.cs @@ -0,0 +1,96 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Cnblogs.Architecture.IntegrationTestProject; +using Cnblogs.Architecture.IntegrationTestProject.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Cnblogs.Architecture.IntegrationTests; + +public class CustomJsonConverterTests +{ + private static readonly JsonSerializerOptions WebDefaults = new(JsonSerializerDefaults.Web); + + [Theory] + [InlineData("/api/v1/mvc/json/long-to-string/")] + [InlineData("/api/v1/long-to-string/")] + public async Task LongToJson_WriteLongToString_CanBeParsedByServerAsync(string baseUrl) + { + // Arrange + const long id = 202410267558024668; + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().GetAsync(baseUrl + id); + var serverObject = await response.Content.ReadFromJsonAsync(WebDefaults); + + // Assert + serverObject.Should().BeEquivalentTo(new LongToStringModel() { Id = id }); + } + + [Theory] + [InlineData("/api/v1/mvc/json/long-to-string/")] + [InlineData("/api/v1/long-to-string/")] + public async Task LongToJson_WriteLongToString_IsStringInJsonAsync(string baseUrl) + { + // Arrange + const long id = 202410267558024668; + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().GetAsync(baseUrl + id); + var browserObject = await response.Content.ReadFromJsonAsync(WebDefaults); + + // Assert + browserObject.EnumerateObject().First().Value.GetString().Should().Be(id.ToString()); + } + + [Theory] + [InlineData("/api/v1/mvc/json/long-to-string/")] + [InlineData("/api/v1/long-to-string/")] + public async Task LongToJson_ReadLongFromString_SuccessAsync(string url) + { + // Arrange + const string json = """ + { + "id": "202410267558024668" + } + """; + + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PostAsync( + url, + new StringContent(json, Encoding.UTF8, "application/json")); + var model = await response.Content.ReadFromJsonAsync(WebDefaults); + + // Assert + model.EnumerateObject().First().Value.GetString().Should().Be("202410267558024668"); + } + + [Theory] + [InlineData("/api/v1/mvc/json/long-to-string/")] + [InlineData("/api/v1/long-to-string/")] + public async Task LongToJson_ReadLongFromNumber_SuccessAsync(string url) + { + // Arrange + const string json = """ + { + "id": 202410267558024668 + } + """; + + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PostAsync( + url, + new StringContent(json, Encoding.UTF8, "application/json")); + var model = await response.Content.ReadFromJsonAsync(WebDefaults); + + // Assert + model.EnumerateObject().First().Value.GetString().Should().Be("202410267558024668"); + } +}