Compare commits

...

5 Commits

Author SHA1 Message Date
227be70f95
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>
2026-01-14 15:56:31 -05:00
bd43bc9bde
Fix gRPC source generator for complex nested types
- Add DateTime/Timestamp conversion in nested property mapping
- Add IsReadOnly property detection to skip computed properties
- Extract ElementNestedProperties for complex list element types
- Skip read-only properties in GenerateComplexObjectMapping and GenerateComplexListMapping

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:25:01 -05:00
661f5b4b1c
Fix GrpcGenerator type mapping for commands and nullable primitives
- Add proper complex type mapping for command results (same as queries already had)
- Handle nullable primitives (long?, int?, etc.) with default value fallback
- Fixes CS0029 and CS0266 compilation errors in generated gRPC service implementations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:29:32 -05:00
99aebcf314
Fix proto generation for collection types (NpgsqlPolygon, etc.)
- Add IsCollectionTypeByInterface() to detect types implementing IList<T>, ICollection<T>, IEnumerable<T>
- Add GetCollectionElementTypeByInterface() to extract element type from collection interfaces
- Add IsCollectionInternalProperty() to filter out Count, Capacity, IsReadOnly, etc.
- Update GenerateComplexTypeMessage to generate `repeated T items` for collection types
- Filter out indexers (!p.IsIndexer) and collection-internal properties from all property extraction

This fixes the invalid proto syntax where C# indexers (this[]) were being generated
as proto fields, causing proto compilation errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:33:55 -05:00
Mathias Beaulieu-Duncan
f76dbb1a97 fix: add Guid to string conversion in gRPC source generator
The MapToProtoModel function was silently failing when mapping Guid
properties to proto string fields, causing IDs to be empty in gRPC
responses. Added explicit Guid → string conversion handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:06:18 -05:00
8 changed files with 1314 additions and 198 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -44,8 +44,16 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
public bool IsNullable { get; set; } public bool IsNullable { get; set; }
public bool IsDecimal { get; set; } public bool IsDecimal { get; set; }
public bool IsDateTime { 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 string? ElementType { get; set; }
public bool IsElementComplexType { get; set; } public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; } public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo() public PropertyInfo()
@ -61,7 +69,15 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
IsNullable = false; IsNullable = false;
IsDecimal = false; IsDecimal = false;
IsDateTime = false; IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false; IsElementComplexType = false;
IsElementGuid = false;
} }
} }
} }

View File

@ -320,7 +320,9 @@ internal class ProtoFileGenerator
var properties = type.GetMembers() var properties = type.GetMembers()
.OfType<IPropertySymbol>() .OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public) .Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList(); .ToList();
// Collect nested complex types to generate after closing this message // Collect nested complex types to generate after closing this message
@ -423,48 +425,73 @@ internal class ProtoFileGenerator
_messagesBuilder.AppendLine($"// {type.Name} entity"); _messagesBuilder.AppendLine($"// {type.Name} entity");
_messagesBuilder.AppendLine($"message {type.Name} {{"); _messagesBuilder.AppendLine($"message {type.Name} {{");
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.ToList();
// Collect nested complex types to generate after closing this message // Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>(); var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1; // Check if this type is a collection (implements IList<T>, ICollection<T>, etc.)
foreach (var prop in properties) var collectionElementType = ProtoFileTypeMapper.GetCollectionElementTypeByInterface(type);
if (collectionElementType != null)
{ {
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type)) // This type is a collection - generate a single repeated field for items
{ var protoElementType = ProtoFileTypeMapper.MapType(collectionElementType, out var needsImport, out var importPath);
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
if (needsImport && importPath != null) if (needsImport && importPath != null)
{ {
_requiredImports.Add(importPath); _requiredImports.Add(importPath);
} }
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name); _messagesBuilder.AppendLine($" repeated {protoElementType} items = 1;");
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// Track enums for later generation // Track the element type for nested generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type); if (IsComplexType(collectionElementType) && collectionElementType is INamedTypeSymbol elementNamedType)
if (enumType != null)
{ {
TrackEnumType(enumType); nestedComplexTypes.Add(elementNamedType);
} }
}
else
{
// Not a collection - generate properties as usual
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList();
// Collect complex types to generate after this message is closed int fieldNumber = 1;
// Use GetElementOrUnderlyingType to extract element type from collections foreach (var prop in properties)
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{ {
nestedComplexTypes.Add(namedType); if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
} {
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
fieldNumber++; var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
TrackEnumType(enumType);
}
// Collect complex types to generate after this message is closed
// Use GetElementOrUnderlyingType to extract element type from collections
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{
nestedComplexTypes.Add(namedType);
}
fieldNumber++;
}
} }
_messagesBuilder.AppendLine("}"); _messagesBuilder.AppendLine("}");
@ -737,7 +764,7 @@ internal class ProtoFileGenerator
FullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) FullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", ""), .Replace("global::", ""),
Namespace = type.ContainingNamespace?.ToDisplayString() ?? "", Namespace = type.ContainingNamespace?.ToDisplayString() ?? "",
SubscriptionKeyProperty = subscriptionKeyProp, SubscriptionKeyProperty = subscriptionKeyProp!, // Already validated as non-null above
SubscriptionKeyInfo = keyPropInfo, SubscriptionKeyInfo = keyPropInfo,
Properties = properties Properties = properties
}); });
@ -755,7 +782,9 @@ internal class ProtoFileGenerator
int fieldNumber = 1; int fieldNumber = 1;
foreach (var prop in type.GetMembers().OfType<IPropertySymbol>() foreach (var prop in type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)) .Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name)))
{ {
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type)) if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
continue; continue;
@ -817,14 +846,16 @@ internal class ProtoFileGenerator
foreach (var prop in notification.Properties) foreach (var prop in notification.Properties)
{ {
var protoType = ProtoFileTypeMapper.MapType( var typeSymbol = _compilation.GetTypeByMetadataName(prop.FullyQualifiedType) ??
_compilation.GetTypeByMetadataName(prop.FullyQualifiedType) ?? GetTypeFromName(prop.FullyQualifiedType);
GetTypeFromName(prop.FullyQualifiedType),
out var needsImport, out var importPath);
if (needsImport && importPath != null) if (typeSymbol != null)
{ {
_requiredImports.Add(importPath); ProtoFileTypeMapper.MapType(typeSymbol, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
} }
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name); var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);

View File

@ -20,6 +20,68 @@ internal static class ProtoFileTypeMapper
// Note: NullableAnnotation.Annotated is for reference type nullability (List<T>?, string?, etc.) // Note: NullableAnnotation.Annotated is for reference type nullability (List<T>?, string?, etc.)
// We don't unwrap these - just use the underlying type. Nullable<T> value types are handled later. // We don't unwrap these - just use the underlying type. Nullable<T> value types are handled later.
// Handle Nullable<T> value types (e.g., int?, decimal?, enum?) FIRST
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
// Unwrap the nullable and map the inner type
return MapType(nullableType.TypeArguments[0], out needsImport, out importPath);
}
// Handle collections BEFORE basic type checks (to avoid matching List<Guid> as Guid)
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, ICollection etc. (but not Nullable<T>)
var collectionTypeName = collectionType.Name;
if (collectionType.TypeArguments.Length == 1 &&
(collectionTypeName.Contains("List") || collectionTypeName.Contains("Collection") ||
collectionTypeName.Contains("Enumerable") || collectionTypeName.Contains("Array") ||
collectionTypeName.Contains("Set") || collectionTypeName.Contains("IList") ||
collectionTypeName.Contains("ICollection") || collectionTypeName.Contains("IEnumerable")))
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(collectionTypeName.Contains("Dictionary") || collectionTypeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
}
// Handle byte[] array type (check before switch since it's an array)
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return "bytes";
}
// Handle Stream types -> bytes
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return "bytes";
}
// Basic types // Basic types
switch (typeName) switch (typeName)
{ {
@ -49,81 +111,35 @@ internal static class ProtoFileTypeMapper
return "double"; return "double";
case "Byte[]": case "Byte[]":
return "bytes"; return "bytes";
} case "Stream":
case "MemoryStream":
// Special types that need imports case "FileStream":
if (fullTypeName.Contains("System.DateTime")) return "bytes";
{ case "Guid":
needsImport = true; // Guid serialized as string
importPath = "google/protobuf/timestamp.proto"; return "string";
return "google.protobuf.Timestamp"; case "Decimal":
} // Decimal serialized as string (no native decimal in proto)
return "string";
if (fullTypeName.Contains("System.TimeSpan")) case "DateTime":
{ case "DateTimeOffset":
needsImport = true; needsImport = true;
importPath = "google/protobuf/duration.proto"; importPath = "google/protobuf/timestamp.proto";
return "google.protobuf.Duration"; return "google.protobuf.Timestamp";
} case "DateOnly":
// DateOnly serialized as string (YYYY-MM-DD format)
if (fullTypeName.Contains("System.Guid")) return "string";
{ case "TimeOnly":
// Guid serialized as string // TimeOnly serialized as string (HH:mm:ss format)
return "string"; return "string";
} case "TimeSpan":
needsImport = true;
if (fullTypeName.Contains("System.Decimal") || typeName == "Decimal" || fullTypeName == "decimal") importPath = "google/protobuf/duration.proto";
{ return "google.protobuf.Duration";
// Decimal serialized as string (no native decimal in proto) case "JsonElement":
return "string"; needsImport = true;
} importPath = "google/protobuf/struct.proto";
return "google.protobuf.Struct";
// Handle Nullable<T> value types (e.g., int?, decimal?, enum?)
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
// Unwrap the nullable and map the inner type
return MapType(nullableType.TypeArguments[0], out needsImport, out importPath);
}
// Collections
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, ICollection etc. (but not Nullable<T>)
var typeName2 = collectionType.Name;
if (collectionType.TypeArguments.Length == 1 &&
(typeName2.Contains("List") || typeName2.Contains("Collection") ||
typeName2.Contains("Enumerable") || typeName2.Contains("Array") ||
typeName2.Contains("Set") || typeName2.Contains("IList") ||
typeName2.Contains("ICollection") || typeName2.Contains("IEnumerable")))
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(typeName.Contains("Dictionary") || typeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
} }
// Enums // Enums
@ -143,7 +159,10 @@ internal static class ProtoFileTypeMapper
} }
/// <summary> /// <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> /// </summary>
public static string ToSnakeCase(string pascalCase) public static string ToSnakeCase(string pascalCase)
{ {
@ -158,16 +177,8 @@ internal static class ProtoFileTypeMapper
var c = pascalCase[i]; var c = pascalCase[i];
if (char.IsUpper(c)) if (char.IsUpper(c))
{ {
// Handle sequences of uppercase letters (e.g., "APIKey" -> "api_key") result.Append('_');
if (i + 1 < pascalCase.Length && char.IsUpper(pascalCase[i + 1])) result.Append(char.ToLowerInvariant(c));
{
result.Append(char.ToLowerInvariant(c));
}
else
{
result.Append('_');
result.Append(char.ToLowerInvariant(c));
}
} }
else else
{ {
@ -186,8 +197,8 @@ internal static class ProtoFileTypeMapper
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Skip these types - they should trigger a warning/error // Skip these types - they should trigger a warning/error
if (fullTypeName.Contains("System.IO.Stream") || // Note: Stream types are now supported (mapped to bytes)
fullTypeName.Contains("System.Threading.CancellationToken") || if (fullTypeName.Contains("System.Threading.CancellationToken") ||
fullTypeName.Contains("System.Threading.Tasks.Task") || fullTypeName.Contains("System.Threading.Tasks.Task") ||
fullTypeName.Contains("System.Collections.Generic.IAsyncEnumerable") || fullTypeName.Contains("System.Collections.Generic.IAsyncEnumerable") ||
fullTypeName.Contains("System.Func") || fullTypeName.Contains("System.Func") ||
@ -200,6 +211,31 @@ internal static class ProtoFileTypeMapper
return false; return false;
} }
/// <summary>
/// Checks if a type is a Stream or byte array type (for special ByteString handling)
/// </summary>
public static bool IsBinaryType(ITypeSymbol typeSymbol)
{
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Check for byte[]
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return true;
}
// Check for Stream types
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return true;
}
var typeName = typeSymbol.Name;
return typeName == "Stream" || typeName == "MemoryStream" || typeName == "FileStream";
}
/// <summary> /// <summary>
/// Gets the element type from a collection type, or returns the type itself if not a collection. /// Gets the element type from a collection type, or returns the type itself if not a collection.
/// Also unwraps Nullable types. /// Also unwraps Nullable types.
@ -251,4 +287,97 @@ internal static class ProtoFileTypeMapper
} }
return null; return null;
} }
/// <summary>
/// Checks if a type is a collection by checking if it implements IList{T}, ICollection{T}, or IEnumerable{T}
/// This handles types like NpgsqlPolygon that implement IList{NpgsqlPoint} but aren't named "List"
/// </summary>
public static bool IsCollectionTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return false;
// Skip string (implements IEnumerable<char>)
if (namedType.SpecialType == SpecialType.System_String)
return false;
// Check all interfaces for IList<T>, ICollection<T>, or IEnumerable<T>
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.IEnumerable<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
{
return true;
}
}
}
return false;
}
/// <summary>
/// Gets the element type from a collection that implements IList{T}, ICollection{T}, or IEnumerable{T}
/// Returns null if the type is not a collection
/// </summary>
public static ITypeSymbol? GetCollectionElementTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return null;
// Skip string
if (namedType.SpecialType == SpecialType.System_String)
return null;
// Prefer IList<T> over ICollection<T> over IEnumerable<T>
ITypeSymbol? elementType = null;
int priority = 0;
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
int currentPriority = 0;
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>")
currentPriority = 3;
else if (ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
currentPriority = 2;
else if (ifaceName == "System.Collections.Generic.IEnumerable<T>")
currentPriority = 1;
if (currentPriority > priority)
{
priority = currentPriority;
elementType = iface.TypeArguments[0];
}
}
}
return elementType;
}
/// <summary>
/// Collection-internal properties that should be skipped when generating proto messages
/// </summary>
private static readonly System.Collections.Generic.HashSet<string> CollectionInternalProperties = new()
{
"Count", "Capacity", "IsReadOnly", "IsSynchronized", "SyncRoot", "Keys", "Values"
};
/// <summary>
/// Checks if a property name is a collection-internal property that should be skipped
/// </summary>
public static bool IsCollectionInternalProperty(string propertyName)
{
return CollectionInternalProperties.Contains(propertyName);
}
} }

View File

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