Fix MinimalApi to resolve authorization services per-request

- Resolve ICommandAuthorizationService and IQueryAuthorizationService from request-scoped serviceProvider
- Allows Scoped authorization services that depend on DbContext
- Updated both MinimalApi and DynamicQuery.MinimalApi

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
David Nguyen 2026-01-14 15:56:31 -05:00
parent bd43bc9bde
commit 227be70f95
Signed by: david.nguyen
GPG Key ID: D5FB5A5715829326
8 changed files with 429 additions and 63 deletions

View File

@ -23,7 +23,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyDynamicQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
foreach (var queryMeta in queryDiscovery.GetQueries())
{
@ -43,14 +42,14 @@ public static class EndpointRouteBuilderExtensions
if (dynamicQueryMeta.ParamsType == null)
{
// DynamicQuery<TSource, TDestination>
MapDynamicQueryPost(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryGet(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryPost(endpoints, route, dynamicQueryMeta);
MapDynamicQueryGet(endpoints, route, dynamicQueryMeta);
}
else
{
// DynamicQuery<TSource, TDestination, TParams>
MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta);
MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta);
}
}
@ -60,8 +59,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryPost(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@ -75,7 +73,7 @@ public static class EndpointRouteBuilderExtensions
.GetMethod(nameof(MapDynamicQueryPostTyped), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(sourceType, destinationType);
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!;
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!;
endpoint
.WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_Post")
@ -91,8 +89,7 @@ public static class EndpointRouteBuilderExtensions
IEndpointRouteBuilder endpoints,
string route,
Type queryType,
Type handlerType,
IQueryAuthorizationService? authorizationService)
Type handlerType)
where TSource : class
where TDestination : class
{
@ -102,6 +99,7 @@ public static class EndpointRouteBuilderExtensions
IServiceProvider serviceProvider,
CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@ -129,8 +127,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryGet(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@ -141,6 +138,7 @@ public static class EndpointRouteBuilderExtensions
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@ -199,8 +197,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryWithParamsPost(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@ -214,7 +211,7 @@ public static class EndpointRouteBuilderExtensions
.GetMethod(nameof(MapDynamicQueryWithParamsPostTyped), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(sourceType, destinationType, paramsType);
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!;
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!;
endpoint
.WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_WithParams_Post")
@ -230,8 +227,7 @@ public static class EndpointRouteBuilderExtensions
IEndpointRouteBuilder endpoints,
string route,
Type queryType,
Type handlerType,
IQueryAuthorizationService? authorizationService)
Type handlerType)
where TSource : class
where TDestination : class
where TParams : class
@ -242,6 +238,7 @@ public static class EndpointRouteBuilderExtensions
IServiceProvider serviceProvider,
CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@ -269,8 +266,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryWithParamsGet(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@ -282,6 +278,7 @@ public static class EndpointRouteBuilderExtensions
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);

View File

@ -35,7 +35,11 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
protected virtual Task<IQueryable<TSource>> GetQueryableAsync(IDynamicQuery query, CancellationToken cancellationToken = default)
{
if (_queryableProviders.Any())
return _queryableProviders.ElementAt(0).GetQueryableAsync(query, cancellationToken);
{
// Use Last() to prefer closed generic registrations (overrides) over open generic (default)
// Registration order: open generic first, closed generic (override) last
return _queryableProviders.Last().GetQueryableAsync(query, cancellationToken);
}
throw new Exception($"You must provide a QueryableProvider<TSource> for {typeof(TSource).Name}");
}

View File

@ -329,6 +329,60 @@ namespace Svrnty.CQRS.Grpc.Generators
var commandTypeFullyQualified = commandType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var resultTypeFullyQualified = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
commandInfo.HandlerInterfaceName = $"ICommandHandler<{commandTypeFullyQualified}, {resultTypeFullyQualified}>";
// Check if result type is primitive
var resultTypeString = resultType.ToDisplayString();
commandInfo.IsResultPrimitiveType = IsPrimitiveType(resultTypeString);
// Extract result type properties if it's a complex type
if (!commandInfo.IsResultPrimitiveType)
{
var resultProperties = resultType.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.ToList();
foreach (var property in resultProperties)
{
var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var isList = IsListOrCollection(property.Type);
var isComplexType = IsUserDefinedComplexType(property.Type);
var propInfo = new PropertyInfo
{
Name = property.Name,
Type = property.Type.ToDisplayString(),
FullyQualifiedType = propertyType,
ProtoType = string.Empty, // Not needed for result mapping
FieldNumber = 0, // Not needed for result mapping
IsList = isList,
IsComplexType = isComplexType,
IsNullable = IsNullableType(property.Type),
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsDateTimeOffset = IsDateTimeOffsetType(property.Type),
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsValueTypeCollection = IsValueTypeCollection(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
// If it's a list, extract element type info
if (isList)
{
var elementType = GetListElementType(property.Type);
if (elementType != null)
{
propInfo.ElementType = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
propInfo.IsElementComplexType = IsUserDefinedComplexType(elementType);
propInfo.IsElementGuid = IsGuidType(elementType);
}
}
commandInfo.ResultProperties.Add(propInfo);
}
}
}
else
{
@ -360,9 +414,11 @@ namespace Svrnty.CQRS.Grpc.Generators
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsDateTimeOffset = IsDateTimeOffsetType(property.Type),
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsValueTypeCollection = IsValueTypeCollection(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
@ -466,6 +522,12 @@ namespace Svrnty.CQRS.Grpc.Generators
return unwrapped.SpecialType == SpecialType.System_DateTime;
}
private static bool IsDateTimeOffsetType(ITypeSymbol type)
{
var unwrapped = UnwrapNullableType(type);
return unwrapped.ToDisplayString() == "System.DateTimeOffset";
}
private static bool IsEnumType(ITypeSymbol type)
{
var unwrapped = UnwrapNullableType(type);
@ -504,29 +566,120 @@ namespace Svrnty.CQRS.Grpc.Generators
private static bool IsListOrCollection(ITypeSymbol type)
{
if (type is IArrayTypeSymbol)
return true;
// Unwrap nullable first to handle NpgsqlPolygon?
var unwrapped = UnwrapNullableType(type);
if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
if (unwrapped is IArrayTypeSymbol arrayType)
{
// byte[] is binary data, not a collection
if (arrayType.ElementType.SpecialType == SpecialType.System_Byte)
return false;
return true;
}
if (unwrapped is INamedTypeSymbol namedType)
{
// Check if it's a generic collection type
if (namedType.IsGenericType)
{
var typeName = namedType.OriginalDefinition.ToDisplayString();
return typeName.StartsWith("System.Collections.Generic.List<") ||
if (typeName.StartsWith("System.Collections.Generic.List<") ||
typeName.StartsWith("System.Collections.Generic.IList<") ||
typeName.StartsWith("System.Collections.Generic.ICollection<") ||
typeName.StartsWith("System.Collections.Generic.IEnumerable<");
typeName.StartsWith("System.Collections.Generic.IEnumerable<"))
{
return true;
}
}
// Check if it implements IList<T>, ICollection<T>, or IEnumerable<T> (handles types like NpgsqlPolygon)
// Skip string which implements IEnumerable<char>
if (namedType.SpecialType == SpecialType.System_String)
return false;
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
{
return true;
}
}
}
}
return false;
}
/// <summary>
/// Checks if a type is a value type that implements a collection interface (like NpgsqlPolygon)
/// These require special construction syntax
/// </summary>
private static bool IsValueTypeCollection(ITypeSymbol type)
{
// Unwrap nullable first to handle NpgsqlPolygon?
var unwrapped = UnwrapNullableType(type);
if (unwrapped is not INamedTypeSymbol namedType)
return false;
// Must be a value type (struct)
if (!namedType.IsValueType)
return false;
// Must implement a collection interface (pass unwrapped since IsListOrCollection also unwraps)
return IsListOrCollection(unwrapped);
}
private static ITypeSymbol? GetListElementType(ITypeSymbol type)
{
if (type is IArrayTypeSymbol arrayType)
// Unwrap nullable first to handle NpgsqlPolygon?
var unwrapped = UnwrapNullableType(type);
if (unwrapped is IArrayTypeSymbol arrayType)
return arrayType.ElementType;
if (type is INamedTypeSymbol namedType && namedType.IsGenericType && namedType.TypeArguments.Length > 0)
if (unwrapped is INamedTypeSymbol namedType)
{
// First try generic type arguments
if (namedType.IsGenericType && namedType.TypeArguments.Length > 0)
{
return namedType.TypeArguments[0];
}
// Fall back to checking interfaces (for types like NpgsqlPolygon that implement IList<NpgsqlPoint>)
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>")
{
return iface.TypeArguments[0];
}
}
}
// Try ICollection<T> or IEnumerable<T> as fallback
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
if (ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>" ||
ifaceName == "System.Collections.Generic.IEnumerable<T>")
{
return iface.TypeArguments[0];
}
}
}
}
return null;
}
@ -582,6 +735,8 @@ namespace Svrnty.CQRS.Grpc.Generators
ProtoType = string.Empty,
FieldNumber = 0,
IsComplexType = IsUserDefinedComplexType(property.Type),
IsList = IsListOrCollection(property.Type),
IsValueTypeCollection = IsValueTypeCollection(property.Type),
};
// Recursively extract nested properties for complex types
@ -620,9 +775,11 @@ namespace Svrnty.CQRS.Grpc.Generators
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsDateTimeOffset = IsDateTimeOffsetType(property.Type),
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsValueTypeCollection = IsValueTypeCollection(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
@ -695,8 +852,20 @@ namespace Svrnty.CQRS.Grpc.Generators
else if (prop.IsElementGuid)
{
// List<string> from proto -> List<Guid> in C#
if (prop.IsValueTypeCollection)
{
// Value type collection proto message has Items field
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
return $"{indent}{prop.Name} = new {constructorType}({source}?.Items?.Select(x => System.Guid.Parse(x)).ToArray() ?? System.Array.Empty<System.Guid>()),";
}
return $"{indent}{prop.Name} = {source}?.Select(x => System.Guid.Parse(x)).ToList(),";
}
else if (prop.IsValueTypeCollection)
{
// Value type collection (like NpgsqlPolygon): proto message has Items field
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
return $"{indent}{prop.Name} = new {constructorType}({source}?.Items?.Select(x => new {prop.ElementType} {{ X = x.X, Y = x.Y }}).ToArray() ?? System.Array.Empty<{prop.ElementType ?? "object"}>()),";
}
else
{
// Primitive list: just ToList()
@ -736,6 +905,19 @@ namespace Svrnty.CQRS.Grpc.Generators
}
}
// Handle DateTimeOffset (proto Timestamp -> C# DateTimeOffset)
if (prop.IsDateTimeOffset)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} == null ? (System.DateTimeOffset?)null : {source}.ToDateTimeOffset(),";
}
else
{
return $"{indent}{prop.Name} = {source}.ToDateTimeOffset(),";
}
}
// Handle Guid (proto string -> C# Guid)
if (prop.IsGuid)
{
@ -785,7 +967,9 @@ namespace Svrnty.CQRS.Grpc.Generators
private static string GenerateComplexListMapping(PropertyInfo prop, string source, string indent)
{
var sb = new StringBuilder();
sb.AppendLine($"{indent}{prop.Name} = {source}?.Select(x => new {prop.ElementType}");
// For value type collections, the proto message has an Items field containing the repeated elements
var itemsSource = prop.IsValueTypeCollection ? $"{source}?.Items" : source;
sb.AppendLine($"{indent}{prop.Name} = {itemsSource}?.Select(x => new {prop.ElementType}");
sb.AppendLine($"{indent}{{");
foreach (var nestedProp in prop.ElementNestedProperties!)
@ -798,7 +982,17 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(nestedAssignment);
}
if (prop.IsValueTypeCollection)
{
// Value type collection: wrap in constructor with array
// Use non-nullable type for constructor (remove trailing ? if present)
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
sb.Append($"{indent}}}).ToArray() is {{ }} arr ? new {constructorType}(arr) : default,");
}
else
{
sb.Append($"{indent}}}).ToList(),");
}
return sb.ToString();
}
@ -878,6 +1072,12 @@ namespace Svrnty.CQRS.Grpc.Generators
{
return GenerateComplexListMapping(prop, source, indent);
}
if (prop.IsValueTypeCollection)
{
// Value type collection (like NpgsqlPolygon): use constructor with array
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
return $"{indent}{prop.Name} = new {constructorType}({source}?.ToArray() ?? System.Array.Empty<{prop.ElementType ?? "object"}>()),";
}
return $"{indent}{prop.Name} = {source}?.ToList(),";
}
@ -901,7 +1101,22 @@ namespace Svrnty.CQRS.Grpc.Generators
// Handle lists
if (prop.IsList)
{
if (prop.IsElementComplexType)
// Value type collections (like NpgsqlPolygon) map to a wrapper message with Items field
if (prop.IsValueTypeCollection)
{
var protoWrapperType = prop.Type.Split('.').Last().Replace("?", "");
var protoElementType = prop.ElementType?.Split('.').Last() ?? "object";
if (prop.IsElementComplexType && prop.ElementNestedProperties != null && prop.ElementNestedProperties.Any())
{
return GenerateResultValueTypeCollectionWithComplexElements(prop, source, indent, protoWrapperType, protoElementType);
}
else
{
// Simple element type
return $"{indent}{prop.Name} = new {protoWrapperType} {{ Items = {{ {source}.Select(x => new {protoElementType} {{ X = x.X, Y = x.Y }}) }} }},";
}
}
else if (prop.IsElementComplexType)
{
// Complex list: map each element to proto type
return GenerateResultComplexListMapping(prop, source, indent);
@ -1040,6 +1255,27 @@ namespace Svrnty.CQRS.Grpc.Generators
return sb.ToString();
}
private static string GenerateResultValueTypeCollectionWithComplexElements(PropertyInfo prop, string source, string indent, string protoWrapperType, string protoElementType)
{
var sb = new StringBuilder();
sb.AppendLine($"{indent}{prop.Name} = new {protoWrapperType} {{ Items = {{");
sb.AppendLine($"{indent} {source}.Select(x => new {protoElementType}");
sb.AppendLine($"{indent} {{");
if (prop.ElementNestedProperties != null)
{
foreach (var nestedProp in prop.ElementNestedProperties)
{
var nestedAssignment = GenerateResultNestedPropertyMapping(nestedProp, "x", indent + " ");
sb.AppendLine(nestedAssignment);
}
}
sb.AppendLine($"{indent} }})");
sb.Append($"{indent}}} }},");
return sb.ToString();
}
private static string GenerateResultComplexObjectMapping(PropertyInfo prop, string source, string indent)
{
var sb = new StringBuilder();
@ -1147,7 +1383,22 @@ namespace Svrnty.CQRS.Grpc.Generators
// Handle lists
if (prop.IsList)
{
if (prop.IsElementGuid)
// Value type collections (like NpgsqlPolygon) map to a wrapper message with Items field
if (prop.IsValueTypeCollection)
{
var protoWrapperType = prop.Type.Split('.').Last().Replace("?", "");
var protoElementType = prop.ElementType?.Split('.').Last() ?? "object";
if (prop.IsElementComplexType && prop.ElementNestedProperties != null && prop.ElementNestedProperties.Any())
{
return GenerateResultValueTypeCollectionWithComplexElements(prop, source, indent, protoWrapperType, protoElementType);
}
else
{
// Simple element type (like NpgsqlPoint)
return $"{indent}{prop.Name} = new {protoWrapperType} {{ Items = {{ {source}.Select(x => new {protoElementType} {{ X = x.X, Y = x.Y }}) }} }},";
}
}
else if (prop.IsElementGuid)
{
return $"{indent}{prop.Name} = {{ {source}?.Select(x => x.ToString()) ?? Enumerable.Empty<string>() }},";
}
@ -1247,9 +1498,11 @@ namespace Svrnty.CQRS.Grpc.Generators
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsDateTimeOffset = IsDateTimeOffsetType(property.Type),
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsValueTypeCollection = IsValueTypeCollection(property.Type),
IsComplexType = IsUserDefinedComplexType(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
@ -1310,9 +1563,11 @@ namespace Svrnty.CQRS.Grpc.Generators
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsDateTimeOffset = IsDateTimeOffsetType(property.Type),
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsValueTypeCollection = IsValueTypeCollection(property.Type),
IsComplexType = IsUserDefinedComplexType(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
@ -2166,6 +2421,7 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine("#nullable enable");
sb.AppendLine("using Grpc.Core;");
sb.AppendLine("using System.Threading.Tasks;");
sb.AppendLine("using System.Linq;");
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
sb.AppendLine($"using {rootNamespace}.Grpc;");
sb.AppendLine("using Svrnty.CQRS.Abstractions;");
@ -2911,7 +3167,9 @@ namespace Svrnty.CQRS.Grpc.Generators
FieldNumber = fieldNumber++,
IsEnum = prop.Type.TypeKind == TypeKind.Enum,
IsDecimal = propType.Contains("decimal") || propType.Contains("Decimal"),
IsDateTime = propType.Contains("DateTime")
IsDateTime = propType.Contains("DateTime"),
IsList = IsListOrCollection(prop.Type),
IsValueTypeCollection = IsValueTypeCollection(prop.Type),
});
}

View File

@ -49,6 +49,12 @@ namespace Svrnty.CQRS.Grpc.Generators.Helpers
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{

View File

@ -44,11 +44,13 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
@ -67,11 +69,13 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}

View File

@ -159,7 +159,10 @@ internal static class ProtoFileTypeMapper
}
/// <summary>
/// Converts C# PascalCase property name to proto snake_case field name
/// Converts C# PascalCase property name to proto snake_case field name.
/// Uses simple conversion: add underscore before each uppercase letter (except first).
/// This matches protobuf's C# codegen expectations for PascalCase conversion.
/// Example: TotalADeduire -> total_a_deduire -> TotalADeduire (in generated C#)
/// </summary>
public static string ToSnakeCase(string pascalCase)
{
@ -173,18 +176,10 @@ internal static class ProtoFileTypeMapper
{
var c = pascalCase[i];
if (char.IsUpper(c))
{
// Handle sequences of uppercase letters (e.g., "APIKey" -> "api_key")
if (i + 1 < pascalCase.Length && char.IsUpper(pascalCase[i + 1]))
{
result.Append(char.ToLowerInvariant(c));
}
else
{
result.Append('_');
result.Append(char.ToLowerInvariant(c));
}
}
else
{
result.Append(c);

View File

@ -19,7 +19,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
foreach (var queryMeta in queryDiscovery.GetQueries())
{
@ -33,8 +32,8 @@ public static class EndpointRouteBuilderExtensions
var route = $"{routePrefix}/{queryMeta.LowerCamelCaseName}";
MapQueryPost(endpoints, route, queryMeta, authorizationService);
MapQueryGet(endpoints, route, queryMeta, authorizationService);
MapQueryPost(endpoints, route, queryMeta);
MapQueryGet(endpoints, route, queryMeta);
}
return endpoints;
@ -43,13 +42,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapQueryPost(
IEndpointRouteBuilder endpoints,
string route,
IQueryMeta queryMeta,
IQueryAuthorizationService? authorizationService)
IQueryMeta queryMeta)
{
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
@ -90,13 +89,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapQueryGet(
IEndpointRouteBuilder endpoints,
string route,
IQueryMeta queryMeta,
IQueryAuthorizationService? authorizationService)
IQueryMeta queryMeta)
{
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
@ -153,7 +152,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command")
{
var commandDiscovery = endpoints.ServiceProvider.GetRequiredService<ICommandDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<ICommandAuthorizationService>();
foreach (var commandMeta in commandDiscovery.GetCommands())
{
@ -165,11 +163,11 @@ public static class EndpointRouteBuilderExtensions
if (commandMeta.CommandResultType == null)
{
MapCommandWithoutResult(endpoints, route, commandMeta, authorizationService);
MapCommandWithoutResult(endpoints, route, commandMeta);
}
else
{
MapCommandWithResult(endpoints, route, commandMeta, authorizationService);
MapCommandWithResult(endpoints, route, commandMeta);
}
}
@ -179,13 +177,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapCommandWithoutResult(
IEndpointRouteBuilder endpoints,
string route,
ICommandMeta commandMeta,
ICommandAuthorizationService? authorizationService)
ICommandMeta commandMeta)
{
var handlerType = typeof(ICommandHandler<>).MakeGenericType(commandMeta.CommandType);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);
@ -221,13 +219,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapCommandWithResult(
IEndpointRouteBuilder endpoints,
string route,
ICommandMeta commandMeta,
ICommandAuthorizationService? authorizationService)
ICommandMeta commandMeta)
{
var handlerType = typeof(ICommandHandler<,>).MakeGenericType(commandMeta.CommandType, commandMeta.CommandResultType!);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);

View File

@ -1,7 +1,111 @@
syntax = "proto3";
option csharp_namespace = "Generated.Grpc";
option csharp_namespace = "Svrnty.Sample.Grpc";
package cqrs;
// Placeholder proto file - will be regenerated on next build
// Command service for CQRS operations
service CommandService {
// AddUserCommand operation
rpc AddUser (AddUserCommandRequest) returns (AddUserCommandResponse);
// RemoveUserCommand operation
rpc RemoveUser (RemoveUserCommandRequest) returns (RemoveUserCommandResponse);
}
// Query service for CQRS operations
service QueryService {
// FetchUserQuery operation
rpc FetchUser (FetchUserQueryRequest) returns (FetchUserQueryResponse);
}
// DynamicQuery service for CQRS operations
service DynamicQueryService {
// Dynamic query for User
rpc QueryUsers (DynamicQueryUsersRequest) returns (DynamicQueryUsersResponse);
}
// Request message for AddUserCommand
message AddUserCommandRequest {
string name = 1;
string email = 2;
int32 age = 3;
}
// Response message for AddUserCommand
message AddUserCommandResponse {
int32 result = 1;
}
// Request message for RemoveUserCommand
message RemoveUserCommandRequest {
int32 user_id = 1;
}
// Response message for RemoveUserCommand
message RemoveUserCommandResponse {
}
// Request message for FetchUserQuery
message FetchUserQueryRequest {
int32 user_id = 1;
}
// Response message for FetchUserQuery
message FetchUserQueryResponse {
User result = 1;
}
// User entity
message User {
int32 id = 1;
string name = 2;
string email = 3;
}
// Dynamic query filter with AND/OR support
message DynamicQueryFilter {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.FilterType
string value = 3;
repeated DynamicQueryFilter and = 4;
repeated DynamicQueryFilter or = 5;
}
// Dynamic query sort
message DynamicQuerySort {
string path = 1;
bool ascending = 2;
}
// Dynamic query group
message DynamicQueryGroup {
string path = 1;
}
// Dynamic query aggregate
message DynamicQueryAggregate {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.AggregateType
}
// Dynamic query request for User
message DynamicQueryUsersRequest {
int32 page = 1;
int32 page_size = 2;
repeated DynamicQueryFilter filters = 3;
repeated DynamicQuerySort sorts = 4;
repeated DynamicQueryGroup groups = 5;
repeated DynamicQueryAggregate aggregates = 6;
}
// Dynamic query response for User
message DynamicQueryUsersResponse {
repeated User data = 1;
int64 total_records = 2;
int32 number_of_pages = 3;
}