Skip to content

Commit b340d1a

Browse files
authored
Merge pull request #77 from cnblogs/75-auto-configure-nullable-parameters
feat: support mapping optional routes automatically
2 parents 6c72dec + 1affba9 commit b340d1a

File tree

4 files changed

+137
-26
lines changed

4 files changed

+137
-26
lines changed

src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
using System.Diagnostics.CodeAnalysis;
2-
2+
using System.Reflection;
3+
using System.Text.RegularExpressions;
34
using Cnblogs.Architecture.Ddd.Cqrs.Abstractions;
4-
55
using Microsoft.AspNetCore.Builder;
66
using Microsoft.AspNetCore.Http;
77
using Microsoft.AspNetCore.Routing;
8+
using Microsoft.AspNetCore.Routing.Patterns;
89

910
namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
1011

@@ -22,35 +23,34 @@ public static class CqrsRouteMapper
2223
/// </summary>
2324
/// <param name="app"><see cref="IApplicationBuilder"/></param>
2425
/// <param name="route">The route template for API.</param>
26+
/// <param name="mapNullableRouteParameters">Multiple routes should be mapped when for nullable route parameters.</param>
27+
/// <param name="nullRouteParameterPattern">Replace route parameter with given string to represent null.</param>
2528
/// <typeparam name="T">The type of the query.</typeparam>
2629
/// <returns></returns>
27-
public static IEndpointConventionBuilder MapQuery<T>(
28-
this IEndpointRouteBuilder app,
29-
[StringSyntax("Route")] string route)
30-
{
31-
return app.MapQuery(route, ([AsParameters] T query) => query);
32-
}
33-
34-
/// <summary>
35-
/// Map a command API, using different HTTP methods based on prefix. See example for details.
36-
/// </summary>
37-
/// <param name="app"><see cref="ApplicationBuilder"/></param>
38-
/// <param name="route">The route template.</param>
39-
/// <typeparam name="T">The type of the command.</typeparam>
4030
/// <example>
31+
/// The following code:
4132
/// <code>
42-
/// app.MapCommand&lt;CreateItemCommand&gt;("/items"); // Starts with 'Create' or 'Add' - POST
43-
/// app.MapCommand&lt;UpdateItemCommand&gt;("/items/{id:int}") // Starts with 'Update' or 'Replace' - PUT
44-
/// app.MapCommand&lt;DeleteCommand&gt;("/items/{id:int}") // Starts with 'Delete' or 'Remove' - DELETE
45-
/// app.MapCommand&lt;ResetItemCommand&gt;("/items/{id:int}:reset) // Others - PUT
33+
/// app.MapQuery&lt;ItemQuery&gt;("apps/{appName}/instance/{instanceId}/roles", true);
34+
/// </code>
35+
/// would register following routes:
36+
/// <code>
37+
/// apps/-/instance/-/roles
38+
/// apps/{appName}/instance/-/roles
39+
/// apps/-/instance/{instanceId}/roles
40+
/// apps/{appName}/instance/{instanceId}/roles
4641
/// </code>
4742
/// </example>
48-
/// <returns></returns>
49-
public static IEndpointConventionBuilder MapCommand<T>(
43+
public static IEndpointConventionBuilder MapQuery<T>(
5044
this IEndpointRouteBuilder app,
51-
[StringSyntax("Route")] string route)
45+
[StringSyntax("Route")] string route,
46+
bool mapNullableRouteParameters = false,
47+
string nullRouteParameterPattern = "-")
5248
{
53-
return app.MapCommand(route, ([AsParameters] T command) => command);
49+
return app.MapQuery(
50+
route,
51+
([AsParameters] T query) => query,
52+
mapNullableRouteParameters,
53+
nullRouteParameterPattern);
5454
}
5555

5656
/// <summary>
@@ -59,11 +59,28 @@ public static IEndpointConventionBuilder MapCommand<T>(
5959
/// <param name="app"><see cref="ApplicationBuilder"/></param>
6060
/// <param name="route">The route template.</param>
6161
/// <param name="handler">The delegate that returns a <see cref="IQuery{TView}"/> instance.</param>
62+
/// <param name="mapNullableRouteParameters">Multiple routes should be mapped when for nullable route parameters.</param>
63+
/// <param name="nullRouteParameterPattern">Replace route parameter with given string to represent null.</param>
6264
/// <returns></returns>
65+
/// <example>
66+
/// The following code:
67+
/// <code>
68+
/// app.MapQuery("apps/{appName}/instance/{instanceId}/roles", (string? appName, string? instanceId) => new ItemQuery(appName, instanceId), true);
69+
/// </code>
70+
/// would register following routes:
71+
/// <code>
72+
/// apps/-/instance/-/roles
73+
/// apps/{appName}/instance/-/roles
74+
/// apps/-/instance/{instanceId}/roles
75+
/// apps/{appName}/instance/{instanceId}/roles
76+
/// </code>
77+
/// </example>
6378
public static IEndpointConventionBuilder MapQuery(
6479
this IEndpointRouteBuilder app,
6580
[StringSyntax("Route")] string route,
66-
Delegate handler)
81+
Delegate handler,
82+
bool mapNullableRouteParameters = false,
83+
string nullRouteParameterPattern = "-")
6784
{
6885
var isQuery = handler.Method.ReturnType.GetInterfaces().Where(x => x.IsGenericType)
6986
.Any(x => QueryTypes.Contains(x.GetGenericTypeDefinition()));
@@ -73,9 +90,69 @@ public static IEndpointConventionBuilder MapQuery(
7390
"delegate does not return a query, please make sure it returns object that implement IQuery<> or IListQuery<> or interface that inherit from them");
7491
}
7592

93+
if (mapNullableRouteParameters == false)
94+
{
95+
return app.MapGet(route, handler).AddEndpointFilter<QueryEndpointHandler>();
96+
}
97+
98+
if (string.IsNullOrWhiteSpace(nullRouteParameterPattern))
99+
{
100+
throw new ArgumentNullException(
101+
nameof(nullRouteParameterPattern),
102+
"argument must not be null or empty");
103+
}
104+
105+
var parsedRoute = RoutePatternFactory.Parse(route);
106+
var context = new NullabilityInfoContext();
107+
var nullableRouteProperties = handler.Method.ReturnType.GetProperties()
108+
.Where(
109+
p => p.GetMethod != null
110+
&& p.SetMethod != null
111+
&& context.Create(p.GetMethod.ReturnParameter).ReadState == NullabilityState.Nullable)
112+
.ToList();
113+
var nullableRoutePattern = parsedRoute.Parameters
114+
.Where(
115+
x => nullableRouteProperties.Any(
116+
y => string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase)))
117+
.ToList();
118+
var subsets = GetNotEmptySubsets(nullableRoutePattern);
119+
foreach (var subset in subsets)
120+
{
121+
var newRoute = subset.Aggregate(
122+
route,
123+
(r, x) =>
124+
{
125+
var regex = new Regex("{" + x.Name + "[^}]*?}", RegexOptions.IgnoreCase);
126+
return regex.Replace(r, nullRouteParameterPattern);
127+
});
128+
app.MapGet(newRoute, handler).AddEndpointFilter<QueryEndpointHandler>();
129+
}
130+
76131
return app.MapGet(route, handler).AddEndpointFilter<QueryEndpointHandler>();
77132
}
78133

134+
/// <summary>
135+
/// Map a command API, using different HTTP methods based on prefix. See example for details.
136+
/// </summary>
137+
/// <param name="app"><see cref="ApplicationBuilder"/></param>
138+
/// <param name="route">The route template.</param>
139+
/// <typeparam name="T">The type of the command.</typeparam>
140+
/// <example>
141+
/// <code>
142+
/// app.MapCommand&lt;CreateItemCommand&gt;("/items"); // Starts with 'Create' or 'Add' - POST
143+
/// app.MapCommand&lt;UpdateItemCommand&gt;("/items/{id:int}") // Starts with 'Update' or 'Replace' - PUT
144+
/// app.MapCommand&lt;DeleteCommand&gt;("/items/{id:int}") // Starts with 'Delete' or 'Remove' - DELETE
145+
/// app.MapCommand&lt;ResetItemCommand&gt;("/items/{id:int}:reset) // Others - PUT
146+
/// </code>
147+
/// </example>
148+
/// <returns></returns>
149+
public static IEndpointConventionBuilder MapCommand<T>(
150+
this IEndpointRouteBuilder app,
151+
[StringSyntax("Route")] string route)
152+
{
153+
return app.MapCommand(route, ([AsParameters] T command) => command);
154+
}
155+
79156
/// <summary>
80157
/// Map a command API, using different method based on type name prefix.
81158
/// </summary>
@@ -174,4 +251,18 @@ private static void EnsureDelegateReturnTypeIsCommand(Delegate handler)
174251
"handler does not return command, check if delegate returns type that implements ICommand<> or ICommand<,>");
175252
}
176253
}
254+
255+
private static List<T[]> GetNotEmptySubsets<T>(ICollection<T> items)
256+
{
257+
var subsetCount = 1 << items.Count;
258+
var results = new List<T[]>(subsetCount);
259+
for (var i = 1; i < subsetCount; i++)
260+
{
261+
var index = i;
262+
var subset = items.Where((_, j) => (index & (1 << j)) > 0).ToArray();
263+
results.Add(subset);
264+
}
265+
266+
return results;
267+
}
177268
}

test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQuery.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries;
44

5-
public record GetStringQuery : IQuery<string>;
5+
public record GetStringQuery(string? AppId = null, int? StringId = null) : IQuery<string>;

test/Cnblogs.Architecture.IntegrationTestProject/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
var apis = app.NewVersionedApi();
3737
var v1 = apis.MapGroup("/api/v{version:apiVersion}").HasApiVersion(1);
38+
v1.MapQuery<GetStringQuery>("apps/{appId}/strings/{stringId:int}/value", true);
3839
v1.MapQuery<GetStringQuery>("strings/{id:int}");
3940
v1.MapQuery<ListStringsQuery>("strings");
4041
v1.MapCommand("strings", (CreatePayload payload) => new CreateCommand(payload.NeedError));

test/Cnblogs.Architecture.IntegrationTests/CqrsRouteMapperTests.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,23 @@ public async Task DeleteItem_SuccessAsync()
7777
// Assert
7878
response.Should().BeSuccessful();
7979
}
80-
}
80+
81+
[Fact]
82+
public async Task GetItem_NullableRouteValue_SuccessAsync()
83+
{
84+
// Arrange
85+
var builder = new WebApplicationFactory<Program>();
86+
87+
// Act
88+
var responses = new List<HttpResponseMessage>
89+
{
90+
await builder.CreateClient().GetAsync("/api/v1/apps/-/strings/-/value"),
91+
await builder.CreateClient().GetAsync("/api/v1/apps/-/strings/1/value"),
92+
await builder.CreateClient().GetAsync("/api/v1/apps/someApp/strings/-/value"),
93+
await builder.CreateClient().GetAsync("/api/v1/apps/someApp/strings/1/value")
94+
};
95+
96+
// Assert
97+
responses.Should().Match(x => x.All(y => y.IsSuccessStatusCode));
98+
}
99+
}

0 commit comments

Comments
 (0)