diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0a69165..2313d06 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,7 +24,10 @@ "Bash(timeout:*)", "Bash(tasklist:*)", "Bash(dotnet build:*)", - "Bash(dotnet --list-sdks:*)" + "Bash(dotnet --list-sdks:*)", + "Bash(dotnet sln:*)", + "Bash(pkill:*)", + "Bash(python3:*)" ], "deny": [], "ask": [] diff --git a/Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs b/Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..085d6bb --- /dev/null +++ b/Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,340 @@ +#nullable enable +using System; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Svrnty.CQRS.Abstractions; +using Svrnty.CQRS.Abstractions.Discovery; +using Svrnty.CQRS.Abstractions.Security; +using Svrnty.CQRS.AspNetCore.Abstractions.Attributes; +using Svrnty.CQRS.DynamicQuery.Abstractions; +using Svrnty.CQRS.DynamicQuery.AspNetCore; +using Svrnty.CQRS.DynamicQuery.Discover; +using PoweredSoft.DynamicQuery.Core; + +namespace Svrnty.CQRS.DynamicQuery.MinimalApi; + +public static class EndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapSvrntyDynamicQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query") + { + var queryDiscovery = endpoints.ServiceProvider.GetRequiredService(); + var authorizationService = endpoints.ServiceProvider.GetService(); + + foreach (var queryMeta in queryDiscovery.GetQueries()) + { + // Only process dynamic queries + if (queryMeta.Category != "DynamicQuery") + continue; + + var ignoreAttribute = queryMeta.QueryType.GetCustomAttribute(); + if (ignoreAttribute != null) + continue; + + if (queryMeta is not DynamicQueryMeta dynamicQueryMeta) + continue; + + var route = $"{routePrefix}/{queryMeta.LowerCamelCaseName}"; + + if (dynamicQueryMeta.ParamsType == null) + { + // DynamicQuery + MapDynamicQueryPost(endpoints, route, dynamicQueryMeta, authorizationService); + MapDynamicQueryGet(endpoints, route, dynamicQueryMeta, authorizationService); + } + else + { + // DynamicQuery + MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta, authorizationService); + MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta, authorizationService); + } + } + + return endpoints; + } + + private static void MapDynamicQueryPost( + IEndpointRouteBuilder endpoints, + string route, + DynamicQueryMeta dynamicQueryMeta, + IQueryAuthorizationService? authorizationService) + { + var sourceType = dynamicQueryMeta.SourceType; + var destinationType = dynamicQueryMeta.DestinationType; + var queryType = typeof(IDynamicQuery<,>).MakeGenericType(sourceType, destinationType); + var resultType = typeof(IQueryExecutionResult<>).MakeGenericType(destinationType); + var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryType, resultType); + var requestBodyType = typeof(DynamicQuery<,>).MakeGenericType(sourceType, destinationType); + + // Create a delegate that properly handles the typed deserialization + var mapPostMethod = typeof(EndpointRouteBuilderExtensions) + .GetMethod(nameof(MapDynamicQueryPostTyped), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(sourceType, destinationType); + + var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!; + + endpoint + .WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_Post") + .WithTags("DynamicQueries") + .Accepts(requestBodyType, "application/json") + .Produces(200, resultType) + .Produces(400) + .Produces(401) + .Produces(403); + } + + private static RouteHandlerBuilder MapDynamicQueryPostTyped( + IEndpointRouteBuilder endpoints, + string route, + Type queryType, + Type handlerType, + IQueryAuthorizationService? authorizationService) + where TSource : class + where TDestination : class + { + return endpoints.MapPost(route, async ( + DynamicQuery query, + HttpContext context, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) => + { + if (authorizationService != null) + { + var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken); + if (authorizationResult == AuthorizationResult.Forbidden) + return Results.StatusCode(403); + if (authorizationResult == AuthorizationResult.Unauthorized) + return Results.Unauthorized(); + } + + var handler = serviceProvider.GetRequiredService(handlerType); + var handleMethod = handlerType.GetMethod("HandleAsync"); + if (handleMethod == null) + return Results.Problem("Handler method not found"); + + var task = (Task)handleMethod.Invoke(handler, [query, cancellationToken])!; + await task; + + var resultProperty = task.GetType().GetProperty("Result"); + var result = resultProperty?.GetValue(task); + + return Results.Ok(result); + }); + } + + private static void MapDynamicQueryGet( + IEndpointRouteBuilder endpoints, + string route, + DynamicQueryMeta dynamicQueryMeta, + IQueryAuthorizationService? authorizationService) + { + var sourceType = dynamicQueryMeta.SourceType; + var destinationType = dynamicQueryMeta.DestinationType; + var queryType = typeof(IDynamicQuery<,>).MakeGenericType(sourceType, destinationType); + var resultType = typeof(IQueryExecutionResult<>).MakeGenericType(destinationType); + var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryType, resultType); + var requestQueryType = typeof(DynamicQuery<,>).MakeGenericType(sourceType, destinationType); + + endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) => + { + if (authorizationService != null) + { + var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken); + if (authorizationResult == AuthorizationResult.Forbidden) + return Results.StatusCode(403); + if (authorizationResult == AuthorizationResult.Unauthorized) + return Results.Unauthorized(); + } + + var query = Activator.CreateInstance(requestQueryType); + if (query == null) + return Results.BadRequest("Could not create query instance"); + + // Bind query string parameters to query object + foreach (var property in requestQueryType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanWrite) + continue; + + var queryStringValue = context.Request.Query[property.Name].FirstOrDefault(); + if (queryStringValue != null) + { + try + { + var convertedValue = Convert.ChangeType(queryStringValue, property.PropertyType); + property.SetValue(query, convertedValue); + } + catch + { + // Skip properties that can't be converted + } + } + } + + var handler = serviceProvider.GetRequiredService(handlerType); + var handleMethod = handlerType.GetMethod("HandleAsync"); + if (handleMethod == null) + return Results.Problem("Handler method not found"); + + var task = (Task)handleMethod.Invoke(handler, [query, cancellationToken])!; + await task; + + var resultProperty = task.GetType().GetProperty("Result"); + var result = resultProperty?.GetValue(task); + + return Results.Ok(result); + }) + .WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_Get") + .WithTags("DynamicQueries") + .Produces(200, resultType) + .Produces(400) + .Produces(401) + .Produces(403); + } + + private static void MapDynamicQueryWithParamsPost( + IEndpointRouteBuilder endpoints, + string route, + DynamicQueryMeta dynamicQueryMeta, + IQueryAuthorizationService? authorizationService) + { + var sourceType = dynamicQueryMeta.SourceType; + var destinationType = dynamicQueryMeta.DestinationType; + var paramsType = dynamicQueryMeta.ParamsType!; + var queryType = typeof(IDynamicQuery<,,>).MakeGenericType(sourceType, destinationType, paramsType); + var resultType = typeof(IQueryExecutionResult<>).MakeGenericType(destinationType); + var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryType, resultType); + var requestBodyType = typeof(DynamicQuery<,,>).MakeGenericType(sourceType, destinationType, paramsType); + + var mapPostMethod = typeof(EndpointRouteBuilderExtensions) + .GetMethod(nameof(MapDynamicQueryWithParamsPostTyped), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(sourceType, destinationType, paramsType); + + var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!; + + endpoint + .WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_WithParams_Post") + .WithTags("DynamicQueries") + .Accepts(requestBodyType, "application/json") + .Produces(200, resultType) + .Produces(400) + .Produces(401) + .Produces(403); + } + + private static RouteHandlerBuilder MapDynamicQueryWithParamsPostTyped( + IEndpointRouteBuilder endpoints, + string route, + Type queryType, + Type handlerType, + IQueryAuthorizationService? authorizationService) + where TSource : class + where TDestination : class + where TParams : class + { + return endpoints.MapPost(route, async ( + DynamicQuery query, + HttpContext context, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) => + { + if (authorizationService != null) + { + var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken); + if (authorizationResult == AuthorizationResult.Forbidden) + return Results.StatusCode(403); + if (authorizationResult == AuthorizationResult.Unauthorized) + return Results.Unauthorized(); + } + + var handler = serviceProvider.GetRequiredService(handlerType); + var handleMethod = handlerType.GetMethod("HandleAsync"); + if (handleMethod == null) + return Results.Problem("Handler method not found"); + + var task = (Task)handleMethod.Invoke(handler, [query, cancellationToken])!; + await task; + + var resultProperty = task.GetType().GetProperty("Result"); + var result = resultProperty?.GetValue(task); + + return Results.Ok(result); + }); + } + + private static void MapDynamicQueryWithParamsGet( + IEndpointRouteBuilder endpoints, + string route, + DynamicQueryMeta dynamicQueryMeta, + IQueryAuthorizationService? authorizationService) + { + var sourceType = dynamicQueryMeta.SourceType; + var destinationType = dynamicQueryMeta.DestinationType; + var paramsType = dynamicQueryMeta.ParamsType!; + var queryType = typeof(IDynamicQuery<,,>).MakeGenericType(sourceType, destinationType, paramsType); + var resultType = typeof(IQueryExecutionResult<>).MakeGenericType(destinationType); + var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryType, resultType); + var requestQueryType = typeof(DynamicQuery<,,>).MakeGenericType(sourceType, destinationType, paramsType); + + endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) => + { + if (authorizationService != null) + { + var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken); + if (authorizationResult == AuthorizationResult.Forbidden) + return Results.StatusCode(403); + if (authorizationResult == AuthorizationResult.Unauthorized) + return Results.Unauthorized(); + } + + var query = Activator.CreateInstance(requestQueryType); + if (query == null) + return Results.BadRequest("Could not create query instance"); + + // Bind query string parameters to query object + foreach (var property in requestQueryType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanWrite) + continue; + + var queryStringValue = context.Request.Query[property.Name].FirstOrDefault(); + if (queryStringValue != null) + { + try + { + var convertedValue = Convert.ChangeType(queryStringValue, property.PropertyType); + property.SetValue(query, convertedValue); + } + catch + { + // Skip properties that can't be converted + } + } + } + + var handler = serviceProvider.GetRequiredService(handlerType); + var handleMethod = handlerType.GetMethod("HandleAsync"); + if (handleMethod == null) + return Results.Problem("Handler method not found"); + + var task = (Task)handleMethod.Invoke(handler, [query, cancellationToken])!; + await task; + + var resultProperty = task.GetType().GetProperty("Result"); + var result = resultProperty?.GetValue(task); + + return Results.Ok(result); + }) + .WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_WithParams_Get") + .WithTags("DynamicQueries") + .Produces(200, resultType) + .Produces(400) + .Produces(401) + .Produces(403); + } +} diff --git a/Svrnty.CQRS.DynamicQuery.MinimalApi/Svrnty.CQRS.DynamicQuery.MinimalApi.csproj b/Svrnty.CQRS.DynamicQuery.MinimalApi/Svrnty.CQRS.DynamicQuery.MinimalApi.csproj new file mode 100644 index 0000000..c169ad9 --- /dev/null +++ b/Svrnty.CQRS.DynamicQuery.MinimalApi/Svrnty.CQRS.DynamicQuery.MinimalApi.csproj @@ -0,0 +1,37 @@ + + + net10.0 + false + 14 + Svrnty + David Lebee, Mathias Beaulieu-Duncan + icon.png + README.md + https://github.com/svrnty/dotnet-cqrs + git + true + MIT + + portable + true + true + true + snupkg + + + + + + + + + + + + + + + + + + diff --git a/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs b/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs index 7a16442..bb8f7f9 100644 --- a/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs +++ b/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs @@ -27,6 +27,10 @@ public static class EndpointRouteBuilderExtensions if (ignoreAttribute != null) continue; + // Skip dynamic queries - they are handled by MapSvrntyDynamicQueries + if (queryMeta.Category == "DynamicQuery") + continue; + var route = $"{routePrefix}/{queryMeta.LowerCamelCaseName}"; MapQueryPost(endpoints, route, queryMeta, authorizationService); diff --git a/Svrnty.CQRS.sln b/Svrnty.CQRS.sln index ae8249c..b333cef 100644 --- a/Svrnty.CQRS.sln +++ b/Svrnty.CQRS.sln @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Grpc.Generators EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.Sample", "Svrnty.Sample\Svrnty.Sample.csproj", "{B7CEE5D0-A7CA-4C2C-AE42-923A8CF9B854}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.DynamicQuery.MinimalApi", "Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj", "{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -187,6 +189,18 @@ Global {B7CEE5D0-A7CA-4C2C-AE42-923A8CF9B854}.Release|x64.Build.0 = Release|Any CPU {B7CEE5D0-A7CA-4C2C-AE42-923A8CF9B854}.Release|x86.ActiveCfg = Release|Any CPU {B7CEE5D0-A7CA-4C2C-AE42-923A8CF9B854}.Release|x86.Build.0 = Release|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Debug|x64.ActiveCfg = Debug|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Debug|x64.Build.0 = Debug|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Debug|x86.ActiveCfg = Debug|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Debug|x86.Build.0 = Debug|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|Any CPU.Build.0 = Release|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x64.ActiveCfg = Release|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x64.Build.0 = Release|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x86.ActiveCfg = Release|Any CPU + {1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Svrnty.Sample/Program.cs b/Svrnty.Sample/Program.cs index 9324c26..af07719 100644 --- a/Svrnty.Sample/Program.cs +++ b/Svrnty.Sample/Program.cs @@ -5,6 +5,8 @@ using Svrnty.CQRS.FluentValidation; using Svrnty.Sample; using Svrnty.Sample.Grpc.Extensions; using Svrnty.CQRS.MinimalApi; +using Svrnty.CQRS.DynamicQuery; +using Svrnty.CQRS.DynamicQuery.MinimalApi; var builder = WebApplication.CreateBuilder(args); @@ -24,6 +26,13 @@ builder.Services.AddCommand(); // Register query handlers with CQRS builder.Services.AddQuery(); +// Register PoweredSoft.DynamicQuery services +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +// Register dynamic query for User entity with queryable provider +builder.Services.AddDynamicQueryWithProvider(); + // Register discovery services for MinimalApi builder.Services.AddDefaultCommandDiscovery(); builder.Services.AddDefaultQueryDiscovery(); @@ -50,6 +59,7 @@ app.UseSwaggerUI(); // Map MinimalApi endpoints for commands and queries app.MapSvrntyCommands(); app.MapSvrntyQueries(); +app.MapSvrntyDynamicQueries(); Console.WriteLine("Auto-Generated gRPC Server with Reflection, Validation, MinimalApi and Swagger"); diff --git a/Svrnty.Sample/SimpleAsyncQueryableService.cs b/Svrnty.Sample/SimpleAsyncQueryableService.cs new file mode 100644 index 0000000..7828789 --- /dev/null +++ b/Svrnty.Sample/SimpleAsyncQueryableService.cs @@ -0,0 +1,72 @@ +using PoweredSoft.Data.Core; +using System.Linq.Expressions; + +namespace Svrnty.Sample; + +/// +/// Simple in-memory implementation of IAsyncQueryableService for testing/demo purposes +/// +public class SimpleAsyncQueryableService : IAsyncQueryableService +{ + public IEnumerable Handlers { get; } = Array.Empty(); + + public Task> ToListAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.ToList()); + } + + public Task FirstOrDefaultAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.FirstOrDefault()); + } + + public Task FirstOrDefaultAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.FirstOrDefault(predicate)); + } + + public Task LastOrDefaultAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.LastOrDefault()); + } + + public Task LastOrDefaultAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.LastOrDefault(predicate)); + } + + public Task AnyAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.Any(predicate)); + } + + public Task AllAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.All(predicate)); + } + + public Task CountAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.Count()); + } + + public Task LongCountAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.LongCount()); + } + + public Task SingleOrDefaultAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.SingleOrDefault(predicate)); + } + + public Task AnyAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.Any()); + } + + public IAsyncQueryableHandlerService? GetAsyncQueryableHandler(IQueryable queryable) + { + return null; // No special handling needed for in-memory queries + } +} diff --git a/Svrnty.Sample/Svrnty.Sample.csproj b/Svrnty.Sample/Svrnty.Sample.csproj index 8b4beef..8aabf02 100644 --- a/Svrnty.Sample/Svrnty.Sample.csproj +++ b/Svrnty.Sample/Svrnty.Sample.csproj @@ -30,6 +30,8 @@ + + diff --git a/Svrnty.Sample/UserQueryableProvider.cs b/Svrnty.Sample/UserQueryableProvider.cs new file mode 100644 index 0000000..d1b6330 --- /dev/null +++ b/Svrnty.Sample/UserQueryableProvider.cs @@ -0,0 +1,23 @@ +using Svrnty.CQRS.DynamicQuery.Abstractions; + +namespace Svrnty.Sample; + +public class UserQueryableProvider : IQueryableProvider +{ + // In-memory sample data for demonstration + private static readonly List SampleUsers = new() + { + new User { Id = 1, Name = "Alice Smith", Email = "alice@example.com" }, + new User { Id = 2, Name = "Bob Johnson", Email = "bob@example.com" }, + new User { Id = 3, Name = "Charlie Brown", Email = "charlie@example.com" }, + new User { Id = 4, Name = "Diana Prince", Email = "diana@example.com" }, + new User { Id = 5, Name = "Eve Adams", Email = "eve@example.com" } + }; + + public Task> GetQueryableAsync(object query, CancellationToken cancellationToken = default) + { + // Return in-memory queryable for demonstration + // The query parameter can be used to apply custom filters or transformations if needed + return Task.FromResult(SampleUsers.AsQueryable()); + } +}