Compare commits

..

16 Commits

Author SHA1 Message Date
Mathias Beaulieu-Duncan 07a7a683b7 feat(altcha): IAltchaDifficultyAdvisor for per-request PoW complexity
Publish NuGets / build (release) Successful in 39s
Adds an abstraction over the CreateChallengeRequest.complexity field
(already present in the proto since the original altcha module landed),
letting applications scale PoW difficulty per request based on actor
signals — repeat-offender counters, threat-intel headers, reputation
scores — without leaking those concerns into the gRPC provider.

  - new IAltchaDifficultyAdvisor in Svrnty.CQRS.Altcha.Abstractions:
    Task<uint?> GetComplexityAsync(...). null means "use the upstream
    service's configured default."

  - NullAltchaDifficultyAdvisor in Svrnty.CQRS.Altcha is the no-op
    fallback registered by AddSvrntyAltcha() via TryAddSingleton, so
    applications can replace it without ordering constraints.

  - AltchaGrpcChallengeProvider now resolves the advisor and sets
    CreateChallengeRequest.Complexity when the advisor returns a value.
    The Altcha server clamps to its configured min/max, so callers
    don't need to enforce bounds here.

No breaking changes to existing consumers — the no-op default keeps
behaviour identical when no advisor is registered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:09:00 -04:00
Mathias Beaulieu-Duncan ede9548cba test(altcha): runtime validation of check pipeline in Svrnty.Sample
Adds a [Altcha]-decorated ProtectedActionCommand with IHasAltchaSolution
and a StubAltchaVerifier that treats the literal "valid-solution" as
passing PoW. Exercises both the HTTP MinimalApi and gRPC pipelines
without requiring an external altcha service.

Validated 4 scenarios on each transport (8 total, all pass):

  HTTP /api/command/protectedAction          POST 6001    gRPC :6000
  -------------------------------------------------------------------
  no AltchaSolution                          401          Unauthenticated
  AltchaSolution = "wrong"                   401          Unauthenticated
  AltchaSolution = "valid-solution"          200 result   OK + result
  addUser (no [Altcha])                      200 result   OK + result

The last row confirms backward compatibility: a request type that
isn't decorated with [Altcha] bypasses the check entirely — the
AltchaAuthorizationCheck self-applies and no-ops, and any consumer
that doesn't call AddSvrntyAltcha() sees zero behavior change.

Generated CommandServiceImpl.g.cs verified to include the
ICommandAuthorizationCheck loop after validation, before handler
invocation, with the materialized command instance in ctx.Command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:46:10 -04:00
Mathias Beaulieu-Duncan 891894d136 feat(altcha): add Svrnty.CQRS.Altcha.MinimalApi challenge endpoint
Single helper extension: MapSvrntyAltchaChallenge() exposes
GET /api/altcha/challenge (configurable prefix) that fetches a fresh
challenge from IAltchaChallengeProvider and projects it onto the
JSON shape the altcha widget v3 expects from its challengeurl —
{ algorithm, challenge, salt, signature, maxnumber } in lowercase.

AllowAnonymous on purpose: the whole point is gating mutations from
unauthenticated callers, so the challenge endpoint must be reachable
without credentials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:26:41 -04:00
Mathias Beaulieu-Duncan 4446288bb6 feat(altcha): add Svrnty.CQRS.Altcha.Grpc with default verifier + proto
The default transport for IAltchaVerifier / IAltchaChallengeProvider —
calls a self-hosted altcha service over gRPC.

Wire contract
- Protos/altcha.proto defines svrnty.cqrs.altcha.v1.AltchaService with
  CreateChallenge + VerifyChallenge RPCs. Shipped in this package as
  source-of-truth; Go (and other) implementations vendor a copy.
- Challenge.challenge_hash is named (not "challenge") to avoid a C#
  property/class name collision; the MinimalApi widget JSON remaps.

Runtime
- AltchaGrpcVerifier maps RpcException → AltchaVerifyResult.Fail with
  a diagnostic reason ("verify-timeout", "service-unavailable", etc.)
  so the auth check surfaces a clean Unauthorized without leaking
  transport detail.
- AltchaGrpcChallengeProvider lets create-challenge failures bubble
  (challenge endpoint should 5xx if altcha is down — clients retry).
- AltchaGrpcOptions.TokenProvider hook for consumer-supplied HMAC
  service-token minting (plan-b will plug in ServiceTokenIssuer).
- AddGrpcClient<AltchaServiceClient> registered with HttpClientFactory.

AddSvrntyAltchaGrpcVerifier(Action<...>) and overload binding from
IConfiguration cover both wiring styles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:25:59 -04:00
Mathias Beaulieu-Duncan 69e29d4f6d feat(altcha): add Svrnty.CQRS.Altcha core check + DI
The Altcha authorization check, plugged into the
ICommandAuthorizationCheck / IQueryAuthorizationCheck seam.

Behavior
- Self-applies: returns Allowed for any request whose type isn't
  decorated with [Altcha]. No-op for the 99% of endpoints that don't
  need PoW.
- Reads ctx.Items["mobile_attested"] for Phase 3 bypass when the
  attribute's AllowMobileAttestationBypass is true.
- Pulls the solution off the request via IHasAltchaSolution and
  delegates verification to IAltchaVerifier (resolved per-call from
  the request scope, so any verifier lifetime works).
- Stashes a diagnostic reason in ctx.Items["altcha_reason"]
  (missing / misconfigured / invalid / replayed / expired / etc.)
  for downstream middleware to surface in error responses.
- Singleton itself — stateless; one instance shared via factory
  registrations under both check interfaces.

AddSvrntyAltcha() registers the check. The verifier is provided by
a transport-specific module (e.g. Svrnty.CQRS.Altcha.Grpc, next).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:24:02 -04:00
Mathias Beaulieu-Duncan 118d12a3db feat(altcha): add Svrnty.CQRS.Altcha.Abstractions package
Abstractions for the Altcha-based proof-of-work module:
- AltchaAttribute (AllowMobileAttestationBypass param)
- IHasAltchaSolution — marker interface for request POCOs carrying
  the widget's solution payload over HTTP/gRPC transports
- IAltchaVerifier / IAltchaChallengeProvider — transport-agnostic
  interfaces; default gRPC implementations ship in Svrnty.CQRS.Altcha.Grpc
- IMobileAttestationProvider — Phase 3 placeholder; concrete impls
  stamp ctx.Items["mobile_attested"] for the Altcha check to read as
  a bypass when AllowMobileAttestationBypass is true
- AltchaChallenge / AltchaVerifyResult DTOs

Lean dependencies — only references Svrnty.CQRS.Abstractions for the
auth-check pipeline types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:22:33 -04:00
Mathias Beaulieu-Duncan 86d87424ab feat(security): add ICommandAuthorizationCheck/IQueryAuthorizationCheck seam
Introduces a non-breaking, multi-instance authorization-check pipeline
that runs alongside the existing single-instance auth services.

Motivation
- Cross-cutting checks (proof-of-work, mobile attestation, rate-limit
  gates, IP allow-lists) don't belong in consumer auth services — they
  ship from framework modules and self-apply via attributes.
- The existing ICommandAuthorizationService takes only a Type; checks
  need the request *instance* to read payload fields (e.g. an Altcha
  solution carried on the command).

Shape
- New abstractions: ICommandAuthorizationCheck, IQueryAuthorizationCheck,
  CommandAuthorizationCheckContext, QueryAuthorizationCheckContext.
- Context carries (Type, Instance, IServiceProvider, Items dict). The
  Items dict lets sibling checks signal one another — e.g. a future
  mobile-attestation check stamps "mobile_attested" for the Altcha
  check to read as a bypass.
- AND semantics: framework resolves IEnumerable<…Check>, runs each in
  registration order, first non-Allowed short-circuits.
- Wired into MinimalApi (commands + queries, POST + GET) and the
  Svrnty.CQRS.Grpc.Generators source generator (commands, queries,
  dynamic queries). In all paths the checks run AFTER the instance
  is materialized and validated, BEFORE handler invocation.

Backward compatibility
- No registered checks = today's behavior exactly.
- ICommandAuthorizationService / IQueryAuthorizationService signatures
  unchanged; consumers' existing auth services keep working untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:21:20 -04:00
Mathias Beaulieu-Duncan a05ebad7fc Fix CS8601 in generated proto→command list mappings
Publish NuGets / build (release) Successful in 28s
Generated CommandServiceImpl.g.cs had warnings like:
    Slug = request.Slug?.ToList(),   // CS8601 if Slug is non-nullable List<T>

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 03:30:27 -04:00
107 changed files with 5475 additions and 6386 deletions
Vendored
BIN
View File
Binary file not shown.
-50
View File
@@ -1,50 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet clean:*)",
"Bash(dotnet run)",
"Bash(dotnet add:*)",
"Bash(timeout 5 dotnet run:*)",
"Bash(dotnet remove:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(cat:*)",
"Bash(taskkill:*)",
"WebSearch",
"Bash(dotnet tool install:*)",
"Bash(protogen:*)",
"Bash(timeout 15 dotnet run:*)",
"Bash(where:*)",
"Bash(timeout 30 dotnet run:*)",
"Bash(timeout 60 dotnet run:*)",
"Bash(timeout 120 dotnet run:*)",
"Bash(git add:*)",
"Bash(curl:*)",
"Bash(timeout 3 cmd:*)",
"Bash(timeout:*)",
"Bash(tasklist:*)",
"Bash(dotnet build:*)",
"Bash(dotnet --list-sdks:*)",
"Bash(dotnet sln:*)",
"Bash(pkill:*)",
"Bash(python3:*)",
"Bash(grpcurl:*)",
"Bash(lsof:*)",
"Bash(xargs kill -9)",
"Bash(dotnet run:*)",
"Bash(find:*)",
"Bash(dotnet pack:*)",
"Bash(unzip:*)",
"WebFetch(domain:andrewlock.net)",
"WebFetch(domain:github.com)",
"WebFetch(domain:stackoverflow.com)",
"WebFetch(domain:www.kenmuse.com)",
"WebFetch(domain:blog.rsuter.com)",
"WebFetch(domain:natemcmaster.com)",
"WebFetch(domain:www.nuget.org)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}
+50 -215
View File
@@ -1,7 +1,5 @@
# Top-most EditorConfig file
root = true
# All files
[*]
indent_style = space
indent_size = 4
@@ -10,252 +8,89 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
# XML project files
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
[*.{csproj,props,targets,xml}]
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]
# Namespace
csharp_style_namespace_declarations = file_scoped:warning
#### Core EditorConfig Options ####
# Braces — Allman style
csharp_new_line_before_open_brace = all
# Indentation and spacing
indent_size = 4
indent_style = space
tab_width = 4
#### .NET Coding Conventions ####
# Organise usings
# Usings
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
csharp_using_directive_placement = outside_namespace:warning
# 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
# var preferences — use var when type is apparent
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Expression-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
# Expression bodies — prefer for simple members
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_constructors = false:suggestion
csharp_style_expression_bodied_operators = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
csharp_style_expression_bodied_accessors = true:suggestion
csharp_style_expression_bodied_lambdas = true:suggestion
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
# Pattern matching
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
csharp_style_pattern_matching_over_as_with_null_check = 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
# Null checking
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:suggestion
# Modifier preferences — exclude interface members (netstandard2.1 compat)
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
# Namespace preferences
csharp_style_namespace_declarations = file_scoped:suggestion
# Field naming — _camelCase for private fields
dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore
#### C# Formatting Rules ####
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
# 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
dotnet_naming_style.camel_case_underscore.required_prefix = _
dotnet_naming_style.camel_case_underscore.capitalization = camel_case
# 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
# Constants — PascalCase
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case
# 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_symbols.constants.applicable_kinds = field
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_style.pascal_case.capitalization = pascal_case
# Interfaces — I prefix
dotnet_naming_rule.interfaces_should_begin_with_i.severity = warning
dotnet_naming_rule.interfaces_should_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_should_begin_with_i.style = begins_with_i
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.camel_case_with_underscore.required_prefix = _
dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case
# Async methods — Async suffix
dotnet_naming_rule.async_methods_should_end_with_async.severity = suggestion
dotnet_naming_rule.async_methods_should_end_with_async.symbols = async_methods
dotnet_naming_rule.async_methods_should_end_with_async.style = ends_with_async
dotnet_naming_symbols.async_methods.applicable_kinds = method
dotnet_naming_symbols.async_methods.required_modifiers = async
dotnet_naming_style.ends_with_async.required_suffix = Async
dotnet_naming_style.ends_with_async.capitalization = pascal_case
dotnet_naming_style.begins_with_t.required_prefix = T
dotnet_naming_style.begins_with_t.capitalization = pascal_case
-9
View File
@@ -1,9 +0,0 @@
# 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
-35
View File
@@ -1,35 +0,0 @@
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
-37
View File
@@ -1,37 +0,0 @@
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
-47
View File
@@ -1,47 +0,0 @@
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 }}"
-86
View File
@@ -1,86 +0,0 @@
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 }}
-34
View File
@@ -1,34 +0,0 @@
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
+1 -11
View File
@@ -4,7 +4,6 @@
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
.research/
.DS_Store
# User-specific files
*.rsuser
@@ -340,13 +339,4 @@ ASALocalRun/
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Secrets and credentials
.env
.env.local
.env.*
*.key
secrets/
.aws/
credentials.json
healthchecksdb
-14
View File
@@ -1,14 +0,0 @@
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
-22
View File
@@ -1,22 +0,0 @@
# 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
+402 -36
View File
@@ -1,47 +1,413 @@
# Development Guidelines
# CLAUDE.md
> **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.
This file provides guidance to AI agents when working with code in this repository.
## Quick Reference
## Project Overview
- **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
This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Segregation (CQRS) for .NET 10. It was forked from PoweredSoft.CQRS and provides:
## Tech Stack
- 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)
| Tool | Version |
|------|---------|
| C# | 14 |
| .NET | 10.0 |
| AOT | enabled (IsAotCompatible=true) |
| Nullable | enabled |
## Solution Structure
## Commands
The solution contains 11 projects organized by responsibility (10 packages + 1 sample project):
| Command | Description |
|---------|-------------|
| `dotnet build` | Build all 18 projects |
| `dotnet test` | Run tests |
| `dotnet format` | Format code |
**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
## Key Dependencies
**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
| 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 |
**Sample Projects:**
- `Svrnty.Sample` - Comprehensive demo project showcasing both HTTP and gRPC endpoints
## Repo-Specific Notes
**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.
- 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`.
## 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
- **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
-52
View File
@@ -1,52 +0,0 @@
# 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 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 svrnty
Copyright (c) 2021 Powered Softwares Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+252 -51
View File
@@ -1,81 +1,282 @@
# Svrnty.CQRS
> 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
> Modern CQRS framework for .NET with gRPC source generation and HTTP Minimal API support.
# CQRS
Our implementation of query and command responsibility segregation (CQRS).
## Where This Fits
**Layer**: libs
This is a backend framework of the [Svrnty Agent System](../README.md).
**Layer**: Framework
**Depends on**: Nothing (standalone .NET framework)
**Depended on by**: a-gent-app (backend services), flutter-cqrs-datasource (client)
**Git**: git.openharbor.io/svrnty/dotnet-cqrs.git
**Depended on by**: a-gent-app (backend services), flutter_cqrs_datasource (client)
**Git**: [git.openharbor.io/svrnty/dotnet-cqrs](https://git.openharbor.io/svrnty/dotnet-cqrs)
## Tech Stack
## Getting Started
- **Language**: C# 14 / .NET 10
- **Framework**: ASP.NET Core Minimal API, gRPC
- **Key Dependencies**: FluentValidation 11.x, Grpc.AspNetCore, PoweredSoft.DynamicQuery
> Install nuget package to your awesome project.
## Quick Start
| Package Name | NuGet | NuGet Install |
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
| Svrnty.CQRS | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS/) | ```dotnet add package Svrnty.CQRS ``` |
| Svrnty.CQRS.MinimalApi | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.MinimalApi.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.MinimalApi/) | ```dotnet add package Svrnty.CQRS.MinimalApi ``` |
| Svrnty.CQRS.FluentValidation | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.FluentValidation.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.FluentValidation/) | ```dotnet add package Svrnty.CQRS.FluentValidation ``` |
| Svrnty.CQRS.DynamicQuery | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery/) | ```dotnet add package Svrnty.CQRS.DynamicQuery ``` |
| Svrnty.CQRS.DynamicQuery.MinimalApi | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.MinimalApi.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.MinimalApi/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi ``` |
| Svrnty.CQRS.Grpc | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Grpc.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Grpc/) | ```dotnet add package Svrnty.CQRS.Grpc ``` |
| Svrnty.CQRS.Grpc.Generators | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Grpc.Generators.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Generators/) | ```dotnet add package Svrnty.CQRS.Grpc.Generators ``` |
```bash
# Build
dotnet build
> Abstractions Packages.
# Run
dotnet run --project Svrnty.Sample
| Package Name | NuGet | NuGet Install |
| ---------------------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -----------------------------------------------------: |
| Svrnty.CQRS.Abstractions | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Abstractions.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Abstractions/) | ```dotnet add package Svrnty.CQRS.Abstractions ``` |
| Svrnty.CQRS.DynamicQuery.Abstractions | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.Abstractions.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.Abstractions/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions ``` |
| Svrnty.CQRS.Grpc.Abstractions | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Grpc.Abstractions.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Abstractions/) | ```dotnet add package Svrnty.CQRS.Grpc.Abstractions ``` |
# Test
dotnet test
```
## Architecture
18 NuGet packages organized by concern:
- **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)
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for a full dependency diagram and data flow.
## Configuration
## Sample of startup code for gRPC (Recommended)
```csharp
// Register handlers
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddQuery<GetUserQuery, User, GetUserQueryHandler>();
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
// Configure CQRS with gRPC + HTTP
var builder = WebApplication.CreateBuilder(args);
// Register your commands with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Register your queries
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Configure CQRS with gRPC support
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc => grpc.EnableReflection());
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
});
var app = builder.Build();
// Map all configured CQRS endpoints
app.UseSvrntyCqrs();
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:
```bash
dotnet add package Grpc.AspNetCore
dotnet add package Grpc.AspNetCore.Server.Reflection
dotnet add package Grpc.StatusProto # For Rich Error Model validation
```
#### 2. Add the source generator as an analyzer:
```bash
dotnet add package Svrnty.CQRS.Grpc.Generators
```
The source generator is automatically configured as an analyzer when installed via NuGet and will generate both the `.proto` files and gRPC service implementations at compile time.
#### 3. Define your C# commands and queries:
```csharp
public record AddUserCommand
{
public required string Name { get; init; }
public required string Email { get; init; }
public int Age { get; init; }
}
public record RemoveUserCommand
{
public int UserId { get; init; }
}
```
**Notes:**
- The source generator automatically creates:
- `.proto` files in the `Protos/` directory from your C# commands and queries
- `CommandServiceImpl` and `QueryServiceImpl` implementations
- 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 - just define your C# types
## Sample of startup code for Minimal API (HTTP)
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args);
// Register your commands with validators
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Register your queries
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
// Configure CQRS with Minimal API support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable Minimal API endpoints
cqrs.AddMinimalApi();
});
// Add Swagger (optional)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Map all configured CQRS endpoints (automatically creates POST /api/command/* and POST/GET /api/query/*)
app.UseSvrntyCqrs();
app.Run();
```
## Documentation
**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
- [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
## Sample enabling both gRPC and HTTP
## Related Libraries
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
- **[flutter_cqrs_datasource](https://git.openharbor.io/svrnty/flutter_cqrs_datasource)** -- Flutter/Dart counterpart for consuming Svrnty.CQRS services from mobile and desktop apps
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.CQRS.MinimalApi;
## Contributing
var builder = WebApplication.CreateBuilder(args);
See [CLAUDE.md](./CLAUDE.md) for development guidelines.
// Register your commands with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
## License
// Register your queries
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
MIT OR Apache-2.0
// Configure CQRS with both gRPC and Minimal API support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
// Enable Minimal API endpoints
cqrs.AddMinimalApi();
});
// Add HTTP support with Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Map all configured CQRS endpoints (both gRPC and HTTP)
app.UseSvrntyCqrs();
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
# Fluent Validation
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
## 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 Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
// Command with result - validator as last generic parameter
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Command without result - validator included in generics
builder.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
## Without Svrnty.CQRS.FluentValidation
If you prefer not to use the FluentValidation package, you need to register commands and validators separately:
```csharp
using FluentValidation;
using Svrnty.CQRS;
// Register command handler
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler>();
// Manually register validator
builder.Services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
```
# 2024-2025 Roadmap
| Task | Description | Status |
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
| Support .NET 10 | .NET 10 with C# 14 language support. | ✅ |
| Update FluentValidation | Upgrade FluentValidation to version 11.x for .NET 10 compatibility. | ✅ |
| Add gRPC Support with source generators | Implement gRPC endpoints with source generators and Google Rich Error Model for validation. | ✅ |
| Create a demo project (Svrnty.CQRS.Grpc.Sample) | Develop a comprehensive demo project showcasing gRPC and HTTP endpoints. | ✅ |
| Create a website for the Framework | Develop a website to host comprehensive documentation for the framework. | ⬜️ |
# 2026 Roadmap
| Task | Description | Status |
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
| gRPC Compression Support | Smart message compression with automatic threshold detection and per-handler control. | ⬜️ |
| gRPC Metadata & Authorization Support | Expose ServerCallContext to handlers and integrate authorization services for gRPC endpoints. | ⬜️ |
+122
View File
@@ -0,0 +1,122 @@
# Saga Orchestration Roadmap
## Completed (Phase 1)
- [x] `Svrnty.CQRS.Sagas.Abstractions` - Core interfaces and contracts
- [x] `Svrnty.CQRS.Sagas` - Orchestration engine with fluent builder API
- [x] `Svrnty.CQRS.Sagas.RabbitMQ` - RabbitMQ message transport
---
## Phase 1d: Testing & Sample
### Unit Tests
- [ ] `SagaBuilder` step configuration tests
- [ ] `SagaOrchestrator` execution flow tests
- [ ] `SagaOrchestrator` compensation flow tests
- [ ] `InMemorySagaStateStore` persistence tests
- [ ] `RabbitMqSagaMessageBus` serialization tests
### Integration Tests
- [ ] End-to-end saga execution with RabbitMQ
- [ ] Multi-step saga with compensation scenario
- [ ] Concurrent saga execution tests
- [ ] Connection recovery tests
### Sample Implementation
- [ ] `OrderProcessingSaga` example in WarehouseManagement
- ReserveInventory step
- ProcessPayment step
- CreateShipment step
- Full compensation flow
---
## Phase 2: Persistence
### Svrnty.CQRS.Sagas.EntityFramework
- [ ] `EfCoreSagaStateStore` implementation
- [ ] `SagaState` entity configuration
- [ ] Migration support
- [ ] PostgreSQL/SQL Server compatibility
- [ ] Optimistic concurrency handling
### Configuration
```csharp
cqrs.AddSagas()
.UseEntityFramework<AppDbContext>();
```
---
## Phase 3: Reliability
### Saga Timeout Service
- [ ] `SagaTimeoutHostedService` - background service for stalled sagas
- [ ] Configurable timeout per saga type
- [ ] Automatic compensation trigger on timeout
- [ ] Dead letter handling for failed compensations
### Retry Policies
- [ ] Exponential backoff support
- [ ] Circuit breaker integration
- [ ] Polly integration option
### Idempotency
- [ ] Message deduplication
- [ ] Idempotent step execution
- [ ] Inbox/Outbox pattern support
---
## Phase 4: Observability
### OpenTelemetry Integration
- [ ] Distributed tracing for saga execution
- [ ] Span per saga step
- [ ] Correlation ID propagation
- [ ] Metrics (saga duration, success/failure rates)
### Saga Dashboard (Optional)
- [ ] Web UI for saga monitoring
- [ ] Real-time saga status
- [ ] Manual compensation trigger
- [ ] Saga history and audit log
---
## Phase 5: Flutter Integration
### gRPC Streaming for Saga Status
- [ ] `ISagaStatusStream` service
- [ ] Real-time saga progress updates
- [ ] Step completion notifications
- [ ] Error/compensation notifications
### Flutter Client
- [ ] Dart client for saga status streaming
- [ ] Saga progress widget components
---
## Phase 6: Alternative Transports
### Svrnty.CQRS.Sagas.AzureServiceBus
- [ ] Azure Service Bus message transport
- [ ] Topic/Subscription topology
- [ ] Dead letter queue handling
### Svrnty.CQRS.Sagas.Kafka
- [ ] Kafka message transport
- [ ] Consumer group management
- [ ] Partition key strategies
---
## Future Considerations
- **Event Sourcing**: Saga state as event stream
- **Saga Versioning**: Handle saga definition changes gracefully
- **Saga Composition**: Nested/child sagas
- **Saga Scheduling**: Delayed saga start
- **Multi-tenancy**: Tenant-aware saga execution
-52
View File
@@ -1,52 +0,0 @@
# 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 |
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
@@ -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
{
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -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; }
}
@@ -1,12 +1,12 @@
using System;
using System;
using System.Collections.Generic;
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();
}
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -10,4 +10,4 @@ public interface IQueryMeta
Type QueryResultType { get; }
string Category { get; }
string LowerCamelCaseName { get; }
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
@@ -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
{
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -13,4 +13,4 @@ public interface ICommandHandler<in TCommand, TCommandResult>
where TCommand : class
{
Task<TCommandResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -7,4 +7,4 @@ public interface IQueryHandler<in TQuery, TQueryResult>
where TQuery : class
{
Task<TQueryResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Abstractions.Security;
/// <summary>
/// Shared shape for command and query authorization-check contexts. Checks
/// receive the request type, the materialized (and validated) request instance,
/// a scoped <see cref="IServiceProvider"/>, and a free-form <see cref="Items"/>
/// dictionary that lets checks in the same pipeline pass signals to each other
/// (e.g. a future mobile-attestation check stamping "mobile_attested" for the
/// Altcha check to read).
/// </summary>
public abstract class AuthorizationCheckContext
{
public required IServiceProvider Services { get; init; }
public IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
}
public sealed class CommandAuthorizationCheckContext : AuthorizationCheckContext
{
public required Type CommandType { get; init; }
public required object Command { get; init; }
}
public sealed class QueryAuthorizationCheckContext : AuthorizationCheckContext
{
public required Type QueryType { get; init; }
public required object Query { get; init; }
}
@@ -1,8 +1,8 @@
namespace Svrnty.CQRS.Abstractions.Security;
namespace Svrnty.CQRS.Abstractions.Security;
public enum AuthorizationResult
{
Unauthorized,
Forbidden,
Allowed
}
}
@@ -0,0 +1,27 @@
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions.Security;
/// <summary>
/// Cross-cutting authorization check that runs alongside (not in place of) the
/// consumer's <see cref="ICommandAuthorizationService"/>. Multiple
/// implementations may be registered; the framework resolves them as
/// <c>IEnumerable&lt;ICommandAuthorizationCheck&gt;</c> and runs each in
/// registration order. AND semantics — any non-<see cref="AuthorizationResult.Allowed"/>
/// short-circuits the pipeline.
/// </summary>
/// <remarks>
/// Use this seam for self-applying, attribute-driven checks shipped by
/// framework modules (proof-of-work, mobile attestation, rate-limit gates,
/// IP allow-lists). The check is responsible for inspecting
/// <see cref="CommandAuthorizationCheckContext.CommandType"/> attributes and
/// no-op'ing (return <see cref="AuthorizationResult.Allowed"/>) when it
/// doesn't apply.
/// </remarks>
public interface ICommandAuthorizationCheck
{
Task<AuthorizationResult> CheckAsync(
CommandAuthorizationCheckContext context,
CancellationToken cancellationToken = default);
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
public interface ICommandAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions.Security;
/// <summary>
/// Query-side counterpart to <see cref="ICommandAuthorizationCheck"/>. See
/// that interface's remarks for usage.
/// </summary>
public interface IQueryAuthorizationCheck
{
Task<AuthorizationResult> CheckAsync(
QueryAuthorizationCheckContext context,
CancellationToken cancellationToken = default);
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
public interface IQueryAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type queryType, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -47,4 +47,4 @@ public static class ServiceCollectionExtensions
return services;
}
}
}
@@ -0,0 +1,27 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Marks a command or query as requiring proof-of-work (or equivalent
/// anti-abuse evidence) before the framework will dispatch it to the handler.
/// </summary>
/// <remarks>
/// The accompanying request type should implement <see cref="IHasAltchaSolution"/>
/// to carry the widget's solution payload. The framework's Altcha
/// authorization check (registered via <c>AddSvrntyAltcha()</c>) reads the
/// solution off the request and calls the configured <see cref="IAltchaVerifier"/>.
/// </remarks>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class AltchaAttribute : Attribute
{
/// <summary>
/// When <c>true</c> (default), a valid mobile-attestation token on the
/// request satisfies the requirement without needing a proof-of-work
/// solution. The Altcha check reads
/// <see cref="Svrnty.CQRS.Abstractions.Security.AuthorizationCheckContext.Items"/>[<c>"mobile_attested"</c>]
/// — when stamped <c>true</c> by an earlier check (e.g. an Apple
/// App Attest / Play Integrity verifier), the PoW check is skipped.
/// Set to <c>false</c> on commands where PoW must always run regardless
/// of caller.
/// </summary>
public bool AllowMobileAttestationBypass { get; set; } = true;
}
@@ -0,0 +1,28 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Server-issued Altcha challenge. Shape matches what the
/// <a href="https://altcha.org/docs/v2/widget-v3/">altcha widget v3</a>
/// expects from its <c>challengeurl</c>.
/// </summary>
public sealed class AltchaChallenge
{
/// <summary>Hashing algorithm name (e.g. <c>SHA-256</c>).</summary>
public required string Algorithm { get; init; }
/// <summary>Hex-encoded hash the client must find a preimage for.</summary>
public required string Challenge { get; init; }
/// <summary>Hex-encoded salt (embeds an expiry timestamp).</summary>
public required string Salt { get; init; }
/// <summary>
/// HMAC-SHA256 of <c>algorithm|challenge|salt|maxnumber</c> using the
/// server's signing secret. Lets the verify step trust the issued
/// challenge without keeping per-challenge state.
/// </summary>
public required string Signature { get; init; }
/// <summary>Upper bound of the PoW search space (equals the requested complexity).</summary>
public required uint MaxNumber { get; init; }
}
@@ -0,0 +1,20 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Outcome of an Altcha solution verification.
/// </summary>
public sealed class AltchaVerifyResult
{
public required bool Ok { get; init; }
/// <summary>
/// Diagnostic only — not surfaced to end users. Suggested values:
/// <c>signature-invalid</c>, <c>expired</c>, <c>pow-incorrect</c>,
/// <c>replayed</c>, <c>redis-unreachable</c>, <c>malformed</c>.
/// </summary>
public string? Reason { get; init; }
public static AltchaVerifyResult Success { get; } = new() { Ok = true };
public static AltchaVerifyResult Fail(string reason) => new() { Ok = false, Reason = reason };
}
@@ -0,0 +1,13 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Mints an Altcha challenge for the widget to solve. The default
/// implementation in <c>Svrnty.CQRS.Altcha.Grpc</c> talks to a self-hosted
/// altcha service; the <c>Svrnty.CQRS.Altcha.MinimalApi</c> package exposes
/// <c>GET /api/altcha/challenge</c> that returns this DTO as the JSON
/// shape the widget expects.
/// </summary>
public interface IAltchaChallengeProvider
{
Task<AltchaChallenge> CreateAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,27 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Resolves a per-request PoW complexity (search-space upper bound) for the
/// next Altcha challenge the server will mint. Implementations may consult
/// per-actor signals — repeat-offender counters, threat-intel headers,
/// reputation scores — to scale difficulty up for suspicious actors while
/// keeping the baseline cheap for everyone else.
/// </summary>
/// <remarks>
/// The framework ships a no-op <see cref="IAltchaDifficultyAdvisor"/> that
/// always returns <c>null</c>, meaning "use the upstream service's configured
/// default complexity." Applications opt into adaptive difficulty by
/// replacing the registration with their own implementation; consult request
/// context via <see cref="Microsoft.AspNetCore.Http.IHttpContextAccessor"/>
/// or scoped DI.
/// </remarks>
public interface IAltchaDifficultyAdvisor
{
/// <summary>
/// Returns the desired <c>maxNumber</c> (PoW search-space upper bound)
/// for the next challenge, or <c>null</c> to defer to the upstream
/// service default. The Altcha server clamps to its configured min/max,
/// so callers don't need to enforce bounds here.
/// </summary>
Task<uint?> GetComplexityAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,18 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Verifies an Altcha solution payload. The default implementation in
/// <c>Svrnty.CQRS.Altcha.Grpc</c> talks to a self-hosted altcha service
/// over gRPC; consumers may register their own implementation (e.g. an
/// in-process variant or a different transport) and the
/// <see cref="ICommandAuthorizationCheck"/>-based pipeline picks it up
/// automatically.
/// </summary>
public interface IAltchaVerifier
{
/// <param name="payload">
/// Base64-encoded JSON payload produced by the Altcha widget — the same
/// string carried on <see cref="IHasAltchaSolution.AltchaSolution"/>.
/// </param>
Task<AltchaVerifyResult> VerifyAsync(string payload, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,16 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Implemented by command and query POCOs that carry an Altcha widget
/// solution. The framework's Altcha check reads <see cref="AltchaSolution"/>
/// off the materialized request, so the value travels naturally over HTTP
/// (JSON body field) and gRPC (proto field) without any extra plumbing.
/// </summary>
public interface IHasAltchaSolution
{
/// <summary>
/// Base64-encoded JSON payload produced by the Altcha widget. Null /
/// empty causes the check to reject the request.
/// </summary>
string? AltchaSolution { get; set; }
}
@@ -0,0 +1,21 @@
namespace Svrnty.CQRS.Altcha.Abstractions;
/// <summary>
/// Phase 3 placeholder — when a future module implements Apple App Attest /
/// Google Play Integrity verification, it stamps
/// <see cref="Svrnty.CQRS.Abstractions.Security.AuthorizationCheckContext.Items"/>[<c>"mobile_attested"</c>]
/// based on the verification result, and the Altcha check reads that flag
/// to short-circuit when <see cref="AltchaAttribute.AllowMobileAttestationBypass"/>
/// is true.
/// </summary>
/// <remarks>
/// Intentionally left abstract and unwired in this phase. The interface
/// exists so Phase 3 can drop in an implementation without touching command
/// definitions or the Altcha check.
/// </remarks>
public interface IMobileAttestationProvider
{
/// <param name="attestationToken">Platform-specific attestation token from the request.</param>
/// <returns><c>true</c> if attestation passes; <c>false</c> otherwise.</returns>
Task<bool> VerifyAsync(string attestationToken, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,33 @@
<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>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
using Grpc.Core;
namespace Svrnty.CQRS.Altcha.Grpc;
/// <summary>
/// Helper that builds gRPC call metadata (an <c>Authorization</c> header)
/// from <see cref="AltchaGrpcOptions.TokenProvider"/>. Kept as a separate
/// shared helper so the verifier and challenge provider apply identical
/// rules.
/// </summary>
internal static class AltchaCallCredentials
{
public static async Task<Metadata?> BuildMetadataAsync(AltchaGrpcOptions options, CancellationToken cancellationToken)
{
if (options.TokenProvider is null)
return null;
var token = await options.TokenProvider(cancellationToken);
if (string.IsNullOrWhiteSpace(token))
return null;
return new Metadata
{
{ "Authorization", $"Bearer {token}" }
};
}
}
@@ -0,0 +1,69 @@
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha.Grpc;
/// <summary>
/// Default <see cref="IAltchaChallengeProvider"/> backed by gRPC. Calls
/// <c>AltchaService.CreateChallenge</c> on the configured endpoint and
/// projects the response onto <see cref="AltchaChallenge"/>.
/// </summary>
public sealed class AltchaGrpcChallengeProvider : IAltchaChallengeProvider
{
private readonly AltchaService.AltchaServiceClient _client;
private readonly IOptions<AltchaGrpcOptions> _options;
private readonly IAltchaDifficultyAdvisor _advisor;
private readonly ILogger<AltchaGrpcChallengeProvider> _logger;
public AltchaGrpcChallengeProvider(
AltchaService.AltchaServiceClient client,
IOptions<AltchaGrpcOptions> options,
IAltchaDifficultyAdvisor advisor,
ILogger<AltchaGrpcChallengeProvider> logger)
{
_client = client;
_options = options;
_advisor = advisor;
_logger = logger;
}
public async Task<AltchaChallenge> CreateAsync(CancellationToken cancellationToken = default)
{
var opts = _options.Value;
var metadata = await AltchaCallCredentials.BuildMetadataAsync(opts, cancellationToken);
var deadline = DateTime.UtcNow.Add(opts.CallTimeout);
var request = new CreateChallengeRequest();
var advisedComplexity = await _advisor.GetComplexityAsync(cancellationToken);
if (advisedComplexity is uint complexity)
{
request.Complexity = complexity;
_logger.LogDebug("Altcha advisor requested complexity {Complexity}", complexity);
}
try
{
var response = await _client.CreateChallengeAsync(
request,
headers: metadata,
deadline: deadline,
cancellationToken: cancellationToken);
return new AltchaChallenge
{
Algorithm = response.Algorithm,
Challenge = response.ChallengeHash,
Salt = response.Salt,
Signature = response.Signature,
MaxNumber = response.Maxnumber
};
}
catch (RpcException ex)
{
_logger.LogError(ex, "Altcha create-challenge failed against {Endpoint}: {Status}", opts.Endpoint, ex.StatusCode);
throw;
}
}
}
@@ -0,0 +1,31 @@
namespace Svrnty.CQRS.Altcha.Grpc;
/// <summary>
/// Configuration for <see cref="AltchaGrpcVerifier"/> and
/// <see cref="AltchaGrpcChallengeProvider"/>. Bind from configuration
/// (e.g. <c>"Altcha"</c> section) or pass via the registration delegate.
/// </summary>
public sealed class AltchaGrpcOptions
{
/// <summary>
/// gRPC endpoint of the altcha service. Typically the internal
/// docker / k8s address — e.g. <c>http://altcha:9090</c> or
/// <c>https://altcha.planb.svc.cluster.local:9090</c>.
/// </summary>
public string Endpoint { get; set; } = string.Empty;
/// <summary>
/// Optional per-call HMAC service-token provider. When set, the
/// returned string is sent as <c>Authorization: Bearer &lt;token&gt;</c>
/// on every outbound gRPC call. Use this to integrate with whatever
/// service-auth scheme the rest of the deployment uses (e.g. plan-b's
/// <c>ServiceTokenIssuer.GetToken("altcha")</c>).
/// </summary>
public Func<CancellationToken, Task<string>>? TokenProvider { get; set; }
/// <summary>
/// Per-call timeout for both <c>CreateChallenge</c> and
/// <c>VerifyChallenge</c>. Defaults to 5s.
/// </summary>
public TimeSpan CallTimeout { get; set; } = TimeSpan.FromSeconds(5);
}
@@ -0,0 +1,66 @@
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha.Grpc;
/// <summary>
/// Default <see cref="IAltchaVerifier"/> backed by gRPC. Calls
/// <c>AltchaService.VerifyChallenge</c> on the configured endpoint and
/// maps any failure (transport error, deadline, server-reported failure)
/// to <see cref="AltchaVerifyResult.Fail"/>. Verification failures are
/// safe defaults — callers see an <c>Unauthorized</c> outcome from the
/// auth check.
/// </summary>
public sealed class AltchaGrpcVerifier : IAltchaVerifier
{
private readonly AltchaService.AltchaServiceClient _client;
private readonly IOptions<AltchaGrpcOptions> _options;
private readonly ILogger<AltchaGrpcVerifier> _logger;
public AltchaGrpcVerifier(
AltchaService.AltchaServiceClient client,
IOptions<AltchaGrpcOptions> options,
ILogger<AltchaGrpcVerifier> logger)
{
_client = client;
_options = options;
_logger = logger;
}
public async Task<AltchaVerifyResult> VerifyAsync(string payload, CancellationToken cancellationToken = default)
{
var opts = _options.Value;
try
{
var metadata = await AltchaCallCredentials.BuildMetadataAsync(opts, cancellationToken);
var deadline = DateTime.UtcNow.Add(opts.CallTimeout);
var response = await _client.VerifyChallengeAsync(
new VerifyChallengeRequest { Payload = payload },
headers: metadata,
deadline: deadline,
cancellationToken: cancellationToken);
return response.Ok
? AltchaVerifyResult.Success
: AltchaVerifyResult.Fail(string.IsNullOrEmpty(response.Reason) ? "invalid" : response.Reason);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
{
_logger.LogWarning(ex, "Altcha verify timed out against {Endpoint}.", opts.Endpoint);
return AltchaVerifyResult.Fail("verify-timeout");
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
{
_logger.LogWarning(ex, "Altcha service unavailable at {Endpoint}.", opts.Endpoint);
return AltchaVerifyResult.Fail("service-unavailable");
}
catch (RpcException ex)
{
_logger.LogWarning(ex, "Altcha verify failed against {Endpoint}: {Status}", opts.Endpoint, ex.StatusCode);
return AltchaVerifyResult.Fail("rpc-error");
}
}
}
@@ -0,0 +1,69 @@
syntax = "proto3";
package svrnty.cqrs.altcha.v1;
option go_package = "svrnty.cqrs.altcha.v1;altchapb";
option csharp_namespace = "Svrnty.CQRS.Altcha.Grpc";
// AltchaService is the wire contract between any backend that gates
// commands/queries with the [Altcha] attribute and a self-hosted Altcha
// implementation. The default .NET client lives in this package; a
// reference Go server lives in plan-b's projects/altcha (which vendors
// this proto). Keep this file the source of truth for both sides.
service AltchaService {
// Mint a fresh challenge for the widget to solve. Server-stateless:
// the response embeds an HMAC signature that VerifyChallenge re-checks.
rpc CreateChallenge(CreateChallengeRequest) returns (Challenge);
// Verify a widget-produced solution payload. The server checks
// signature + expiry + PoW correctness, then atomically claims the
// challenge in a replay-protection cache (e.g. Redis SETNX) so each
// solution is single-use across all server replicas.
rpc VerifyChallenge(VerifyChallengeRequest) returns (VerifyChallengeResponse);
}
message CreateChallengeRequest {
// Optional per-request complexity override (PoW search-space upper
// bound). Higher = slower client solve. Server clamps to its
// configured min/max. Omit to use the server default.
optional uint32 complexity = 1;
}
message Challenge {
// Hashing algorithm — e.g. "SHA-256" (v2 default). Future versions may
// upgrade to PBKDF2 / Argon2; the field lets the widget pick the
// right solver.
string algorithm = 1;
// Hex-encoded hash the client must find a preimage for. Field is
// named "challenge_hash" rather than "challenge" to avoid a C#
// property/class name collision (the generated message class is
// also named Challenge); JSON projection for the widget remaps it.
string challenge_hash = 2;
// Hex-encoded random salt. Typically embeds the expiry timestamp so
// verifiers can re-derive the TTL without server state.
string salt = 3;
// HMAC-SHA256(secret, algorithm|challenge|salt|maxnumber). Lets
// VerifyChallenge confirm the challenge was issued by this server.
string signature = 4;
// PoW search-space upper bound. Equals the complexity used.
uint32 maxnumber = 5;
}
message VerifyChallengeRequest {
// Base64-encoded JSON payload produced by the Altcha widget — the
// value carried over the wire on IHasAltchaSolution.AltchaSolution.
string payload = 1;
}
message VerifyChallengeResponse {
bool ok = 1;
// Diagnostic only; not surfaced to end users. Suggested values:
// "signature-invalid", "expired", "pow-incorrect", "replayed",
// "redis-unreachable", "malformed".
string reason = 2;
}
@@ -0,0 +1,64 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha.Grpc;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the gRPC-backed <see cref="IAltchaVerifier"/> and
/// <see cref="IAltchaChallengeProvider"/>. Configure the endpoint
/// and optional service-auth token provider via the
/// <paramref name="configure"/> delegate.
/// </summary>
/// <example>
/// <code>
/// services.AddSvrntyAltcha();
/// services.AddSvrntyAltchaGrpcVerifier(opts =>
/// {
/// opts.Endpoint = "http://altcha:9090";
/// opts.TokenProvider = async ct => await tokenIssuer.GetTokenAsync("altcha", ct);
/// });
/// </code>
/// </example>
public static IServiceCollection AddSvrntyAltchaGrpcVerifier(
this IServiceCollection services,
Action<AltchaGrpcOptions> configure)
{
services.Configure(configure);
RegisterCore(services);
return services;
}
/// <summary>
/// Binds <see cref="AltchaGrpcOptions"/> from a configuration section
/// (typically <c>"Altcha:Grpc"</c>) and registers the gRPC verifier
/// and challenge provider.
/// </summary>
public static IServiceCollection AddSvrntyAltchaGrpcVerifier(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<AltchaGrpcOptions>(configuration);
RegisterCore(services);
return services;
}
private static void RegisterCore(IServiceCollection services)
{
services.AddGrpcClient<AltchaService.AltchaServiceClient>((sp, client) =>
{
var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<AltchaGrpcOptions>>().Value;
if (string.IsNullOrWhiteSpace(opts.Endpoint))
throw new InvalidOperationException(
"Altcha gRPC endpoint not configured. Set AltchaGrpcOptions.Endpoint " +
"(e.g. http://altcha:9090).");
client.Address = new Uri(opts.Endpoint);
});
services.TryAddSingleton<IAltchaVerifier, AltchaGrpcVerifier>();
services.TryAddSingleton<IAltchaChallengeProvider, AltchaGrpcChallengeProvider>();
}
}
@@ -0,0 +1,47 @@
<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>
<PackageReference Include="Google.Protobuf" Version="3.30.2" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.71.0" />
<PackageReference Include="Grpc.Tools" Version="2.71.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\altcha.proto" GrpcServices="Client" />
</ItemGroup>
</Project>
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace Svrnty.CQRS.Altcha.MinimalApi;
/// <summary>
/// JSON projection of <see cref="Svrnty.CQRS.Altcha.Abstractions.AltchaChallenge"/>
/// in the exact shape the
/// <a href="https://altcha.org/docs/v2/widget-v3/">altcha widget v3</a>
/// expects from a <c>challengeurl</c> response. Property names are
/// lowercased and <c>challenge</c> (no underscore) to match the widget.
/// </summary>
public sealed class AltchaChallengeDto
{
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
[JsonPropertyName("challenge")]
public required string Challenge { get; init; }
[JsonPropertyName("salt")]
public required string Salt { get; init; }
[JsonPropertyName("signature")]
public required string Signature { get; init; }
[JsonPropertyName("maxnumber")]
public required uint MaxNumber { get; init; }
}
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha.MinimalApi;
public static class EndpointRouteBuilderExtensions
{
/// <summary>
/// Maps <c>GET {routePrefix}</c> (default <c>/api/altcha/challenge</c>)
/// returning a fresh challenge in the JSON shape the
/// <a href="https://altcha.org/docs/v2/widget-v3/">altcha widget</a>
/// consumes via its <c>challengeurl</c> attribute.
/// </summary>
/// <remarks>
/// Requires an <see cref="IAltchaChallengeProvider"/> to be registered
/// (typically by <c>AddSvrntyAltchaGrpcVerifier(...)</c>). The endpoint
/// allows anonymous access — the whole point is gating mutations from
/// unauthenticated callers, so the challenge endpoint must be reachable
/// without credentials.
/// </remarks>
public static IEndpointRouteBuilder MapSvrntyAltchaChallenge(
this IEndpointRouteBuilder endpoints,
string routePrefix = "/api/altcha/challenge")
{
endpoints.MapGet(routePrefix, async (
IAltchaChallengeProvider provider,
CancellationToken cancellationToken) =>
{
var challenge = await provider.CreateAsync(cancellationToken);
return Results.Ok(new AltchaChallengeDto
{
Algorithm = challenge.Algorithm,
Challenge = challenge.Challenge,
Salt = challenge.Salt,
Signature = challenge.Signature,
MaxNumber = challenge.MaxNumber
});
})
.AllowAnonymous()
.WithName("Altcha_Challenge_Get")
.WithTags("Altcha")
.Produces<AltchaChallengeDto>(200)
.Produces(503);
return endpoints;
}
}
@@ -0,0 +1,37 @@
<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>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<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.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,93 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Abstractions.Security;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha;
/// <summary>
/// The single check that powers the <see cref="AltchaAttribute"/>. Plays both
/// <see cref="ICommandAuthorizationCheck"/> and <see cref="IQueryAuthorizationCheck"/>
/// roles so a request payload of either kind is gated identically.
/// </summary>
/// <remarks>
/// Self-applying: the check no-ops (returns <see cref="AuthorizationResult.Allowed"/>)
/// for any request whose type isn't decorated with <see cref="AltchaAttribute"/>.
/// Resolves <see cref="IAltchaVerifier"/> from the request scope per call so the
/// check is agnostic to the verifier's lifetime.
/// </remarks>
public sealed class AltchaAuthorizationCheck : ICommandAuthorizationCheck, IQueryAuthorizationCheck
{
internal const string ReasonItemKey = "altcha_reason";
internal const string MobileAttestedItemKey = "mobile_attested";
private readonly ILogger<AltchaAuthorizationCheck> _logger;
public AltchaAuthorizationCheck(ILogger<AltchaAuthorizationCheck> logger)
{
_logger = logger;
}
public Task<AuthorizationResult> CheckAsync(
CommandAuthorizationCheckContext context,
CancellationToken cancellationToken = default)
=> CheckCoreAsync(context, context.CommandType, context.Command, cancellationToken);
public Task<AuthorizationResult> CheckAsync(
QueryAuthorizationCheckContext context,
CancellationToken cancellationToken = default)
=> CheckCoreAsync(context, context.QueryType, context.Query, cancellationToken);
private async Task<AuthorizationResult> CheckCoreAsync(
AuthorizationCheckContext context,
Type subjectType,
object subject,
CancellationToken cancellationToken)
{
var altchaAttr = subjectType.GetCustomAttribute<AltchaAttribute>(inherit: false);
if (altchaAttr is null)
return AuthorizationResult.Allowed;
if (altchaAttr.AllowMobileAttestationBypass
&& context.Items.TryGetValue(MobileAttestedItemKey, out var attested)
&& attested is true)
{
_logger.LogDebug("Altcha bypassed for {Type}: mobile attestation present.", subjectType.FullName);
return AuthorizationResult.Allowed;
}
if (subject is not IHasAltchaSolution carrier)
{
// Developer error: [Altcha] on a request that doesn't carry the solution field.
_logger.LogError(
"[Altcha] is set on {Type} but the type does not implement IHasAltchaSolution. " +
"Verification cannot proceed — treating as forbidden.",
subjectType.FullName);
context.Items[ReasonItemKey] = "misconfigured";
return AuthorizationResult.Forbidden;
}
if (string.IsNullOrWhiteSpace(carrier.AltchaSolution))
{
_logger.LogWarning("Altcha required for {Type} but no solution was supplied.", subjectType.FullName);
context.Items[ReasonItemKey] = "missing";
return AuthorizationResult.Unauthorized;
}
var verifier = context.Services.GetRequiredService<IAltchaVerifier>();
var result = await verifier.VerifyAsync(carrier.AltchaSolution, cancellationToken);
if (!result.Ok)
{
_logger.LogWarning(
"Altcha verification rejected {Type}: reason={Reason}",
subjectType.FullName,
result.Reason ?? "unspecified");
context.Items[ReasonItemKey] = result.Reason ?? "invalid";
return AuthorizationResult.Unauthorized;
}
return AuthorizationResult.Allowed;
}
}
@@ -0,0 +1,15 @@
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha;
/// <summary>
/// No-op fallback registered by <c>AddSvrntyAltcha()</c>. Always returns
/// <c>null</c>, leaving complexity at the upstream Altcha service's
/// configured default. Applications that want adaptive difficulty
/// replace this registration with their own implementation.
/// </summary>
internal sealed class NullAltchaDifficultyAdvisor : IAltchaDifficultyAdvisor
{
public Task<uint?> GetComplexityAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<uint?>(null);
}
@@ -0,0 +1,36 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Abstractions.Security;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.CQRS.Altcha;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="AltchaAuthorizationCheck"/> as both an
/// <see cref="ICommandAuthorizationCheck"/> and an
/// <see cref="IQueryAuthorizationCheck"/>, plus a no-op
/// <see cref="IAltchaDifficultyAdvisor"/> that defers to the upstream
/// Altcha service's configured default complexity. Applications opt
/// into adaptive difficulty by registering their own
/// <see cref="IAltchaDifficultyAdvisor"/> before or after this call —
/// the <c>TryAdd</c> registration here yields to any existing one.
/// </summary>
/// <remarks>
/// The check is a no-op until an
/// <see cref="IAltchaVerifier"/> implementation is also registered
/// (typically via <c>AddSvrntyAltchaGrpcVerifier(...)</c> from
/// <c>Svrnty.CQRS.Altcha.Grpc</c>). Idempotent for the concrete check;
/// the multi-instance interface registrations are added unconditionally,
/// so callers should invoke this exactly once per application startup.
/// </remarks>
public static IServiceCollection AddSvrntyAltcha(this IServiceCollection services)
{
services.TryAddSingleton<AltchaAuthorizationCheck>();
services.AddSingleton<ICommandAuthorizationCheck>(sp => sp.GetRequiredService<AltchaAuthorizationCheck>());
services.AddSingleton<IQueryAuthorizationCheck>(sp => sp.GetRequiredService<AltchaAuthorizationCheck>());
services.TryAddSingleton<IAltchaDifficultyAdvisor, NullAltchaDifficultyAdvisor>();
return services;
}
}
@@ -0,0 +1,39 @@
<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>
<ItemGroup>
<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.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -13,4 +13,4 @@ public interface IAlterQueryableService<TSource, TDestination, in TParams>
where TParams : class
{
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQueryParams<TParams> dynamicQuery, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
@@ -15,15 +15,15 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
where TDestination : class
where TParams : class
{
}
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();
}
}
@@ -1,9 +1,9 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryInterceptorProvider<TSource, TDestination>
{
IEnumerable<Type> GetInterceptorsTypes();
}
}
@@ -1,7 +1,7 @@
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryParams<out TParams>
where TParams : class
{
TParams? GetParams();
TParams GetParams();
}
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IQueryableProvider<TSource>
{
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
}
}
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Attributes;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -14,7 +15,6 @@ using Svrnty.CQRS.Abstractions.Security;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using Svrnty.CQRS.DynamicQuery.Discover;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery.MinimalApi;
@@ -1,4 +1,4 @@
using System;
using System;
using Pluralize.NET;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -7,7 +7,7 @@ namespace Svrnty.CQRS.DynamicQuery.Discover;
public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResultType)
: QueryMeta(queryType, serviceType, queryResultType)
{
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type DestinationType => QueryType.GetGenericArguments()[1];
public override string Category => "DynamicQuery";
public override string Name
@@ -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; }
}
+12 -12
View File
@@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -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();
return Aggregates?.Select(t => t.ToAggregate())?.ToList();//.AsEnumerable<IAggregate>()?.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();
}
@@ -1,13 +1,13 @@
using System;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using System;
namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryAggregate
{
public required string Path { get; set; }
public required string Type { get; set; }
public string Path { get; set; }
public 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)
+10 -10
View File
@@ -1,23 +1,23 @@
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryHandler<TSource, TDestination>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
{
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
}
@@ -29,7 +29,7 @@ public class DynamicQueryHandler<TSource, TDestination>
}
public class DynamicQueryHandler<TSource, TDestination, TParams>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
@@ -37,10 +37,10 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
{
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams;
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
@@ -49,7 +49,7 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
protected override async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
{
source = await base.AlterSourceAsync(source, query, cancellationToken);
source = await base.AlterSourceAsync(source, query, cancellationToken);
if (query is IDynamicQueryParams<TParams> withParams)
{
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -6,9 +6,9 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -60,10 +60,7 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
{
var types = _dynamicQueryInterceptorProviders.SelectMany(t => t.GetInterceptorsTypes()).Distinct();
foreach (var type in types)
{
if (_serviceProvider.GetService(type) is IQueryInterceptor interceptor)
yield return interceptor;
}
yield return _serviceProvider.GetService(type) as IQueryInterceptor;
}
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query,
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
@@ -26,11 +26,11 @@ public static class ServiceCollectionExtensions
return new DynamicQueryServicesBuilder(services);
}
public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string? name = null)
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
{
@@ -51,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
{
@@ -60,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
@@ -86,15 +86,15 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string? name = null)
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string name = null)
where TSourceAndDestination : class
where TParams : class
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string? name = null)
where TSource : class
where TDestination : class
where TParams : class
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
where TSource : class
where TDestination : class
where TParams : class
{
// add query handler.
services.AddTransient<IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>, DynamicQueryHandler<TSource, TDestination, TParams>>();
@@ -133,7 +133,7 @@ public static class ServiceCollectionExtensions
where TParams : class
where TService : class, IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>
{
return services.AddTransient<IAlterQueryableService< TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
return services.AddTransient<IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
}
public static IServiceCollection AddAlterQueryableWithParams<TSource, TDestination, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
{
services.AddQuery<TQuery, TQueryResult, TQueryHandler>()
.AddFluentValidator<TQuery, TValidator>();
return services;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,102 +1,101 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Helpers
namespace Svrnty.CQRS.Grpc.Generators.Helpers;
internal static class ProtoTypeMapper
{
internal static class ProtoTypeMapper
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
{
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
return csharpType.Replace("?", "");
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
return csharpType.Replace("?", "");
}
}
@@ -1,83 +1,82 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
namespace Svrnty.CQRS.Grpc.Generators.Models
namespace Svrnty.CQRS.Grpc.Generators.Models;
public class CommandInfo
{
public class CommandInfo
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
}
@@ -1,28 +1,27 @@
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public class DynamicQueryInfo
{
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
namespace Svrnty.CQRS.Grpc.Generators.Models;
public DynamicQueryInfo()
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
}
public class DynamicQueryInfo
{
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
public DynamicQueryInfo()
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
}
}
@@ -1,50 +1,49 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models
namespace Svrnty.CQRS.Grpc.Generators.Models;
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// </summary>
public class NotificationInfo
{
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public class NotificationInfo
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
/// <summary>
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
}
+24 -25
View File
@@ -1,30 +1,29 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public class QueryInfo
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string ResultType { get; set; }
public string ResultFullyQualifiedName { get; set; }
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
namespace Svrnty.CQRS.Grpc.Generators.Models;
public QueryInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
ResultType = string.Empty;
ResultFullyQualifiedName = string.Empty;
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
public class QueryInfo
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string ResultType { get; set; }
public string ResultFullyQualifiedName { get; set; }
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public QueryInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
ResultType = string.Empty;
ResultFullyQualifiedName = string.Empty;
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
BIN
View File
Binary file not shown.
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
@@ -16,6 +17,64 @@ namespace Svrnty.CQRS.MinimalApi;
public static class EndpointRouteBuilderExtensions
{
private static async Task<IResult?> RunCommandChecksAsync(
IServiceProvider serviceProvider,
Type commandType,
object command,
CancellationToken cancellationToken)
{
var checks = serviceProvider.GetServices<ICommandAuthorizationCheck>().ToList();
if (checks.Count == 0)
return null;
var context = new CommandAuthorizationCheckContext
{
CommandType = commandType,
Command = command,
Services = serviceProvider
};
foreach (var check in checks)
{
var result = await check.CheckAsync(context, cancellationToken);
if (result == AuthorizationResult.Forbidden)
return Results.StatusCode(403);
if (result == AuthorizationResult.Unauthorized)
return Results.Unauthorized();
}
return null;
}
private static async Task<IResult?> RunQueryChecksAsync(
IServiceProvider serviceProvider,
Type queryType,
object query,
CancellationToken cancellationToken)
{
var checks = serviceProvider.GetServices<IQueryAuthorizationCheck>().ToList();
if (checks.Count == 0)
return null;
var context = new QueryAuthorizationCheckContext
{
QueryType = queryType,
Query = query,
Services = serviceProvider
};
foreach (var check in checks)
{
var result = await check.CheckAsync(context, cancellationToken);
if (result == AuthorizationResult.Forbidden)
return Results.StatusCode(403);
if (result == AuthorizationResult.Unauthorized)
return Results.Unauthorized();
}
return null;
}
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
@@ -63,6 +122,10 @@ public static class EndpointRouteBuilderExtensions
if (query == null || !queryMeta.QueryType.IsInstanceOfType(query))
return Results.BadRequest("Invalid query payload");
var checkResult = await RunQueryChecksAsync(serviceProvider, queryMeta.QueryType, query, cancellationToken);
if (checkResult != null)
return checkResult;
var handler = serviceProvider.GetRequiredService(handlerType);
var handleMethod = handlerType.GetMethod("HandleAsync");
if (handleMethod == null)
@@ -128,6 +191,10 @@ public static class EndpointRouteBuilderExtensions
}
}
var checkResult = await RunQueryChecksAsync(serviceProvider, queryMeta.QueryType, query, cancellationToken);
if (checkResult != null)
return checkResult;
var handler = serviceProvider.GetRequiredService(handlerType);
var handleMethod = handlerType.GetMethod("HandleAsync");
if (handleMethod == null)
@@ -198,6 +265,10 @@ public static class EndpointRouteBuilderExtensions
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
return Results.BadRequest("Invalid command payload");
var checkResult = await RunCommandChecksAsync(serviceProvider, commandMeta.CommandType, command, cancellationToken);
if (checkResult != null)
return checkResult;
var handler = serviceProvider.GetRequiredService(handlerType);
var handleMethod = handlerType.GetMethod("HandleAsync");
if (handleMethod == null)
@@ -240,6 +311,10 @@ public static class EndpointRouteBuilderExtensions
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
return Results.BadRequest("Invalid command payload");
var checkResult = await RunCommandChecksAsync(serviceProvider, commandMeta.CommandType, command, cancellationToken);
if (checkResult != null)
return checkResult;
var handler = serviceProvider.GetRequiredService(handlerType);
var handleMethod = handlerType.GetMethod("HandleAsync");
if (handleMethod == null)
+54 -17
View File
@@ -43,9 +43,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Abstractions", "Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj", "{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Tests", "tests\Svrnty.CQRS.Tests\Svrnty.CQRS.Tests.csproj", "{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha", "Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj", "{9986C034-D585-4045-9F6C-99896B8A385B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Grpc", "Svrnty.CQRS.Altcha.Grpc\Svrnty.CQRS.Altcha.Grpc.csproj", "{628DE10C-FCDB-418B-8341-FA246BBCF70E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.MinimalApi", "Svrnty.CQRS.Altcha.MinimalApi\Svrnty.CQRS.Altcha.MinimalApi.csproj", "{26B24C13-FA06-4611-A371-2B640B8066F2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -261,25 +265,58 @@ Global
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
{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
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x64.ActiveCfg = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x64.Build.0 = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x86.ActiveCfg = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x86.Build.0 = Debug|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|Any CPU.Build.0 = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x64.ActiveCfg = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x64.Build.0 = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.ActiveCfg = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.Build.0 = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x64.ActiveCfg = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x64.Build.0 = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x86.ActiveCfg = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x86.Build.0 = Debug|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|Any CPU.Build.0 = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|x64.ActiveCfg = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|x64.Build.0 = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.ActiveCfg = Release|Any CPU
{9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.Build.0 = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x64.ActiveCfg = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x64.Build.0 = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x86.ActiveCfg = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x86.Build.0 = Debug|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|Any CPU.Build.0 = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x64.ActiveCfg = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x64.Build.0 = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.ActiveCfg = Release|Any CPU
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.Build.0 = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x64.ActiveCfg = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x64.Build.0 = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x86.ActiveCfg = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x86.Build.0 = Debug|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|Any CPU.Build.0 = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x64.ActiveCfg = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x64.Build.0 = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x86.ActiveCfg = Release|Any CPU
{26B24C13-FA06-4611-A371-2B640B8066F2}.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
BIN
View File
Binary file not shown.
+3 -2
View File
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Discovery;
@@ -43,7 +44,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand>
{
@@ -54,7 +55,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler with result to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, TResult, TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, TResult, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand, TResult>
{
+3 -3
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -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);
}
+3 -3
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -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);
}
+4
View File
@@ -26,6 +26,10 @@
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
</ItemGroup>
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Configuration;
namespace Svrnty.CQRS.MinimalApi;
namespace Svrnty.CQRS;
public static class WebApplicationExtensions
{
+11 -3
View File
@@ -1,11 +1,13 @@
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Svrnty.CQRS;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Altcha;
using Svrnty.CQRS.Altcha.Abstractions;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.Sample;
using Svrnty.CQRS.MinimalApi;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.Abstractions;
using Svrnty.Sample;
var builder = WebApplication.CreateBuilder(args);
@@ -27,8 +29,14 @@ 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.AddCommand<ProtectedActionCommand, string, ProtectedActionCommandHandler>();
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Wire the Altcha check + a stub verifier so [Altcha] commands run
// through the ICommandAuthorizationCheck pipeline.
builder.Services.AddSvrntyAltcha();
builder.Services.AddSingleton<IAltchaVerifier, StubAltchaVerifier>();
// Configure CQRS with fluent API
builder.Services.AddSvrntyCqrs(cqrs =>
{
+37
View File
@@ -0,0 +1,37 @@
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Altcha.Abstractions;
namespace Svrnty.Sample;
// Exercises the ICommandAuthorizationCheck seam at runtime.
// The command is decorated with [Altcha] and carries the solution
// through IHasAltchaSolution. With Svrnty.CQRS.Altcha + a registered
// IAltchaVerifier, the framework's check pipeline reads the field,
// calls the verifier, and short-circuits on failure.
[Altcha]
public sealed class ProtectedActionCommand : IHasAltchaSolution
{
public string Action { get; set; } = string.Empty;
public string? AltchaSolution { get; set; }
}
public sealed class ProtectedActionCommandHandler : ICommandHandler<ProtectedActionCommand, string>
{
public Task<string> HandleAsync(ProtectedActionCommand command, CancellationToken cancellationToken = default)
{
return Task.FromResult($"executed:{command.Action}");
}
}
// Stub verifier that doesn't talk to an external altcha service —
// enough to exercise the check pipeline in isolation. Treats the
// literal string "valid-solution" as a passing PoW solution.
public sealed class StubAltchaVerifier : IAltchaVerifier
{
public Task<AltchaVerifyResult> VerifyAsync(string payload, CancellationToken cancellationToken = default)
{
if (payload == "valid-solution")
return Task.FromResult(AltchaVerifyResult.Success);
return Task.FromResult(AltchaVerifyResult.Fail("stub-rejected"));
}
}
+14
View File
@@ -9,6 +9,9 @@ service CommandService {
// AddUserCommand operation
rpc AddUser (AddUserCommandRequest) returns (AddUserCommandResponse);
// ProtectedActionCommand operation
rpc ProtectedAction (ProtectedActionCommandRequest) returns (ProtectedActionCommandResponse);
// RemoveUserCommand operation
rpc RemoveUser (RemoveUserCommandRequest) returns (RemoveUserCommandResponse);
@@ -40,6 +43,17 @@ message AddUserCommandResponse {
int32 result = 1;
}
// Request message for ProtectedActionCommand
message ProtectedActionCommandRequest {
string action = 1;
string altcha_solution = 2;
}
// Response message for ProtectedActionCommand
message ProtectedActionCommandResponse {
string result = 1;
}
// Request message for RemoveUserCommand
message RemoveUserCommandRequest {
int32 user_id = 1;
+1 -1
View File
@@ -1,5 +1,5 @@
using PoweredSoft.Data.Core;
using System.Linq.Expressions;
using PoweredSoft.Data.Core;
namespace Svrnty.Sample;
+2
View File
@@ -33,6 +33,8 @@
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Grpc.Abstractions\Svrnty.CQRS.Grpc.Abstractions.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj" />
</ItemGroup>
<!-- Import the proto generation targets for testing (in production this would come from the NuGet package) -->
-178
View File
@@ -1,178 +0,0 @@
# 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.
-514
View File
@@ -1,514 +0,0 @@
# 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.
-335
View File
@@ -1,335 +0,0 @@
# 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
View File
@@ -1,129 +0,0 @@
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
@@ -1,124 +0,0 @@
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.Discovery;
namespace Svrnty.CQRS.Tests;
public class CommandDiscoveryTests
{
private static CommandDiscovery CreateDiscovery(params ICommandMeta[] metas)
{
return new CommandDiscovery(metas);
}
[Fact]
public void GetCommands_ReturnsAllRegistered()
{
var meta1 = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
var meta2 = new CommandMeta(typeof(DeletePersonCommand), typeof(object));
var discovery = CreateDiscovery(meta1, meta2);
var commands = discovery.GetCommands().ToList();
Assert.Equal(2, commands.Count);
}
[Fact]
public void GetCommands_ReturnsEmpty_WhenNoneRegistered()
{
var discovery = CreateDiscovery();
var commands = discovery.GetCommands().ToList();
Assert.Empty(commands);
}
[Fact]
public void FindCommand_ByName_ReturnsCorrectMeta()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
var discovery = CreateDiscovery(meta);
var found = discovery.FindCommand("CreatePerson");
Assert.NotNull(found);
Assert.Equal(typeof(CreatePersonCommand), found.CommandType);
}
[Fact]
public void FindCommand_ByName_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindCommand("NonExistent");
Assert.Null(found);
}
[Fact]
public void FindCommand_ByType_ReturnsCorrectMeta()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
var discovery = CreateDiscovery(meta);
var found = discovery.FindCommand(typeof(CreatePersonCommand));
Assert.NotNull(found);
Assert.Equal("CreatePerson", found.Name);
}
[Fact]
public void FindCommand_ByType_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindCommand(typeof(CreatePersonCommand));
Assert.Null(found);
}
[Fact]
public void CommandExists_ByName_ReturnsTrue_WhenFound()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.CommandExists("CreatePerson"));
}
[Fact]
public void CommandExists_ByName_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.CommandExists("CreatePerson"));
}
[Fact]
public void CommandExists_ByType_ReturnsTrue_WhenFound()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.CommandExists(typeof(CreatePersonCommand)));
}
[Fact]
public void CommandExists_ByType_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.CommandExists(typeof(CreatePersonCommand)));
}
[Fact]
public void FindCommand_WithCustomName_FindsByAttributeName()
{
var meta = new CommandMeta(typeof(CreateWidgetCommand), typeof(object));
var discovery = CreateDiscovery(meta);
var found = discovery.FindCommand("customCreate");
Assert.NotNull(found);
Assert.Equal(typeof(CreateWidgetCommand), found.CommandType);
}
}
@@ -1,64 +0,0 @@
using Svrnty.CQRS.Abstractions.Discovery;
namespace Svrnty.CQRS.Tests;
public class CommandMetaTests
{
[Fact]
public void Name_StripsCommandSuffix()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
Assert.Equal("CreatePerson", meta.Name);
}
[Fact]
public void Name_UsesCommandNameAttribute_WhenPresent()
{
var meta = new CommandMeta(typeof(CreateWidgetCommand), typeof(object));
Assert.Equal("customCreate", meta.Name);
}
[Fact]
public void LowerCamelCaseName_ConvertsFirstCharToLower()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
Assert.Equal("createPerson", meta.LowerCamelCaseName);
}
[Fact]
public void LowerCamelCaseName_PreservesAlreadyLowerCase()
{
var meta = new CommandMeta(typeof(CreateWidgetCommand), typeof(object));
// customCreate -> already lower first char
Assert.Equal("customCreate", meta.LowerCamelCaseName);
}
[Fact]
public void CommandType_IsSetCorrectly()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
Assert.Equal(typeof(CreatePersonCommand), meta.CommandType);
}
[Fact]
public void ServiceType_IsSetCorrectly()
{
var serviceType = typeof(object);
var meta = new CommandMeta(typeof(CreatePersonCommand), serviceType);
Assert.Equal(serviceType, meta.ServiceType);
}
[Fact]
public void CommandResultType_IsSetCorrectly_WithThreeArgConstructor()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
Assert.Equal(typeof(CreatePersonResult), meta.CommandResultType);
}
[Fact]
public void CommandResultType_IsNull_WithTwoArgConstructor()
{
var meta = new CommandMeta(typeof(DeletePersonCommand), typeof(object));
Assert.Null(meta.CommandResultType);
}
}
@@ -1,106 +0,0 @@
using Svrnty.CQRS.Configuration;
namespace Svrnty.CQRS.Tests;
public class CqrsConfigurationTests
{
private class TestConfig
{
public string Value { get; set; } = string.Empty;
}
private class OtherConfig
{
public int Number { get; set; }
}
[Fact]
public void SetConfiguration_CanBeRetrieved()
{
var config = new CqrsConfiguration();
var testConfig = new TestConfig { Value = "hello" };
config.SetConfiguration(testConfig);
var retrieved = config.GetConfiguration<TestConfig>();
Assert.NotNull(retrieved);
Assert.Equal("hello", retrieved.Value);
}
[Fact]
public void GetConfiguration_ReturnsNull_WhenNotSet()
{
var config = new CqrsConfiguration();
var retrieved = config.GetConfiguration<TestConfig>();
Assert.Null(retrieved);
}
[Fact]
public void HasConfiguration_ReturnsTrue_WhenSet()
{
var config = new CqrsConfiguration();
config.SetConfiguration(new TestConfig());
Assert.True(config.HasConfiguration<TestConfig>());
}
[Fact]
public void HasConfiguration_ReturnsFalse_WhenNotSet()
{
var config = new CqrsConfiguration();
Assert.False(config.HasConfiguration<TestConfig>());
}
[Fact]
public void SetConfiguration_OverwritesPrevious()
{
var config = new CqrsConfiguration();
config.SetConfiguration(new TestConfig { Value = "first" });
config.SetConfiguration(new TestConfig { Value = "second" });
var retrieved = config.GetConfiguration<TestConfig>();
Assert.Equal("second", retrieved!.Value);
}
[Fact]
public void MultipleConfigTypes_AreIndependent()
{
var config = new CqrsConfiguration();
config.SetConfiguration(new TestConfig { Value = "test" });
config.SetConfiguration(new OtherConfig { Number = 42 });
Assert.Equal("test", config.GetConfiguration<TestConfig>()!.Value);
Assert.Equal(42, config.GetConfiguration<OtherConfig>()!.Number);
}
[Fact]
public void ExecuteMappingCallbacks_InvokesAllCallbacks()
{
var config = new CqrsConfiguration();
var callCount = 0;
config.AddMappingCallback(_ => callCount++);
config.AddMappingCallback(_ => callCount++);
config.ExecuteMappingCallbacks(new object());
Assert.Equal(2, callCount);
}
[Fact]
public void ExecuteMappingCallbacks_PassesAppObject()
{
var config = new CqrsConfiguration();
object? receivedApp = null;
config.AddMappingCallback(app => receivedApp = app);
var expected = new object();
config.ExecuteMappingCallbacks(expected);
Assert.Same(expected, receivedApp);
}
}
-81
View File
@@ -1,81 +0,0 @@
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Attributes;
namespace Svrnty.CQRS.Tests;
// Commands
public class CreatePersonCommand
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}
public class DeletePersonCommand
{
public int Id { get; set; }
}
[CommandName("customCreate")]
public class CreateWidgetCommand
{
public string Name { get; set; } = string.Empty;
}
// Command results
public class CreatePersonResult
{
public int Id { get; set; }
}
// Queries
public class PersonQuery
{
public string? NameFilter { get; set; }
}
[QueryName("customPersonLookup")]
public class PersonLookupQuery
{
public int Id { get; set; }
}
// Handlers
public class CreatePersonCommandHandler : ICommandHandler<CreatePersonCommand, CreatePersonResult>
{
public Task<CreatePersonResult> HandleAsync(CreatePersonCommand command, CancellationToken cancellationToken = default)
{
return Task.FromResult(new CreatePersonResult { Id = 1 });
}
}
public class DeletePersonCommandHandler : ICommandHandler<DeletePersonCommand>
{
public Task HandleAsync(DeletePersonCommand command, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
public class CreateWidgetCommandHandler : ICommandHandler<CreateWidgetCommand>
{
public Task HandleAsync(CreateWidgetCommand command, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
public class PersonQueryHandler : IQueryHandler<PersonQuery, IEnumerable<string>>
{
public Task<IEnumerable<string>> HandleAsync(PersonQuery query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IEnumerable<string>>(["Alice", "Bob"]);
}
}
public class PersonLookupQueryHandler : IQueryHandler<PersonLookupQuery, string>
{
public Task<string> HandleAsync(PersonLookupQuery query, CancellationToken cancellationToken = default)
{
return Task.FromResult("Alice");
}
}

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