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));
+ }
+}