diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs index a23fa1b..c4814fb 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs @@ -1,10 +1,11 @@ using System.Diagnostics.CodeAnalysis; - +using System.Reflection; +using System.Text.RegularExpressions; using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; - using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; @@ -22,35 +23,34 @@ public static class CqrsRouteMapper /// /// /// The route template for API. + /// Multiple routes should be mapped when for nullable route parameters. + /// Replace route parameter with given string to represent null. /// The type of the query. /// - public static IEndpointConventionBuilder MapQuery( - this IEndpointRouteBuilder app, - [StringSyntax("Route")] string route) - { - return app.MapQuery(route, ([AsParameters] T query) => query); - } - - /// - /// Map a command API, using different HTTP methods based on prefix. See example for details. - /// - /// - /// The route template. - /// The type of the command. /// + /// The following code: /// - /// app.MapCommand<CreateItemCommand>("/items"); // Starts with 'Create' or 'Add' - POST - /// app.MapCommand<UpdateItemCommand>("/items/{id:int}") // Starts with 'Update' or 'Replace' - PUT - /// app.MapCommand<DeleteCommand>("/items/{id:int}") // Starts with 'Delete' or 'Remove' - DELETE - /// app.MapCommand<ResetItemCommand>("/items/{id:int}:reset) // Others - PUT + /// app.MapQuery<ItemQuery>("apps/{appName}/instance/{instanceId}/roles", true); + /// + /// would register following routes: + /// + /// apps/-/instance/-/roles + /// apps/{appName}/instance/-/roles + /// apps/-/instance/{instanceId}/roles + /// apps/{appName}/instance/{instanceId}/roles /// /// - /// - public static IEndpointConventionBuilder MapCommand( + public static IEndpointConventionBuilder MapQuery( this IEndpointRouteBuilder app, - [StringSyntax("Route")] string route) + [StringSyntax("Route")] string route, + bool mapNullableRouteParameters = false, + string nullRouteParameterPattern = "-") { - return app.MapCommand(route, ([AsParameters] T command) => command); + return app.MapQuery( + route, + ([AsParameters] T query) => query, + mapNullableRouteParameters, + nullRouteParameterPattern); } /// @@ -59,11 +59,28 @@ public static IEndpointConventionBuilder MapCommand( /// /// The route template. /// The delegate that returns a instance. + /// Multiple routes should be mapped when for nullable route parameters. + /// Replace route parameter with given string to represent null. /// + /// + /// The following code: + /// + /// app.MapQuery("apps/{appName}/instance/{instanceId}/roles", (string? appName, string? instanceId) => new ItemQuery(appName, instanceId), true); + /// + /// would register following routes: + /// + /// apps/-/instance/-/roles + /// apps/{appName}/instance/-/roles + /// apps/-/instance/{instanceId}/roles + /// apps/{appName}/instance/{instanceId}/roles + /// + /// public static IEndpointConventionBuilder MapQuery( this IEndpointRouteBuilder app, [StringSyntax("Route")] string route, - Delegate handler) + Delegate handler, + bool mapNullableRouteParameters = false, + string nullRouteParameterPattern = "-") { var isQuery = handler.Method.ReturnType.GetInterfaces().Where(x => x.IsGenericType) .Any(x => QueryTypes.Contains(x.GetGenericTypeDefinition())); @@ -73,9 +90,69 @@ public static IEndpointConventionBuilder MapQuery( "delegate does not return a query, please make sure it returns object that implement IQuery<> or IListQuery<> or interface that inherit from them"); } + if (mapNullableRouteParameters == false) + { + return app.MapGet(route, handler).AddEndpointFilter(); + } + + if (string.IsNullOrWhiteSpace(nullRouteParameterPattern)) + { + throw new ArgumentNullException( + nameof(nullRouteParameterPattern), + "argument must not be null or empty"); + } + + var parsedRoute = RoutePatternFactory.Parse(route); + var context = new NullabilityInfoContext(); + var nullableRouteProperties = handler.Method.ReturnType.GetProperties() + .Where( + p => p.GetMethod != null + && p.SetMethod != null + && context.Create(p.GetMethod.ReturnParameter).ReadState == NullabilityState.Nullable) + .ToList(); + var nullableRoutePattern = parsedRoute.Parameters + .Where( + x => nullableRouteProperties.Any( + y => string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase))) + .ToList(); + var subsets = GetNotEmptySubsets(nullableRoutePattern); + foreach (var subset in subsets) + { + var newRoute = subset.Aggregate( + route, + (r, x) => + { + var regex = new Regex("{" + x.Name + "[^}]*?}", RegexOptions.IgnoreCase); + return regex.Replace(r, nullRouteParameterPattern); + }); + app.MapGet(newRoute, handler).AddEndpointFilter(); + } + return app.MapGet(route, handler).AddEndpointFilter(); } + /// + /// Map a command API, using different HTTP methods based on prefix. See example for details. + /// + /// + /// The route template. + /// The type of the command. + /// + /// + /// app.MapCommand<CreateItemCommand>("/items"); // Starts with 'Create' or 'Add' - POST + /// app.MapCommand<UpdateItemCommand>("/items/{id:int}") // Starts with 'Update' or 'Replace' - PUT + /// app.MapCommand<DeleteCommand>("/items/{id:int}") // Starts with 'Delete' or 'Remove' - DELETE + /// app.MapCommand<ResetItemCommand>("/items/{id:int}:reset) // Others - PUT + /// + /// + /// + public static IEndpointConventionBuilder MapCommand( + this IEndpointRouteBuilder app, + [StringSyntax("Route")] string route) + { + return app.MapCommand(route, ([AsParameters] T command) => command); + } + /// /// Map a command API, using different method based on type name prefix. /// @@ -174,4 +251,18 @@ private static void EnsureDelegateReturnTypeIsCommand(Delegate handler) "handler does not return command, check if delegate returns type that implements ICommand<> or ICommand<,>"); } } + + private static List GetNotEmptySubsets(ICollection items) + { + var subsetCount = 1 << items.Count; + var results = new List(subsetCount); + for (var i = 1; i < subsetCount; i++) + { + var index = i; + var subset = items.Where((_, j) => (index & (1 << j)) > 0).ToArray(); + results.Add(subset); + } + + return results; + } } diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQuery.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQuery.cs index 74bb3b1..82c7908 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQuery.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQuery.cs @@ -2,4 +2,4 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries; -public record GetStringQuery : IQuery; \ No newline at end of file +public record GetStringQuery(string? AppId = null, int? StringId = null) : IQuery; diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs index 766731b..a224822 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs @@ -35,6 +35,7 @@ var apis = app.NewVersionedApi(); var v1 = apis.MapGroup("/api/v{version:apiVersion}").HasApiVersion(1); +v1.MapQuery("apps/{appId}/strings/{stringId:int}/value", true); v1.MapQuery("strings/{id:int}"); v1.MapQuery("strings"); v1.MapCommand("strings", (CreatePayload payload) => new CreateCommand(payload.NeedError)); diff --git a/test/Cnblogs.Architecture.IntegrationTests/CqrsRouteMapperTests.cs b/test/Cnblogs.Architecture.IntegrationTests/CqrsRouteMapperTests.cs index 532a7f5..2333b3a 100644 --- a/test/Cnblogs.Architecture.IntegrationTests/CqrsRouteMapperTests.cs +++ b/test/Cnblogs.Architecture.IntegrationTests/CqrsRouteMapperTests.cs @@ -77,4 +77,23 @@ public async Task DeleteItem_SuccessAsync() // Assert response.Should().BeSuccessful(); } -} \ No newline at end of file + + [Fact] + public async Task GetItem_NullableRouteValue_SuccessAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var responses = new List + { + await builder.CreateClient().GetAsync("/api/v1/apps/-/strings/-/value"), + await builder.CreateClient().GetAsync("/api/v1/apps/-/strings/1/value"), + await builder.CreateClient().GetAsync("/api/v1/apps/someApp/strings/-/value"), + await builder.CreateClient().GetAsync("/api/v1/apps/someApp/strings/1/value") + }; + + // Assert + responses.Should().Match(x => x.All(y => y.IsSuccessStatusCode)); + } +}