Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fc680cd93 | |||
| 55f1324286 | |||
| b34bf874b4 | |||
| c6de10b98b | |||
| 3945c1a158 | |||
| 7614f68512 | |||
| fdee02c960 | |||
| a4525bad6a | |||
| 3df094b9e7 | |||
|
6aece5a769
|
|||
|
b372805c4e
|
|||
|
89ccbe990f
|
|||
|
433b852a43
|
|||
|
03041721ca
|
|||
|
05449b9a28
|
|||
|
dfbef9d161
|
|||
|
377977b080
|
|||
|
20147bfec7
|
|||
|
18f81a28e8
|
|||
|
201768e716
|
|||
|
932ee6e632
|
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet clean:*)",
|
||||
"Bash(dotnet run)",
|
||||
"Bash(dotnet add:*)",
|
||||
"Bash(timeout 5 dotnet run:*)",
|
||||
"Bash(dotnet remove:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"WebSearch",
|
||||
"Bash(dotnet tool install:*)",
|
||||
"Bash(protogen:*)",
|
||||
"Bash(timeout 15 dotnet run:*)",
|
||||
"Bash(where:*)",
|
||||
"Bash(timeout 30 dotnet run:*)",
|
||||
"Bash(timeout 60 dotnet run:*)",
|
||||
"Bash(timeout 120 dotnet run:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(timeout 3 cmd:*)",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(dotnet --list-sdks:*)",
|
||||
"Bash(dotnet sln:*)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(grpcurl:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(xargs kill -9)",
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(dotnet pack:*)",
|
||||
"Bash(unzip:*)",
|
||||
"WebFetch(domain:andrewlock.net)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:stackoverflow.com)",
|
||||
"WebFetch(domain:www.kenmuse.com)",
|
||||
"WebFetch(domain:blog.rsuter.com)",
|
||||
"WebFetch(domain:natemcmaster.com)",
|
||||
"WebFetch(domain:www.nuget.org)",
|
||||
"Bash(mkdir:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{csproj,props,targets,xml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.proto]
|
||||
indent_size = 2
|
||||
|
||||
[*.cs]
|
||||
# Namespace
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
|
||||
# Braces — Allman style
|
||||
csharp_new_line_before_open_brace = all
|
||||
|
||||
# Usings
|
||||
dotnet_sort_system_directives_first = true
|
||||
csharp_using_directive_placement = outside_namespace:warning
|
||||
|
||||
# var preferences — use var when type is apparent
|
||||
csharp_style_var_for_built_in_types = true:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
|
||||
# Expression bodies — prefer for simple members
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_constructors = false:suggestion
|
||||
csharp_style_expression_bodied_operators = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_properties = true:suggestion
|
||||
csharp_style_expression_bodied_accessors = true:suggestion
|
||||
csharp_style_expression_bodied_lambdas = true:suggestion
|
||||
|
||||
# Pattern matching
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||
|
||||
# Null checking
|
||||
csharp_style_throw_expression = true:suggestion
|
||||
csharp_style_conditional_delegate_call = true:suggestion
|
||||
|
||||
# Modifier preferences — exclude interface members (netstandard2.1 compat)
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||
|
||||
# Field naming — _camelCase for private fields
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore
|
||||
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
|
||||
dotnet_naming_symbols.private_fields.required_modifiers =
|
||||
|
||||
dotnet_naming_style.camel_case_underscore.required_prefix = _
|
||||
dotnet_naming_style.camel_case_underscore.capitalization = camel_case
|
||||
|
||||
# Constants — PascalCase
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_symbols.constants.applicable_kinds = field
|
||||
dotnet_naming_symbols.constants.required_modifiers = const
|
||||
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
# Interfaces — I prefix
|
||||
dotnet_naming_rule.interfaces_should_begin_with_i.severity = warning
|
||||
dotnet_naming_rule.interfaces_should_begin_with_i.symbols = interfaces
|
||||
dotnet_naming_rule.interfaces_should_begin_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
||||
|
||||
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||
|
||||
# Async methods — Async suffix
|
||||
dotnet_naming_rule.async_methods_should_end_with_async.severity = suggestion
|
||||
dotnet_naming_rule.async_methods_should_end_with_async.symbols = async_methods
|
||||
dotnet_naming_rule.async_methods_should_end_with_async.style = ends_with_async
|
||||
|
||||
dotnet_naming_symbols.async_methods.applicable_kinds = method
|
||||
dotnet_naming_symbols.async_methods.required_modifiers = async
|
||||
|
||||
dotnet_naming_style.ends_with_async.required_suffix = Async
|
||||
dotnet_naming_style.ends_with_async.capitalization = pascal_case
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to AI agents when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
Our implementation of query and command responsibility segregation (CQRS).
|
||||
|
||||
## Where This Fits
|
||||
|
||||
This is a backend framework of the [Svrnty Agent System](../README.md).
|
||||
|
||||
**Layer**: Framework
|
||||
**Depends on**: Nothing (standalone .NET framework)
|
||||
**Depended on by**: a-gent-app (backend services), flutter_cqrs_datasource (client)
|
||||
**Git**: [git.openharbor.io/svrnty/dotnet-cqrs](https://git.openharbor.io/svrnty/dotnet-cqrs)
|
||||
|
||||
## Getting Started
|
||||
|
||||
> Install nuget package to your awesome project.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Attributes;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Attributes;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Svrnty.CQRS.Abstractions.Attributes;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@@ -10,4 +10,4 @@ public interface IQueryMeta
|
||||
Type QueryResultType { get; }
|
||||
string Category { get; }
|
||||
string LowerCamelCaseName { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Svrnty.CQRS.Abstractions.Attributes;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions;
|
||||
@@ -13,4 +13,4 @@ public interface ICommandHandler<in TCommand, TCommandResult>
|
||||
where TCommand : class
|
||||
{
|
||||
Task<TCommandResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions;
|
||||
@@ -7,4 +7,4 @@ public interface IQueryHandler<in TQuery, TQueryResult>
|
||||
where TQuery : class
|
||||
{
|
||||
Task<TQueryResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace Svrnty.CQRS.Abstractions.Security;
|
||||
namespace Svrnty.CQRS.Abstractions.Security;
|
||||
|
||||
public enum AuthorizationResult
|
||||
{
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
Allowed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
|
||||
public interface ICommandAuthorizationService
|
||||
{
|
||||
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
|
||||
public interface IQueryAuthorizationService
|
||||
{
|
||||
Task<AuthorizationResult> IsAllowedAsync(Type queryType, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@@ -47,4 +47,4 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -13,4 +13,4 @@ public interface IAlterQueryableService<TSource, TDestination, in TParams>
|
||||
where TParams : class
|
||||
{
|
||||
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQueryParams<TParams> dynamicQuery, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
@@ -15,7 +15,7 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
|
||||
where TDestination : class
|
||||
where TParams : class
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
|
||||
public interface IDynamicQuery
|
||||
@@ -26,4 +26,4 @@ public interface IDynamicQuery
|
||||
List<IAggregate> GetAggregates();
|
||||
int? GetPage();
|
||||
int? GetPageSize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
|
||||
public interface IDynamicQueryInterceptorProvider<TSource, TDestination>
|
||||
{
|
||||
IEnumerable<Type> GetInterceptorsTypes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
public interface IDynamicQueryParams<out TParams>
|
||||
where TParams : class
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
public interface IQueryableProvider<TSource>
|
||||
{
|
||||
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
using Svrnty.CQRS.Abstractions.Attributes;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
@@ -14,7 +15,6 @@ using Svrnty.CQRS.Abstractions.Security;
|
||||
using Svrnty.CQRS.DynamicQuery;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
using Svrnty.CQRS.DynamicQuery.Discover;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery.MinimalApi;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Pluralize.NET;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Svrnty.CQRS.DynamicQuery.Discover;
|
||||
public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResultType)
|
||||
: QueryMeta(queryType, serviceType, queryResultType)
|
||||
{
|
||||
public Type SourceType => QueryType.GetGenericArguments()[0];
|
||||
public Type SourceType => QueryType.GetGenericArguments()[0];
|
||||
public Type DestinationType => QueryType.GetGenericArguments()[1];
|
||||
public override string Category => "DynamicQuery";
|
||||
public override string Name
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
using PoweredSoft.DynamicQuery;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using PoweredSoft.DynamicQuery;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
public class DynamicQueryHandler<TSource, TDestination>
|
||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination>, IQueryExecutionResult<TDestination>>
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
{
|
||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
|
||||
{
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public class DynamicQueryHandler<TSource, TDestination>
|
||||
}
|
||||
|
||||
public class DynamicQueryHandler<TSource, TDestination, TParams>
|
||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
@@ -37,10 +37,10 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
|
||||
{
|
||||
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams;
|
||||
|
||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
|
||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
|
||||
{
|
||||
@@ -49,7 +49,7 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
|
||||
|
||||
protected override async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
source = await base.AlterSourceAsync(source, query, cancellationToken);
|
||||
source = await base.AlterSourceAsync(source, query, cancellationToken);
|
||||
|
||||
if (query is IDynamicQueryParams<TParams> withParams)
|
||||
{
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
using PoweredSoft.DynamicQuery;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
@@ -16,10 +19,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
||||
private readonly IQueryHandlerAsync _queryHandlerAsync;
|
||||
private readonly IEnumerable<IQueryableProvider<TSource>> _queryableProviders;
|
||||
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination>> _alterQueryableServices;
|
||||
private readonly IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> _dynamicQueryInterceptorProviders;
|
||||
|
||||
private readonly IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>>
|
||||
_dynamicQueryInterceptorProviders;
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
|
||||
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
|
||||
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||
@@ -32,7 +38,8 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
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())
|
||||
{
|
||||
@@ -56,7 +63,8 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
||||
yield return _serviceProvider.GetService(type) as IQueryInterceptor;
|
||||
}
|
||||
|
||||
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query, CancellationToken cancellationToken = default)
|
||||
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await GetQueryableAsync(query, cancellationToken);
|
||||
source = await AlterSourceAsync(source, query, cancellationToken);
|
||||
@@ -67,11 +75,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
||||
_queryHandlerAsync.AddInterceptor(interceptor);
|
||||
|
||||
var criteria = CreateCriteriaFromQuery(query);
|
||||
var result = await _queryHandlerAsync.ExecuteAsync<TSource, TDestination>(source, criteria, options, cancellationToken);
|
||||
var result =
|
||||
await _queryHandlerAsync.ExecuteAsync<TSource, TDestination>(source, criteria, options, cancellationToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
protected virtual async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
|
||||
protected virtual async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var t in _alterQueryableServices)
|
||||
source = await t.AlterQueryableAsync(source, query, cancellationToken);
|
||||
@@ -81,16 +91,94 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
||||
|
||||
protected virtual IQueryCriteria CreateCriteriaFromQuery(IDynamicQuery query)
|
||||
{
|
||||
var filters = query?.GetFilters() ?? new List<IFilter>();
|
||||
ConvertFilterValuesToPropertyTypes(filters);
|
||||
var criteria = new QueryCriteria
|
||||
{
|
||||
Page = query?.GetPage(),
|
||||
PageSize = query?.GetPageSize(),
|
||||
Filters = query?.GetFilters() ?? new List<IFilter>(),
|
||||
Filters = filters,
|
||||
Sorts = query?.GetSorts() ?? new List<ISort>(),
|
||||
Groups = query?.GetGroups() ?? new List<IGroup>(),
|
||||
Aggregates = query?.GetAggregates() ?? new List<IAggregate>()
|
||||
};
|
||||
return criteria;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts string filter values to the correct CLR type based on TSource property types.
|
||||
/// This handles the case where transport layers (e.g. gRPC) pass all values as strings,
|
||||
/// but PoweredSoft.DynamicLinq needs the actual type to build LINQ expressions.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2087",
|
||||
Justification = "TSource properties are preserved by EF Core and DynamicLinq usage")]
|
||||
private static void ConvertFilterValuesToPropertyTypes(List<IFilter> filters)
|
||||
{
|
||||
for (var i = filters.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var filter = filters[i];
|
||||
if (filter is SimpleFilter simpleFilter)
|
||||
{
|
||||
if (simpleFilter.Value == null)
|
||||
{
|
||||
filters.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (simpleFilter.Value is string strValue && !string.IsNullOrEmpty(strValue))
|
||||
{
|
||||
var propertyType = ResolvePropertyType(typeof(TSource), simpleFilter.Path);
|
||||
if (propertyType == null)
|
||||
continue;
|
||||
|
||||
var targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
|
||||
|
||||
if (targetType == typeof(DateTime))
|
||||
{
|
||||
if (DateTime.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal,
|
||||
out var dt))
|
||||
{
|
||||
simpleFilter.Value = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
else if (targetType == typeof(DateTimeOffset))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.None,
|
||||
out var dto))
|
||||
{
|
||||
simpleFilter.Value = dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (filter is CompositeFilter compositeFilter && compositeFilter.Filters != null)
|
||||
{
|
||||
ConvertFilterValuesToPropertyTypes(compositeFilter.Filters);
|
||||
}
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2070",
|
||||
Justification = "Property types are preserved by EF Core and DynamicLinq usage")]
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075",
|
||||
Justification = "Nested property type resolution is inherently dynamic")]
|
||||
static Type? ResolvePropertyType(
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return null;
|
||||
|
||||
var currentType = type;
|
||||
foreach (var part in path.Split('.'))
|
||||
{
|
||||
var property = currentType.GetProperty(part,
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
if (property == null)
|
||||
return null;
|
||||
currentType = property.PropertyType;
|
||||
}
|
||||
|
||||
return currentType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using PoweredSoft.Data.Core;
|
||||
@@ -91,10 +91,10 @@ public static class ServiceCollectionExtensions
|
||||
where TParams : class
|
||||
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
|
||||
|
||||
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
where TParams : class
|
||||
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
where TParams : class
|
||||
{
|
||||
// add query handler.
|
||||
services.AddTransient<IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>, DynamicQueryHandler<TSource, TDestination, TParams>>();
|
||||
@@ -133,7 +133,7 @@ public static class ServiceCollectionExtensions
|
||||
where TParams : class
|
||||
where TService : class, IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>
|
||||
{
|
||||
return services.AddTransient<IAlterQueryableService< TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
|
||||
return services.AddTransient<IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAlterQueryableWithParams<TSource, TDestination, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Confluent.Kafka;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Svrnty.CQRS.Events.Abstractions;
|
||||
|
||||
namespace Svrnty.CQRS.Events.Kafka;
|
||||
|
||||
/// <summary>
|
||||
/// Apache Kafka implementation of the domain event publisher.
|
||||
/// </summary>
|
||||
public class KafkaDomainEventPublisher : IDomainEventPublisher, IAsyncDisposable
|
||||
{
|
||||
private readonly KafkaEventOptions _options;
|
||||
private readonly ILogger<KafkaDomainEventPublisher> _logger;
|
||||
private readonly IProducer<string, string> _producer;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Kafka domain event publisher.
|
||||
/// </summary>
|
||||
public KafkaDomainEventPublisher(
|
||||
IOptions<KafkaEventOptions> options,
|
||||
ILogger<KafkaDomainEventPublisher> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
|
||||
var config = BuildProducerConfig();
|
||||
_producer = new ProducerBuilder<string, string>(config)
|
||||
.SetErrorHandler((_, e) => _logger.LogError("Kafka producer error: {Reason}", e.Reason))
|
||||
.SetLogHandler((_, log) => _logger.LogDebug("Kafka: {Message}", log.Message))
|
||||
.Build();
|
||||
}
|
||||
|
||||
private ProducerConfig BuildProducerConfig()
|
||||
{
|
||||
var config = new ProducerConfig
|
||||
{
|
||||
BootstrapServers = _options.BootstrapServers,
|
||||
ClientId = _options.ClientId,
|
||||
EnableIdempotence = _options.EnableIdempotence,
|
||||
MessageTimeoutMs = _options.MessageTimeoutMs,
|
||||
Acks = (Acks)_options.Acks,
|
||||
MessageSendMaxRetries = _options.Retries
|
||||
};
|
||||
|
||||
// Configure security if specified
|
||||
if (!string.IsNullOrEmpty(_options.SecurityProtocol) &&
|
||||
_options.SecurityProtocol != "Plaintext")
|
||||
{
|
||||
config.SecurityProtocol = Enum.Parse<SecurityProtocol>(_options.SecurityProtocol);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_options.SaslMechanism))
|
||||
{
|
||||
config.SaslMechanism = Enum.Parse<SaslMechanism>(_options.SaslMechanism);
|
||||
config.SaslUsername = _options.SaslUsername;
|
||||
config.SaslPassword = _options.SaslPassword;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
|
||||
where TEvent : IDomainEvent
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var eventTypeName = typeof(TEvent).Name;
|
||||
var topic = GetTopicName(eventTypeName);
|
||||
var key = GetEventKey(@event);
|
||||
var value = JsonSerializer.Serialize(@event);
|
||||
|
||||
var message = new Message<string, string>
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
Headers = new Headers
|
||||
{
|
||||
{ "event-type", Encoding.UTF8.GetBytes(eventTypeName) },
|
||||
{ "event-id", Encoding.UTF8.GetBytes(@event.EventId.ToString()) },
|
||||
{ "occurred-at", Encoding.UTF8.GetBytes(@event.OccurredAt.ToString("O")) },
|
||||
{ "content-type", Encoding.UTF8.GetBytes("application/json") }
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _producer.ProduceAsync(topic, message, cancellationToken);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Published event {EventType} with ID {EventId} to topic {Topic}, partition {Partition}, offset {Offset}",
|
||||
eventTypeName,
|
||||
@event.EventId,
|
||||
topic,
|
||||
result.Partition.Value,
|
||||
result.Offset.Value);
|
||||
}
|
||||
catch (ProduceException<string, string> ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to publish event {EventType} with ID {EventId} to topic {Topic}: {Error}",
|
||||
eventTypeName,
|
||||
@event.EventId,
|
||||
topic,
|
||||
ex.Error.Reason);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetTopicName(string eventTypeName)
|
||||
{
|
||||
// Use custom mapper if provided
|
||||
if (_options.TopicMapper != null)
|
||||
{
|
||||
return _options.TopicMapper(eventTypeName);
|
||||
}
|
||||
|
||||
// Default mapping: group events by category
|
||||
// e.g., RequestCreatedEvent -> {prefix}.requests
|
||||
// e.g., ContainerSpawnedEvent -> {prefix}.containers
|
||||
var category = GetEventCategory(eventTypeName);
|
||||
return $"{_options.TopicPrefix}.{category}";
|
||||
}
|
||||
|
||||
private static string GetEventCategory(string eventTypeName)
|
||||
{
|
||||
// Remove "Event" suffix
|
||||
var name = eventTypeName.Replace("Event", "");
|
||||
|
||||
// Map to categories based on prefix
|
||||
return name switch
|
||||
{
|
||||
var n when n.StartsWith("Request") => "requests",
|
||||
var n when n.StartsWith("Container") => "containers",
|
||||
var n when n.StartsWith("Plan") => "plans",
|
||||
var n when n.StartsWith("Project") => "projects",
|
||||
var n when n.StartsWith("Solution") => "solutions",
|
||||
var n when n.StartsWith("Claude") || n.StartsWith("Session") => "sessions",
|
||||
var n when n.StartsWith("Worker") => "workers",
|
||||
var n when n.StartsWith("Deployment") => "deployments",
|
||||
_ => "events" // Default catch-all topic
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetEventKey<TEvent>(TEvent @event) where TEvent : IDomainEvent
|
||||
{
|
||||
// Use reflection to find the best partition key
|
||||
// Priority: RequestId > ProjectId > SolutionId > EventId
|
||||
var type = typeof(TEvent);
|
||||
|
||||
// Try RequestId first (most common for request-scoped events)
|
||||
var requestIdProp = type.GetProperty("RequestId");
|
||||
if (requestIdProp?.GetValue(@event) is Guid requestId && requestId != Guid.Empty)
|
||||
{
|
||||
return requestId.ToString();
|
||||
}
|
||||
|
||||
// Try ContainerId
|
||||
var containerIdProp = type.GetProperty("ContainerId");
|
||||
if (containerIdProp?.GetValue(@event) is Guid containerId && containerId != Guid.Empty)
|
||||
{
|
||||
return containerId.ToString();
|
||||
}
|
||||
|
||||
// Try ProjectId
|
||||
var projectIdProp = type.GetProperty("ProjectId");
|
||||
if (projectIdProp?.GetValue(@event) is Guid projectId && projectId != Guid.Empty)
|
||||
{
|
||||
return projectId.ToString();
|
||||
}
|
||||
|
||||
// Try SolutionId
|
||||
var solutionIdProp = type.GetProperty("SolutionId");
|
||||
if (solutionIdProp?.GetValue(@event) is Guid solutionId && solutionId != Guid.Empty)
|
||||
{
|
||||
return solutionId.ToString();
|
||||
}
|
||||
|
||||
// Fall back to EventId
|
||||
return @event.EventId.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
// Flush pending messages with timeout
|
||||
_producer.Flush(TimeSpan.FromSeconds(10));
|
||||
_producer.Dispose();
|
||||
|
||||
_logger.LogDebug("Kafka domain event publisher disposed");
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace Svrnty.CQRS.Events.Kafka;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Kafka domain event publishing.
|
||||
/// </summary>
|
||||
public class KafkaEventOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Kafka bootstrap servers. Default: localhost:9092
|
||||
/// </summary>
|
||||
public string BootstrapServers { get; set; } = "localhost:9092";
|
||||
|
||||
/// <summary>
|
||||
/// Prefix for Kafka topics. Default: domain
|
||||
/// Events will be published to topics like {TopicPrefix}.{category}
|
||||
/// </summary>
|
||||
public string TopicPrefix { get; set; } = "domain";
|
||||
|
||||
/// <summary>
|
||||
/// Client identifier for Kafka producer. Default: cqrs-events
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = "cqrs-events";
|
||||
|
||||
/// <summary>
|
||||
/// Enable idempotent producer for exactly-once semantics. Default: true
|
||||
/// </summary>
|
||||
public bool EnableIdempotence { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Message timeout in milliseconds. Default: 30000 (30 seconds)
|
||||
/// </summary>
|
||||
public int MessageTimeoutMs { get; set; } = 30000;
|
||||
|
||||
/// <summary>
|
||||
/// Security protocol. Default: Plaintext
|
||||
/// Options: Plaintext, Ssl, SaslPlaintext, SaslSsl
|
||||
/// </summary>
|
||||
public string SecurityProtocol { get; set; } = "Plaintext";
|
||||
|
||||
/// <summary>
|
||||
/// SASL mechanism for authentication. Optional.
|
||||
/// Options: Plain, ScramSha256, ScramSha512
|
||||
/// </summary>
|
||||
public string? SaslMechanism { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SASL username for authentication. Optional.
|
||||
/// </summary>
|
||||
public string? SaslUsername { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SASL password for authentication. Optional.
|
||||
/// </summary>
|
||||
public string? SaslPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of acknowledgements required. Default: All (-1)
|
||||
/// Options: None (0), Leader (1), All (-1)
|
||||
/// </summary>
|
||||
public int Acks { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retries. Default: 3
|
||||
/// </summary>
|
||||
public int Retries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Custom topic mapping function. If not set, default mapping is used.
|
||||
/// Maps event type name to topic name.
|
||||
/// </summary>
|
||||
public Func<string, string>? TopicMapper { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.Events.Abstractions;
|
||||
|
||||
namespace Svrnty.CQRS.Events.Kafka;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Kafka domain event publishing.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Kafka domain event publishing to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action for Kafka options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddKafkaDomainEvents(
|
||||
this IServiceCollection services,
|
||||
Action<KafkaEventOptions>? configure = null)
|
||||
{
|
||||
if (configure != null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddSingleton<IDomainEventPublisher, KafkaDomainEventPublisher>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Kafka domain event publishing with custom topic mapping.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="topicMapper">Custom function to map event type names to topic names.</param>
|
||||
/// <param name="configure">Optional configuration action for other Kafka options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddKafkaDomainEvents(
|
||||
this IServiceCollection services,
|
||||
Func<string, string> topicMapper,
|
||||
Action<KafkaEventOptions>? configure = null)
|
||||
{
|
||||
services.Configure<KafkaEventOptions>(options =>
|
||||
{
|
||||
options.TopicMapper = topicMapper;
|
||||
configure?.Invoke(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<IDomainEventPublisher, KafkaDomainEventPublisher>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsAotCompatible>false</IsAotCompatible>
|
||||
<LangVersion>14</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<Company>Svrnty</Company>
|
||||
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
|
||||
<PackageIcon>icon.png</PackageIcon>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<Description>Apache Kafka domain event publishing for Svrnty.CQRS framework</Description>
|
||||
|
||||
<DebugType>portable</DebugType>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<IncludeSource>true</IncludeSource>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
|
||||
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Svrnty.CQRS.Events.Abstractions\Svrnty.CQRS.Events.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Confluent.Kafka" Version="2.6.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
services.AddQuery<TQuery, TQueryResult, TQueryHandler>()
|
||||
.AddFluentValidator<TQuery, TValidator>();
|
||||
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Build.Framework;
|
||||
using Microsoft.Build.Utilities;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators;
|
||||
|
||||
/// <summary>
|
||||
/// MSBuild task that generates .proto files by creating its own Roslyn compilation.
|
||||
/// This runs BEFORE CoreCompile to solve the source generator timing issue.
|
||||
/// </summary>
|
||||
public class GenerateProtoFileTask : Task
|
||||
{
|
||||
/// <summary>
|
||||
/// The project directory
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ProjectDirectory { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The output directory where the proto file should be written (typically Protos/)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string OutputDirectory { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the proto file to generate (typically cqrs_services.proto)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ProtoFileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The C# source files to compile (from @(Compile) ItemGroup)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ITaskItem[] SourceFiles { get; set; } = Array.Empty<ITaskItem>();
|
||||
|
||||
/// <summary>
|
||||
/// The assembly references (from @(ReferencePath) ItemGroup)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ITaskItem[] References { get; set; } = Array.Empty<ITaskItem>();
|
||||
|
||||
/// <summary>
|
||||
/// The root namespace of the project
|
||||
/// </summary>
|
||||
public string RootNamespace { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The assembly name of the project
|
||||
/// </summary>
|
||||
public string AssemblyName { get; set; } = string.Empty;
|
||||
|
||||
public override bool Execute()
|
||||
{
|
||||
try
|
||||
{
|
||||
Log.LogMessage(MessageImportance.High,
|
||||
"Svrnty.CQRS.Grpc: Generating proto file via MSBuild task...");
|
||||
|
||||
// Determine the namespace for the proto file
|
||||
var projectNamespace = !string.IsNullOrEmpty(RootNamespace) ? RootNamespace
|
||||
: !string.IsNullOrEmpty(AssemblyName) ? AssemblyName
|
||||
: "Generated";
|
||||
var grpcNamespace = $"{projectNamespace}.Grpc";
|
||||
var packageName = "cqrs";
|
||||
|
||||
// Create the compilation
|
||||
var compilation = CreateCompilation();
|
||||
if (compilation == null)
|
||||
{
|
||||
Log.LogWarning("Svrnty.CQRS.Grpc: Could not create compilation. Writing placeholder proto file.");
|
||||
WritePlaceholderProto(grpcNamespace);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for compilation errors that would prevent proper analysis
|
||||
var diagnostics = compilation.GetDiagnostics()
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||
.ToList();
|
||||
|
||||
if (diagnostics.Count > 0)
|
||||
{
|
||||
Log.LogMessage(MessageImportance.Normal,
|
||||
$"Svrnty.CQRS.Grpc: Compilation has {diagnostics.Count} errors. Attempting to generate proto anyway...");
|
||||
|
||||
// Log first few errors for debugging
|
||||
foreach (var diag in diagnostics.Take(5))
|
||||
{
|
||||
Log.LogMessage(MessageImportance.Low, $" {diag.GetMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// Use the ProtoFileGenerator to generate content
|
||||
var generator = new ProtoFileGenerator(compilation);
|
||||
var protoContent = generator.Generate(packageName, grpcNamespace);
|
||||
|
||||
// Check if we got meaningful content
|
||||
if (string.IsNullOrWhiteSpace(protoContent) || !protoContent.Contains("rpc "))
|
||||
{
|
||||
Log.LogMessage(MessageImportance.High,
|
||||
"Svrnty.CQRS.Grpc: No commands/queries/notifications found. Writing minimal proto file.");
|
||||
WritePlaceholderProto(grpcNamespace);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
|
||||
? OutputDirectory
|
||||
: Path.Combine(ProjectDirectory, OutputDirectory);
|
||||
Directory.CreateDirectory(fullOutputPath);
|
||||
|
||||
// Write the proto file
|
||||
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
|
||||
File.WriteAllText(protoFilePath, protoContent);
|
||||
|
||||
Log.LogMessage(MessageImportance.High,
|
||||
$"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.LogErrorFromException(ex, true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private CSharpCompilation? CreateCompilation()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse all source files into syntax trees
|
||||
var syntaxTrees = new List<SyntaxTree>();
|
||||
foreach (var sourceFile in SourceFiles)
|
||||
{
|
||||
var filePath = sourceFile.ItemSpec;
|
||||
if (!Path.IsPathRooted(filePath))
|
||||
{
|
||||
filePath = Path.Combine(ProjectDirectory, filePath);
|
||||
}
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
Log.LogMessage(MessageImportance.Low, $"Source file not found: {filePath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sourceText = File.ReadAllText(filePath);
|
||||
var syntaxTree = CSharpSyntaxTree.ParseText(
|
||||
sourceText,
|
||||
CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest),
|
||||
path: filePath);
|
||||
syntaxTrees.Add(syntaxTree);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.LogMessage(MessageImportance.Low, $"Failed to parse {filePath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (syntaxTrees.Count == 0)
|
||||
{
|
||||
Log.LogWarning("Svrnty.CQRS.Grpc: No source files could be parsed.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Log.LogMessage(MessageImportance.Normal,
|
||||
$"Svrnty.CQRS.Grpc: Parsed {syntaxTrees.Count} source files");
|
||||
|
||||
// Create metadata references from the References
|
||||
var metadataReferences = new List<MetadataReference>();
|
||||
foreach (var reference in References)
|
||||
{
|
||||
var refPath = reference.ItemSpec;
|
||||
if (!File.Exists(refPath))
|
||||
{
|
||||
Log.LogMessage(MessageImportance.Low, $"Reference not found: {refPath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var metadataRef = MetadataReference.CreateFromFile(refPath);
|
||||
metadataReferences.Add(metadataRef);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.LogMessage(MessageImportance.Low, $"Failed to load reference {refPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Log.LogMessage(MessageImportance.Normal,
|
||||
$"Svrnty.CQRS.Grpc: Loaded {metadataReferences.Count} references");
|
||||
|
||||
// Create the compilation
|
||||
var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
|
||||
.WithNullableContextOptions(NullableContextOptions.Enable);
|
||||
|
||||
var assemblyName = !string.IsNullOrEmpty(AssemblyName) ? AssemblyName : "TempCompilation";
|
||||
var compilation = CSharpCompilation.Create(
|
||||
assemblyName,
|
||||
syntaxTrees,
|
||||
metadataReferences,
|
||||
compilationOptions);
|
||||
|
||||
return compilation;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.LogMessage(MessageImportance.High,
|
||||
$"Svrnty.CQRS.Grpc: Failed to create compilation: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void WritePlaceholderProto(string grpcNamespace)
|
||||
{
|
||||
var placeholderProto = $@"syntax = ""proto3"";
|
||||
|
||||
option csharp_namespace = ""{grpcNamespace}"";
|
||||
|
||||
package cqrs;
|
||||
|
||||
// Placeholder proto file - will be regenerated when commands/queries are available
|
||||
// Using namespace: {grpcNamespace}
|
||||
|
||||
// Empty service definitions so Grpc.Tools generates base classes
|
||||
service CommandService {{
|
||||
}}
|
||||
|
||||
service QueryService {{
|
||||
}}
|
||||
|
||||
service DynamicQueryService {{
|
||||
}}
|
||||
";
|
||||
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
|
||||
? OutputDirectory
|
||||
: Path.Combine(ProjectDirectory, OutputDirectory);
|
||||
Directory.CreateDirectory(fullOutputPath);
|
||||
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
|
||||
File.WriteAllText(protoFilePath, placeholderProto);
|
||||
|
||||
Log.LogMessage(MessageImportance.High,
|
||||
$"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {protoFilePath}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,102 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Helpers
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Helpers;
|
||||
|
||||
internal static class ProtoTypeMapper
|
||||
{
|
||||
internal static class ProtoTypeMapper
|
||||
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
|
||||
{
|
||||
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
|
||||
// Primitives
|
||||
{ "System.String", "string" },
|
||||
{ "System.Boolean", "bool" },
|
||||
{ "System.Int32", "int32" },
|
||||
{ "System.Int64", "int64" },
|
||||
{ "System.UInt32", "uint32" },
|
||||
{ "System.UInt64", "uint64" },
|
||||
{ "System.Single", "float" },
|
||||
{ "System.Double", "double" },
|
||||
{ "System.Byte", "uint32" },
|
||||
{ "System.SByte", "int32" },
|
||||
{ "System.Int16", "int32" },
|
||||
{ "System.UInt16", "uint32" },
|
||||
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
|
||||
{ "System.DateTime", "int64" }, // Unix timestamp
|
||||
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
|
||||
{ "System.Guid", "string" },
|
||||
{ "System.TimeSpan", "int64" }, // Ticks
|
||||
|
||||
// Nullable variants
|
||||
{ "System.Boolean?", "bool" },
|
||||
{ "System.Int32?", "int32" },
|
||||
{ "System.Int64?", "int64" },
|
||||
{ "System.UInt32?", "uint32" },
|
||||
{ "System.UInt64?", "uint64" },
|
||||
{ "System.Single?", "float" },
|
||||
{ "System.Double?", "double" },
|
||||
{ "System.Byte?", "uint32" },
|
||||
{ "System.SByte?", "int32" },
|
||||
{ "System.Int16?", "int32" },
|
||||
{ "System.UInt16?", "uint32" },
|
||||
{ "System.Decimal?", "string" },
|
||||
{ "System.DateTime?", "int64" },
|
||||
{ "System.DateTimeOffset?", "int64" },
|
||||
{ "System.Guid?", "string" },
|
||||
{ "System.TimeSpan?", "int64" },
|
||||
};
|
||||
|
||||
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
|
||||
{
|
||||
isRepeated = false;
|
||||
isOptional = false;
|
||||
|
||||
// Handle byte[] as bytes proto type (NOT repeated uint32)
|
||||
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
|
||||
{
|
||||
// Primitives
|
||||
{ "System.String", "string" },
|
||||
{ "System.Boolean", "bool" },
|
||||
{ "System.Int32", "int32" },
|
||||
{ "System.Int64", "int64" },
|
||||
{ "System.UInt32", "uint32" },
|
||||
{ "System.UInt64", "uint64" },
|
||||
{ "System.Single", "float" },
|
||||
{ "System.Double", "double" },
|
||||
{ "System.Byte", "uint32" },
|
||||
{ "System.SByte", "int32" },
|
||||
{ "System.Int16", "int32" },
|
||||
{ "System.UInt16", "uint32" },
|
||||
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
|
||||
{ "System.DateTime", "int64" }, // Unix timestamp
|
||||
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
|
||||
{ "System.Guid", "string" },
|
||||
{ "System.TimeSpan", "int64" }, // Ticks
|
||||
|
||||
// Nullable variants
|
||||
{ "System.Boolean?", "bool" },
|
||||
{ "System.Int32?", "int32" },
|
||||
{ "System.Int64?", "int64" },
|
||||
{ "System.UInt32?", "uint32" },
|
||||
{ "System.UInt64?", "uint64" },
|
||||
{ "System.Single?", "float" },
|
||||
{ "System.Double?", "double" },
|
||||
{ "System.Byte?", "uint32" },
|
||||
{ "System.SByte?", "int32" },
|
||||
{ "System.Int16?", "int32" },
|
||||
{ "System.UInt16?", "uint32" },
|
||||
{ "System.Decimal?", "string" },
|
||||
{ "System.DateTime?", "int64" },
|
||||
{ "System.DateTimeOffset?", "int64" },
|
||||
{ "System.Guid?", "string" },
|
||||
{ "System.TimeSpan?", "int64" },
|
||||
};
|
||||
|
||||
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
|
||||
{
|
||||
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("[]"))
|
||||
{
|
||||
isRepeated = true;
|
||||
var elementType = csharpType.Substring(0, csharpType.Length - 2);
|
||||
return MapToProtoType(elementType, out _, out _);
|
||||
}
|
||||
|
||||
// Handle generic collections
|
||||
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
|
||||
{
|
||||
isRepeated = true;
|
||||
var startIndex = csharpType.IndexOf('<') + 1;
|
||||
var endIndex = csharpType.LastIndexOf('>');
|
||||
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
|
||||
return MapToProtoType(elementType, out _, out _);
|
||||
}
|
||||
|
||||
// Handle nullable value types
|
||||
if (csharpType.EndsWith("?"))
|
||||
{
|
||||
isOptional = true;
|
||||
}
|
||||
|
||||
// Check if it's a known primitive type
|
||||
if (TypeMap.TryGetValue(csharpType, out var protoType))
|
||||
{
|
||||
return protoType;
|
||||
}
|
||||
|
||||
// For unknown types, assume it's a custom message type
|
||||
// Extract just the type name without namespace
|
||||
var lastDot = csharpType.LastIndexOf('.');
|
||||
if (lastDot >= 0)
|
||||
{
|
||||
return csharpType.Substring(lastDot + 1).Replace("?", "");
|
||||
}
|
||||
|
||||
return csharpType.Replace("?", "");
|
||||
return "bytes";
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (csharpType.EndsWith("[]"))
|
||||
{
|
||||
isRepeated = true;
|
||||
var elementType = csharpType.Substring(0, csharpType.Length - 2);
|
||||
return MapToProtoType(elementType, out _, out _);
|
||||
}
|
||||
|
||||
// Handle generic collections
|
||||
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
|
||||
{
|
||||
isRepeated = true;
|
||||
var startIndex = csharpType.IndexOf('<') + 1;
|
||||
var endIndex = csharpType.LastIndexOf('>');
|
||||
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
|
||||
return MapToProtoType(elementType, out _, out _);
|
||||
}
|
||||
|
||||
// Handle nullable value types
|
||||
if (csharpType.EndsWith("?"))
|
||||
{
|
||||
isOptional = true;
|
||||
}
|
||||
|
||||
// Check if it's a known primitive type
|
||||
if (TypeMap.TryGetValue(csharpType, out var protoType))
|
||||
{
|
||||
return protoType;
|
||||
}
|
||||
|
||||
// For unknown types, assume it's a custom message type
|
||||
// Extract just the type name without namespace
|
||||
var lastDot = csharpType.LastIndexOf('.');
|
||||
if (lastDot >= 0)
|
||||
{
|
||||
return csharpType.Substring(lastDot + 1).Replace("?", "");
|
||||
}
|
||||
|
||||
return csharpType.Replace("?", "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,82 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models;
|
||||
|
||||
public class CommandInfo
|
||||
{
|
||||
public class CommandInfo
|
||||
public string Name { get; set; }
|
||||
public string FullyQualifiedName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
public string? ResultType { get; set; }
|
||||
public string? ResultFullyQualifiedName { get; set; }
|
||||
public bool HasResult => ResultType != null;
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public List<PropertyInfo> ResultProperties { get; set; }
|
||||
public bool IsResultPrimitiveType { get; set; }
|
||||
|
||||
public CommandInfo()
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string FullyQualifiedName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
public string? ResultType { get; set; }
|
||||
public string? ResultFullyQualifiedName { get; set; }
|
||||
public bool HasResult => ResultType != null;
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public List<PropertyInfo> ResultProperties { get; set; }
|
||||
public bool IsResultPrimitiveType { get; set; }
|
||||
|
||||
public CommandInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
Properties = new List<PropertyInfo>();
|
||||
HandlerInterfaceName = string.Empty;
|
||||
ResultProperties = new List<PropertyInfo>();
|
||||
IsResultPrimitiveType = false;
|
||||
}
|
||||
}
|
||||
|
||||
public class PropertyInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string FullyQualifiedType { get; set; }
|
||||
public string ProtoType { get; set; }
|
||||
public int FieldNumber { get; set; }
|
||||
public bool IsComplexType { get; set; }
|
||||
public List<PropertyInfo> NestedProperties { get; set; }
|
||||
|
||||
// Type conversion metadata
|
||||
public bool IsEnum { get; set; }
|
||||
public bool IsList { get; set; }
|
||||
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; }
|
||||
public List<PropertyInfo>? ElementNestedProperties { get; set; }
|
||||
|
||||
public PropertyInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
Type = string.Empty;
|
||||
FullyQualifiedType = string.Empty;
|
||||
ProtoType = string.Empty;
|
||||
IsComplexType = false;
|
||||
NestedProperties = new List<PropertyInfo>();
|
||||
IsEnum = false;
|
||||
IsList = false;
|
||||
IsNullable = false;
|
||||
IsDecimal = false;
|
||||
IsDateTime = false;
|
||||
IsDateTimeOffset = false;
|
||||
IsGuid = false;
|
||||
IsJsonElement = false;
|
||||
IsBinaryType = false;
|
||||
IsStream = false;
|
||||
IsReadOnly = false;
|
||||
IsValueTypeCollection = false;
|
||||
IsElementComplexType = false;
|
||||
IsElementGuid = false;
|
||||
}
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
Properties = new List<PropertyInfo>();
|
||||
HandlerInterfaceName = string.Empty;
|
||||
ResultProperties = new List<PropertyInfo>();
|
||||
IsResultPrimitiveType = false;
|
||||
}
|
||||
}
|
||||
|
||||
public class PropertyInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string FullyQualifiedType { get; set; }
|
||||
public string ProtoType { get; set; }
|
||||
public int FieldNumber { get; set; }
|
||||
public bool IsComplexType { get; set; }
|
||||
public List<PropertyInfo> NestedProperties { get; set; }
|
||||
|
||||
// Type conversion metadata
|
||||
public bool IsEnum { get; set; }
|
||||
public bool IsList { get; set; }
|
||||
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; }
|
||||
public List<PropertyInfo>? ElementNestedProperties { get; set; }
|
||||
|
||||
public PropertyInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
Type = string.Empty;
|
||||
FullyQualifiedType = string.Empty;
|
||||
ProtoType = string.Empty;
|
||||
IsComplexType = false;
|
||||
NestedProperties = new List<PropertyInfo>();
|
||||
IsEnum = false;
|
||||
IsList = false;
|
||||
IsNullable = false;
|
||||
IsDecimal = false;
|
||||
IsDateTime = false;
|
||||
IsDateTimeOffset = false;
|
||||
IsGuid = false;
|
||||
IsJsonElement = false;
|
||||
IsBinaryType = false;
|
||||
IsStream = false;
|
||||
IsReadOnly = false;
|
||||
IsValueTypeCollection = false;
|
||||
IsElementComplexType = false;
|
||||
IsElementGuid = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
{
|
||||
public class DynamicQueryInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string SourceType { get; set; }
|
||||
public string SourceTypeFullyQualified { get; set; }
|
||||
public string DestinationType { get; set; }
|
||||
public string DestinationTypeFullyQualified { get; set; }
|
||||
public string? ParamsType { get; set; }
|
||||
public string? ParamsTypeFullyQualified { get; set; }
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public string QueryInterfaceName { get; set; }
|
||||
public bool HasParams { get; set; }
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models;
|
||||
|
||||
public DynamicQueryInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
SourceType = string.Empty;
|
||||
SourceTypeFullyQualified = string.Empty;
|
||||
DestinationType = string.Empty;
|
||||
DestinationTypeFullyQualified = string.Empty;
|
||||
HandlerInterfaceName = string.Empty;
|
||||
QueryInterfaceName = string.Empty;
|
||||
HasParams = false;
|
||||
}
|
||||
public class DynamicQueryInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string SourceType { get; set; }
|
||||
public string SourceTypeFullyQualified { get; set; }
|
||||
public string DestinationType { get; set; }
|
||||
public string DestinationTypeFullyQualified { get; set; }
|
||||
public string? ParamsType { get; set; }
|
||||
public string? ParamsTypeFullyQualified { get; set; }
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public string QueryInterfaceName { get; set; }
|
||||
public bool HasParams { get; set; }
|
||||
|
||||
public DynamicQueryInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
SourceType = string.Empty;
|
||||
SourceTypeFullyQualified = string.Empty;
|
||||
DestinationType = string.Empty;
|
||||
DestinationTypeFullyQualified = string.Empty;
|
||||
HandlerInterfaceName = string.Empty;
|
||||
QueryInterfaceName = string.Empty;
|
||||
HasParams = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a discovered streaming notification type for proto/gRPC generation.
|
||||
/// </summary>
|
||||
public class NotificationInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a discovered streaming notification type for proto/gRPC generation.
|
||||
/// The notification type name (e.g., "InventoryChangeNotification").
|
||||
/// </summary>
|
||||
public class NotificationInfo
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The fully qualified type name including namespace.
|
||||
/// </summary>
|
||||
public string FullyQualifiedName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The namespace of the notification type.
|
||||
/// </summary>
|
||||
public string Namespace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The property name used as the subscription key (from [StreamingNotification] attribute).
|
||||
/// </summary>
|
||||
public string SubscriptionKeyProperty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription key property info.
|
||||
/// </summary>
|
||||
public PropertyInfo SubscriptionKeyInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All properties of the notification type.
|
||||
/// </summary>
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
|
||||
public NotificationInfo()
|
||||
{
|
||||
/// <summary>
|
||||
/// The notification type name (e.g., "InventoryChangeNotification").
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The fully qualified type name including namespace.
|
||||
/// </summary>
|
||||
public string FullyQualifiedName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The namespace of the notification type.
|
||||
/// </summary>
|
||||
public string Namespace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The property name used as the subscription key (from [StreamingNotification] attribute).
|
||||
/// </summary>
|
||||
public string SubscriptionKeyProperty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription key property info.
|
||||
/// </summary>
|
||||
public PropertyInfo SubscriptionKeyInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All properties of the notification type.
|
||||
/// </summary>
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
|
||||
public NotificationInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
SubscriptionKeyProperty = string.Empty;
|
||||
SubscriptionKeyInfo = new PropertyInfo();
|
||||
Properties = new List<PropertyInfo>();
|
||||
}
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
SubscriptionKeyProperty = string.Empty;
|
||||
SubscriptionKeyInfo = new PropertyInfo();
|
||||
Properties = new List<PropertyInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
{
|
||||
public class QueryInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string FullyQualifiedName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
public string ResultType { get; set; }
|
||||
public string ResultFullyQualifiedName { get; set; }
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public List<PropertyInfo> ResultProperties { get; set; }
|
||||
public bool IsResultPrimitiveType { get; set; }
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models;
|
||||
|
||||
public QueryInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
Properties = new List<PropertyInfo>();
|
||||
ResultType = string.Empty;
|
||||
ResultFullyQualifiedName = string.Empty;
|
||||
HandlerInterfaceName = string.Empty;
|
||||
ResultProperties = new List<PropertyInfo>();
|
||||
IsResultPrimitiveType = false;
|
||||
}
|
||||
public class QueryInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string FullyQualifiedName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
public string ResultType { get; set; }
|
||||
public string ResultFullyQualifiedName { get; set; }
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public List<PropertyInfo> ResultProperties { get; set; }
|
||||
public bool IsResultPrimitiveType { get; set; }
|
||||
|
||||
public QueryInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
Properties = new List<PropertyInfo>();
|
||||
ResultType = string.Empty;
|
||||
ResultFullyQualifiedName = string.Empty;
|
||||
HandlerInterfaceName = string.Empty;
|
||||
ResultProperties = new List<PropertyInfo>();
|
||||
IsResultPrimitiveType = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,17 +413,21 @@ internal class ProtoFileGenerator
|
||||
|
||||
private void GenerateComplexTypeMessage(INamedTypeSymbol? type)
|
||||
{
|
||||
if (type == null || _generatedMessages.Contains(type.Name))
|
||||
if (type == null)
|
||||
return;
|
||||
|
||||
var messageName = ProtoFileTypeMapper.GetProtoMessageName(type);
|
||||
if (_generatedMessages.Contains(messageName))
|
||||
return;
|
||||
|
||||
// Don't generate messages for system types or primitives
|
||||
if (type.ContainingNamespace?.ToString().StartsWith("System") == true)
|
||||
return;
|
||||
|
||||
_generatedMessages.Add(type.Name);
|
||||
_generatedMessages.Add(messageName);
|
||||
|
||||
_messagesBuilder.AppendLine($"// {type.Name} entity");
|
||||
_messagesBuilder.AppendLine($"message {type.Name} {{");
|
||||
_messagesBuilder.AppendLine($"// {messageName} entity");
|
||||
_messagesBuilder.AppendLine($"message {messageName} {{");
|
||||
|
||||
// Collect nested complex types to generate after closing this message
|
||||
var nestedComplexTypes = new List<INamedTypeSymbol>();
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators;
|
||||
|
||||
/// <summary>
|
||||
/// Incremental source generator that generates .proto files from C# commands and queries
|
||||
/// </summary>
|
||||
[Generator]
|
||||
public class ProtoFileSourceGenerator : IIncrementalGenerator
|
||||
{
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
// Register a post-initialization output to generate the proto file
|
||||
context.RegisterPostInitializationOutput(ctx =>
|
||||
{
|
||||
// Generate a placeholder - the actual proto will be generated in the source output
|
||||
});
|
||||
|
||||
// Collect type declarations to trigger generation
|
||||
// We use any type declaration as a trigger since ProtoFileGenerator scans all assemblies
|
||||
var typeDeclarations = context.SyntaxProvider
|
||||
.CreateSyntaxProvider(
|
||||
predicate: static (s, _) => s is TypeDeclarationSyntax,
|
||||
transform: static (ctx, _) => GetTypeSymbol(ctx))
|
||||
.Where(static m => m is not null)
|
||||
.Collect();
|
||||
|
||||
// Combine with compilation to have access to it
|
||||
var compilationAndTypes = context.CompilationProvider.Combine(typeDeclarations);
|
||||
|
||||
// Generate proto file when commands/queries change
|
||||
context.RegisterSourceOutput(compilationAndTypes, (spc, source) =>
|
||||
{
|
||||
var (compilation, types) = source;
|
||||
|
||||
// Note: We no longer bail out early since ProtoFileGenerator now scans all referenced assemblies
|
||||
// The types from source are just a trigger - the generator will find types from all assemblies
|
||||
|
||||
try
|
||||
{
|
||||
// Get the root namespace from the compilation - this matches what GrpcGenerator does
|
||||
var rootNamespace = compilation.AssemblyName ?? "Generated";
|
||||
var packageName = "cqrs";
|
||||
var csharpNamespace = $"{rootNamespace}.Grpc";
|
||||
|
||||
// Generate the proto file content
|
||||
var generator = new ProtoFileGenerator(compilation);
|
||||
var protoContent = generator.Generate(packageName, csharpNamespace);
|
||||
|
||||
// Output as an embedded resource that can be extracted
|
||||
var protoFileName = "cqrs_services.proto";
|
||||
|
||||
// Generate a C# class that contains the proto content
|
||||
// This allows build tools to extract it if needed
|
||||
var csContent = $$"""
|
||||
// <auto-generated />
|
||||
#nullable enable
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generated
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains the auto-generated Protocol Buffer definition
|
||||
/// </summary>
|
||||
internal static class GeneratedProtoFile
|
||||
{
|
||||
public const string FileName = "{{protoFileName}}";
|
||||
|
||||
public const string Content = @"{{protoContent.Replace("\"", "\"\"")}}";
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
spc.AddSource("GeneratedProtoFile.g.cs", csContent);
|
||||
|
||||
// Report that we generated the proto content
|
||||
var descriptor = new DiagnosticDescriptor(
|
||||
"CQRSGRPC002",
|
||||
"Proto file generated",
|
||||
"Generated proto file content in GeneratedProtoFile class",
|
||||
"Svrnty.CQRS.Grpc",
|
||||
DiagnosticSeverity.Info,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Report diagnostic if generation fails
|
||||
var descriptor = new DiagnosticDescriptor(
|
||||
"CQRSGRPC001",
|
||||
"Proto file generation failed",
|
||||
"Failed to generate proto file: {0}",
|
||||
"Svrnty.CQRS.Grpc",
|
||||
DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true);
|
||||
|
||||
spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, ex.Message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static INamedTypeSymbol? GetTypeSymbol(GeneratorSyntaxContext context)
|
||||
{
|
||||
var typeDecl = (TypeDeclarationSyntax)context.Node;
|
||||
var symbol = context.SemanticModel.GetDeclaredSymbol(typeDecl) as INamedTypeSymbol;
|
||||
|
||||
// Skip if it has GrpcIgnore attribute
|
||||
if (symbol?.GetAttributes().Any(a => a.AttributeClass?.Name == "GrpcIgnoreAttribute") == true)
|
||||
return null;
|
||||
|
||||
return symbol;
|
||||
}
|
||||
|
||||
private static string? GetBuildProperty(SourceProductionContext context, string propertyName)
|
||||
{
|
||||
// Try to get build properties from the compilation options
|
||||
// This is a simplified approach - in practice, you might need analyzer config
|
||||
return null; // Will use defaults
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators;
|
||||
@@ -151,13 +152,27 @@ internal static class ProtoFileTypeMapper
|
||||
// Complex types (classes/records) become message types
|
||||
if (typeSymbol.TypeKind == TypeKind.Class || typeSymbol.TypeKind == TypeKind.Struct)
|
||||
{
|
||||
return typeName; // Reference the message type by name
|
||||
return GetProtoMessageName(typeSymbol); // Reference the message type by name (handles generics)
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return "string"; // Default to string for unknown types
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the proto message name for a type, handling generic types by qualifying
|
||||
/// with type arguments. e.g. Translation<FaqTranslationQueryItem> becomes TranslationOfFaqTranslationQueryItem.
|
||||
/// </summary>
|
||||
public static string GetProtoMessageName(ITypeSymbol typeSymbol)
|
||||
{
|
||||
if (typeSymbol is INamedTypeSymbol namedType && namedType.IsGenericType && namedType.TypeArguments.Length > 0)
|
||||
{
|
||||
var typeArgs = string.Join("And", namedType.TypeArguments.Select(t => GetProtoMessageName(t)));
|
||||
return $"{namedType.Name}Of{typeArgs}";
|
||||
}
|
||||
return typeSymbol.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts C# PascalCase property name to proto snake_case field name.
|
||||
/// Uses simple conversion: add underscore before each uppercase letter (except first).
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Build.Framework;
|
||||
using Microsoft.Build.Utilities;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators;
|
||||
|
||||
/// <summary>
|
||||
/// MSBuild task that extracts the auto-generated proto file content from the source generator
|
||||
/// output and writes it to disk so Grpc.Tools can process it
|
||||
/// </summary>
|
||||
public class WriteProtoFileTask : Task
|
||||
{
|
||||
/// <summary>
|
||||
/// The project directory where we should look for generated files
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ProjectDirectory { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The intermediate output path (typically obj/Debug/net10.0)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string IntermediateOutputPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The output directory where the proto file should be written (typically Protos/)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string OutputDirectory { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the proto file to generate (typically cqrs_services.proto)
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ProtoFileName { get; set; } = string.Empty;
|
||||
|
||||
public override bool Execute()
|
||||
{
|
||||
try
|
||||
{
|
||||
Log.LogMessage(MessageImportance.High,
|
||||
"Svrnty.CQRS.Grpc: Extracting auto-generated proto file...");
|
||||
|
||||
// Look for the generated C# file containing the proto content
|
||||
// Source generators output to obj/Generated, not IntermediateOutputPath/Generated
|
||||
var generatedFilePath = Path.Combine(
|
||||
ProjectDirectory,
|
||||
"obj",
|
||||
"Generated",
|
||||
"Svrnty.CQRS.Grpc.Generators",
|
||||
"Svrnty.CQRS.Grpc.Generators.ProtoFileSourceGenerator",
|
||||
"GeneratedProtoFile.g.cs"
|
||||
);
|
||||
|
||||
if (!File.Exists(generatedFilePath))
|
||||
{
|
||||
Log.LogWarning(
|
||||
$"Generated proto file not found at {generatedFilePath}. " +
|
||||
"The proto file may not have been generated yet. This is normal on first build.");
|
||||
|
||||
// Write a minimal placeholder proto file so Grpc.Tools doesn't fail
|
||||
// The real content will be generated on the next build
|
||||
var placeholderProto = @"syntax = ""proto3"";
|
||||
|
||||
option csharp_namespace = ""Generated.Grpc"";
|
||||
|
||||
package cqrs;
|
||||
|
||||
// Placeholder proto file - will be regenerated on next build
|
||||
";
|
||||
var placeholderOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
|
||||
Directory.CreateDirectory(placeholderOutputPath);
|
||||
var placeholderProtoFilePath = Path.Combine(placeholderOutputPath, ProtoFileName);
|
||||
File.WriteAllText(placeholderProtoFilePath, placeholderProto);
|
||||
|
||||
Log.LogMessage(MessageImportance.High,
|
||||
$"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {placeholderProtoFilePath}. " +
|
||||
"Run build again to generate the actual proto content.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read the generated C# file
|
||||
var csContent = File.ReadAllText(generatedFilePath);
|
||||
|
||||
// Extract the proto content using a more robust approach
|
||||
// Looking for: public const string Content = @"...";
|
||||
var startMarker = "public const string Content = @\"";
|
||||
var startIndex = csContent.IndexOf(startMarker);
|
||||
|
||||
if (startIndex < 0)
|
||||
{
|
||||
Log.LogError($"Could not find Content property in {generatedFilePath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
startIndex += startMarker.Length;
|
||||
|
||||
// Find the closing "; - We need the LAST occurrence because the content contains escaped quotes
|
||||
// The pattern is: Content = @"...content...";
|
||||
// where content has "" for literal quotes
|
||||
var endMarker = "\";";
|
||||
|
||||
// Find where the next field starts or class ends to limit our search
|
||||
var nextFieldOrEnd = csContent.IndexOf("\n }", startIndex); // End of class
|
||||
if (nextFieldOrEnd < 0)
|
||||
{
|
||||
nextFieldOrEnd = csContent.Length;
|
||||
}
|
||||
|
||||
var endIndex = csContent.LastIndexOf(endMarker, nextFieldOrEnd, nextFieldOrEnd - startIndex);
|
||||
|
||||
if (endIndex < 0 || endIndex < startIndex)
|
||||
{
|
||||
Log.LogError($"Could not find end of Content property in {generatedFilePath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract and unescape doubled quotes
|
||||
var protoContent = csContent.Substring(startIndex, endIndex - startIndex);
|
||||
protoContent = protoContent.Replace("\"\"", "\"");
|
||||
|
||||
Log.LogMessage(MessageImportance.High,
|
||||
$"Extracted proto content length: {protoContent.Length} characters");
|
||||
|
||||
// Ensure output directory exists
|
||||
var fullOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
|
||||
Directory.CreateDirectory(fullOutputPath);
|
||||
|
||||
// Write the proto file
|
||||
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
|
||||
File.WriteAllText(protoFilePath, protoContent);
|
||||
|
||||
Log.LogMessage(MessageImportance.High,
|
||||
$"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.LogErrorFromException(ex, true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,29 +9,48 @@
|
||||
<!-- Determine the assembly path (different for NuGet package vs project reference) -->
|
||||
<PropertyGroup>
|
||||
<_GeneratorsAssemblyPath Condition="Exists('$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
||||
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
||||
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
||||
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Load the WriteProtoFileTask from the generator assembly -->
|
||||
<UsingTask TaskName="Svrnty.CQRS.Grpc.Generators.WriteProtoFileTask"
|
||||
<!-- Load the GenerateProtoFileTask from the generator assembly -->
|
||||
<UsingTask TaskName="Svrnty.CQRS.Grpc.Generators.GenerateProtoFileTask"
|
||||
AssemblyFile="$(_GeneratorsAssemblyPath)"
|
||||
Condition="'$(_GeneratorsAssemblyPath)' != ''" />
|
||||
|
||||
<!-- This target ensures the Protos directory exists before the generator runs -->
|
||||
<Target Name="EnsureProtosDirectory" BeforeTargets="CoreCompile">
|
||||
<!-- This target ensures the Protos directory exists -->
|
||||
<Target Name="EnsureProtosDirectory" BeforeTargets="SvrntyGenerateProtoFile">
|
||||
<MakeDir Directories="$(ProtoOutputDirectory)" Condition="!Exists('$(ProtoOutputDirectory)')" />
|
||||
</Target>
|
||||
|
||||
<!-- Extract the proto file from the source generator output BEFORE Grpc.Tools processes protos -->
|
||||
<!-- Runs before CoreCompile, after source generators have been executed -->
|
||||
<Target Name="SvrntyExtractProtoFile" BeforeTargets="CoreCompile" AfterTargets="ResolveProjectReferences" DependsOnTargets="EnsureProtosDirectory" Condition="'$(GenerateProtoFile)' == 'true'">
|
||||
<Message Text="Svrnty.CQRS.Grpc: Extracting auto-generated proto file to $(ProtoOutputDirectory)\$(GeneratedProtoFileName)" Importance="high" />
|
||||
<!--
|
||||
Generate the proto file BEFORE Grpc.Tools processes protos and BEFORE CoreCompile.
|
||||
This runs AFTER ResolveAssemblyReferences so we have access to @(ReferencePath).
|
||||
|
||||
<WriteProtoFileTask
|
||||
Key timing:
|
||||
- AfterTargets="ResolveAssemblyReferences" ensures we have all references resolved
|
||||
- BeforeTargets="_gRPC_GetProtoc;CoreCompile" ensures proto is generated before:
|
||||
1. Grpc.Tools compiles the proto into C# (_gRPC_GetProtoc is Grpc.Tools' entry point)
|
||||
2. CoreCompile compiles the project
|
||||
-->
|
||||
<Target Name="SvrntyGenerateProtoFile"
|
||||
BeforeTargets="_gRPC_GetProtoc;CoreCompile"
|
||||
AfterTargets="ResolveAssemblyReferences"
|
||||
DependsOnTargets="EnsureProtosDirectory"
|
||||
Condition="'$(GenerateProtoFile)' == 'true' AND '$(_GeneratorsAssemblyPath)' != ''">
|
||||
|
||||
<Message Text="Svrnty.CQRS.Grpc: Generating proto file from $(MSBuildProjectName)..." Importance="high" />
|
||||
<Message Text="Svrnty.CQRS.Grpc: Source files count: @(Compile->Count())" Importance="normal" />
|
||||
<Message Text="Svrnty.CQRS.Grpc: References count: @(ReferencePath->Count())" Importance="normal" />
|
||||
|
||||
<GenerateProtoFileTask
|
||||
ProjectDirectory="$(MSBuildProjectDirectory)"
|
||||
IntermediateOutputPath="$(IntermediateOutputPath)"
|
||||
OutputDirectory="$(ProtoOutputDirectory)"
|
||||
ProtoFileName="$(GeneratedProtoFileName)" />
|
||||
ProtoFileName="$(GeneratedProtoFileName)"
|
||||
SourceFiles="@(Compile)"
|
||||
References="@(ReferencePath)"
|
||||
RootNamespace="$(RootNamespace)"
|
||||
AssemblyName="$(AssemblyName)" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -33,6 +33,7 @@ public static class CqrsBuilderExtensions
|
||||
{
|
||||
Console.WriteLine("Warning: AddGrpcFromConfiguration not found. gRPC services were not registered.");
|
||||
Console.WriteLine("Make sure your project has source generators enabled and references Svrnty.CQRS.Grpc.Generators.");
|
||||
DiagnoseGeneratedCode();
|
||||
}
|
||||
|
||||
// Register mapping callback for automatic endpoint mapping
|
||||
@@ -49,6 +50,59 @@ public static class CqrsBuilderExtensions
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void DiagnoseGeneratedCode()
|
||||
{
|
||||
var entryAsm = Assembly.GetEntryAssembly();
|
||||
if (entryAsm == null)
|
||||
{
|
||||
Console.WriteLine("Diagnostic: Entry assembly is null");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Diagnostic: Entry assembly = {entryAsm.GetName().Name}");
|
||||
|
||||
try
|
||||
{
|
||||
var allTypes = entryAsm.GetTypes();
|
||||
Console.WriteLine($"Diagnostic: Total types in entry assembly = {allTypes.Length}");
|
||||
|
||||
var grpcTypes = allTypes
|
||||
.Where(t => t.FullName?.Contains("Grpc") == true)
|
||||
.ToList();
|
||||
|
||||
if (grpcTypes.Any())
|
||||
{
|
||||
Console.WriteLine("Diagnostic: Found Grpc-related types:");
|
||||
foreach (var t in grpcTypes)
|
||||
{
|
||||
Console.WriteLine($" - {t.FullName} (IsClass={t.IsClass}, IsSealed={t.IsSealed}, IsPublic={t.IsPublic})");
|
||||
|
||||
// Check for our target method
|
||||
var method = t.GetMethod("AddGrpcFromConfiguration", BindingFlags.Static | BindingFlags.Public);
|
||||
if (method != null)
|
||||
Console.WriteLine($" -> HAS AddGrpcFromConfiguration method!");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Diagnostic: No Grpc-related types found. Source generator did NOT run.");
|
||||
}
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
Console.WriteLine($"Diagnostic: ReflectionTypeLoadException - {ex.Message}");
|
||||
foreach (var le in ex.LoaderExceptions)
|
||||
{
|
||||
if (le != null)
|
||||
Console.WriteLine($" LoaderException: {le.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Diagnostic: Exception - {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static MethodInfo? FindExtensionMethod(string methodName, Type parameterType)
|
||||
{
|
||||
// Search through all loaded assemblies for the extension method
|
||||
|
||||
@@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Kafka", "Svrnty.CQRS.Events.Kafka\Svrnty.CQRS.Events.Kafka.csproj", "{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -257,6 +259,18 @@ Global
|
||||
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
using Svrnty.CQRS.Discovery;
|
||||
@@ -43,7 +44,7 @@ public class CqrsBuilder
|
||||
/// <summary>
|
||||
/// Adds a command handler to the CQRS pipeline
|
||||
/// </summary>
|
||||
public CqrsBuilder AddCommand<TCommand, TCommandHandler>()
|
||||
public CqrsBuilder AddCommand<TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
|
||||
where TCommand : class
|
||||
where TCommandHandler : class, ICommandHandler<TCommand>
|
||||
{
|
||||
@@ -54,7 +55,7 @@ public class CqrsBuilder
|
||||
/// <summary>
|
||||
/// Adds a command handler with result to the CQRS pipeline
|
||||
/// </summary>
|
||||
public CqrsBuilder AddCommand<TCommand, TResult, TCommandHandler>()
|
||||
public CqrsBuilder AddCommand<TCommand, TResult, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
|
||||
where TCommand : class
|
||||
where TCommandHandler : class, ICommandHandler<TCommand, TResult>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.Configuration;
|
||||
|
||||
namespace Svrnty.CQRS.MinimalApi;
|
||||
namespace Svrnty.CQRS;
|
||||
|
||||
public static class WebApplicationExtensions
|
||||
{
|
||||
@@ -1,11 +1,11 @@
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Svrnty.CQRS;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
using Svrnty.CQRS.DynamicQuery;
|
||||
using Svrnty.CQRS.FluentValidation;
|
||||
using Svrnty.CQRS.Grpc;
|
||||
using Svrnty.Sample;
|
||||
using Svrnty.CQRS.MinimalApi;
|
||||
using Svrnty.CQRS.DynamicQuery;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
using Svrnty.Sample;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using PoweredSoft.Data.Core;
|
||||
using System.Linq.Expressions;
|
||||
using PoweredSoft.Data.Core;
|
||||
|
||||
namespace Svrnty.Sample;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user