Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6e67986fa | |||
| 2c5059d947 | |||
| 5c7736db98 | |||
| 41eb5b97cb | |||
| c7d9228a88 | |||
| 313b8c83ea | |||
| 3fa59306c2 | |||
| 697b36900b | |||
| 7ef3e56759 | |||
| acde9ec22a | |||
| 2ff8eae75c | |||
| 7e12f73160 | |||
| 16ca6f722b | |||
| 1f12cc8c59 | |||
| 7ead822067 | |||
| 346c4ac77c | |||
| 5f3602d071 | |||
| 92231df745 | |||
| 5a35e23234 | |||
| 148a9573e0 | |||
| 9ed9400e4d | |||
| 3df094b9e7 | |||
|
6aece5a769
|
|||
|
b372805c4e
|
|||
|
89ccbe990f
|
|||
|
433b852a43
|
|||
|
03041721ca
|
|||
|
05449b9a28
|
|||
|
dfbef9d161
|
|||
|
377977b080
|
|||
|
20147bfec7
|
|||
|
18f81a28e8
|
|||
|
201768e716
|
|||
|
932ee6e632
|
|||
| 4bf03446c0 | |||
|
227be70f95
|
|||
|
bd43bc9bde
|
|||
|
661f5b4b1c
|
|||
|
99aebcf314
|
|||
| f76dbb1a97 | |||
| 9b9e2cbdbe | |||
| 4051800934 | |||
| a312428093 | |||
|
dea62c2434
|
|||
| e72cbe4319 | |||
| 467e700885 |
@@ -40,7 +40,9 @@
|
||||
"WebFetch(domain:stackoverflow.com)",
|
||||
"WebFetch(domain:www.kenmuse.com)",
|
||||
"WebFetch(domain:blog.rsuter.com)",
|
||||
"WebFetch(domain:natemcmaster.com)"
|
||||
"WebFetch(domain:natemcmaster.com)",
|
||||
"WebFetch(domain:www.nuget.org)",
|
||||
"Bash(mkdir:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
# Top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# All files
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
# XML project files
|
||||
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
|
||||
indent_size = 2
|
||||
|
||||
# XML config files
|
||||
[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
|
||||
indent_size = 2
|
||||
|
||||
# JSON and YAML files
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
# Markdown files
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Proto files
|
||||
[*.proto]
|
||||
indent_size = 2
|
||||
|
||||
# Solution files
|
||||
[*.sln]
|
||||
indent_style = tab
|
||||
|
||||
# C# files
|
||||
[*.cs]
|
||||
|
||||
#### Core EditorConfig Options ####
|
||||
|
||||
# Indentation and spacing
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
tab_width = 4
|
||||
|
||||
#### .NET Coding Conventions ####
|
||||
|
||||
# Organise usings
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
|
||||
# this. and Me. preferences
|
||||
dotnet_style_qualification_for_field = false:suggestion
|
||||
dotnet_style_qualification_for_property = false:suggestion
|
||||
dotnet_style_qualification_for_method = false:suggestion
|
||||
dotnet_style_qualification_for_event = false:suggestion
|
||||
|
||||
# Language keywords vs BCL types preferences
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
|
||||
dotnet_style_predefined_type_for_member_access = true:suggestion
|
||||
|
||||
# Parentheses preferences
|
||||
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
|
||||
|
||||
# Modifier preferences
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
|
||||
|
||||
# Expression-level preferences
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_collection_initializer = true:suggestion
|
||||
dotnet_style_explicit_tuple_names = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
dotnet_style_object_initializer = true:suggestion
|
||||
dotnet_style_prefer_auto_properties = true:suggestion
|
||||
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
|
||||
dotnet_style_prefer_conditional_expression_over_return = true:silent
|
||||
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
||||
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
||||
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
||||
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
|
||||
dotnet_style_prefer_simplified_interpolation = true:suggestion
|
||||
|
||||
# Field preferences
|
||||
dotnet_style_readonly_field = true:suggestion
|
||||
|
||||
# Parameter preferences
|
||||
dotnet_code_quality_unused_parameters = all:suggestion
|
||||
|
||||
#### C# Coding Conventions ####
|
||||
|
||||
# var preferences
|
||||
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-bodied members
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:silent
|
||||
csharp_style_expression_bodied_constructors = false:silent
|
||||
csharp_style_expression_bodied_operators = when_on_single_line:silent
|
||||
csharp_style_expression_bodied_properties = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_indexers = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_accessors = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_lambdas = when_on_single_line:silent
|
||||
csharp_style_expression_bodied_local_functions = when_on_single_line:silent
|
||||
|
||||
# Pattern matching preferences
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||
csharp_style_prefer_not_pattern = true:suggestion
|
||||
csharp_style_prefer_pattern_matching = true:silent
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
|
||||
# Null-checking preferences
|
||||
csharp_style_conditional_delegate_call = true:suggestion
|
||||
csharp_style_prefer_null_check_over_type_check = true:suggestion
|
||||
|
||||
# Code-block preferences
|
||||
csharp_prefer_braces = true:silent
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
csharp_style_prefer_method_group_conversion = true:silent
|
||||
|
||||
# Expression-level preferences
|
||||
csharp_prefer_simple_default_expression = true:suggestion
|
||||
csharp_style_prefer_local_over_anonymous_function = true:suggestion
|
||||
csharp_style_prefer_index_operator = true:suggestion
|
||||
csharp_style_prefer_range_operator = true:suggestion
|
||||
csharp_style_prefer_tuple_swap = true:suggestion
|
||||
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
|
||||
csharp_style_inlined_variable_declaration = true:suggestion
|
||||
csharp_style_deconstructed_variable_declaration = true:suggestion
|
||||
csharp_style_throw_expression = true:suggestion
|
||||
|
||||
# 'using' directive preferences
|
||||
csharp_using_directive_placement = outside_namespace:suggestion
|
||||
|
||||
# Namespace preferences
|
||||
csharp_style_namespace_declarations = file_scoped:suggestion
|
||||
|
||||
#### C# Formatting Rules ####
|
||||
|
||||
# New line preferences
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
csharp_new_line_before_members_in_object_initializers = true
|
||||
csharp_new_line_before_members_in_anonymous_types = true
|
||||
csharp_new_line_between_query_expression_clauses = true
|
||||
|
||||
# Indentation preferences
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_switch_labels = true
|
||||
csharp_indent_labels = one_less_than_current
|
||||
csharp_indent_block_contents = true
|
||||
csharp_indent_braces = false
|
||||
csharp_indent_case_contents_when_block = true
|
||||
|
||||
# Space preferences
|
||||
csharp_space_after_cast = false
|
||||
csharp_space_after_keywords_in_control_flow_statements = true
|
||||
csharp_space_between_parentheses = false
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_name_and_open_parenthesis = false
|
||||
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_name_and_opening_parenthesis = false
|
||||
csharp_space_after_comma = true
|
||||
csharp_space_before_comma = false
|
||||
csharp_space_after_dot = false
|
||||
csharp_space_before_dot = false
|
||||
csharp_space_after_semicolon_in_for_statement = true
|
||||
csharp_space_before_semicolon_in_for_statement = false
|
||||
csharp_space_around_declaration_statements = false
|
||||
csharp_space_before_open_square_brackets = false
|
||||
csharp_space_between_empty_square_brackets = false
|
||||
csharp_space_between_square_brackets = false
|
||||
|
||||
# Wrapping preferences
|
||||
csharp_preserve_single_line_statements = false
|
||||
csharp_preserve_single_line_blocks = true
|
||||
|
||||
#### Naming Conventions ####
|
||||
|
||||
# Naming rules
|
||||
|
||||
dotnet_naming_rule.interface_should_begin_with_i.severity = suggestion
|
||||
dotnet_naming_rule.interface_should_begin_with_i.symbols = interface
|
||||
dotnet_naming_rule.interface_should_begin_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
|
||||
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with_underscore.severity = suggestion
|
||||
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with_underscore.symbols = private_or_internal_field
|
||||
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with_underscore.style = camel_case_with_underscore
|
||||
|
||||
dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields
|
||||
dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case
|
||||
|
||||
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_rule.type_parameters_should_begin_with_t.severity = suggestion
|
||||
dotnet_naming_rule.type_parameters_should_begin_with_t.symbols = type_parameters
|
||||
dotnet_naming_rule.type_parameters_should_begin_with_t.style = begins_with_t
|
||||
|
||||
# Symbol specifications
|
||||
|
||||
dotnet_naming_symbols.interface.applicable_kinds = interface
|
||||
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
|
||||
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
|
||||
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
|
||||
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
|
||||
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = private, internal, private_protected
|
||||
|
||||
dotnet_naming_symbols.constant_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.constant_fields.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.constant_fields.required_modifiers = const
|
||||
|
||||
dotnet_naming_symbols.async_methods.applicable_kinds = method
|
||||
dotnet_naming_symbols.async_methods.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.async_methods.required_modifiers = async
|
||||
|
||||
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
|
||||
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
|
||||
|
||||
# Naming styles
|
||||
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.camel_case_with_underscore.required_prefix = _
|
||||
dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case
|
||||
|
||||
dotnet_naming_style.ends_with_async.required_suffix = Async
|
||||
dotnet_naming_style.ends_with_async.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.begins_with_t.required_prefix = T
|
||||
dotnet_naming_style.begins_with_t.capitalization = pascal_case
|
||||
@@ -0,0 +1,9 @@
|
||||
# dotnet-cqrs Environment Configuration
|
||||
# Copy to .env and fill in values before running
|
||||
|
||||
# NuGet publishing (required for dotnet pack + push)
|
||||
NUGET_API_KEY=
|
||||
|
||||
# Application URLs (for Svrnty.Sample project)
|
||||
ASPNETCORE_URLS=http://localhost:19898
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
@@ -0,0 +1,35 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: JP
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
groups:
|
||||
nuget-all:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
target-branch: JP
|
||||
open-pull-requests-limit: 1
|
||||
labels:
|
||||
- "ci"
|
||||
- "dependencies"
|
||||
groups:
|
||||
actions-all:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
@@ -0,0 +1,37 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [JP]
|
||||
pull_request:
|
||||
branches: [JP]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore Svrnty.CQRS.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build Svrnty.CQRS.sln --no-restore --warnaserror
|
||||
|
||||
- name: Test
|
||||
run: dotnet test Svrnty.CQRS.sln --no-build --verbosity normal
|
||||
|
||||
- name: Format check
|
||||
run: dotnet format Svrnty.CQRS.sln --verify-no-changes
|
||||
@@ -0,0 +1,47 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [JP]
|
||||
pull_request:
|
||||
branches: [JP]
|
||||
schedule:
|
||||
- cron: "0 8 * * 1" # Weekly on Monday at 08:00 UTC
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: CodeQL Analysis
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [csharp]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Build
|
||||
run: dotnet build Svrnty.CQRS.sln
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (e.g. v1.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Validate, Build, Pack & Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
else
|
||||
TAG="${{ inputs.tag }}"
|
||||
fi
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Tag must match semver format (vX.Y.Z[-suffix]): got ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore Svrnty.CQRS.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build Svrnty.CQRS.sln --no-restore --configuration Release --warnaserror
|
||||
|
||||
- name: Test
|
||||
run: dotnet test Svrnty.CQRS.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Format check
|
||||
run: dotnet format Svrnty.CQRS.sln --verify-no-changes
|
||||
|
||||
- name: Pack NuGet packages
|
||||
run: |
|
||||
dotnet pack Svrnty.CQRS.sln \
|
||||
--no-build \
|
||||
--configuration Release \
|
||||
--output ./artifacts \
|
||||
-p:Version=${{ steps.tag.outputs.version }}
|
||||
|
||||
- name: Upload NuGet artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nuget-packages
|
||||
path: ./artifacts/*.nupkg
|
||||
retention-days: 30
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.tag.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/*.nupkg
|
||||
artifacts/*.snupkg
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [JP]
|
||||
pull_request:
|
||||
branches: [JP]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly on Monday at 06:00 UTC
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
vulnerability-scan:
|
||||
name: .NET vulnerability scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Check for vulnerable packages
|
||||
run: dotnet list package --vulnerable --include-transitive
|
||||
+11
-1
@@ -4,6 +4,7 @@
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
.research/
|
||||
.DS_Store
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
@@ -339,4 +340,13 @@ ASALocalRun/
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
healthchecksdb
|
||||
|
||||
# Secrets and credentials
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
*.key
|
||||
secrets/
|
||||
.aws/
|
||||
credentials.json
|
||||
@@ -0,0 +1,14 @@
|
||||
name: dotnet-cqrs
|
||||
description: Modern CQRS framework for .NET with gRPC source generation and HTTP Minimal API support
|
||||
owner: mathias@svrnty.io
|
||||
layer: L3
|
||||
stack: C# 14/.NET 10
|
||||
status: stable
|
||||
dependencies: []
|
||||
dependents:
|
||||
- flutter_cqrs_datasource
|
||||
- a-gent-app
|
||||
entry_points:
|
||||
readme: README.md
|
||||
registry: null
|
||||
schemas: Svrnty.CQRS.sln
|
||||
@@ -0,0 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- `.library-manifest.yaml` for cross-repo discovery and dependency tracking
|
||||
- Initial project setup
|
||||
- `docs/ARCHITECTURE.md` -- package dependency graph, CQRS data flows, saga flow, separation of concerns
|
||||
- `docs/PACKAGE_INDEX.md` -- per-package reference for all 18 NuGet packages with key types and dependencies
|
||||
- `docs/GETTING_STARTED.md` -- step-by-step guide covering handler registration, gRPC setup, MinimalApi, DynamicQuery, domain events, sagas, and notifications
|
||||
|
||||
### Changed
|
||||
- Updated README.md to reflect correct package count (18), added links to new docs, added Related Libraries section linking to flutter_cqrs_datasource
|
||||
|
||||
### Fixed
|
||||
|
||||
### Removed
|
||||
@@ -1,413 +1,47 @@
|
||||
# CLAUDE.md
|
||||
# Development Guidelines
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
> **Source of truth**: All engineering principles, commit rules, documentation standards, and governance policies are defined in the [root CLAUDE.md](../CLAUDE.md). This file contains repo-specific notes only.
|
||||
|
||||
## Project Overview
|
||||
## Quick Reference
|
||||
|
||||
This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Segregation (CQRS) for .NET 10. It was forked from PoweredSoft.CQRS and provides:
|
||||
- **Branch**: `JP` for active development
|
||||
- **Commit format**: `type(scope): message`
|
||||
- **Co-Author**: `Co-Authored-By: Svrnty Inc. <jp@svrnty.io>`
|
||||
- **Hooks**: `lefthook install` — enforces author, secrets, doc hygiene
|
||||
- **Docs required**: README.md, CHANGELOG.md, LICENSE, CONTRIBUTING.md, SECURITY.md
|
||||
|
||||
- CQRS pattern implementation with command/query handlers exposed via HTTP or gRPC
|
||||
- Automatic HTTP endpoint generation via Minimal API
|
||||
- Automatic gRPC endpoint generation with source generators and Google Rich Error Model validation
|
||||
- Dynamic query capabilities (filtering, sorting, grouping, aggregation)
|
||||
- FluentValidation support with RFC 7807 Problem Details (HTTP) and Google Rich Error Model (gRPC)
|
||||
- AOT (Ahead-of-Time) compilation compatibility for core packages (where dependencies allow)
|
||||
## Tech Stack
|
||||
|
||||
## Solution Structure
|
||||
| Tool | Version |
|
||||
|------|---------|
|
||||
| C# | 14 |
|
||||
| .NET | 10.0 |
|
||||
| AOT | enabled (IsAotCompatible=true) |
|
||||
| Nullable | enabled |
|
||||
|
||||
The solution contains 11 projects organized by responsibility (10 packages + 1 sample project):
|
||||
## Commands
|
||||
|
||||
**Abstractions (interfaces and contracts only):**
|
||||
- `Svrnty.CQRS.Abstractions` - Core interfaces (ICommandHandler, IQueryHandler, discovery contracts)
|
||||
- `Svrnty.CQRS.DynamicQuery.Abstractions` - Dynamic query interfaces (multi-targets netstandard2.1 and net10.0)
|
||||
- `Svrnty.CQRS.Grpc.Abstractions` - gRPC-specific interfaces and contracts
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `dotnet build` | Build all 18 projects |
|
||||
| `dotnet test` | Run tests |
|
||||
| `dotnet format` | Format code |
|
||||
|
||||
**Implementation:**
|
||||
- `Svrnty.CQRS` - Core discovery and registration logic
|
||||
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries (recommended for HTTP)
|
||||
- `Svrnty.CQRS.DynamicQuery` - PoweredSoft.DynamicQuery integration for advanced filtering
|
||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi` - Minimal API endpoint mapping for dynamic queries
|
||||
- `Svrnty.CQRS.FluentValidation` - Validation integration helpers
|
||||
- `Svrnty.CQRS.Grpc` - gRPC service implementation support
|
||||
- `Svrnty.CQRS.Grpc.Generators` - Source generator for .proto files and gRPC service implementations
|
||||
## Key Dependencies
|
||||
|
||||
**Sample Projects:**
|
||||
- `Svrnty.Sample` - Comprehensive demo project showcasing both HTTP and gRPC endpoints
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| Svrnty.CQRS.Core | Core CQRS abstractions |
|
||||
| Svrnty.CQRS.DynamicQuery | Dynamic query support |
|
||||
| Svrnty.CQRS.gRPC | gRPC transport |
|
||||
| Svrnty.CQRS.Events | Event sourcing |
|
||||
| Svrnty.CQRS.Sagas | Saga orchestration |
|
||||
| Svrnty.CQRS.Notifications | Notification handlers |
|
||||
| Svrnty.CQRS.MinimalApi | Minimal API bindings |
|
||||
|
||||
**Key Design Principle:** Abstractions projects contain ONLY interfaces/attributes with minimal dependencies. Implementation projects depend on abstractions. This allows consumers to reference abstractions without pulling in heavy implementation dependencies.
|
||||
## Repo-Specific Notes
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Restore dependencies
|
||||
dotnet restore
|
||||
|
||||
# Build entire solution
|
||||
dotnet build
|
||||
|
||||
# Build in Release mode
|
||||
dotnet build -c Release
|
||||
|
||||
# Create NuGet packages (with version)
|
||||
dotnet pack -c Release -o ./artifacts -p:Version=1.0.0
|
||||
|
||||
# Build specific project
|
||||
dotnet build Svrnty.CQRS/Svrnty.CQRS.csproj
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
This repository does not currently contain test projects. When adding tests:
|
||||
- Place them in a `tests/` directory or alongside source projects
|
||||
- Name them with `.Tests` suffix (e.g., `Svrnty.CQRS.Tests`)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core CQRS Pattern
|
||||
|
||||
The framework uses handler interfaces that follow this pattern:
|
||||
|
||||
```csharp
|
||||
// Command with no result
|
||||
ICommandHandler<TCommand>
|
||||
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default)
|
||||
|
||||
// Command with result
|
||||
ICommandHandler<TCommand, TResult>
|
||||
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default)
|
||||
|
||||
// Query (always returns result)
|
||||
IQueryHandler<TQuery, TResult>
|
||||
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default)
|
||||
```
|
||||
|
||||
### Metadata-Driven Discovery
|
||||
|
||||
The framework uses a **metadata pattern** for runtime discovery:
|
||||
|
||||
1. When you register a handler using `services.AddCommand<TCommand, THandler>()`, it:
|
||||
- Registers the handler in DI as `ICommandHandler<TCommand, THandler>`
|
||||
- Creates metadata (`ICommandMeta`) describing the command type, handler type, and result type
|
||||
- Stores metadata as singleton in DI
|
||||
|
||||
2. Discovery services (`ICommandDiscovery`, `IQueryDiscovery`) implemented in `Svrnty.CQRS`:
|
||||
- Query all registered metadata from DI container
|
||||
- Provide lookup methods: `GetCommand(string name)`, `GetCommands()`, etc.
|
||||
|
||||
3. Endpoint mapping (HTTP and gRPC) uses discovery to:
|
||||
- Enumerate all registered commands/queries
|
||||
- Dynamically generate endpoints at application startup
|
||||
- Apply naming conventions (convert to lowerCamelCase)
|
||||
- Generate gRPC service implementations via source generators
|
||||
|
||||
**Key Files:**
|
||||
- `Svrnty.CQRS.Abstractions/Discovery/` - Metadata interfaces
|
||||
- `Svrnty.CQRS/Discovery/` - Discovery implementations
|
||||
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint generation
|
||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - Dynamic query endpoint generation
|
||||
- `Svrnty.CQRS.Grpc.Generators/` - gRPC service generation via source generators
|
||||
|
||||
### Integration Options
|
||||
|
||||
There are two primary integration options for exposing commands and queries:
|
||||
|
||||
#### Option 1: gRPC (Recommended for performance-critical scenarios)
|
||||
|
||||
The **Svrnty.CQRS.Grpc** package with **Svrnty.CQRS.Grpc.Generators** source generator provides high-performance gRPC endpoints:
|
||||
|
||||
**Registration:**
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register CQRS services
|
||||
builder.Services.AddSvrntyCQRS();
|
||||
builder.Services.AddDefaultCommandDiscovery();
|
||||
builder.Services.AddDefaultQueryDiscovery();
|
||||
|
||||
// Add your commands and queries
|
||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
|
||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||
|
||||
// Add gRPC support
|
||||
builder.Services.AddGrpc();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map auto-generated gRPC service implementations
|
||||
app.MapGrpcService<CommandServiceImpl>();
|
||||
app.MapGrpcService<QueryServiceImpl>();
|
||||
|
||||
// Enable gRPC reflection for tools like grpcurl
|
||||
app.MapGrpcReflectionService();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
1. Define `.proto` files in `Protos/` directory with your commands/queries as messages
|
||||
2. Source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
|
||||
3. Property names in C# commands must match proto field names (case-insensitive)
|
||||
4. FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
|
||||
5. Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
|
||||
|
||||
**Features:**
|
||||
- High-performance binary protocol
|
||||
- Automatic service implementation generation at compile time
|
||||
- Google Rich Error Model for structured validation errors
|
||||
- Full FluentValidation integration
|
||||
- gRPC reflection support for development tools
|
||||
- Suitable for microservices, internal APIs, and low-latency scenarios
|
||||
|
||||
**Key Files:**
|
||||
- `Svrnty.CQRS.Grpc/` - Runtime support for gRPC services
|
||||
- `Svrnty.CQRS.Grpc.Generators/` - Source generator for service implementations
|
||||
|
||||
#### Option 2: HTTP via Minimal API (Recommended for web/browser scenarios)
|
||||
|
||||
The **Svrnty.CQRS.MinimalApi** package provides HTTP endpoints for CQRS commands and queries:
|
||||
|
||||
**Registration:**
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register CQRS services
|
||||
builder.Services.AddSvrntyCQRS();
|
||||
builder.Services.AddDefaultCommandDiscovery();
|
||||
builder.Services.AddDefaultQueryDiscovery();
|
||||
|
||||
// Add your commands and queries
|
||||
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
|
||||
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
|
||||
|
||||
// Add Swagger (optional)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map endpoints (this creates routes automatically)
|
||||
app.MapSvrntyCommands(); // Maps all commands to POST /api/command/{name}
|
||||
app.MapSvrntyQueries(); // Maps all queries to POST/GET /api/query/{name}
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
1. Extension methods iterate through `ICommandDiscovery` and `IQueryDiscovery`
|
||||
2. For each command/query, creates Minimal API endpoints using `MapPost()`/`MapGet()`
|
||||
3. Applies naming conventions (lowerCamelCase)
|
||||
4. Respects `[CommandControllerIgnore]` and `[QueryControllerIgnore]` attributes
|
||||
5. Integrates with `ICommandAuthorizationService` and `IQueryAuthorizationService`
|
||||
6. Supports OpenAPI/Swagger documentation
|
||||
|
||||
**Features:**
|
||||
- Queries support both POST (with JSON body) and GET (with query string parameters)
|
||||
- Commands only support POST with JSON body
|
||||
- Authorization via authorization services (returns 401/403 status codes)
|
||||
- Customizable route prefixes: `MapSvrntyCommands("my-prefix")`
|
||||
- Automatic OpenAPI tags: "Commands" and "Queries"
|
||||
- RFC 7807 Problem Details for validation errors
|
||||
- Full Swagger/OpenAPI support
|
||||
|
||||
**Key Files:**
|
||||
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - Main implementation
|
||||
|
||||
#### Option 3: Both gRPC and HTTP (Dual Protocol Support)
|
||||
|
||||
You can enable both protocols simultaneously, allowing clients to choose their preferred protocol:
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register CQRS services
|
||||
builder.Services.AddSvrntyCQRS();
|
||||
builder.Services.AddDefaultCommandDiscovery();
|
||||
builder.Services.AddDefaultQueryDiscovery();
|
||||
|
||||
// Add commands and queries
|
||||
AddCommands(builder.Services);
|
||||
AddQueries(builder.Services);
|
||||
|
||||
// Add both gRPC and HTTP support
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map both gRPC and HTTP endpoints
|
||||
app.MapGrpcService<CommandServiceImpl>();
|
||||
app.MapGrpcService<QueryServiceImpl>();
|
||||
app.MapGrpcReflectionService();
|
||||
|
||||
app.MapSvrntyCommands();
|
||||
app.MapSvrntyQueries();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single codebase supports multiple protocols
|
||||
- gRPC for high-performance, low-latency scenarios (microservices, internal APIs)
|
||||
- HTTP for web browsers, legacy clients, and public APIs
|
||||
- Same commands, queries, and validation logic for both protocols
|
||||
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
|
||||
|
||||
### Dynamic Query System
|
||||
|
||||
Dynamic queries provide OData-like filtering capabilities:
|
||||
|
||||
**Core Components:**
|
||||
- `IDynamicQuery<TSource, TDestination>` - Interface with GetFilters(), GetSorts(), GetGroups(), GetAggregates()
|
||||
- `IQueryableProvider<TSource>` - Provides base IQueryable to query against
|
||||
- `IAlterQueryableService<TSource, TDestination>` - Middleware to modify queries (e.g., security filters)
|
||||
- `DynamicQueryHandler<TSource, TDestination>` - Executes queries using PoweredSoft.DynamicQuery
|
||||
|
||||
**Request Flow:**
|
||||
1. HTTP request with filters/sorts/aggregates
|
||||
2. Minimal API endpoint receives request
|
||||
3. DynamicQueryHandler gets base queryable from IQueryableProvider
|
||||
4. Applies alterations from all registered IAlterQueryableService instances
|
||||
5. Builds PoweredSoft query criteria
|
||||
6. Executes and returns IQueryExecutionResult
|
||||
|
||||
**Registration Example:**
|
||||
```csharp
|
||||
// Register dynamic query
|
||||
services.AddDynamicQuery<Person, PersonDto>()
|
||||
.AddDynamicQueryWithProvider<Person, PersonQueryableProvider>()
|
||||
.AddAlterQueryable<Person, PersonDto, SecurityFilter>();
|
||||
|
||||
// Map dynamic query endpoints
|
||||
app.MapSvrntyDynamicQueries(); // Creates POST/GET /api/query/{queryName} endpoints
|
||||
```
|
||||
|
||||
**Key Files:**
|
||||
- `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs` - Query execution logic
|
||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint mapping
|
||||
|
||||
## Package Configuration
|
||||
|
||||
All projects target .NET 10.0 and use C# 14, sharing common configuration:
|
||||
|
||||
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions which multi-targets `netstandard2.1;net10.0`)
|
||||
- **Language Version**: C# 14
|
||||
- **IsAotCompatible**: Currently set but not enforced (many dependencies are not AOT-compatible yet)
|
||||
- **Symbols**: Portable debug symbols with source, published as `.snupkg`
|
||||
- **NuGet metadata**: Icon, README, license (MIT), and repository URL included in packages
|
||||
- **Authors**: David Lebee, Mathias Beaulieu-Duncan
|
||||
- **Repository**: https://git.openharbor.io/svrnty/dotnet-cqrs
|
||||
|
||||
### Package Dependencies
|
||||
|
||||
**Core Dependencies:**
|
||||
- **Microsoft.Extensions.DependencyInjection.Abstractions**: 10.0.0-rc.2.25502.107 (will update to stable when .NET 10 is released)
|
||||
- **FluentValidation**: 11.11.0
|
||||
- **PoweredSoft.DynamicQuery**: 3.0.1
|
||||
- **Pluralize.NET**: 1.0.2
|
||||
|
||||
**gRPC Dependencies (for Svrnty.CQRS.Grpc):**
|
||||
- **Grpc.AspNetCore**: 2.68.0 or later
|
||||
- **Grpc.AspNetCore.Server.Reflection**: 2.71.0 or later (optional, for reflection)
|
||||
- **Grpc.StatusProto**: 2.71.0 or later (for Rich Error Model validation)
|
||||
- **Grpc.Tools**: 2.76.0 or later (for .proto compilation)
|
||||
|
||||
**Source Generator Dependencies (for Svrnty.CQRS.Grpc.Generators):**
|
||||
- **Microsoft.CodeAnalysis.CSharp**: 5.0.0-2.final
|
||||
- **Microsoft.CodeAnalysis.Analyzers**: 3.11.0
|
||||
- **Microsoft.Build.Utilities.Core**: 17.0.0
|
||||
- Targets: netstandard2.0 (for Roslyn compatibility)
|
||||
|
||||
## Publishing
|
||||
|
||||
NuGet packages are published automatically via GitHub Actions when a release is created:
|
||||
|
||||
**Workflow:** `.github/workflows/publish-nugets.yml`
|
||||
1. Triggered on release publication
|
||||
2. Extracts version from release tag
|
||||
3. Runs `dotnet pack -c Release -p:Version={tag}`
|
||||
4. Pushes to NuGet.org using `NUGET_API_KEY` secret
|
||||
|
||||
**Manual publish:**
|
||||
```bash
|
||||
# Create packages with specific version
|
||||
dotnet pack -c Release -o ./artifacts -p:Version=1.2.3
|
||||
|
||||
# Push to NuGet
|
||||
dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key YOUR_KEY
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
**Adding a New Command/Query Handler:**
|
||||
|
||||
1. Create command/query POCO in consumer project
|
||||
2. Implement handler: `ICommandHandler<TCommand, TResult>`
|
||||
3. Register in DI: `services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>()`
|
||||
4. (Optional) Add validator: `services.AddTransient<IValidator<CreatePersonCommand>, Validator>()`
|
||||
5. Controller endpoint is automatically generated
|
||||
|
||||
**Adding a New Feature to Framework:**
|
||||
|
||||
1. Add interface to appropriate Abstractions project
|
||||
2. Implement in corresponding implementation project
|
||||
3. Update ServiceCollectionExtensions with registration method
|
||||
4. Ensure all projects maintain AOT compatibility (unless AspNetCore-specific)
|
||||
5. Update package version and release notes
|
||||
|
||||
**Naming Conventions:**
|
||||
|
||||
- Commands/Queries: Use `[CommandName]` or `[QueryName]` attribute for custom names
|
||||
- Default naming: Strips "Command"/"Query" suffix, converts to lowerCamelCase
|
||||
- Example: `CreatePersonCommand` -> `createPerson` endpoint
|
||||
|
||||
## C# 14 Language Features
|
||||
|
||||
The project now uses C# 14, which introduces several new features. Be aware of these breaking changes:
|
||||
|
||||
**Potential Breaking Changes:**
|
||||
- **`field` keyword**: New contextual keyword in property accessors for implicit backing fields
|
||||
- **`extension` keyword**: Reserved for extension containers; use `@extension` for identifiers
|
||||
- **`partial` return type**: Cannot use `partial` as return type without escaping
|
||||
- **Span<T> overload resolution**: New implicit conversions may select different overloads
|
||||
- **`scoped` as lambda modifier**: Always treated as modifier in lambda parameters
|
||||
|
||||
**New Features Available:**
|
||||
- Extension members (static extension members and extension properties)
|
||||
- Implicit span conversions
|
||||
- Unbound generic types with `nameof`
|
||||
- Lambda parameter modifiers without type specification
|
||||
- Partial instance constructors and events
|
||||
- Null-conditional assignment (`?.=` and `?[]=`)
|
||||
|
||||
The codebase currently compiles without warnings on C# 14.
|
||||
|
||||
## Important Implementation Notes
|
||||
|
||||
1. **AOT Compatibility**: Currently not enforced. The `IsAotCompatible` property is set on some projects but many dependencies (including FluentValidation, PoweredSoft.DynamicQuery) are not AOT-compatible. Future work may address this.
|
||||
|
||||
2. **Async Everywhere**: All handlers are async. Always support CancellationToken.
|
||||
|
||||
3. **Generic Type Safety**: Framework relies heavily on generics for compile-time safety. When adding features, maintain strong typing.
|
||||
|
||||
4. **Metadata Pattern**: When extending discovery, always create corresponding metadata classes (implement ICommandMeta/IQueryMeta).
|
||||
|
||||
5. **Endpoint Mapping Timing**: Endpoints are mapped at application startup. Discovery services must be registered before calling `MapSvrntyCommands()`/`MapSvrntyQueries()` or mapping gRPC services.
|
||||
|
||||
6. **FluentValidation Integration**:
|
||||
- For HTTP: Validation happens automatically in the Minimal API pipeline. Errors return RFC 7807 Problem Details.
|
||||
- For gRPC: Validation happens automatically via source-generated services. Errors return Google Rich Error Model with structured FieldViolations.
|
||||
- The framework REGISTERS validators in DI; actual validation execution is handled by the endpoint implementations.
|
||||
|
||||
7. **DynamicQuery Interceptors**: Support up to 5 interceptors per query type. Interceptors modify PoweredSoft DynamicQuery behavior.
|
||||
|
||||
## Common Code Locations
|
||||
|
||||
- Handler interfaces: `Svrnty.CQRS.Abstractions/ICommandHandler.cs`, `IQueryHandler.cs`
|
||||
- Discovery implementations: `Svrnty.CQRS/Discovery/`
|
||||
- Service registration: `*/ServiceCollectionExtensions.cs` in each project
|
||||
- HTTP endpoint mapping: `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs`
|
||||
- Dynamic query logic: `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs`
|
||||
- Dynamic query endpoints: `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs`
|
||||
- gRPC support: `Svrnty.CQRS.Grpc/` runtime, `Svrnty.CQRS.Grpc.Generators/` source generators
|
||||
- Sample application: `Svrnty.Sample/` - demonstrates both HTTP and gRPC integration
|
||||
- Solution file: `Svrnty.CQRS.sln` with 18 projects.
|
||||
- Lint is handled by .NET analyzers — AOT compatibility and nullable reference types are enforced.
|
||||
- No Docker or proto files in this repo.
|
||||
- Published under the `svrnty` org (git.openharbor.io/svrnty), not `a-gent`.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing to this project.
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
See [CLAUDE.md](./CLAUDE.md) for development practices, engineering principles, and coding standards.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Fork & Clone**
|
||||
```bash
|
||||
git clone <your-fork-url>
|
||||
cd <project>
|
||||
git checkout JP
|
||||
```
|
||||
|
||||
2. **Create a Branch**
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
3. **Make Changes**
|
||||
- Follow the guidelines in CLAUDE.md
|
||||
- Keep changes focused and minimal
|
||||
- Write tests if applicable
|
||||
|
||||
4. **Validate**
|
||||
- Run format checks
|
||||
- Run lint checks
|
||||
- Run test suite
|
||||
|
||||
5. **Commit**
|
||||
```bash
|
||||
git commit -m "feat: your change description"
|
||||
```
|
||||
|
||||
AI-authored commits must include:
|
||||
```
|
||||
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
|
||||
```
|
||||
|
||||
6. **Push & Create PR**
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
- Open a PR against the `JP` branch
|
||||
- Provide clear description of changes
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue for questions or discussions.
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Powered Softwares Inc.
|
||||
Copyright (c) 2026 svrnty
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,295 +1,81 @@
|
||||
> This project was originally initiated by [Powered Software Inc.](https://poweredsoft.com/) and was forked from the [PoweredSoft.CQRS](https://github.com/PoweredSoft/CQRS) Repository
|
||||
# Svrnty.CQRS
|
||||
|
||||
# CQRS
|
||||
> Modern CQRS framework for .NET with gRPC source generation and HTTP Minimal API support.
|
||||
|
||||
Our implementation of query and command responsibility segregation (CQRS).
|
||||
## Where This Fits
|
||||
|
||||
## Getting Started
|
||||
**Layer**: libs
|
||||
**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.git
|
||||
|
||||
> Install nuget package to your awesome project.
|
||||
## Tech Stack
|
||||
|
||||
| Package Name | NuGet | NuGet Install |
|
||||
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
|
||||
| Svrnty.CQRS | [](https://www.nuget.org/packages/Svrnty.CQRS/) | ```dotnet add package Svrnty.CQRS ``` |
|
||||
| Svrnty.CQRS.MinimalApi | [](https://www.nuget.org/packages/Svrnty.CQRS.MinimalApi/) | ```dotnet add package Svrnty.CQRS.MinimalApi ``` |
|
||||
| Svrnty.CQRS.AspNetCore | [](https://www.nuget.org/packages/Svrnty.CQRS.AspNetCore/) | ```dotnet add package Svrnty.CQRS.AspNetCore ``` |
|
||||
| Svrnty.CQRS.FluentValidation | [](https://www.nuget.org/packages/Svrnty.CQRS.FluentValidation/) | ```dotnet add package Svrnty.CQRS.FluentValidation ``` |
|
||||
| Svrnty.CQRS.DynamicQuery | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery/) | ```dotnet add package Svrnty.CQRS.DynamicQuery ``` |
|
||||
| Svrnty.CQRS.DynamicQuery.AspNetCore | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.AspNetCore/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.AspNetCore ``` |
|
||||
| Svrnty.CQRS.Grpc | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc/) | ```dotnet add package Svrnty.CQRS.Grpc ``` |
|
||||
| Svrnty.CQRS.Grpc.Generators | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Generators/) | ```dotnet add package Svrnty.CQRS.Grpc.Generators ``` |
|
||||
- **Language**: C# 14 / .NET 10
|
||||
- **Framework**: ASP.NET Core Minimal API, gRPC
|
||||
- **Key Dependencies**: FluentValidation 11.x, Grpc.AspNetCore, PoweredSoft.DynamicQuery
|
||||
|
||||
> Abstractions Packages.
|
||||
|
||||
| Package Name | NuGet | NuGet Install |
|
||||
| ---------------------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -----------------------------------------------------: |
|
||||
| Svrnty.CQRS.Abstractions | [](https://www.nuget.org/packages/Svrnty.CQRS.Abstractions/) | ```dotnet add package Svrnty.CQRS.Abstractions ``` |
|
||||
| Svrnty.CQRS.DynamicQuery.Abstractions | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.Abstractions/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions ``` |
|
||||
| Svrnty.CQRS.Grpc.Abstractions | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Abstractions/) | ```dotnet add package Svrnty.CQRS.Grpc.Abstractions ``` |
|
||||
|
||||
|
||||
## Sample of startup code for gRPC (Recommended)
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register CQRS core services
|
||||
builder.Services.AddSvrntyCQRS();
|
||||
builder.Services.AddDefaultCommandDiscovery();
|
||||
builder.Services.AddDefaultQueryDiscovery();
|
||||
|
||||
// Add your commands and queries
|
||||
AddQueries(builder.Services);
|
||||
AddCommands(builder.Services);
|
||||
|
||||
// Add gRPC support
|
||||
builder.Services.AddGrpc();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map auto-generated gRPC service implementations
|
||||
app.MapGrpcService<CommandServiceImpl>();
|
||||
app.MapGrpcService<QueryServiceImpl>();
|
||||
|
||||
// Enable gRPC reflection for tools like grpcurl
|
||||
app.MapGrpcReflectionService();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
### Important: gRPC Requirements
|
||||
|
||||
The gRPC implementation uses **Grpc.Tools** with `.proto` files and **source generators** for automatic service implementation:
|
||||
|
||||
#### 1. Install required packages:
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
dotnet add package Grpc.AspNetCore
|
||||
dotnet add package Grpc.AspNetCore.Server.Reflection
|
||||
dotnet add package Grpc.StatusProto # For Rich Error Model validation
|
||||
# Build
|
||||
dotnet build
|
||||
|
||||
# Run
|
||||
dotnet run --project Svrnty.Sample
|
||||
|
||||
# Test
|
||||
dotnet test
|
||||
```
|
||||
|
||||
#### 2. Add the source generator as an analyzer:
|
||||
## Architecture
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS.Grpc.Generators
|
||||
```
|
||||
18 NuGet packages organized by concern:
|
||||
|
||||
The source generator is automatically configured as an analyzer when installed via NuGet and will generate the gRPC service implementations at compile time.
|
||||
- **Abstractions**: Core interfaces (ICommandHandler, IQueryHandler, IDomainEvent, ISaga, INotificationPublisher)
|
||||
- **Core**: Discovery, registration, handler execution, CqrsBuilder fluent API
|
||||
- **MinimalApi**: HTTP endpoint mapping with RFC 7807 validation
|
||||
- **Grpc**: gRPC service support with Google Rich Error Model
|
||||
- **Grpc.Generators**: Roslyn source generator for .proto files and service implementations
|
||||
- **DynamicQuery**: PoweredSoft integration for filtering, sorting, paging (with EF Core support)
|
||||
- **FluentValidation**: Validator registration helpers
|
||||
- **Events**: Domain event publishing (with RabbitMQ transport)
|
||||
- **Sagas**: Saga orchestration pattern with compensation and distributed execution (with RabbitMQ transport)
|
||||
- **Notifications**: Real-time notification streaming (with gRPC transport)
|
||||
|
||||
#### 3. Define your proto files in `Protos/` directory:
|
||||
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for a full dependency diagram and data flow.
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
service CommandService {
|
||||
rpc AddUser(AddUserCommandRequest) returns (AddUserCommandResponse);
|
||||
rpc RemoveUser(RemoveUserCommandRequest) returns (google.protobuf.Empty);
|
||||
}
|
||||
|
||||
message AddUserCommandRequest {
|
||||
string name = 1;
|
||||
string email = 2;
|
||||
int32 age = 3;
|
||||
}
|
||||
|
||||
message AddUserCommandResponse {
|
||||
int32 result = 1;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Define your C# commands matching the proto structure:
|
||||
## Configuration
|
||||
|
||||
```csharp
|
||||
public record AddUserCommand
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Email { get; init; }
|
||||
public int Age { get; init; }
|
||||
}
|
||||
// Register handlers
|
||||
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
||||
builder.Services.AddQuery<GetUserQuery, User, GetUserQueryHandler>();
|
||||
|
||||
public record RemoveUserCommand
|
||||
// Configure CQRS with gRPC + HTTP
|
||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||
{
|
||||
public int UserId { get; init; }
|
||||
}
|
||||
cqrs.AddGrpc(grpc => grpc.EnableReflection());
|
||||
cqrs.AddMinimalApi();
|
||||
});
|
||||
|
||||
app.UseSvrntyCqrs();
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- The source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
|
||||
- Property names in C# commands must match proto field names (case-insensitive)
|
||||
- FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
|
||||
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
|
||||
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
|
||||
- No need for protobuf-net attributes
|
||||
## Documentation
|
||||
|
||||
## Sample of startup code for Minimal API (HTTP)
|
||||
- [Architecture](./docs/ARCHITECTURE.md) -- Package dependency graph, CQRS data flows, separation of concerns
|
||||
- [Package Index](./docs/PACKAGE_INDEX.md) -- Per-package reference with key types and dependencies
|
||||
- [Getting Started](./docs/GETTING_STARTED.md) -- Step-by-step guide covering commands, queries, gRPC, DynamicQuery, events, sagas, and notifications
|
||||
|
||||
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
|
||||
## Related Libraries
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
- **[flutter_cqrs_datasource](https://git.openharbor.io/svrnty/flutter_cqrs_datasource)** -- Flutter/Dart counterpart for consuming Svrnty.CQRS services from mobile and desktop apps
|
||||
|
||||
// Register CQRS core services
|
||||
builder.Services.AddSvrntyCQRS();
|
||||
builder.Services.AddDefaultCommandDiscovery();
|
||||
builder.Services.AddDefaultQueryDiscovery();
|
||||
## Contributing
|
||||
|
||||
// Add your commands and queries
|
||||
AddQueries(builder.Services);
|
||||
AddCommands(builder.Services);
|
||||
See [CLAUDE.md](./CLAUDE.md) for development guidelines.
|
||||
|
||||
// Add Swagger (optional)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
## License
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// Map CQRS endpoints - automatically creates routes for all commands and queries
|
||||
app.MapSvrntyCommands(); // Creates POST /api/command/{commandName} endpoints
|
||||
app.MapSvrntyQueries(); // Creates POST/GET /api/query/{queryName} endpoints
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- FluentValidation is automatically integrated with **RFC 7807 Problem Details** for structured validation errors
|
||||
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
|
||||
- Supports both POST and GET (for queries) endpoints
|
||||
- Automatically generates Swagger/OpenAPI documentation
|
||||
|
||||
## Sample enabling both gRPC and HTTP
|
||||
|
||||
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register CQRS core services
|
||||
builder.Services.AddSvrntyCQRS();
|
||||
builder.Services.AddDefaultCommandDiscovery();
|
||||
builder.Services.AddDefaultQueryDiscovery();
|
||||
|
||||
// Add your commands and queries
|
||||
AddQueries(builder.Services);
|
||||
AddCommands(builder.Services);
|
||||
|
||||
// Add gRPC support
|
||||
builder.Services.AddGrpc();
|
||||
|
||||
// Add HTTP support with Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// Map gRPC endpoints
|
||||
app.MapGrpcService<CommandServiceImpl>();
|
||||
app.MapGrpcService<QueryServiceImpl>();
|
||||
app.MapGrpcReflectionService();
|
||||
|
||||
// Map HTTP endpoints
|
||||
app.MapSvrntyCommands();
|
||||
app.MapSvrntyQueries();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single codebase supports multiple protocols
|
||||
- gRPC for high-performance, low-latency scenarios (microservices, internal APIs)
|
||||
- HTTP for web browsers, legacy clients, and public APIs
|
||||
- Same commands, queries, and validation logic for both protocols
|
||||
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
|
||||
|
||||
> Example how to add your queries and commands.
|
||||
|
||||
```csharp
|
||||
private void AddCommands(IServiceCollection services)
|
||||
{
|
||||
services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
|
||||
services.AddTransient<IValidator<CreatePersonCommand>, CreatePersonCommandValidator>();
|
||||
|
||||
services.AddCommand<EchoCommand, string, EchoCommandHandler>();
|
||||
services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
|
||||
}
|
||||
|
||||
private void AddQueries(IServiceCollection services)
|
||||
{
|
||||
services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
|
||||
}
|
||||
```
|
||||
|
||||
# Fluent Validation
|
||||
|
||||
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
|
||||
|
||||
## Without Svrnty.CQRS.FluentValidation
|
||||
|
||||
You need to register commands and validators separately:
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using FluentValidation;
|
||||
using Svrnty.CQRS;
|
||||
|
||||
private void AddCommands(IServiceCollection services)
|
||||
{
|
||||
// Register command handler
|
||||
services.AddCommand<EchoCommand, string, EchoCommandHandler>();
|
||||
|
||||
// Manually register validator
|
||||
services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
|
||||
}
|
||||
```
|
||||
|
||||
## With Svrnty.CQRS.FluentValidation (Recommended)
|
||||
|
||||
The package exposes extension method overloads that accept the validator as a generic parameter:
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS.FluentValidation
|
||||
```
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
|
||||
|
||||
private void AddCommands(IServiceCollection services)
|
||||
{
|
||||
// Command without result - validator included in generics
|
||||
services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
|
||||
|
||||
// Command with result - validator as last generic parameter
|
||||
services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **Single line registration** - Handler and validator registered together
|
||||
- **Type safety** - Compiler ensures validator matches command type
|
||||
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
|
||||
- **Cleaner code** - Clear intent that validation is part of command pipeline
|
||||
|
||||
# 2024-2025 Roadmap
|
||||
|
||||
| Task | Description | Status |
|
||||
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
|
||||
| Support .NET 8 | Ensure compatibility with .NET 8. | ✅ |
|
||||
| Support .NET 10 | Upgrade to .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. | ✅ |
|
||||
| Create a website for the Framework | Develop a website to host comprehensive documentation for the framework. | ⬜️ |
|
||||
MIT OR Apache-2.0
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly.
|
||||
|
||||
**Do NOT open a public issue.**
|
||||
|
||||
### How to Report
|
||||
|
||||
Email: **security@svrnty.com**
|
||||
|
||||
Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Any suggested fixes (optional)
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Acknowledgment**: Within 48 hours
|
||||
- **Initial Assessment**: Within 7 days
|
||||
- **Resolution Target**: Within 30 days (depending on severity)
|
||||
|
||||
### What to Expect
|
||||
|
||||
1. We will acknowledge receipt of your report
|
||||
2. We will investigate and validate the issue
|
||||
3. We will work on a fix and coordinate disclosure
|
||||
4. We will credit you (if desired) when the fix is released
|
||||
|
||||
### Scope
|
||||
|
||||
This policy applies to:
|
||||
- Code in this repository
|
||||
- Dependencies we control
|
||||
- Infrastructure we operate
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Third-party services or dependencies
|
||||
- Social engineering attacks
|
||||
- Physical security
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the latest release only.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| Latest | Yes |
|
||||
| Older | No |
|
||||
@@ -19,7 +19,7 @@ public sealed class CommandMeta : ICommandMeta
|
||||
ServiceType = serviceType;
|
||||
}
|
||||
|
||||
private CommandNameAttribute NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
|
||||
private CommandNameAttribute? NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
|
||||
|
||||
public string Name
|
||||
{
|
||||
@@ -32,7 +32,7 @@ public sealed class CommandMeta : ICommandMeta
|
||||
|
||||
public Type CommandType { get; }
|
||||
public Type ServiceType { get; }
|
||||
public Type CommandResultType { get; }
|
||||
public Type? CommandResultType { get; }
|
||||
|
||||
public string LowerCamelCaseName
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ public interface ICommandMeta
|
||||
string Name { get; }
|
||||
Type CommandType { get; }
|
||||
Type ServiceType { get; }
|
||||
Type CommandResultType { get; }
|
||||
Type? CommandResultType { get; }
|
||||
string LowerCamelCaseName { get; }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
public interface IQueryDiscovery
|
||||
{
|
||||
IQueryMeta FindQuery(string name);
|
||||
IQueryMeta FindQuery(Type queryType);
|
||||
IQueryMeta? FindQuery(string name);
|
||||
IQueryMeta? FindQuery(Type queryType);
|
||||
IEnumerable<IQueryMeta> GetQueries();
|
||||
bool QueryExists(string name);
|
||||
bool QueryExists(Type queryType);
|
||||
@@ -16,8 +16,8 @@ public interface ICommandDiscovery
|
||||
{
|
||||
bool CommandExists(string name);
|
||||
bool CommandExists(Type commandType);
|
||||
ICommandMeta FindCommand(string name);
|
||||
ICommandMeta FindCommand(Type commandType);
|
||||
ICommandMeta? FindCommand(string name);
|
||||
ICommandMeta? FindCommand(Type commandType);
|
||||
IEnumerable<ICommandMeta> GetCommands();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ public class QueryMeta : IQueryMeta
|
||||
QueryResultType = queryResultType;
|
||||
}
|
||||
|
||||
protected virtual QueryNameAttribute NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>();
|
||||
protected virtual QueryNameAttribute? NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>();
|
||||
|
||||
public virtual string Name
|
||||
{
|
||||
|
||||
@@ -27,6 +27,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -20,10 +20,10 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
|
||||
|
||||
public interface IDynamicQuery
|
||||
{
|
||||
List<IFilter> GetFilters();
|
||||
List<IGroup> GetGroups();
|
||||
List<ISort> GetSorts();
|
||||
List<IAggregate> GetAggregates();
|
||||
List<IFilter>? GetFilters();
|
||||
List<IGroup>? GetGroups();
|
||||
List<ISort>? GetSorts();
|
||||
List<IAggregate>? GetAggregates();
|
||||
int? GetPage();
|
||||
int? GetPageSize();
|
||||
}
|
||||
@@ -3,5 +3,5 @@
|
||||
public interface IDynamicQueryParams<out TParams>
|
||||
where TParams : class
|
||||
{
|
||||
TParams GetParams();
|
||||
TParams? GetParams();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -22,7 +22,7 @@ public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResult
|
||||
}
|
||||
}
|
||||
|
||||
public Type ParamsType { get; internal set; }
|
||||
public string OverridableName { get; internal set; }
|
||||
public Type? ParamsType { get; internal set; }
|
||||
public string? OverridableName { get; internal set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ public class DynamicQuery<TSource, TDestination, TParams> : DynamicQuery, IDynam
|
||||
where TDestination : class
|
||||
where TParams : class
|
||||
{
|
||||
public TParams Params { get; set; }
|
||||
public TParams? Params { get; set; }
|
||||
|
||||
public TParams GetParams()
|
||||
public TParams? GetParams()
|
||||
{
|
||||
return Params;
|
||||
}
|
||||
@@ -30,23 +30,23 @@ public class DynamicQuery : IDynamicQuery
|
||||
{
|
||||
public int? Page { get; set; }
|
||||
public int? PageSize { get; set; }
|
||||
public List<Sort> Sorts { get; set; }
|
||||
public List<DynamicQueryAggregate> Aggregates { get; set; }
|
||||
public List<Group> Groups { get; set; }
|
||||
public List<DynamicQueryFilter> Filters { get; set; }
|
||||
public List<Sort>? Sorts { get; set; }
|
||||
public List<DynamicQueryAggregate>? Aggregates { get; set; }
|
||||
public List<Group>? Groups { get; set; }
|
||||
public List<DynamicQueryFilter>? Filters { get; set; }
|
||||
|
||||
|
||||
public List<IAggregate> GetAggregates()
|
||||
public List<IAggregate>? GetAggregates()
|
||||
{
|
||||
return Aggregates?.Select(t => t.ToAggregate())?.ToList();//.AsEnumerable<IAggregate>()?.ToList();
|
||||
return Aggregates?.Select(t => t.ToAggregate())?.ToList();
|
||||
}
|
||||
|
||||
public List<IFilter> GetFilters()
|
||||
public List<IFilter>? GetFilters()
|
||||
{
|
||||
return Filters?.Select(t => t.ToFilter())?.ToList();
|
||||
}
|
||||
|
||||
public List<IGroup> GetGroups()
|
||||
public List<IGroup>? GetGroups()
|
||||
{
|
||||
return this.Groups?.AsEnumerable<IGroup>()?.ToList();
|
||||
}
|
||||
@@ -61,7 +61,7 @@ public class DynamicQuery : IDynamicQuery
|
||||
return this.PageSize;
|
||||
}
|
||||
|
||||
public List<ISort> GetSorts()
|
||||
public List<ISort>? GetSorts()
|
||||
{
|
||||
return this.Sorts?.AsEnumerable<ISort>()?.ToList();
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
public class DynamicQueryAggregate
|
||||
{
|
||||
public string Path { get; set; }
|
||||
public string Type { get; set; }
|
||||
public required string Path { get; set; }
|
||||
public required string Type { get; set; }
|
||||
|
||||
public IAggregate ToAggregate()
|
||||
{
|
||||
|
||||
@@ -9,14 +9,14 @@ namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
public class DynamicQueryFilter
|
||||
{
|
||||
public List<DynamicQueryFilter> Filters { get; set; }
|
||||
public List<DynamicQueryFilter>? Filters { get; set; }
|
||||
public bool? And { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public bool? Not { get; set; }
|
||||
public string Path { get; set; }
|
||||
public object Value { get; set; }
|
||||
public string? Path { get; set; }
|
||||
public object? Value { get; set; }
|
||||
|
||||
public string QueryValue
|
||||
public string? QueryValue
|
||||
{
|
||||
get
|
||||
{
|
||||
@@ -32,7 +32,7 @@ public class DynamicQueryFilter
|
||||
|
||||
public IFilter ToFilter()
|
||||
{
|
||||
var type = Enum.Parse<FilterType>(Type);
|
||||
var type = Enum.Parse<FilterType>(Type!);
|
||||
if (type == FilterType.Composite)
|
||||
{
|
||||
var compositeFilter = new CompositeFilter
|
||||
@@ -44,7 +44,7 @@ public class DynamicQueryFilter
|
||||
return compositeFilter;
|
||||
}
|
||||
|
||||
object value = Value;
|
||||
object? value = Value;
|
||||
if (Value is JsonElement jsonElement)
|
||||
{
|
||||
switch (jsonElement.ValueKind)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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;
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -49,10 +60,14 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
||||
{
|
||||
var types = _dynamicQueryInterceptorProviders.SelectMany(t => t.GetInterceptorsTypes()).Distinct();
|
||||
foreach (var type in types)
|
||||
yield return _serviceProvider.GetService(type) as IQueryInterceptor;
|
||||
{
|
||||
if (_serviceProvider.GetService(type) is IQueryInterceptor interceptor)
|
||||
yield return interceptor;
|
||||
}
|
||||
}
|
||||
|
||||
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 +78,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 +94,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,21 +1,36 @@
|
||||
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
|
||||
{
|
||||
public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string name = null)
|
||||
/// <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);
|
||||
|
||||
public static IServiceCollection AddDynamicQuery<TSource, TDestination>(this IServiceCollection services, string name = null)
|
||||
public static IServiceCollection AddDynamicQuery<TSource, TDestination>(this IServiceCollection services, string? name = null)
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
{
|
||||
@@ -36,7 +51,7 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddDynamicQueryWithProvider<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string name = null)
|
||||
public static IServiceCollection AddDynamicQueryWithProvider<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string? name = null)
|
||||
where TQueryableProvider : class, IQueryableProvider<TSource>
|
||||
where TSource : class
|
||||
{
|
||||
@@ -45,7 +60,7 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddDynamicQueryWithParamsAndProvider<TSource, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string name = null)
|
||||
public static IServiceCollection AddDynamicQueryWithParamsAndProvider<TSource, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string? name = null)
|
||||
where TQueryableProvider : class, IQueryableProvider<TSource>
|
||||
where TParams : class
|
||||
where TSource : class
|
||||
@@ -55,12 +70,28 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string name = null)
|
||||
/// <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)
|
||||
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string? name = null)
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
where TParams : class
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -49,6 +49,12 @@ namespace Svrnty.CQRS.Grpc.Generators.Helpers
|
||||
isRepeated = false;
|
||||
isOptional = false;
|
||||
|
||||
// Handle byte[] as bytes proto type (NOT repeated uint32)
|
||||
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
|
||||
{
|
||||
return "bytes";
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (csharpType.EndsWith("[]"))
|
||||
{
|
||||
|
||||
@@ -35,6 +35,26 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
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()
|
||||
{
|
||||
@@ -42,6 +62,22 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<FaqTranslationQueryItem> becomes TranslationOfFaqTranslationQueryItem.
|
||||
/// </summary>
|
||||
public static string GetProtoMessageName(ITypeSymbol typeSymbol)
|
||||
{
|
||||
if (typeSymbol is INamedTypeSymbol namedType && namedType.IsGenericType && namedType.TypeArguments.Length > 0)
|
||||
{
|
||||
var typeArgs = string.Join("And", namedType.TypeArguments.Select(t => GetProtoMessageName(t)));
|
||||
return $"{namedType.Name}Of{typeArgs}";
|
||||
}
|
||||
return typeSymbol.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts C# PascalCase property name to proto snake_case field name.
|
||||
/// Uses simple conversion: add underscore before each uppercase letter (except first).
|
||||
/// 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>
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+103
@@ -31,6 +31,22 @@ 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
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Tests", "tests\Svrnty.CQRS.Tests\Svrnty.CQRS.Tests.csproj", "{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -173,10 +189,97 @@ 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
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {D6D431EA-C04F-462B-8033-60F510FEB49E}
|
||||
EndGlobalSection
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -15,8 +15,8 @@ public sealed class CommandDiscovery : ICommandDiscovery
|
||||
}
|
||||
|
||||
public IEnumerable<ICommandMeta> GetCommands() => _commandMetas;
|
||||
public ICommandMeta FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name);
|
||||
public ICommandMeta FindCommand(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType);
|
||||
public ICommandMeta? FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name);
|
||||
public ICommandMeta? FindCommand(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType);
|
||||
public bool CommandExists(string name) => _commandMetas.Any(t => t.Name == name);
|
||||
public bool CommandExists(Type commandType) => _commandMetas.Any(t => t.CommandType == commandType);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ public sealed class QueryDiscovery : IQueryDiscovery
|
||||
}
|
||||
|
||||
public IEnumerable<IQueryMeta> GetQueries() => _queryMetas;
|
||||
public IQueryMeta FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name);
|
||||
public IQueryMeta FindQuery(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType);
|
||||
public IQueryMeta? FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name);
|
||||
public IQueryMeta? FindQuery(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType);
|
||||
public bool QueryExists(string name) => _queryMetas.Any(t => t.Name == name);
|
||||
public bool QueryExists(Type queryType) => _queryMetas.Any(t => t.QueryType == queryType);
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@ builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, Simp
|
||||
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
|
||||
builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>();
|
||||
|
||||
// Register commands and queries with validators
|
||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
||||
|
||||
// Configure CQRS with fluent API
|
||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||
{
|
||||
// Register commands and queries with validators
|
||||
cqrs.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
||||
cqrs.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||
cqrs.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
||||
|
||||
// Enable gRPC endpoints with reflection
|
||||
cqrs.AddGrpc(grpc =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# Architecture
|
||||
|
||||
> Svrnty.CQRS is a modular CQRS/event-sourcing framework for .NET 10, organized as 18 NuGet packages with clear separation of concerns.
|
||||
|
||||
## Package Dependency Graph
|
||||
|
||||
```
|
||||
Svrnty.CQRS.Abstractions
|
||||
(ICommandHandler, IQueryHandler)
|
||||
|
|
||||
+-----------------+-----------------+
|
||||
| |
|
||||
Svrnty.CQRS Svrnty.CQRS.FluentValidation
|
||||
(Discovery, Registration, (AbstractValidator<T> binding)
|
||||
CqrsBuilder, DI) depends on: Abstractions, Core
|
||||
|
|
||||
+------------+------------+---------------------------+
|
||||
| | | |
|
||||
MinimalApi Grpc DynamicQuery Sagas
|
||||
(HTTP REST) (gRPC) (Filtering, (Orchestrator,
|
||||
| Sorting, Paging) Compensation)
|
||||
| | |
|
||||
Grpc.Abstractions DQ.Abstractions Sagas.Abstractions
|
||||
(GrpcIgnore attr) (IQueryableProvider) (ISaga, ISagaBuilder,
|
||||
| | | ISagaOrchestrator)
|
||||
Grpc.Generators DQ.MinimalApi | |
|
||||
(Source gen, (HTTP endpoints | Sagas.RabbitMQ
|
||||
.proto gen) for DQ) | (RabbitMQ transport)
|
||||
|
|
||||
DQ.EntityFramework
|
||||
(EF Core provider)
|
||||
|
||||
Events.Abstractions Notifications.Abstractions
|
||||
(IDomainEvent, (INotificationPublisher,
|
||||
IDomainEventPublisher) StreamingNotificationAttribute)
|
||||
| |
|
||||
Events.RabbitMQ Notifications.Grpc
|
||||
(RabbitMQ transport) (gRPC streaming)
|
||||
```
|
||||
|
||||
## Dependency Matrix
|
||||
|
||||
| Package | Depends On (internal) |
|
||||
|---|---|
|
||||
| `Svrnty.CQRS.Abstractions` | _(none)_ |
|
||||
| `Svrnty.CQRS` | Abstractions |
|
||||
| `Svrnty.CQRS.MinimalApi` | Abstractions, Core |
|
||||
| `Svrnty.CQRS.Grpc` | Core |
|
||||
| `Svrnty.CQRS.Grpc.Abstractions` | _(none)_ |
|
||||
| `Svrnty.CQRS.Grpc.Generators` | _(none, Roslyn source gen)_ |
|
||||
| `Svrnty.CQRS.FluentValidation` | Abstractions, Core |
|
||||
| `Svrnty.CQRS.DynamicQuery.Abstractions` | _(none)_ |
|
||||
| `Svrnty.CQRS.DynamicQuery` | DynamicQuery.Abstractions, Core |
|
||||
| `Svrnty.CQRS.DynamicQuery.MinimalApi` | Abstractions, DynamicQuery.Abstractions, DynamicQuery |
|
||||
| `Svrnty.CQRS.DynamicQuery.EntityFramework` | DynamicQuery |
|
||||
| `Svrnty.CQRS.Events.Abstractions` | _(none)_ |
|
||||
| `Svrnty.CQRS.Events.RabbitMQ` | Events.Abstractions |
|
||||
| `Svrnty.CQRS.Sagas.Abstractions` | _(none)_ |
|
||||
| `Svrnty.CQRS.Sagas` | Core, Sagas.Abstractions |
|
||||
| `Svrnty.CQRS.Sagas.RabbitMQ` | Sagas |
|
||||
| `Svrnty.CQRS.Notifications.Abstractions` | _(none)_ |
|
||||
| `Svrnty.CQRS.Notifications.Grpc` | Notifications.Abstractions |
|
||||
|
||||
## CQRS Data Flow
|
||||
|
||||
### Command Flow
|
||||
|
||||
```
|
||||
Client Request
|
||||
|
|
||||
v
|
||||
[MinimalApi POST /api/command/{name}] or [gRPC CommandService/{name}]
|
||||
|
|
||||
v
|
||||
FluentValidation (if validator registered)
|
||||
|
|
||||
|-- Validation fails --> RFC 7807 ProblemDetails (HTTP) / Google Rich Error (gRPC)
|
||||
|
|
||||
v
|
||||
ICommandHandler<TCommand, TResult>.HandleAsync(command, ct)
|
||||
|
|
||||
v
|
||||
Command Result (or void)
|
||||
|
|
||||
+--> (optional) IDomainEventPublisher.PublishAsync(event)
|
||||
+--> (optional) INotificationPublisher.PublishAsync(notification)
|
||||
```
|
||||
|
||||
### Query Flow
|
||||
|
||||
```
|
||||
Client Request
|
||||
|
|
||||
v
|
||||
[MinimalApi POST /api/query/{name}] or [gRPC QueryService/{name}]
|
||||
|
|
||||
v
|
||||
IQueryHandler<TQuery, TResult>.HandleAsync(query, ct)
|
||||
|
|
||||
v
|
||||
Query Result
|
||||
```
|
||||
|
||||
### Dynamic Query Flow
|
||||
|
||||
```
|
||||
Client Request (with filters, sorts, pagination)
|
||||
|
|
||||
v
|
||||
[MinimalApi POST /api/dynamic-query/{entity}]
|
||||
|
|
||||
v
|
||||
IQueryableProvider<TSource>.GetQueryableAsync(query, ct)
|
||||
|
|
||||
v
|
||||
PoweredSoft.DynamicQuery engine (applies filters, sorts, groups, aggregates)
|
||||
|
|
||||
v
|
||||
IAlterQueryableService (optional interception)
|
||||
|
|
||||
v
|
||||
Paged/Grouped result set
|
||||
```
|
||||
|
||||
### Saga Flow
|
||||
|
||||
```
|
||||
ISagaOrchestrator.StartAsync<TSaga, TData>(data)
|
||||
|
|
||||
v
|
||||
ISaga<TData>.Configure(builder) -- defines steps
|
||||
|
|
||||
v
|
||||
Step 1: Execute --> Step 2: Execute --> Step 3: Execute --> Completed
|
||||
| | |
|
||||
| | +-- fails -->
|
||||
| | |
|
||||
| +-- compensate <-----------------+
|
||||
| |
|
||||
+-- compensate <-----------------+
|
||||
|
|
||||
v
|
||||
Compensated (rolled back)
|
||||
```
|
||||
|
||||
## Separation of Concerns
|
||||
|
||||
The framework follows a layered architecture:
|
||||
|
||||
1. **Abstractions layer** (4 packages) -- Pure interfaces and marker types with zero dependencies. Can be referenced by any project without pulling in implementation details.
|
||||
- `Svrnty.CQRS.Abstractions`
|
||||
- `Svrnty.CQRS.DynamicQuery.Abstractions`
|
||||
- `Svrnty.CQRS.Events.Abstractions`
|
||||
- `Svrnty.CQRS.Sagas.Abstractions`
|
||||
- `Svrnty.CQRS.Grpc.Abstractions`
|
||||
- `Svrnty.CQRS.Notifications.Abstractions`
|
||||
|
||||
2. **Core layer** (1 package) -- Handler discovery, DI registration, and the `CqrsBuilder` fluent API.
|
||||
- `Svrnty.CQRS`
|
||||
|
||||
3. **Transport layer** (4 packages) -- Maps commands/queries to HTTP or gRPC endpoints.
|
||||
- `Svrnty.CQRS.MinimalApi`
|
||||
- `Svrnty.CQRS.Grpc`
|
||||
- `Svrnty.CQRS.Grpc.Generators`
|
||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi`
|
||||
|
||||
4. **Feature layer** (4 packages) -- Optional capabilities that can be composed in.
|
||||
- `Svrnty.CQRS.FluentValidation`
|
||||
- `Svrnty.CQRS.DynamicQuery`
|
||||
- `Svrnty.CQRS.DynamicQuery.EntityFramework`
|
||||
- `Svrnty.CQRS.Sagas`
|
||||
|
||||
5. **Infrastructure layer** (3 packages) -- Concrete transport bindings for messaging and streaming.
|
||||
- `Svrnty.CQRS.Events.RabbitMQ`
|
||||
- `Svrnty.CQRS.Sagas.RabbitMQ`
|
||||
- `Svrnty.CQRS.Notifications.Grpc`
|
||||
|
||||
This layering ensures that application code depends only on abstractions, while transport and infrastructure concerns remain pluggable.
|
||||
@@ -0,0 +1,514 @@
|
||||
# Getting Started
|
||||
|
||||
> Step-by-step guide to building a CQRS application with Svrnty.CQRS on .NET 10.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK
|
||||
- A text editor or IDE with C# support
|
||||
|
||||
## 1. Create a New Project
|
||||
|
||||
```bash
|
||||
dotnet new web -n MyCqrsApp
|
||||
cd MyCqrsApp
|
||||
```
|
||||
|
||||
Add the required packages:
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS
|
||||
dotnet add package Svrnty.CQRS.Abstractions
|
||||
dotnet add package Svrnty.CQRS.MinimalApi
|
||||
dotnet add package Svrnty.CQRS.FluentValidation
|
||||
```
|
||||
|
||||
## 2. Define Commands and Queries
|
||||
|
||||
### Command with Result
|
||||
|
||||
A command represents an action that changes state. Implement `ICommandHandler<TCommand, TResult>` for commands that return a value.
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
|
||||
// The command (a plain record/class)
|
||||
public record CreateUserCommand
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public int Age { get; set; }
|
||||
}
|
||||
|
||||
// The handler
|
||||
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
|
||||
{
|
||||
public Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Your business logic here -- persist to database, etc.
|
||||
return Task.FromResult(123); // Return the new user ID
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Command without Result
|
||||
|
||||
For commands that do not return a value, implement `ICommandHandler<TCommand>`:
|
||||
|
||||
```csharp
|
||||
public record DeleteUserCommand
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
|
||||
{
|
||||
public Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Delete the user
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Query
|
||||
|
||||
A query retrieves data without side effects. Implement `IQueryHandler<TQuery, TResult>`:
|
||||
|
||||
```csharp
|
||||
public record GetUserQuery
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
}
|
||||
|
||||
public record UserDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
|
||||
{
|
||||
public Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new UserDto
|
||||
{
|
||||
Id = query.UserId,
|
||||
Name = "John Doe",
|
||||
Email = "john@example.com"
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Register Handlers
|
||||
|
||||
In `Program.cs`, register your handlers with the DI container:
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
using Svrnty.CQRS.MinimalApi;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register command and query handlers
|
||||
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
||||
builder.Services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();
|
||||
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
|
||||
|
||||
// Configure CQRS with MinimalApi transport
|
||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||
{
|
||||
cqrs.AddMinimalApi();
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map all CQRS endpoints
|
||||
app.UseSvrntyCqrs();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
This will expose:
|
||||
- `POST /api/command/CreateUser` -- executes CreateUserCommand
|
||||
- `POST /api/command/DeleteUser` -- executes DeleteUserCommand
|
||||
- `POST /api/query/GetUser` -- executes GetUserQuery
|
||||
|
||||
## 4. Add FluentValidation
|
||||
|
||||
Add validators to enforce business rules before handler execution:
|
||||
|
||||
```csharp
|
||||
using FluentValidation;
|
||||
|
||||
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
||||
{
|
||||
public CreateUserCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty().WithMessage("Email is required")
|
||||
.EmailAddress().WithMessage("Email must be valid");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty().WithMessage("Name is required");
|
||||
|
||||
RuleFor(x => x.Age)
|
||||
.GreaterThan(0).WithMessage("Age must be greater than 0");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register the command with its validator using the 4-type-parameter overload:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();
|
||||
```
|
||||
|
||||
Validation errors are returned as RFC 7807 Problem Details (HTTP) or Google Rich Error Model (gRPC).
|
||||
|
||||
## 5. gRPC Setup
|
||||
|
||||
Add the gRPC packages:
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS.Grpc
|
||||
dotnet add package Svrnty.CQRS.Grpc.Generators
|
||||
dotnet add package Svrnty.CQRS.Grpc.Abstractions
|
||||
```
|
||||
|
||||
Configure Kestrel for dual-protocol support and enable gRPC:
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Svrnty.CQRS.Grpc;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure dual ports
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.ListenLocalhost(6000, o => o.Protocols = HttpProtocols.Http2); // gRPC
|
||||
options.ListenLocalhost(6001, o => o.Protocols = HttpProtocols.Http1); // HTTP API
|
||||
});
|
||||
|
||||
// Register handlers (same as before)
|
||||
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
||||
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
|
||||
|
||||
// Enable both gRPC and MinimalApi
|
||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||
{
|
||||
cqrs.AddGrpc(grpc =>
|
||||
{
|
||||
grpc.EnableReflection(); // Enable gRPC reflection for tools like grpcurl
|
||||
});
|
||||
|
||||
cqrs.AddMinimalApi();
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseSvrntyCqrs();
|
||||
app.Run();
|
||||
```
|
||||
|
||||
The `Svrnty.CQRS.Grpc.Generators` package automatically generates `.proto` files and gRPC service implementations from your registered command/query types at build time.
|
||||
|
||||
### Excluding Commands from gRPC
|
||||
|
||||
Use the `[GrpcIgnore]` attribute to prevent a command or query from being exposed via gRPC:
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS.Grpc.Abstractions.Attributes;
|
||||
|
||||
[GrpcIgnore]
|
||||
public record InternalCommand
|
||||
{
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
## 6. DynamicQuery Usage
|
||||
|
||||
Dynamic queries provide automatic filtering, sorting, grouping, and pagination for entity collections.
|
||||
|
||||
Add the packages:
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS.DynamicQuery
|
||||
dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions
|
||||
dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi
|
||||
```
|
||||
|
||||
### Define a Queryable Provider
|
||||
|
||||
Implement `IQueryableProvider<T>` to supply the data source:
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
public class UserQueryableProvider : IQueryableProvider<UserDto>
|
||||
{
|
||||
private readonly MyDbContext _db;
|
||||
|
||||
public UserQueryableProvider(MyDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public Task<IQueryable<UserDto>> GetQueryableAsync(object query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_db.Users.AsQueryable());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Register the Provider
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
// Register PoweredSoft dependencies
|
||||
builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, MyAsyncQueryableService>();
|
||||
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
|
||||
|
||||
// Register the dynamic query provider
|
||||
builder.Services.AddDynamicQueryWithProvider<UserDto, UserQueryableProvider>();
|
||||
```
|
||||
|
||||
This exposes a POST endpoint that accepts filter, sort, group, and pagination parameters, returning paged results automatically.
|
||||
|
||||
### Entity Framework Integration
|
||||
|
||||
For EF Core projects, add the EF integration package:
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS.DynamicQuery.EntityFramework
|
||||
```
|
||||
|
||||
This provides a ready-made `IAsyncQueryableService` backed by EF Core.
|
||||
|
||||
## 7. Domain Events
|
||||
|
||||
Domain events allow you to publish side effects after a command completes.
|
||||
|
||||
Add the packages:
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS.Events.Abstractions
|
||||
dotnet add package Svrnty.CQRS.Events.RabbitMQ # or implement your own IDomainEventPublisher
|
||||
```
|
||||
|
||||
### Define an Event
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS.Events.Abstractions;
|
||||
|
||||
public record UserCreatedEvent : IDomainEvent
|
||||
{
|
||||
public Guid EventId { get; } = Guid.NewGuid();
|
||||
public DateTime OccurredAt { get; } = DateTime.UtcNow;
|
||||
|
||||
public int UserId { get; init; }
|
||||
public string Email { get; init; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
### Publish from a Command Handler
|
||||
|
||||
```csharp
|
||||
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
|
||||
{
|
||||
private readonly IDomainEventPublisher _events;
|
||||
|
||||
public CreateUserCommandHandler(IDomainEventPublisher events)
|
||||
{
|
||||
_events = events;
|
||||
}
|
||||
|
||||
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken ct = default)
|
||||
{
|
||||
var userId = 123; // persist user
|
||||
|
||||
await _events.PublishAsync(new UserCreatedEvent
|
||||
{
|
||||
UserId = userId,
|
||||
Email = command.Email
|
||||
}, ct);
|
||||
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Saga Pattern
|
||||
|
||||
Sagas orchestrate multi-step workflows with automatic compensation (rollback) on failure.
|
||||
|
||||
Add the packages:
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS.Sagas
|
||||
dotnet add package Svrnty.CQRS.Sagas.Abstractions
|
||||
dotnet add package Svrnty.CQRS.Sagas.RabbitMQ # for distributed sagas
|
||||
```
|
||||
|
||||
### Define Saga Data
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS.Sagas.Abstractions;
|
||||
|
||||
public class CreateOrderSagaData : ISagaData
|
||||
{
|
||||
public Guid CorrelationId { get; set; }
|
||||
public int OrderId { get; set; }
|
||||
public int PaymentId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Define a Saga
|
||||
|
||||
```csharp
|
||||
public class CreateOrderSaga : ISaga<CreateOrderSagaData>
|
||||
{
|
||||
public void Configure(ISagaBuilder<CreateOrderSagaData> builder)
|
||||
{
|
||||
builder
|
||||
.Step("CreateOrder")
|
||||
.Execute(async (data, ctx, ct) =>
|
||||
{
|
||||
// Create the order
|
||||
data.OrderId = 42;
|
||||
})
|
||||
.Compensate(async (data, ctx, ct) =>
|
||||
{
|
||||
// Cancel the order on rollback
|
||||
})
|
||||
.Then()
|
||||
|
||||
.Step("ProcessPayment")
|
||||
.Execute(async (data, ctx, ct) =>
|
||||
{
|
||||
// Charge payment
|
||||
data.PaymentId = 99;
|
||||
})
|
||||
.Compensate(async (data, ctx, ct) =>
|
||||
{
|
||||
// Refund payment on rollback
|
||||
})
|
||||
.Then();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Execute a Saga
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS.Sagas.Abstractions;
|
||||
|
||||
public class OrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
|
||||
{
|
||||
private readonly ISagaOrchestrator _orchestrator;
|
||||
|
||||
public OrderCommandHandler(ISagaOrchestrator orchestrator)
|
||||
{
|
||||
_orchestrator = orchestrator;
|
||||
}
|
||||
|
||||
public async Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken ct = default)
|
||||
{
|
||||
var state = await _orchestrator.StartAsync<CreateOrderSaga, CreateOrderSagaData>(
|
||||
new CreateOrderSagaData { Amount = command.Amount }, ct);
|
||||
|
||||
// state.Status will be Completed or Compensated
|
||||
return state.Status == SagaStatus.Completed ? 1 : 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Saga statuses: `NotStarted` -> `InProgress` -> `Completed` (success) or `Failed` -> `Compensating` -> `Compensated` (rolled back).
|
||||
|
||||
### Remote Steps (Distributed Sagas)
|
||||
|
||||
For steps that execute on remote services via RabbitMQ:
|
||||
|
||||
```csharp
|
||||
builder
|
||||
.SendCommand<ChargePaymentCommand, PaymentResult>("ChargePayment")
|
||||
.WithCommand((data, ctx) => new ChargePaymentCommand { Amount = data.Amount })
|
||||
.OnResponse(async (data, ctx, result, ct) =>
|
||||
{
|
||||
data.PaymentId = result.PaymentId;
|
||||
})
|
||||
.Compensate<RefundPaymentCommand>((data, ctx) =>
|
||||
new RefundPaymentCommand { PaymentId = data.PaymentId })
|
||||
.WithTimeout(TimeSpan.FromSeconds(30))
|
||||
.WithRetry(maxRetries: 3, delay: TimeSpan.FromSeconds(2))
|
||||
.Then();
|
||||
```
|
||||
|
||||
## 9. Real-Time Notifications
|
||||
|
||||
For pushing real-time updates to clients via gRPC streaming:
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS.Notifications.Abstractions
|
||||
dotnet add package Svrnty.CQRS.Notifications.Grpc
|
||||
```
|
||||
|
||||
### Define a Notification
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS.Notifications.Abstractions;
|
||||
|
||||
[StreamingNotification(SubscriptionKey = "user-updates")]
|
||||
public record UserUpdatedNotification
|
||||
{
|
||||
public int UserId { get; init; }
|
||||
public string NewEmail { get; init; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
### Publish a Notification
|
||||
|
||||
```csharp
|
||||
public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand>
|
||||
{
|
||||
private readonly INotificationPublisher _notifications;
|
||||
|
||||
public UpdateUserCommandHandler(INotificationPublisher notifications)
|
||||
{
|
||||
_notifications = notifications;
|
||||
}
|
||||
|
||||
public async Task HandleAsync(UpdateUserCommand command, CancellationToken ct = default)
|
||||
{
|
||||
// Update user...
|
||||
|
||||
await _notifications.PublishAsync(new UserUpdatedNotification
|
||||
{
|
||||
UserId = command.UserId,
|
||||
NewEmail = command.NewEmail
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Running the Sample App
|
||||
|
||||
The repository includes a complete sample application:
|
||||
|
||||
```bash
|
||||
cd Svrnty.Sample
|
||||
dotnet run
|
||||
```
|
||||
|
||||
This starts:
|
||||
- gRPC server on `http://localhost:6000` (HTTP/2)
|
||||
- HTTP API on `http://localhost:6001` (HTTP/1.1)
|
||||
- Swagger UI at `http://localhost:6001/swagger`
|
||||
|
||||
The sample demonstrates commands with validation, queries, gRPC reflection, MinimalApi endpoints, and dynamic queries.
|
||||
@@ -0,0 +1,335 @@
|
||||
# Package Index
|
||||
|
||||
> Complete reference for all 18 NuGet packages in the Svrnty.CQRS framework.
|
||||
|
||||
## Overview
|
||||
|
||||
| # | Package | Path | NuGet Package |
|
||||
|---|---------|------|:---:|
|
||||
| 1 | [Svrnty.CQRS.Abstractions](#1-svrntycqrsabstractions) | `Svrnty.CQRS.Abstractions/` | Yes |
|
||||
| 2 | [Svrnty.CQRS](#2-svrntycqrs) | `Svrnty.CQRS/` | Yes |
|
||||
| 3 | [Svrnty.CQRS.MinimalApi](#3-svrntycqrsminimalapi) | `Svrnty.CQRS.MinimalApi/` | Yes |
|
||||
| 4 | [Svrnty.CQRS.Grpc](#4-svrntycqrsgrpc) | `Svrnty.CQRS.Grpc/` | Yes |
|
||||
| 5 | [Svrnty.CQRS.Grpc.Abstractions](#5-svrntycqrsgrpcabstractions) | `Svrnty.CQRS.Grpc.Abstractions/` | Yes |
|
||||
| 6 | [Svrnty.CQRS.Grpc.Generators](#6-svrntycqrsgrpcgenerators) | `Svrnty.CQRS.Grpc.Generators/` | Yes |
|
||||
| 7 | [Svrnty.CQRS.FluentValidation](#7-svrntycqrsfluentvalidation) | `Svrnty.CQRS.FluentValidation/` | Yes |
|
||||
| 8 | [Svrnty.CQRS.DynamicQuery.Abstractions](#8-svrntycqrsdynamicqueryabstractions) | `Svrnty.CQRS.DynamicQuery.Abstractions/` | Yes |
|
||||
| 9 | [Svrnty.CQRS.DynamicQuery](#9-svrntycqrsdynamicquery) | `Svrnty.CQRS.DynamicQuery/` | Yes |
|
||||
| 10 | [Svrnty.CQRS.DynamicQuery.MinimalApi](#10-svrntycqrsdynamicqueryminimalapi) | `Svrnty.CQRS.DynamicQuery.MinimalApi/` | Yes |
|
||||
| 11 | [Svrnty.CQRS.DynamicQuery.EntityFramework](#11-svrntycqrsdynamicqueryentityframework) | `Svrnty.CQRS.DynamicQuery.EntityFramework/` | Yes |
|
||||
| 12 | [Svrnty.CQRS.Events.Abstractions](#12-svrntycqrseventsabstractions) | `Svrnty.CQRS.Events.Abstractions/` | Yes |
|
||||
| 13 | [Svrnty.CQRS.Events.RabbitMQ](#13-svrntycqrseventsrabbitmq) | `Svrnty.CQRS.Events.RabbitMQ/` | Yes |
|
||||
| 14 | [Svrnty.CQRS.Sagas.Abstractions](#14-svrntycqrssagasabstractions) | `Svrnty.CQRS.Sagas.Abstractions/` | Yes |
|
||||
| 15 | [Svrnty.CQRS.Sagas](#15-svrntycqrssagas) | `Svrnty.CQRS.Sagas/` | Yes |
|
||||
| 16 | [Svrnty.CQRS.Sagas.RabbitMQ](#16-svrntycqrssagasrabbitmq) | `Svrnty.CQRS.Sagas.RabbitMQ/` | Yes |
|
||||
| 17 | [Svrnty.CQRS.Notifications.Abstractions](#17-svrntycqrsnotificationsabstractions) | `Svrnty.CQRS.Notifications.Abstractions/` | Yes |
|
||||
| 18 | [Svrnty.CQRS.Notifications.Grpc](#18-svrntycqrsnotificationsgrpc) | `Svrnty.CQRS.Notifications.Grpc/` | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Package Details
|
||||
|
||||
### 1. Svrnty.CQRS.Abstractions
|
||||
|
||||
**Purpose**: Core interfaces that define the CQRS contract. This is the only package your domain/application layer needs to reference.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: Yes
|
||||
|
||||
**Key Types**:
|
||||
- `ICommandHandler<TCommand>` -- Handler for commands with no return value
|
||||
- `ICommandHandler<TCommand, TResult>` -- Handler for commands returning a result
|
||||
- `IQueryHandler<TQuery, TResult>` -- Handler for queries
|
||||
- `ICommandMeta` / `IQueryMeta` -- Discovery metadata
|
||||
- `ICommandDiscovery` / `IQueryDiscovery` -- Service discovery interfaces
|
||||
- `ICommandAuthorizationService<TCommand>` -- Per-command authorization
|
||||
- `IQueryAuthorizationService<TQuery>` -- Per-query authorization
|
||||
- `CommandNameAttribute` / `QueryNameAttribute` -- Custom naming
|
||||
- `IgnoreCommandAttribute` / `IgnoreQueryAttribute` -- Exclude from auto-discovery
|
||||
|
||||
**Internal Dependencies**: None
|
||||
|
||||
---
|
||||
|
||||
### 2. Svrnty.CQRS
|
||||
|
||||
**Purpose**: Core registration and discovery engine. Provides the `AddSvrntyCqrs()` fluent API and auto-discovers registered handlers.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: Yes
|
||||
|
||||
**Key Types**:
|
||||
- `CqrsBuilder` -- Fluent builder for configuring transports and features
|
||||
- `CqrsConfiguration` -- Configuration state
|
||||
- `ServiceCollectionExtensions.AddSvrntyCqrs()` -- Entry point
|
||||
- `ServiceCollectionExtensions.AddCommand<T, TResult, THandler>()` -- Register a command handler
|
||||
- `ServiceCollectionExtensions.AddQuery<T, TResult, THandler>()` -- Register a query handler
|
||||
- `CommandDiscovery` / `QueryDiscovery` -- Default discovery implementations
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS.Abstractions`
|
||||
|
||||
---
|
||||
|
||||
### 3. Svrnty.CQRS.MinimalApi
|
||||
|
||||
**Purpose**: Maps registered commands and queries to ASP.NET Core Minimal API HTTP endpoints. Includes RFC 7807 Problem Details for validation errors.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: No
|
||||
|
||||
**Key Types**:
|
||||
- `CqrsBuilderExtensions.AddMinimalApi()` -- Enable HTTP endpoints
|
||||
- `MinimalApiCqrsOptions` -- Configuration (route prefixes, etc.)
|
||||
- `ValidationFilter` -- Endpoint filter for FluentValidation
|
||||
- `WebApplicationExtensions.UseSvrntyCqrs()` -- Map endpoints at startup
|
||||
- `EndpointRouteBuilderExtensions` -- Route mapping helpers
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS.Abstractions`, `Svrnty.CQRS`
|
||||
|
||||
**External Dependencies**: `FluentValidation 11.x`, `Microsoft.AspNetCore.App`
|
||||
|
||||
---
|
||||
|
||||
### 4. Svrnty.CQRS.Grpc
|
||||
|
||||
**Purpose**: Maps registered commands and queries to gRPC services. Uses Google Rich Error Model for structured validation errors.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: No
|
||||
|
||||
**Key Types**:
|
||||
- `CqrsBuilderExtensions.AddGrpc()` -- Enable gRPC endpoints
|
||||
- `GrpcCqrsOptions` -- Configuration (reflection, etc.)
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS`
|
||||
|
||||
**External Dependencies**: `Grpc.AspNetCore 2.71.0`
|
||||
|
||||
---
|
||||
|
||||
### 5. Svrnty.CQRS.Grpc.Abstractions
|
||||
|
||||
**Purpose**: Attributes for controlling gRPC code generation behavior.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: Yes
|
||||
|
||||
**Key Types**:
|
||||
- `GrpcIgnoreAttribute` -- Marks a command/query to be excluded from gRPC service generation
|
||||
|
||||
**Internal Dependencies**: None
|
||||
|
||||
---
|
||||
|
||||
### 6. Svrnty.CQRS.Grpc.Generators
|
||||
|
||||
**Purpose**: Roslyn source generator that auto-generates `.proto` files and gRPC service implementations from registered command/query types.
|
||||
|
||||
**Target**: `netstandard2.0` (Roslyn component) | **AOT**: N/A
|
||||
|
||||
**Key Types**:
|
||||
- Source generator (analyzer DLL)
|
||||
- MSBuild `WriteProtoFileTask` -- Writes generated `.proto` files to disk
|
||||
- Build targets and props for NuGet consumers
|
||||
|
||||
**Internal Dependencies**: None (ships as analyzer)
|
||||
|
||||
**External Dependencies**: `Microsoft.CodeAnalysis.CSharp 5.0.0`, `Microsoft.Build.Utilities.Core 17.0.0`
|
||||
|
||||
---
|
||||
|
||||
### 7. Svrnty.CQRS.FluentValidation
|
||||
|
||||
**Purpose**: Integrates FluentValidation with command/query registration. Validators are automatically invoked before handler execution.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: Yes
|
||||
|
||||
**Key Types**:
|
||||
- `ServiceCollectionExtensions.AddCommand<TCmd, TResult, THandler, TValidator>()` -- Register command with validator
|
||||
- Automatic `AbstractValidator<T>` binding
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS`, `Svrnty.CQRS.Abstractions`
|
||||
|
||||
**External Dependencies**: `FluentValidation 11.11.0`
|
||||
|
||||
---
|
||||
|
||||
### 8. Svrnty.CQRS.DynamicQuery.Abstractions
|
||||
|
||||
**Purpose**: Interfaces for the dynamic query subsystem. Defines how data sources are provided and queries are intercepted.
|
||||
|
||||
**Target**: `netstandard2.1`, `net10.0` (multi-target) | **AOT**: Conditional
|
||||
|
||||
**Key Types**:
|
||||
- `IQueryableProvider<TSource>` -- Provides an `IQueryable<T>` data source
|
||||
- `IQueryableProviderOverride<TSource>` -- Override default provider
|
||||
- `IAlterQueryableService<TSource>` -- Intercept/modify queryables
|
||||
- `IDynamicQuery` / `IDynamicQueryParams` -- Query parameter contracts
|
||||
- `IDynamicQueryInterceptorProvider` -- Interceptor registration
|
||||
|
||||
**Internal Dependencies**: None
|
||||
|
||||
**External Dependencies**: `PoweredSoft.DynamicQuery.Core 3.0.1`
|
||||
|
||||
---
|
||||
|
||||
### 9. Svrnty.CQRS.DynamicQuery
|
||||
|
||||
**Purpose**: Implementation of dynamic query execution with filtering, sorting, grouping, pagination, and aggregation.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: Yes
|
||||
|
||||
**Key Types**:
|
||||
- `ServiceCollectionExtensions.AddDynamicQueryWithProvider<TSource, TProvider>()` -- Register a queryable provider
|
||||
- Dynamic query handler pipeline
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS.DynamicQuery.Abstractions`, `Svrnty.CQRS`
|
||||
|
||||
**External Dependencies**: `PoweredSoft.DynamicQuery 3.0.1`, `Pluralize.NET 1.0.2`
|
||||
|
||||
---
|
||||
|
||||
### 10. Svrnty.CQRS.DynamicQuery.MinimalApi
|
||||
|
||||
**Purpose**: HTTP Minimal API endpoints for dynamic queries. Exposes each registered entity as a POST endpoint with filter/sort/page parameters.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: No
|
||||
|
||||
**Key Types**:
|
||||
- Endpoint mapping for dynamic query routes (`/api/dynamic-query/{entity}`)
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS.Abstractions`, `Svrnty.CQRS.DynamicQuery.Abstractions`, `Svrnty.CQRS.DynamicQuery`
|
||||
|
||||
**External Dependencies**: `Microsoft.AspNetCore.App`
|
||||
|
||||
---
|
||||
|
||||
### 11. Svrnty.CQRS.DynamicQuery.EntityFramework
|
||||
|
||||
**Purpose**: Entity Framework Core integration for dynamic queries. Provides an EF-backed `IAsyncQueryableService`.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: No
|
||||
|
||||
**Key Types**:
|
||||
- EF Core queryable service adapter
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS.DynamicQuery`
|
||||
|
||||
**External Dependencies**: `PoweredSoft.Data.EntityFrameworkCore 3.0.0`
|
||||
|
||||
---
|
||||
|
||||
### 12. Svrnty.CQRS.Events.Abstractions
|
||||
|
||||
**Purpose**: Interfaces for domain event publishing.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: Yes
|
||||
|
||||
**Key Types**:
|
||||
- `IDomainEvent` -- Marker interface (EventId, OccurredAt)
|
||||
- `IDomainEventPublisher` -- Publish events to external systems
|
||||
|
||||
**Internal Dependencies**: None
|
||||
|
||||
---
|
||||
|
||||
### 13. Svrnty.CQRS.Events.RabbitMQ
|
||||
|
||||
**Purpose**: RabbitMQ-backed implementation of domain event publishing.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: No
|
||||
|
||||
**Key Types**:
|
||||
- RabbitMQ event publisher implementation
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS.Events.Abstractions`
|
||||
|
||||
**External Dependencies**: `RabbitMQ.Client 7.0.0`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.Options`
|
||||
|
||||
---
|
||||
|
||||
### 14. Svrnty.CQRS.Sagas.Abstractions
|
||||
|
||||
**Purpose**: Interfaces and types for the saga orchestration pattern with compensation (rollback) support.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: Yes
|
||||
|
||||
**Key Types**:
|
||||
- `ISaga<TData>` -- Define a saga with steps
|
||||
- `ISagaBuilder<TData>` -- Fluent builder for local and remote steps
|
||||
- `ISagaStepBuilder<TData>` -- Configure Execute/Compensate actions
|
||||
- `ISagaRemoteStepBuilder<TData, TCommand>` -- Remote command steps with timeout/retry
|
||||
- `ISagaOrchestrator` -- Start sagas, query state
|
||||
- `ISagaData` -- Marker interface (CorrelationId)
|
||||
- `SagaState` -- Persistent saga state (status, completed steps, errors)
|
||||
- `SagaStatus` -- Enum: NotStarted, InProgress, Completed, Failed, Compensating, Compensated
|
||||
- `ISagaStateStore` -- Persistence abstraction
|
||||
- `ISagaMessageBus` -- Messaging abstraction
|
||||
- `SagaMessage` / `SagaStepResponse` -- Message types
|
||||
- `ISagaContext` -- Step execution context
|
||||
|
||||
**Internal Dependencies**: None
|
||||
|
||||
---
|
||||
|
||||
### 15. Svrnty.CQRS.Sagas
|
||||
|
||||
**Purpose**: Default saga orchestrator implementation with step execution, compensation, and state management.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: Yes
|
||||
|
||||
**Key Types**:
|
||||
- Saga orchestrator engine
|
||||
- In-memory state store (default)
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS`, `Svrnty.CQRS.Sagas.Abstractions`
|
||||
|
||||
**External Dependencies**: `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.Options`
|
||||
|
||||
---
|
||||
|
||||
### 16. Svrnty.CQRS.Sagas.RabbitMQ
|
||||
|
||||
**Purpose**: RabbitMQ-backed message bus for distributed saga step execution across microservices.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: No
|
||||
|
||||
**Key Types**:
|
||||
- RabbitMQ saga message bus implementation
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS.Sagas`
|
||||
|
||||
**External Dependencies**: `RabbitMQ.Client 7.0.0`, `Microsoft.Extensions.Hosting.Abstractions`, `Microsoft.Extensions.Options`
|
||||
|
||||
---
|
||||
|
||||
### 17. Svrnty.CQRS.Notifications.Abstractions
|
||||
|
||||
**Purpose**: Interfaces for real-time notification streaming to clients.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: Yes
|
||||
|
||||
**Key Types**:
|
||||
- `INotificationPublisher` -- Publish notifications to subscribed clients
|
||||
- `StreamingNotificationAttribute` -- Marks a type as a streamable notification with a subscription key
|
||||
|
||||
**Internal Dependencies**: None
|
||||
|
||||
---
|
||||
|
||||
### 18. Svrnty.CQRS.Notifications.Grpc
|
||||
|
||||
**Purpose**: gRPC server-streaming implementation for real-time notifications.
|
||||
|
||||
**Target**: `net10.0` | **AOT**: No
|
||||
|
||||
**Key Types**:
|
||||
- gRPC notification streaming service
|
||||
|
||||
**Internal Dependencies**: `Svrnty.CQRS.Notifications.Abstractions`
|
||||
|
||||
**External Dependencies**: `Grpc.AspNetCore 2.71.0`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.Extensions.Logging.Abstractions`
|
||||
|
||||
---
|
||||
|
||||
## Additional Projects (not NuGet packages)
|
||||
|
||||
| Project | Path | Purpose |
|
||||
|---------|------|---------|
|
||||
| `Svrnty.Sample` | `Svrnty.Sample/` | Sample web application demonstrating commands, queries, gRPC, MinimalApi, DynamicQuery, and validation |
|
||||
| `Svrnty.CQRS.Tests` | `tests/Svrnty.CQRS.Tests/` | Unit and integration test suite |
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
pre-commit:
|
||||
parallel: true
|
||||
commands:
|
||||
check-author:
|
||||
run: |
|
||||
EMAIL=$(git config user.email)
|
||||
ALLOWED="jp@svrnty.io mathias@svrnty.io"
|
||||
for a in $ALLOWED; do
|
||||
[ "$EMAIL" = "$a" ] && exit 0
|
||||
done
|
||||
echo "BLOCKED: author email '$EMAIL' not in allowed list: $ALLOWED"
|
||||
exit 1
|
||||
no-secrets:
|
||||
run: |
|
||||
BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|pem|key)$|credentials\.json|id_rsa|id_ed25519' || true)
|
||||
if [ -n "$BLOCKED" ]; then
|
||||
echo "BLOCKED: refusing to commit sensitive files:"
|
||||
echo "$BLOCKED"
|
||||
exit 1
|
||||
fi
|
||||
no-large-files:
|
||||
run: |
|
||||
LARGE=$(git diff --cached --name-only -z | xargs -0 -I{} sh -c 'if [ -f "{}" ]; then size=$(wc -c < "{}"); if [ "$size" -gt 5242880 ]; then echo "{} ($(( size / 1048576 ))MB)"; fi; fi' || true)
|
||||
if [ -n "$LARGE" ]; then
|
||||
echo "WARNING: large files staged (>5MB):"
|
||||
echo "$LARGE"
|
||||
fi
|
||||
doc-hygiene:
|
||||
run: |
|
||||
STAGED=$(git diff --cached --name-only)
|
||||
# Check if code files are staged (not just docs)
|
||||
CODE_CHANGED=$(echo "$STAGED" | grep -vE '\.(md|txt|yml|yaml|json|toml|lock)$|^LICENSE$|^\.gitignore$' || true)
|
||||
if [ -z "$CODE_CHANGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
# Warn if CHANGELOG.md is not being updated with code changes
|
||||
if ! echo "$STAGED" | grep -q '^CHANGELOG.md$'; then
|
||||
echo "WARNING: code changes staged without CHANGELOG.md update"
|
||||
echo " → Update CHANGELOG.md under [Unreleased] before committing"
|
||||
echo " → See root CLAUDE.md § Documentation Standards for format"
|
||||
fi
|
||||
# Warn if README.md is missing
|
||||
if [ ! -f "README.md" ]; then
|
||||
echo "WARNING: README.md is missing — every repo must have one"
|
||||
echo " → See root CLAUDE.md § README Requirements for structure"
|
||||
fi
|
||||
|
||||
commit-msg:
|
||||
commands:
|
||||
validate-message:
|
||||
run: |
|
||||
MSG=$(cat "{1}")
|
||||
if echo "$MSG" | head -1 | grep -qE '^Merge '; then
|
||||
exit 0
|
||||
fi
|
||||
if ! echo "$MSG" | head -1 | grep -qE '^[a-z]+(\([a-zA-Z0-9_-]+\))?: .+'; then
|
||||
echo "WARNING: commit message does not follow conventional format: type(scope): message"
|
||||
echo " → Types: feat, fix, refactor, docs, test, chore, ci, perf"
|
||||
fi
|
||||
append-coauthor:
|
||||
run: |
|
||||
MSG=$(cat "{1}")
|
||||
if ! echo "$MSG" | grep -qF 'Co-Authored-By: Svrnty Inc. <jp@svrnty.io>'; then
|
||||
printf '\n\nCo-Authored-By: Svrnty Inc. <jp@svrnty.io>\n' >> "{1}"
|
||||
fi
|
||||
|
||||
post-commit:
|
||||
commands:
|
||||
register-repo:
|
||||
run: |
|
||||
REPO_NAME=$(basename "$(git rev-parse --show-toplevel)")
|
||||
ROOT_CLAUDE="$(git rev-parse --show-toplevel)/../CLAUDE.md"
|
||||
[ -f "$ROOT_CLAUDE" ] || exit 0
|
||||
if grep -qF "| \`$REPO_NAME\`" "$ROOT_CLAUDE"; then
|
||||
exit 0
|
||||
fi
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
TOTAL_LINE=$(grep -n '^\*\*Total:' "$ROOT_CLAUDE" | head -1 | cut -d: -f1)
|
||||
if [ -z "$TOTAL_LINE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
OLD_COUNT=$(sed -n "${TOTAL_LINE}p" "$ROOT_CLAUDE" | grep -oP '\d+')
|
||||
NEW_COUNT=$((OLD_COUNT + 1))
|
||||
sed -i "${TOTAL_LINE}i| \`${REPO_NAME}\` | — | NEW REPO — registered ${DATE} (${COMMIT}). Update stack and purpose. |" "$ROOT_CLAUDE"
|
||||
NEW_TOTAL_LINE=$((TOTAL_LINE + 1))
|
||||
sed -i "${NEW_TOTAL_LINE}s/Total: ${OLD_COUNT}/Total: ${NEW_COUNT}/" "$ROOT_CLAUDE"
|
||||
echo "REGISTRY: added '$REPO_NAME' to root CLAUDE.md (${DATE}, ${COMMIT})"
|
||||
bootstrap-siblings:
|
||||
run: |
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
HOOKS_DIR="$REPO_ROOT/../.svrnty-hooks"
|
||||
[ -d "$HOOKS_DIR" ] || exit 0
|
||||
[ -f "$HOOKS_DIR/lefthook.yml" ] || exit 0
|
||||
for sibling in "$REPO_ROOT"/../*/; do
|
||||
[ -d "$sibling/.git" ] || continue
|
||||
[ -f "$sibling/lefthook.yml" ] && continue
|
||||
SNAME=$(basename "$sibling")
|
||||
# Deploy lefthook
|
||||
cp "$HOOKS_DIR/lefthook.yml" "$sibling/lefthook.yml"
|
||||
# Deploy CLAUDE.md
|
||||
[ -f "$HOOKS_DIR/CLAUDE.md.template" ] && cp "$HOOKS_DIR/CLAUDE.md.template" "$sibling/CLAUDE.md"
|
||||
# Deploy governance docs
|
||||
[ -f "$HOOKS_DIR/LICENSE" ] && [ ! -f "$sibling/LICENSE" ] && cp "$HOOKS_DIR/LICENSE" "$sibling/LICENSE"
|
||||
[ -f "$HOOKS_DIR/CONTRIBUTING.md" ] && [ ! -f "$sibling/CONTRIBUTING.md" ] && cp "$HOOKS_DIR/CONTRIBUTING.md" "$sibling/CONTRIBUTING.md"
|
||||
[ -f "$HOOKS_DIR/SECURITY.md" ] && [ ! -f "$sibling/SECURITY.md" ] && cp "$HOOKS_DIR/SECURITY.md" "$sibling/SECURITY.md"
|
||||
[ -f "$HOOKS_DIR/CHANGELOG.md.template" ] && [ ! -f "$sibling/CHANGELOG.md" ] && cp "$HOOKS_DIR/CHANGELOG.md.template" "$sibling/CHANGELOG.md"
|
||||
# Install lefthook
|
||||
(cd "$sibling" && lefthook install 2>/dev/null)
|
||||
echo "BOOTSTRAP: installed lefthook + governance docs in '$SNAME'"
|
||||
done
|
||||
|
||||
pre-push:
|
||||
commands:
|
||||
protect-main:
|
||||
run: |
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
|
||||
echo "BLOCKED: direct push to $BRANCH is not allowed"
|
||||
exit 1
|
||||
fi
|
||||
check-behind-remote:
|
||||
run: |
|
||||
git fetch origin 2>/dev/null || true
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
BEHIND=$(git rev-list --count HEAD..origin/"$BRANCH" 2>/dev/null || echo 0)
|
||||
if [ "$BEHIND" -gt 0 ]; then
|
||||
echo "WARNING: local branch is $BEHIND commit(s) behind origin/$BRANCH"
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user