Skip to content

feat: support mapping optional routes automatically #77

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 115 additions & 24 deletions src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -22,35 +23,34 @@ public static class CqrsRouteMapper
/// </summary>
/// <param name="app"><see cref="IApplicationBuilder"/></param>
/// <param name="route">The route template for API.</param>
/// <param name="mapNullableRouteParameters">Multiple routes should be mapped when for nullable route parameters.</param>
/// <param name="nullRouteParameterPattern">Replace route parameter with given string to represent null.</param>
/// <typeparam name="T">The type of the query.</typeparam>
/// <returns></returns>
public static IEndpointConventionBuilder MapQuery<T>(
this IEndpointRouteBuilder app,
[StringSyntax("Route")] string route)
{
return app.MapQuery(route, ([AsParameters] T query) => query);
}

/// <summary>
/// Map a command API, using different HTTP methods based on prefix. See example for details.
/// </summary>
/// <param name="app"><see cref="ApplicationBuilder"/></param>
/// <param name="route">The route template.</param>
/// <typeparam name="T">The type of the command.</typeparam>
/// <example>
/// The following code:
/// <code>
/// app.MapCommand&lt;CreateItemCommand&gt;("/items"); // Starts with 'Create' or 'Add' - POST
/// app.MapCommand&lt;UpdateItemCommand&gt;("/items/{id:int}") // Starts with 'Update' or 'Replace' - PUT
/// app.MapCommand&lt;DeleteCommand&gt;("/items/{id:int}") // Starts with 'Delete' or 'Remove' - DELETE
/// app.MapCommand&lt;ResetItemCommand&gt;("/items/{id:int}:reset) // Others - PUT
/// app.MapQuery&lt;ItemQuery&gt;("apps/{appName}/instance/{instanceId}/roles", true);
/// </code>
/// would register following routes:
/// <code>
/// apps/-/instance/-/roles
/// apps/{appName}/instance/-/roles
/// apps/-/instance/{instanceId}/roles
/// apps/{appName}/instance/{instanceId}/roles
/// </code>
/// </example>
/// <returns></returns>
public static IEndpointConventionBuilder MapCommand<T>(
public static IEndpointConventionBuilder MapQuery<T>(
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);
}

/// <summary>
Expand All @@ -59,11 +59,28 @@ public static IEndpointConventionBuilder MapCommand<T>(
/// <param name="app"><see cref="ApplicationBuilder"/></param>
/// <param name="route">The route template.</param>
/// <param name="handler">The delegate that returns a <see cref="IQuery{TView}"/> instance.</param>
/// <param name="mapNullableRouteParameters">Multiple routes should be mapped when for nullable route parameters.</param>
/// <param name="nullRouteParameterPattern">Replace route parameter with given string to represent null.</param>
/// <returns></returns>
/// <example>
/// The following code:
/// <code>
/// app.MapQuery("apps/{appName}/instance/{instanceId}/roles", (string? appName, string? instanceId) => new ItemQuery(appName, instanceId), true);
/// </code>
/// would register following routes:
/// <code>
/// apps/-/instance/-/roles
/// apps/{appName}/instance/-/roles
/// apps/-/instance/{instanceId}/roles
/// apps/{appName}/instance/{instanceId}/roles
/// </code>
/// </example>
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()));
Expand All @@ -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<QueryEndpointHandler>();
}

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<QueryEndpointHandler>();
}

return app.MapGet(route, handler).AddEndpointFilter<QueryEndpointHandler>();
}

/// <summary>
/// Map a command API, using different HTTP methods based on prefix. See example for details.
/// </summary>
/// <param name="app"><see cref="ApplicationBuilder"/></param>
/// <param name="route">The route template.</param>
/// <typeparam name="T">The type of the command.</typeparam>
/// <example>
/// <code>
/// app.MapCommand&lt;CreateItemCommand&gt;("/items"); // Starts with 'Create' or 'Add' - POST
/// app.MapCommand&lt;UpdateItemCommand&gt;("/items/{id:int}") // Starts with 'Update' or 'Replace' - PUT
/// app.MapCommand&lt;DeleteCommand&gt;("/items/{id:int}") // Starts with 'Delete' or 'Remove' - DELETE
/// app.MapCommand&lt;ResetItemCommand&gt;("/items/{id:int}:reset) // Others - PUT
/// </code>
/// </example>
/// <returns></returns>
public static IEndpointConventionBuilder MapCommand<T>(
this IEndpointRouteBuilder app,
[StringSyntax("Route")] string route)
{
return app.MapCommand(route, ([AsParameters] T command) => command);
}

/// <summary>
/// Map a command API, using different method based on type name prefix.
/// </summary>
Expand Down Expand Up @@ -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<T[]> GetNotEmptySubsets<T>(ICollection<T> items)
{
var subsetCount = 1 << items.Count;
var results = new List<T[]>(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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries;

public record GetStringQuery : IQuery<string>;
public record GetStringQuery(string? AppId = null, int? StringId = null) : IQuery<string>;
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

var apis = app.NewVersionedApi();
var v1 = apis.MapGroup("/api/v{version:apiVersion}").HasApiVersion(1);
v1.MapQuery<GetStringQuery>("apps/{appId}/strings/{stringId:int}/value", true);
v1.MapQuery<GetStringQuery>("strings/{id:int}");
v1.MapQuery<ListStringsQuery>("strings");
v1.MapCommand("strings", (CreatePayload payload) => new CreateCommand(payload.NeedError));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,23 @@ public async Task DeleteItem_SuccessAsync()
// Assert
response.Should().BeSuccessful();
}
}

[Fact]
public async Task GetItem_NullableRouteValue_SuccessAsync()
{
// Arrange
var builder = new WebApplicationFactory<Program>();

// Act
var responses = new List<HttpResponseMessage>
{
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));
}
}