Compare commits

..

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:07:03 -05:00
82 changed files with 5173 additions and 3726 deletions
-50
View File
@@ -1,50 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet clean:*)",
"Bash(dotnet run)",
"Bash(dotnet add:*)",
"Bash(timeout 5 dotnet run:*)",
"Bash(dotnet remove:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(cat:*)",
"Bash(taskkill:*)",
"WebSearch",
"Bash(dotnet tool install:*)",
"Bash(protogen:*)",
"Bash(timeout 15 dotnet run:*)",
"Bash(where:*)",
"Bash(timeout 30 dotnet run:*)",
"Bash(timeout 60 dotnet run:*)",
"Bash(timeout 120 dotnet run:*)",
"Bash(git add:*)",
"Bash(curl:*)",
"Bash(timeout 3 cmd:*)",
"Bash(timeout:*)",
"Bash(tasklist:*)",
"Bash(dotnet build:*)",
"Bash(dotnet --list-sdks:*)",
"Bash(dotnet sln:*)",
"Bash(pkill:*)",
"Bash(python3:*)",
"Bash(grpcurl:*)",
"Bash(lsof:*)",
"Bash(xargs kill -9)",
"Bash(dotnet run:*)",
"Bash(find:*)",
"Bash(dotnet pack:*)",
"Bash(unzip:*)",
"WebFetch(domain:andrewlock.net)",
"WebFetch(domain:github.com)",
"WebFetch(domain:stackoverflow.com)",
"WebFetch(domain:www.kenmuse.com)",
"WebFetch(domain:blog.rsuter.com)",
"WebFetch(domain:natemcmaster.com)",
"WebFetch(domain:www.nuget.org)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}
+96
View File
@@ -0,0 +1,96 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{csproj,props,targets,xml}]
indent_size = 2
[*.{json,yml,yaml}]
indent_size = 2
[*.proto]
indent_size = 2
[*.cs]
# Namespace
csharp_style_namespace_declarations = file_scoped:warning
# Braces — Allman style
csharp_new_line_before_open_brace = all
# Usings
dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:warning
# var preferences — use var when type is apparent
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Expression bodies — prefer for simple members
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_constructors = false:suggestion
csharp_style_expression_bodied_operators = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
csharp_style_expression_bodied_accessors = true:suggestion
csharp_style_expression_bodied_lambdas = true:suggestion
# Pattern matching
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
# Null checking
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences — exclude interface members (netstandard2.1 compat)
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
# Field naming — _camelCase for private fields
dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.camel_case_underscore.required_prefix = _
dotnet_naming_style.camel_case_underscore.capitalization = camel_case
# Constants — PascalCase
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case
dotnet_naming_symbols.constants.applicable_kinds = field
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_style.pascal_case.capitalization = pascal_case
# Interfaces — I prefix
dotnet_naming_rule.interfaces_should_begin_with_i.severity = warning
dotnet_naming_rule.interfaces_should_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_should_begin_with_i.style = begins_with_i
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
# Async methods — Async suffix
dotnet_naming_rule.async_methods_should_end_with_async.severity = suggestion
dotnet_naming_rule.async_methods_should_end_with_async.symbols = async_methods
dotnet_naming_rule.async_methods_should_end_with_async.style = ends_with_async
dotnet_naming_symbols.async_methods.applicable_kinds = method
dotnet_naming_symbols.async_methods.required_modifiers = async
dotnet_naming_style.ends_with_async.required_suffix = Async
dotnet_naming_style.ends_with_async.capitalization = pascal_case
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to AI agents when working with code in this repository.
## Project Overview
+9
View File
@@ -4,6 +4,15 @@
Our implementation of query and command responsibility segregation (CQRS).
## Where This Fits
This is a backend framework of the [Svrnty Agent System](../README.md).
**Layer**: Framework
**Depends on**: Nothing (standalone .NET framework)
**Depended on by**: a-gent-app (backend services), flutter_cqrs_datasource (client)
**Git**: [git.openharbor.io/svrnty/dotnet-cqrs](https://git.openharbor.io/svrnty/dotnet-cqrs)
## Getting Started
> Install nuget package to your awesome project.
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -10,4 +10,4 @@ public interface IQueryMeta
Type QueryResultType { get; }
string Category { get; }
string LowerCamelCaseName { get; }
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -13,4 +13,4 @@ public interface ICommandHandler<in TCommand, TCommandResult>
where TCommand : class
{
Task<TCommandResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -7,4 +7,4 @@ public interface IQueryHandler<in TQuery, TQueryResult>
where TQuery : class
{
Task<TQueryResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
}
}
@@ -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,7 +15,7 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
where TDestination : class
where TParams : class
{
}
public interface IDynamicQuery
@@ -26,4 +26,4 @@ public interface IDynamicQuery
List<IAggregate> GetAggregates();
int? GetPage();
int? GetPageSize();
}
}
@@ -1,9 +1,9 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryInterceptorProvider<TSource, TDestination>
{
IEnumerable<Type> GetInterceptorsTypes();
}
}
@@ -1,4 +1,4 @@
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryParams<out TParams>
where TParams : class
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IQueryableProvider<TSource>
{
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
}
}
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Attributes;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -14,7 +15,6 @@ using Svrnty.CQRS.Abstractions.Security;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using Svrnty.CQRS.DynamicQuery.Discover;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery.MinimalApi;
@@ -1,4 +1,4 @@
using System;
using System;
using Pluralize.NET;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -7,7 +7,7 @@ namespace Svrnty.CQRS.DynamicQuery.Discover;
public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResultType)
: QueryMeta(queryType, serviceType, queryResultType)
{
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type DestinationType => QueryType.GetGenericArguments()[1];
public override string Category => "DynamicQuery";
public override string Name
+1 -1
View File
@@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -1,6 +1,6 @@
using System;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using System;
namespace Svrnty.CQRS.DynamicQuery;
+10 -10
View File
@@ -1,23 +1,23 @@
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryHandler<TSource, TDestination>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
{
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
}
@@ -29,7 +29,7 @@ public class DynamicQueryHandler<TSource, TDestination>
}
public class DynamicQueryHandler<TSource, TDestination, TParams>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
@@ -37,10 +37,10 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
{
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams;
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
@@ -49,7 +49,7 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
protected override async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
{
source = await base.AlterSourceAsync(source, query, cancellationToken);
source = await base.AlterSourceAsync(source, query, cancellationToken);
if (query is IDynamicQueryParams<TParams> withParams)
{
@@ -1,11 +1,14 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -16,10 +19,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
private readonly IQueryHandlerAsync _queryHandlerAsync;
private readonly IEnumerable<IQueryableProvider<TSource>> _queryableProviders;
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination>> _alterQueryableServices;
private readonly IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> _dynamicQueryInterceptorProviders;
private readonly IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>>
_dynamicQueryInterceptorProviders;
private readonly IServiceProvider _serviceProvider;
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
@@ -32,7 +38,8 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
_serviceProvider = serviceProvider;
}
protected virtual Task<IQueryable<TSource>> GetQueryableAsync(IDynamicQuery query, CancellationToken cancellationToken = default)
protected virtual Task<IQueryable<TSource>> GetQueryableAsync(IDynamicQuery query,
CancellationToken cancellationToken = default)
{
if (_queryableProviders.Any())
{
@@ -56,7 +63,8 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
yield return _serviceProvider.GetService(type) as IQueryInterceptor;
}
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query, CancellationToken cancellationToken = default)
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query,
CancellationToken cancellationToken = default)
{
var source = await GetQueryableAsync(query, cancellationToken);
source = await AlterSourceAsync(source, query, cancellationToken);
@@ -67,11 +75,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
_queryHandlerAsync.AddInterceptor(interceptor);
var criteria = CreateCriteriaFromQuery(query);
var result = await _queryHandlerAsync.ExecuteAsync<TSource, TDestination>(source, criteria, options, cancellationToken);
var result =
await _queryHandlerAsync.ExecuteAsync<TSource, TDestination>(source, criteria, options, cancellationToken);
return result;
}
protected virtual async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
protected virtual async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query,
CancellationToken cancellationToken)
{
foreach (var t in _alterQueryableServices)
source = await t.AlterQueryableAsync(source, query, cancellationToken);
@@ -81,16 +91,94 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
protected virtual IQueryCriteria CreateCriteriaFromQuery(IDynamicQuery query)
{
var filters = query?.GetFilters() ?? new List<IFilter>();
ConvertFilterValuesToPropertyTypes(filters);
var criteria = new QueryCriteria
{
Page = query?.GetPage(),
PageSize = query?.GetPageSize(),
Filters = query?.GetFilters() ?? new List<IFilter>(),
Filters = filters,
Sorts = query?.GetSorts() ?? new List<ISort>(),
Groups = query?.GetGroups() ?? new List<IGroup>(),
Aggregates = query?.GetAggregates() ?? new List<IAggregate>()
};
return criteria;
}
/// <summary>
/// Converts string filter values to the correct CLR type based on TSource property types.
/// This handles the case where transport layers (e.g. gRPC) pass all values as strings,
/// but PoweredSoft.DynamicLinq needs the actual type to build LINQ expressions.
/// </summary>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2087",
Justification = "TSource properties are preserved by EF Core and DynamicLinq usage")]
private static void ConvertFilterValuesToPropertyTypes(List<IFilter> filters)
{
for (var i = filters.Count - 1; i >= 0; i--)
{
var filter = filters[i];
if (filter is SimpleFilter simpleFilter)
{
if (simpleFilter.Value == null)
{
filters.RemoveAt(i);
continue;
}
if (simpleFilter.Value is string strValue && !string.IsNullOrEmpty(strValue))
{
var propertyType = ResolvePropertyType(typeof(TSource), simpleFilter.Path);
if (propertyType == null)
continue;
var targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
if (targetType == typeof(DateTime))
{
if (DateTime.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal,
out var dt))
{
simpleFilter.Value = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
}
}
else if (targetType == typeof(DateTimeOffset))
{
if (DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.None,
out var dto))
{
simpleFilter.Value = dto;
}
}
}
}
else if (filter is CompositeFilter compositeFilter && compositeFilter.Filters != null)
{
ConvertFilterValuesToPropertyTypes(compositeFilter.Filters);
}
}
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2070",
Justification = "Property types are preserved by EF Core and DynamicLinq usage")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075",
Justification = "Nested property type resolution is inherently dynamic")]
static Type? ResolvePropertyType(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, string? path)
{
if (string.IsNullOrEmpty(path))
return null;
var currentType = type;
foreach (var part in path.Split('.'))
{
var property = currentType.GetProperty(part,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (property == null)
return null;
currentType = property.PropertyType;
}
return currentType;
}
}
}
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
@@ -91,10 +91,10 @@ public static class ServiceCollectionExtensions
where TParams : class
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
where TSource : class
where TDestination : class
where TParams : class
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
where TSource : class
where TDestination : class
where TParams : class
{
// add query handler.
services.AddTransient<IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>, DynamicQueryHandler<TSource, TDestination, TParams>>();
@@ -133,7 +133,7 @@ public static class ServiceCollectionExtensions
where TParams : class
where TService : class, IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>
{
return services.AddTransient<IAlterQueryableService< TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
return services.AddTransient<IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
}
public static IServiceCollection AddAlterQueryableWithParams<TSource, TDestination, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
{
services.AddQuery<TQuery, TQueryResult, TQueryHandler>()
.AddFluentValidator<TQuery, TValidator>();
return services;
}
}
}
@@ -0,0 +1,256 @@
#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// MSBuild task that generates .proto files by creating its own Roslyn compilation.
/// This runs BEFORE CoreCompile to solve the source generator timing issue.
/// </summary>
public class GenerateProtoFileTask : Task
{
/// <summary>
/// The project directory
/// </summary>
[Required]
public string ProjectDirectory { get; set; } = string.Empty;
/// <summary>
/// The output directory where the proto file should be written (typically Protos/)
/// </summary>
[Required]
public string OutputDirectory { get; set; } = string.Empty;
/// <summary>
/// The name of the proto file to generate (typically cqrs_services.proto)
/// </summary>
[Required]
public string ProtoFileName { get; set; } = string.Empty;
/// <summary>
/// The C# source files to compile (from @(Compile) ItemGroup)
/// </summary>
[Required]
public ITaskItem[] SourceFiles { get; set; } = Array.Empty<ITaskItem>();
/// <summary>
/// The assembly references (from @(ReferencePath) ItemGroup)
/// </summary>
[Required]
public ITaskItem[] References { get; set; } = Array.Empty<ITaskItem>();
/// <summary>
/// The root namespace of the project
/// </summary>
public string RootNamespace { get; set; } = string.Empty;
/// <summary>
/// The assembly name of the project
/// </summary>
public string AssemblyName { get; set; } = string.Empty;
public override bool Execute()
{
try
{
Log.LogMessage(MessageImportance.High,
"Svrnty.CQRS.Grpc: Generating proto file via MSBuild task...");
// Determine the namespace for the proto file
var projectNamespace = !string.IsNullOrEmpty(RootNamespace) ? RootNamespace
: !string.IsNullOrEmpty(AssemblyName) ? AssemblyName
: "Generated";
var grpcNamespace = $"{projectNamespace}.Grpc";
var packageName = "cqrs";
// Create the compilation
var compilation = CreateCompilation();
if (compilation == null)
{
Log.LogWarning("Svrnty.CQRS.Grpc: Could not create compilation. Writing placeholder proto file.");
WritePlaceholderProto(grpcNamespace);
return true;
}
// Check for compilation errors that would prevent proper analysis
var diagnostics = compilation.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error)
.ToList();
if (diagnostics.Count > 0)
{
Log.LogMessage(MessageImportance.Normal,
$"Svrnty.CQRS.Grpc: Compilation has {diagnostics.Count} errors. Attempting to generate proto anyway...");
// Log first few errors for debugging
foreach (var diag in diagnostics.Take(5))
{
Log.LogMessage(MessageImportance.Low, $" {diag.GetMessage()}");
}
}
// Use the ProtoFileGenerator to generate content
var generator = new ProtoFileGenerator(compilation);
var protoContent = generator.Generate(packageName, grpcNamespace);
// Check if we got meaningful content
if (string.IsNullOrWhiteSpace(protoContent) || !protoContent.Contains("rpc "))
{
Log.LogMessage(MessageImportance.High,
"Svrnty.CQRS.Grpc: No commands/queries/notifications found. Writing minimal proto file.");
WritePlaceholderProto(grpcNamespace);
return true;
}
// Ensure output directory exists
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
? OutputDirectory
: Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(fullOutputPath);
// Write the proto file
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
File.WriteAllText(protoFilePath, protoContent);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
return true;
}
catch (Exception ex)
{
Log.LogErrorFromException(ex, true);
return false;
}
}
private CSharpCompilation? CreateCompilation()
{
try
{
// Parse all source files into syntax trees
var syntaxTrees = new List<SyntaxTree>();
foreach (var sourceFile in SourceFiles)
{
var filePath = sourceFile.ItemSpec;
if (!Path.IsPathRooted(filePath))
{
filePath = Path.Combine(ProjectDirectory, filePath);
}
if (!File.Exists(filePath))
{
Log.LogMessage(MessageImportance.Low, $"Source file not found: {filePath}");
continue;
}
try
{
var sourceText = File.ReadAllText(filePath);
var syntaxTree = CSharpSyntaxTree.ParseText(
sourceText,
CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest),
path: filePath);
syntaxTrees.Add(syntaxTree);
}
catch (Exception ex)
{
Log.LogMessage(MessageImportance.Low, $"Failed to parse {filePath}: {ex.Message}");
}
}
if (syntaxTrees.Count == 0)
{
Log.LogWarning("Svrnty.CQRS.Grpc: No source files could be parsed.");
return null;
}
Log.LogMessage(MessageImportance.Normal,
$"Svrnty.CQRS.Grpc: Parsed {syntaxTrees.Count} source files");
// Create metadata references from the References
var metadataReferences = new List<MetadataReference>();
foreach (var reference in References)
{
var refPath = reference.ItemSpec;
if (!File.Exists(refPath))
{
Log.LogMessage(MessageImportance.Low, $"Reference not found: {refPath}");
continue;
}
try
{
var metadataRef = MetadataReference.CreateFromFile(refPath);
metadataReferences.Add(metadataRef);
}
catch (Exception ex)
{
Log.LogMessage(MessageImportance.Low, $"Failed to load reference {refPath}: {ex.Message}");
}
}
Log.LogMessage(MessageImportance.Normal,
$"Svrnty.CQRS.Grpc: Loaded {metadataReferences.Count} references");
// Create the compilation
var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
.WithNullableContextOptions(NullableContextOptions.Enable);
var assemblyName = !string.IsNullOrEmpty(AssemblyName) ? AssemblyName : "TempCompilation";
var compilation = CSharpCompilation.Create(
assemblyName,
syntaxTrees,
metadataReferences,
compilationOptions);
return compilation;
}
catch (Exception ex)
{
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Failed to create compilation: {ex.Message}");
return null;
}
}
private void WritePlaceholderProto(string grpcNamespace)
{
var placeholderProto = $@"syntax = ""proto3"";
option csharp_namespace = ""{grpcNamespace}"";
package cqrs;
// Placeholder proto file - will be regenerated when commands/queries are available
// Using namespace: {grpcNamespace}
// Empty service definitions so Grpc.Tools generates base classes
service CommandService {{
}}
service QueryService {{
}}
service DynamicQueryService {{
}}
";
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
? OutputDirectory
: Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(fullOutputPath);
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
File.WriteAllText(protoFilePath, placeholderProto);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {protoFilePath}");
}
}
File diff suppressed because it is too large Load Diff
@@ -1,102 +1,101 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Helpers
namespace Svrnty.CQRS.Grpc.Generators.Helpers;
internal static class ProtoTypeMapper
{
internal static class ProtoTypeMapper
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
{
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
return csharpType.Replace("?", "");
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
return csharpType.Replace("?", "");
}
}
@@ -1,83 +1,82 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
namespace Svrnty.CQRS.Grpc.Generators.Models
namespace Svrnty.CQRS.Grpc.Generators.Models;
public class CommandInfo
{
public class CommandInfo
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
}
@@ -1,28 +1,27 @@
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public class DynamicQueryInfo
{
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
namespace Svrnty.CQRS.Grpc.Generators.Models;
public DynamicQueryInfo()
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
}
public class DynamicQueryInfo
{
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
public DynamicQueryInfo()
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
}
}
@@ -1,50 +1,49 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models
namespace Svrnty.CQRS.Grpc.Generators.Models;
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// </summary>
public class NotificationInfo
{
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public class NotificationInfo
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
/// <summary>
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
}
+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;
}
}
@@ -413,17 +413,21 @@ internal class ProtoFileGenerator
private void GenerateComplexTypeMessage(INamedTypeSymbol? type)
{
if (type == null || _generatedMessages.Contains(type.Name))
if (type == null)
return;
var messageName = ProtoFileTypeMapper.GetProtoMessageName(type);
if (_generatedMessages.Contains(messageName))
return;
// Don't generate messages for system types or primitives
if (type.ContainingNamespace?.ToString().StartsWith("System") == true)
return;
_generatedMessages.Add(type.Name);
_generatedMessages.Add(messageName);
_messagesBuilder.AppendLine($"// {type.Name} entity");
_messagesBuilder.AppendLine($"message {type.Name} {{");
_messagesBuilder.AppendLine($"// {messageName} entity");
_messagesBuilder.AppendLine($"message {messageName} {{");
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
@@ -1,124 +0,0 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// Incremental source generator that generates .proto files from C# commands and queries
/// </summary>
[Generator]
public class ProtoFileSourceGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Register a post-initialization output to generate the proto file
context.RegisterPostInitializationOutput(ctx =>
{
// Generate a placeholder - the actual proto will be generated in the source output
});
// Collect type declarations to trigger generation
// We use any type declaration as a trigger since ProtoFileGenerator scans all assemblies
var typeDeclarations = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (s, _) => s is TypeDeclarationSyntax,
transform: static (ctx, _) => GetTypeSymbol(ctx))
.Where(static m => m is not null)
.Collect();
// Combine with compilation to have access to it
var compilationAndTypes = context.CompilationProvider.Combine(typeDeclarations);
// Generate proto file when commands/queries change
context.RegisterSourceOutput(compilationAndTypes, (spc, source) =>
{
var (compilation, types) = source;
// Note: We no longer bail out early since ProtoFileGenerator now scans all referenced assemblies
// The types from source are just a trigger - the generator will find types from all assemblies
try
{
// Get the root namespace from the compilation - this matches what GrpcGenerator does
var rootNamespace = compilation.AssemblyName ?? "Generated";
var packageName = "cqrs";
var csharpNamespace = $"{rootNamespace}.Grpc";
// Generate the proto file content
var generator = new ProtoFileGenerator(compilation);
var protoContent = generator.Generate(packageName, csharpNamespace);
// Output as an embedded resource that can be extracted
var protoFileName = "cqrs_services.proto";
// Generate a C# class that contains the proto content
// This allows build tools to extract it if needed
var csContent = $$"""
// <auto-generated />
#nullable enable
namespace Svrnty.CQRS.Grpc.Generated
{
/// <summary>
/// Contains the auto-generated Protocol Buffer definition
/// </summary>
internal static class GeneratedProtoFile
{
public const string FileName = "{{protoFileName}}";
public const string Content = @"{{protoContent.Replace("\"", "\"\"")}}";
}
}
""";
spc.AddSource("GeneratedProtoFile.g.cs", csContent);
// Report that we generated the proto content
var descriptor = new DiagnosticDescriptor(
"CQRSGRPC002",
"Proto file generated",
"Generated proto file content in GeneratedProtoFile class",
"Svrnty.CQRS.Grpc",
DiagnosticSeverity.Info,
isEnabledByDefault: true);
spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None));
}
catch (Exception ex)
{
// Report diagnostic if generation fails
var descriptor = new DiagnosticDescriptor(
"CQRSGRPC001",
"Proto file generation failed",
"Failed to generate proto file: {0}",
"Svrnty.CQRS.Grpc",
DiagnosticSeverity.Warning,
isEnabledByDefault: true);
spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, ex.Message));
}
});
}
private static INamedTypeSymbol? GetTypeSymbol(GeneratorSyntaxContext context)
{
var typeDecl = (TypeDeclarationSyntax)context.Node;
var symbol = context.SemanticModel.GetDeclaredSymbol(typeDecl) as INamedTypeSymbol;
// Skip if it has GrpcIgnore attribute
if (symbol?.GetAttributes().Any(a => a.AttributeClass?.Name == "GrpcIgnoreAttribute") == true)
return null;
return symbol;
}
private static string? GetBuildProperty(SourceProductionContext context, string propertyName)
{
// Try to get build properties from the compilation options
// This is a simplified approach - in practice, you might need analyzer config
return null; // Will use defaults
}
}
+16 -1
View File
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using Microsoft.CodeAnalysis;
namespace Svrnty.CQRS.Grpc.Generators;
@@ -151,13 +152,27 @@ internal static class ProtoFileTypeMapper
// Complex types (classes/records) become message types
if (typeSymbol.TypeKind == TypeKind.Class || typeSymbol.TypeKind == TypeKind.Struct)
{
return typeName; // Reference the message type by name
return GetProtoMessageName(typeSymbol); // Reference the message type by name (handles generics)
}
// Fallback
return "string"; // Default to string for unknown types
}
/// <summary>
/// Gets the proto message name for a type, handling generic types by qualifying
/// with type arguments. e.g. Translation&lt;FaqTranslationQueryItem&gt; becomes TranslationOfFaqTranslationQueryItem.
/// </summary>
public static string GetProtoMessageName(ITypeSymbol typeSymbol)
{
if (typeSymbol is INamedTypeSymbol namedType && namedType.IsGenericType && namedType.TypeArguments.Length > 0)
{
var typeArgs = string.Join("And", namedType.TypeArguments.Select(t => GetProtoMessageName(t)));
return $"{namedType.Name}Of{typeArgs}";
}
return typeSymbol.Name;
}
/// <summary>
/// Converts C# PascalCase property name to proto snake_case field name.
/// Uses simple conversion: add underscore before each uppercase letter (except first).
@@ -1,150 +0,0 @@
#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// MSBuild task that extracts the auto-generated proto file content from the source generator
/// output and writes it to disk so Grpc.Tools can process it
/// </summary>
public class WriteProtoFileTask : Task
{
/// <summary>
/// The project directory where we should look for generated files
/// </summary>
[Required]
public string ProjectDirectory { get; set; } = string.Empty;
/// <summary>
/// The intermediate output path (typically obj/Debug/net10.0)
/// </summary>
[Required]
public string IntermediateOutputPath { get; set; } = string.Empty;
/// <summary>
/// The output directory where the proto file should be written (typically Protos/)
/// </summary>
[Required]
public string OutputDirectory { get; set; } = string.Empty;
/// <summary>
/// The name of the proto file to generate (typically cqrs_services.proto)
/// </summary>
[Required]
public string ProtoFileName { get; set; } = string.Empty;
public override bool Execute()
{
try
{
Log.LogMessage(MessageImportance.High,
"Svrnty.CQRS.Grpc: Extracting auto-generated proto file...");
// Look for the generated C# file containing the proto content
// Source generators output to obj/Generated, not IntermediateOutputPath/Generated
var generatedFilePath = Path.Combine(
ProjectDirectory,
"obj",
"Generated",
"Svrnty.CQRS.Grpc.Generators",
"Svrnty.CQRS.Grpc.Generators.ProtoFileSourceGenerator",
"GeneratedProtoFile.g.cs"
);
if (!File.Exists(generatedFilePath))
{
Log.LogWarning(
$"Generated proto file not found at {generatedFilePath}. " +
"The proto file may not have been generated yet. This is normal on first build.");
// Write a minimal placeholder proto file so Grpc.Tools doesn't fail
// The real content will be generated on the next build
var placeholderProto = @"syntax = ""proto3"";
option csharp_namespace = ""Generated.Grpc"";
package cqrs;
// Placeholder proto file - will be regenerated on next build
";
var placeholderOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(placeholderOutputPath);
var placeholderProtoFilePath = Path.Combine(placeholderOutputPath, ProtoFileName);
File.WriteAllText(placeholderProtoFilePath, placeholderProto);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {placeholderProtoFilePath}. " +
"Run build again to generate the actual proto content.");
return true;
}
// Read the generated C# file
var csContent = File.ReadAllText(generatedFilePath);
// Extract the proto content using a more robust approach
// Looking for: public const string Content = @"...";
var startMarker = "public const string Content = @\"";
var startIndex = csContent.IndexOf(startMarker);
if (startIndex < 0)
{
Log.LogError($"Could not find Content property in {generatedFilePath}");
return false;
}
startIndex += startMarker.Length;
// Find the closing "; - We need the LAST occurrence because the content contains escaped quotes
// The pattern is: Content = @"...content...";
// where content has "" for literal quotes
var endMarker = "\";";
// Find where the next field starts or class ends to limit our search
var nextFieldOrEnd = csContent.IndexOf("\n }", startIndex); // End of class
if (nextFieldOrEnd < 0)
{
nextFieldOrEnd = csContent.Length;
}
var endIndex = csContent.LastIndexOf(endMarker, nextFieldOrEnd, nextFieldOrEnd - startIndex);
if (endIndex < 0 || endIndex < startIndex)
{
Log.LogError($"Could not find end of Content property in {generatedFilePath}");
return false;
}
// Extract and unescape doubled quotes
var protoContent = csContent.Substring(startIndex, endIndex - startIndex);
protoContent = protoContent.Replace("\"\"", "\"");
Log.LogMessage(MessageImportance.High,
$"Extracted proto content length: {protoContent.Length} characters");
// Ensure output directory exists
var fullOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(fullOutputPath);
// Write the proto file
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
File.WriteAllText(protoFilePath, protoContent);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
return true;
}
catch (Exception ex)
{
Log.LogErrorFromException(ex, true);
return false;
}
}
}
@@ -9,29 +9,48 @@
<!-- Determine the assembly path (different for NuGet package vs project reference) -->
<PropertyGroup>
<_GeneratorsAssemblyPath Condition="Exists('$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
</PropertyGroup>
<!-- Load the WriteProtoFileTask from the generator assembly -->
<UsingTask TaskName="Svrnty.CQRS.Grpc.Generators.WriteProtoFileTask"
<!-- Load the GenerateProtoFileTask from the generator assembly -->
<UsingTask TaskName="Svrnty.CQRS.Grpc.Generators.GenerateProtoFileTask"
AssemblyFile="$(_GeneratorsAssemblyPath)"
Condition="'$(_GeneratorsAssemblyPath)' != ''" />
<!-- This target ensures the Protos directory exists before the generator runs -->
<Target Name="EnsureProtosDirectory" BeforeTargets="CoreCompile">
<!-- This target ensures the Protos directory exists -->
<Target Name="EnsureProtosDirectory" BeforeTargets="SvrntyGenerateProtoFile">
<MakeDir Directories="$(ProtoOutputDirectory)" Condition="!Exists('$(ProtoOutputDirectory)')" />
</Target>
<!-- Extract the proto file from the source generator output BEFORE Grpc.Tools processes protos -->
<!-- Runs before CoreCompile, after source generators have been executed -->
<Target Name="SvrntyExtractProtoFile" BeforeTargets="CoreCompile" AfterTargets="ResolveProjectReferences" DependsOnTargets="EnsureProtosDirectory" Condition="'$(GenerateProtoFile)' == 'true'">
<Message Text="Svrnty.CQRS.Grpc: Extracting auto-generated proto file to $(ProtoOutputDirectory)\$(GeneratedProtoFileName)" Importance="high" />
<!--
Generate the proto file BEFORE Grpc.Tools processes protos and BEFORE CoreCompile.
This runs AFTER ResolveAssemblyReferences so we have access to @(ReferencePath).
<WriteProtoFileTask
Key timing:
- AfterTargets="ResolveAssemblyReferences" ensures we have all references resolved
- BeforeTargets="_gRPC_GetProtoc;CoreCompile" ensures proto is generated before:
1. Grpc.Tools compiles the proto into C# (_gRPC_GetProtoc is Grpc.Tools' entry point)
2. CoreCompile compiles the project
-->
<Target Name="SvrntyGenerateProtoFile"
BeforeTargets="_gRPC_GetProtoc;CoreCompile"
AfterTargets="ResolveAssemblyReferences"
DependsOnTargets="EnsureProtosDirectory"
Condition="'$(GenerateProtoFile)' == 'true' AND '$(_GeneratorsAssemblyPath)' != ''">
<Message Text="Svrnty.CQRS.Grpc: Generating proto file from $(MSBuildProjectName)..." Importance="high" />
<Message Text="Svrnty.CQRS.Grpc: Source files count: @(Compile->Count())" Importance="normal" />
<Message Text="Svrnty.CQRS.Grpc: References count: @(ReferencePath->Count())" Importance="normal" />
<GenerateProtoFileTask
ProjectDirectory="$(MSBuildProjectDirectory)"
IntermediateOutputPath="$(IntermediateOutputPath)"
OutputDirectory="$(ProtoOutputDirectory)"
ProtoFileName="$(GeneratedProtoFileName)" />
ProtoFileName="$(GeneratedProtoFileName)"
SourceFiles="@(Compile)"
References="@(ReferencePath)"
RootNamespace="$(RootNamespace)"
AssemblyName="$(AssemblyName)" />
</Target>
</Project>
+54
View File
@@ -33,6 +33,7 @@ public static class CqrsBuilderExtensions
{
Console.WriteLine("Warning: AddGrpcFromConfiguration not found. gRPC services were not registered.");
Console.WriteLine("Make sure your project has source generators enabled and references Svrnty.CQRS.Grpc.Generators.");
DiagnoseGeneratedCode();
}
// Register mapping callback for automatic endpoint mapping
@@ -49,6 +50,59 @@ public static class CqrsBuilderExtensions
return builder;
}
private static void DiagnoseGeneratedCode()
{
var entryAsm = Assembly.GetEntryAssembly();
if (entryAsm == null)
{
Console.WriteLine("Diagnostic: Entry assembly is null");
return;
}
Console.WriteLine($"Diagnostic: Entry assembly = {entryAsm.GetName().Name}");
try
{
var allTypes = entryAsm.GetTypes();
Console.WriteLine($"Diagnostic: Total types in entry assembly = {allTypes.Length}");
var grpcTypes = allTypes
.Where(t => t.FullName?.Contains("Grpc") == true)
.ToList();
if (grpcTypes.Any())
{
Console.WriteLine("Diagnostic: Found Grpc-related types:");
foreach (var t in grpcTypes)
{
Console.WriteLine($" - {t.FullName} (IsClass={t.IsClass}, IsSealed={t.IsSealed}, IsPublic={t.IsPublic})");
// Check for our target method
var method = t.GetMethod("AddGrpcFromConfiguration", BindingFlags.Static | BindingFlags.Public);
if (method != null)
Console.WriteLine($" -> HAS AddGrpcFromConfiguration method!");
}
}
else
{
Console.WriteLine("Diagnostic: No Grpc-related types found. Source generator did NOT run.");
}
}
catch (ReflectionTypeLoadException ex)
{
Console.WriteLine($"Diagnostic: ReflectionTypeLoadException - {ex.Message}");
foreach (var le in ex.LoaderExceptions)
{
if (le != null)
Console.WriteLine($" LoaderException: {le.Message}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Diagnostic: Exception - {ex.GetType().Name}: {ex.Message}");
}
}
private static MethodInfo? FindExtensionMethod(string methodName, Type parameterType)
{
// Search through all loaded assemblies for the extension method
@@ -1,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)
@@ -83,8 +146,7 @@ public static class EndpointRouteBuilderExtensions
.Produces(200, queryMeta.QueryResultType)
.Produces(400)
.Produces(401)
.Produces(403)
.WithAllowAnonymousIfAttributePresent(queryMeta.QueryType);
.Produces(403);
}
private static void MapQueryGet(
@@ -129,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)
@@ -147,8 +213,7 @@ public static class EndpointRouteBuilderExtensions
.Produces(200, queryMeta.QueryResultType)
.Produces(400)
.Produces(401)
.Produces(403)
.WithAllowAnonymousIfAttributePresent(queryMeta.QueryType);
.Produces(403);
}
public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command")
@@ -200,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)
@@ -215,8 +284,7 @@ public static class EndpointRouteBuilderExtensions
.Produces(200)
.Produces(400)
.Produces(401)
.Produces(403)
.WithAllowAnonymousIfAttributePresent(commandMeta.CommandType);
.Produces(403);
}
private static void MapCommandWithResult(
@@ -243,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)
@@ -263,17 +335,6 @@ public static class EndpointRouteBuilderExtensions
.Produces(200, commandMeta.CommandResultType)
.Produces(400)
.Produces(401)
.Produces(403)
.WithAllowAnonymousIfAttributePresent(commandMeta.CommandType);
}
private static RouteHandlerBuilder WithAllowAnonymousIfAttributePresent(this RouteHandlerBuilder builder, Type type)
{
var allowAnonymousAttribute = type.GetCustomAttribute<Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute>();
if (allowAnonymousAttribute != null)
{
builder.AllowAnonymous();
}
return builder;
.Produces(403);
}
}
+56
View File
@@ -43,6 +43,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.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.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
Debug|Any CPU = Debug|Any CPU
@@ -257,6 +265,54 @@ 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
{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
+3 -2
View File
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Discovery;
@@ -43,7 +44,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand>
{
@@ -54,7 +55,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler with result to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, TResult, TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, TResult, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand, TResult>
{
+1 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
+1 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
+4
View File
@@ -26,6 +26,10 @@
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
</ItemGroup>
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Configuration;
namespace Svrnty.CQRS.MinimalApi;
namespace Svrnty.CQRS;
public static class WebApplicationExtensions
{
+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) -->