Compare commits

..

29 Commits

Author SHA1 Message Date
Mathias Beaulieu-Duncan a05ebad7fc Fix CS8601 in generated proto→command list mappings
Publish NuGets / build (release) Successful in 28s
Generated CommandServiceImpl.g.cs had warnings like:
    Slug = request.Slug?.ToList(),   // CS8601 if Slug is non-nullable List<T>

The ?. was over-defensive: proto3 repeated fields are emitted as
RepeatedField<T> in C# and are NEVER null. The conditional access
made the result List<T>? which then triggered CS8601 when assigned
to a non-nullable target on the command POCO.

Dropped ?. in 4 emission sites in GrpcGenerator.cs covering:
- Top-level primitive list mapping (line 872)
- Top-level Guid list mapping (line 861)
- Nested primitive list mapping in NestedPropertyAssignment (line 1083)
- Complex list .Select chain in GenerateComplexListMapping (line 974,
  conditional: kept ?. for value-type collections where source.Items is
  read off a possibly-null wrapper message)

Real fix in the generator instead of CS8601 NoWarn suppression in
consumer csprojs. Consumers can drop the suppression after bumping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:42:17 -04:00
Mathias Beaulieu-Duncan ee3ad866d9 Use InvariantCulture for decimal.Parse in generated gRPC mappers
Generated code was using locale-dependent parsing for decimal values.
On systems with comma decimal separator (e.g., French locale), parsing
"0.95" would throw FormatException because the system expected "0,95".

Switched all 4 decimal.Parse() call sites in the generated proto→domain
mappers to pass System.Globalization.CultureInfo.InvariantCulture for
consistent behavior across locales.

Inspired by JP's commit 599204d on feat/grpc-generator-improvements
(applied manually since cherry-pick had heavy context conflicts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:33:27 -04:00
mathias 55f1324286 Merge pull request 'feat/claude-code-harness' (#2) from feat/claude-code-harness into main
Publish NuGets / build (release) Successful in 34s
Reviewed-on: #2
2026-03-12 06:44:11 -04:00
Mathias Beaulieu-Duncan b34bf874b4 Remove Claude harness — replaced by claude-cqrs-plugin
The in-repo .claude/ harness (rules, skills, settings) is superseded by
the standalone claude-cqrs-plugin which provides the same guidance as a
reusable plugin across all Svrnty.CQRS projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:42:50 -04:00
Mathias Beaulieu-Duncan c6de10b98b Move UseSvrntyCqrs() from MinimalApi to core Svrnty.CQRS package
gRPC-only projects couldn't call app.UseSvrntyCqrs() without adding the
MinimalApi package. The method only calls ExecuteMappingCallbacks() which
is already in core — it had no MinimalApi dependency. Adds ASP.NET Core
FrameworkReference to the core package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:38:26 -04:00
Mathias Beaulieu-Duncan 3945c1a158 Add project-init agent for scaffolding new CQRS projects
Scaffolds a complete Svrnty.CQRS project from a natural language
description — creates solution, web project, DAL with PostgreSQL,
entities, Program.cs, first feature, proto file, and .editorconfig.
Defaults to gRPC-only; MinimalApi added only on request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:38:26 -04:00
mathias 7614f68512 Merge pull request 'feat/claude-code-harness' (#1) from feat/claude-code-harness into main
Publish NuGets / build (release) Successful in 32s
Reviewed-on: #1
2026-03-12 03:35:26 -04:00
Mathias Beaulieu-Duncan fdee02c960 Apply dotnet format with new editorconfig rules
Automated formatting: BOM removal, using sort order, final newlines,
whitespace normalization across all projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 03:30:50 -04:00
Mathias Beaulieu-Duncan a4525bad6a Add Claude Code harness: rules, skills, hooks, and editorconfig
- Add path-specific rules for commands/queries, dynamic queries, validation, and gRPC
- Add /add-command, /add-query, /add-dynamic-query scaffolding skills
- Add project settings with post-edit formatting, proto validation, and build-gate hooks
- Add .editorconfig codifying existing code style conventions
- Trim CLAUDE.md from 414 to 130 lines (domain details moved to rules)
- Add .harness-version tracking for the shared claude-harness repo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 03:30:27 -04:00
Svrnty 3df094b9e7 docs: sanitise product references, add "Where This Fits" to README
Co-Authored-By: Svrnty Inc. <jp@svrnty.io, mathias@svrnty.io>
2026-02-27 13:08:17 -05:00
david.nguyen 6aece5a769 Handle generic types in proto message name generation
Publish NuGets / build (release) Successful in 39s
Generic types like Translation<T> now produce qualified message names
(e.g. TranslationOfFaqTranslationQueryItem) to avoid duplicate message
definitions in generated .proto files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:56:37 -05:00
david.nguyen b372805c4e Fix string filter values not converting to correct CLR types for DynamicQuery
Publish NuGets / build (release) Successful in 41s
Convert string filter values (e.g. from gRPC transport) to their actual
property types (DateTime, DateTimeOffset) so PoweredSoft.DynamicLinq can
build valid LINQ expressions. Also removes filters with null values and
recurses into composite filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:28:09 -05:00
david.nguyen 89ccbe990f add AND / OR support when filtering
Publish NuGets / build (release) Successful in 34s
2026-02-02 17:53:43 -05:00
david.nguyen 433b852a43 Refactor proto generation from source generator to MSBuild task
Publish NuGets / build (release) Successful in 40s
Replace ProtoFileSourceGenerator and WriteProtoFileTask with a new
GenerateProtoFileTask that creates its own Roslyn compilation. This
solves the timing issue where source generators run too late for
Grpc.Tools to process the generated proto files.

The new task runs after ResolveAssemblyReferences but before
_gRPC_GetProtoc and CoreCompile, ensuring the proto file exists
when Grpc.Tools needs it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 14:35:56 -05:00
david.nguyen 03041721ca Preserve existing proto files instead of overwriting
Publish NuGets / build (release) Successful in 39s
If a proto file already exists (committed to repo), don't overwrite it
with a placeholder. This allows first-time builds to work correctly
when the proto file is already in the repository.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:05:45 -05:00
david.nguyen 05449b9a28 Add empty service definitions to placeholder proto
Grpc.Tools needs service definitions to generate the base classes
(CommandService+CommandServiceBase, etc.) that GrpcGenerator looks for.
Without these, the service bases wouldn't exist on first build.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 02:21:43 -05:00
david.nguyen dfbef9d161 Fix placeholder proto namespace to use project's actual namespace
The WriteProtoFileTask now receives RootNamespace and AssemblyName from
MSBuild and uses them for the placeholder proto's csharp_namespace instead
of hardcoded "Generated.Grpc". This ensures GrpcGenerator can find the
service base types on first build, enabling gRPC service registration
to work without requiring a second build.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 02:19:50 -05:00
david.nguyen 377977b080 Make GeneratedProtoFile class public for cross-assembly discovery
Publish NuGets / build (release) Successful in 34s
The generated proto file holder class was internal, preventing
reflection-based service registration from discovering it across
assembly boundaries.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 00:31:34 -05:00
david.nguyen 20147bfec7 Add diagnostic logging when gRPC generated code not found
Publish NuGets / build (release) Successful in 37s
When AddGrpcFromConfiguration method is not found via reflection,
logs detailed diagnostics to help identify the root cause:
- Entry assembly name and total type count
- All Grpc-related types with their IsClass/IsSealed/IsPublic flags
- Whether the target method exists on each type
- ReflectionTypeLoadException details if type loading fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:10:18 -05:00
david.nguyen 18f81a28e8 Add authorization checks to gRPC service implementations
Publish NuGets / build (release) Successful in 44s
- Add ICommandAuthorizationService check to CommandServiceImpl
- Add IQueryAuthorizationService check to QueryServiceImpl
- Add IQueryAuthorizationService check to DynamicQueryServiceImpl
- Return Unauthenticated/PermissionDenied gRPC status codes
- Use global:: prefix for Grpc.Core namespace to avoid conflicts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:18:07 -05:00
david.nguyen 201768e716 Revert AllowAnonymous endpoint propagation
Publish NuGets / build (release) Successful in 35s
Remove the WithAllowAnonymousIfAttributePresent helper method.
Authorization should be handled by IQueryAuthorizationService and
ICommandAuthorizationService implementations, not by ASP.NET Core
middleware.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:07:03 -05:00
david.nguyen 932ee6e632 add AllowAnonymous support for MinimalApi endpoints
Publish NuGets / build (release) Successful in 37s
Endpoints with [AllowAnonymous] attribute on query/command class
now bypass ASP.NET Core authorization middleware.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:29:26 -05:00
jp 4bf03446c0 docs: update .NET 8 references to .NET 10
Publish NuGets / build (release) Successful in 50s
Consolidated roadmap to show .NET 10 with C# 14 as current target.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:22:27 -05:00
david.nguyen 227be70f95 Fix MinimalApi to resolve authorization services per-request
- Resolve ICommandAuthorizationService and IQueryAuthorizationService from request-scoped serviceProvider
- Allows Scoped authorization services that depend on DbContext
- Updated both MinimalApi and DynamicQuery.MinimalApi

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:56:31 -05:00
david.nguyen bd43bc9bde Fix gRPC source generator for complex nested types
- Add DateTime/Timestamp conversion in nested property mapping
- Add IsReadOnly property detection to skip computed properties
- Extract ElementNestedProperties for complex list element types
- Skip read-only properties in GenerateComplexObjectMapping and GenerateComplexListMapping

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:06:18 -05:00
Mathias Beaulieu-Duncan 9b9e2cbdbe added domain events, fix IQueryalbeProvider abstraction, added support for sagas and RabbitMQ 2025-12-20 15:13:05 -05:00
101 changed files with 8353 additions and 2119 deletions
-50
View File
@@ -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": []
}
}
+96
View File
@@ -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 -1
View File
@@ -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
+10 -2
View File
@@ -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.
@@ -259,8 +268,7 @@ builder.Services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
| Task | Description | Status |
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
| Support .NET 8 | Ensure compatibility with .NET 8. | ✅ |
| Support .NET 10 | Upgrade to .NET 10 with C# 14 language support. | ✅ |
| Support .NET 10 | .NET 10 with C# 14 language support. | ✅ |
| Update FluentValidation | Upgrade FluentValidation to version 11.x for .NET 10 compatibility. | ✅ |
| Add gRPC Support with source generators | Implement gRPC endpoints with source generators and Google Rich Error Model for validation. | ✅ |
| Create a demo project (Svrnty.CQRS.Grpc.Sample) | Develop a comprehensive demo project showcasing gRPC and HTTP endpoints. | ✅ |
+122
View File
@@ -0,0 +1,122 @@
# Saga Orchestration Roadmap
## Completed (Phase 1)
- [x] `Svrnty.CQRS.Sagas.Abstractions` - Core interfaces and contracts
- [x] `Svrnty.CQRS.Sagas` - Orchestration engine with fluent builder API
- [x] `Svrnty.CQRS.Sagas.RabbitMQ` - RabbitMQ message transport
---
## Phase 1d: Testing & Sample
### Unit Tests
- [ ] `SagaBuilder` step configuration tests
- [ ] `SagaOrchestrator` execution flow tests
- [ ] `SagaOrchestrator` compensation flow tests
- [ ] `InMemorySagaStateStore` persistence tests
- [ ] `RabbitMqSagaMessageBus` serialization tests
### Integration Tests
- [ ] End-to-end saga execution with RabbitMQ
- [ ] Multi-step saga with compensation scenario
- [ ] Concurrent saga execution tests
- [ ] Connection recovery tests
### Sample Implementation
- [ ] `OrderProcessingSaga` example in WarehouseManagement
- ReserveInventory step
- ProcessPayment step
- CreateShipment step
- Full compensation flow
---
## Phase 2: Persistence
### Svrnty.CQRS.Sagas.EntityFramework
- [ ] `EfCoreSagaStateStore` implementation
- [ ] `SagaState` entity configuration
- [ ] Migration support
- [ ] PostgreSQL/SQL Server compatibility
- [ ] Optimistic concurrency handling
### Configuration
```csharp
cqrs.AddSagas()
.UseEntityFramework<AppDbContext>();
```
---
## Phase 3: Reliability
### Saga Timeout Service
- [ ] `SagaTimeoutHostedService` - background service for stalled sagas
- [ ] Configurable timeout per saga type
- [ ] Automatic compensation trigger on timeout
- [ ] Dead letter handling for failed compensations
### Retry Policies
- [ ] Exponential backoff support
- [ ] Circuit breaker integration
- [ ] Polly integration option
### Idempotency
- [ ] Message deduplication
- [ ] Idempotent step execution
- [ ] Inbox/Outbox pattern support
---
## Phase 4: Observability
### OpenTelemetry Integration
- [ ] Distributed tracing for saga execution
- [ ] Span per saga step
- [ ] Correlation ID propagation
- [ ] Metrics (saga duration, success/failure rates)
### Saga Dashboard (Optional)
- [ ] Web UI for saga monitoring
- [ ] Real-time saga status
- [ ] Manual compensation trigger
- [ ] Saga history and audit log
---
## Phase 5: Flutter Integration
### gRPC Streaming for Saga Status
- [ ] `ISagaStatusStream` service
- [ ] Real-time saga progress updates
- [ ] Step completion notifications
- [ ] Error/compensation notifications
### Flutter Client
- [ ] Dart client for saga status streaming
- [ ] Saga progress widget components
---
## Phase 6: Alternative Transports
### Svrnty.CQRS.Sagas.AzureServiceBus
- [ ] Azure Service Bus message transport
- [ ] Topic/Subscription topology
- [ ] Dead letter queue handling
### Svrnty.CQRS.Sagas.Kafka
- [ ] Kafka message transport
- [ ] Consumer group management
- [ ] Partition key strategies
---
## Future Considerations
- **Event Sourcing**: Saga state as event stream
- **Saga Versioning**: Handle saga definition changes gracefully
- **Saga Composition**: Nested/child sagas
- **Saga Scheduling**: Delayed saga start
- **Multi-tenancy**: Tenant-aware saga execution
@@ -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;
+2 -2
View File
@@ -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);
}
}
+2 -2
View File
@@ -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);
}
}
@@ -0,0 +1,14 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
/// <summary>
/// Marker interface for custom queryable providers that project entities to DTOs.
/// Extends <see cref="IQueryableProvider{TSource}"/> for semantic clarity in registration.
/// </summary>
/// <typeparam name="TSource">The DTO/Item type returned by the queryable.</typeparam>
public interface IQueryableProviderOverride<TSource> : IQueryableProvider<TSource>
{
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
using PoweredSoft.Data.EntityFrameworkCore;
namespace Svrnty.CQRS.DynamicQuery.EntityFramework;
/// <summary>
/// Extensions for configuring DynamicQuery with Entity Framework Core.
/// </summary>
public static class DynamicQueryServicesBuilderExtensions
{
/// <summary>
/// Uses Entity Framework Core for async queryable operations.
/// This replaces the default in-memory implementation with EF Core's async support.
/// </summary>
/// <param name="builder">The DynamicQuery services builder.</param>
/// <returns>The builder for chaining.</returns>
public static DynamicQueryServicesBuilder UseEntityFramework(this DynamicQueryServicesBuilder builder)
{
// Remove in-memory implementation and add EF Core implementation
builder.Services.RemoveAll<IAsyncQueryableService>();
builder.Services.AddPoweredSoftEntityFrameworkCoreDataServices();
return builder;
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<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>
<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>
<PackageReference Include="PoweredSoft.Data.EntityFrameworkCore" Version="3.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
</ItemGroup>
</Project>
@@ -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;
@@ -23,7 +23,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyDynamicQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
foreach (var queryMeta in queryDiscovery.GetQueries())
{
@@ -43,14 +42,14 @@ public static class EndpointRouteBuilderExtensions
if (dynamicQueryMeta.ParamsType == null)
{
// DynamicQuery<TSource, TDestination>
MapDynamicQueryPost(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryGet(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryPost(endpoints, route, dynamicQueryMeta);
MapDynamicQueryGet(endpoints, route, dynamicQueryMeta);
}
else
{
// DynamicQuery<TSource, TDestination, TParams>
MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta);
MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta);
}
}
@@ -60,8 +59,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryPost(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -75,7 +73,7 @@ public static class EndpointRouteBuilderExtensions
.GetMethod(nameof(MapDynamicQueryPostTyped), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(sourceType, destinationType);
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!;
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!;
endpoint
.WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_Post")
@@ -91,8 +89,7 @@ public static class EndpointRouteBuilderExtensions
IEndpointRouteBuilder endpoints,
string route,
Type queryType,
Type handlerType,
IQueryAuthorizationService? authorizationService)
Type handlerType)
where TSource : class
where TDestination : class
{
@@ -102,6 +99,7 @@ public static class EndpointRouteBuilderExtensions
IServiceProvider serviceProvider,
CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -129,8 +127,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryGet(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -141,6 +138,7 @@ public static class EndpointRouteBuilderExtensions
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -199,8 +197,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryWithParamsPost(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -214,7 +211,7 @@ public static class EndpointRouteBuilderExtensions
.GetMethod(nameof(MapDynamicQueryWithParamsPostTyped), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(sourceType, destinationType, paramsType);
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!;
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!;
endpoint
.WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_WithParams_Post")
@@ -230,8 +227,7 @@ public static class EndpointRouteBuilderExtensions
IEndpointRouteBuilder endpoints,
string route,
Type queryType,
Type handlerType,
IQueryAuthorizationService? authorizationService)
Type handlerType)
where TSource : class
where TDestination : class
where TParams : class
@@ -242,6 +238,7 @@ public static class EndpointRouteBuilderExtensions
IServiceProvider serviceProvider,
CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -269,8 +266,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryWithParamsGet(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -282,6 +278,7 @@ public static class EndpointRouteBuilderExtensions
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -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 -1
View File
@@ -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;
+10 -10
View File
@@ -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,10 +38,15 @@ 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())
return _queryableProviders.ElementAt(0).GetQueryableAsync(query, cancellationToken);
{
// Use Last() to prefer closed generic registrations (overrides) over open generic (default)
// Registration order: open generic first, closed generic (override) last
return _queryableProviders.Last().GetQueryableAsync(query, cancellationToken);
}
throw new Exception($"You must provide a QueryableProvider<TSource> for {typeof(TSource).Name}");
}
@@ -52,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);
@@ -63,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);
@@ -77,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;
}
}
}
@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
namespace Svrnty.CQRS.DynamicQuery;
/// <summary>
/// Builder for configuring DynamicQuery services.
/// </summary>
public class DynamicQueryServicesBuilder
{
/// <summary>
/// The service collection being configured.
/// </summary>
public IServiceCollection Services { get; }
internal DynamicQueryServicesBuilder(IServiceCollection services)
{
Services = services;
}
}
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using PoweredSoft.Data.Core;
namespace Svrnty.CQRS.DynamicQuery;
/// <summary>
/// In-memory implementation of IAsyncQueryableService.
/// For EF Core projects, use AddDynamicQueryServices().UseEntityFramework() instead.
/// </summary>
public class InMemoryAsyncQueryableService : IAsyncQueryableService
{
public IEnumerable<IAsyncQueryableHandlerService> Handlers { get; } = Array.Empty<IAsyncQueryableHandlerService>();
public Task<List<TSource>> ToListAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.ToList());
}
public Task<TSource?> FirstOrDefaultAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.FirstOrDefault());
}
public Task<TSource?> FirstOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.FirstOrDefault(predicate));
}
public Task<TSource?> LastOrDefaultAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LastOrDefault());
}
public Task<TSource?> LastOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LastOrDefault(predicate));
}
public Task<bool> AnyAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Any());
}
public Task<bool> AnyAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Any(predicate));
}
public Task<bool> AllAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.All(predicate));
}
public Task<int> CountAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Count());
}
public Task<long> LongCountAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LongCount());
}
public Task<TSource?> SingleOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.SingleOrDefault(predicate));
}
public IAsyncQueryableHandlerService? GetAsyncQueryableHandler<TSource>(IQueryable<TSource> queryable)
{
return null;
}
}
@@ -1,16 +1,31 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using Svrnty.CQRS.DynamicQuery.Discover;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers core DynamicQuery services with in-memory async queryable.
/// For EF Core projects, chain with .UseEntityFramework().
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>A builder for further configuration.</returns>
public static DynamicQueryServicesBuilder AddDynamicQueryServices(this IServiceCollection services)
{
services.TryAddTransient<IAsyncQueryableService, InMemoryAsyncQueryableService>();
services.TryAddTransient<IQueryHandlerAsync, QueryHandlerAsync>();
return new DynamicQueryServicesBuilder(services);
}
public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string name = null)
where TSourceAndDestination : class
=> AddDynamicQuery<TSourceAndDestination, TSourceAndDestination>(services, name: name);
@@ -55,15 +70,31 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Registers a custom queryable provider override for the specified source type.
/// Use this for DTOs that require custom projection from entities.
/// </summary>
/// <typeparam name="TSource">The DTO/Item type returned by the queryable.</typeparam>
/// <typeparam name="TProvider">The provider implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddQueryableProviderOverride<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>(this IServiceCollection services)
where TSource : class
where TProvider : class, IQueryableProviderOverride<TSource>
{
services.AddTransient<IQueryableProvider<TSource>, TProvider>();
return services;
}
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string name = null)
where TSourceAndDestination : class
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>>();
@@ -102,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,17 @@
namespace Svrnty.CQRS.Events.Abstractions;
/// <summary>
/// Marker interface for domain events.
/// </summary>
public interface IDomainEvent
{
/// <summary>
/// Unique identifier for this event instance.
/// </summary>
Guid EventId { get; }
/// <summary>
/// Timestamp when the event occurred.
/// </summary>
DateTime OccurredAt { get; }
}
@@ -0,0 +1,16 @@
namespace Svrnty.CQRS.Events.Abstractions;
/// <summary>
/// Interface for publishing domain events to external systems.
/// </summary>
public interface IDomainEventPublisher
{
/// <summary>
/// Publishes a domain event.
/// </summary>
/// <typeparam name="TEvent">The type of event to publish.</typeparam>
/// <param name="event">The event to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
where TEvent : IDomainEvent;
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</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>
<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>
</Project>
@@ -0,0 +1,163 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.RabbitMQ;
/// <summary>
/// RabbitMQ implementation of the domain event publisher.
/// </summary>
public class RabbitMqDomainEventPublisher : IDomainEventPublisher, IAsyncDisposable
{
private readonly RabbitMqEventOptions _options;
private readonly ILogger<RabbitMqDomainEventPublisher> _logger;
private IConnection? _connection;
private IChannel? _channel;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private bool _disposed;
/// <summary>
/// Creates a new RabbitMQ domain event publisher.
/// </summary>
public RabbitMqDomainEventPublisher(
IOptions<RabbitMqEventOptions> options,
ILogger<RabbitMqDomainEventPublisher> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
where TEvent : IDomainEvent
{
await EnsureConnectionAsync(cancellationToken);
var eventTypeName = typeof(TEvent).Name;
var routingKey = GetRoutingKey(eventTypeName);
var body = JsonSerializer.SerializeToUtf8Bytes(@event);
var properties = new BasicProperties
{
MessageId = @event.EventId.ToString(),
ContentType = "application/json",
DeliveryMode = _options.Durable ? DeliveryModes.Persistent : DeliveryModes.Transient,
Timestamp = new AmqpTimestamp(new DateTimeOffset(@event.OccurredAt).ToUnixTimeSeconds()),
Headers = new Dictionary<string, object?>
{
["event-type"] = eventTypeName,
["event-id"] = @event.EventId.ToString()
}
};
await _channel!.BasicPublishAsync(
exchange: _options.Exchange,
routingKey: routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
_logger.LogDebug(
"Published domain event {EventType} with ID {EventId} to routing key {RoutingKey}",
eventTypeName, @event.EventId, routingKey);
}
private static string GetRoutingKey(string eventTypeName)
{
// Convert PascalCase to dot-notation, e.g., "InventoryMovementEvent" -> "events.inventory.movement"
var name = eventTypeName.Replace("Event", "");
var words = new List<string>();
var currentWord = new StringBuilder();
foreach (var c in name)
{
if (char.IsUpper(c) && currentWord.Length > 0)
{
words.Add(currentWord.ToString().ToLowerInvariant());
currentWord.Clear();
}
currentWord.Append(c);
}
if (currentWord.Length > 0)
{
words.Add(currentWord.ToString().ToLowerInvariant());
}
return "events." + string.Join(".", words);
}
private async Task EnsureConnectionAsync(CancellationToken cancellationToken)
{
if (_connection?.IsOpen == true && _channel?.IsOpen == true)
{
return;
}
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_connection?.IsOpen == true && _channel?.IsOpen == true)
{
return;
}
var factory = new ConnectionFactory
{
HostName = _options.HostName,
Port = _options.Port,
UserName = _options.UserName,
Password = _options.Password,
VirtualHost = _options.VirtualHost
};
_connection = await factory.CreateConnectionAsync(cancellationToken);
_channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
// Declare topic exchange for domain events
await _channel.ExchangeDeclareAsync(
exchange: _options.Exchange,
type: ExchangeType.Topic,
durable: _options.Durable,
autoDelete: false,
cancellationToken: cancellationToken);
_logger.LogInformation(
"Connected to RabbitMQ at {Host}:{Port}, exchange: {Exchange}",
_options.HostName, _options.Port, _options.Exchange);
}
finally
{
_connectionLock.Release();
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_channel?.IsOpen == true)
{
await _channel.CloseAsync();
}
_channel?.Dispose();
if (_connection?.IsOpen == true)
{
await _connection.CloseAsync();
}
_connection?.Dispose();
_connectionLock.Dispose();
}
}
@@ -0,0 +1,42 @@
namespace Svrnty.CQRS.Events.RabbitMQ;
/// <summary>
/// Configuration options for RabbitMQ domain event publishing.
/// </summary>
public class RabbitMqEventOptions
{
/// <summary>
/// RabbitMQ host name. Default: localhost
/// </summary>
public string HostName { get; set; } = "localhost";
/// <summary>
/// RabbitMQ port. Default: 5672
/// </summary>
public int Port { get; set; } = 5672;
/// <summary>
/// RabbitMQ username. Default: guest
/// </summary>
public string UserName { get; set; } = "guest";
/// <summary>
/// RabbitMQ password. Default: guest
/// </summary>
public string Password { get; set; } = "guest";
/// <summary>
/// RabbitMQ virtual host. Default: /
/// </summary>
public string VirtualHost { get; set; } = "/";
/// <summary>
/// Exchange name for domain events. Default: domain.events
/// </summary>
public string Exchange { get; set; } = "domain.events";
/// <summary>
/// Whether to use durable exchanges. Default: true
/// </summary>
public bool Durable { get; set; } = true;
}
@@ -0,0 +1,30 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.RabbitMQ;
/// <summary>
/// Extension methods for registering RabbitMQ domain event publishing.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds RabbitMQ domain event publishing to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action for RabbitMQ options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddRabbitMqDomainEvents(
this IServiceCollection services,
Action<RabbitMqEventOptions>? configure = null)
{
if (configure != null)
{
services.Configure(configure);
}
services.AddSingleton<IDomainEventPublisher, RabbitMqDomainEventPublisher>();
return services;
}
}
@@ -0,0 +1,40 @@
<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>
<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="RabbitMQ.Client" Version="7.0.0" />
<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,96 +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 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,47 +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 PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
}
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;
}
}
@@ -0,0 +1,49 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models;
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// </summary>
public class 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>();
}
}
+24 -25
View File
@@ -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;
}
}
+461 -51
View File
@@ -2,29 +2,90 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Svrnty.CQRS.Grpc.Generators.Models;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// Generates Protocol Buffer (.proto) files from C# Command and Query types
/// Generates Protocol Buffer (.proto) files from C# Command, Query, and Notification types
/// </summary>
internal class ProtoFileGenerator
{
private readonly Compilation _compilation;
private readonly HashSet<string> _requiredImports = new HashSet<string>();
private readonly HashSet<string> _generatedMessages = new HashSet<string>();
private readonly HashSet<string> _generatedEnums = new HashSet<string>();
private readonly List<INamedTypeSymbol> _pendingEnums = new List<INamedTypeSymbol>();
private readonly StringBuilder _messagesBuilder = new StringBuilder();
private readonly StringBuilder _enumsBuilder = new StringBuilder();
private List<INamedTypeSymbol>? _allTypesCache;
/// <summary>
/// Gets the discovered notifications after Generate() is called.
/// </summary>
public List<NotificationInfo> DiscoveredNotifications { get; private set; } = new List<NotificationInfo>();
public ProtoFileGenerator(Compilation compilation)
{
_compilation = compilation;
}
/// <summary>
/// Gets all types from the compilation and all referenced assemblies
/// </summary>
private IEnumerable<INamedTypeSymbol> GetAllTypes()
{
if (_allTypesCache != null)
return _allTypesCache;
_allTypesCache = new List<INamedTypeSymbol>();
// Get types from the current assembly
CollectTypesFromNamespace(_compilation.Assembly.GlobalNamespace, _allTypesCache);
// Get types from all referenced assemblies
foreach (var reference in _compilation.References)
{
var assemblySymbol = _compilation.GetAssemblyOrModuleSymbol(reference) as IAssemblySymbol;
if (assemblySymbol != null)
{
CollectTypesFromNamespace(assemblySymbol.GlobalNamespace, _allTypesCache);
}
}
return _allTypesCache;
}
private static void CollectTypesFromNamespace(INamespaceSymbol ns, List<INamedTypeSymbol> types)
{
foreach (var type in ns.GetTypeMembers())
{
types.Add(type);
CollectNestedTypes(type, types);
}
foreach (var nestedNs in ns.GetNamespaceMembers())
{
CollectTypesFromNamespace(nestedNs, types);
}
}
private static void CollectNestedTypes(INamedTypeSymbol type, List<INamedTypeSymbol> types)
{
foreach (var nestedType in type.GetTypeMembers())
{
types.Add(nestedType);
CollectNestedTypes(nestedType, types);
}
}
public string Generate(string packageName, string csharpNamespace)
{
var commands = DiscoverCommands();
var queries = DiscoverQueries();
var dynamicQueries = DiscoverDynamicQueries();
var notifications = DiscoverNotifications();
DiscoveredNotifications = notifications;
var sb = new StringBuilder();
@@ -98,6 +159,24 @@ internal class ProtoFileGenerator
sb.AppendLine();
}
// Notification Service (server streaming)
if (notifications.Any())
{
sb.AppendLine("// NotificationService for real-time streaming notifications");
sb.AppendLine("service NotificationService {");
foreach (var notification in notifications)
{
var methodName = $"SubscribeTo{notification.Name}";
var requestType = $"SubscribeTo{notification.Name}Request";
sb.AppendLine($" // Subscribe to {notification.Name} notifications");
sb.AppendLine($" rpc {methodName} ({requestType}) returns (stream {notification.Name});");
sb.AppendLine();
}
sb.AppendLine("}");
sb.AppendLine();
}
// Generate messages for commands
foreach (var command in commands)
{
@@ -118,7 +197,17 @@ internal class ProtoFileGenerator
GenerateDynamicQueryMessages(dq);
}
// Append all generated messages
// Generate messages for notifications
foreach (var notification in notifications)
{
GenerateNotificationMessages(notification);
}
// Generate any pending enum definitions
GeneratePendingEnums();
// Append all generated enums first, then messages
sb.Append(_enumsBuilder);
sb.Append(_messagesBuilder);
// Insert imports if any were needed
@@ -138,24 +227,78 @@ internal class ProtoFileGenerator
private List<INamedTypeSymbol> DiscoverCommands()
{
return _compilation.GetSymbolsWithName(
name => name.EndsWith("Command"),
SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.Where(t => !HasGrpcIgnoreAttribute(t))
.Where(t => t.TypeKind == TypeKind.Class || t.TypeKind == TypeKind.Struct)
.ToList();
// First, find all command handlers to know which commands are actually registered
var commandHandlerInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`1");
var commandHandlerWithResultInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`2");
if (commandHandlerInterface == null && commandHandlerWithResultInterface == null)
return new List<INamedTypeSymbol>();
var registeredCommands = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
foreach (var iface in type.AllInterfaces)
{
if (iface.IsGenericType)
{
if ((commandHandlerInterface != null && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerInterface)) ||
(commandHandlerWithResultInterface != null && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerWithResultInterface)))
{
var commandType = iface.TypeArguments[0] as INamedTypeSymbol;
if (commandType != null && !HasGrpcIgnoreAttribute(commandType))
{
registeredCommands.Add(commandType);
}
}
}
}
}
return registeredCommands.ToList();
}
private List<INamedTypeSymbol> DiscoverQueries()
{
return _compilation.GetSymbolsWithName(
name => name.EndsWith("Query"),
SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.Where(t => !HasGrpcIgnoreAttribute(t))
.Where(t => t.TypeKind == TypeKind.Class || t.TypeKind == TypeKind.Struct)
.ToList();
// First, find all query handlers to know which queries are actually registered
var queryHandlerInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.IQueryHandler`2");
var dynamicQueryInterface2 = _compilation.GetTypeByMetadataName("Svrnty.CQRS.DynamicQuery.Abstractions.IDynamicQuery`2");
var dynamicQueryInterface3 = _compilation.GetTypeByMetadataName("Svrnty.CQRS.DynamicQuery.Abstractions.IDynamicQuery`3");
if (queryHandlerInterface == null)
return new List<INamedTypeSymbol>();
var registeredQueries = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
foreach (var iface in type.AllInterfaces)
{
if (iface.IsGenericType && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, queryHandlerInterface))
{
var queryType = iface.TypeArguments[0] as INamedTypeSymbol;
if (queryType != null && !HasGrpcIgnoreAttribute(queryType))
{
// Skip dynamic queries - they're handled separately
if (queryType.IsGenericType &&
((dynamicQueryInterface2 != null && SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface2)) ||
(dynamicQueryInterface3 != null && SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface3))))
{
continue;
}
registeredQueries.Add(queryType);
}
}
}
}
return registeredQueries.ToList();
}
private bool HasGrpcIgnoreAttribute(INamedTypeSymbol type)
@@ -177,9 +320,14 @@ internal class ProtoFileGenerator
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList();
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1;
foreach (var prop in properties)
{
@@ -199,10 +347,19 @@ internal class ProtoFileGenerator
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// If this is a complex type, generate its message too
if (IsComplexType(prop.Type))
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
GenerateComplexTypeMessage(prop.Type as INamedTypeSymbol);
TrackEnumType(enumType);
}
// Collect complex types to generate after this message is closed
// Use GetElementOrUnderlyingType to extract element type from collections
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{
nestedComplexTypes.Add(namedType);
}
fieldNumber++;
@@ -210,6 +367,12 @@ internal class ProtoFileGenerator
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
// Now generate nested complex type messages
foreach (var nestedType in nestedComplexTypes)
{
GenerateComplexTypeMessage(nestedType);
}
}
private void GenerateResponseMessage(INamedTypeSymbol type)
@@ -250,52 +413,99 @@ 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} {{");
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.ToList();
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1;
foreach (var prop in properties)
// Check if this type is a collection (implements IList<T>, ICollection<T>, etc.)
var collectionElementType = ProtoFileTypeMapper.GetCollectionElementTypeByInterface(type);
if (collectionElementType != null)
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
{
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
// This type is a collection - generate a single repeated field for items
var protoElementType = ProtoFileTypeMapper.MapType(collectionElementType, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
_messagesBuilder.AppendLine($" repeated {protoElementType} items = 1;");
// Recursively generate nested complex types
if (IsComplexType(prop.Type))
// Track the element type for nested generation
if (IsComplexType(collectionElementType) && collectionElementType is INamedTypeSymbol elementNamedType)
{
GenerateComplexTypeMessage(prop.Type as INamedTypeSymbol);
nestedComplexTypes.Add(elementNamedType);
}
}
else
{
// Not a collection - generate properties as usual
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList();
fieldNumber++;
int fieldNumber = 1;
foreach (var prop in properties)
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
{
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
TrackEnumType(enumType);
}
// Collect complex types to generate after this message is closed
// Use GetElementOrUnderlyingType to extract element type from collections
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{
nestedComplexTypes.Add(namedType);
}
fieldNumber++;
}
}
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
// Now generate nested complex type messages
foreach (var nestedType in nestedComplexTypes)
{
GenerateComplexTypeMessage(nestedType);
}
}
private ITypeSymbol? GetResultType(INamedTypeSymbol commandOrQueryType)
@@ -305,11 +515,8 @@ internal class ProtoFileGenerator
? "ICommandHandler"
: "IQueryHandler";
// Find all types in the compilation
var allTypes = _compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type)
.OfType<INamedTypeSymbol>();
foreach (var type in allTypes)
// Find all types in the compilation and referenced assemblies
foreach (var type in GetAllTypes())
{
// Check if this type implements the handler interface
foreach (var @interface in type.AllInterfaces)
@@ -372,10 +579,8 @@ internal class ProtoFileGenerator
return new List<INamedTypeSymbol>();
var dynamicQueryTypes = new List<INamedTypeSymbol>();
var allTypes = _compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type)
.OfType<INamedTypeSymbol>();
foreach (var type in allTypes)
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
@@ -471,4 +676,209 @@ internal class ProtoFileGenerator
return word + "es";
return word + "s";
}
/// <summary>
/// Tracks an enum type for later generation
/// </summary>
private void TrackEnumType(INamedTypeSymbol enumType)
{
if (!_generatedEnums.Contains(enumType.Name) && !_pendingEnums.Any(e => e.Name == enumType.Name))
{
_pendingEnums.Add(enumType);
}
}
/// <summary>
/// Generates all pending enum definitions
/// </summary>
private void GeneratePendingEnums()
{
foreach (var enumType in _pendingEnums)
{
if (_generatedEnums.Contains(enumType.Name))
continue;
_generatedEnums.Add(enumType.Name);
_enumsBuilder.AppendLine($"// {enumType.Name} enum");
_enumsBuilder.AppendLine($"enum {enumType.Name} {{");
// Get all enum members
var members = enumType.GetMembers()
.OfType<IFieldSymbol>()
.Where(f => f.HasConstantValue)
.ToList();
foreach (var member in members)
{
var protoFieldName = $"{ProtoFileTypeMapper.ToSnakeCase(enumType.Name).ToUpperInvariant()}_{ProtoFileTypeMapper.ToSnakeCase(member.Name).ToUpperInvariant()}";
var value = member.ConstantValue;
_enumsBuilder.AppendLine($" {protoFieldName} = {value};");
}
_enumsBuilder.AppendLine("}");
_enumsBuilder.AppendLine();
}
}
/// <summary>
/// Discovers types marked with [StreamingNotification] attribute
/// </summary>
private List<NotificationInfo> DiscoverNotifications()
{
var streamingNotificationAttribute = _compilation.GetTypeByMetadataName(
"Svrnty.CQRS.Notifications.Abstractions.StreamingNotificationAttribute");
if (streamingNotificationAttribute == null)
return new List<NotificationInfo>();
var notifications = new List<NotificationInfo>();
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
var attr = type.GetAttributes()
.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(
a.AttributeClass, streamingNotificationAttribute));
if (attr == null)
continue;
// Extract SubscriptionKey from attribute
var subscriptionKeyArg = attr.NamedArguments
.FirstOrDefault(a => a.Key == "SubscriptionKey");
var subscriptionKeyProp = subscriptionKeyArg.Value.Value as string;
if (string.IsNullOrEmpty(subscriptionKeyProp))
continue;
// Get all properties of the notification type
var properties = ExtractNotificationProperties(type);
// Find the subscription key property info
var keyPropInfo = properties.FirstOrDefault(p => p.Name == subscriptionKeyProp);
if (keyPropInfo == null)
continue;
notifications.Add(new NotificationInfo
{
Name = type.Name,
FullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", ""),
Namespace = type.ContainingNamespace?.ToDisplayString() ?? "",
SubscriptionKeyProperty = subscriptionKeyProp!, // Already validated as non-null above
SubscriptionKeyInfo = keyPropInfo,
Properties = properties
});
}
return notifications;
}
/// <summary>
/// Extracts property information from a notification type
/// </summary>
private List<Models.PropertyInfo> ExtractNotificationProperties(INamedTypeSymbol type)
{
var properties = new List<Models.PropertyInfo>();
int fieldNumber = 1;
foreach (var prop in type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name)))
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
continue;
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out _, out _);
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
properties.Add(new Models.PropertyInfo
{
Name = prop.Name,
Type = prop.Type.Name,
FullyQualifiedType = prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", ""),
ProtoType = protoType,
FieldNumber = fieldNumber++,
IsEnum = enumType != null,
IsDecimal = prop.Type.SpecialType == SpecialType.System_Decimal ||
prop.Type.ToDisplayString().Contains("decimal"),
IsDateTime = prop.Type.ToDisplayString().Contains("DateTime"),
IsNullable = prop.Type.NullableAnnotation == NullableAnnotation.Annotated ||
(prop.Type is INamedTypeSymbol namedType &&
namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
});
if (enumType != null)
{
TrackEnumType(enumType);
}
}
return properties;
}
/// <summary>
/// Generates proto messages for a notification type
/// </summary>
private void GenerateNotificationMessages(NotificationInfo notification)
{
// Generate subscription request message (contains only the subscription key)
var requestMessageName = $"SubscribeTo{notification.Name}Request";
if (!_generatedMessages.Contains(requestMessageName))
{
_generatedMessages.Add(requestMessageName);
_messagesBuilder.AppendLine($"// Subscription request for {notification.Name}");
_messagesBuilder.AppendLine($"message {requestMessageName} {{");
_messagesBuilder.AppendLine($" {notification.SubscriptionKeyInfo.ProtoType} {ProtoFileTypeMapper.ToSnakeCase(notification.SubscriptionKeyProperty)} = 1;");
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
}
// Generate the notification message itself
if (!_generatedMessages.Contains(notification.Name))
{
_generatedMessages.Add(notification.Name);
_messagesBuilder.AppendLine($"// {notification.Name} streaming notification");
_messagesBuilder.AppendLine($"message {notification.Name} {{");
foreach (var prop in notification.Properties)
{
var typeSymbol = _compilation.GetTypeByMetadataName(prop.FullyQualifiedType) ??
GetTypeFromName(prop.FullyQualifiedType);
if (typeSymbol != null)
{
ProtoFileTypeMapper.MapType(typeSymbol, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {prop.ProtoType} {fieldName} = {prop.FieldNumber};");
}
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
}
}
/// <summary>
/// Gets a type symbol from a type name by searching all types
/// </summary>
private ITypeSymbol? GetTypeFromName(string fullTypeName)
{
// Try to find the type in all types
return GetAllTypes().FirstOrDefault(t =>
t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") == fullTypeName ||
t.ToDisplayString() == fullTypeName);
}
}
@@ -1,132 +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 all command and query types
var commandsAndQueries = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (s, _) => IsCommandOrQuery(s),
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(commandsAndQueries);
// Generate proto file when commands/queries change
context.RegisterSourceOutput(compilationAndTypes, (spc, source) =>
{
var (compilation, types) = source;
if (types.IsDefaultOrEmpty)
return;
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 bool IsCommandOrQuery(SyntaxNode node)
{
if (node is not TypeDeclarationSyntax typeDecl)
return false;
var name = typeDecl.Identifier.Text;
return name.EndsWith("Command") || name.EndsWith("Query");
}
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
}
}
+285 -78
View File
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using Microsoft.CodeAnalysis;
namespace Svrnty.CQRS.Grpc.Generators;
@@ -17,10 +18,69 @@ internal static class ProtoFileTypeMapper
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var typeName = typeSymbol.Name;
// Nullable types - unwrap
if (typeSymbol.NullableAnnotation == NullableAnnotation.Annotated && typeSymbol is INamedTypeSymbol namedType && namedType.TypeArguments.Length > 0)
// Note: NullableAnnotation.Annotated is for reference type nullability (List<T>?, string?, etc.)
// We don't unwrap these - just use the underlying type. Nullable<T> value types are handled later.
// Handle Nullable<T> value types (e.g., int?, decimal?, enum?) FIRST
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
return MapType(namedType.TypeArguments[0], out needsImport, out importPath);
// Unwrap the nullable and map the inner type
return MapType(nullableType.TypeArguments[0], out needsImport, out importPath);
}
// Handle collections BEFORE basic type checks (to avoid matching List<Guid> as Guid)
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, ICollection etc. (but not Nullable<T>)
var collectionTypeName = collectionType.Name;
if (collectionType.TypeArguments.Length == 1 &&
(collectionTypeName.Contains("List") || collectionTypeName.Contains("Collection") ||
collectionTypeName.Contains("Enumerable") || collectionTypeName.Contains("Array") ||
collectionTypeName.Contains("Set") || collectionTypeName.Contains("IList") ||
collectionTypeName.Contains("ICollection") || collectionTypeName.Contains("IEnumerable")))
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(collectionTypeName.Contains("Dictionary") || collectionTypeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
}
// Handle byte[] array type (check before switch since it's an array)
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return "bytes";
}
// Handle Stream types -> bytes
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return "bytes";
}
// Basic types
@@ -52,67 +112,35 @@ internal static class ProtoFileTypeMapper
return "double";
case "Byte[]":
return "bytes";
}
// Special types that need imports
if (fullTypeName.Contains("System.DateTime"))
{
needsImport = true;
importPath = "google/protobuf/timestamp.proto";
return "google.protobuf.Timestamp";
}
if (fullTypeName.Contains("System.TimeSpan"))
{
needsImport = true;
importPath = "google/protobuf/duration.proto";
return "google.protobuf.Duration";
}
if (fullTypeName.Contains("System.Guid"))
{
// Guid serialized as string
return "string";
}
if (fullTypeName.Contains("System.Decimal"))
{
// Decimal serialized as string (no native decimal in proto)
return "string";
}
// Collections
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, etc.
if (collectionType.TypeArguments.Length == 1)
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(typeName.Contains("Dictionary") || typeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
case "Stream":
case "MemoryStream":
case "FileStream":
return "bytes";
case "Guid":
// Guid serialized as string
return "string";
case "Decimal":
// Decimal serialized as string (no native decimal in proto)
return "string";
case "DateTime":
case "DateTimeOffset":
needsImport = true;
importPath = "google/protobuf/timestamp.proto";
return "google.protobuf.Timestamp";
case "DateOnly":
// DateOnly serialized as string (YYYY-MM-DD format)
return "string";
case "TimeOnly":
// TimeOnly serialized as string (HH:mm:ss format)
return "string";
case "TimeSpan":
needsImport = true;
importPath = "google/protobuf/duration.proto";
return "google.protobuf.Duration";
case "JsonElement":
needsImport = true;
importPath = "google/protobuf/struct.proto";
return "google.protobuf.Struct";
}
// Enums
@@ -124,7 +152,7 @@ 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
@@ -132,7 +160,24 @@ internal static class ProtoFileTypeMapper
}
/// <summary>
/// Converts C# PascalCase property name to proto snake_case field name
/// Gets the proto message name for a type, handling generic types by qualifying
/// with type arguments. e.g. Translation&lt;FaqTranslationQueryItem&gt; 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).
/// This matches protobuf's C# codegen expectations for PascalCase conversion.
/// Example: TotalADeduire -> total_a_deduire -> TotalADeduire (in generated C#)
/// </summary>
public static string ToSnakeCase(string pascalCase)
{
@@ -147,16 +192,8 @@ internal static class ProtoFileTypeMapper
var c = pascalCase[i];
if (char.IsUpper(c))
{
// Handle sequences of uppercase letters (e.g., "APIKey" -> "api_key")
if (i + 1 < pascalCase.Length && char.IsUpper(pascalCase[i + 1]))
{
result.Append(char.ToLowerInvariant(c));
}
else
{
result.Append('_');
result.Append(char.ToLowerInvariant(c));
}
result.Append('_');
result.Append(char.ToLowerInvariant(c));
}
else
{
@@ -175,8 +212,8 @@ internal static class ProtoFileTypeMapper
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Skip these types - they should trigger a warning/error
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.Threading.CancellationToken") ||
// Note: Stream types are now supported (mapped to bytes)
if (fullTypeName.Contains("System.Threading.CancellationToken") ||
fullTypeName.Contains("System.Threading.Tasks.Task") ||
fullTypeName.Contains("System.Collections.Generic.IAsyncEnumerable") ||
fullTypeName.Contains("System.Func") ||
@@ -188,4 +225,174 @@ internal static class ProtoFileTypeMapper
return false;
}
/// <summary>
/// Checks if a type is a Stream or byte array type (for special ByteString handling)
/// </summary>
public static bool IsBinaryType(ITypeSymbol typeSymbol)
{
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Check for byte[]
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return true;
}
// Check for Stream types
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return true;
}
var typeName = typeSymbol.Name;
return typeName == "Stream" || typeName == "MemoryStream" || typeName == "FileStream";
}
/// <summary>
/// Gets the element type from a collection type, or returns the type itself if not a collection.
/// Also unwraps Nullable types.
/// </summary>
public static ITypeSymbol GetElementOrUnderlyingType(ITypeSymbol typeSymbol)
{
// Unwrap Nullable<T>
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
return GetElementOrUnderlyingType(nullableType.TypeArguments[0]);
}
// Extract element type from collections
if (typeSymbol is INamedTypeSymbol collectionType && collectionType.TypeArguments.Length == 1)
{
var typeName = collectionType.Name;
if (typeName.Contains("List") || typeName.Contains("Collection") ||
typeName.Contains("Enumerable") || typeName.Contains("Array") ||
typeName.Contains("Set") || typeName.Contains("IList") ||
typeName.Contains("ICollection") || typeName.Contains("IEnumerable"))
{
return GetElementOrUnderlyingType(collectionType.TypeArguments[0]);
}
}
return typeSymbol;
}
/// <summary>
/// Checks if the type is an enum (including nullable enums)
/// </summary>
public static bool IsEnumType(ITypeSymbol typeSymbol)
{
var underlying = GetElementOrUnderlyingType(typeSymbol);
return underlying.TypeKind == TypeKind.Enum;
}
/// <summary>
/// Gets the enum type symbol if this is an enum or nullable enum, otherwise null
/// </summary>
public static INamedTypeSymbol? GetEnumType(ITypeSymbol typeSymbol)
{
var underlying = GetElementOrUnderlyingType(typeSymbol);
if (underlying.TypeKind == TypeKind.Enum && underlying is INamedTypeSymbol enumType)
{
return enumType;
}
return null;
}
/// <summary>
/// Checks if a type is a collection by checking if it implements IList{T}, ICollection{T}, or IEnumerable{T}
/// This handles types like NpgsqlPolygon that implement IList{NpgsqlPoint} but aren't named "List"
/// </summary>
public static bool IsCollectionTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return false;
// Skip string (implements IEnumerable<char>)
if (namedType.SpecialType == SpecialType.System_String)
return false;
// Check all interfaces for IList<T>, ICollection<T>, or IEnumerable<T>
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IEnumerable<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
{
return true;
}
}
}
return false;
}
/// <summary>
/// Gets the element type from a collection that implements IList{T}, ICollection{T}, or IEnumerable{T}
/// Returns null if the type is not a collection
/// </summary>
public static ITypeSymbol? GetCollectionElementTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return null;
// Skip string
if (namedType.SpecialType == SpecialType.System_String)
return null;
// Prefer IList<T> over ICollection<T> over IEnumerable<T>
ITypeSymbol? elementType = null;
int priority = 0;
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
int currentPriority = 0;
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>")
currentPriority = 3;
else if (ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
currentPriority = 2;
else if (ifaceName == "System.Collections.Generic.IEnumerable<T>")
currentPriority = 1;
if (currentPriority > priority)
{
priority = currentPriority;
elementType = iface.TypeArguments[0];
}
}
}
return elementType;
}
/// <summary>
/// Collection-internal properties that should be skipped when generating proto messages
/// </summary>
private static readonly System.Collections.Generic.HashSet<string> CollectionInternalProperties = new()
{
"Count", "Capacity", "IsReadOnly", "IsSynchronized", "SyncRoot", "Keys", "Values"
};
/// <summary>
/// Checks if a property name is a collection-internal property that should be skipped
/// </summary>
public static bool IsCollectionInternalProperty(string propertyName)
{
return CollectionInternalProperties.Contains(propertyName);
}
}
@@ -1,130 +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.");
return true; // Don't fail the build, just skip
}
// 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>
+54
View File
@@ -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
+1 -1
View File
@@ -27,7 +27,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.68.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
</ItemGroup>
<ItemGroup>
@@ -19,7 +19,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
foreach (var queryMeta in queryDiscovery.GetQueries())
{
@@ -33,8 +32,8 @@ public static class EndpointRouteBuilderExtensions
var route = $"{routePrefix}/{queryMeta.LowerCamelCaseName}";
MapQueryPost(endpoints, route, queryMeta, authorizationService);
MapQueryGet(endpoints, route, queryMeta, authorizationService);
MapQueryPost(endpoints, route, queryMeta);
MapQueryGet(endpoints, route, queryMeta);
}
return endpoints;
@@ -43,13 +42,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapQueryPost(
IEndpointRouteBuilder endpoints,
string route,
IQueryMeta queryMeta,
IQueryAuthorizationService? authorizationService)
IQueryMeta queryMeta)
{
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
@@ -90,13 +89,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapQueryGet(
IEndpointRouteBuilder endpoints,
string route,
IQueryMeta queryMeta,
IQueryAuthorizationService? authorizationService)
IQueryMeta queryMeta)
{
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
@@ -153,7 +152,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command")
{
var commandDiscovery = endpoints.ServiceProvider.GetRequiredService<ICommandDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<ICommandAuthorizationService>();
foreach (var commandMeta in commandDiscovery.GetCommands())
{
@@ -165,11 +163,11 @@ public static class EndpointRouteBuilderExtensions
if (commandMeta.CommandResultType == null)
{
MapCommandWithoutResult(endpoints, route, commandMeta, authorizationService);
MapCommandWithoutResult(endpoints, route, commandMeta);
}
else
{
MapCommandWithResult(endpoints, route, commandMeta, authorizationService);
MapCommandWithResult(endpoints, route, commandMeta);
}
}
@@ -179,13 +177,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapCommandWithoutResult(
IEndpointRouteBuilder endpoints,
string route,
ICommandMeta commandMeta,
ICommandAuthorizationService? authorizationService)
ICommandMeta commandMeta)
{
var handlerType = typeof(ICommandHandler<>).MakeGenericType(commandMeta.CommandType);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);
@@ -221,13 +219,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapCommandWithResult(
IEndpointRouteBuilder endpoints,
string route,
ICommandMeta commandMeta,
ICommandAuthorizationService? authorizationService)
ICommandMeta commandMeta)
{
var handlerType = typeof(ICommandHandler<,>).MakeGenericType(commandMeta.CommandType, commandMeta.CommandResultType!);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);
@@ -0,0 +1,18 @@
namespace Svrnty.CQRS.Notifications.Abstractions;
/// <summary>
/// Publishes notifications to all subscribed gRPC clients.
/// </summary>
public interface INotificationPublisher
{
/// <summary>
/// Publish a notification to all subscribers matching the subscription key.
/// The subscription key is extracted from the notification based on the
/// <see cref="StreamingNotificationAttribute.SubscriptionKey"/> property.
/// </summary>
/// <typeparam name="TNotification">The notification type marked with <see cref="StreamingNotificationAttribute"/>.</typeparam>
/// <param name="notification">The notification to publish.</param>
/// <param name="ct">Cancellation token.</param>
Task PublishAsync<TNotification>(TNotification notification, CancellationToken ct = default)
where TNotification : class;
}
@@ -0,0 +1,15 @@
namespace Svrnty.CQRS.Notifications.Abstractions;
/// <summary>
/// Marks a record as a streaming notification that can be subscribed to via gRPC.
/// The framework will auto-generate proto definitions and service implementations.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class StreamingNotificationAttribute : Attribute
{
/// <summary>
/// The property name used as the subscription key.
/// Subscribers filter notifications by this value.
/// </summary>
public required string SubscriptionKey { get; set; }
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</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>
<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>
</Project>
@@ -0,0 +1,76 @@
using System.Collections.Concurrent;
using System.Reflection;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Notifications.Abstractions;
namespace Svrnty.CQRS.Notifications.Grpc;
/// <summary>
/// Publishes notifications to subscribed gRPC clients.
/// </summary>
public class NotificationPublisher : INotificationPublisher
{
private readonly NotificationSubscriptionManager _manager;
private readonly ILogger<NotificationPublisher> _logger;
// Cache subscription key property info per notification type
private static readonly ConcurrentDictionary<Type, SubscriptionKeyInfo> _keyCache = new();
public NotificationPublisher(
NotificationSubscriptionManager manager,
ILogger<NotificationPublisher> logger)
{
_manager = manager;
_logger = logger;
}
/// <inheritdoc />
public async Task PublishAsync<TNotification>(TNotification notification, CancellationToken ct = default)
where TNotification : class
{
ArgumentNullException.ThrowIfNull(notification);
var keyInfo = GetSubscriptionKeyInfo(typeof(TNotification));
var subscriptionKey = keyInfo.Property.GetValue(notification);
if (subscriptionKey == null)
{
_logger.LogWarning(
"Subscription key {PropertyName} is null on {NotificationType}, skipping notification",
keyInfo.PropertyName, typeof(TNotification).Name);
return;
}
_logger.LogDebug(
"Publishing {NotificationType} with subscription key {PropertyName}={KeyValue}",
typeof(TNotification).Name, keyInfo.PropertyName, subscriptionKey);
await _manager.NotifyAsync(notification, subscriptionKey, ct);
}
private static SubscriptionKeyInfo GetSubscriptionKeyInfo(Type type)
{
return _keyCache.GetOrAdd(type, t =>
{
var attr = t.GetCustomAttribute<StreamingNotificationAttribute>();
if (attr == null)
{
throw new InvalidOperationException(
$"Type {t.Name} is not marked with [{nameof(StreamingNotificationAttribute)}]. " +
$"Add the attribute with a SubscriptionKey to enable streaming notifications.");
}
var property = t.GetProperty(attr.SubscriptionKey);
if (property == null)
{
throw new InvalidOperationException(
$"Property '{attr.SubscriptionKey}' specified in [{nameof(StreamingNotificationAttribute)}] " +
$"was not found on type {t.Name}.");
}
return new SubscriptionKeyInfo(attr.SubscriptionKey, property);
});
}
private sealed record SubscriptionKeyInfo(string PropertyName, PropertyInfo Property);
}
@@ -0,0 +1,164 @@
using System.Collections.Concurrent;
using Grpc.Core;
using Microsoft.Extensions.Logging;
namespace Svrnty.CQRS.Notifications.Grpc;
/// <summary>
/// Manages gRPC stream subscriptions for notifications.
/// Thread-safe singleton that tracks subscriptions and routes notifications to subscribers.
/// </summary>
public class NotificationSubscriptionManager
{
private readonly ConcurrentDictionary<(string TypeName, string Key), ConcurrentBag<object>> _subscriptions = new();
private readonly ILogger<NotificationSubscriptionManager> _logger;
public NotificationSubscriptionManager(ILogger<NotificationSubscriptionManager> logger)
{
_logger = logger;
}
/// <summary>
/// Subscribe to notifications of a specific domain type with a mapper to convert to proto format.
/// </summary>
/// <typeparam name="TDomain">The domain notification type.</typeparam>
/// <typeparam name="TProto">The proto message type.</typeparam>
/// <param name="subscriptionKey">The subscription key value (e.g., inventory ID).</param>
/// <param name="stream">The gRPC server stream writer.</param>
/// <param name="mapper">Function to map domain notification to proto message.</param>
/// <returns>A disposable that removes the subscription when disposed.</returns>
public IDisposable Subscribe<TDomain, TProto>(
object subscriptionKey,
IServerStreamWriter<TProto> stream,
Func<TDomain, TProto> mapper) where TDomain : class
{
var key = (typeof(TDomain).FullName!, subscriptionKey.ToString()!);
var subscriber = new Subscriber<TDomain, TProto>(stream, mapper);
var bag = _subscriptions.GetOrAdd(key, _ => new ConcurrentBag<object>());
bag.Add(subscriber);
_logger.LogInformation(
"Client subscribed to {NotificationType} with key {SubscriptionKey}. Total subscribers: {Count}",
typeof(TDomain).Name, subscriptionKey, bag.Count);
return new SubscriptionHandle(() => Remove(key, subscriber));
}
/// <summary>
/// Notify all subscribers of a specific notification type and subscription key.
/// </summary>
internal async Task NotifyAsync<TDomain>(TDomain notification, object subscriptionKey, CancellationToken ct) where TDomain : class
{
var key = (typeof(TDomain).FullName!, subscriptionKey.ToString()!);
if (!_subscriptions.TryGetValue(key, out var subscribers))
{
_logger.LogDebug(
"No subscribers for {NotificationType} with key {SubscriptionKey}",
typeof(TDomain).Name, subscriptionKey);
return;
}
var deadSubscribers = new List<object>();
foreach (var sub in subscribers)
{
if (sub is INotifiable<TDomain> notifiable)
{
try
{
await notifiable.NotifyAsync(notification, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to notify subscriber for {NotificationType}, marking for removal",
typeof(TDomain).Name);
deadSubscribers.Add(sub);
}
}
}
// Clean up dead subscribers
foreach (var dead in deadSubscribers)
{
Remove(key, dead);
}
_logger.LogDebug(
"Notified {Count} subscribers for {NotificationType} with key {SubscriptionKey}",
subscribers.Count - deadSubscribers.Count, typeof(TDomain).Name, subscriptionKey);
}
private void Remove((string TypeName, string Key) key, object subscriber)
{
if (_subscriptions.TryGetValue(key, out var bag))
{
// ConcurrentBag doesn't support removal, so we rebuild
var remaining = bag.Where(s => !ReferenceEquals(s, subscriber)).ToList();
if (remaining.Count == 0)
{
_subscriptions.TryRemove(key, out _);
}
else
{
var newBag = new ConcurrentBag<object>(remaining);
_subscriptions.TryUpdate(key, newBag, bag);
}
_logger.LogInformation(
"Client unsubscribed from {NotificationType} with key {SubscriptionKey}",
key.TypeName.Split('.').Last(), key.Key);
}
}
}
/// <summary>
/// Internal interface for type-erased notification delivery.
/// </summary>
internal interface INotifiable<in TDomain>
{
Task NotifyAsync(TDomain notification, CancellationToken ct);
}
/// <summary>
/// Wraps a gRPC stream writer with a domain→proto mapper.
/// </summary>
internal sealed class Subscriber<TDomain, TProto> : INotifiable<TDomain>
{
private readonly IServerStreamWriter<TProto> _stream;
private readonly Func<TDomain, TProto> _mapper;
public Subscriber(IServerStreamWriter<TProto> stream, Func<TDomain, TProto> mapper)
{
_stream = stream;
_mapper = mapper;
}
public async Task NotifyAsync(TDomain notification, CancellationToken ct)
{
var proto = _mapper(notification);
await _stream.WriteAsync(proto, ct);
}
}
/// <summary>
/// Handle that removes a subscription when disposed.
/// </summary>
internal sealed class SubscriptionHandle : IDisposable
{
private readonly Action _onDispose;
private bool _disposed;
public SubscriptionHandle(Action onDispose)
{
_onDispose = onDispose;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_onDispose();
}
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Notifications.Abstractions;
namespace Svrnty.CQRS.Notifications.Grpc;
/// <summary>
/// Extension methods for registering streaming notification services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds gRPC streaming notification services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddStreamingNotifications(this IServiceCollection services)
{
// Subscription manager is singleton - shared state for all subscriptions
services.AddSingleton<NotificationSubscriptionManager>();
// Publisher can be singleton since it only depends on the manager
services.AddSingleton<INotificationPublisher, NotificationPublisher>();
return services;
}
}
@@ -0,0 +1,39 @@
<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>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>
<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>
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Notifications.Abstractions\Svrnty.CQRS.Notifications.Abstractions.csproj" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Defines a saga with its steps and compensation logic.
/// </summary>
/// <typeparam name="TData">The saga's data/context type.</typeparam>
public interface ISaga<TData> where TData : class, ISagaData, new()
{
/// <summary>
/// Configures the saga steps using the fluent builder.
/// </summary>
/// <param name="builder">The saga builder for defining steps.</param>
void Configure(ISagaBuilder<TData> builder);
}
@@ -0,0 +1,173 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Fluent builder for defining saga steps.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public interface ISagaBuilder<TData> where TData : class, ISagaData
{
/// <summary>
/// Adds a local step that executes synchronously within the orchestrator process.
/// </summary>
/// <param name="name">Unique name for this step.</param>
/// <returns>Builder for configuring the step.</returns>
ISagaStepBuilder<TData> Step(string name);
/// <summary>
/// Adds a step that sends a command to a remote service via messaging.
/// </summary>
/// <typeparam name="TCommand">The command type to send.</typeparam>
/// <param name="name">Unique name for this step.</param>
/// <returns>Builder for configuring the remote step.</returns>
ISagaRemoteStepBuilder<TData, TCommand> SendCommand<TCommand>(string name) where TCommand : class;
/// <summary>
/// Adds a step that sends a command and expects a specific result.
/// </summary>
/// <typeparam name="TCommand">The command type to send.</typeparam>
/// <typeparam name="TResult">The expected result type.</typeparam>
/// <param name="name">Unique name for this step.</param>
/// <returns>Builder for configuring the remote step.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> SendCommand<TCommand, TResult>(string name) where TCommand : class;
}
/// <summary>
/// Builder for configuring a local saga step.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public interface ISagaStepBuilder<TData> where TData : class, ISagaData
{
/// <summary>
/// Defines the execution action for this step.
/// </summary>
/// <param name="action">The action to execute.</param>
/// <returns>This builder for chaining.</returns>
ISagaStepBuilder<TData> Execute(Func<TData, ISagaContext, CancellationToken, Task> action);
/// <summary>
/// Defines the compensation action for this step.
/// </summary>
/// <param name="action">The compensation action to execute on rollback.</param>
/// <returns>This builder for chaining.</returns>
ISagaStepBuilder<TData> Compensate(Func<TData, ISagaContext, CancellationToken, Task> action);
/// <summary>
/// Completes this step definition and returns to the saga builder.
/// </summary>
/// <returns>The saga builder for adding more steps.</returns>
ISagaBuilder<TData> Then();
}
/// <summary>
/// Builder for configuring a remote command saga step (no result).
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type to send.</typeparam>
public interface ISagaRemoteStepBuilder<TData, TCommand>
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Defines how to build the command from saga data.
/// </summary>
/// <param name="commandBuilder">Function to create the command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder);
/// <summary>
/// Defines what to do when the command completes successfully.
/// </summary>
/// <param name="handler">Handler for the response.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> OnResponse(Func<TData, ISagaContext, CancellationToken, Task> handler);
/// <summary>
/// Defines the compensation command to send on rollback.
/// </summary>
/// <typeparam name="TCompensationCommand">The compensation command type.</typeparam>
/// <param name="compensationBuilder">Function to create the compensation command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder) where TCompensationCommand : class;
/// <summary>
/// Sets a timeout for this step.
/// </summary>
/// <param name="timeout">The timeout duration.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> WithTimeout(TimeSpan timeout);
/// <summary>
/// Configures retry behavior for this step.
/// </summary>
/// <param name="maxRetries">Maximum number of retries.</param>
/// <param name="delay">Delay between retries.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> WithRetry(int maxRetries, TimeSpan delay);
/// <summary>
/// Completes this step definition and returns to the saga builder.
/// </summary>
/// <returns>The saga builder for adding more steps.</returns>
ISagaBuilder<TData> Then();
}
/// <summary>
/// Builder for configuring a remote command saga step with result.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type to send.</typeparam>
/// <typeparam name="TResult">The expected result type.</typeparam>
public interface ISagaRemoteStepBuilder<TData, TCommand, TResult>
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Defines how to build the command from saga data.
/// </summary>
/// <param name="commandBuilder">Function to create the command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder);
/// <summary>
/// Defines what to do when the command completes successfully with a result.
/// </summary>
/// <param name="handler">Handler for the response with result.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> OnResponse(
Func<TData, ISagaContext, TResult, CancellationToken, Task> handler);
/// <summary>
/// Defines the compensation command to send on rollback.
/// </summary>
/// <typeparam name="TCompensationCommand">The compensation command type.</typeparam>
/// <param name="compensationBuilder">Function to create the compensation command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder) where TCompensationCommand : class;
/// <summary>
/// Sets a timeout for this step.
/// </summary>
/// <param name="timeout">The timeout duration.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> WithTimeout(TimeSpan timeout);
/// <summary>
/// Configures retry behavior for this step.
/// </summary>
/// <param name="maxRetries">Maximum number of retries.</param>
/// <param name="delay">Delay between retries.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> WithRetry(int maxRetries, TimeSpan delay);
/// <summary>
/// Completes this step definition and returns to the saga builder.
/// </summary>
/// <returns>The saga builder for adding more steps.</returns>
ISagaBuilder<TData> Then();
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Provides context information during saga step execution.
/// </summary>
public interface ISagaContext
{
/// <summary>
/// Unique identifier for this saga instance.
/// </summary>
Guid SagaId { get; }
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
Guid CorrelationId { get; }
/// <summary>
/// The fully qualified type name of the saga.
/// </summary>
string SagaType { get; }
/// <summary>
/// Index of the current step being executed.
/// </summary>
int CurrentStepIndex { get; }
/// <summary>
/// Name of the current step being executed.
/// </summary>
string CurrentStepName { get; }
/// <summary>
/// Results from completed steps, keyed by step name.
/// </summary>
IReadOnlyDictionary<string, object?> StepResults { get; }
/// <summary>
/// Gets a result from a previous step.
/// </summary>
/// <typeparam name="T">The expected result type.</typeparam>
/// <param name="stepName">The name of the step.</param>
/// <returns>The result, or default if not found.</returns>
T? GetStepResult<T>(string stepName);
/// <summary>
/// Sets a result for the current step (available to subsequent steps).
/// </summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="result">The result value.</param>
void SetStepResult<T>(T result);
}
@@ -0,0 +1,14 @@
using System;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Marker interface for saga data. All saga data classes must implement this interface.
/// </summary>
public interface ISagaData
{
/// <summary>
/// Correlation ID for tracing the saga across services.
/// </summary>
Guid CorrelationId { get; set; }
}
@@ -0,0 +1,52 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Orchestrates saga execution.
/// </summary>
public interface ISagaOrchestrator
{
/// <summary>
/// Starts a new saga instance with a generated correlation ID.
/// </summary>
/// <typeparam name="TSaga">The saga type.</typeparam>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <param name="initialData">The initial saga data.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state.</returns>
Task<SagaState> StartAsync<TSaga, TData>(TData initialData, CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new();
/// <summary>
/// Starts a new saga instance with a specific correlation ID.
/// </summary>
/// <typeparam name="TSaga">The saga type.</typeparam>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <param name="initialData">The initial saga data.</param>
/// <param name="correlationId">The correlation ID for tracing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state.</returns>
Task<SagaState> StartAsync<TSaga, TData>(TData initialData, Guid correlationId, CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new();
/// <summary>
/// Gets the current state of a saga by its ID.
/// </summary>
/// <param name="sagaId">The saga instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetStateAsync(Guid sagaId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current state of a saga by its correlation ID.
/// </summary>
/// <param name="correlationId">The correlation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetStateByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions.Messaging;
/// <summary>
/// Abstraction for saga messaging transport.
/// </summary>
public interface ISagaMessageBus
{
/// <summary>
/// Publishes a saga command message to the message bus.
/// </summary>
/// <param name="message">The message to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishAsync(SagaMessage message, CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a saga step response to the message bus.
/// </summary>
/// <param name="response">The response to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishResponseAsync(SagaStepResponse response, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to saga messages for a specific command type.
/// </summary>
/// <typeparam name="TCommand">The command type to subscribe to.</typeparam>
/// <param name="handler">Handler that processes the message and returns a response.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SubscribeAsync<TCommand>(
Func<SagaMessage, TCommand, CancellationToken, Task<SagaStepResponse>> handler,
CancellationToken cancellationToken = default) where TCommand : class;
/// <summary>
/// Subscribes to saga step responses.
/// </summary>
/// <param name="handler">Handler that processes responses.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SubscribeToResponsesAsync(
Func<SagaStepResponse, CancellationToken, Task> handler,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Sagas.Abstractions.Messaging;
/// <summary>
/// Message envelope for saga commands sent to remote services.
/// </summary>
public record SagaMessage
{
/// <summary>
/// Unique identifier for this message.
/// </summary>
public Guid MessageId { get; init; } = Guid.NewGuid();
/// <summary>
/// The saga instance ID.
/// </summary>
public Guid SagaId { get; init; }
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
public Guid CorrelationId { get; init; }
/// <summary>
/// Name of the saga step that sent this message.
/// </summary>
public string StepName { get; init; } = string.Empty;
/// <summary>
/// Fully qualified type name of the command.
/// </summary>
public string CommandType { get; init; } = string.Empty;
/// <summary>
/// Serialized command payload (JSON).
/// </summary>
public string? Payload { get; init; }
/// <summary>
/// When the message was created.
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Additional headers for the message.
/// </summary>
public Dictionary<string, string> Headers { get; init; } = new();
/// <summary>
/// Whether this is a compensation command.
/// </summary>
public bool IsCompensation { get; init; }
}
@@ -0,0 +1,59 @@
using System;
namespace Svrnty.CQRS.Sagas.Abstractions.Messaging;
/// <summary>
/// Response message from a saga step execution.
/// </summary>
public record SagaStepResponse
{
/// <summary>
/// Unique identifier for this response.
/// </summary>
public Guid MessageId { get; init; } = Guid.NewGuid();
/// <summary>
/// The saga instance ID.
/// </summary>
public Guid SagaId { get; init; }
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
public Guid CorrelationId { get; init; }
/// <summary>
/// Name of the saga step that this response is for.
/// </summary>
public string StepName { get; init; } = string.Empty;
/// <summary>
/// Whether the step executed successfully.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Fully qualified type name of the result (if any).
/// </summary>
public string? ResultType { get; init; }
/// <summary>
/// Serialized result payload (JSON).
/// </summary>
public string? ResultPayload { get; init; }
/// <summary>
/// Error message if the step failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Stack trace if the step failed (for debugging).
/// </summary>
public string? StackTrace { get; init; }
/// <summary>
/// When the response was created.
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions.Persistence;
/// <summary>
/// Abstraction for saga state persistence.
/// </summary>
public interface ISagaStateStore
{
/// <summary>
/// Creates a new saga state entry.
/// </summary>
/// <param name="state">The saga state to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created saga state.</returns>
Task<SagaState> CreateAsync(SagaState state, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a saga state by its ID.
/// </summary>
/// <param name="sagaId">The saga instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetByIdAsync(Guid sagaId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a saga state by its correlation ID.
/// </summary>
/// <param name="correlationId">The correlation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing saga state.
/// </summary>
/// <param name="state">The saga state to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated saga state.</returns>
Task<SagaState> UpdateAsync(SagaState state, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all pending (in progress) sagas.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of pending saga states.</returns>
Task<IReadOnlyList<SagaState>> GetPendingSagasAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all sagas with a specific status.
/// </summary>
/// <param name="status">The status to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of saga states with the specified status.</returns>
Task<IReadOnlyList<SagaState>> GetSagasByStatusAsync(SagaStatus status, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Represents the persistent state of a saga instance.
/// </summary>
public class SagaState
{
/// <summary>
/// Unique identifier for this saga instance.
/// </summary>
public Guid SagaId { get; set; } = Guid.NewGuid();
/// <summary>
/// The fully qualified type name of the saga.
/// </summary>
public string SagaType { get; set; } = string.Empty;
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
public Guid CorrelationId { get; set; }
/// <summary>
/// Current execution status.
/// </summary>
public SagaStatus Status { get; set; } = SagaStatus.NotStarted;
/// <summary>
/// Index of the current step being executed.
/// </summary>
public int CurrentStepIndex { get; set; }
/// <summary>
/// Name of the current step being executed.
/// </summary>
public string? CurrentStepName { get; set; }
/// <summary>
/// Results from completed steps, keyed by step name.
/// </summary>
public Dictionary<string, object?> StepResults { get; set; } = new();
/// <summary>
/// Names of steps that have been completed.
/// </summary>
public List<string> CompletedSteps { get; set; } = new();
/// <summary>
/// Errors that occurred during saga execution.
/// </summary>
public List<SagaStepError> Errors { get; set; } = new();
/// <summary>
/// Serialized saga data (JSON).
/// </summary>
public string? SerializedData { get; set; }
/// <summary>
/// When the saga was created.
/// </summary>
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// When the saga was last updated.
/// </summary>
public DateTimeOffset? UpdatedAt { get; set; }
/// <summary>
/// When the saga completed (successfully or compensated).
/// </summary>
public DateTimeOffset? CompletedAt { get; set; }
}
/// <summary>
/// Represents an error that occurred during saga step execution.
/// </summary>
public record SagaStepError(
string StepName,
string ErrorMessage,
string? StackTrace,
DateTimeOffset OccurredAt
);
@@ -0,0 +1,37 @@
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Represents the execution state of a saga.
/// </summary>
public enum SagaStatus
{
/// <summary>
/// Saga has not started execution.
/// </summary>
NotStarted,
/// <summary>
/// Saga is currently executing steps.
/// </summary>
InProgress,
/// <summary>
/// Saga completed successfully.
/// </summary>
Completed,
/// <summary>
/// Saga failed and compensation has not been triggered.
/// </summary>
Failed,
/// <summary>
/// Saga is currently executing compensation steps.
/// </summary>
Compensating,
/// <summary>
/// Saga has been compensated (rolled back) successfully.
/// </summary>
Compensated
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<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>
<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>
</Project>
@@ -0,0 +1,60 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Configuration;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// Extensions for adding RabbitMQ saga transport to the CQRS pipeline.
/// </summary>
public static class CqrsBuilderExtensions
{
/// <summary>
/// Uses RabbitMQ as the message transport for sagas.
/// </summary>
/// <param name="builder">The CQRS builder.</param>
/// <param name="configure">Configuration action for RabbitMQ options.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder UseRabbitMq(this CqrsBuilder builder, Action<RabbitMqSagaOptions> configure)
{
var options = new RabbitMqSagaOptions();
configure(options);
builder.Services.Configure<RabbitMqSagaOptions>(opt =>
{
opt.HostName = options.HostName;
opt.Port = options.Port;
opt.UserName = options.UserName;
opt.Password = options.Password;
opt.VirtualHost = options.VirtualHost;
opt.CommandExchange = options.CommandExchange;
opt.ResponseExchange = options.ResponseExchange;
opt.QueuePrefix = options.QueuePrefix;
opt.DurableQueues = options.DurableQueues;
opt.PrefetchCount = options.PrefetchCount;
opt.ConnectionRetryDelay = options.ConnectionRetryDelay;
opt.MaxConnectionRetries = options.MaxConnectionRetries;
});
// Replace the default message bus with RabbitMQ implementation
builder.Services.RemoveAll<ISagaMessageBus>();
builder.Services.AddSingleton<ISagaMessageBus, RabbitMqSagaMessageBus>();
// Add hosted service for connection management
builder.Services.AddHostedService<RabbitMqSagaHostedService>();
return builder;
}
/// <summary>
/// Uses RabbitMQ as the message transport for sagas with default options.
/// </summary>
/// <param name="builder">The CQRS builder.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder UseRabbitMq(this CqrsBuilder builder)
{
return builder.UseRabbitMq(_ => { });
}
}
@@ -0,0 +1,88 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// Hosted service that manages RabbitMQ saga connections and subscriptions.
/// </summary>
public class RabbitMqSagaHostedService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ISagaMessageBus _messageBus;
private readonly ILogger<RabbitMqSagaHostedService> _logger;
/// <summary>
/// Creates a new RabbitMQ saga hosted service.
/// </summary>
public RabbitMqSagaHostedService(
IServiceProvider serviceProvider,
ISagaMessageBus messageBus,
ILogger<RabbitMqSagaHostedService> logger)
{
_serviceProvider = serviceProvider;
_messageBus = messageBus;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Starting RabbitMQ saga hosted service");
try
{
// Subscribe to saga responses so the orchestrator can process them
await _messageBus.SubscribeToResponsesAsync(
async (response, ct) =>
{
using var scope = _serviceProvider.CreateScope();
var orchestrator = scope.ServiceProvider.GetRequiredService<ISagaOrchestrator>();
// The orchestrator needs to handle responses
// This is a simplified approach - in production you'd want to handle this more robustly
_logger.LogDebug(
"Received response for saga {SagaId}, step {StepName}, success: {Success}",
response.SagaId, response.StepName, response.Success);
// For now, we just log the response
// The orchestrator's HandleResponseAsync method would be called here
// but it requires knowing the saga data type, which we don't have in this context
},
stoppingToken);
_logger.LogInformation("RabbitMQ saga hosted service started successfully");
// Keep the service running
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("RabbitMQ saga hosted service is stopping");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in RabbitMQ saga hosted service");
throw;
}
}
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping RabbitMQ saga hosted service");
if (_messageBus is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
await base.StopAsync(cancellationToken);
}
}
@@ -0,0 +1,335 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// RabbitMQ implementation of the saga message bus.
/// </summary>
public class RabbitMqSagaMessageBus : ISagaMessageBus, IAsyncDisposable
{
private readonly RabbitMqSagaOptions _options;
private readonly ILogger<RabbitMqSagaMessageBus> _logger;
private IConnection? _connection;
private IChannel? _publishChannel;
private readonly ConcurrentDictionary<string, IChannel> _subscriptionChannels = new();
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private bool _disposed;
/// <summary>
/// Creates a new RabbitMQ saga message bus.
/// </summary>
public RabbitMqSagaMessageBus(
IOptions<RabbitMqSagaOptions> options,
ILogger<RabbitMqSagaMessageBus> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task PublishAsync(SagaMessage message, CancellationToken cancellationToken = default)
{
await EnsureConnectionAsync(cancellationToken);
var routingKey = $"saga.command.{message.CommandType}";
var body = JsonSerializer.SerializeToUtf8Bytes(message);
var properties = new BasicProperties
{
MessageId = message.MessageId.ToString(),
CorrelationId = message.CorrelationId.ToString(),
ContentType = "application/json",
DeliveryMode = _options.DurableQueues ? DeliveryModes.Persistent : DeliveryModes.Transient,
Timestamp = new AmqpTimestamp(message.Timestamp.ToUnixTimeSeconds()),
Headers = new Dictionary<string, object?>
{
["saga-id"] = message.SagaId.ToString(),
["step-name"] = message.StepName,
["is-compensation"] = message.IsCompensation.ToString()
}
};
await _publishChannel!.BasicPublishAsync(
exchange: _options.CommandExchange,
routingKey: routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
_logger.LogDebug(
"Published saga command {CommandType} for saga {SagaId}, step {StepName}",
message.CommandType, message.SagaId, message.StepName);
}
/// <inheritdoc />
public async Task PublishResponseAsync(SagaStepResponse response, CancellationToken cancellationToken = default)
{
await EnsureConnectionAsync(cancellationToken);
var routingKey = $"saga.response.{response.SagaId}";
var body = JsonSerializer.SerializeToUtf8Bytes(response);
var properties = new BasicProperties
{
MessageId = response.MessageId.ToString(),
CorrelationId = response.CorrelationId.ToString(),
ContentType = "application/json",
DeliveryMode = _options.DurableQueues ? DeliveryModes.Persistent : DeliveryModes.Transient,
Timestamp = new AmqpTimestamp(response.Timestamp.ToUnixTimeSeconds()),
Headers = new Dictionary<string, object?>
{
["saga-id"] = response.SagaId.ToString(),
["step-name"] = response.StepName,
["success"] = response.Success.ToString()
}
};
await _publishChannel!.BasicPublishAsync(
exchange: _options.ResponseExchange,
routingKey: routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
_logger.LogDebug(
"Published saga response for saga {SagaId}, step {StepName}, success: {Success}",
response.SagaId, response.StepName, response.Success);
}
/// <inheritdoc />
public async Task SubscribeAsync<TCommand>(
Func<SagaMessage, TCommand, CancellationToken, Task<SagaStepResponse>> handler,
CancellationToken cancellationToken = default)
where TCommand : class
{
await EnsureConnectionAsync(cancellationToken);
var commandTypeName = typeof(TCommand).FullName!;
var queueName = $"{_options.QueuePrefix}.{SanitizeQueueName(commandTypeName)}";
var routingKey = $"saga.command.{commandTypeName}";
var channel = await _connection!.CreateChannelAsync(cancellationToken: cancellationToken);
_subscriptionChannels[commandTypeName] = channel;
// Declare queue
await channel.QueueDeclareAsync(
queue: queueName,
durable: _options.DurableQueues,
exclusive: false,
autoDelete: false,
cancellationToken: cancellationToken);
// Bind to command exchange
await channel.QueueBindAsync(
queue: queueName,
exchange: _options.CommandExchange,
routingKey: routingKey,
cancellationToken: cancellationToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: _options.PrefetchCount, global: false, cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, ea) =>
{
try
{
var messageJson = Encoding.UTF8.GetString(ea.Body.ToArray());
var message = JsonSerializer.Deserialize<SagaMessage>(messageJson);
if (message == null)
{
_logger.LogWarning("Received null saga message");
await channel.BasicNackAsync(ea.DeliveryTag, false, false, cancellationToken);
return;
}
var command = JsonSerializer.Deserialize<TCommand>(message.Payload!);
if (command == null)
{
_logger.LogWarning("Failed to deserialize command {CommandType}", commandTypeName);
await channel.BasicNackAsync(ea.DeliveryTag, false, false, cancellationToken);
return;
}
var response = await handler(message, command, cancellationToken);
await PublishResponseAsync(response, cancellationToken);
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing saga command {CommandType}", commandTypeName);
await channel.BasicNackAsync(ea.DeliveryTag, false, true, cancellationToken);
}
};
await channel.BasicConsumeAsync(queueName, false, consumer, cancellationToken);
_logger.LogInformation(
"Subscribed to saga commands of type {CommandType} on queue {QueueName}",
commandTypeName, queueName);
}
/// <inheritdoc />
public async Task SubscribeToResponsesAsync(
Func<SagaStepResponse, CancellationToken, Task> handler,
CancellationToken cancellationToken = default)
{
await EnsureConnectionAsync(cancellationToken);
var queueName = $"{_options.QueuePrefix}.responses";
var routingKey = "saga.response.#";
var channel = await _connection!.CreateChannelAsync(cancellationToken: cancellationToken);
_subscriptionChannels["responses"] = channel;
// Declare queue
await channel.QueueDeclareAsync(
queue: queueName,
durable: _options.DurableQueues,
exclusive: false,
autoDelete: false,
cancellationToken: cancellationToken);
// Bind to response exchange
await channel.QueueBindAsync(
queue: queueName,
exchange: _options.ResponseExchange,
routingKey: routingKey,
cancellationToken: cancellationToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: _options.PrefetchCount, global: false, cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, ea) =>
{
try
{
var responseJson = Encoding.UTF8.GetString(ea.Body.ToArray());
var response = JsonSerializer.Deserialize<SagaStepResponse>(responseJson);
if (response == null)
{
_logger.LogWarning("Received null saga response");
await channel.BasicNackAsync(ea.DeliveryTag, false, false, cancellationToken);
return;
}
await handler(response, cancellationToken);
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing saga response");
await channel.BasicNackAsync(ea.DeliveryTag, false, true, cancellationToken);
}
};
await channel.BasicConsumeAsync(queueName, false, consumer, cancellationToken);
_logger.LogInformation("Subscribed to saga responses on queue {QueueName}", queueName);
}
private async Task EnsureConnectionAsync(CancellationToken cancellationToken)
{
if (_connection?.IsOpen == true && _publishChannel?.IsOpen == true)
{
return;
}
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_connection?.IsOpen == true && _publishChannel?.IsOpen == true)
{
return;
}
var factory = new ConnectionFactory
{
HostName = _options.HostName,
Port = _options.Port,
UserName = _options.UserName,
Password = _options.Password,
VirtualHost = _options.VirtualHost
};
_connection = await factory.CreateConnectionAsync(cancellationToken);
_publishChannel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
// Declare exchanges
await _publishChannel.ExchangeDeclareAsync(
exchange: _options.CommandExchange,
type: ExchangeType.Topic,
durable: _options.DurableQueues,
autoDelete: false,
cancellationToken: cancellationToken);
await _publishChannel.ExchangeDeclareAsync(
exchange: _options.ResponseExchange,
type: ExchangeType.Topic,
durable: _options.DurableQueues,
autoDelete: false,
cancellationToken: cancellationToken);
_logger.LogInformation(
"Connected to RabbitMQ at {Host}:{Port}",
_options.HostName, _options.Port);
}
finally
{
_connectionLock.Release();
}
}
private static string SanitizeQueueName(string name)
{
return name.Replace(".", "-").Replace("+", "-").ToLowerInvariant();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var channel in _subscriptionChannels.Values)
{
if (channel.IsOpen)
{
await channel.CloseAsync();
}
channel.Dispose();
}
if (_publishChannel?.IsOpen == true)
{
await _publishChannel.CloseAsync();
}
_publishChannel?.Dispose();
if (_connection?.IsOpen == true)
{
await _connection.CloseAsync();
}
_connection?.Dispose();
_connectionLock.Dispose();
}
}
@@ -0,0 +1,69 @@
using System;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// Configuration options for RabbitMQ saga transport.
/// </summary>
public class RabbitMqSagaOptions
{
/// <summary>
/// RabbitMQ host name (default: localhost).
/// </summary>
public string HostName { get; set; } = "localhost";
/// <summary>
/// RabbitMQ port (default: 5672).
/// </summary>
public int Port { get; set; } = 5672;
/// <summary>
/// RabbitMQ user name (default: guest).
/// </summary>
public string UserName { get; set; } = "guest";
/// <summary>
/// RabbitMQ password (default: guest).
/// </summary>
public string Password { get; set; } = "guest";
/// <summary>
/// RabbitMQ virtual host (default: /).
/// </summary>
public string VirtualHost { get; set; } = "/";
/// <summary>
/// Exchange name for saga commands (default: svrnty.sagas.commands).
/// </summary>
public string CommandExchange { get; set; } = "svrnty.sagas.commands";
/// <summary>
/// Exchange name for saga responses (default: svrnty.sagas.responses).
/// </summary>
public string ResponseExchange { get; set; } = "svrnty.sagas.responses";
/// <summary>
/// Queue name prefix for this service (default: saga-service).
/// </summary>
public string QueuePrefix { get; set; } = "saga-service";
/// <summary>
/// Whether to use durable queues (default: true).
/// </summary>
public bool DurableQueues { get; set; } = true;
/// <summary>
/// Prefetch count for consumers (default: 10).
/// </summary>
public ushort PrefetchCount { get; set; } = 10;
/// <summary>
/// Connection retry delay (default: 5 seconds).
/// </summary>
public TimeSpan ConnectionRetryDelay { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Maximum connection retry attempts (default: 10).
/// </summary>
public int MaxConnectionRetries { get; set; } = 10;
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<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>
<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.Sagas\Svrnty.CQRS.Sagas.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RabbitMQ.Client" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,54 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Builder for configuring local saga steps.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public class LocalSagaStepBuilder<TData> : ISagaStepBuilder<TData>
where TData : class, ISagaData
{
private readonly SagaBuilder<TData> _parent;
private readonly LocalSagaStepDefinition<TData> _definition;
/// <summary>
/// Creates a new local step builder.
/// </summary>
/// <param name="parent">The parent saga builder.</param>
/// <param name="name">The step name.</param>
/// <param name="order">The step order.</param>
public LocalSagaStepBuilder(SagaBuilder<TData> parent, string name, int order)
{
_parent = parent;
_definition = new LocalSagaStepDefinition<TData>
{
Name = name,
Order = order
};
}
/// <inheritdoc />
public ISagaStepBuilder<TData> Execute(Func<TData, ISagaContext, CancellationToken, Task> action)
{
_definition.ExecuteAction = action;
return this;
}
/// <inheritdoc />
public ISagaStepBuilder<TData> Compensate(Func<TData, ISagaContext, CancellationToken, Task> action)
{
_definition.CompensateAction = action;
return this;
}
/// <inheritdoc />
public ISagaBuilder<TData> Then()
{
_parent.AddStep(_definition);
return _parent;
}
}
@@ -0,0 +1,158 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Builder for configuring remote saga steps (without result).
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
public class RemoteSagaStepBuilder<TData, TCommand> : ISagaRemoteStepBuilder<TData, TCommand>
where TData : class, ISagaData
where TCommand : class
{
private readonly SagaBuilder<TData> _parent;
private readonly RemoteSagaStepDefinition<TData, TCommand> _definition;
/// <summary>
/// Creates a new remote step builder.
/// </summary>
/// <param name="parent">The parent saga builder.</param>
/// <param name="name">The step name.</param>
/// <param name="order">The step order.</param>
public RemoteSagaStepBuilder(SagaBuilder<TData> parent, string name, int order)
{
_parent = parent;
_definition = new RemoteSagaStepDefinition<TData, TCommand>
{
Name = name,
Order = order
};
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder)
{
_definition.CommandBuilder = commandBuilder;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> OnResponse(Func<TData, ISagaContext, CancellationToken, Task> handler)
{
_definition.ResponseHandler = handler;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder)
where TCompensationCommand : class
{
_definition.CompensationCommandType = typeof(TCompensationCommand);
_definition.CompensationBuilder = (data, ctx) => compensationBuilder(data, ctx);
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> WithTimeout(TimeSpan timeout)
{
_definition.Timeout = timeout;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> WithRetry(int maxRetries, TimeSpan delay)
{
_definition.MaxRetries = maxRetries;
_definition.RetryDelay = delay;
return this;
}
/// <inheritdoc />
public ISagaBuilder<TData> Then()
{
_parent.AddStep(_definition);
return _parent;
}
}
/// <summary>
/// Builder for configuring remote saga steps with result.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
public class RemoteSagaStepBuilderWithResult<TData, TCommand, TResult> : ISagaRemoteStepBuilder<TData, TCommand, TResult>
where TData : class, ISagaData
where TCommand : class
{
private readonly SagaBuilder<TData> _parent;
private readonly RemoteSagaStepDefinition<TData, TCommand, TResult> _definition;
/// <summary>
/// Creates a new remote step builder with result.
/// </summary>
/// <param name="parent">The parent saga builder.</param>
/// <param name="name">The step name.</param>
/// <param name="order">The step order.</param>
public RemoteSagaStepBuilderWithResult(SagaBuilder<TData> parent, string name, int order)
{
_parent = parent;
_definition = new RemoteSagaStepDefinition<TData, TCommand, TResult>
{
Name = name,
Order = order
};
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder)
{
_definition.CommandBuilder = commandBuilder;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> OnResponse(
Func<TData, ISagaContext, TResult, CancellationToken, Task> handler)
{
_definition.ResponseHandler = handler;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder)
where TCompensationCommand : class
{
_definition.CompensationCommandType = typeof(TCompensationCommand);
_definition.CompensationBuilder = (data, ctx) => compensationBuilder(data, ctx);
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> WithTimeout(TimeSpan timeout)
{
_definition.Timeout = timeout;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> WithRetry(int maxRetries, TimeSpan delay)
{
_definition.MaxRetries = maxRetries;
_definition.RetryDelay = delay;
return this;
}
/// <inheritdoc />
public ISagaBuilder<TData> Then()
{
_parent.AddStep(_definition);
return _parent;
}
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Implementation of the saga builder for defining saga steps.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public class SagaBuilder<TData> : ISagaBuilder<TData>
where TData : class, ISagaData
{
private readonly List<SagaStepDefinition> _steps = new();
/// <summary>
/// Gets the defined steps.
/// </summary>
public IReadOnlyList<SagaStepDefinition> Steps => _steps.AsReadOnly();
/// <inheritdoc />
public ISagaStepBuilder<TData> Step(string name)
{
return new LocalSagaStepBuilder<TData>(this, name, _steps.Count);
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> SendCommand<TCommand>(string name)
where TCommand : class
{
return new RemoteSagaStepBuilder<TData, TCommand>(this, name, _steps.Count);
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> SendCommand<TCommand, TResult>(string name)
where TCommand : class
{
return new RemoteSagaStepBuilderWithResult<TData, TCommand, TResult>(this, name, _steps.Count);
}
/// <summary>
/// Adds a step definition to the builder.
/// </summary>
/// <param name="step">The step definition to add.</param>
internal void AddStep(SagaStepDefinition step)
{
_steps.Add(step);
}
}
@@ -0,0 +1,149 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Base class for saga step definitions.
/// </summary>
public abstract class SagaStepDefinition
{
/// <summary>
/// Unique name for this step.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Order of the step in the saga.
/// </summary>
public int Order { get; set; }
/// <summary>
/// Whether this step has a compensation action.
/// </summary>
public abstract bool HasCompensation { get; }
/// <summary>
/// Whether this step is a remote step (sends a command).
/// </summary>
public abstract bool IsRemote { get; }
/// <summary>
/// Timeout for this step.
/// </summary>
public TimeSpan? Timeout { get; set; }
/// <summary>
/// Maximum number of retries.
/// </summary>
public int MaxRetries { get; set; }
/// <summary>
/// Delay between retries.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
}
/// <summary>
/// Definition for a local saga step.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public class LocalSagaStepDefinition<TData> : SagaStepDefinition
where TData : class, ISagaData
{
/// <summary>
/// The execution action.
/// </summary>
public Func<TData, ISagaContext, CancellationToken, Task>? ExecuteAction { get; set; }
/// <summary>
/// The compensation action.
/// </summary>
public Func<TData, ISagaContext, CancellationToken, Task>? CompensateAction { get; set; }
/// <inheritdoc />
public override bool HasCompensation => CompensateAction != null;
/// <inheritdoc />
public override bool IsRemote => false;
}
/// <summary>
/// Definition for a remote saga step.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
public class RemoteSagaStepDefinition<TData, TCommand> : SagaStepDefinition
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Function to build the command.
/// </summary>
public Func<TData, ISagaContext, TCommand>? CommandBuilder { get; set; }
/// <summary>
/// Handler for successful response.
/// </summary>
public Func<TData, ISagaContext, CancellationToken, Task>? ResponseHandler { get; set; }
/// <summary>
/// Type of the compensation command.
/// </summary>
public Type? CompensationCommandType { get; set; }
/// <summary>
/// Function to build the compensation command.
/// </summary>
public Func<TData, ISagaContext, object>? CompensationBuilder { get; set; }
/// <inheritdoc />
public override bool HasCompensation => CompensationBuilder != null;
/// <inheritdoc />
public override bool IsRemote => true;
}
/// <summary>
/// Definition for a remote saga step with result.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
public class RemoteSagaStepDefinition<TData, TCommand, TResult> : SagaStepDefinition
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Function to build the command.
/// </summary>
public Func<TData, ISagaContext, TCommand>? CommandBuilder { get; set; }
/// <summary>
/// Handler for successful response with result.
/// </summary>
public Func<TData, ISagaContext, TResult, CancellationToken, Task>? ResponseHandler { get; set; }
/// <summary>
/// Type of the compensation command.
/// </summary>
public Type? CompensationCommandType { get; set; }
/// <summary>
/// Function to build the compensation command.
/// </summary>
public Func<TData, ISagaContext, object>? CompensationBuilder { get; set; }
/// <summary>
/// The expected result type.
/// </summary>
public Type ResultType => typeof(TResult);
/// <inheritdoc />
public override bool HasCompensation => CompensationBuilder != null;
/// <inheritdoc />
public override bool IsRemote => true;
}
@@ -0,0 +1,39 @@
using System;
namespace Svrnty.CQRS.Sagas.Configuration;
/// <summary>
/// Configuration options for saga orchestration.
/// </summary>
public class SagaOptions
{
/// <summary>
/// Default timeout for saga steps (default: 30 seconds).
/// </summary>
public TimeSpan DefaultStepTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Default number of retries for failed steps (default: 3).
/// </summary>
public int DefaultMaxRetries { get; set; } = 3;
/// <summary>
/// Default delay between retries (default: 1 second).
/// </summary>
public TimeSpan DefaultRetryDelay { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Whether to automatically compensate on failure (default: true).
/// </summary>
public bool AutoCompensateOnFailure { get; set; } = true;
/// <summary>
/// Interval for checking pending/stalled sagas (default: 1 minute).
/// </summary>
public TimeSpan StalledSagaCheckInterval { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Time after which a saga step is considered stalled (default: 5 minutes).
/// </summary>
public TimeSpan StepStalledTimeout { get; set; } = TimeSpan.FromMinutes(5);
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Configuration;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Persistence;
using Svrnty.CQRS.Sagas.Configuration;
using Svrnty.CQRS.Sagas.Persistence;
namespace Svrnty.CQRS.Sagas;
/// <summary>
/// Extensions for adding saga support to the CQRS pipeline.
/// </summary>
public static class CqrsBuilderExtensions
{
/// <summary>
/// Adds saga orchestration support to the CQRS pipeline.
/// </summary>
/// <param name="builder">The CQRS builder.</param>
/// <param name="configure">Optional configuration action.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder AddSagas(this CqrsBuilder builder, Action<SagaOptions>? configure = null)
{
var options = new SagaOptions();
configure?.Invoke(options);
builder.Services.Configure<SagaOptions>(opt =>
{
opt.DefaultStepTimeout = options.DefaultStepTimeout;
opt.DefaultMaxRetries = options.DefaultMaxRetries;
opt.DefaultRetryDelay = options.DefaultRetryDelay;
opt.AutoCompensateOnFailure = options.AutoCompensateOnFailure;
opt.StalledSagaCheckInterval = options.StalledSagaCheckInterval;
opt.StepStalledTimeout = options.StepStalledTimeout;
});
// Store configuration
builder.Configuration.SetConfiguration(options);
// Register core saga services
builder.Services.TryAddSingleton<ISagaOrchestrator, SagaOrchestrator>();
// Register default in-memory state store if not already registered
builder.Services.TryAddSingleton<ISagaStateStore, InMemorySagaStateStore>();
return builder;
}
/// <summary>
/// Registers a saga type with the CQRS pipeline.
/// </summary>
/// <typeparam name="TSaga">The saga type.</typeparam>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <param name="builder">The CQRS builder.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder AddSaga<TSaga, TData>(this CqrsBuilder builder)
where TSaga : class, ISaga<TData>
where TData : class, ISagaData, new()
{
builder.Services.AddTransient<TSaga>();
builder.Services.AddTransient<ISaga<TData>, TSaga>();
return builder;
}
/// <summary>
/// Uses a custom saga state store implementation.
/// </summary>
/// <typeparam name="TStore">The state store implementation type.</typeparam>
/// <param name="builder">The CQRS builder.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder UseSagaStateStore<TStore>(this CqrsBuilder builder)
where TStore : class, ISagaStateStore
{
// Remove existing registration
var descriptor = new ServiceDescriptor(typeof(ISagaStateStore), typeof(TStore), ServiceLifetime.Singleton);
builder.Services.Replace(descriptor);
return builder;
}
}
@@ -0,0 +1,68 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Persistence;
namespace Svrnty.CQRS.Sagas.Persistence;
/// <summary>
/// In-memory saga state store for development and testing.
/// </summary>
public class InMemorySagaStateStore : ISagaStateStore
{
private readonly ConcurrentDictionary<Guid, SagaState> _states = new();
/// <inheritdoc />
public Task<SagaState> CreateAsync(SagaState state, CancellationToken cancellationToken = default)
{
if (!_states.TryAdd(state.SagaId, state))
{
throw new InvalidOperationException($"Saga with ID {state.SagaId} already exists.");
}
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<SagaState?> GetByIdAsync(Guid sagaId, CancellationToken cancellationToken = default)
{
_states.TryGetValue(sagaId, out var state);
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<SagaState?> GetByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default)
{
var state = _states.Values.FirstOrDefault(s => s.CorrelationId == correlationId);
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<SagaState> UpdateAsync(SagaState state, CancellationToken cancellationToken = default)
{
state.UpdatedAt = DateTimeOffset.UtcNow;
_states[state.SagaId] = state;
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<IReadOnlyList<SagaState>> GetPendingSagasAsync(CancellationToken cancellationToken = default)
{
var pending = _states.Values
.Where(s => s.Status == SagaStatus.InProgress || s.Status == SagaStatus.Compensating)
.ToList();
return Task.FromResult<IReadOnlyList<SagaState>>(pending);
}
/// <inheritdoc />
public Task<IReadOnlyList<SagaState>> GetSagasByStatusAsync(SagaStatus status, CancellationToken cancellationToken = default)
{
var sagas = _states.Values
.Where(s => s.Status == status)
.ToList();
return Task.FromResult<IReadOnlyList<SagaState>>(sagas);
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas;
/// <summary>
/// Implementation of saga context providing runtime information during step execution.
/// </summary>
public class SagaContext : ISagaContext
{
private readonly SagaState _state;
/// <summary>
/// Creates a new saga context from a saga state.
/// </summary>
/// <param name="state">The saga state.</param>
public SagaContext(SagaState state)
{
_state = state ?? throw new ArgumentNullException(nameof(state));
}
/// <inheritdoc />
public Guid SagaId => _state.SagaId;
/// <inheritdoc />
public Guid CorrelationId => _state.CorrelationId;
/// <inheritdoc />
public string SagaType => _state.SagaType;
/// <inheritdoc />
public int CurrentStepIndex => _state.CurrentStepIndex;
/// <inheritdoc />
public string CurrentStepName => _state.CurrentStepName ?? string.Empty;
/// <inheritdoc />
public IReadOnlyDictionary<string, object?> StepResults => _state.StepResults;
/// <inheritdoc />
public T? GetStepResult<T>(string stepName)
{
if (_state.StepResults.TryGetValue(stepName, out var value) && value is T result)
{
return result;
}
return default;
}
/// <inheritdoc />
public void SetStepResult<T>(T result)
{
_state.StepResults[CurrentStepName] = result;
}
}
+429
View File
@@ -0,0 +1,429 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
using Svrnty.CQRS.Sagas.Abstractions.Persistence;
using Svrnty.CQRS.Sagas.Builders;
using Svrnty.CQRS.Sagas.Configuration;
namespace Svrnty.CQRS.Sagas;
/// <summary>
/// Implementation of saga orchestration.
/// </summary>
public class SagaOrchestrator : ISagaOrchestrator
{
private readonly IServiceProvider _serviceProvider;
private readonly ISagaStateStore _stateStore;
private readonly ISagaMessageBus? _messageBus;
private readonly ILogger<SagaOrchestrator> _logger;
private readonly SagaOptions _options;
/// <summary>
/// Creates a new saga orchestrator.
/// </summary>
public SagaOrchestrator(
IServiceProvider serviceProvider,
ISagaStateStore stateStore,
IOptions<SagaOptions> options,
ILogger<SagaOrchestrator> logger,
ISagaMessageBus? messageBus = null)
{
_serviceProvider = serviceProvider;
_stateStore = stateStore;
_messageBus = messageBus;
_logger = logger;
_options = options.Value;
}
/// <inheritdoc />
public Task<SagaState> StartAsync<TSaga, TData>(TData initialData, CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new()
{
return StartAsync<TSaga, TData>(initialData, Guid.NewGuid(), cancellationToken);
}
/// <inheritdoc />
public async Task<SagaState> StartAsync<TSaga, TData>(
TData initialData,
Guid correlationId,
CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new()
{
initialData.CorrelationId = correlationId;
// Get the saga instance and configure it
var saga = _serviceProvider.GetRequiredService<TSaga>();
var builder = new SagaBuilder<TData>();
saga.Configure(builder);
var steps = builder.Steps;
if (steps.Count == 0)
{
throw new InvalidOperationException($"Saga {typeof(TSaga).Name} has no steps configured.");
}
// Create initial state
var state = new SagaState
{
SagaType = typeof(TSaga).FullName!,
CorrelationId = correlationId,
Status = SagaStatus.InProgress,
CurrentStepIndex = 0,
CurrentStepName = steps[0].Name,
SerializedData = JsonSerializer.Serialize(initialData)
};
state = await _stateStore.CreateAsync(state, cancellationToken);
_logger.LogInformation(
"Started saga {SagaType} with ID {SagaId} and CorrelationId {CorrelationId}",
state.SagaType, state.SagaId, state.CorrelationId);
// Execute the first step
await ExecuteNextStepAsync<TData>(state, steps, initialData, cancellationToken);
return state;
}
/// <inheritdoc />
public Task<SagaState?> GetStateAsync(Guid sagaId, CancellationToken cancellationToken = default)
{
return _stateStore.GetByIdAsync(sagaId, cancellationToken);
}
/// <inheritdoc />
public Task<SagaState?> GetStateByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default)
{
return _stateStore.GetByCorrelationIdAsync(correlationId, cancellationToken);
}
/// <summary>
/// Handles a response from a remote step.
/// </summary>
public async Task HandleResponseAsync<TData>(
SagaStepResponse response,
CancellationToken cancellationToken = default)
where TData : class, ISagaData, new()
{
var state = await _stateStore.GetByIdAsync(response.SagaId, cancellationToken);
if (state == null)
{
_logger.LogWarning("Received response for unknown saga {SagaId}", response.SagaId);
return;
}
var data = JsonSerializer.Deserialize<TData>(state.SerializedData!);
if (data == null)
{
_logger.LogError("Failed to deserialize saga data for {SagaId}", response.SagaId);
return;
}
// Get the saga definition
var sagaType = Type.GetType(state.SagaType);
if (sagaType == null)
{
_logger.LogError("Unknown saga type {SagaType}", state.SagaType);
return;
}
var saga = _serviceProvider.GetService(sagaType) as ISaga<TData>;
if (saga == null)
{
_logger.LogError("Could not resolve saga {SagaType}", state.SagaType);
return;
}
var builder = new SagaBuilder<TData>();
saga.Configure(builder);
var steps = builder.Steps;
if (response.Success)
{
_logger.LogInformation(
"Step {StepName} completed successfully for saga {SagaId}",
response.StepName, response.SagaId);
state.CompletedSteps.Add(response.StepName);
state.CurrentStepIndex++;
if (state.CurrentStepIndex >= steps.Count)
{
// Saga completed
state.Status = SagaStatus.Completed;
state.CompletedAt = DateTimeOffset.UtcNow;
await _stateStore.UpdateAsync(state, cancellationToken);
_logger.LogInformation("Saga {SagaId} completed successfully", state.SagaId);
}
else
{
// Move to next step
state.CurrentStepName = steps[state.CurrentStepIndex].Name;
await _stateStore.UpdateAsync(state, cancellationToken);
await ExecuteNextStepAsync(state, steps, data, cancellationToken);
}
}
else
{
_logger.LogError(
"Step {StepName} failed for saga {SagaId}: {Error}",
response.StepName, response.SagaId, response.ErrorMessage);
state.Errors.Add(new SagaStepError(
response.StepName,
response.ErrorMessage ?? "Unknown error",
response.StackTrace,
DateTimeOffset.UtcNow));
if (_options.AutoCompensateOnFailure)
{
await StartCompensationAsync(state, steps, data, cancellationToken);
}
else
{
state.Status = SagaStatus.Failed;
await _stateStore.UpdateAsync(state, cancellationToken);
}
}
}
private async Task ExecuteNextStepAsync<TData>(
SagaState state,
System.Collections.Generic.IReadOnlyList<SagaStepDefinition> steps,
TData data,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (state.CurrentStepIndex >= steps.Count)
{
state.Status = SagaStatus.Completed;
state.CompletedAt = DateTimeOffset.UtcNow;
await _stateStore.UpdateAsync(state, cancellationToken);
return;
}
var step = steps[state.CurrentStepIndex];
var context = new SagaContext(state);
_logger.LogDebug(
"Executing step {StepName} ({StepIndex}/{TotalSteps}) for saga {SagaId}",
step.Name, state.CurrentStepIndex + 1, steps.Count, state.SagaId);
try
{
if (step.IsRemote)
{
await ExecuteRemoteStepAsync(state, step, data, context, cancellationToken);
}
else
{
await ExecuteLocalStepAsync(state, step, data, context, steps, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing step {StepName} for saga {SagaId}", step.Name, state.SagaId);
state.Errors.Add(new SagaStepError(
step.Name,
ex.Message,
ex.StackTrace,
DateTimeOffset.UtcNow));
if (_options.AutoCompensateOnFailure)
{
await StartCompensationAsync(state, steps, data, cancellationToken);
}
else
{
state.Status = SagaStatus.Failed;
await _stateStore.UpdateAsync(state, cancellationToken);
}
}
}
private async Task ExecuteLocalStepAsync<TData>(
SagaState state,
SagaStepDefinition step,
TData data,
SagaContext context,
System.Collections.Generic.IReadOnlyList<SagaStepDefinition> steps,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (step is LocalSagaStepDefinition<TData> localStep && localStep.ExecuteAction != null)
{
await localStep.ExecuteAction(data, context, cancellationToken);
}
// Local step completed, update state and continue
state.CompletedSteps.Add(step.Name);
state.SerializedData = JsonSerializer.Serialize(data);
state.CurrentStepIndex++;
if (state.CurrentStepIndex < steps.Count)
{
state.CurrentStepName = steps[state.CurrentStepIndex].Name;
}
await _stateStore.UpdateAsync(state, cancellationToken);
// Continue to next step
await ExecuteNextStepAsync(state, steps, data, cancellationToken);
}
private async Task ExecuteRemoteStepAsync<TData>(
SagaState state,
SagaStepDefinition step,
TData data,
SagaContext context,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (_messageBus == null)
{
throw new InvalidOperationException(
"Remote saga steps require a message bus. Configure RabbitMQ or another transport.");
}
object? command = null;
string commandType;
// Get the command from the step definition
var stepType = step.GetType();
var commandBuilderProp = stepType.GetProperty("CommandBuilder");
if (commandBuilderProp?.GetValue(step) is Delegate commandBuilder)
{
command = commandBuilder.DynamicInvoke(data, context);
}
if (command == null)
{
throw new InvalidOperationException($"Step {step.Name} did not produce a command.");
}
commandType = command.GetType().FullName!;
var message = new SagaMessage
{
SagaId = state.SagaId,
CorrelationId = state.CorrelationId,
StepName = step.Name,
CommandType = commandType,
Payload = JsonSerializer.Serialize(command, command.GetType())
};
await _messageBus.PublishAsync(message, cancellationToken);
await _stateStore.UpdateAsync(state, cancellationToken);
_logger.LogDebug(
"Published command {CommandType} for step {StepName} of saga {SagaId}",
commandType, step.Name, state.SagaId);
}
private async Task StartCompensationAsync<TData>(
SagaState state,
System.Collections.Generic.IReadOnlyList<SagaStepDefinition> steps,
TData data,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
_logger.LogInformation("Starting compensation for saga {SagaId}", state.SagaId);
state.Status = SagaStatus.Compensating;
await _stateStore.UpdateAsync(state, cancellationToken);
// Execute compensation in reverse order
var context = new SagaContext(state);
var completedSteps = state.CompletedSteps.ToList();
for (var i = completedSteps.Count - 1; i >= 0; i--)
{
var stepName = completedSteps[i];
var step = steps.FirstOrDefault(s => s.Name == stepName);
if (step == null || !step.HasCompensation)
{
continue;
}
_logger.LogDebug("Compensating step {StepName} for saga {SagaId}", stepName, state.SagaId);
try
{
if (step.IsRemote)
{
await ExecuteRemoteCompensationAsync(state, step, data, context, cancellationToken);
}
else if (step is LocalSagaStepDefinition<TData> localStep && localStep.CompensateAction != null)
{
await localStep.CompensateAction(data, context, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during compensation of step {StepName} for saga {SagaId}",
stepName, state.SagaId);
// Continue with other compensations even if one fails
}
}
state.Status = SagaStatus.Compensated;
state.CompletedAt = DateTimeOffset.UtcNow;
await _stateStore.UpdateAsync(state, cancellationToken);
_logger.LogInformation("Saga {SagaId} compensation completed", state.SagaId);
}
private async Task ExecuteRemoteCompensationAsync<TData>(
SagaState state,
SagaStepDefinition step,
TData data,
SagaContext context,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (_messageBus == null)
{
return;
}
var stepType = step.GetType();
var compensationBuilderProp = stepType.GetProperty("CompensationBuilder");
var compensationTypeProp = stepType.GetProperty("CompensationCommandType");
if (compensationBuilderProp?.GetValue(step) is Delegate compensationBuilder &&
compensationTypeProp?.GetValue(step) is Type compensationType)
{
var compensationCommand = compensationBuilder.DynamicInvoke(data, context);
if (compensationCommand != null)
{
var message = new SagaMessage
{
SagaId = state.SagaId,
CorrelationId = state.CorrelationId,
StepName = step.Name,
CommandType = compensationType.FullName!,
Payload = JsonSerializer.Serialize(compensationCommand, compensationType),
IsCompensation = true
};
await _messageBus.PublishAsync(message, cancellationToken);
_logger.LogDebug(
"Published compensation command {CommandType} for step {StepName} of saga {SagaId}",
compensationType.Name, step.Name, state.SagaId);
}
}
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<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>
<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\Svrnty.CQRS.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Sagas.Abstractions\Svrnty.CQRS.Sagas.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>
+84
View File
@@ -31,6 +31,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.Sample", "Svrnty.Sam
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.DynamicQuery.MinimalApi", "Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj", "{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Sagas.Abstractions", "Svrnty.CQRS.Sagas.Abstractions\Svrnty.CQRS.Sagas.Abstractions.csproj", "{13B6608A-596B-495B-9C08-F9B3F0D1915A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Sagas", "Svrnty.CQRS.Sagas\Svrnty.CQRS.Sagas.csproj", "{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Sagas.RabbitMQ", "Svrnty.CQRS.Sagas.RabbitMQ\Svrnty.CQRS.Sagas.RabbitMQ.csproj", "{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.DynamicQuery.EntityFramework", "Svrnty.CQRS.DynamicQuery.EntityFramework\Svrnty.CQRS.DynamicQuery.EntityFramework.csproj", "{25456A0B-69AF-4251-B34D-2A3873CD8D80}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstractions", "Svrnty.CQRS.Events.Abstractions\Svrnty.CQRS.Events.Abstractions.csproj", "{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}"
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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -173,6 +185,78 @@ Global
{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x64.Build.0 = Release|Any CPU
{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x86.ActiveCfg = Release|Any CPU
{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x86.Build.0 = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x64.ActiveCfg = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x64.Build.0 = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x86.ActiveCfg = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x86.Build.0 = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|Any CPU.Build.0 = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x64.ActiveCfg = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x64.Build.0 = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x86.ActiveCfg = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x86.Build.0 = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x64.ActiveCfg = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x64.Build.0 = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x86.ActiveCfg = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x86.Build.0 = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|Any CPU.Build.0 = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x64.ActiveCfg = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x64.Build.0 = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x86.ActiveCfg = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x86.Build.0 = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x64.ActiveCfg = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x64.Build.0 = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x86.ActiveCfg = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x86.Build.0 = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|Any CPU.Build.0 = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x64.ActiveCfg = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x64.Build.0 = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x86.ActiveCfg = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x86.Build.0 = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x64.ActiveCfg = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x64.Build.0 = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x86.ActiveCfg = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x86.Build.0 = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|Any CPU.Build.0 = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x64.ActiveCfg = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x64.Build.0 = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x86.ActiveCfg = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x86.Build.0 = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x64.ActiveCfg = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x64.Build.0 = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x86.ActiveCfg = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x86.Build.0 = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|Any CPU.Build.0 = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x64.ActiveCfg = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x64.Build.0 = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x86.ActiveCfg = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x86.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x64.ActiveCfg = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x64.Build.0 = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x86.ActiveCfg = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x86.Build.0 = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|Any CPU.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.ActiveCfg = Release|Any CPU
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+3 -2
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
+1 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
+4
View File
@@ -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>
@@ -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
{
+3 -3
View File
@@ -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);

Some files were not shown because too many files have changed in this diff Show More