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